Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 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 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
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();
});
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,15 @@ const SessionSchema = z
* restart.
*/
resumeMaxSessions: z.number().int().min(1).default(200),
/**
* Resume sessions without loading their history: a resumed session holds
* its metadata only, and loads its scrollback and canonical history the
* first time something uses them (attach, a prompt, a fork, …). Most
* resumed sessions are never opened before the next restart, and their
* history was most of the daemon's memory. Off = load every session's at
* start, as before.
*/
lazyLoad: z.boolean().default(true),
/**
* Per-turn workspace snapshots (#354): at the start of every turn the
* session's working directory (minus .gitignore'd files, secrets and
Expand Down Expand Up @@ -556,6 +565,7 @@ const SessionSchema = z
mcpToolTimeoutMs: 120_000,
attachTailBytes: 512 * 1024,
resumeMaxSessions: 200,
lazyLoad: true,
seedBudgetChars: 0,
seedCharsPerToken: 2,
seedWindowFraction: 0.7,
Expand Down Expand Up @@ -1506,6 +1516,8 @@ export interface CodeoidConfig {
idleReleaseMinutes?: number;
/** Sessions restored from disk at daemon start, newest-first by last activity. Resume is also time-boxed, so this is the coarse guard; the remainder stays on disk. Defaults to 200 when omitted. */
resumeMaxSessions?: number;
/** Load a resumed session's history the first time it is used, not at start. Defaults to true when omitted. */
lazyLoad?: boolean;
/** Per-turn workspace snapshots (#354). Enabled unless `enabled: false`. */
checkpoints?: {
enabled?: boolean;
Expand Down Expand Up @@ -1817,6 +1829,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [
{ env: "CODEOID_TURN_STALL_TIMEOUT_MS", path: "session.turnStallTimeoutMs", kind: "int" },
{ env: "CODEOID_MCP_TOOL_TIMEOUT_MS", path: "session.mcpToolTimeoutMs", kind: "int" },
{ env: "CODEOID_RESUME_MAX_SESSIONS", path: "session.resumeMaxSessions", kind: "int" },
{ env: "CODEOID_SESSION_LAZY_LOAD", path: "session.lazyLoad", kind: "boolean" },
{ env: "CODEOID_SEED_BUDGET_CHARS", path: "session.seedBudgetChars", kind: "int" },
{ env: "CODEOID_SEED_CHARS_PER_TOKEN", path: "session.seedCharsPerToken", kind: "float" },
{ env: "CODEOID_SEED_WINDOW_FRACTION", path: "session.seedWindowFraction", kind: "float" },
Expand Down
3 changes: 3 additions & 0 deletions src/daemon/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ export interface DispatcherHost {
workerStatus(sessionId: string): SessionStatus | null;
/** Compressed completion digest for a finished worker — never raw transcript. */
buildWorkerDigest(task: DispatchTaskRow): string;
/** Load what `buildWorkerDigest` reads (a resumed worker's history), before it runs. */
prepareWorkerDigest?(task: DispatchTaskRow): Promise<void>;
/** Tear down a finished worker session (best-effort). */
destroyWorker(sessionId: string, reason: string): Promise<void>;
/**
Expand Down Expand Up @@ -1075,6 +1077,7 @@ export class Dispatcher {

try {
// Digest BEFORE teardown — it reads the live session + memory.
await this.#host.prepareWorkerDigest?.(task);
const digest = this.#host.buildWorkerDigest(task);
if (status === "idle") {
// The read above and this write are not one transaction; the store's
Expand Down
Loading