Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
885eeef
perf: drain checks interrupted sessions again within milliseconds, no…
saucam Nov 6, 2026
24800e2
perf: the collaboration tests await the transcript store's writes ins…
saucam Nov 6, 2026
ba6abdc
perf: incremental typecheck, so a re-check after an edit takes 2.7-4.…
saucam Nov 6, 2026
9ad10d0
fix: round-1 audit: a stopped turn's last reply is shown whole, not t…
saucam Nov 6, 2026
97b528c
fix: round-1 audit: drain waits for interrupted turns to record their…
saucam Nov 6, 2026
81476b6
fix: round-1 audit: the collaboration tests' teardown flushes every t…
saucam Nov 6, 2026
1707bd2
fix: incremental typecheck keeps its build info in the repo's .cache,…
saucam Nov 6, 2026
6f36939
fix: round-2 audit: a pi turn interrupted before pi is up ends there,…
saucam Nov 7, 2026
d354f8f
fix: round-2 audit: no turn starts once drain begins, and drain waits…
saucam Nov 7, 2026
5eda72e
fix: round-2 audit: a routine run in flight at shutdown records its f…
saucam Nov 7, 2026
5de2d9d
fix: round-3 audit: a routine run at shutdown waits as long as drain …
saucam Nov 7, 2026
6caacaa
fix: round-3 audit: a shutdown waits for what a turn's end started (t…
saucam Nov 7, 2026
9a0fb97
fix: round-3 audit: a dispatch task whose start a stopping daemon ref…
saucam Nov 7, 2026
4622edb
fix: round-3 audit: a send refused at shutdown doesn't mark its clien…
saucam Nov 7, 2026
b92ab0f
perf: a resumed session loads its history the first time it is used (…
saucam Nov 9, 2026
c88e81f
Merge remote-tracking branch 'gh/main' into perf/optimization-session
Nov 9, 2026
56bf3da
test: the owner's added directory is checked once the session's sandb…
Nov 9, 2026
70e2ca6
test: Docker access is checked once the session's sandbox info is re-…
Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ package-lock.json
# end-to-end suite output (#429)
test-results/
playwright-report/

# tsc incremental build info (bun run typecheck)
.cache/
22 changes: 20 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Sessions live in the daemon. Clients are stateless.
- **Scrollback buffer**: circular ring (5000 entries / 20MB) replayed on attach
- **Transcript persistence**: JSONL per session, user prompts written before API call
- **Session resume**: on daemon restart, meta files scanned, sessions rebuilt, scrollback restored
- **Lazy loading** (`session.lazyLoad`, default on): a resumed session holds its metadata only (`Session.prepareResume`: the log's next seq and a mark of each of its files, its newest canonical turn, the turn index) and loads its scrollback and canonical history the first time it is used, exactly as a resume loaded them: the load reads the files as they stood at resume (`LoadTranscriptOptions.asOf`: by identity and size, so whatever was appended since is left out, whatever its seqs; an old log's seqs can go back down), and `#seq` then continues past the highest seq loaded. A transcript that can't be read at start fails the session's resume (logged, left on disk), as before. A transcript, canonical log or turn index that can't be read at a later load fails that use (the session left unloaded, read again on the next). A canonical log that can't be read when the history loads at resume (`lazyLoad: false`, or a session loaded then) leaves the session running without it, as before, but never written over (`Session#historyUnread`): going back or forking from it is refused (`RewindError` `history_unavailable`), a whole rewrite of it is never written (a rotation, which empties it anyway, excepted), turns are still appended, and each use reads it again (`#rereadHistory`, not mid-turn). Entry points await `Session.ensureHydrated()` (one shared load): `SessionManager.handle` for any message naming a session (less `NO_HISTORY_MESSAGES`, less a caller without the scopes its handler needs, `HISTORY_SCOPES`, and less what a loop worker's guard refuses, `#refusedBeforeHistory`; while one waits, every later message naming that session waits behind it, `#historyGate`, so they run in arrival order; a prompt waiting is a preparing send, `Session.holdSend`, which the drain stops and waits out; a client gone meanwhile is detached after), `send`, and the async history readers; `#scrollback` / `#accumulator` are getters that load synchronously, logged, if anything reaches them first (`Session.syncHydrations`). A notice while unloaded is appended at the next seq and kept for the load (`#buffer`); a re-seed owed by a teardown waits for it (`#reseedAfterTeardown`). Sessions from before the canonical log or turn index, or with a fresh backing, load at resume.

Detach from terminal -> attach from Telegram -> same session, same context, scrollback replayed.

Expand All @@ -161,8 +162,25 @@ retries the turn on that model instead of failing it. There is no daemon-side
retry manager.

**Graceful shutdown**: cleanup registry runs in LIFO order on SIGTERM/SIGINT/SIGHUP.
Sessions drained (in-flight work interrupted), store flushed, WebSockets closed,
frontends stopped. 30-second grace period before forced exit.
Sessions drained: from its start no turn starts (a send is refused, a turn
the backend starts on its own is interrupted as it starts, no wake, recovery
or seed retry runs: `Session.beginDrain`); in-flight work is interrupted once
per turn, and every turn not yet recorded (one its owner, a routine or a loop
stopped just before included) is waited for until its end, spend included, is
recorded — a backend acknowledges an interrupt before the turn's result
arrives — but at most its backend's settle bound (`interruptSettleMs`,
`DRAIN_SETTLE_MS` 2 s; opencode 3 s), all sessions at once, then named in the
log and left; then, at once and at most `TURN_END_SETTLE_MS` (5 s), what
each turn's end started and doesn't await (`Session.turnEndWork`: the #448
git/settings-commit check's audit row and warning line, the end snapshot,
`after_turn` hooks) and the routine runs' final rows, spend included
(`RoutineService.settled`: a run waits as long as drain waits for its turn,
then records it, cost unknown if drain gave up on it); then the transcript
writes are flushed; polled at 10 ms backing off to 50 ms, bounded by drain's
10 s, never a fixed wait. Drain is one-way: the manager refuses sends after it.
A dispatch task whose start a stopping daemon refused goes back to the queue,
no attempt spent, and a stopped dispatcher claims nothing more. Store flushed,
WebSockets closed, frontends stopped. 30-second grace period before forced exit.

**One daemon per data dir**: `codeoid start` locks each dir the daemon owns
(`daemonOwnedDirs`: the database's, transcripts, opencode's database and data
Expand Down
3 changes: 3 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ CODEOID_RESUME_MAX_SESSIONS=200 # sessions restored from disk at startu
# Resume is also time-boxed, so raising this costs
# startup time; the remainder stays on disk and loads
# on a later restart.
CODEOID_SESSION_LAZY_LOAD=true # a resumed session loads its scrollback and history the
# first time it is used (opened, sent to, forked, …),
# not at start; false loads every session's at start
CODEOID_IDLE_RELEASE_MINUTES=30 # minutes idle before a session's backend process and
# everything it started (MCP servers, commands still
# running) are stopped; the next message resumes it
Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ Thinking content and sub-agent tool calls surface as separate italic messages. S
| Pattern | What it does |
|---|---|
| **Retry with fallback model** | Exponential backoff with jitter, 429/529/5xx categorization, falls back to cheaper model after 3 capacity failures. |
| **Graceful shutdown** | LIFO cleanup registry with 30s grace. SIGTERM / SIGINT / SIGHUP handlers drain sessions before closing the store. |
| **Graceful shutdown** | LIFO cleanup registry with 30s grace. SIGTERM / SIGINT / SIGHUP handlers drain sessions before closing the store: no new turn starts once the drain begins, and every turn still ending (interrupted by the drain, or stopped just before it) has its late result (its last reply, its cost), what its end starts (the sandbox's git and settings-commit check, the end snapshot, `after_turn` hooks; at most 5 s), a routine run's final row with its spend, and its transcript writes recorded first. A backend that never ends its turn holds the shutdown only its settle bound (2 s; opencode 3 s), every session at once, and is named in the log. |
| **Session resume** | JSONL transcripts per session; user prompts written BEFORE API calls. On daemon restart, sessions rebuild from transcript; `#hasQueried` flag ensures Claude Code's own session store is reused via `resume` instead of re-creating. |
| **Session limits** | **None by default** — running many sessions in parallel is the point. `rateLimit.maxSessionsPerUser` / `rateLimit.maxCreationsPerHour` default to `0` (unlimited); set either for a shared multi-user daemon. Automated spawning is bounded at its source by `dispatch.maxConcurrentWorkers`. |
| **Tool approval correlation** | Each approval request has a unique `approvalId`; first response wins; multiple concurrent approvals supported. |
Expand Down
2 changes: 1 addition & 1 deletion docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Hooks are declared in `~/.codeoid/config.json`. No plugin loading, no JS API —
| `tool_call` | Before a tool executes, **before** the approval gate | block; mutate input |
| `tool_result` | After a tool completes | patch the recorded output |
| `before_turn` | A fresh turn is starting (not mid-turn injections) | append to the system prompt |
| `after_turn` | Turn finished, carries the normalized result | observe |
| `after_turn` | Turn finished, carries the normalized result. A shutdown waits for it (at most 5 s, with the rest of the turn's end) | observe |
| `session_start` | Session created (`source`: `"new"` \| `"resume"`) | observe |
| `session_end` | Session destroyed | observe |
| `provider_switched` | Backend switched mid-session (`from`, `to`, `seeded`) | observe |
Expand Down
2 changes: 1 addition & 1 deletion docs/routines.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ So a routine that asks "is anything wrong?" stays quiet on the days when nothing
| `over_budget` | It crossed `--max-cost` and was stopped. Counts toward the failure pause. |
| `skipped` | It didn't run: the last run was still going (`--overlap skip`), a run was already queued behind it (`--overlap queue`), a persistent routine's session was busy, waiting on you, or had your background work going, the daily spend cap was reached, the routine was paused before it started, or the fire was missed during downtime. |
| `cancelled` | You cancelled it (`codeoid routine cancel <runId>`). |
| `interrupted` | The daemon stopped while it was in flight. |
| `interrupted` | The daemon stopped while it was in flight. Its spend up to the stop is recorded before the daemon exits (unless its backend didn't end the turn within its settle bound, 2 s, opencode 3 s: then the run is recorded with its cost unknown). |

A `needs_attention` run keeps tracking its session after it ends.
When you approve and the agent finishes, the run's cost and summary are updated, so the spend counts toward the daily cap and the history shows what was actually done.
Expand Down
3 changes: 2 additions & 1 deletion docs/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -1352,7 +1352,8 @@ session's first turn) and that changes one of
`.qwen/settings.json`, `.codex/`, `.pi/` (at any depth: a session may start
in a subdirectory; against each parent, a root commit against nothing) is
named in a warning line, with its files (event `sandbox.settings_committed`,
audited `session.settings_committed`). **Review those commits
audited `session.settings_committed`; a turn interrupted by a shutdown
gets its look before the daemon exits, at most 5 s). **Review those commits
(`git show <commit>`) before a session runs where they are checked out.**
The look runs the daemon's hardened git (nothing the repository's config
names runs; only names are compared) and reads at most 500 new commits per
Expand Down
147 changes: 147 additions & 0 deletions e2e/specs/lazy-load.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Page } from "@playwright/test";
import { type Codeoid, createSessionInUi, expect, focusSession, messages, openUi, send, test, waitIdle } from "../harness";

// A resumed session holds its metadata only, and loads its scrollback and
// history the first time it is opened (`session.lazyLoad`, on by default).

/** Long enough that a few turns overflow the attach tail set below. */
const FILLER = "lorem ipsum dolor sit amet ".repeat(5).trim();
const TURNS = 8;

/** The sidebar row's text, less its relative "created …" line. */
async function rowText(page: Page, name: string): Promise<string> {
const row = page.getByRole("listitem").filter({ has: page.getByText(name, { exact: true }) });
return (await row.innerText()).replace(/created .*$/m, "").trim();
}

/** Sessions whose history the daemon has loaded since `from` in its log. */
function loadsSince(codeoid: Codeoid, from: number): string[] {
return codeoid.log
.slice(from)
.split("\n")
.filter((l) => l.includes("history loaded:"));
}

/** `codeoid ls`, one block per session, sorted (a resume lists newest first), less what attaching changes. */
async function ls(codeoid: Codeoid): Promise<string[]> {
return (await codeoid.cliOk("ls"))
.replace(/clients: \d+/g, "clients: _")
.split(/\n\s*\n/)
.map((b) => b.trim())
.filter((b) => b && !b.startsWith("Sessions:"))
.sort();
}

/** Scroll the transcript (virtualized: only what is in view is rendered). */
async function scrollTranscript(page: Page, to: "top" | "bottom"): Promise<void> {
await page.getByRole("main").evaluate((el, end) => {
type Box = { scrollHeight: number; clientHeight: number; scrollTop: number };
for (const n of [el, ...el.querySelectorAll("*")] as unknown as Box[]) {
if (n.scrollHeight > n.clientHeight) n.scrollTop = end === "top" ? 0 : n.scrollHeight;
}
}, to);
}

/** Page back to the session's first message, then show it. */
async function pageToTop(page: Page): Promise<void> {
const sentinel = page.getByTestId("older-history-sentinel");
for (let i = 0; i < 40 && (await sentinel.count()) > 0; i++) {
const more = sentinel.getByRole("button");
// Scrolling it into view may load the page first: the button goes away.
if ((await more.count()) > 0) await more.click({ timeout: 1_000 }).catch(() => {});
await page.waitForTimeout(100);
}
await expect(sentinel).toHaveCount(0);
await scrollTranscript(page, "top");
}

test("resumed sessions list before they load, and load their whole history when opened, across restarts", async ({ page, browser, codeoid }) => {
test.setTimeout(180_000);
// Memory on (embeddings off: nothing to download), so each turn's usage is
// recorded and restored with the session, as on a default install; and a
// small attach tail, so opening the session pages its history.
const configPath = join(codeoid.configDir, "codeoid", "config.json");
const config = JSON.parse(readFileSync(configPath, "utf8")) as { session: Record<string, unknown> };
writeFileSync(
configPath,
JSON.stringify({ ...config, memory: { enabled: true, embeddings: { enabled: false } }, session: { ...config.session, attachTailBytes: 2048 } }),
);
await codeoid.restart();
await openUi(page, codeoid);
await createSessionInUi(page, "long", codeoid.workdir);
for (let i = 1; i <= TURNS; i++) {
await send(page, `turn ${i} ${FILLER}`);
await expect(messages(page).filter({ hasText: `scripted: turn ${i} ` })).toHaveCount(1);
await waitIdle(page);
}
await codeoid.cliOk("new", "other", codeoid.workdir);
// The sidebar lists another client's new session on its next list.
await page.reload();
await expect(page.getByText("live", { exact: true })).toBeVisible();
await focusSession(page, "other");
await send(page, "hello other");
await expect(messages(page).filter({ hasText: "scripted: hello other" })).toHaveCount(1);
await waitIdle(page);
const rows = { long: await rowText(page, "long"), other: await rowText(page, "other") };
expect(rows.long).toContain(`${TURNS}t`);
const listed = await ls(codeoid);
const idOf = (name: string) => /id: ([0-9a-f-]{36})/.exec(listed.find((b) => b.startsWith(`${name} `))!)![1]!;
const ids: Record<string, string> = { long: idOf("long"), other: idOf("other") };
const longId = ids.long!;
const loadsOf = (name: string, from: number) => loadsSince(codeoid, from).filter((l) => l.includes(ids[name]!.slice(0, 8)));


// This page re-attaches its focused session when it reconnects: park it.
await page.goto("about:blank");

for (const round of [1, 2]) {
const from = codeoid.log.length;
await codeoid.restart();

// A fresh browser: nothing focused, so nothing is opened.
const ctx = await browser.newContext();
const fresh = await ctx.newPage();
const sent: string[] = [];
fresh.on("websocket", (ws) => ws.on("framesent", (f) => sent.push(String(f.payload))));
await openUi(fresh, codeoid);
await expect(fresh.getByRole("listitem").filter({ has: fresh.getByText("long", { exact: true }) })).toBeVisible();
// The list says what it said before the restart. The UI opens the newest
// session by itself; the other one, listed but not opened, isn't loaded.
expect(await rowText(fresh, "long")).toBe(rows.long);
expect(await rowText(fresh, "other")).toBe(rows.other);
expect(await ls(codeoid)).toEqual(listed);
const opened = (await fresh.getByRole("heading", { level: 2 }).innerText()).trim();
const notOpened = opened === "long" ? "other" : "long";
expect(loadsOf(notOpened, from)).toEqual([]);

// Opening one loads it (and only it): the newest turns at once, the rest paged.
await focusSession(fresh, "long");
await expect(messages(fresh).filter({ hasText: `scripted: turn ${TURNS} ` })).toHaveCount(1);
await pageToTop(fresh);
await expect(messages(fresh).filter({ hasText: "scripted: turn 1 " })).toBeVisible();
// The first turns came by paging: the attach sent only the newest.
expect(sent.some((f) => f.includes('"scrollback.page"') && f.includes(longId))).toBe(true);
// Loaded once, when opened.
expect(loadsOf("long", from)).toHaveLength(1);

// It takes a new prompt and answers it.
await scrollTranscript(fresh, "bottom");
await send(fresh, `after restart ${round}`);
await scrollTranscript(fresh, "bottom");
await expect(messages(fresh).filter({ hasText: `scripted: after restart ${round}` })).toHaveCount(1);
await waitIdle(fresh);
rows.long = await rowText(fresh, "long");
expect(rows.long).toContain(`${TURNS + round}t`);
await ctx.close();
}

// The first browser signs in again and finds the whole history.
await openUi(page, codeoid);
await focusSession(page, "long");
await expect(messages(page).filter({ hasText: "scripted: after restart 2" })).toHaveCount(1);
await expect(messages(page).filter({ hasText: "scripted: after restart 1" })).toHaveCount(1);
await pageToTop(page);
await expect(messages(page).filter({ hasText: "scripted: turn 1 " })).toBeVisible();
});
1 change: 1 addition & 0 deletions e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "../.cache/tsc/e2e.tsbuildinfo",
"rootDir": "..",
"declaration": false,
"sourceMap": false
Expand Down
1 change: 1 addition & 0 deletions packages/core/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "../../.cache/tsc/core.tsbuildinfo",
"rootDir": "src",
"noEmit": true
},
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "../../.cache/tsc/protocol.tsbuildinfo",
"rootDir": "src",
"noEmit": true
},
Expand Down
Loading
Loading