diff --git a/apps/desktop/e2e/event-journal.spec.ts b/apps/desktop/e2e/event-journal.spec.ts index 48697ced..76d5ea91 100644 --- a/apps/desktop/e2e/event-journal.spec.ts +++ b/apps/desktop/e2e/event-journal.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { EventJournal, replayItems } from "../src/main/engine/event-journal"; import { createThread, launch, seedHome } from "./support"; @@ -52,3 +53,47 @@ test("without the flag the app writes no journal", async () => { fs.rmSync(repo, { recursive: true, force: true }); } }); + +// CF-6 (#279): the app's journal keeps its identity across a clean relaunch, so paired clients still +// resume, and takes a new one after the app is killed, so a cursor issued before the crash reloads. +test("the app's journal keeps its identity across a clean relaunch and takes a new one after a kill", async () => { + const { home, repo } = seedHome(); + const previous = process.env.MODEX_EVENT_JOURNAL; + process.env.MODEX_EVENT_JOURNAL = "1"; + const file = path.join(home, "app", "events.sqlite"); + // Read-only, so this test never marks the journal open or closed itself. + const meta = () => { + const db = new DatabaseSync(file, { readOnly: true }); + try { + const value = (key: string) => (db.prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined)?.value; + return { id: value("id"), open: value("open") }; + } finally { db.close(); } + }; + try { + let run = await launch(home); + try { await createThread(run.page, "journal this session"); } finally { await run.app.close(); } + const first = meta(); + expect(first.id).toBeTruthy(); + expect(first.open).toBe("0"); + + run = await launch(home); + await run.app.close(); + expect(meta()).toEqual(first); + + run = await launch(home); + const killed = new Promise((resolve) => run.app.process().once("exit", resolve)); + run.app.process().kill("SIGKILL"); + await killed; + expect(meta()).toEqual({ id: first.id, open: "1" }); + + run = await launch(home); + try { + await expect.poll(() => meta().id).not.toBe(first.id); + } finally { await run.app.close(); } + expect(meta().open).toBe("0"); + } finally { + if (previous === undefined) delete process.env.MODEX_EVENT_JOURNAL; else process.env.MODEX_EVENT_JOURNAL = previous; + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/engine/companion.ts b/apps/desktop/src/main/engine/companion.ts index 72c4ed7a..2ccee3c7 100644 --- a/apps/desktop/src/main/engine/companion.ts +++ b/apps/desktop/src/main/engine/companion.ts @@ -400,6 +400,13 @@ export function mobileItem(item: ThreadItem): Record { // Phones cannot answer questions yet: a notice every client can show, with the questions and never the answers. case "question": return { id: item.id, kind: "notice", level: "info", at: item.at, text: `The agent asked: ${item.questions.map((q) => q.text).join(" · ")}\n${item.answers ? (item.delivery === "uncertain" ? "Answered on your Mac; Modex can't tell whether the agent received it." : "Answered on your Mac.") : item.withdrawn ? "Not answered." : "Answer it in Modex on your Mac."}` }; + // A kind this build does not know, such as one a newer Modex saved before a downgrade: an inert + // notice, never `undefined`, which goes out as null and makes the phone reject the whole snapshot. + // Its own fields are not forwarded, since they may hold anything. + default: { + const unknown = item as { id: string; at: string }; + return { id: unknown.id, kind: "notice", level: "info", text: "An item from a newer version of Modex. Update Modex on your Mac to see it.", at: unknown.at }; + } } } diff --git a/apps/desktop/src/main/engine/desktop-client.ts b/apps/desktop/src/main/engine/desktop-client.ts index c23c9701..58323ae7 100644 --- a/apps/desktop/src/main/engine/desktop-client.ts +++ b/apps/desktop/src/main/engine/desktop-client.ts @@ -273,7 +273,9 @@ export class DesktopClient extends EventEmitter { res.on("data", (chunk: string) => { if (generation !== this.generation || ended) return; buffer += chunk; - if (buffer.length > 1024 * 1024) { res.destroy(); return; } + // A frame this large is dropped. Resuming would replay it into the same limit forever, so + // forget the cursor: the reconnect reloads state, which carries the item another way. + if (buffer.length > 1024 * 1024) { this.journal = undefined; res.destroy(); return; } let end: number; while ((end = buffer.indexOf("\n\n")) >= 0) { const frame = buffer.slice(0, end); diff --git a/apps/desktop/src/main/engine/event-journal.ts b/apps/desktop/src/main/engine/event-journal.ts index 02e9c126..643c74fd 100644 --- a/apps/desktop/src/main/engine/event-journal.ts +++ b/apps/desktop/src/main/engine/event-journal.ts @@ -23,7 +23,10 @@ export const RESUME_LIMIT = 10_000; export interface EventJournalOptions { flushMs?: number; retention?: JournalRetention; - /** A timed flush failed. The journal stays usable for the caller to close. */ + /** + * A write failed, on a timer or while a client read. Rows were lost, so the journal records + * nothing more and resumes no cursor; it stays usable for the caller to close. Called once. + */ onError?: (error: unknown) => void; } @@ -52,7 +55,11 @@ export class EventJournal { private listeners = new Set<(entries: JournalEntry[]) => void>(); /** The highest cursor retention has deleted; 0 before any pruning. */ private prunedThrough: number; + /** The highest cursor written, so a reload can still name one after a failed write. */ + private written: number; private closed = false; + /** A write failed and its rows are gone: nothing more is recorded and no cursor resumes. */ + private failed = false; constructor(file: string, options: EventJournalOptions = {}) { this.flushMs = options.flushMs ?? 250; @@ -71,9 +78,15 @@ export class EventJournal { ); CREATE INDEX IF NOT EXISTS events_thread ON events (thread_id, seq); CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`); - this.db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('id', ?)").run(crypto.randomUUID()); + // `open` stays "1" until a clean close. A journal still marked open was left by a process that + // died, or one whose write failed, so its last batch may be missing while the state files have + // it: a new identity makes every cursor issued before then reload instead of resuming past it. + if (this.meta("open") === "1") this.setMeta("id", crypto.randomUUID()); + else this.db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('id', ?)").run(crypto.randomUUID()); + this.setMeta("open", "1"); this.id = this.meta("id")!; this.prunedThrough = Number(this.meta("pruned_through") ?? 0); + this.written = Number((this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events").get() as { seq: number }).seq); this.insert = this.db.prepare("INSERT INTO events (at, thread_id, type, event) VALUES (?, ?, ?, ?)"); } @@ -81,8 +94,21 @@ export class EventJournal { return (this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined)?.value; } + private setMeta(key: string, value: string): void { + this.db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(key, value); + } + + /** Rows were lost: record nothing more, resume no cursor, and tell the caller once. */ + private fail(error: unknown): void { + if (this.failed) return; + this.failed = true; + this.pending = []; + this.slots = new Map(); + this.onError?.(error); + } + record(event: ThreadEvent): void { - if (this.closed) return; + if (this.closed || this.failed) return; const key = mergeKey(event); const slot = key === undefined ? undefined : this.slots.get(key); if (slot !== undefined) this.pending[slot] = merge(this.pending[slot]!, event); @@ -94,7 +120,7 @@ export class EventJournal { } this.timer ??= setTimeout(() => { this.timer = undefined; - try { this.flush(); } catch (error) { this.onError?.(error); } + try { this.flush(); } catch { /* flush reported it */ } }, this.flushMs); this.timer.unref?.(); } @@ -108,14 +134,20 @@ export class EventJournal { this.slots = new Map(); const at = new Date().toISOString(); const entries: JournalEntry[] = []; - this.db.exec("BEGIN"); try { - for (const event of batch) entries.push({ seq: Number(this.insert.run(at, event.threadId, event.type, JSON.stringify(event)).lastInsertRowid), at, event }); - this.db.exec("COMMIT"); + this.db.exec("BEGIN"); + try { + for (const event of batch) entries.push({ seq: Number(this.insert.run(at, event.threadId, event.type, JSON.stringify(event)).lastInsertRowid), at, event }); + this.db.exec("COMMIT"); + } catch (error) { + try { this.db.exec("ROLLBACK"); } catch { /* the write already failed */ } + throw error; + } } catch (error) { - this.db.exec("ROLLBACK"); + this.fail(error); throw error; } + this.written = entries.at(-1)!.seq; for (const listener of this.listeners) { try { listener(entries); } catch (error) { console.error("[modex] event journal subscriber failed:", error); } @@ -152,9 +184,11 @@ export class EventJournal { * usable cursor, the answer is "reload state" with the cursor to continue from after that reload. */ resume(id: string | undefined, cursor: number | undefined, opts: { threadId?: string } = {}): JournalResume { - const latest = this.latest(); + let latest: number; + // Reading flushes. A write that fails here is reported, and the client reloads. + try { latest = this.latest(); } catch { return { id: this.id, latest: Math.max(this.prunedThrough, this.written) }; } const reload = { id: this.id, latest }; - if (id !== this.id || cursor === undefined || !Number.isSafeInteger(cursor) || cursor < this.prunedThrough || cursor > latest) return reload; + if (this.failed || id !== this.id || cursor === undefined || !Number.isSafeInteger(cursor) || cursor < this.prunedThrough || cursor > latest) return reload; const count = Number(((opts.threadId ? this.db.prepare("SELECT COUNT(*) AS n FROM events WHERE seq > ? AND thread_id = ?").get(cursor, opts.threadId) : this.db.prepare("SELECT COUNT(*) AS n FROM events WHERE seq > ?").get(cursor)) as { n: number }).n); @@ -191,6 +225,10 @@ export class EventJournal { try { this.flush(); } finally { this.closed = true; this.listeners.clear(); + // Only a journal that lost nothing closes clean; otherwise the next open takes a new identity. + if (!this.failed) { + try { this.setMeta("open", "0"); } catch { /* left open: the next open is treated as a crash */ } + } this.db.close(); } } diff --git a/apps/desktop/test/conversation-migration.test.ts b/apps/desktop/test/conversation-migration.test.ts index 817d38d9..36456cbe 100644 --- a/apps/desktop/test/conversation-migration.test.ts +++ b/apps/desktop/test/conversation-migration.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { EventJournal, replayItems } from "../src/main/engine/event-journal.js"; import { Store } from "../src/main/engine/store.js"; import { ThreadRunner } from "../src/main/engine/runner.js"; @@ -136,3 +137,109 @@ test("loading a thread with nothing to settle sends nothing and leaves its file await runner.dispose(); } finally { old.journal.close(); } }); + +test("an item kind this Mac does not know reaches the phone as a readable, inert notice, never as null", () => { + // After a downgrade, the state files can hold items a newer Modex wrote. One null item makes the + // phone reject its whole snapshot, so every item must map to something it can show. + const future = { id: "w1", kind: "widget", at: "2026-10-18T00:00:00.000Z", body: "whatever a newer Modex put here" } as unknown as ThreadItem; + const phone = mobileItem(future); + assert.ok(phone, "never null or undefined"); + assert.equal(phone.id, "w1"); + assert.equal(phone.kind, "notice", "a kind every phone build renders, with nothing to act on"); + assert.equal(typeof phone.text, "string"); + assert.ok(!JSON.stringify(phone).includes("whatever a newer Modex"), "an unknown item's fields are not forwarded: they may hold anything"); + assert.ok(!JSON.stringify([future, future].map(mobileItem)).includes("null"), "a snapshot with unknown items has no null entries"); +}); + +/** Every file under `home` except the journal's own, by its bytes. */ +function besideJournal(home: string): Map { + const files = new Map(); + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) walk(file); + else if (!entry.name.startsWith("events.sqlite")) files.set(path.relative(home, file), fs.readFileSync(file).toString("base64")); + } + }; + walk(home); + return files; +} + +// v0.1.0's journal schema, verbatim (`git show v0.1.0:apps/desktop/src/main/engine/event-journal.ts`): +// an events table, no meta table, so no identity and no cursors. +const V010_SCHEMA = `PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + CREATE TABLE IF NOT EXISTS events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + thread_id TEXT NOT NULL, + type TEXT NOT NULL, + event TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS events_thread ON events (thread_id, seq);`; + +test("a journal v0.1.0 wrote opens with its rows, takes an identity, replays unchanged, and leaves drafts and state files alone", async () => { + const home = tmpdir("modex-migration-v010-"); + const store = new Store(home); + const project = store.addProject(gitRepo()); + const setup = new ThreadRunner({ home, store, emit: () => {} }); + const thread = await setup.createThread(project.id, { backend: "claude", auto: false }); + store.updateThread(thread.id, { title: "Old thread", titleSource: "manual" }); + store.saveItems(thread.id, LEGACY); + await setup.dispose(); + fs.writeFileSync(path.join(store.dir, "update-recovery.json"), JSON.stringify({ drafts: [{ threadId: thread.id, text: "half a reply" }] })); + const file = path.join(home, "app", "events.sqlite"); + const old = new DatabaseSync(file); + old.exec(V010_SCHEMA); + const insert = old.prepare("INSERT INTO events (at, thread_id, type, event) VALUES (?, ?, ?, ?)"); + for (const item of LEGACY) insert.run(at, thread.id, "item", JSON.stringify({ threadId: thread.id, type: "item", item })); + old.close(); + const files = besideJournal(home); + + const journal = new EventJournal(file, { flushMs: 5 }); + try { + assert.match(journal.id, /^[\w-]{1,64}$/, "it gains an identity clients can resume from"); + assert.equal(journal.latest(), LEGACY.length, "every row v0.1.0 wrote is kept, and cursors continue after them"); + assert.deepEqual(replayItems(journal.since(0, { threadId: thread.id }), thread.id), LEGACY, "and replays to the saved transcript"); + assert.equal(journal.resume(undefined, undefined).entries, undefined, "a client from before cursors reloads"); + const { runner } = runnerOn(home, journal); + assert.deepEqual(runner.items(thread.id), LEGACY, "the transcript loads with every word"); + await runner.dispose(); + } finally { journal.close(); } + const relaunched = new EventJournal(file); + const id = relaunched.id; + relaunched.close(); + assert.equal(new EventJournal(file).id, id, "the identity is kept across clean relaunches"); + assert.deepEqual(besideJournal(home), files, "the draft and the state files are byte-identical"); +}); + +test("a journal this build wrote stays usable after a downgrade: v0.1.0's and v0.2.0's own statements still read and write it", () => { + const file = path.join(tmpdir("modex-migration-rollback-"), "events.sqlite"); + const journal = new EventJournal(file); + journal.record({ threadId: "t1", type: "item", item: LEGACY[0]! }); + const id = journal.id; + const cursor = journal.latest(); + journal.close(); + + // v0.1.0 opening it: its schema is a subset, so it opens, reads and appends. + const v010 = new DatabaseSync(file); + v010.exec(V010_SCHEMA); + v010.prepare("INSERT INTO events (at, thread_id, type, event) VALUES (?, ?, ?, ?)").run(at, "t1", "item", JSON.stringify({ threadId: "t1", type: "item", item: LEGACY[1] })); + assert.equal((v010.prepare("SELECT seq, at, event FROM events WHERE seq > ? ORDER BY seq LIMIT ?").all(0, 10) as unknown[]).length, 2); + v010.close(); + + // v0.2.0 opening it: the same meta table; it keeps the identity and ignores the `open` flag. + const v020 = new DatabaseSync(file); + v020.exec(`${V010_SCHEMA} + CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`); + v020.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('id', ?)").run("would-be-new-id"); + assert.equal((v020.prepare("SELECT value FROM meta WHERE key = ?").get("id") as { value: string }).value, id, "v0.2.0 keeps the identity this build gave it"); + assert.equal(Number((v020.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events").get() as { seq: number }).seq), cursor + 1); + v020.close(); + + // Back on this build: nothing the older builds did confuses it. + const back = new EventJournal(file); + assert.equal(back.id, id, "an older build's clean use does not look like a crash"); + assert.equal(back.since(0).length, 2); + back.close(); +}); diff --git a/apps/desktop/test/event-journal.test.ts b/apps/desktop/test/event-journal.test.ts index 10b23f14..b0bee9ed 100644 --- a/apps/desktop/test/event-journal.test.ts +++ b/apps/desktop/test/event-journal.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; import path from "node:path"; import { EventJournal, replayItems } from "../src/main/engine/event-journal.js"; import { Store } from "../src/main/engine/store.js"; @@ -9,6 +10,7 @@ import type { ThreadEvent, ThreadItem } from "../src/shared/types.js"; import type { MockStep } from "@modex/core"; import { gitRepo, tmpdir, writeScript } from "./helpers.js"; import { CodexBackend } from "../src/main/engine/backends/codex.js"; +import { ClaudeBackend } from "../src/main/engine/backends/claude.js"; import { mobileItem } from "../src/main/engine/companion.js"; import { FakeProcess, fakeSpawn } from "./fakeproc.js"; import { CODEX_THREAD, codexServer, fixture } from "./conversation-fixtures.js"; @@ -297,3 +299,129 @@ test("attempts stay separate through the journal: a retry that reuses a native i journal.close(); } }); + +// ── Journal faults (CF-6, journal mechanics) ────────────────────────────────────────────────────── + +/** Every file under `home` except the journal's own, by its bytes, so a test can show nothing else changed. */ +function besideJournal(home: string): Map { + const files = new Map(); + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) walk(file); + else if (!entry.name.startsWith("events.sqlite")) files.set(path.relative(home, file), fs.readFileSync(file).toString("base64")); + } + }; + walk(home); + return files; +} + +/** The process dies: nothing flushes and nothing closes, but the file is no longer held open. */ +function crash(journal: EventJournal): void { + const inner = journal as unknown as { timer?: ReturnType; db: { close(): void } }; + clearTimeout(inner.timer); + inner.db.close(); +} + +test("a write that fails while a client reads is reported and turns the journal off: no cursor resumes past the lost rows", () => { + const errors: unknown[] = []; + const journal = new EventJournal(path.join(tmpdir("modex-journal-"), "events.sqlite"), { flushMs: 60_000, onError: (error) => errors.push(error) }); + journal.record({ threadId: "t1", type: "item", item: note("a") }); + const cursor = journal.latest(); + journal.record({ threadId: "t1", type: "item", item: note("b") }); + const inner = journal as unknown as { insert: unknown }; + const insert = inner.insert; + inner.insert = { run() { throw new Error("SQLITE_FULL: database or disk is full"); } }; + // The host's /events and the phone's /v1/snapshot read like this, and reading flushes. + const during = journal.resume(journal.id, cursor); + assert.equal(during.entries, undefined, "the client reloads instead of resuming"); + assert.equal(errors.length, 1, "the failure is reported, as a timed flush's is"); + assert.match(String(errors[0]), /SQLITE_FULL/); + inner.insert = insert; // the disk has room again + journal.record({ threadId: "t1", type: "item", item: note("c") }); + assert.equal(journal.resume(journal.id, cursor).entries, undefined, "a cursor from before the lost rows never resumes past them"); + assert.equal(errors.length, 1, "reported once"); + journal.close(); +}); + +test("a journal Modex did not close cleanly gets a new id, so every cursor issued before the crash reloads", () => { + const file = path.join(tmpdir("modex-journal-"), "events.sqlite"); + const before = new EventJournal(file, { flushMs: 60_000 }); + before.record({ threadId: "t1", type: "item", item: note("a") }); + const cursor = before.latest(); + // Answered on disk, still in the batch window when the process dies. + before.record({ threadId: "t1", type: "item_update", id: "a", patch: { text: "answered" } }); + crash(before); + const after = new EventJournal(file); + assert.notEqual(after.id, before.id, "the reopened journal has a new identity"); + assert.equal(after.resume(before.id, cursor).entries, undefined, "a pre-crash cursor reloads instead of resuming past the lost update"); + assert.ok(after.latest() >= cursor, "cursors still only grow"); + after.close(); + const relaunched = new EventJournal(file); + assert.equal(relaunched.id, after.id, "a clean close keeps the identity, so an ordinary relaunch still resumes"); + relaunched.close(); +}); + +test("a cursor from a deleted journal reloads, and journal faults leave every other file under MODEX_HOME as it was", () => { + const home = tmpdir("modex-journal-home-"); + const store = new Store(home); + store.addProject(gitRepo()); + fs.writeFileSync(path.join(store.dir, "update-recovery.json"), JSON.stringify({ drafts: [{ threadId: "t1", text: "half a reply" }] })); + const files = besideJournal(home); + const file = path.join(home, "app", "events.sqlite"); + const first = new EventJournal(file, { flushMs: 60_000 }); + first.record({ threadId: "t1", type: "item", item: note("a") }); + const cursor = { id: first.id, at: first.latest() }; + crash(first); + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(`${file}${suffix}`, { force: true }); + const fresh = new EventJournal(file, { flushMs: 60_000, onError: () => {} }); + assert.notEqual(fresh.id, cursor.id); + assert.equal(fresh.resume(cursor.id, cursor.at).entries, undefined, "a cursor from the deleted file reloads"); + assert.equal(fresh.resume(fresh.id, cursor.at).entries, undefined, "even when its number names a row the new file never had"); + fresh.record({ threadId: "t1", type: "item", item: note("b") }); + (fresh as unknown as { insert: { run(): never } }).insert = { run() { throw new Error("disk full"); } }; + fresh.resume(fresh.id, 0); + crash(fresh); + new EventJournal(file).close(); + assert.deepEqual(besideJournal(home), files, "drafts and state files are untouched by a crash, a deleted journal and a failed write"); +}); + +test("an MCP route's capability token reaches no event, journal row or file when a failed turn reports its launch", async () => { + const home = tmpdir("modex-journal-secret-"); + const store = new Store(home); + const journal = new EventJournal(path.join(home, "app", "events.sqlite"), { flushMs: 5 }); + const token = "3f0c2a6e-8b1d-4c55-9e0f-7a2b1c3d4e5f"; + const proc = new FakeProcess(); + const emitted: ThreadEvent[] = []; + const pages = { prepare: async () => ({ connection: { url: `http://127.0.0.1:4100/mcp/${token}`, instructions: "Notes" }, close: async () => {} }) }; + const runner = new ThreadRunner({ home, store, emit: (event) => { emitted.push(event); journal.record(event); }, + backends: { claude: new ClaudeBackend("claude", fakeSpawn(proc).spawn) }, + pages: pages as unknown as NonNullable[0]["pages"]> }); + const project = store.addProject(gitRepo()); + const thread = await runner.createThread(project.id, { backend: "claude", auto: false }); + store.updateThread(thread.id, { title: "Fixture", titleSource: "manual" }); + try { + const turn = runner.send(thread.id, "go"); + await proc.waitFor((line) => line.includes('"type":"user"')); + proc.stderr.write("fatal\n"); + proc.close(1); + await turn; + journal.flush(); + assert.ok(JSON.stringify(runner.items(thread.id)).includes("127.0.0.1:4100/mcp/"), "the failure still names the route it launched with"); + assert.ok(!JSON.stringify(emitted).includes(token), "no event carries the token"); + assert.ok(!journal.since(0).some((entry) => JSON.stringify(entry.event).includes(token)), "no journal row carries it"); + } finally { + await runner.dispose(); + journal.close(); + } + const leaks: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) walk(file); + else if (fs.readFileSync(file).includes(token)) leaks.push(path.relative(home, file)); + } + }; + walk(home); + assert.deepEqual(leaks, [], "no file under MODEX_HOME holds it, the SQLite file and its WAL included"); +}); diff --git a/apps/desktop/test/journal-resume.test.ts b/apps/desktop/test/journal-resume.test.ts index 09537bc8..d441cb29 100644 --- a/apps/desktop/test/journal-resume.test.ts +++ b/apps/desktop/test/journal-resume.test.ts @@ -2,21 +2,24 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; +import { spawnSync } from "node:child_process"; import https from "node:https"; import path from "node:path"; import { once } from "node:events"; import { EventJournal } from "../src/main/engine/event-journal.js"; import { DesktopHost } from "../src/main/engine/desktop-host.js"; import { DesktopClient } from "../src/main/engine/desktop-client.js"; -import { CompanionServer } from "../src/main/engine/companion.js"; +import { CompanionServer, mobileItem } from "../src/main/engine/companion.js"; import { Store } from "../src/main/engine/store.js"; import { ThreadRunner } from "../src/main/engine/runner.js"; import { MockBackend } from "../src/main/engine/backends/mock.js"; import type { ThreadEvent, ThreadItem } from "../src/shared/types.js"; +import type { TurnSink } from "../src/main/engine/backends/types.js"; +import { replayItems } from "../src/main/engine/event-journal.js"; import type { MockStep } from "@modex/core"; import { gitRepo, tmpdir, writeScript } from "./helpers.js"; -type Phone = { id: string; kind: string; text?: string; status?: string }; +type Phone = { id: string; kind: string; text?: string; status?: string; answer?: string }; type PhoneSnapshot = { items: Phone[]; journal?: { id: string; cursor: number }; delta?: true }; /** Applies an item event as the window does: an `item` upserts by id, an `item_update` merges. */ @@ -192,3 +195,427 @@ test("a desktop whose cursor the host cannot resume reloads, and a stopped journ fs.rmSync(home, { recursive: true, force: true }); } }); + +// ── Journal faults through the real host and client (CF-6, journal mechanics) ────────────────────── + +const note = (id: string, text = id): ThreadItem => ({ id, kind: "user", text, at: new Date().toISOString() }) as ThreadItem; +const until = async (check: () => boolean, ms = 5000): Promise => { + for (const end = Date.now() + ms; !check(); await new Promise((resolve) => setTimeout(resolve, 20))) { + if (Date.now() > end) throw new Error("timed out"); + } +}; + +/** A journal-backed host with one paired desktop client, collecting what the client sees. */ +async function pairedOverJournal(prefix: string) { + const home = tmpdir(prefix); + let journal: EventJournal | undefined = new EventJournal(path.join(home, "app", "events.sqlite"), { flushMs: 5 }); + const host = new DesktopHost(home, { version: "test", channels: () => [], async invoke() { return null; }, journal: () => journal }); + let unsubscribe = journal.subscribe((entries) => host.broadcastJournal(entries)); + const client = new DesktopClient(() => {}); + const connections: boolean[] = []; + client.on("connected", ({ resumed }: { resumed: boolean }) => connections.push(resumed)); + const ids: string[] = []; + client.on("thread", (event: ThreadEvent) => { if (event.type === "item") ids.push(event.item.id); }); + await host.start(); + await client.pair(host.invite(), "a".repeat(64)); + await once(client, "connected", { signal: AbortSignal.timeout(5000) }); + return { + home, host, client, connections, ids, + get journal() { return journal!; }, + /** Swap in another journal, as a relaunch over the same home would. */ + replace(next: EventJournal | undefined) { unsubscribe(); journal = next; if (next) unsubscribe = next.subscribe((entries) => host.broadcastJournal(entries)); }, + async dispose() { client.dispose(); await host.dispose(); journal?.close(); fs.rmSync(home, { recursive: true, force: true }); }, + }; +} + +test("a thread event too big for the client's frame buffer reloads once instead of resuming into it forever", async () => { + const paired = await pairedOverJournal("modex-journal-big-"); + try { + paired.journal.record({ threadId: "t1", type: "item", item: note("small") }); + await until(() => paired.ids.includes("small")); + paired.journal.record({ threadId: "t1", type: "item", item: note("big", "y".repeat(1_100_000)) }); + await until(() => paired.connections.length >= 2); + await new Promise((resolve) => setTimeout(resolve, 3000)); // a client that resumes into the frame reconnects again and again here + assert.equal(paired.connections[1], false, "the client reloads state after a frame it cannot read"); + assert.ok(paired.connections.length <= 3, `it settles instead of looping (${paired.connections.length} connections)`); + paired.journal.record({ threadId: "t1", type: "item", item: note("later") }); + await until(() => paired.ids.includes("later")); + assert.ok(!paired.ids.includes("big"), "the oversized frame itself never arrives; the reload carries that item"); + } finally { + await paired.dispose(); + } +}); + +test("Modex killed right after an approval is answered: pre-crash cursors reload on both clients, the card shows the answer, and nothing is sent again", async () => { + const home = tmpdir("modex-journal-crash-"); + const store = new Store(home); + const project = store.addProject(gitRepo()); + const idle = { id: "claude" as const, listModels: async () => [], dispose: async () => {}, async runTurn() { return { status: "completed" as const }; } }; + const setup = new ThreadRunner({ home, store, backends: { claude: idle }, emit: () => {} }); + const thread = await setup.createThread(project.id, { backend: "claude", auto: false }); + store.updateThread(thread.id, { title: "Fixture", titleSource: "manual" }); + await setup.dispose(); + + // A real Modex process: the CLI asks, the answer is saved and handed to the CLI, and the process is + // killed before the journal's 250 ms batch is written. The paired desktop's cursor is taken first. + const module = (name: string) => JSON.stringify(new URL(`../src/main/engine/${name}.js`, import.meta.url).href); + const script = `import fs from "node:fs"; import path from "node:path"; + import { EventJournal } from ${module("event-journal")}; import { Store } from ${module("store")}; import { ThreadRunner } from ${module("runner")}; + const home = ${JSON.stringify(home)}, threadId = ${JSON.stringify(thread.id)}; + const journal = new EventJournal(path.join(home, "app", "events.sqlite")); + const backend = { id: "claude", listModels: async () => [], dispose: async () => {}, async runTurn(_text, _opts, sink) { + const answer = await sink.approval({ question: "Allow Bash: $ deploy?", canAlways: false }); + fs.appendFileSync(path.join(home, "decisions.log"), answer + "\\n"); + process.kill(process.pid, "SIGKILL"); + return { status: "completed" }; + } }; + const runner = new ThreadRunner({ home, store: new Store(home), emit: (event) => journal.record(event), backends: { claude: backend } }); + void runner.send(threadId, "deploy"); + for (let i = 0; i < 500 && !runner.items(threadId).some((item) => item.kind === "approval"); i++) await new Promise((r) => setTimeout(r, 10)); + journal.flush(); + const card = runner.items(threadId).find((item) => item.kind === "approval"); + fs.writeFileSync(path.join(home, "pre-crash.json"), JSON.stringify({ id: journal.id, cursor: journal.latest(), card: card.id })); + runner.answer(threadId, card.id, "yes"); + await new Promise(() => {});`; + const child = spawnSync(process.execPath, ["--input-type=module", "-e", script], { timeout: 30_000, encoding: "utf8" }); + assert.equal(child.signal, "SIGKILL", child.stderr); + const before = JSON.parse(fs.readFileSync(path.join(home, "pre-crash.json"), "utf8")) as { id: string; cursor: number; card: string }; + assert.equal(fs.readFileSync(path.join(home, "decisions.log"), "utf8"), "yes\n", "the CLI heard one decision"); + assert.equal((new Store(home).items(thread.id).find((item) => item.id === before.card) as { answer?: string }).answer, "yes", "the answer was saved"); + + // Relaunch over the same home, with a CLI that counts every call. + const journal = new EventJournal(path.join(home, "app", "events.sqlite"), { flushMs: 5 }); + let ran = 0; + const counting = { ...idle, async runTurn() { ran++; return { status: "completed" as const }; } }; + const relaunched = new Store(home); + const runner = new ThreadRunner({ home, store: relaunched, emit: (event) => journal.record(event), backends: { claude: counting } }); + const host = new DesktopHost(home, { version: "test", channels: () => [], async invoke() { return null; }, journal: () => journal }); + journal.subscribe((entries) => host.broadcastJournal(entries)); + const companion = new CompanionServer(home, { state: () => relaunched.snapshot(), items: (id) => runner.items(id), status: (id) => runner.status(id), + send: async () => ({ ok: true }), answer: () => {}, journal: () => journal }, () => ["127.0.0.1"], () => () => {}); + const client = new DesktopClient(() => {}); + const connections: boolean[] = []; + client.on("connected", ({ resumed }: { resumed: boolean }) => connections.push(resumed)); + try { + assert.notEqual(journal.id, before.id, "the journal a dead process left open takes a new identity"); + await host.start(); + const pairingUri = (await companion.start()).pairingUri!; + await client.pair(host.invite(), "a".repeat(64)); + await once(client, "connected", { signal: AbortSignal.timeout(5000) }); + // The paired desktop held its pre-crash cursor across the outage. + client.suspend(); + (client as unknown as { journal?: { id: string; cursor: number } }).journal = { id: before.id, cursor: before.cursor }; + client.resume(); + await once(client, "connected", { signal: AbortSignal.timeout(5000) }); + assert.deepEqual(connections, [false, false], "a pre-crash cursor reloads instead of resuming into a card that looks open"); + const phone = await phoneSnapshot(pairingUri, home, thread.id, { id: before.id, cursor: before.cursor }); + assert.equal(phone.delta, undefined, "the phone's pre-crash cursor reloads too"); + assert.equal(phone.items.find((item) => item.id === before.card)?.answer, "yes", "and its reload shows the card answered"); + assert.equal(ran, 0, "reloading, replaying and reconnecting ran nothing"); + assert.equal(fs.readFileSync(path.join(home, "decisions.log"), "utf8"), "yes\n", "and sent the CLI no second decision"); + } finally { + client.dispose(); + await companion.dispose(); + await host.dispose(); + await runner.dispose(); + journal.close(); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("a journal write that fails while the phone reads turns journaling off: the phone and the desktop reload, and live events continue", async () => { + const home = tmpdir("modex-journal-fail-"); + const store = new Store(home); + const project = store.addProject(gitRepo()); + let journal: EventJournal | undefined; + const stopped: unknown[] = []; + const host = new DesktopHost(home, { version: "test", channels: () => [], async invoke() { return null; }, journal: () => journal }); + // index.ts's wiring: the main process records into the journal while it is on and broadcasts + // directly otherwise; a failure stops it, and streams it fed reconnect without it. + const emit = (event: ThreadEvent) => { if (journal) journal.record(event); else host.broadcast("thread", event); }; + const idle = { id: "claude" as const, listModels: async () => [], dispose: async () => {}, async runTurn() { return { status: "completed" as const }; } }; + const runner = new ThreadRunner({ home, store, backends: { claude: idle }, emit }); + journal = new EventJournal(path.join(home, "app", "events.sqlite"), { flushMs: 60_000, onError: (error) => { + stopped.push(error); + const failed = journal; + journal = undefined; + try { failed?.close(); } catch { /* the state files remain authoritative */ } + host.restartStreams(); + } }); + journal.subscribe((entries) => host.broadcastJournal(entries)); + const companion = new CompanionServer(home, { state: () => store.snapshot(), items: (id) => runner.items(id), status: (id) => runner.status(id), + send: async () => ({ ok: true }), answer: () => {}, journal: () => journal }, () => ["127.0.0.1"], () => () => {}); + const client = new DesktopClient(() => {}); + const connections: boolean[] = []; + client.on("connected", ({ resumed }: { resumed: boolean }) => connections.push(resumed)); + const events: ThreadEvent[] = []; + client.on("thread", (event: ThreadEvent) => events.push(event)); + try { + const thread = await runner.createThread(project.id, { backend: "claude", auto: false }); + await host.start(); + const pairingUri = (await companion.start()).pairingUri!; + await client.pair(host.invite(), "a".repeat(64)); + await once(client, "connected", { signal: AbortSignal.timeout(5000) }); + const first = await phoneSnapshot(pairingUri, home, thread.id); + assert.ok(first.journal, "the phone holds a cursor"); + + emit({ threadId: thread.id, type: "status", status: "running" }); // pending in the batch window + (journal as unknown as { insert: { run(): never } }).insert = { run() { throw new Error("SQLITE_FULL: database or disk is full"); } }; + const after = await phoneSnapshot(pairingUri, home, thread.id, first.journal); + assert.equal(after.delta, undefined, "the phone whose read hit the failure reloads instead of getting an error or a gap"); + assert.equal(stopped.length, 1, "the failure stopped the journal once"); + await until(() => connections.length === 2); + assert.deepEqual(connections, [false, false], "the desktop reconnects without the journal and reloads"); + + emit({ threadId: thread.id, type: "status", status: "idle" }); + await until(() => events.some((event) => event.type === "status" && event.status === "idle")); + const later = await phoneSnapshot(pairingUri, home, thread.id, after.journal); + assert.equal(later.delta, undefined, "with the journal off, every phone snapshot is a full transcript"); + } finally { + client.dispose(); + await companion.dispose(); + await host.dispose(); + await runner.dispose(); + journal?.close(); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +/** A host, phone server and paired desktop over one journal and a real thread, to probe cursors and limits. */ +async function hostPhoneAndDesktop(prefix: string) { + const home = tmpdir(prefix); + const file = path.join(home, "app", "events.sqlite"); + const store = new Store(home); + const project = store.addProject(gitRepo()); + let journal = new EventJournal(file, { flushMs: 5 }); + const idle = { id: "claude" as const, listModels: async () => [], dispose: async () => {}, async runTurn() { return { status: "completed" as const }; } }; + const runner = new ThreadRunner({ home, store, backends: { claude: idle }, emit: (event) => journal.record(event) }); + const host = new DesktopHost(home, { version: "test", channels: () => [], async invoke() { return null; }, journal: () => journal }); + let unsubscribe = journal.subscribe((entries) => host.broadcastJournal(entries)); + const companion = new CompanionServer(home, { state: () => store.snapshot(), items: (id) => runner.items(id), status: (id) => runner.status(id), + send: async () => ({ ok: true }), answer: () => {}, journal: () => journal }, () => ["127.0.0.1"], () => () => {}); + const client = new DesktopClient(() => {}); + const connections: boolean[] = []; + client.on("connected", ({ resumed }: { resumed: boolean }) => connections.push(resumed)); + const received: ThreadEvent[] = []; + client.on("thread", (event: ThreadEvent) => received.push(event)); + const thread = await runner.createThread(project.id, { backend: "claude", auto: false }); + await host.start(); + const pairingUri = (await companion.start()).pairingUri!; + await client.pair(host.invite(), "a".repeat(64)); + await once(client, "connected", { signal: AbortSignal.timeout(5000) }); + return { + home, file, thread, host, client, connections, received, + get journal() { return journal; }, + /** The desktop reconnects holding `cursor`, as after sleep; resolves whether it resumed. */ + async desktopFrom(cursor: { id: string; cursor: number }): Promise { + client.suspend(); + (client as unknown as { journal?: { id: string; cursor: number } }).journal = cursor; + client.resume(); + await once(client, "connected", { signal: AbortSignal.timeout(5000) }); + return connections.at(-1)!; + }, + phoneFrom: (cursor?: { id: string; cursor: number }) => phoneSnapshot(pairingUri, home, thread.id, cursor), + /** The journal file is deleted and a new one opens over the same home. */ + replaceJournal() { + unsubscribe(); + journal.close(); + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(`${file}${suffix}`, { force: true }); + journal = new EventJournal(file, { flushMs: 5 }); + unsubscribe = journal.subscribe((entries) => host.broadcastJournal(entries)); + }, + async dispose() { client.dispose(); await companion.dispose(); await host.dispose(); await runner.dispose(); journal.close(); fs.rmSync(home, { recursive: true, force: true }); }, + }; +} + +test("every cursor the journal cannot honour reloads both clients: ahead of the log, pruned, and from a deleted journal", async () => { + const at = await hostPhoneAndDesktop("modex-journal-cursors-"); + try { + // Distinct items, so each is its own row (updates to one item would merge into one). + for (let i = 0; i < 4; i++) at.journal.record({ threadId: at.thread.id, type: "item", item: note(`c${i}`) }); + const latest = at.journal.latest(); + const id = at.journal.id; + // A control first: the client really does present the cursor it is given. + assert.equal(await at.desktopFrom({ id, cursor: latest }), true, "a current cursor resumes the desktop"); + assert.equal((await at.phoneFrom({ id, cursor: latest })).delta, true, "and the phone"); + + assert.equal(await at.desktopFrom({ id, cursor: latest + 50 }), false, "a cursor ahead of the log reloads the desktop"); + assert.equal((await at.phoneFrom({ id, cursor: latest + 50 })).delta, undefined, "and the phone"); + + (at.journal as unknown as { retention: { maxAgeMs: number; maxRows: number } }).retention = { maxAgeMs: 60_000, maxRows: 1 }; + at.journal.prune(); + assert.equal(await at.desktopFrom({ id, cursor: 1 }), false, "a pruned cursor reloads the desktop"); + assert.equal((await at.phoneFrom({ id, cursor: 1 })).delta, undefined, "and the phone"); + + at.replaceJournal(); + assert.notEqual(at.journal.id, id); + assert.equal(await at.desktopFrom({ id, cursor: latest }), false, "a cursor from a deleted journal reloads the desktop"); + assert.equal((await at.phoneFrom({ id, cursor: latest })).delta, undefined, "and the phone"); + } finally { + await at.dispose(); + } +}); + +test("a resume whose replay passes 4 MB reloads instead, and one under it replays every missed event once", async () => { + const at = await hostPhoneAndDesktop("modex-journal-replay-"); + try { + const big = (id: string) => ({ threadId: at.thread.id, type: "item" as const, item: note(id, "z".repeat(900_000)) }); + const missed = async (count: number, tag: string): Promise => { + at.journal.flush(); + const cursor = { id: at.journal.id, cursor: at.journal.latest() }; + at.client.suspend(); + for (let i = 0; i < count; i++) at.journal.record(big(`${tag}${i}`)); + at.journal.flush(); + return at.desktopFrom(cursor); + }; + assert.equal(await missed(3, "small"), true, "about 2.7 MB missed: the desktop resumes"); + await until(() => ["small0", "small1", "small2"].every((id) => at.received.some((event) => event.type === "item" && event.item.id === id))); + for (const id of ["small0", "small1", "small2"]) assert.equal(at.received.filter((event) => event.type === "item" && event.item.id === id).length, 1, `${id} arrived once`); + assert.equal(await missed(5, "large"), false, "about 4.5 MB missed: the desktop reloads instead of replaying it"); + assert.ok(!at.received.some((event) => event.type === "item" && event.item.id.startsWith("large")), "nothing of the oversized replay was streamed"); + } finally { + await at.dispose(); + } +}); + +test("a journal batch delivered twice reaches the paired desktop once", async () => { + const at = await hostPhoneAndDesktop("modex-journal-twice-"); + try { + at.journal.flush(); + const cursor = at.journal.latest(); + at.journal.record({ threadId: at.thread.id, type: "item", item: note("once") }); + at.journal.record({ threadId: at.thread.id, type: "item_update", id: "once", patch: { text: "edited" } }); + at.journal.flush(); + const batch = at.journal.since(cursor); + at.host.broadcastJournal(batch); // the same rows again, as a retried write or a second subscriber would send them + at.journal.record({ threadId: at.thread.id, type: "status", status: "idle" }); + await until(() => at.received.some((event) => event.type === "status" && event.status === "idle")); + const rows = batch.filter((entry) => entry.event.threadId === at.thread.id && (entry.event.type === "item" || entry.event.type === "item_update")); + assert.ok(rows.length >= 1); + const mine = at.received.filter((event) => event.threadId === at.thread.id && (event.type === "item" || event.type === "item_update")); + assert.deepEqual(mine, rows.map((entry) => entry.event), "each row applied once, though it was sent twice"); + } finally { + await at.dispose(); + } +}); + +test("one mixed transcript converges on every path, survives hostile frames, and nothing is run or answered twice", async () => { + const home = tmpdir("modex-journal-mixed-"); + const store = new Store(home); + const project = store.addProject(gitRepo()); + const live = new EventJournal(path.join(home, "app", "events.sqlite"), { flushMs: 5 }); + // The same events through a journal that batches the whole run into one window, so a card's + // opening and its withdrawal or answer coalesce into one row. + const coalesced = new EventJournal(path.join(home, "batched.sqlite"), { flushMs: 60_000 }); + const emitted: ThreadEvent[] = []; + const emit = (event: ThreadEvent) => { emitted.push(event); live.record(event); coalesced.record(event); }; + let runs = 0; + const decisions: string[] = []; + const sinks: TurnSink[] = []; + const at = (item: string) => ({ runtime: "fixture", item }); + const backend = { id: "claude" as const, listModels: async () => [], dispose: async () => {}, async runTurn(_text: string, _opts: unknown, sink: TurnSink) { + const n = ++runs; + sinks.push(sink); + if (n === 1) { + sink.observe({ type: "message.start", native: at("m1"), phase: "commentary" }); + sink.observe({ type: "message.delta", native: at("m1"), text: "Looking first." }); + sink.observe({ type: "message.done", native: at("m1") }); + sink.observe({ type: "tool.start", native: at("t1"), tool: { name: "shell", title: "$ ls", args: { command: "ls" } } }); + sink.observe({ type: "tool.done", native: at("t1"), outcome: "succeeded", progress: { output: "a\nb\n" } }); + sink.observe({ type: "tool.update", native: at("t1"), progress: { output: "a late frame after the tool ended" } }); + const answer = await sink.approval({ question: "Allow Bash: $ rm -rf build?", canAlways: false }); + decisions.push(answer); + sink.observe({ type: "tool.start", native: at("t2"), tool: { name: "shell", title: "$ rm -rf build", args: { command: "rm -rf build" } } }); + sink.observe({ type: "tool.done", native: at("t2"), outcome: answer === "yes" ? "succeeded" : "denied" }); + sink.observe({ type: "message.start", native: at("m2"), phase: "final" }); + sink.observe({ type: "message.delta", native: at("m2"), text: "first try" }); + return { status: "failed" as const, error: "try again" }; + } + // The retry reuses the first attempt's native message id, and the first attempt's sink speaks late. + sinks[0]!.observe({ type: "message.delta", native: at("m2"), text: " (late, from attempt 1)" }); + const withdrawn = new AbortController(); + const asked = sink.approval({ question: "Allow Bash: $ deploy?", canAlways: false }, withdrawn.signal); + withdrawn.abort(); // the CLI settles the request itself before anyone answers + decisions.push(`withdrawn:${await asked}`); + sink.observe({ type: "message.start", native: at("m2"), phase: "final" }); + sink.observe({ type: "message.delta", native: at("m2"), text: "second try" }); + sink.observe({ type: "message.done", native: at("m2") }); + return { status: "completed" as const }; + } }; + const runner = new ThreadRunner({ home, store, backends: { claude: backend }, emit }); + const host = new DesktopHost(home, { version: "test", channels: () => [], async invoke() { return null; }, journal: () => live }); + live.subscribe((entries) => host.broadcastJournal(entries)); + const companion = new CompanionServer(home, { state: () => store.snapshot(), items: (id) => runner.items(id), status: (id) => runner.status(id), + send: async () => ({ ok: true }), answer: () => {}, journal: () => live }, () => ["127.0.0.1"], () => () => {}); + const client = new DesktopClient(() => {}); + const connections: boolean[] = []; + client.on("connected", ({ resumed }: { resumed: boolean }) => connections.push(resumed)); + let desktop: ThreadItem[] = []; + let restarted: ThreadRunner | undefined; + try { + const thread = await runner.createThread(project.id, { backend: "claude", auto: false }); + store.updateThread(thread.id, { title: "Fixture", titleSource: "manual" }); + client.on("thread", (event: ThreadEvent) => { if (event.threadId === thread.id) desktop = apply(desktop, event); }); + await host.start(); + const pairingUri = (await companion.start()).pairingUri!; + await client.pair(host.invite(), "a".repeat(64)); + await once(client, "connected", { signal: AbortSignal.timeout(5000) }); + desktop = structuredClone(runner.items(thread.id)); + + // Attempt 1 waits on its approval: the phone loads, and the desktop sleeps through the rest. + const turn = runner.send(thread.id, "clean up"); + await until(() => runner.items(thread.id).some((item) => item.kind === "approval")); + const mid = await phoneSnapshot(pairingUri, home, thread.id); + client.suspend(); + const card = runner.items(thread.id).find((item) => item.kind === "approval")!; + runner.answer(thread.id, card.id, "yes"); + runner.answer(thread.id, card.id, "yes"); // a second click, or the phone's answer arriving late + await turn; + await runner.retry(thread.id); + client.resume(); + await once(client, "connected", { signal: AbortSignal.timeout(5000) }); + live.flush(); + coalesced.flush(); + + const saved = new Store(home).items(thread.id); + const fromLive = live.since(0, { threadId: thread.id }); + const fromBatched = coalesced.since(0, { threadId: thread.id }); + // Memory can hold keys set to undefined, such as a failure card's empty debug fields; the files and every + // client see items through JSON, so the live projection is compared the same way. + const wire = (items: ThreadItem[]) => JSON.parse(JSON.stringify(items)) as ThreadItem[]; + assert.deepEqual(wire(runner.items(thread.id)), saved, "the live projection is what a journal-off reload loads"); + assert.deepEqual(wire(replayItems(emitted, thread.id)), saved, "the live event stream folds to it"); + assert.deepEqual(replayItems(fromLive, thread.id), saved, "the journal replays to it"); + assert.deepEqual(replayItems([...fromLive, ...fromLive], thread.id), saved, "and replaying it twice changes nothing"); + assert.ok(fromBatched.length < fromLive.length, "the batched journal really coalesced"); + assert.deepEqual(replayItems(fromBatched, thread.id), saved, "the batched journal replays to it too"); + assert.deepEqual(connections, [false, true], "the desktop resumed after its sleep"); + await until(() => JSON.stringify(desktop) === JSON.stringify(saved)); + const delta = await phoneSnapshot(pairingUri, home, thread.id, mid.journal); + assert.equal(delta.delta, true); + assert.deepEqual(merge(mid.items, delta.items), JSON.parse(JSON.stringify(saved.map(mobileItem))), "the phone's mid-run snapshot plus its delta matches a full one"); + + // What the transcript says, on every path above. + const cards = saved.filter((item) => item.kind === "approval") as (ThreadItem & { answer?: string; withdrawn?: string })[]; + assert.deepEqual(cards.map((item) => [item.answer, Boolean(item.withdrawn)]), [["yes", false], ["no", true]], "an answered card and a withdrawn one, through coalescing"); + const tools = saved.filter((item) => item.kind === "tool") as (ThreadItem & { status: string; outcome?: string })[]; + assert.deepEqual(tools.map((item) => [item.status, item.outcome]), [["done", "succeeded"], ["done", "succeeded"]], "a late update never reopens a finished tool"); + const answers = saved.filter((item) => item.kind === "assistant") as (ThreadItem & { text: string; outcome?: string })[]; + assert.deepEqual(answers.map((item) => [item.text, item.outcome]), [["Looking first.", "succeeded"], ["first try", "unknown"], ["second try", "succeeded"]], "the old attempt's late text reaches no item"); + + // Nothing ran or answered more than the user asked, through replay, reconnects and a restart. + restarted = new ThreadRunner({ home, store: new Store(home), backends: { claude: backend }, emit: () => {} }); + assert.deepEqual(wire(restarted.items(thread.id)), saved, "a restarted Modex loads the same transcript"); + assert.equal(runs, 2, "two attempts ran: the turn and its retry"); + assert.deepEqual(decisions, ["yes", "withdrawn:no"], "the CLI heard one answer, and the withdrawn request settled no"); + } finally { + client.dispose(); + await companion.dispose(); + await host.dispose(); + await restarted?.dispose(); + await runner.dispose(); + live.close(); + coalesced.close(); + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/docs/event-journal.md b/docs/event-journal.md index 343adbaf..aee078ae 100644 --- a/docs/event-journal.md +++ b/docs/event-journal.md @@ -13,7 +13,10 @@ files stay authoritative: nothing reads the journal to decide app state, so dele loses only journal history. If the journal can't open or a write fails, Modex logs the error and turns the journal off for -that run. Turns and the state files are unaffected. +that run. Turns and the state files are unaffected. This covers a write that fails while a client is +reading, since reading flushes the pending batch. That client gets "reload state", and the journal +records nothing more. Paired desktops then reconnect without it and reload, and the phone gets full +transcripts. ## What it records @@ -49,6 +52,13 @@ Each journal file has its own id, kept in its `meta` table. A cursor means somet with that id: after the file is deleted, a new file starts over with a new id, so an old cursor can't skip events. +The id also changes when a journal may be missing rows. While open, the journal records +`open = 1` in `meta`, and a clean close resets it. It reopens still marked open in two cases: +the process died with a batch unwritten, which can be up to 250 ms of events the state files +already hold, or a write failed. Either way it takes a new id, so every cursor issued before then +reloads instead of resuming past the gap. An ordinary quit keeps the id, so a relaunch still +resumes. + ## Resuming after sleep or a reconnect `EventJournal.resume(id, cursor, { threadId })` answers a reconnecting client in one synchronous @@ -72,7 +82,9 @@ On a resumed reconnect, the Store edition refreshes its thread list and the sele Changes, but keeps its loaded transcripts: the replay brings them up to date. When it can't resume, it reloads everything as before. It also closes the stream when the Mac goes to sleep and reopens it on wake, rather than waiting 45 seconds for a heartbeat timeout. A replay over 4 MB reloads -instead. A stream that falls 8 MB behind is dropped, and its client resumes from its cursor. +instead. A stream that falls 8 MB behind is dropped, and its client resumes from its cursor. A +desktop drops any single frame over 1 MB, and then reloads instead of resuming, because resuming +would replay the same frame into the same limit. Thread events reach paired desktops up to 250 ms later while the journal is on: they arrive with the batch that wrote them. The local window still gets each event as it happens. If the journal @@ -84,6 +96,9 @@ thread list current. With the journal on, a snapshot for a selected thread inclu resume them, `items` holds only that thread's items that changed after the cursor, marked `delta: true`. The phone replaces each changed item by id and appends new ones. Changing threads, reconnecting or forgetting the Mac drops the cursor, so the next snapshot is a full transcript. +An item of a kind this Mac doesn't know, such as one a newer Modex saved before a downgrade, goes to +the phone as an info notice. Its own fields aren't forwarded. It is never sent as `null`, which +would make the phone reject the whole snapshot. `test/journal-resume.test.ts` is the paired sleep test. The scripted runner and a real journal run a turn. A paired desktop over TLS and the phone's HTTP requests catch up. Both clients then sleep, a @@ -91,6 +106,25 @@ second turn runs, and both wake. The desktop must resume without reloading and r exactly once. The phone must receive only the sleep's items, and both transcripts must match the Mac's. `CompanionModelTests` checks the phone's merge. +The same file covers faults, through the real host, phone server and desktop client over TLS: +- a SIGKILL right after an approval is answered; +- a write that fails during a phone read; +- cursors ahead of the log, pruned, and from a deleted journal; +- the 4 MB replay limit; +- a frame over 1 MB; +- a batch delivered twice. + +One mixed transcript must also look the same on every path: +- the saved files; +- the live projection and stream; +- a live journal and a fully batched one, each also replayed twice; +- the phone's snapshot plus its delta; +- a desktop that slept; +- a restarted runner. + +Along the way it takes a duplicated answer, a late tool update, an old attempt's late text and a +withdrawn request, and it counts the CLI's runs and answers. + These rules make that work: - **A row never changes.** Merging happens only in the pending batch, and every read flushes that @@ -129,6 +163,33 @@ replays them unchanged. item field. Replay and resume show resolved and withdrawn cards inert and never send an answer. A secret answer is never written here. See [across replay and reconnect](human-requests.md#across-replay-and-reconnect). +## Compatibility and rollback + +The schema is additive. +- **`events`:** unchanged since v0.1.0. Its columns are listed above. +- **`meta`:** a key/value table that v0.1.0 doesn't have. It holds these keys: + - `id`: the journal's identity; + - `pruned_through`: the highest cursor retention deleted; + - `open`: `"1"` from open until a clean close. + +**Older journals.** A v0.1.0 file, with an `events` table and nothing else, opens with every row +kept, gains an id, and replays unchanged. A v0.2.0 file keeps its id. + +**Downgrading.** An older build can still read and append to a file this build wrote. Here is +what it doesn't do: +- v0.2.0 ignores `open`, so a crash it has keeps the id, and its own resume rules apply. +- v0.1.0 doesn't prune and has no cursors, so retention isn't enforced until this build opens the + file again. +- v0.1.0's and v0.2.0's phone mapping has no case for kinds newer than themselves, such as `plan` + (#289) and `question` (#293). It sends them as `null`, and the phone then rejects the snapshot + until that thread's items are gone or Modex is upgraded again. + +The journal is never authoritative, so deleting `events.sqlite` is always a safe rollback. It +loses only journal history, and every client reloads. + +`test/conversation-migration.test.ts` builds a v0.1.0 file with that release's schema. It also +drives a file this build wrote with v0.1.0's and v0.2.0's own SQL. + ## Retention Rows older than 7 days are deleted, and so are the oldest rows past 250,000. Modex prunes when the diff --git a/docs/status.md b/docs/status.md index 37dbc993..d9341e73 100644 --- a/docs/status.md +++ b/docs/status.md @@ -74,6 +74,17 @@ and [landing verification](reviews/2026-10-11-landing-clarity.md). - **Replay and clients.** Journal replay, paired desktops and the phone agree on the same items; a duplicated frame no longer doubles an item. On the phone, a failed or denied tool says so in its title. Work a crashed Modex left running is saved and sent as unknown instead of spinning forever. + - **Journal faults (CF-6 #279).** These apply with `MODEX_EVENT_JOURNAL=1`. + - A write that fails while a client reads turns the journal off, and clients reload. Before, that + batch was lost silently. + - A journal a crash or a failed write left open takes a new identity. Cursors from before then + reload instead of resuming past the gap, so an answered card no longer looks open on a paired + desktop after a crash. + - A paired desktop reloads after a frame over 1 MB instead of reconnecting forever. + - The phone gets a notice for an item kind its Mac doesn't know, instead of rejecting the + snapshot. + + [Faults and rollback](event-journal.md#compatibility-and-rollback). - **Evidence (CF-5 #278, first part).** A command shows exactly what ran, where, and its exit code as the CLI reported it. Tool output keeps its last 256 KB with a visible note. A patch is labelled as this turn's. Codex memory citations show as citations, and a "Sources:" list the model wrote