From f195ee3d02c1b80f0798358cd154c82ecd933358 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 9 Oct 2026 18:09:38 +0200 Subject: [PATCH 1/7] harness: claim card branch Harness-Claim: true Agent: switchboard-dev Host: ANCHOR Base-Ref: origin/main Base-SHA: 9c0e82782cd1ac1d4775f16f4a9e28b1045de29f Claimed-At: 2026-10-09T16:09:37.9634719Z Nonce: c7a9f6b2381345a8 From 30f1410c1a5c99ebc1cfef9990d59df917d0df59 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 9 Oct 2026 18:40:28 +0200 Subject: [PATCH 2/7] test(restore): pin that restore follows a session's continued-in record (#518) --- test/canary-cli-continuations.test.js | 49 +++++ test/open-session-terminal.test.js | 1 + test/process-exit-status.test.js | 1 + test/restore-continuations.test.js | 229 ++++++++++++++++++++++++ test/restore-pending-persist.test.js | 1 + test/resume-guard.test.js | 2 +- test/session-continuation-index.test.js | 25 +++ 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 test/canary-cli-continuations.test.js create mode 100644 test/restore-continuations.test.js create mode 100644 test/session-continuation-index.test.js diff --git a/test/canary-cli-continuations.test.js b/test/canary-cli-continuations.test.js new file mode 100644 index 000000000..dbe1ca37c --- /dev/null +++ b/test/canary-cli-continuations.test.js @@ -0,0 +1,49 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +test('CANARY live: bounded CLI transcript sampling retains the continued-in field names', { timeout: 9000 }, async t => { + const projects = path.join(os.homedir(), '.claude', 'projects'); + let files = []; + try { + const folders = fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory()).slice(0, 32); + for (const folder of folders) { + const dir = path.join(projects, folder.name); + files.push(...fs.readdirSync(dir).filter(name => name.endsWith('.jsonl')).slice(0, 4).map(name => path.join(dir, name))); + } + } catch { t.skip('CLI transcript directory unavailable; observed-format fixture still runs'); return; } + const deadline = Date.now() + 3000; + let budget = 8 * 1024 * 1024, checked = 0; + for (const file of files) { + if (budget <= 0 || Date.now() >= deadline) break; + let pending = ''; + const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024, encoding: 'utf8' }); + try { + for await (const chunk of stream) { + budget -= Buffer.byteLength(chunk); + pending += chunk; + const lines = pending.split('\n'); + pending = lines.pop(); + for (const line of lines) { + if (!line.includes('continued-in')) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.type !== 'continued-in') continue; + assert.equal(typeof entry.sessionId, 'string', 'CLI continued-in must carry sessionId'); + assert.equal(typeof entry.continuedInSessionId, 'string', 'CLI continued-in must carry continuedInSessionId'); + assert.equal(typeof entry.timestamp, 'string', 'CLI continued-in must carry timestamp'); + checked++; + } + if (pending.length > 1024 * 1024) pending = ''; + if (checked || budget <= 0 || Date.now() >= deadline) break; + } + } catch (err) { + if (err.code === 'ERR_ASSERTION') throw err; + } finally { stream.destroy(); } + if (checked) break; + } + if (!checked) t.skip('no continued-in record within 8 MiB / 3 s bounded sample; CLI drift not measured'); +}); diff --git a/test/open-session-terminal.test.js b/test/open-session-terminal.test.js index e68fd2e21..46749a6e6 100644 --- a/test/open-session-terminal.test.js +++ b/test/open-session-terminal.test.js @@ -27,6 +27,7 @@ async function withHarness(setup, fn) { destroySession: (id) => calls.destroyed.push(id), showSession: (id) => calls.shown.push(id), guardResume: async () => true, + resolveResumeSession: require('../public/resume-guard').resolveResumeSession, createTerminalEntry: (session) => { calls.created.push(session.sessionId); return { initialSize: { cols: 80, rows: 24 }, terminal: { write: (text) => calls.written.push(text) } }; diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index 375dc3efe..afc98aff8 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -84,6 +84,7 @@ const PRELUDE = ` function setup() { const dom = new JSDOM('', { runScripts: 'outside-only' }); const { window } = dom; + window.resolveResumeSession = require('../public/resume-guard').resolveResumeSession; let exitHandler = null; const h = { whileOpening: null }; window.api = { diff --git a/test/restore-continuations.test.js b/test/restore-continuations.test.js new file mode 100644 index 000000000..63c266a66 --- /dev/null +++ b/test/restore-continuations.test.js @@ -0,0 +1,229 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { loadAppFunctions } = require('./app-source'); +const cache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); + +function setup(t, graph, { live = {}, answer = false } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-continuations-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const folder = encodeProjectPath(root); + fs.mkdirSync(path.join(root, folder)); + for (const [id, children] of Object.entries(graph)) { + const records = [ + { type: 'user', cwd: root, timestamp: '2026-10-09T12:00:00Z', message: { content: 'hello' } }, + ...children.map(child => ({ type: 'continued-in', sessionId: id, continuedInSessionId: child, timestamp: '2026-10-09T12:01:00Z' })), + { type: 'assistant', timestamp: '2026-10-09T12:02:00Z', message: { content: 'after continuation' } }, + ]; + fs.writeFileSync(path.join(root, folder, id + '.jsonl'), records.map(JSON.stringify).join('\n') + '\n'); + } + const rows = new Map(); + const db = { + getSetting: () => ({}), getAllFolderMeta: () => new Map(), getAllMeta: () => new Map(), + getCachedByFolder: () => [...rows.values()], getCachedSession: id => rows.get(id), + upsertCachedSessions: entries => entries.forEach(row => rows.set(row.sessionId, row)), + setFolderMeta() {}, getMeta() {}, setName() {}, upsertSearchEntries() {}, replaceSessionMetrics() {}, + deleteCachedFolder() {}, deleteCachedSession() {}, deleteSearchFolder() {}, deleteSearchSession() {}, + }; + cache.init({ PROJECTS_DIR: root, db, activeSessions: new Map(), getMainWindow: () => null, log: console }); + cache.refreshFolder(folder); + const dom = new JSDOM('', { runScripts: 'outside-only' }); + t.after(() => dom.window.close()); + const ctx = dom.getInternalVMContext(); + const settingsFile = path.join(root, 'settings.json'); + const saved = [{ sessionId: 'old', projectPath: root, active: true }]; + fs.writeFileSync(settingsFile, JSON.stringify({ openWorkingSet: saved })); + const spawned = [], prompts = []; + dom.window.confirm = message => { prompts.push(message); return typeof answer === 'function' ? answer(message) : answer; }; + dom.window.api = { + getSetting: async () => JSON.parse(fs.readFileSync(settingsFile, 'utf8')), + setSetting: async (_, value) => fs.writeFileSync(settingsFile, JSON.stringify(value)), + getSessionContinuations: async id => typeof cache.resolveSessionContinuations === 'function' + ? cache.resolveSessionContinuations(id) : { candidates: [], unresolved: false }, + getSessionLiveElsewhere: async id => live[id] || null, + getSessionsLiveElsewhere: async ids => Object.fromEntries(ids.filter(id => live[id]).map(id => [id, live[id]])), + openTerminal: async (id, project, isNew, options) => { spawned.push({ id, project, options }); return { ok: true }; }, + }; + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/resume-guard.js'), 'utf8'), ctx); + vm.runInContext(` + var openSessions = new Map(), sessionMap = new Map(); + var activeSessionId = null, restoringWorkingSet = true, restorePlanner = null; + var sessionOpenedOutsideRestore = false, _persistChain = Promise.resolve(); + var RESTORE_STAGGER_MS = 0; + function createTerminalEntry(session) { + const entry = { session, terminal: { write() {} }, initialSize: {} }; + openSessions.set(session.sessionId, entry); return entry; + } + function showSession(id) { activeSessionId = id; } + function destroySession(id) { openSessions.delete(id); } + async function resolveDefaultSessionOptions() { return {}; } + function syncPtySizeAfterOpen() {} function setSessionSandboxed() {} + function forgetSessionExit() {} function beginPtyOpen() {} function settlePtyOpen() {} + function schedulePersistWorkingSet() {} function pollActiveSessions() {} + function showLiveElsewhereNotice() {} + `, ctx); + ctx.rows = [...rows.values()]; + vm.runInContext('rows.forEach(row => sessionMap.set(row.sessionId, row));', ctx); + const app = loadAppFunctions(ctx, { + declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight'], + functions: ['runRestore', 'openSession', 'persistWorkingSet', 'pendingRestoreEntries'], + }); + return { + spawned, prompts, rows, root, db, folder, ctx, + restore: async () => { await app.runRestore(saved); await app.persistWorkingSet(); }, + click: async () => { await app.openSession(rows.get('old')); await app.persistWorkingSet(); }, + settings: () => JSON.parse(fs.readFileSync(settingsFile, 'utf8')), + }; +} + +for (const [name, graph, expected] of [ + ['one continuation', { old: ['new'], new: [] }, 'new'], + ['a chain old to mid to new', { old: ['mid'], mid: ['new'], new: [] }, 'new'], + ['no continued-in record', { old: [] }, 'old'], + ['converging branches', { old: ['mid', 'new'], mid: ['new'], new: [] }, 'new'], +]) { + test(`restore resolves ${name} and persists the final id`, { timeout: 9000 }, async t => { + const h = setup(t, graph); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), [expected]); + assert.equal(h.spawned[0].project, h.root); + assert.deepEqual(h.settings().openWorkingSet, [{ sessionId: expected, projectPath: h.root, active: true }]); + assert.equal(h.prompts.length, 0); + }); +} + +for (const [name, graph] of [ + ['two terminal continuations', { old: ['a', 'b'], a: [], b: [] }], + ['a cycle', { old: ['mid'], mid: ['old'] }], + ['a missing continuation', { old: ['missing'] }], +]) { + test(`restore asks before ${name} and spawns nothing on dismissal`, { timeout: 9000 }, async t => { + const h = setup(t, graph); + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.ok(h.prompts.length > 0); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'old'); + if (name === 'two terminal continuations') { + assert.match(h.prompts[0], /a/); + assert.match(h.prompts[0], /b/); + assert.match(h.prompts[0], /2026-10-09T12:02:00Z/); + } + }); +} + +test('manual click offers the continuation before opening it', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }, { answer: true }); + await h.click(); + assert.deepEqual(h.spawned.map(call => call.id), ['new']); + assert.match(h.prompts[0], /new/); +}); + +test('choosing the second continuation opens only that candidate and persists it', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['a', 'b'], a: [], b: [] }, { answer: message => /Open b\?/.test(message) }); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['b']); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'b'); +}); + +test('a fresh continuation index resolves without reading transcript contents again', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }); + const read = t.mock.method(fs, 'readSync'); + const full = t.mock.method(fs, 'readFileSync'); + const result = await h.ctx.api.getSessionContinuations('old'); + assert.deepEqual(result.candidates.map(candidate => candidate.sessionId), ['new']); + assert.equal(read.mock.callCount(), 0); + assert.equal(full.mock.callCount(), 0); +}); + +test('a malformed continuation target asks rather than resuming the old id', { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + fs.appendFileSync(file, '{"type":"continued-in","sessionId":"old","continuedInSessionId":"../escape"}\n'); + delete h.rows.get('old').continuationIndex; + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.ok(h.prompts.length); +}); + +test('restore attaches to the continued background job', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }, { live: { new: { kind: 'bg', jobId: '1234abcd', cwd: '/bg', pid: 1 } } }); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['new']); + assert.equal(h.spawned[0].options.type, 'attach'); + assert.equal(h.spawned[0].options.jobId, '1234abcd'); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'new'); + assert.equal(h.settings().openWorkingSet[0].active, true); +}); + +test('reopening a closed old tab still offers its continuation', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }, { answer: true }); + vm.runInContext('openSessions.set("old", { session: sessionMap.get("old"), closed: true });', h.ctx); + await h.click(); + assert.deepEqual(h.spawned.map(call => call.id), ['new']); + assert.match(h.prompts[0], /new/); +}); + +test('a held old entry is rekeyed when manual continuation opening meets a live schedule', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }, { answer: true, live: { new: { kind: 'schedule', pid: 2 } } }); + vm.runInContext(`skippedWorkingSetEntries.set('old', { item: { sessionId: 'old', projectPath: ${JSON.stringify(h.root)} }, index: 0 });`, h.ctx); + await h.click(); + assert.deepEqual(h.spawned, []); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'new'); +}); + +test('manual continuation attach preserves the rekeyed held working-set entry', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }, { answer: true, live: { new: { kind: 'bg', jobId: '1234abcd', pid: 2 } } }); + vm.runInContext(`skippedWorkingSetEntries.set('old', { item: { sessionId: 'old', projectPath: ${JSON.stringify(h.root)} }, index: 0 });`, h.ctx); + await h.click(); + assert.equal(h.spawned[0].options.type, 'attach'); + assert.equal(h.settings().openWorkingSet[0]?.sessionId, 'new'); + assert.equal(h.settings().openWorkingSet[0]?.active, true); +}); + +for (const size of [2, 5]) test(`legacy cache fallback streams a ${size} MiB ordinary record before continued-in`, { timeout: 9000 }, async t => { + const h = setup(t, { old: [], new: [] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + fs.appendFileSync(file, JSON.stringify({ sessionId: 'old', type: 'assistant', message: { content: 'x'.repeat(size * 1024 * 1024) } }) + '\n'); + fs.appendFileSync(file, JSON.stringify({ type: 'continued-in', sessionId: 'old', continuedInSessionId: 'new' }) + '\n'); + delete h.rows.get('old').continuationIndex; + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['new']); +}); + +test('a partial continuation recovers once the CLI finishes its record', { timeout: 9000 }, async t => { + const h = setup(t, { old: [], new: [] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + fs.appendFileSync(file, '{"type":"continued-in","sessionId":"old",'); + await h.restore(); + assert.deepEqual(h.spawned, []); + fs.appendFileSync(file, '"continuedInSessionId":"new"}\n'); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['new']); +}); + +test('depth limit asks without resuming the original id', { timeout: 9000 }, async t => { + const graph = { old: ['n0'] }; + for (let i = 0; i < 35; i++) graph['n' + i] = i === 34 ? [] : ['n' + (i + 1)]; + const h = setup(t, graph); + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.ok(h.prompts.length); +}); + +test('refresh indexes a continuation appended beyond the display header', { timeout: 9000 }, async t => { + const h = setup(t, { old: [], new: [] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + fs.appendFileSync(file, JSON.stringify({ type: 'assistant', message: { content: 'x'.repeat(300000) } }) + '\n'); + fs.appendFileSync(file, JSON.stringify({ type: 'continued-in', sessionId: 'old', continuedInSessionId: 'new' }) + '\n{}\n'); + fs.utimesSync(file, new Date('2030-01-01'), new Date('2030-01-01')); + cache.refreshFolder(h.folder, { files: new Set(['old.jsonl']) }); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['new']); +}); diff --git a/test/restore-pending-persist.test.js b/test/restore-pending-persist.test.js index a62857c96..ae5e0f5d0 100644 --- a/test/restore-pending-persist.test.js +++ b/test/restore-pending-persist.test.js @@ -20,6 +20,7 @@ const SAVED = [ function setup() { const dom = new JSDOM('', { runScripts: 'outside-only' }); const ctx = dom.getInternalVMContext(); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/resume-guard.js'), 'utf8'), ctx); const settings = { global: { openWorkingSet: SAVED } }; dom.window.api = { getSetting: async (key) => JSON.parse(JSON.stringify(settings[key] || null)), diff --git a/test/resume-guard.test.js b/test/resume-guard.test.js index f87ef0c37..e452cd813 100644 --- a/test/resume-guard.test.js +++ b/test/resume-guard.test.js @@ -133,7 +133,7 @@ test('the reload path resumes the remembered session as an automatic resume', () test('the working-set restore resumes each session as an automatic resume', () => { const body = functionBody(APP_SRC, 'async function runRestore('); - assert.match(body, /openSession\(s, undefined, \{ automatic: true, live \}\)/); + assert.match(body, /openSession\(s, undefined, \{ automatic: true, live, continuationResolved: true, allowBgAttach: s.sessionId !== originalId \}\)/); assert.doesNotMatch(body, /openSession\(s\)/); }); diff --git a/test/session-continuation-index.test.js b/test/session-continuation-index.test.js new file mode 100644 index 000000000..ebaf6d28d --- /dev/null +++ b/test/session-continuation-index.test.js @@ -0,0 +1,25 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { readSessionFile } = require('../read-session-file'); + +test('Observed-format fixture: CLI 2.1.289 and 2.1.295 continued-in field names are indexed mid-file', t => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-continuation-canary-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const file = path.join(dir, 'old.jsonl'); + fs.writeFileSync(file, [ + { type: 'user', message: { content: 'fixture' } }, + { type: 'continued-in', sessionId: 'old', continuedInSessionId: 'new', timestamp: '2026-10-09T12:00:00Z' }, + { type: 'continued-in', sessionId: 'other', continuedInSessionId: 'wrong', timestamp: '2026-10-09T12:01:00Z' }, + { type: 'last-prompt', lastPrompt: 'bookkeeping follows' }, + ].map(JSON.stringify).join('\n') + '\n'); + const row = readSessionFile(file, 'folder', dir); + assert.ok(row.continuationIndex, 'the scanner must preserve the observed continued-in contract'); + const index = JSON.parse(row.continuationIndex); + assert.deepEqual(index.ids, ['new']); +}); + From 99a6f97208c79dfe65bc6bcc7a84bbc31d84768b Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 9 Oct 2026 18:48:23 +0200 Subject: [PATCH 3/7] fix(restore): follow a session's continuation instead of resuming the pre-fork transcript (#518) The CLI records a backgrounded session's new id as a continued-in record in the old transcript; restore and the sidebar ignored it and resumed the old one. --- .ai/contexts/cli-session-state.md | 28 +++++++ .ai/contexts/ipc-bridge.md | 6 ++ .ai/contexts/session-cache.md | 21 +++++ CHANGELOG.md | 3 + db.js | 10 ++- docs/session-restore.md | 10 +++ eslint.config.js | 1 + main.js | 1 + preload.js | 1 + public/app.js | 63 ++++++++++++--- public/resume-guard.js | 30 +++++++- read-session-file.js | 14 +++- session-cache.js | 32 ++++++++ session-continuations.js | 124 ++++++++++++++++++++++++++++++ 14 files changed, 326 insertions(+), 18 deletions(-) create mode 100644 session-continuations.js diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 1ed31dd11..a69f23bee 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -347,3 +347,31 @@ reader knows immediately to look outward rather than hunt a bug in Switchboard. Add one whenever you build on an undocumented external artefact; do not add one for an assumption a normal unit test can pin. + +## Conversation continuations + +CLI 2.1.289 and 2.1.295 append `{type:"continued-in", sessionId:, +continuedInSessionId:, timestamp:...}` to the old transcript. These records +can be mid-file and repeated; the new transcript has no back-link. This is an +observed interface, pinned by `test/canary-cli-continuations.test.js` with a live +sample bounded to 8 MiB / 3 seconds (skipped when no record is available). +`test/session-continuation-index.test.js` always runs an observed-format fixture; +that fixture proves parser agreement, not CLI drift. + +`session-continuations` IPC calls `sessionCache.resolveSessionContinuations`. +It traverses indexed links in record order, deduplicates terminal ids and bounds +traversal to 32 edges / 128 node visits. Cycles, missing rows/files, malformed +records and scan limits return `unresolved`, preventing any old-id resume. +`resolveResumeSession` is shared by restore and sidebar clicks. An automatic +restore selects exactly one terminal id; manual opens offer it. Several ids +use the existing confirm UI, listing every candidate and last activity before +asking whether to open each one. Declining all choices keeps the saved entry. +An unresolved graph asks for retry/sidebar selection without spawning. Remote +sessions retain their existing attach behavior. + +After resolution, `runRestore` checks the final id's liveness; a background +continuation requests the guard's existing `{attach}` verdict. Other live kinds +retain their refusal/confirmation rules. Saved entries are rekeyed before +opening; background continuation attachments retain the resolved entry in the +working set. Ordinary attach tabs remain excluded. Part A, live descriptor +rekeying while the original PTY runs, remains out of scope pending #477. diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 589dd315c..c538e373b 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -621,3 +621,9 @@ What Switchboard sets *after* cleaning stays: the plain terminal's `CLAUDECODE=1 3. Renderer: `const result = await window.api.myThing(...)` 4. Test: prefer a unit test for the main-side logic (extract to a pure function the handler calls); jsdom integration tests for renderer side. 5. Document here. + +### Conversation continuations (#518B) + +| IPC | Args | Returns | Notes | +|---|---|---|---| +| `session-continuations` | `(sessionId)` | `{candidates:[{sessionId,modified}],unresolved,continued}` | Bounded traversal of the transcript continuation index; exposed as `api.getSessionContinuations`. Restore and manual resume share the renderer resolver. See cli-session-state.md, Conversation continuations. | diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index cd6e64bc3..df5f8d8c8 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -1671,3 +1671,24 @@ search_fts USING fts5(id, type, folder, title, body, tokenize='trigram') search_map(id PK, type, folder) -- backref for FTS delete settings(key PK, value JSON) ``` + +## Continuation index (#518B) + +`session_cache.continuationIndex` is nullable JSON `{ids, bytes, complete, +mtime, unresolved, skipLine?}`. Schema reconciliation adds it without wiping +old rows. Full transcript scans in `read-session-file.js` collect links while +already parsing the file, including worker scans. Header-only refresh also +updates this index with a bounded 4 MiB chunk scan, starting at the previous +safe cursor for appended transcripts. Same-size/shrunken changes restart at +byte zero; the CLI's append-only history is assumed for growth. + +`resolveSessionContinuations` verifies size/mtime before trusting the index. +A legacy/incomplete/stale index uses bounded chunk reads, yielding between +4 MiB chunks with a shared 128 MiB budget for the entire traversal; it writes +progress back to the row. A complete fresh index only needs stat, without +reading transcript contents. A partial trailing JSON record retains the +last complete-line cursor, so an append retries it. Lines over 1 MiB are +skipped only when their top-level type in the first 64 KiB is known and is +not continued-in; skipLine persists across chunks. An unclassifiable oversized +line stays unresolved until full indexing replaces the index. No transcript +content is sent through this IPC, only candidate ids and activity times. diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a6aa33e..4c7a172a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased +### Fixed +- Restoring a conversation that continued under another id now opens its continuation and saves the new id. Multiple continuations offer a choice with their last activity, and clicking the old session offers the continuation. (#518) + ## v0.0.91 — 2026-10-09 ### New diff --git a/db.js b/db.js index 4b56998c1..b06ec6cfa 100644 --- a/db.js +++ b/db.js @@ -319,6 +319,7 @@ if (migrations.length > currentDbVersion) { // see .ai/contexts/session-cache.md ("SDK-launched sessions") if (!cols.has('entrypoint')) db.exec('ALTER TABLE session_cache ADD COLUMN entrypoint TEXT'); if (!cols.has('scheduleSlug')) db.exec('ALTER TABLE session_cache ADD COLUMN scheduleSlug TEXT'); + if (!cols.has('continuationIndex')) db.exec('ALTER TABLE session_cache ADD COLUMN continuationIndex TEXT'); db.exec('CREATE INDEX IF NOT EXISTS idx_session_cache_parent ON session_cache(parentSessionId)'); // Fork table (shipped in our v5), referenced unconditionally by prepare() // below — must exist whatever db_version claims. @@ -421,8 +422,8 @@ const stmts = { cacheCount: db.prepare('SELECT COUNT(*) as cnt FROM session_cache'), cacheGetAll: db.prepare('SELECT * FROM session_cache'), cacheUpsert: db.prepare(` - INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, parentSessionId, agentId, subagentType, description, fileMtime, bridgeSessionId, mergedIntoSessionId, entrypoint, scheduleSlug) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, parentSessionId, agentId, subagentType, description, fileMtime, bridgeSessionId, mergedIntoSessionId, entrypoint, scheduleSlug, continuationIndex) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sessionId) DO UPDATE SET folder = excluded.folder, projectPath = excluded.projectPath, summary = excluded.summary, firstPrompt = excluded.firstPrompt, @@ -433,7 +434,8 @@ const stmts = { subagentType = excluded.subagentType, description = excluded.description, bridgeSessionId = excluded.bridgeSessionId, mergedIntoSessionId = excluded.mergedIntoSessionId, - entrypoint = excluded.entrypoint, scheduleSlug = excluded.scheduleSlug + entrypoint = excluded.entrypoint, scheduleSlug = excluded.scheduleSlug, + continuationIndex = excluded.continuationIndex `), cacheGetByParent: db.prepare('SELECT * FROM session_cache WHERE parentSessionId = ? ORDER BY created ASC'), // Kept as SELECT * (upstream narrowed this to sessionId+fileMtime): our @@ -564,7 +566,7 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => { s.subagentType || null, s.description || null, s.fileMtime || null, s.bridgeSessionId || null, s.mergedIntoSessionId || null, typeof s.entrypoint === 'string' ? s.entrypoint : null, - s.scheduleSlug || null + s.scheduleSlug || null, s.continuationIndex || null ); } }); diff --git a/docs/session-restore.md b/docs/session-restore.md index f5f746240..f3156cf6d 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -44,5 +44,15 @@ its previous launch. Sessions reopen one after another, half a second apart. restoring N session(s) from last time…*. - Opening a session yourself while restore is running cancels the rest of it. +If a conversation continued under another id, restore follows its continuation +chain and saves the final id with the saved entry's project and active state. +Several distinct final ids prompt for a choice, listing each id and its last +activity. Dismissing the choices keeps the saved entry without launching it. +A cycle, missing transcript or incomplete bounded scan asks you to retry after +indexing or open a candidate from the sidebar; it never resumes the old id. +Clicking an old sidebar row offers its continuation before opening it. A live +background continuation uses the existing attach path and its final id stays +in the saved working set for a later restore. + Within one run of the app, going back to a session whose process still runs is a reattach: the terminal replays its buffered output onto the same process. diff --git a/eslint.config.js b/eslint.config.js index b1442769a..58f8e3110 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -356,6 +356,7 @@ const rendererCrossFileGlobals = { createRestorePlanner: 'readonly', // Resume guard for sessions live in another process (public/resume-guard.js) guardResume: 'readonly', + resolveResumeSession: 'readonly', liveElsewhereMany: 'readonly', // Agents view (public/agents-view.js) — see .ai/contexts/bg-agents.md agentsViewActive: 'writable', diff --git a/main.js b/main.js index 491ee1e16..91c9d5d0f 100644 --- a/main.js +++ b/main.js @@ -3139,6 +3139,7 @@ const localTranscriptTracker = createLocalTranscriptTracker({ hasPty: sessionHas // see .ai/contexts/cli-session-state.md ("Live elsewhere") ipcMain.handle('session-live-elsewhere', (_event, sessionId) => cliSessionState.liveElsewhere(sessionId, sessionHasPty, ptyPids)); +ipcMain.handle('session-continuations', (_event, sessionId) => sessionCache.resolveSessionContinuations(sessionId)); ipcMain.handle('sessions-live-elsewhere', (_event, sessionIds) => cliSessionState.liveElsewhereMany(sessionIds, sessionHasPty, ptyPids)); // see .ai/contexts/bg-agents.md diff --git a/preload.js b/preload.js index 84e6430bc..3bc6c043f 100644 --- a/preload.js +++ b/preload.js @@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld('api', { rebuildCache: () => ipcRenderer.invoke('rebuild-cache'), getActiveSessions: () => ipcRenderer.invoke('get-active-sessions'), getSessionLiveElsewhere: (id) => ipcRenderer.invoke('session-live-elsewhere', id), + getSessionContinuations: (id) => ipcRenderer.invoke('session-continuations', id), getSessionsLiveElsewhere: (ids) => ipcRenderer.invoke('sessions-live-elsewhere', ids), getActiveTerminals: () => ipcRenderer.invoke('get-active-terminals'), stopSession: (id) => ipcRenderer.invoke('stop-session', id), diff --git a/public/app.js b/public/app.js index 2b4789e54..103256ab9 100644 --- a/public/app.js +++ b/public/app.js @@ -188,10 +188,10 @@ function persistWorkingSet() { }); } const held = [...skippedWorkingSetEntries.values(), ...pendingRestoreEntries()] - .filter(({ item }) => !openSessions.has(item.sessionId)) + .filter(({ item, keepAttached }) => !openSessions.has(item.sessionId) || (keepAttached && openSessions.get(item.sessionId).attach)) .sort((a, b) => a.index - b.index); - for (const { item, index } of held) { - set.splice(Math.min(index, set.length), 0, { sessionId: item.sessionId, projectPath: item.projectPath, active: false }); + for (const { item, index, keepAttached } of held) { + set.splice(Math.min(index, set.length), 0, { sessionId: item.sessionId, projectPath: item.projectPath, active: !!keepAttached && item.sessionId === activeSessionId }); } global.openWorkingSet = set; await window.api.setSetting('global', global); @@ -225,26 +225,45 @@ async function runRestore(list) { try { const liveById = await liveElsewhereMany(pending.map(item => item.sessionId), { api: window.api }); for (const [position, item] of list.entries()) { - const s = sessionMap.get(item.sessionId); + let s = sessionMap.get(item.sessionId); if (!s || openSessions.has(item.sessionId)) { restoreInFlight.delete(item.sessionId); continue; } // Resume with the project's current "new session" defaults, exactly like a // manual session relaunch — not options frozen from a previous launch. - const live = liveById[item.sessionId] || null; + const originalId = item.sessionId; + s = await resolveResumeSession({ ...s, projectPath: item.projectPath }, { automatic: true, api: window.api, confirm: msg => window.confirm(msg) }); + if (!s) { + const index = restoreSavedIndex.has(originalId) ? restoreSavedIndex.get(originalId) : position; + skippedWorkingSetEntries.set(originalId, { item, index }); + restoreInFlight.delete(originalId); + continue; + } + if (s.sessionId !== originalId) { + restoreSavedIndex.set(s.sessionId, restoreSavedIndex.get(originalId) ?? position); + skippedWorkingSetEntries.delete(originalId); + item.sessionId = s.sessionId; + } + let live = liveById[originalId] || null; + if (s.sessionId !== originalId) { + try { live = await window.api.getSessionLiveElsewhere(s.sessionId); } catch { live = null; } + } let opened; try { - opened = await openSession(s, undefined, { automatic: true, live }); + opened = await openSession(s, undefined, { automatic: true, live, continuationResolved: true, allowBgAttach: s.sessionId !== originalId }); } finally { - restoreInFlight.delete(item.sessionId); + restoreInFlight.delete(originalId); } if (opened === false) { const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; skippedWorkingSetEntries.set(item.sessionId, { item, index }); - skippedNow.push({ session: s, live }); + if (live) skippedNow.push({ session: s, live }); continue; } + if (s.sessionId !== originalId && openSessions.get(s.sessionId)?.attach) { + skippedWorkingSetEntries.set(s.sessionId, { item, index: restoreSavedIndex.get(s.sessionId), keepAttached: true }); + } await new Promise(r => setTimeout(r, RESTORE_STAGGER_MS)); } } finally { @@ -1340,8 +1359,25 @@ async function showTerminalHeader(session) { // Terminal lifecycle (createTerminalEntry, destroySession, showSession, setupDragAndDrop) → terminal-manager.js -async function openSession(session, customOptions, { automatic = false, live } = {}) { +async function openSession(session, customOptions, { automatic = false, live, continuationResolved = false, allowBgAttach = false } = {}) { if (!restoringWorkingSet) sessionOpenedOutsideRestore = true; + if (!continuationResolved && customOptions?.type !== 'attach' && (!openSessions.has(session.sessionId) || openSessions.get(session.sessionId).closed)) { + const originalId = session.sessionId; + session = await resolveResumeSession(session, { automatic, api: window.api, confirm: msg => window.confirm(msg) }); + if (!session) return false; + if (session.sessionId !== originalId) { + live = undefined; + allowBgAttach = true; + const held = skippedWorkingSetEntries.get(originalId); + if (held || restoreSavedIndex.has(originalId)) { + restoreSavedIndex.set(session.sessionId, held?.index ?? restoreSavedIndex.get(originalId)); + } + if (held) { + skippedWorkingSetEntries.set(session.sessionId, { ...held, item: { ...held.item, sessionId: session.sessionId } }); + } + skippedWorkingSetEntries.delete(originalId); + } + } const { sessionId, projectPath } = session; // If already open, handle closed-session cleanup or just show it @@ -1356,7 +1392,7 @@ async function openSession(session, customOptions, { automatic = false, live } = } // see .ai/contexts/cli-session-state.md ("Live elsewhere") and .ai/contexts/bg-agents.md ("Attach") - const verdict = customOptions?.type === 'attach' ? true : await guardResume(session, { automatic, live, api: window.api, confirm: (msg) => window.confirm(msg) }); + const verdict = customOptions?.type === 'attach' ? true : await guardResume(session, { automatic, live, allowBgAttach, api: window.api, confirm: (msg) => window.confirm(msg) }); if (verdict === false) return false; if (verdict && typeof verdict === 'object' && verdict.attach) { customOptions = { type: 'attach', jobId: verdict.attach, cwd: verdict.cwd || projectPath }; @@ -1387,6 +1423,13 @@ async function openSession(session, customOptions, { automatic = false, live } = } if (result.reattached) entry.attach = !!result.attach; skippedWorkingSetEntries.delete(sessionId); + if (allowBgAttach && entry.attach) { + skippedWorkingSetEntries.set(sessionId, { + item: { sessionId, projectPath }, + index: restoreSavedIndex.get(sessionId) ?? Number.MAX_SAFE_INTEGER, + keepAttached: true, + }); + } syncPtySizeAfterOpen(entry, result); if (typeof setSessionMcpState === 'function') setSessionMcpState(sessionId, result.mcpState, result.mcpError); setSessionSandboxed(sessionId, result.sandbox); diff --git a/public/resume-guard.js b/public/resume-guard.js index c2620d841..9ed2a62fc 100644 --- a/public/resume-guard.js +++ b/public/resume-guard.js @@ -17,7 +17,7 @@ async function liveElsewhereMany(sessionIds, { api } = {}) { } } -async function guardResume(session, { automatic = false, api, confirm, live } = {}) { +async function guardResume(session, { automatic = false, api, confirm, live, allowBgAttach = false } = {}) { if (!session || session.type === 'terminal') return true; if (live === undefined) { try { @@ -27,7 +27,7 @@ async function guardResume(session, { automatic = false, api, confirm, live } = } } if (!live) return true; - if (automatic) return false; + if (automatic && !(allowBgAttach && live.kind === 'bg')) return false; if (live.kind === 'schedule') { const where = live.cwd ? ` in ${live.cwd}` : ''; confirm(`This session's scheduled task is already running (pid ${live.pid}${where}).\n\n` @@ -42,6 +42,30 @@ async function guardResume(session, { automatic = false, api, confirm, live } = return !!confirm(liveElsewhereMessage(live)); } +// see .ai/contexts/cli-session-state.md ("Conversation continuations") +async function resolveResumeSession(session, { automatic = false, api, confirm } = {}) { + if (!session || session.type === 'terminal' || session.remoteAlias || !api.getSessionContinuations) return session; + let result; + try { result = await api.getSessionContinuations(session.sessionId); } + catch { confirm('Could not check this conversation for continuations. Try opening it again after indexing.'); return null; } + if (!result || result.unresolved) { + const candidates = (result?.candidates || []).map(c => `${c.sessionId} (last activity: ${c.modified || 'unknown'})`).join('\n'); + confirm('This conversation has an unresolved continuation (cycle, missing transcript or scan limit).\n' + + candidates + '\nTry again after indexing or open a candidate from the sidebar.'); + return null; + } + const candidates = result.candidates || []; + if (!candidates.length) return session; + if (automatic && candidates.length === 1) return { ...session, sessionId: candidates[0].sessionId }; + const listing = candidates.map(c => `${c.sessionId} (last activity: ${c.modified || 'unknown'})`).join('\n'); + for (const candidate of candidates) { + if (confirm(`This conversation continued under another id:\n${listing}\n\nOpen ${candidate.sessionId}?`)) { + return { ...session, sessionId: candidate.sessionId }; + } + } + return null; +} + if (typeof module !== 'undefined' && module.exports) { - module.exports = { guardResume, liveElsewhereMany, liveElsewhereMessage }; + module.exports = { guardResume, liveElsewhereMany, liveElsewhereMessage, resolveResumeSession }; } diff --git a/read-session-file.js b/read-session-file.js index 0d89fc855..9ba72dbc9 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -1,6 +1,7 @@ const path = require('path'); const fs = require('fs'); const { StringDecoder } = require('string_decoder'); +const { continuationId } = require('./session-continuations'); /** Subagent transcripts land under //subagents/agent-.jsonl. * We surface them as first-class rows with a synthetic sessionId so they're addressable @@ -176,6 +177,9 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let textContent = ''; let slug = null; let scheduleSlug = null; + const continuationIds = new Set(); + let continuationUnresolved = false; + let incompleteTail = false; let customTitle = null; let aiTitle = null; let agentId = null; @@ -195,7 +199,14 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { // line should not invalidate the whole file. Skip the malformed line and // keep parsing. let entry; - try { entry = JSON.parse(line); } catch { continue; } + try { entry = JSON.parse(line); } catch { + if (line === lines.at(-1) && !content.endsWith('\n')) incompleteTail = true; + else continuationUnresolved = true; + continue; + } + const continuation = continuationId(entry, fileBase); + if (continuation) continuationIds.add(continuation); + if (entry.type === 'continued-in' && entry.sessionId === fileBase && !continuation) continuationUnresolved = true; if (entry.timestamp) { // ISO-8601 UTC strings — lexicographic comparison is chronological if (!firstTimestamp || entry.timestamp < firstTimestamp) firstTimestamp = entry.timestamp; @@ -289,6 +300,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { modified: lastTimestamp || stat.mtime.toISOString(), fileMtime: stat.mtime.toISOString(), messageCount, textContent, slug, scheduleSlug, customTitle, aiTitle, + continuationIndex: JSON.stringify({ ids: [...continuationIds], bytes: incompleteTail ? Buffer.byteLength(content.slice(0, content.lastIndexOf('\n') + 1)) : stat.size, complete: !incompleteTail, mtime: stat.mtime.toISOString(), unresolved: continuationUnresolved }), bridgeSessionId, entrypoint: typedInTerminal ? 'cli' : (entrypoint ?? ''), dailyMetrics, diff --git a/session-cache.js b/session-cache.js index 7cbde1498..156302978 100644 --- a/session-cache.js +++ b/session-cache.js @@ -9,6 +9,7 @@ const { encodeProjectPath, decodeProjectFolderBestEffort } = require('./encode-p const { parseFolderKey, joinFolderKey } = require('./remote-hosts'); const { isPanelShellSession } = require('./panel-terminal-target'); const { SETTING_DEFAULTS } = require('./public/setting-defaults'); +const { scanContinuationIndex, resolveContinuations, validId } = require('./session-continuations'); /** * Session cache module. @@ -290,6 +291,7 @@ function refreshFolder(folder, opts = {}) { // Merge: keep cached body/messageCount/created, overlay fresh display fields. const merged = { ...cachedEntry, + continuationIndex: scanContinuationIndex(filePath, cachedEntry.sessionId, cachedEntry.continuationIndex), folder, projectPath, summary: h.summary || cachedEntry.summary, firstPrompt: h.firstPrompt || cachedEntry.firstPrompt, @@ -1069,7 +1071,37 @@ function populateCacheViaWorker() { return populatePromise; } +async function resolveSessionContinuations(sessionId) { + let scanBudget = 128 * 1024 * 1024; + return resolveContinuations(sessionId, async id => { + if (!validId(id)) return null; + const row = getCachedSession?.(id); + if (!row) return null; + const file = resolveJsonlPath(resolveFolderDir(row.folder), { ...row, folder: '.' }); + const stat = fs.statSync(file); + let index; + try { index = JSON.parse(row.continuationIndex); } catch {} + if (!index?.complete || index.bytes !== stat.size || index.mtime !== stat.mtime.toISOString()) { + const start = index && index.bytes < stat.size ? index.bytes : 0; + let remaining = Math.min(scanBudget, stat.size - start); + do { + const bytes = Math.min(remaining, 4 * 1024 * 1024); + remaining -= bytes; + scanBudget -= bytes; + const before = index?.bytes || 0; + row.continuationIndex = scanContinuationIndex(file, id, row.continuationIndex, bytes); + index = JSON.parse(row.continuationIndex); + await new Promise(resolve => setImmediate(resolve)); + if (index.bytes === before) break; + } while (remaining > 0 && !index.complete); + upsertCachedSessions([row]); + } + return { index, modified: row.modified }; + }); +} + module.exports = { + resolveSessionContinuations, init, readSessionFile, readFolderFromFilesystem, diff --git a/session-continuations.js b/session-continuations.js new file mode 100644 index 000000000..f45470202 --- /dev/null +++ b/session-continuations.js @@ -0,0 +1,124 @@ +const fs = require('fs'); + +const validId = id => typeof id === 'string' && /^[A-Za-z0-9_-]{1,200}$/.test(id); + +function continuationId(entry, sessionId) { + return entry.type === 'continued-in' && entry.sessionId === sessionId && validId(entry.continuedInSessionId) + ? entry.continuedInSessionId : null; +} + +function topLevelType(prefix) { + let depth = 0; + for (let i = 0; i < prefix.length; i++) { + const char = prefix[i]; + if (char === '{' || char === '[') depth++; + else if (char === '}' || char === ']') depth--; + else if (char === '"') { + const start = i++; + while (i < prefix.length && prefix[i] !== '"') { + if (prefix[i] === '\\') i++; + i++; + } + if (depth === 1) { + try { + if (JSON.parse(prefix.slice(start, i + 1)) === 'type') { + const value = prefix.slice(i + 1).match(/^\s*:\s*("(?:\\.|[^"\\])*")/); + if (value) return JSON.parse(value[1]); + } + } catch {} + } + } + } + return null; +} + +function scanContinuationIndex(file, sessionId, previous, maxBytes = 4 * 1024 * 1024) { + const stat = fs.statSync(file); + let index; + try { index = JSON.parse(previous); } catch {} + const mtime = stat.mtime.toISOString(); + if (index?.complete && index.bytes === stat.size && index.mtime === mtime) return previous; + const append = index && index.bytes < stat.size; + const ids = new Set(append ? index.ids : []); + let position = append ? index.bytes : 0; + const end = Math.min(stat.size, position + maxBytes); + const fd = fs.openSync(file, 'r'); + const buffer = Buffer.alloc(64 * 1024); + let pending = Buffer.alloc(0), oversized = append ? !!index.skipLine : false, unresolved = append ? !!index.unresolved : false; + let consumed = position; + try { + while (position < end) { + const n = fs.readSync(fd, buffer, 0, Math.min(buffer.length, end - position), position); + if (!n) break; + position += n; + pending = Buffer.concat([pending, buffer.subarray(0, n)]); + let newline; + while ((newline = pending.indexOf(10)) !== -1) { + const line = pending.subarray(0, newline); + if (!oversized && line.length) { + try { + const entry = JSON.parse(line.toString('utf8')); + const id = continuationId(entry, sessionId); + if (id) ids.add(id); + if (entry.type === 'continued-in' && entry.sessionId === sessionId && !id) unresolved = true; + } catch { unresolved = true; } + } + pending = pending.subarray(newline + 1); + consumed = position - pending.length; + oversized = false; + } + if (pending.length > 1024 * 1024) { + if (!oversized) { + const type = topLevelType(pending.subarray(0, 65536).toString('utf8')); + if (!type || type === 'continued-in') unresolved = true; + } + pending = Buffer.alloc(0); + oversized = true; + consumed = position; + } + } + if (position === stat.size && pending.length && !oversized) { + try { + const entry = JSON.parse(pending.toString('utf8')); + const id = continuationId(entry, sessionId); + if (id) ids.add(id); + if (entry.type === 'continued-in' && entry.sessionId === sessionId && !id) unresolved = true; + consumed = position; + } catch {} + } + if (oversized) consumed = position; + } finally { fs.closeSync(fd); } + return JSON.stringify({ ids: [...ids], bytes: consumed, complete: consumed === stat.size, mtime, unresolved, skipLine: oversized }); +} + +async function resolveContinuations(sessionId, getNode, { maxDepth = 32, maxNodes = 128 } = {}) { + const candidates = new Map(), visiting = new Set(), visited = new Set(); + let unresolved = false, nodes = 0, continued = false; + async function visit(id, depth) { + if (visiting.has(id) || depth > maxDepth || ++nodes > maxNodes) { unresolved = true; return; } + if (visited.has(id)) return; + let node; + try { node = await getNode(id); } catch { unresolved = true; return; } + if (!node) { unresolved = true; return; } + const index = node.index; + if (!index?.complete || index.unresolved) unresolved = true; + const children = index?.ids || []; + if (!children.length) { + if (id !== sessionId) candidates.set(id, { sessionId: id, modified: node.modified || null }); + } else { + continued = true; + visiting.add(id); + for (const child of children) { + await visit(child, depth + 1); + if (nodes > maxNodes) break; + } + visiting.delete(id); + } + visited.add(id); + } + if (!validId(sessionId)) return { candidates: [], unresolved: true, continued: false }; + await visit(sessionId, 0); + return { candidates: [...candidates.values()], unresolved, continued }; +} + +module.exports = { continuationId, scanContinuationIndex, resolveContinuations, validId }; From 6674296663ecfcb5727bb9ff742c181a0bba4ac7 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 9 Oct 2026 19:34:33 +0200 Subject: [PATCH 4/7] fix(restore): never block a session without continuation, keep automatic restore non-blocking Parse errors only hold a session when continuation bytes are involved, scans complete without a total cap, automatic restore holds ambiguous entries in a notice instead of a modal, and a manual open can still choose the original. --- .ai/contexts/cli-session-state.md | 32 ++-- .ai/contexts/session-cache.md | 53 ++++--- CHANGELOG.md | 2 +- db.js | 2 + docs/session-restore.md | 24 ++- main.js | 4 +- public/app.js | 52 +++--- public/resume-guard.js | 32 ++-- read-session-file.js | 4 +- session-cache.js | 41 ++--- session-continuations.js | 142 ++++++++--------- test/canary-cli-continuations.test.js | 49 ------ test/continuation-regressions.test.js | 218 ++++++++++++++++++++++++++ test/continuations-harness.js | 87 ++++++++++ test/open-session-terminal.test.js | 1 + test/process-exit-status.test.js | 1 + test/restore-continuations.test.js | 99 ++---------- test/restore-live-elsewhere.test.js | 1 + test/restore-pending-persist.test.js | 1 + test/resume-guard.test.js | 5 - test/schedule-live-resume.test.js | 1 + 21 files changed, 542 insertions(+), 309 deletions(-) delete mode 100644 test/canary-cli-continuations.test.js create mode 100644 test/continuation-regressions.test.js create mode 100644 test/continuations-harness.js diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index a69f23bee..0509a8f4c 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -353,21 +353,31 @@ for an assumption a normal unit test can pin. CLI 2.1.289 and 2.1.295 append `{type:"continued-in", sessionId:, continuedInSessionId:, timestamp:...}` to the old transcript. These records can be mid-file and repeated; the new transcript has no back-link. This is an -observed interface, pinned by `test/canary-cli-continuations.test.js` with a live -sample bounded to 8 MiB / 3 seconds (skipped when no record is available). -`test/session-continuation-index.test.js` always runs an observed-format fixture; -that fixture proves parser agreement, not CLI drift. +observed interface, pinned by an always-running synthetic fixture in +`test/session-continuation-index.test.js`. That fixture proves parser agreement, +not CLI drift; the test suite does not sample real conversation transcripts. `session-continuations` IPC calls `sessionCache.resolveSessionContinuations`. It traverses indexed links in record order, deduplicates terminal ids and bounds -traversal to 32 edges / 128 node visits. Cycles, missing rows/files, malformed -records and scan limits return `unresolved`, preventing any old-id resume. +traversal to 32 edges / 128 node visits. Cycles, missing continuation rows/files +and malformed continuation records return `unresolved`. Unrelated JSON parse +failures and incomplete tails do not block resume. File scanning yields between +chunks and has no total byte cap; chunk size only controls pacing. + `resolveResumeSession` is shared by restore and sidebar clicks. An automatic -restore selects exactly one terminal id; manual opens offer it. Several ids -use the existing confirm UI, listing every candidate and last activity before -asking whether to open each one. Declining all choices keeps the saved entry. -An unresolved graph asks for retry/sidebar selection without spawning. Remote -sessions retain their existing attach behavior. +restore selects exactly one terminal id. Several ids, unresolved graphs and +failed lookups hold the saved entry and join the non-blocking restore notice, +which names the original entry and tells the user to open it from the sidebar +to choose. No continuation confirmation runs during automatic restore. Manual +opens list each candidate's id and last activity before asking whether to open +it. Declining all candidates offers an explicit original-session choice; an +unresolved graph or failed lookup offers the same choice with a warning. +Cancellation keeps the held entry. Remote sessions retain their attach behavior. + +`restoreStartupSessions` shares a map of lookup promises between the remembered +active-session open and working-set restore. Each original id is resolved once +during that startup pass; the map is discarded afterward so subsequent manual +opens and indexing retries re-check the transcript. After resolution, `runRestore` checks the final id's liveness; a background continuation requests the guard's existing `{attach}` verdict. Other live kinds diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index df5f8d8c8..d4d249e3e 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -1674,21 +1674,38 @@ settings(key PK, value JSON) ## Continuation index (#518B) -`session_cache.continuationIndex` is nullable JSON `{ids, bytes, complete, -mtime, unresolved, skipLine?}`. Schema reconciliation adds it without wiping -old rows. Full transcript scans in `read-session-file.js` collect links while -already parsing the file, including worker scans. Header-only refresh also -updates this index with a bounded 4 MiB chunk scan, starting at the previous -safe cursor for appended transcripts. Same-size/shrunken changes restart at -byte zero; the CLI's append-only history is assumed for growth. - -`resolveSessionContinuations` verifies size/mtime before trusting the index. -A legacy/incomplete/stale index uses bounded chunk reads, yielding between -4 MiB chunks with a shared 128 MiB budget for the entire traversal; it writes -progress back to the row. A complete fresh index only needs stat, without -reading transcript contents. A partial trailing JSON record retains the -last complete-line cursor, so an append retries it. Lines over 1 MiB are -skipped only when their top-level type in the first 64 KiB is known and is -not continued-in; skipLine persists across chunks. An unclassifiable oversized -line stays unresolved until full indexing replaces the index. No transcript -content is sent through this IPC, only candidate ids and activity times. +`session_cache.continuationIndex` is nullable JSON with `format:2`, `ids`, +`bytes`, `complete`, `mtime` and `unresolved`. Schema reconciliation adds the +column without wiping old rows. Full transcript scans collect links while +already parsing the file, including worker scans. Missing or older-format +indexes are rebuilt lazily, including indexes that previously marked ordinary +parse errors as unresolved. + +Header-only refresh skips subagent continuation indexes and shares a 1 MiB +synchronous continuation budget across all changed parent files in that refresh. +Other files retain their stale index until a later refresh or resume lookup. +Existing display-header and SDK-entrypoint reads keep their own budgets. + +`resolveSessionContinuations` verifies format, size and mtime before trusting +an index. A complete fresh index requires only stat. A legacy, incomplete or +stale index is read in 1 MiB chunks, yielding with `setImmediate` between chunks, +with no total scan cap. Tests can inject smaller `chunkBytes`. Persistence uses +`setCachedContinuationIndex`, which updates only that column, preserving display +fields written by a concurrent refresh; it does not upsert an old row snapshot. + +Chunk indexes also carry `version:2`, `size`, `sealedIds`, a bounded base64 +`pending` line, and the last 64 indexed bytes as hexadecimal `tail`. The tail is +verified before reusing a cursor after growth; mismatch, shrink or a same-size +mtime change starts at byte zero. This is a local tail check, not a proof that +an arbitrary rewrite preserved the entire prefix. Full-scan indexes lack +a cursor witness and restart from zero when stale. + +Lines are retained up to 1 MiB. Oversized records are streamed with `skipLine`, +`lineContinued` and an 11-byte `patternTail`, detecting literal `continued-in` +bytes anywhere in the record, including across chunks. A malformed or oversized +record is unresolved only if it contains these bytes. Valid continuation records +with invalid targets also remain unresolved. An incomplete tail without those +bytes never blocks the original session. Partial tails remain buffered for a +later append; tail errors are recomputed rather than carried forward forever. +No transcript content is sent through the IPC, only candidate ids and activity +times. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7a172a3..50250c527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed -- Restoring a conversation that continued under another id now opens its continuation and saves the new id. Multiple continuations offer a choice with their last activity, and clicking the old session offers the continuation. (#518) +- Restoring a conversation that continued under another id now opens its continuation and saves the new id, including with large transcripts or unrelated damaged records. Multiple or unresolved continuations stay in the saved set with a non-blocking notice; open the old session to choose a continuation or explicitly reopen the original. (#518) ## v0.0.91 — 2026-10-09 diff --git a/db.js b/db.js index b06ec6cfa..96b2f17ab 100644 --- a/db.js +++ b/db.js @@ -445,6 +445,7 @@ const stmts = { cacheGetByFolder: db.prepare('SELECT * FROM session_cache WHERE folder = ?'), cacheGetMissingEntrypoint: db.prepare('SELECT sessionId, folder FROM session_cache WHERE entrypoint IS NULL AND parentSessionId IS NULL'), cacheSetEntrypoint: db.prepare('UPDATE session_cache SET entrypoint = ? WHERE sessionId = ?'), + cacheSetContinuationIndex: db.prepare('UPDATE session_cache SET continuationIndex = ? WHERE sessionId = ?'), cacheGetFolder: db.prepare('SELECT folder FROM session_cache WHERE sessionId = ?'), cacheGetSession: db.prepare('SELECT * FROM session_cache WHERE sessionId = ?'), cacheDeleteSession: db.prepare('DELETE FROM session_cache WHERE sessionId = ?'), @@ -906,6 +907,7 @@ module.exports = { getMeta, getAllMeta, setName, toggleStar, setArchived, isCachePopulated, getAllCached, getCachedByFolder, getCachedMissingEntrypoint, setCachedEntrypoints, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, touchCachedModified: (sessionId, modified, fileMtime = modified) => stmts.cacheTouchModified.run(modified, fileMtime, sessionId), + setCachedContinuationIndex: (sessionId, index) => stmts.cacheSetContinuationIndex.run(index, sessionId), deleteCachedSession, deleteCachedFolder, replaceSessionMetrics, getFolderMeta, getAllFolderMeta, setFolderMeta, diff --git a/docs/session-restore.md b/docs/session-restore.md index f3156cf6d..c158d886f 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -46,13 +46,23 @@ its previous launch. Sessions reopen one after another, half a second apart. If a conversation continued under another id, restore follows its continuation chain and saves the final id with the saved entry's project and active state. -Several distinct final ids prompt for a choice, listing each id and its last -activity. Dismissing the choices keeps the saved entry without launching it. -A cycle, missing transcript or incomplete bounded scan asks you to retry after -indexing or open a candidate from the sidebar; it never resumes the old id. -Clicking an old sidebar row offers its continuation before opening it. A live -background continuation uses the existing attach path and its final id stays -in the saved working set for a later restore. +One final id is selected automatically. Several final ids, a cycle, a missing +continuation transcript or a malformed continuation record hold the saved entry +without launching it. A non-blocking notice names the held conversation and +asks you to open it from the sidebar to choose; other saved sessions continue +restoring. Automatic restore never displays a continuation confirmation dialog. + +Clicking an old sidebar row offers its continuations, listing each id and its +last activity. After declining the candidates, you can explicitly choose +**Open the original session** or cancel. An unresolved continuation also offers +this explicit original-session choice, with a warning and any known candidates. +The original id is never silently selected when continuation information exists. +Ordinary malformed lines, incomplete tails and oversized records without +continuation bytes do not block opening the original session. Large transcripts +are indexed in yielded chunks without a total byte limit. + +A live background continuation uses the existing attach path and its final id +stays in the saved working set for a later restore. Within one run of the app, going back to a session whose process still runs is a reattach: the terminal replays its buffered output onto the same process. diff --git a/main.js b/main.js index 91c9d5d0f..cf1e052c0 100644 --- a/main.js +++ b/main.js @@ -151,7 +151,7 @@ if (app.isPackaged || process.env.FORCE_UPDATER) { } const { getMeta, getAllMeta, toggleStar, setName, setArchived, - isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, + isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, setCachedContinuationIndex, deleteCachedSession, deleteCachedFolder, replaceSessionMetrics, touchCachedModified, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, @@ -489,7 +489,7 @@ sessionCache.init({ deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession, replaceSessionMetrics, touchCachedModified, deleteSearchFolder, deleteSearchSession, upsertSearchEntries, setFolderMeta, getFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName, - isInitialScanComplete, setInitialScanComplete, getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession, + isInitialScanComplete, setInitialScanComplete, getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession, setCachedContinuationIndex, }, }); const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem, diff --git a/public/app.js b/public/app.js index 103256ab9..265f5e3ef 100644 --- a/public/app.js +++ b/public/app.js @@ -233,10 +233,12 @@ async function runRestore(list) { // Resume with the project's current "new session" defaults, exactly like a // manual session relaunch — not options frozen from a previous launch. const originalId = item.sessionId; - s = await resolveResumeSession({ ...s, projectPath: item.projectPath }, { automatic: true, api: window.api, confirm: msg => window.confirm(msg) }); + const originalSession = s; + s = await resolveResumeSession({ ...s, projectPath: item.projectPath }, { automatic: true, api: window.api, confirm: msg => window.confirm(msg), resolutions: window._startupResumeResolutions }); if (!s) { const index = restoreSavedIndex.has(originalId) ? restoreSavedIndex.get(originalId) : position; skippedWorkingSetEntries.set(originalId, { item, index }); + skippedNow.push({ session: originalSession, continuation: true }); restoreInFlight.delete(originalId); continue; } @@ -406,11 +408,15 @@ function showNotRestoredNotice(unavailable) { } function showLiveElsewhereNotice(skipped) { - const pids = skipped.map(({ live }) => (live ? live.pid : '?')).join(', '); - const text = skipped.length === 1 - ? `Not reopened: ${cleanDisplayName(skipped[0].session.name || skipped[0].session.aiTitle || skipped[0].session.summary) || skipped[0].session.sessionId} is live in pid ${pids}` - : `Not reopened: ${skipped.length} sessions live in pids ${pids}`; - showRestoreNotice('restore-live-elsewhere-toast', text); + const live = skipped.filter(item => !item.continuation); + const held = skipped.filter(item => item.continuation); + const label = ({ session }) => cleanDisplayName(session.name || session.aiTitle || session.summary) || session.sessionId; + const parts = []; + if (live.length === 1) parts.push('Not reopened: ' + label(live[0]) + ' is live in pid ' + (live[0].live?.pid || '?')); + else if (live.length) parts.push('Not reopened: ' + live.length + ' sessions live in pids ' + live.map(item => item.live?.pid || '?').join(', ')); + if (held.length) parts.push('Not reopened: ' + held.map(item => label(item) + ' (' + item.session.sessionId + ')').join(', ') + + ' needs a continuation choice. Open it from the sidebar to choose.'); + showRestoreNotice('restore-live-elsewhere-toast', parts.join('. ')); } async function markRestoreIndexingDone() { @@ -1363,7 +1369,7 @@ async function openSession(session, customOptions, { automatic = false, live, co if (!restoringWorkingSet) sessionOpenedOutsideRestore = true; if (!continuationResolved && customOptions?.type !== 'attach' && (!openSessions.has(session.sessionId) || openSessions.get(session.sessionId).closed)) { const originalId = session.sessionId; - session = await resolveResumeSession(session, { automatic, api: window.api, confirm: msg => window.confirm(msg) }); + session = await resolveResumeSession(session, { automatic, api: window.api, confirm: msg => window.confirm(msg), resolutions: window._startupResumeResolutions }); if (!session) return false; if (session.sessionId !== originalId) { live = undefined; @@ -1682,23 +1688,21 @@ setTimeout(() => { // Let the settings panel push updated key bindings live (no restart needed). window._applyShortcuts = (stored) => setAppShortcuts(stored); -loadProjects().then(async () => { - // Restore grid view preference before opening sessions so they enter grid mode - if (localStorage.getItem('gridViewActive') === '1') { - showGridView(); - } - // Restore active session after reload (sessionStorage — lost on full restart). - // Must be awaited so openSessions is populated before restoreWorkingSet runs its - // !openSessions.has(id) filter — otherwise the session can pass the filter and - // be opened a second time (duplicate PTY / duplicate claude --resume). - if (activeSessionId && !openSessions.has(activeSessionId)) { - const session = sessionMap.get(activeSessionId); - if (session) await openSession(session, undefined, { automatic: true }); - } - // Restore working set (persisted across full restarts via global settings) - await restoreWorkingSet(); - restoreAgentsViewAtStartup(); -}); +// see .ai/contexts/cli-session-state.md ("Conversation continuations") +async function restoreStartupSessions() { + window._startupResumeResolutions = new Map(); + try { + if (localStorage.getItem('gridViewActive') === '1') showGridView(); + if (activeSessionId && !openSessions.has(activeSessionId)) { + const session = sessionMap.get(activeSessionId); + if (session) await openSession(session, undefined, { automatic: true }); + } + await restoreWorkingSet(); + restoreAgentsViewAtStartup(); + } finally { window._startupResumeResolutions = null; } +} + +loadProjects().then(restoreStartupSessions); // Live-reload sidebar when filesystem changes are detected let projectsChangedTimer = null; diff --git a/public/resume-guard.js b/public/resume-guard.js index 9ed2a62fc..69a233ee1 100644 --- a/public/resume-guard.js +++ b/public/resume-guard.js @@ -43,27 +43,33 @@ async function guardResume(session, { automatic = false, api, confirm, live, all } // see .ai/contexts/cli-session-state.md ("Conversation continuations") -async function resolveResumeSession(session, { automatic = false, api, confirm } = {}) { - if (!session || session.type === 'terminal' || session.remoteAlias || !api.getSessionContinuations) return session; +async function resolveResumeSession(session, { automatic = false, api, confirm, resolutions } = {}) { + if (!session || session.type === 'terminal' || session.remoteAlias) return session; let result; - try { result = await api.getSessionContinuations(session.sessionId); } - catch { confirm('Could not check this conversation for continuations. Try opening it again after indexing.'); return null; } + try { + if (automatic && resolutions) { + if (!resolutions.has(session.sessionId)) resolutions.set(session.sessionId, api.getSessionContinuations(session.sessionId)); + result = await resolutions.get(session.sessionId); + } else result = await api.getSessionContinuations(session.sessionId); + } catch { + if (automatic) return null; + return confirm('Could not check this conversation for continuations.\n\nOpen the original session ' + session.sessionId + '?') ? session : null; + } + const candidates = result?.candidates || []; + const listing = candidates.map(c => c.sessionId + ' (last activity: ' + (c.modified || 'unknown') + ')').join('\n'); if (!result || result.unresolved) { - const candidates = (result?.candidates || []).map(c => `${c.sessionId} (last activity: ${c.modified || 'unknown'})`).join('\n'); - confirm('This conversation has an unresolved continuation (cycle, missing transcript or scan limit).\n' - + candidates + '\nTry again after indexing or open a candidate from the sidebar.'); - return null; + if (automatic) return null; + return confirm('This conversation has an unresolved continuation (cycle, missing transcript or malformed continuation record).\n' + + listing + '\nYou can retry after indexing or open a candidate from the sidebar.\n\nOpen the original session ' + session.sessionId + '?') ? session : null; } - const candidates = result.candidates || []; if (!candidates.length) return session; - if (automatic && candidates.length === 1) return { ...session, sessionId: candidates[0].sessionId }; - const listing = candidates.map(c => `${c.sessionId} (last activity: ${c.modified || 'unknown'})`).join('\n'); + if (automatic) return candidates.length === 1 ? { ...session, sessionId: candidates[0].sessionId } : null; for (const candidate of candidates) { - if (confirm(`This conversation continued under another id:\n${listing}\n\nOpen ${candidate.sessionId}?`)) { + if (confirm('This conversation continued under another id:\n' + listing + '\n\nOpen ' + candidate.sessionId + '?')) { return { ...session, sessionId: candidate.sessionId }; } } - return null; + return confirm('No continuation selected.\n\nOpen the original session ' + session.sessionId + '?') ? session : null; } if (typeof module !== 'undefined' && module.exports) { diff --git a/read-session-file.js b/read-session-file.js index 9ba72dbc9..4426c4a97 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -201,7 +201,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let entry; try { entry = JSON.parse(line); } catch { if (line === lines.at(-1) && !content.endsWith('\n')) incompleteTail = true; - else continuationUnresolved = true; + if (line.includes('continued-in')) continuationUnresolved = true; continue; } const continuation = continuationId(entry, fileBase); @@ -300,7 +300,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { modified: lastTimestamp || stat.mtime.toISOString(), fileMtime: stat.mtime.toISOString(), messageCount, textContent, slug, scheduleSlug, customTitle, aiTitle, - continuationIndex: JSON.stringify({ ids: [...continuationIds], bytes: incompleteTail ? Buffer.byteLength(content.slice(0, content.lastIndexOf('\n') + 1)) : stat.size, complete: !incompleteTail, mtime: stat.mtime.toISOString(), unresolved: continuationUnresolved }), + continuationIndex: JSON.stringify({ format: 2, ids: [...continuationIds], bytes: incompleteTail ? Buffer.byteLength(content.slice(0, content.lastIndexOf('\n') + 1)) : stat.size, complete: !incompleteTail, mtime: stat.mtime.toISOString(), unresolved: continuationUnresolved }), bridgeSessionId, entrypoint: typedInTerminal ? 'cli' : (entrypoint ?? ''), dailyMetrics, diff --git a/session-cache.js b/session-cache.js index 156302978..f7c191037 100644 --- a/session-cache.js +++ b/session-cache.js @@ -20,7 +20,7 @@ let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSes let deleteSearchFolder, deleteSearchSession, upsertSearchEntries; let setFolderMeta, getFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName; let isInitialScanComplete, setInitialScanComplete; -let getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession; +let getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession, setCachedContinuationIndex; let entrypointBackfill = null; function init(ctx) { @@ -51,6 +51,7 @@ function init(ctx) { setInitialScanComplete = ctx.db.setInitialScanComplete; getCachedMissingEntrypoint = ctx.db.getCachedMissingEntrypoint; getCachedSession = ctx.db.getCachedSession; + setCachedContinuationIndex = ctx.db.setCachedContinuationIndex; setCachedEntrypoints = ctx.db.setCachedEntrypoints; entrypointBackfill = null; } @@ -238,6 +239,7 @@ function refreshFolder(folder, opts = {}) { filesToScan = enumerateSessionFiles(folderPath); } + let continuationBytesLeft = 1024 * 1024; const currentIds = new Set(); let changed = false; @@ -288,10 +290,16 @@ function refreshFolder(folder, opts = {}) { // EXISTING -- header-only refresh. const h = readSessionDisplayHeader(filePath, { parentSessionId }); if (h) { + let continuationIndex = cachedEntry.continuationIndex; + if (parentSessionId || cachedEntry.parentSessionId) continuationIndex = null; + else if (continuationBytesLeft) { + continuationIndex = scanContinuationIndex(filePath, cachedEntry.sessionId, continuationIndex, continuationBytesLeft); + continuationBytesLeft = 0; + } // Merge: keep cached body/messageCount/created, overlay fresh display fields. const merged = { ...cachedEntry, - continuationIndex: scanContinuationIndex(filePath, cachedEntry.sessionId, cachedEntry.continuationIndex), + continuationIndex, folder, projectPath, summary: h.summary || cachedEntry.summary, firstPrompt: h.firstPrompt || cachedEntry.firstPrompt, @@ -1071,32 +1079,25 @@ function populateCacheViaWorker() { return populatePromise; } -async function resolveSessionContinuations(sessionId) { - let scanBudget = 128 * 1024 * 1024; +async function resolveSessionContinuations(sessionId, { chunkBytes = 1024 * 1024 } = {}) { + if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 1) throw new RangeError('chunkBytes must be a positive integer'); return resolveContinuations(sessionId, async id => { if (!validId(id)) return null; const row = getCachedSession?.(id); - if (!row) return null; + if (!row || row.parentSessionId) return null; const file = resolveJsonlPath(resolveFolderDir(row.folder), { ...row, folder: '.' }); const stat = fs.statSync(file); - let index; - try { index = JSON.parse(row.continuationIndex); } catch {} - if (!index?.complete || index.bytes !== stat.size || index.mtime !== stat.mtime.toISOString()) { - const start = index && index.bytes < stat.size ? index.bytes : 0; - let remaining = Math.min(scanBudget, stat.size - start); + let serialized = row.continuationIndex, index; + try { index = JSON.parse(serialized); } catch {} + if (index?.format !== 2 || !index.complete || index.bytes !== stat.size || index.mtime !== stat.mtime.toISOString()) { do { - const bytes = Math.min(remaining, 4 * 1024 * 1024); - remaining -= bytes; - scanBudget -= bytes; - const before = index?.bytes || 0; - row.continuationIndex = scanContinuationIndex(file, id, row.continuationIndex, bytes); - index = JSON.parse(row.continuationIndex); + serialized = scanContinuationIndex(file, id, serialized, chunkBytes); + index = JSON.parse(serialized); await new Promise(resolve => setImmediate(resolve)); - if (index.bytes === before) break; - } while (remaining > 0 && !index.complete); - upsertCachedSessions([row]); + } while (!index.complete && index.bytes < index.size); + setCachedContinuationIndex(id, serialized); } - return { index, modified: row.modified }; + return { index, modified: getCachedSession(id)?.modified || row.modified }; }); } diff --git a/session-continuations.js b/session-continuations.js index f45470202..8437ffbca 100644 --- a/session-continuations.js +++ b/session-continuations.js @@ -3,92 +3,92 @@ const fs = require('fs'); const validId = id => typeof id === 'string' && /^[A-Za-z0-9_-]{1,200}$/.test(id); function continuationId(entry, sessionId) { - return entry.type === 'continued-in' && entry.sessionId === sessionId && validId(entry.continuedInSessionId) + return entry?.type === 'continued-in' && entry.sessionId === sessionId && validId(entry.continuedInSessionId) ? entry.continuedInSessionId : null; } -function topLevelType(prefix) { - let depth = 0; - for (let i = 0; i < prefix.length; i++) { - const char = prefix[i]; - if (char === '{' || char === '[') depth++; - else if (char === '}' || char === ']') depth--; - else if (char === '"') { - const start = i++; - while (i < prefix.length && prefix[i] !== '"') { - if (prefix[i] === '\\') i++; - i++; - } - if (depth === 1) { - try { - if (JSON.parse(prefix.slice(start, i + 1)) === 'type') { - const value = prefix.slice(i + 1).match(/^\s*:\s*("(?:\\.|[^"\\])*")/); - if (value) return JSON.parse(value[1]); - } - } catch {} - } - } - } - return null; -} - -function scanContinuationIndex(file, sessionId, previous, maxBytes = 4 * 1024 * 1024) { - const stat = fs.statSync(file); +// see .ai/contexts/session-cache.md ("Continuation index") +function scanContinuationIndex(file, sessionId, previous, maxBytes = 1024 * 1024) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new RangeError('maxBytes must be a positive integer'); + const stat = fs.statSync(file), mtime = stat.mtime.toISOString(); let index; try { index = JSON.parse(previous); } catch {} - const mtime = stat.mtime.toISOString(); - if (index?.complete && index.bytes === stat.size && index.mtime === mtime) return previous; - const append = index && index.bytes < stat.size; - const ids = new Set(append ? index.ids : []); - let position = append ? index.bytes : 0; - const end = Math.min(stat.size, position + maxBytes); const fd = fs.openSync(file, 'r'); - const buffer = Buffer.alloc(64 * 1024); - let pending = Buffer.alloc(0), oversized = append ? !!index.skipLine : false, unresolved = append ? !!index.unresolved : false; - let consumed = position; + const tailAt = position => { + const tail = Buffer.alloc(Math.min(64, position)); + fs.readSync(fd, tail, 0, tail.length, position - tail.length); + return tail.toString('hex'); + }; try { + const reuse = index?.version === 2 && index.bytes <= stat.size && index.size <= stat.size + && (index.size !== stat.size || index.mtime === mtime) && index.tail === tailAt(index.bytes); + if (reuse && index.complete && index.bytes === stat.size && index.mtime === mtime) return previous; + const ids = new Set(reuse ? index.sealedIds : []); + let position = reuse ? index.bytes : 0; + let pending = reuse ? Buffer.from(index.pending || '', 'base64') : Buffer.alloc(0); + let oversized = reuse ? !!index.skipLine : false; + let invalid = reuse ? !!index.invalid : false; + let lineContinued = reuse ? !!index.lineContinued : false; + let patternTail = reuse ? index.patternTail || '' : ''; + const end = Math.min(stat.size, position + maxBytes), buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes)); + const consume = line => { + if (!line.length) return true; + try { + const entry = JSON.parse(line.toString('utf8')); + const id = continuationId(entry, sessionId); + if (id) ids.add(id); + if (entry?.type === 'continued-in' && entry.sessionId === sessionId && !id) invalid = true; + return true; + } catch { + if (line.includes('continued-in')) invalid = true; + return false; + } + }; + const segment = bytes => { + if (oversized) { + const text = patternTail + bytes.toString('latin1'); + lineContinued ||= text.includes('continued-in'); + patternTail = text.slice(-11); + } else { + pending = Buffer.concat([pending, bytes]); + if (pending.length > 1024 * 1024) { + lineContinued = pending.includes('continued-in'); + patternTail = pending.subarray(-11).toString('latin1'); + pending = Buffer.alloc(0); + oversized = true; + } + } + }; while (position < end) { const n = fs.readSync(fd, buffer, 0, Math.min(buffer.length, end - position), position); if (!n) break; position += n; - pending = Buffer.concat([pending, buffer.subarray(0, n)]); - let newline; - while ((newline = pending.indexOf(10)) !== -1) { - const line = pending.subarray(0, newline); - if (!oversized && line.length) { - try { - const entry = JSON.parse(line.toString('utf8')); - const id = continuationId(entry, sessionId); - if (id) ids.add(id); - if (entry.type === 'continued-in' && entry.sessionId === sessionId && !id) unresolved = true; - } catch { unresolved = true; } - } - pending = pending.subarray(newline + 1); - consumed = position - pending.length; - oversized = false; - } - if (pending.length > 1024 * 1024) { - if (!oversized) { - const type = topLevelType(pending.subarray(0, 65536).toString('utf8')); - if (!type || type === 'continued-in') unresolved = true; - } + let start = 0, newline; + while ((newline = buffer.indexOf(10, start)) !== -1 && newline < n) { + segment(buffer.subarray(start, newline)); + if (oversized) invalid ||= lineContinued; + else consume(pending); pending = Buffer.alloc(0); - oversized = true; - consumed = position; + oversized = false; + lineContinued = false; + patternTail = ''; + start = newline + 1; } + segment(buffer.subarray(start, n)); } - if (position === stat.size && pending.length && !oversized) { - try { - const entry = JSON.parse(pending.toString('utf8')); - const id = continuationId(entry, sessionId); - if (id) ids.add(id); - if (entry.type === 'continued-in' && entry.sessionId === sessionId && !id) unresolved = true; - consumed = position; - } catch {} + const sealedIds = [...ids]; + let tailValid = false, tailUnresolved = false; + if (position === stat.size && pending.length) { + const before = invalid; + tailValid = consume(pending); + tailUnresolved = invalid && !before; + invalid = before; } - if (oversized) consumed = position; + return JSON.stringify({ format: 2, version: 2, ids: [...ids], sealedIds, bytes: position, + complete: position === stat.size && (!pending.length || tailValid || oversized), size: stat.size, mtime, + unresolved: invalid || tailUnresolved || (oversized && lineContinued), invalid, + skipLine: oversized, lineContinued, patternTail, pending: pending.toString('base64'), tail: tailAt(position) }); } finally { fs.closeSync(fd); } - return JSON.stringify({ ids: [...ids], bytes: consumed, complete: consumed === stat.size, mtime, unresolved, skipLine: oversized }); } async function resolveContinuations(sessionId, getNode, { maxDepth = 32, maxNodes = 128 } = {}) { @@ -101,7 +101,7 @@ async function resolveContinuations(sessionId, getNode, { maxDepth = 32, maxNode try { node = await getNode(id); } catch { unresolved = true; return; } if (!node) { unresolved = true; return; } const index = node.index; - if (!index?.complete || index.unresolved) unresolved = true; + if (index?.unresolved) unresolved = true; const children = index?.ids || []; if (!children.length) { if (id !== sessionId) candidates.set(id, { sessionId: id, modified: node.modified || null }); diff --git a/test/canary-cli-continuations.test.js b/test/canary-cli-continuations.test.js deleted file mode 100644 index dbe1ca37c..000000000 --- a/test/canary-cli-continuations.test.js +++ /dev/null @@ -1,49 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -test('CANARY live: bounded CLI transcript sampling retains the continued-in field names', { timeout: 9000 }, async t => { - const projects = path.join(os.homedir(), '.claude', 'projects'); - let files = []; - try { - const folders = fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory()).slice(0, 32); - for (const folder of folders) { - const dir = path.join(projects, folder.name); - files.push(...fs.readdirSync(dir).filter(name => name.endsWith('.jsonl')).slice(0, 4).map(name => path.join(dir, name))); - } - } catch { t.skip('CLI transcript directory unavailable; observed-format fixture still runs'); return; } - const deadline = Date.now() + 3000; - let budget = 8 * 1024 * 1024, checked = 0; - for (const file of files) { - if (budget <= 0 || Date.now() >= deadline) break; - let pending = ''; - const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024, encoding: 'utf8' }); - try { - for await (const chunk of stream) { - budget -= Buffer.byteLength(chunk); - pending += chunk; - const lines = pending.split('\n'); - pending = lines.pop(); - for (const line of lines) { - if (!line.includes('continued-in')) continue; - let entry; - try { entry = JSON.parse(line); } catch { continue; } - if (entry.type !== 'continued-in') continue; - assert.equal(typeof entry.sessionId, 'string', 'CLI continued-in must carry sessionId'); - assert.equal(typeof entry.continuedInSessionId, 'string', 'CLI continued-in must carry continuedInSessionId'); - assert.equal(typeof entry.timestamp, 'string', 'CLI continued-in must carry timestamp'); - checked++; - } - if (pending.length > 1024 * 1024) pending = ''; - if (checked || budget <= 0 || Date.now() >= deadline) break; - } - } catch (err) { - if (err.code === 'ERR_ASSERTION') throw err; - } finally { stream.destroy(); } - if (checked) break; - } - if (!checked) t.skip('no continued-in record within 8 MiB / 3 s bounded sample; CLI drift not measured'); -}); diff --git a/test/continuation-regressions.test.js b/test/continuation-regressions.test.js new file mode 100644 index 000000000..16eed9f01 --- /dev/null +++ b/test/continuation-regressions.test.js @@ -0,0 +1,218 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const cache = require('../session-cache'); +const { scanContinuationIndex } = require('../session-continuations'); +const { loadAppFunctions } = require('./app-source'); +const { setup } = require('./continuations-harness'); + +function append(h, text) { + const file = path.join(h.root, h.folder, 'old.jsonl'); + fs.appendFileSync(file, text); + return file; +} + +for (const [name, text] of [ + ['truncated tail', '{"type":"assistant","message":'], + ['malformed middle line', '{bad json}\n{}\n'], + ['oversized line', JSON.stringify({ padding: 'x'.repeat(2 * 1024 * 1024), type: 'assistant' }) + '\n'], +]) { + for (const mode of ['manual', 'restore']) { + test(`R2 ${name} without continued-in opens the original id on ${mode}`, { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }); + append(h, text); + if (name === 'malformed middle line') cache.refreshFolder(h.folder); + else delete h.rows.get('old').continuationIndex; + await (mode === 'manual' ? h.click() : h.restore()); + assert.deepEqual(h.spawned.map(call => call.id), ['old']); + assert.deepEqual(h.prompts, []); + }); + } +} + +test('R2 cold full scan tolerates malformed ordinary records', { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }); + append(h, '{bad json}\n{}\n'); + h.rows.clear(); + cache.refreshFolder(h.folder); + await h.click(); + assert.deepEqual(h.spawned.map(call => call.id), ['old']); +}); + +test('R2 upgrading a fresh legacy index repairs unrelated cached parse errors', { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }); + const file = append(h, '{bad json}\n{}\n'); + const stat = fs.statSync(file); + h.rows.get('old').continuationIndex = JSON.stringify({ ids: [], bytes: stat.size, + complete: true, mtime: stat.mtime.toISOString(), unresolved: true }); + await h.click(); + assert.deepEqual(h.spawned.map(call => call.id), ['old']); + assert.deepEqual(h.prompts, []); +}); + +test('R2 legacy scan completes beyond its injectable pacing budget and yields', { timeout: 9000 }, async t => { + const h = setup(t, { old: [], new: [] }, { chunkBytes: 64 }); + append(h, JSON.stringify({ type: 'assistant', padding: 'x'.repeat(4096) }) + '\n' + + '{"type":"continued-in","sessionId":"old","continuedInSessionId":"new"}\n'); + delete h.rows.get('old').continuationIndex; + const read = t.mock.method(fs, 'readSync'); + let yielded = false; + const turn = new Promise(resolve => setImmediate(() => { yielded = true; resolve(); })); + await h.restore(); + await turn; + assert.deepEqual(h.spawned.map(call => call.id), ['new']); + assert.equal(yielded, true); + assert.ok(read.mock.calls.every(call => call.arguments[3] <= 64), 'injected budget bounds reads, not total scan progress'); + assert.equal(JSON.parse(h.rows.get('old').continuationIndex).complete, true); +}); + +for (const [name, graph] of [ + ['two continuations', { old: ['a', 'b'], a: [], b: [], other: [] }], + ['unresolved continuation', { old: ['missing'], other: [] }], +]) { + test(`R2 automatic restore holds ${name}, restores others and lists a nonblocking notice`, { timeout: 9000 }, async t => { + const savedEntries = [{ sessionId: 'old', active: true }, { sessionId: 'other' }]; + const h = setup(t, graph, { savedEntries }); + await h.restore(); + assert.deepEqual(h.prompts, []); + assert.deepEqual(h.spawned.map(call => call.id), ['other']); + assert.match(h.dom.window.notice, /old/); + assert.match(h.dom.window.notice, /sidebar.*choose/i); + assert.deepEqual(h.settings().openWorkingSet.map(item => item.sessionId), ['old', 'other']); + }); +} + +test('R2 automatic failed continuation lookup holds the entry without confirm', { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }); + h.ctx.api.getSessionContinuations = async () => { throw new Error('IPC unavailable'); }; + await h.restore(); + assert.deepEqual(h.prompts, []); + assert.deepEqual(h.spawned, []); + assert.match(h.dom.window.notice, /old/); +}); + +test('R2 a missing continuation API cannot silently bypass the resume guard', { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }); + delete h.ctx.api.getSessionContinuations; + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.deepEqual(h.prompts, []); + assert.match(h.dom.window.notice, /old/); +}); + +for (const graph of [{ old: ['a', 'b'], a: [], b: [] }, { old: ['missing'] }]) { + test(`R2 manual open can explicitly select the original id (${Object.keys(graph).length} nodes)`, { timeout: 9000 }, async t => { + const h = setup(t, graph, { answer: message => /Open the original session old\?/.test(message) }); + await h.click(); + assert.deepEqual(h.spawned.map(call => call.id), ['old']); + assert.match(h.prompts.at(-1), /Open the original session old\?/); + }); +} + +test('R2 header refresh skips subagents and bounds synchronous continuation reads', { timeout: 9000 }, t => { + const h = setup(t, { old: [] }); + const subdir = path.join(h.root, h.folder, 'old', 'subagents'); + fs.mkdirSync(subdir, { recursive: true }); + const subfile = path.join(subdir, 'agent-a.jsonl'); + fs.writeFileSync(subfile, JSON.stringify({ type: 'user', isSidechain: true, agentId: 'a', message: { content: 'subagent' } }) + '\n'); + cache.refreshFolder(h.folder); + const file = append(h, JSON.stringify({ padding: 'x'.repeat(5 * 1024 * 1024), type: 'assistant' }) + '\n'); + fs.appendFileSync(subfile, JSON.stringify({ padding: 'x'.repeat(5 * 1024 * 1024), type: 'assistant' }) + '\n'); + fs.utimesSync(file, new Date('2030-01-01'), new Date('2030-01-01')); + fs.utimesSync(subfile, new Date('2030-01-01'), new Date('2030-01-01')); + const descriptors = new Map(), bytes = new Map(); + const open = fs.openSync, read = fs.readSync; + t.mock.method(fs, 'openSync', (...args) => { const fd = open(...args); descriptors.set(fd, args[0]); return fd; }); + t.mock.method(fs, 'readSync', (...args) => { + const n = read(...args), name = descriptors.get(args[0]); + bytes.set(name, (bytes.get(name) || 0) + n); + return n; + }); + cache.refreshFolder(h.folder, { files: new Set([path.join('old', 'subagents', 'agent-a.jsonl'), 'old.jsonl']) }); + assert.ok(bytes.get(file) <= 1024 * 1024 + 256 * 1024 + 128, 'top-level header plus at most 1 MiB continuation work'); + assert.ok(bytes.get(subfile) <= 256 * 1024, 'subagents receive only the display-header read'); +}); + +test('R2 one refresh shares a 1 MiB continuation budget across changed parents', { timeout: 9000 }, t => { + const h = setup(t, { old: [], other: [] }); + for (const id of ['old', 'other']) { + const file = path.join(h.root, h.folder, id + '.jsonl'); + fs.appendFileSync(file, JSON.stringify({ padding: 'x'.repeat(2 * 1024 * 1024), type: 'assistant' }) + '\n'); + fs.utimesSync(file, new Date('2030-01-01'), new Date('2030-01-01')); + } + const read = t.mock.method(fs, 'readSync'); + cache.refreshFolder(h.folder, { files: new Set(['old.jsonl', 'other.jsonl']) }); + const bytes = read.mock.calls.reduce((sum, call) => sum + call.result, 0); + assert.ok(bytes <= 1024 * 1024 + 2 * 256 * 1024 + 128, 'one continuation pacing budget per refresh'); +}); + +test('R2 yielded continuation indexing preserves fresher display fields', { timeout: 9000 }, async t => { + const h = setup(t, { old: [], new: [] }, { chunkBytes: 64 }); + append(h, JSON.stringify({ padding: 'x'.repeat(4096), type: 'assistant' }) + '\n' + + '{"type":"continued-in","sessionId":"old","continuedInSessionId":"new"}\n'); + delete h.rows.get('old').continuationIndex; + const resolving = h.ctx.api.getSessionContinuations('old'); + const fresh = { ...h.rows.get('old'), summary: 'new summary', modified: '2030-01-01', fileMtime: '2030-01-02' }; + h.db.upsertCachedSessions([fresh]); + await resolving; + const row = h.rows.get('old'); + assert.equal(row.summary, fresh.summary); + assert.equal(row.modified, fresh.modified); + assert.equal(row.fileMtime, fresh.fileMtime); + assert.deepEqual(JSON.parse(row.continuationIndex).ids, ['new']); +}); + +test('R2 append indexing rejects a rewritten indexed tail', { timeout: 9000 }, t => { + const h = setup(t, { old: [], a: [], b: [] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + const line = id => JSON.stringify({ type: 'continued-in', sessionId: 'old', continuedInSessionId: id }) + '\n'; + fs.writeFileSync(file, line('a')); + const previous = scanContinuationIndex(file, 'old'); + fs.writeFileSync(file, line('b') + '{}\n'); + const current = JSON.parse(scanContinuationIndex(file, 'old', previous)); + assert.deepEqual(current.ids, ['b']); +}); + +test('R2 small chunks retain split continuation byte patterns in oversized records', { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }, { chunkBytes: 65536 }); + const prefix = '{"padding":"', suffix = '","type":"continued-in",broken}\n'; + const pad = 2 * 1024 * 1024 + 65530 - prefix.length - suffix.indexOf('continued-in'); + append(h, prefix + 'x'.repeat(pad) + suffix); + delete h.rows.get('old').continuationIndex; + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.deepEqual(h.prompts, []); + assert.match(h.dom.window.notice, /old/); +}); + +test('R2 startup resolves an active continued session only once', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }); + const lookup = t.mock.method(h.ctx.api, 'getSessionContinuations'); + vm.runInContext("activeSessionId = 'old';", h.ctx); + const source = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8'); + const end = source.indexOf('\n// Live-reload sidebar'); + const start = source.lastIndexOf('loadProjects().then(', end); + if (source.includes('async function restoreStartupSessions(')) { + loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions'] }); + } + h.ctx.restoreWorkingSet = h.restore; + h.ctx.restoreAgentsViewAtStartup = () => {}; + let startup; + h.ctx.loadProjects = () => ({ then: callback => { startup = callback(); } }); + vm.runInContext(source.slice(start, end), h.ctx); + await startup; + assert.deepEqual(h.spawned.map(call => call.id), ['new']); + assert.deepEqual(lookup.mock.calls.map(call => call.arguments[0]), ['old']); +}); + +test('R2 restore checks final liveness automatically before spawning', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }, { live: { new: { pid: 7 } }, answer: true }); + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.deepEqual(h.prompts, []); + assert.match(h.dom.window.notice, /pid 7/); +}); diff --git a/test/continuations-harness.js b/test/continuations-harness.js new file mode 100644 index 000000000..579f7d149 --- /dev/null +++ b/test/continuations-harness.js @@ -0,0 +1,87 @@ +'use strict'; + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { loadAppFunctions } = require('./app-source'); +const cache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); + +function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-continuations-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const folder = encodeProjectPath(root); + fs.mkdirSync(path.join(root, folder)); + for (const [id, children] of Object.entries(graph)) { + const records = [ + { type: 'user', cwd: root, timestamp: '2026-10-09T12:00:00Z', message: { content: 'hello' } }, + ...children.map(child => ({ type: 'continued-in', sessionId: id, continuedInSessionId: child, timestamp: '2026-10-09T12:01:00Z' })), + { type: 'assistant', timestamp: '2026-10-09T12:02:00Z', message: { content: 'after continuation' } }, + ]; + fs.writeFileSync(path.join(root, folder, id + '.jsonl'), records.map(JSON.stringify).join('\n') + '\n'); + } + const rows = new Map(), folderMeta = new Map(); + const db = { + getSetting: () => ({}), getAllFolderMeta: () => folderMeta, getFolderMeta: id => folderMeta.get(id), getAllMeta: () => new Map(), + getCachedByFolder: () => [...rows.values()], getCachedSession: id => rows.has(id) ? { ...rows.get(id) } : undefined, + upsertCachedSessions: entries => entries.forEach(row => rows.set(row.sessionId, row)), + setCachedContinuationIndex(id, index) { if (rows.has(id)) rows.set(id, { ...rows.get(id), continuationIndex: index }); }, + setFolderMeta(id, projectPath, mtime) { folderMeta.set(id, { projectPath, mtime }); }, getMeta() {}, setName() {}, upsertSearchEntries() {}, replaceSessionMetrics() {}, + deleteCachedFolder() {}, deleteCachedSession() {}, deleteSearchFolder() {}, deleteSearchSession() {}, + }; + cache.init({ PROJECTS_DIR: root, db, activeSessions: new Map(), getMainWindow: () => null, log: console }); + cache.refreshFolder(folder); + const dom = new JSDOM('', { runScripts: 'outside-only', url: 'http://localhost' }); + t.after(() => dom.window.close()); + const ctx = dom.getInternalVMContext(); + const settingsFile = path.join(root, 'settings.json'); + const saved = savedEntries || [{ sessionId: 'old', projectPath: root, active: true }]; + fs.writeFileSync(settingsFile, JSON.stringify({ openWorkingSet: saved })); + const spawned = [], prompts = []; + dom.window.confirm = message => { prompts.push(message); return typeof answer === 'function' ? answer(message) : answer; }; + dom.window.api = { + getSetting: async () => JSON.parse(fs.readFileSync(settingsFile, 'utf8')), + setSetting: async (_, value) => fs.writeFileSync(settingsFile, JSON.stringify(value)), + getSessionContinuations: async id => typeof cache.resolveSessionContinuations === 'function' + ? cache.resolveSessionContinuations(id, { chunkBytes }) : { candidates: [], unresolved: false }, + getSessionLiveElsewhere: async id => live[id] || null, + getSessionsLiveElsewhere: async ids => Object.fromEntries(ids.filter(id => live[id]).map(id => [id, live[id]])), + openTerminal: async (id, project, isNew, options) => { spawned.push({ id, project, options }); return { ok: true }; }, + }; + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/resume-guard.js'), 'utf8'), ctx); + vm.runInContext(` + var openSessions = new Map(), sessionMap = new Map(); + var activeSessionId = null, restoringWorkingSet = true, restorePlanner = null; + var sessionOpenedOutsideRestore = false, _persistChain = Promise.resolve(); + var RESTORE_STAGGER_MS = 0; + function createTerminalEntry(session) { + const entry = { session, terminal: { write() {} }, initialSize: {} }; + openSessions.set(session.sessionId, entry); return entry; + } + function showSession(id) { activeSessionId = id; } + function destroySession(id) { openSessions.delete(id); } + async function resolveDefaultSessionOptions() { return {}; } + function syncPtySizeAfterOpen() {} function setSessionSandboxed() {} + function forgetSessionExit() {} function beginPtyOpen() {} function settlePtyOpen() {} + function schedulePersistWorkingSet() {} function pollActiveSessions() {} + function cleanDisplayName(value) { return value || ''; } + `, ctx); + ctx.rows = [...rows.values()]; + vm.runInContext('rows.forEach(row => sessionMap.set(row.sessionId, row));', ctx); + dom.window.showRestoreNotice = (_, text) => { dom.window.notice = text; }; + const app = loadAppFunctions(ctx, { + declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight'], + functions: ['runRestore', 'openSession', 'persistWorkingSet', 'pendingRestoreEntries', 'showLiveElsewhereNotice'], + }); + return { + spawned, prompts, rows, root, db, folder, ctx, + app, dom, saved, + restore: async () => { await app.runRestore(saved); await app.persistWorkingSet(); }, + click: async () => { await app.openSession(rows.get('old')); await app.persistWorkingSet(); }, + settings: () => JSON.parse(fs.readFileSync(settingsFile, 'utf8')), + }; +} + +module.exports = { setup }; diff --git a/test/open-session-terminal.test.js b/test/open-session-terminal.test.js index 46749a6e6..e3bf79bec 100644 --- a/test/open-session-terminal.test.js +++ b/test/open-session-terminal.test.js @@ -41,6 +41,7 @@ async function withHarness(setup, fn) { schedulePersistWorkingSet: () => {}, pollActiveSessions: () => {}, api: { + getSessionContinuations: async () => ({ candidates: [], unresolved: false, continued: false }), openTerminal: async (sessionId, projectPath, isNew, sessionOptions, initialSize) => { calls.openTerminal.push({ sessionId, projectPath, isNew, sessionOptions, initialSize }); return { ok: true }; diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index afc98aff8..09611d522 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -88,6 +88,7 @@ function setup() { let exitHandler = null; const h = { whileOpening: null }; window.api = { + getSessionContinuations: async () => ({ candidates: [], unresolved: false, continued: false }), onProcessExited: (cb) => { exitHandler = cb; }, openTerminal: async () => { if (h.whileOpening) h.whileOpening(); diff --git a/test/restore-continuations.test.js b/test/restore-continuations.test.js index 63c266a66..7d95c1534 100644 --- a/test/restore-continuations.test.js +++ b/test/restore-continuations.test.js @@ -3,85 +3,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -const os = require('node:os'); const path = require('node:path'); const vm = require('node:vm'); -const { JSDOM } = require('jsdom'); -const { loadAppFunctions } = require('./app-source'); const cache = require('../session-cache'); -const { encodeProjectPath } = require('../encode-project-path'); - -function setup(t, graph, { live = {}, answer = false } = {}) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-continuations-')); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); - const folder = encodeProjectPath(root); - fs.mkdirSync(path.join(root, folder)); - for (const [id, children] of Object.entries(graph)) { - const records = [ - { type: 'user', cwd: root, timestamp: '2026-10-09T12:00:00Z', message: { content: 'hello' } }, - ...children.map(child => ({ type: 'continued-in', sessionId: id, continuedInSessionId: child, timestamp: '2026-10-09T12:01:00Z' })), - { type: 'assistant', timestamp: '2026-10-09T12:02:00Z', message: { content: 'after continuation' } }, - ]; - fs.writeFileSync(path.join(root, folder, id + '.jsonl'), records.map(JSON.stringify).join('\n') + '\n'); - } - const rows = new Map(); - const db = { - getSetting: () => ({}), getAllFolderMeta: () => new Map(), getAllMeta: () => new Map(), - getCachedByFolder: () => [...rows.values()], getCachedSession: id => rows.get(id), - upsertCachedSessions: entries => entries.forEach(row => rows.set(row.sessionId, row)), - setFolderMeta() {}, getMeta() {}, setName() {}, upsertSearchEntries() {}, replaceSessionMetrics() {}, - deleteCachedFolder() {}, deleteCachedSession() {}, deleteSearchFolder() {}, deleteSearchSession() {}, - }; - cache.init({ PROJECTS_DIR: root, db, activeSessions: new Map(), getMainWindow: () => null, log: console }); - cache.refreshFolder(folder); - const dom = new JSDOM('', { runScripts: 'outside-only' }); - t.after(() => dom.window.close()); - const ctx = dom.getInternalVMContext(); - const settingsFile = path.join(root, 'settings.json'); - const saved = [{ sessionId: 'old', projectPath: root, active: true }]; - fs.writeFileSync(settingsFile, JSON.stringify({ openWorkingSet: saved })); - const spawned = [], prompts = []; - dom.window.confirm = message => { prompts.push(message); return typeof answer === 'function' ? answer(message) : answer; }; - dom.window.api = { - getSetting: async () => JSON.parse(fs.readFileSync(settingsFile, 'utf8')), - setSetting: async (_, value) => fs.writeFileSync(settingsFile, JSON.stringify(value)), - getSessionContinuations: async id => typeof cache.resolveSessionContinuations === 'function' - ? cache.resolveSessionContinuations(id) : { candidates: [], unresolved: false }, - getSessionLiveElsewhere: async id => live[id] || null, - getSessionsLiveElsewhere: async ids => Object.fromEntries(ids.filter(id => live[id]).map(id => [id, live[id]])), - openTerminal: async (id, project, isNew, options) => { spawned.push({ id, project, options }); return { ok: true }; }, - }; - vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/resume-guard.js'), 'utf8'), ctx); - vm.runInContext(` - var openSessions = new Map(), sessionMap = new Map(); - var activeSessionId = null, restoringWorkingSet = true, restorePlanner = null; - var sessionOpenedOutsideRestore = false, _persistChain = Promise.resolve(); - var RESTORE_STAGGER_MS = 0; - function createTerminalEntry(session) { - const entry = { session, terminal: { write() {} }, initialSize: {} }; - openSessions.set(session.sessionId, entry); return entry; - } - function showSession(id) { activeSessionId = id; } - function destroySession(id) { openSessions.delete(id); } - async function resolveDefaultSessionOptions() { return {}; } - function syncPtySizeAfterOpen() {} function setSessionSandboxed() {} - function forgetSessionExit() {} function beginPtyOpen() {} function settlePtyOpen() {} - function schedulePersistWorkingSet() {} function pollActiveSessions() {} - function showLiveElsewhereNotice() {} - `, ctx); - ctx.rows = [...rows.values()]; - vm.runInContext('rows.forEach(row => sessionMap.set(row.sessionId, row));', ctx); - const app = loadAppFunctions(ctx, { - declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight'], - functions: ['runRestore', 'openSession', 'persistWorkingSet', 'pendingRestoreEntries'], - }); - return { - spawned, prompts, rows, root, db, folder, ctx, - restore: async () => { await app.runRestore(saved); await app.persistWorkingSet(); }, - click: async () => { await app.openSession(rows.get('old')); await app.persistWorkingSet(); }, - settings: () => JSON.parse(fs.readFileSync(settingsFile, 'utf8')), - }; -} + +const { setup } = require('./continuations-harness'); for (const [name, graph, expected] of [ ['one continuation', { old: ['new'], new: [] }, 'new'], @@ -104,16 +30,15 @@ for (const [name, graph] of [ ['a cycle', { old: ['mid'], mid: ['old'] }], ['a missing continuation', { old: ['missing'] }], ]) { - test(`restore asks before ${name} and spawns nothing on dismissal`, { timeout: 9000 }, async t => { + test(`restore holds ${name} and spawns nothing on dismissal`, { timeout: 9000 }, async t => { const h = setup(t, graph); await h.restore(); assert.deepEqual(h.spawned, []); - assert.ok(h.prompts.length > 0); + assert.equal(h.prompts.length, 0); + assert.match(h.dom.window.notice, /sidebar/); assert.equal(h.settings().openWorkingSet[0].sessionId, 'old'); if (name === 'two terminal continuations') { - assert.match(h.prompts[0], /a/); - assert.match(h.prompts[0], /b/); - assert.match(h.prompts[0], /2026-10-09T12:02:00Z/); + assert.match(h.dom.window.notice, /old/); } }); } @@ -127,7 +52,7 @@ test('manual click offers the continuation before opening it', { timeout: 9000 } test('choosing the second continuation opens only that candidate and persists it', { timeout: 9000 }, async t => { const h = setup(t, { old: ['a', 'b'], a: [], b: [] }, { answer: message => /Open b\?/.test(message) }); - await h.restore(); + await h.click(); assert.deepEqual(h.spawned.map(call => call.id), ['b']); assert.equal(h.settings().openWorkingSet[0].sessionId, 'b'); }); @@ -142,14 +67,15 @@ test('a fresh continuation index resolves without reading transcript contents ag assert.equal(full.mock.callCount(), 0); }); -test('a malformed continuation target asks rather than resuming the old id', { timeout: 9000 }, async t => { +test('a malformed continuation target holds rather than resuming the old id', { timeout: 9000 }, async t => { const h = setup(t, { old: [] }); const file = path.join(h.root, h.folder, 'old.jsonl'); fs.appendFileSync(file, '{"type":"continued-in","sessionId":"old","continuedInSessionId":"../escape"}\n'); delete h.rows.get('old').continuationIndex; await h.restore(); assert.deepEqual(h.spawned, []); - assert.ok(h.prompts.length); + assert.equal(h.prompts.length, 0); + assert.match(h.dom.window.notice, /sidebar/); }); test('restore attaches to the continued background job', { timeout: 9000 }, async t => { @@ -208,13 +134,14 @@ test('a partial continuation recovers once the CLI finishes its record', { timeo assert.deepEqual(h.spawned.map(call => call.id), ['new']); }); -test('depth limit asks without resuming the original id', { timeout: 9000 }, async t => { +test('depth limit holds without resuming the original id', { timeout: 9000 }, async t => { const graph = { old: ['n0'] }; for (let i = 0; i < 35; i++) graph['n' + i] = i === 34 ? [] : ['n' + (i + 1)]; const h = setup(t, graph); await h.restore(); assert.deepEqual(h.spawned, []); - assert.ok(h.prompts.length); + assert.equal(h.prompts.length, 0); + assert.match(h.dom.window.notice, /sidebar/); }); test('refresh indexes a continuation appended beyond the display header', { timeout: 9000 }, async t => { diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index 77fb7057c..4a72e44cf 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -47,6 +47,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { const liveQueries = { single: [], batch: [] }; dom.window.api = { + getSessionContinuations: async () => ({ candidates: [], unresolved: false, continued: false }), getSetting: async (key) => JSON.parse(JSON.stringify(settings[key] || null)), setSetting: async (key, value) => { settings[key] = JSON.parse(JSON.stringify(value)); }, getSessionLiveElsewhere: async (id) => { liveQueries.single.push(id); return liveIds.includes(id) ? LIVE : null; }, diff --git a/test/restore-pending-persist.test.js b/test/restore-pending-persist.test.js index ae5e0f5d0..1eb64eca4 100644 --- a/test/restore-pending-persist.test.js +++ b/test/restore-pending-persist.test.js @@ -23,6 +23,7 @@ function setup() { vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/resume-guard.js'), 'utf8'), ctx); const settings = { global: { openWorkingSet: SAVED } }; dom.window.api = { + getSessionContinuations: async () => ({ candidates: [], unresolved: false, continued: false }), getSetting: async (key) => JSON.parse(JSON.stringify(settings[key] || null)), setSetting: async (key, value) => { settings[key] = JSON.parse(JSON.stringify(value)); }, }; diff --git a/test/resume-guard.test.js b/test/resume-guard.test.js index e452cd813..074346a53 100644 --- a/test/resume-guard.test.js +++ b/test/resume-guard.test.js @@ -131,11 +131,6 @@ test('the reload path resumes the remembered session as an automatic resume', () /if \(activeSessionId && !openSessions\.has\(activeSessionId\)\) \{\s*const session = sessionMap\.get\(activeSessionId\);\s*if \(session\) await openSession\(session, undefined, \{ automatic: true \}\);/); }); -test('the working-set restore resumes each session as an automatic resume', () => { - const body = functionBody(APP_SRC, 'async function runRestore('); - assert.match(body, /openSession\(s, undefined, \{ automatic: true, live, continuationResolved: true, allowBgAttach: s.sessionId !== originalId \}\)/); - assert.doesNotMatch(body, /openSession\(s\)/); -}); test('index.html loads the guard before app.js, preload exposes the check, main answers it', () => { const html = read('public/index.html'); diff --git a/test/schedule-live-resume.test.js b/test/schedule-live-resume.test.js index d68a9cfb1..e857dd681 100644 --- a/test/schedule-live-resume.test.js +++ b/test/schedule-live-resume.test.js @@ -64,6 +64,7 @@ function setup(t, { sandbox = false } = {}) { const settings = { global: {} }; const session = { sessionId: SID, projectPath: dir, name: 'Scheduled task' }; dom.window.api = { + getSessionContinuations: async () => ({ candidates: [], unresolved: false, continued: false }), getSessionLiveElsewhere: (id) => handlers.get('session-live-elsewhere')(null, id), getSessionsLiveElsewhere: (ids) => handlers.get('sessions-live-elsewhere')(null, ids), openTerminal: async (...args) => { openTerminalCalls.push(args); return { ok: true }; }, From 8f041c7caa374e6e7a17c4746af68e72a5883ed1 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 9 Oct 2026 19:46:43 +0200 Subject: [PATCH 5/7] fix(restore): drop a continuation whose transcript was never written when another exists --- .ai/contexts/session-cache.md | 13 +++++++ CHANGELOG.md | 2 +- docs/session-restore.md | 8 +++- session-cache.js | 11 +++++- session-continuations.js | 7 +++- test/restore-continuations.test.js | 59 ++++++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index d4d249e3e..476e214b0 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -1693,6 +1693,19 @@ with no total scan cap. Tests can inject smaller `chunkBytes`. Persistence uses `setCachedContinuationIndex`, which updates only that column, preserving display fields written by a concurrent refresh; it does not upsert an old row snapshot. +When a target has no cache row, resolution lazily inventories transcript filenames +recursively under the source session's projects root (or that host's mirror root). +This asynchronous directory inventory is shared within one resolution and reads +no transcript contents. A file found anywhere in that root remains unresolved +until indexed; resolution does not index it on demand. Directory read failures +or symbolic links that may hide files also keep absence unconfirmed. +Only a target absent from both the cache and this inventory is marked missing. +The graph drops that target if its parent has another existing continuation. +If every child is confirmed missing, that parent remains unresolved, including +inside a longer chain: silently resuming a discontinued branch is unsafe, and +manual opening already offers an explicit original-id choice. Cached rows with +missing or unreadable transcripts retain the existing unresolved behaviour. + Chunk indexes also carry `version:2`, `size`, `sealedIds`, a bounded base64 `pending` line, and the last 64 indexed bytes as hexadecimal `tail`. The tail is verified before reusing a cursor after growth; mismatch, shrink or a same-size diff --git a/CHANGELOG.md b/CHANGELOG.md index 50250c527..f1a065621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed -- Restoring a conversation that continued under another id now opens its continuation and saves the new id, including with large transcripts or unrelated damaged records. Multiple or unresolved continuations stay in the saved set with a non-blocking notice; open the old session to choose a continuation or explicitly reopen the original. (#518) +- Restoring a conversation that continued under another id now opens its continuation and saves the new id, including with large transcripts, unrelated damaged records or an extra continuation that never saved a transcript. Multiple or unresolved continuations stay in the saved set with a non-blocking notice; open the old session to choose a continuation or explicitly reopen the original. (#518) ## v0.0.91 — 2026-10-09 diff --git a/docs/session-restore.md b/docs/session-restore.md index c158d886f..34ee733a8 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -46,8 +46,12 @@ its previous launch. Sessions reopen one after another, half a second apart. If a conversation continued under another id, restore follows its continuation chain and saves the final id with the saved entry's project and active state. -One final id is selected automatically. Several final ids, a cycle, a missing -continuation transcript or a malformed continuation record hold the saved entry +One final id is selected automatically. A target absent from both the index and +the transcripts on disk is ignored when its parent has another existing +continuation. A transcript present on disk but not yet indexed still holds +restore until indexing catches up. If all of a node's targets are missing, +restore stays unresolved rather than reopening that node automatically. +Several final ids, a cycle or a malformed continuation record hold the saved entry without launching it. A non-blocking notice names the held conversation and asks you to open it from the sidebar to choose; other saved sessions continue restoring. Automatic restore never displays a continuation confirmation dialog. diff --git a/session-cache.js b/session-cache.js index f7c191037..4b46e4f58 100644 --- a/session-cache.js +++ b/session-cache.js @@ -1081,10 +1081,19 @@ function populateCacheViaWorker() { async function resolveSessionContinuations(sessionId, { chunkBytes = 1024 * 1024 } = {}) { if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 1) throw new RangeError('chunkBytes must be a positive integer'); + let diskEntries; return resolveContinuations(sessionId, async id => { if (!validId(id)) return null; const row = getCachedSession?.(id); - if (!row || row.parentSessionId) return null; + if (!row) { + const { alias } = parseFolderKey(getCachedSession?.(sessionId)?.folder || ''); + const root = alias === null ? PROJECTS_DIR : remoteRoots.get(alias); + diskEntries ||= fs.promises.readdir(root, { recursive: true, withFileTypes: true }); + const entries = await diskEntries; + return entries.some(entry => entry.isSymbolicLink() || (entry.isFile() && entry.name === id + '.jsonl')) + ? null : { missing: true }; + } + if (row.parentSessionId) return null; const file = resolveJsonlPath(resolveFolderDir(row.folder), { ...row, folder: '.' }); const stat = fs.statSync(file); let serialized = row.continuationIndex, index; diff --git a/session-continuations.js b/session-continuations.js index 8437ffbca..b4eca0bc7 100644 --- a/session-continuations.js +++ b/session-continuations.js @@ -100,6 +100,7 @@ async function resolveContinuations(sessionId, getNode, { maxDepth = 32, maxNode let node; try { node = await getNode(id); } catch { unresolved = true; return; } if (!node) { unresolved = true; return; } + if (node.missing) return false; const index = node.index; if (index?.unresolved) unresolved = true; const children = index?.ids || []; @@ -108,16 +109,18 @@ async function resolveContinuations(sessionId, getNode, { maxDepth = 32, maxNode } else { continued = true; visiting.add(id); + let allMissing = true; for (const child of children) { - await visit(child, depth + 1); + if (await visit(child, depth + 1) !== false) allMissing = false; if (nodes > maxNodes) break; } + if (allMissing) unresolved = true; visiting.delete(id); } visited.add(id); } if (!validId(sessionId)) return { candidates: [], unresolved: true, continued: false }; - await visit(sessionId, 0); + if (await visit(sessionId, 0) === false) unresolved = true; return { candidates: [...candidates.values()], unresolved, continued }; } diff --git a/test/restore-continuations.test.js b/test/restore-continuations.test.js index 7d95c1534..7f28c828e 100644 --- a/test/restore-continuations.test.js +++ b/test/restore-continuations.test.js @@ -9,6 +9,65 @@ const cache = require('../session-cache'); const { setup } = require('./continuations-harness'); +for (const children of [['new', 'missing'], ['missing', 'new']]) { + test(`R3 restore drops a disk-missing sibling (${children.join(', ')}) and persists the existing id`, { timeout: 9000 }, async t => { + const h = setup(t, { old: children, new: [] }); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['new']); + assert.deepEqual(h.prompts, []); + assert.deepEqual(h.settings().openWorkingSet, [{ sessionId: 'new', projectPath: h.root, active: true }]); + const result = await h.ctx.api.getSessionContinuations('old'); + assert.equal(result.unresolved, false); + assert.deepEqual(result.candidates.map(candidate => candidate.sessionId), ['new']); + }); +} + +for (const location of ['same folder', 'other folder', 'nested folder']) { + test(`R3 an unindexed transcript in ${location} is not dropped beside an existing target`, { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new', 'unindexed'], new: [], unindexed: [] }); + h.rows.delete('unindexed'); + if (location !== 'same folder') { + const targetDir = path.join(h.root, 'other-project', ...(location === 'nested folder' ? ['legacy'] : [])); + fs.mkdirSync(targetDir, { recursive: true }); + fs.renameSync(path.join(h.root, h.folder, 'unindexed.jsonl'), path.join(targetDir, 'unindexed.jsonl')); + } + const result = await h.ctx.api.getSessionContinuations('old'); + assert.equal(result.unresolved, true); + assert.deepEqual(result.candidates.map(candidate => candidate.sessionId), ['new']); + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.deepEqual(h.prompts, []); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'old'); + assert.match(h.dom.window.notice, /sidebar/); + }); +} + +for (const graph of [ + { old: ['missing-a', 'missing-b'] }, + { old: ['mid', 'missing-a'], mid: ['missing-b'] }, +]) { + test(`R3 all targets missing at ${graph.mid ? 'a descendant' : 'the root'} remain unresolved`, { timeout: 9000 }, async t => { + const h = setup(t, graph); + const result = await h.ctx.api.getSessionContinuations('old'); + assert.equal(result.unresolved, true); + assert.deepEqual(result.candidates, []); + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.deepEqual(h.prompts, []); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'old'); + assert.match(h.dom.window.notice, /sidebar/); + }); +} + +test('R3 a failed disk inventory cannot discard an unknown target', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new', 'missing'], new: [] }); + t.mock.method(fs.promises, 'readdir', async () => { throw Object.assign(new Error('fixture denied'), { code: 'EACCES' }); }); + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.deepEqual(h.prompts, []); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'old'); +}); + for (const [name, graph, expected] of [ ['one continuation', { old: ['new'], new: [] }, 'new'], ['a chain old to mid to new', { old: ['mid'], mid: ['new'], new: [] }, 'new'], From d960d6f9bcf094e9fffd9a52c6e43c617dbe9a63 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 9 Oct 2026 20:18:22 +0200 Subject: [PATCH 6/7] fix(restore): ignore mere mentions of continued-in, retry entries held for indexing, keep a live new continuation --- .ai/contexts/cli-session-state.md | 19 +- .ai/contexts/session-cache.md | 40 +++-- CHANGELOG.md | 2 +- docs/session-restore.md | 14 +- main.js | 4 +- public/app.js | 37 +++- public/resume-guard.js | 4 +- read-session-file.js | 9 +- session-cache.js | 9 +- session-continuations.js | 30 ++-- test/continuation-regressions.test.js | 5 +- test/continuation-round4.test.js | 245 ++++++++++++++++++++++++++ test/continuations-harness.js | 4 +- test/restore-continuations.test.js | 2 +- test/restore-live-elsewhere.test.js | 2 +- test/restore-pending-persist.test.js | 2 +- test/restore-unavailable.test.js | 1 + test/schedule-live-resume.test.js | 2 +- test/terminal-refresh.test.js | 2 +- 19 files changed, 364 insertions(+), 69 deletions(-) create mode 100644 test/continuation-round4.test.js diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 0509a8f4c..96bc09938 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -367,8 +367,10 @@ chunks and has no total byte cap; chunk size only controls pacing. `resolveResumeSession` is shared by restore and sidebar clicks. An automatic restore selects exactly one terminal id. Several ids, unresolved graphs and failed lookups hold the saved entry and join the non-blocking restore notice, -which names the original entry and tells the user to open it from the sidebar -to choose. No continuation confirmation runs during automatic restore. Manual +which names the original entry. A disk-present, unindexed continuation says +it is waiting for indexing; held entries retry once when indexing finishes, +even when the ordinary planner already settled. Ambiguous graphs ask for a +sidebar choice. No continuation confirmation runs during automatic restore. Manual opens list each candidate's id and last activity before asking whether to open it. Declining all candidates offers an explicit original-session choice; an unresolved graph or failed lookup offers the same choice with a warning. @@ -379,6 +381,19 @@ active-session open and working-set restore. Each original id is resolved once during that startup pass; the map is discarded afterward so subsequent manual opens and indexing retries re-check the transcript. +The remembered active-session `openSession` must be awaited before working-set +restore starts. Continuation and live-elsewhere checks are asynchronous: before +they finish, no terminal entry has been inserted into `openSessions`. Starting +`restoreWorkingSet` concurrently can see the same session as unopened and launch +a second PTY / duplicate `claude --resume`. Awaiting that first open also lets the +working-set pass reuse its completed continuation lookup. An automatic remembered +open does not count as the manual action that cancels later indexing retries. + +For uncached local targets, continuation IPC checks the same live-elsewhere +descriptor lookup before declaring the transcript missing. A live target remains +a candidate before its first transcript record is written. An indexed sibling +therefore cannot silently win over a live new continuation. + After resolution, `runRestore` checks the final id's liveness; a background continuation requests the guard's existing `{attach}` verdict. Other live kinds retain their refusal/confirmation rules. Saved entries are rekeyed before diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 476e214b0..157e66651 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -1674,12 +1674,12 @@ settings(key PK, value JSON) ## Continuation index (#518B) -`session_cache.continuationIndex` is nullable JSON with `format:2`, `ids`, +`session_cache.continuationIndex` is nullable JSON with `format:3`, `ids`, `bytes`, `complete`, `mtime` and `unresolved`. Schema reconciliation adds the column without wiping old rows. Full transcript scans collect links while already parsing the file, including worker scans. Missing or older-format -indexes are rebuilt lazily, including indexes that previously marked ordinary -parse errors as unresolved. +indexes are rebuilt lazily, including format-2 indexes that marked a mere +`continued-in` mention in an unrelated malformed or oversized record unresolved. Header-only refresh skips subagent continuation indexes and shares a 1 MiB synchronous continuation budget across all changed parent files in that refresh. @@ -1699,26 +1699,44 @@ This asynchronous directory inventory is shared within one resolution and reads no transcript contents. A file found anywhere in that root remains unresolved until indexed; resolution does not index it on demand. Directory read failures or symbolic links that may hide files also keep absence unconfirmed. -Only a target absent from both the cache and this inventory is marked missing. +For a local target without a cache row, the IPC also supplies the existing +CLI live-elsewhere lookup. A live descriptor keeps the target as a terminal +candidate even before its first transcript record exists; lookup failures +remain unresolved. Remote mirror targets never query local CLI descriptors. +Only a target absent from the cache, liveness lookup and inventory is marked missing. The graph drops that target if its parent has another existing continuation. If every child is confirmed missing, that parent remains unresolved, including inside a longer chain: silently resuming a discontinued branch is unsafe, and manual opening already offers an explicit original-id choice. Cached rows with missing or unreadable transcripts retain the existing unresolved behaviour. -Chunk indexes also carry `version:2`, `size`, `sealedIds`, a bounded base64 +Chunk indexes also carry `version:3`, `size`, `sealedIds`, a bounded base64 `pending` line, and the last 64 indexed bytes as hexadecimal `tail`. The tail is verified before reusing a cursor after growth; mismatch, shrink or a same-size mtime change starts at byte zero. This is a local tail check, not a proof that an arbitrary rewrite preserved the entire prefix. Full-scan indexes lack a cursor witness and restart from zero when stale. -Lines are retained up to 1 MiB. Oversized records are streamed with `skipLine`, -`lineContinued` and an 11-byte `patternTail`, detecting literal `continued-in` -bytes anywhere in the record, including across chunks. A malformed or oversized -record is unresolved only if it contains these bytes. Valid continuation records -with invalid targets also remain unresolved. An incomplete tail without those -bytes never blocks the original session. Partial tails remain buffered for a +Lines are retained up to 1 MiB. Oversized records are ignored by both continuation +indexers; chunk scans carry `skipLine` until the newline, including across appends. +Actual CLI continuation records are small bookkeeping records. Malformed lines +only hold resolution when they match `"type"\s*:\s*"continued-in"`; an unrelated +mention of the word does not. Valid continuation records with invalid targets +also remain unresolved. Partial tails remain buffered for a later append; tail errors are recomputed rather than carried forward forever. No transcript content is sent through the IPC, only candidate ids and activity times. + +A disk-present target without a cache row returns `waitingForIndex` with the +unresolved graph. Automatic restore uses this to show a waiting notice instead +of a continuation-choice notice. `markRestoreIndexingDone` reloads projects and +retries held continuation entries, including after the ordinary restore planner +has settled. If completion arrived during an in-flight resolution before it could +be held, that restore pass notices completion and performs the same retry when +it returns. Passes starting after completion do not schedule another retry. +It invalidates their startup lookup promises before resolving again, +persists the resolved id and active state, and consumes each retry once. A failed +project reload leaves retries available for the next completion event. A manual +open cancels automatic retry; a remembered automatic open does not. Entries still +ambiguous after indexing retain the sidebar-choice notice. Entries still unindexed +after the retry name that state and offer a manual sidebar retry. diff --git a/CHANGELOG.md b/CHANGELOG.md index ec892562b..1e16d01c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### Fixed - On Windows, writing to a terminal that is closing no longer raises an uncaught main-process exception, including when closing an attached agent tab. (#517) -- Restoring a conversation that continued under another id now opens its continuation and saves the new id, including with large transcripts, unrelated damaged records or an extra continuation that never saved a transcript. Multiple or unresolved continuations stay in the saved set with a non-blocking notice; open the old session to choose a continuation or explicitly reopen the original. (#518) +- Restoring a conversation that continued under another id now opens its continuation and saves the new id, waits and retries when indexing finishes, and keeps live continuations available before their first transcript is saved. Unrelated damaged or oversized records no longer block restore; multiple or unresolved continuations stay saved with a notice to open the old session and choose a continuation or explicitly reopen the original. (#518) ## v0.0.91 — 2026-10-09 diff --git a/docs/session-restore.md b/docs/session-restore.md index 34ee733a8..df0223329 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -46,10 +46,11 @@ its previous launch. Sessions reopen one after another, half a second apart. If a conversation continued under another id, restore follows its continuation chain and saves the final id with the saved entry's project and active state. -One final id is selected automatically. A target absent from both the index and -the transcripts on disk is ignored when its parent has another existing +One final id is selected automatically. A target absent from the index, the +transcripts on disk and live CLI processes is ignored when its parent has another existing continuation. A transcript present on disk but not yet indexed still holds -restore until indexing catches up. If all of a node's targets are missing, +restore with a waiting-for-indexing notice; held entries retry once when indexing +finishes. If all of a node's targets are missing, restore stays unresolved rather than reopening that node automatically. Several final ids, a cycle or a malformed continuation record hold the saved entry without launching it. A non-blocking notice names the held conversation and @@ -61,11 +62,12 @@ last activity. After declining the candidates, you can explicitly choose **Open the original session** or cancel. An unresolved continuation also offers this explicit original-session choice, with a warning and any known candidates. The original id is never silently selected when continuation information exists. -Ordinary malformed lines, incomplete tails and oversized records without -continuation bytes do not block opening the original session. Large transcripts +Ordinary malformed lines that mention continuations, unrelated incomplete tails +and oversized records do not block opening the original session. Large transcripts are indexed in yielded chunks without a total byte limit. -A live background continuation uses the existing attach path and its final id +A live continuation remains a candidate even before its first transcript record +exists. A live background continuation uses the existing attach path and its final id stays in the saved working set for a later restore. Within one run of the app, going back to a session whose process still runs is diff --git a/main.js b/main.js index b37a46838..3606c5187 100644 --- a/main.js +++ b/main.js @@ -3139,7 +3139,9 @@ const localTranscriptTracker = createLocalTranscriptTracker({ hasPty: sessionHas // see .ai/contexts/cli-session-state.md ("Live elsewhere") ipcMain.handle('session-live-elsewhere', (_event, sessionId) => cliSessionState.liveElsewhere(sessionId, sessionHasPty, ptyPids)); -ipcMain.handle('session-continuations', (_event, sessionId) => sessionCache.resolveSessionContinuations(sessionId)); +ipcMain.handle('session-continuations', (_event, sessionId) => sessionCache.resolveSessionContinuations(sessionId, { + getSessionLiveElsewhere: id => cliSessionState.liveElsewhere(id, sessionHasPty, ptyPids), +})); ipcMain.handle('sessions-live-elsewhere', (_event, sessionIds) => cliSessionState.liveElsewhereMany(sessionIds, sessionHasPty, ptyPids)); // see .ai/contexts/bg-agents.md diff --git a/public/app.js b/public/app.js index 265f5e3ef..a40ab0027 100644 --- a/public/app.js +++ b/public/app.js @@ -218,7 +218,7 @@ function schedulePersistWorkingSet() { }, 500); } -async function runRestore(list) { +async function runRestore(list, { retryAfterIndexing = !restoreIndexingDone } = {}) { const pending = list.filter(item => sessionMap.has(item.sessionId) && !openSessions.has(item.sessionId)); for (const item of pending) restoreInFlight.set(item.sessionId, item); const skippedNow = []; @@ -234,11 +234,12 @@ async function runRestore(list) { // manual session relaunch — not options frozen from a previous launch. const originalId = item.sessionId; const originalSession = s; - s = await resolveResumeSession({ ...s, projectPath: item.projectPath }, { automatic: true, api: window.api, confirm: msg => window.confirm(msg), resolutions: window._startupResumeResolutions }); + let waitingForIndex = false; + s = await resolveResumeSession({ ...s, projectPath: item.projectPath }, { automatic: true, api: window.api, confirm: msg => window.confirm(msg), resolutions: window._startupResumeResolutions, onHold: reason => { waitingForIndex = reason.waitingForIndex; } }); if (!s) { const index = restoreSavedIndex.has(originalId) ? restoreSavedIndex.get(originalId) : position; - skippedWorkingSetEntries.set(originalId, { item, index }); - skippedNow.push({ session: originalSession, continuation: true }); + skippedWorkingSetEntries.set(originalId, { item, index, retryContinuation: retryAfterIndexing }); + skippedNow.push({ session: originalSession, continuation: true, waitingForIndex, retryAfterIndexing }); restoreInFlight.delete(originalId); continue; } @@ -277,6 +278,9 @@ async function runRestore(list) { if (activeItem && openSessions.has(activeItem.sessionId)) { showSession(activeItem.sessionId); } + if (retryAfterIndexing && restoreIndexingDone && skippedNow.some(item => item.continuation) && !sessionOpenedOutsideRestore) { + await markRestoreIndexingDone(); + } } async function restoreWorkingSet() { @@ -409,11 +413,14 @@ function showNotRestoredNotice(unavailable) { function showLiveElsewhereNotice(skipped) { const live = skipped.filter(item => !item.continuation); - const held = skipped.filter(item => item.continuation); + const held = skipped.filter(item => item.continuation && !item.waitingForIndex); + const waiting = skipped.filter(item => item.waitingForIndex); const label = ({ session }) => cleanDisplayName(session.name || session.aiTitle || session.summary) || session.sessionId; const parts = []; if (live.length === 1) parts.push('Not reopened: ' + label(live[0]) + ' is live in pid ' + (live[0].live?.pid || '?')); else if (live.length) parts.push('Not reopened: ' + live.length + ' sessions live in pids ' + live.map(item => item.live?.pid || '?').join(', ')); + if (waiting.length) parts.push('Not reopened: ' + waiting.map(label).join(', ') + ' is waiting for indexing. ' + + (waiting.some(item => item.retryAfterIndexing) ? 'Restore will retry when indexing finishes' : 'Open it from the sidebar to retry')); if (held.length) parts.push('Not reopened: ' + held.map(item => label(item) + ' (' + item.session.sessionId + ')').join(', ') + ' needs a continuation choice. Open it from the sidebar to choose.'); showRestoreNotice('restore-live-elsewhere-toast', parts.join('. ')); @@ -421,7 +428,8 @@ function showLiveElsewhereNotice(skipped) { async function markRestoreIndexingDone() { restoreIndexingDone = true; - if (!restorePlanner || restorePlanner.isSettled()) return; + const held = () => [...skippedWorkingSetEntries.values()].filter(entry => entry.retryContinuation); + if ((!restorePlanner || restorePlanner.isSettled()) && !held().length) return; let reloaded = false; for (let attempt = 0; attempt < 2 && !reloaded; attempt++) { try { @@ -429,7 +437,20 @@ async function markRestoreIndexingDone() { reloaded = true; } catch (e) { console.warn('[switchboard] reload after indexing failed', e); } } - if (reloaded) await tickRestorePlanner(); + if (!reloaded) return; + await tickRestorePlanner(); + const retry = held(); + if (!retry.length || sessionOpenedOutsideRestore) return; + for (const entry of retry) { + entry.retryContinuation = false; + window._startupResumeResolutions?.delete(entry.item.sessionId); + } + document.getElementById('restore-live-elsewhere-toast')?.remove(); + restoringWorkingSet = true; + try { + await runRestore(retry.map(entry => entry.item), { retryAfterIndexing: false }); + } finally { restoringWorkingSet = false; } + await persistWorkingSet(); } async function maybeRetryRestoreWorkingSet() { @@ -1366,7 +1387,7 @@ async function showTerminalHeader(session) { // Terminal lifecycle (createTerminalEntry, destroySession, showSession, setupDragAndDrop) → terminal-manager.js async function openSession(session, customOptions, { automatic = false, live, continuationResolved = false, allowBgAttach = false } = {}) { - if (!restoringWorkingSet) sessionOpenedOutsideRestore = true; + if (!restoringWorkingSet && !automatic) sessionOpenedOutsideRestore = true; if (!continuationResolved && customOptions?.type !== 'attach' && (!openSessions.has(session.sessionId) || openSessions.get(session.sessionId).closed)) { const originalId = session.sessionId; session = await resolveResumeSession(session, { automatic, api: window.api, confirm: msg => window.confirm(msg), resolutions: window._startupResumeResolutions }); diff --git a/public/resume-guard.js b/public/resume-guard.js index 69a233ee1..745669e89 100644 --- a/public/resume-guard.js +++ b/public/resume-guard.js @@ -43,7 +43,7 @@ async function guardResume(session, { automatic = false, api, confirm, live, all } // see .ai/contexts/cli-session-state.md ("Conversation continuations") -async function resolveResumeSession(session, { automatic = false, api, confirm, resolutions } = {}) { +async function resolveResumeSession(session, { automatic = false, api, confirm, resolutions, onHold } = {}) { if (!session || session.type === 'terminal' || session.remoteAlias) return session; let result; try { @@ -58,7 +58,7 @@ async function resolveResumeSession(session, { automatic = false, api, confirm, const candidates = result?.candidates || []; const listing = candidates.map(c => c.sessionId + ' (last activity: ' + (c.modified || 'unknown') + ')').join('\n'); if (!result || result.unresolved) { - if (automatic) return null; + if (automatic) { onHold?.({ waitingForIndex: !!result?.waitingForIndex }); return null; } return confirm('This conversation has an unresolved continuation (cycle, missing transcript or malformed continuation record).\n' + listing + '\nYou can retry after indexing or open a candidate from the sidebar.\n\nOpen the original session ' + session.sessionId + '?') ? session : null; } diff --git a/read-session-file.js b/read-session-file.js index 4426c4a97..4af0c07f2 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -194,6 +194,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let firstTimestamp = null; let lastTimestamp = null; for (const line of lines) { + const continuationLine = Buffer.byteLength(line) <= 1024 * 1024; // Per-line try/catch: a JSONL file being written concurrently by a live // Claude CLI session can have its tail captured mid-write — one truncated // line should not invalidate the whole file. Skip the malformed line and @@ -201,12 +202,12 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let entry; try { entry = JSON.parse(line); } catch { if (line === lines.at(-1) && !content.endsWith('\n')) incompleteTail = true; - if (line.includes('continued-in')) continuationUnresolved = true; + if (continuationLine && /"type"\s*:\s*"continued-in"/.test(line)) continuationUnresolved = true; continue; } - const continuation = continuationId(entry, fileBase); + const continuation = continuationLine ? continuationId(entry, fileBase) : null; if (continuation) continuationIds.add(continuation); - if (entry.type === 'continued-in' && entry.sessionId === fileBase && !continuation) continuationUnresolved = true; + if (continuationLine && entry.type === 'continued-in' && entry.sessionId === fileBase && !continuation) continuationUnresolved = true; if (entry.timestamp) { // ISO-8601 UTC strings — lexicographic comparison is chronological if (!firstTimestamp || entry.timestamp < firstTimestamp) firstTimestamp = entry.timestamp; @@ -300,7 +301,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { modified: lastTimestamp || stat.mtime.toISOString(), fileMtime: stat.mtime.toISOString(), messageCount, textContent, slug, scheduleSlug, customTitle, aiTitle, - continuationIndex: JSON.stringify({ format: 2, ids: [...continuationIds], bytes: incompleteTail ? Buffer.byteLength(content.slice(0, content.lastIndexOf('\n') + 1)) : stat.size, complete: !incompleteTail, mtime: stat.mtime.toISOString(), unresolved: continuationUnresolved }), + continuationIndex: JSON.stringify({ format: 3, ids: [...continuationIds], bytes: incompleteTail ? Buffer.byteLength(content.slice(0, content.lastIndexOf('\n') + 1)) : stat.size, complete: !incompleteTail, mtime: stat.mtime.toISOString(), unresolved: continuationUnresolved }), bridgeSessionId, entrypoint: typedInTerminal ? 'cli' : (entrypoint ?? ''), dailyMetrics, diff --git a/session-cache.js b/session-cache.js index 4b46e4f58..31c4b945e 100644 --- a/session-cache.js +++ b/session-cache.js @@ -1079,7 +1079,7 @@ function populateCacheViaWorker() { return populatePromise; } -async function resolveSessionContinuations(sessionId, { chunkBytes = 1024 * 1024 } = {}) { +async function resolveSessionContinuations(sessionId, { chunkBytes = 1024 * 1024, getSessionLiveElsewhere } = {}) { if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 1) throw new RangeError('chunkBytes must be a positive integer'); let diskEntries; return resolveContinuations(sessionId, async id => { @@ -1087,18 +1087,19 @@ async function resolveSessionContinuations(sessionId, { chunkBytes = 1024 * 1024 const row = getCachedSession?.(id); if (!row) { const { alias } = parseFolderKey(getCachedSession?.(sessionId)?.folder || ''); + if (alias === null && getSessionLiveElsewhere && await getSessionLiveElsewhere(id)) return { index: { ids: [] } }; const root = alias === null ? PROJECTS_DIR : remoteRoots.get(alias); diskEntries ||= fs.promises.readdir(root, { recursive: true, withFileTypes: true }); const entries = await diskEntries; - return entries.some(entry => entry.isSymbolicLink() || (entry.isFile() && entry.name === id + '.jsonl')) - ? null : { missing: true }; + if (entries.some(entry => entry.isFile() && entry.name === id + '.jsonl')) return { waitingForIndex: true }; + return entries.some(entry => entry.isSymbolicLink()) ? null : { missing: true }; } if (row.parentSessionId) return null; const file = resolveJsonlPath(resolveFolderDir(row.folder), { ...row, folder: '.' }); const stat = fs.statSync(file); let serialized = row.continuationIndex, index; try { index = JSON.parse(serialized); } catch {} - if (index?.format !== 2 || !index.complete || index.bytes !== stat.size || index.mtime !== stat.mtime.toISOString()) { + if (index?.format !== 3 || !index.complete || index.bytes !== stat.size || index.mtime !== stat.mtime.toISOString()) { do { serialized = scanContinuationIndex(file, id, serialized, chunkBytes); index = JSON.parse(serialized); diff --git a/session-continuations.js b/session-continuations.js index b4eca0bc7..660eca261 100644 --- a/session-continuations.js +++ b/session-continuations.js @@ -20,7 +20,7 @@ function scanContinuationIndex(file, sessionId, previous, maxBytes = 1024 * 1024 return tail.toString('hex'); }; try { - const reuse = index?.version === 2 && index.bytes <= stat.size && index.size <= stat.size + const reuse = index?.version === 3 && index.bytes <= stat.size && index.size <= stat.size && (index.size !== stat.size || index.mtime === mtime) && index.tail === tailAt(index.bytes); if (reuse && index.complete && index.bytes === stat.size && index.mtime === mtime) return previous; const ids = new Set(reuse ? index.sealedIds : []); @@ -28,8 +28,6 @@ function scanContinuationIndex(file, sessionId, previous, maxBytes = 1024 * 1024 let pending = reuse ? Buffer.from(index.pending || '', 'base64') : Buffer.alloc(0); let oversized = reuse ? !!index.skipLine : false; let invalid = reuse ? !!index.invalid : false; - let lineContinued = reuse ? !!index.lineContinued : false; - let patternTail = reuse ? index.patternTail || '' : ''; const end = Math.min(stat.size, position + maxBytes), buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes)); const consume = line => { if (!line.length) return true; @@ -40,20 +38,14 @@ function scanContinuationIndex(file, sessionId, previous, maxBytes = 1024 * 1024 if (entry?.type === 'continued-in' && entry.sessionId === sessionId && !id) invalid = true; return true; } catch { - if (line.includes('continued-in')) invalid = true; + if (/"type"\s*:\s*"continued-in"/.test(line.toString('utf8'))) invalid = true; return false; } }; const segment = bytes => { - if (oversized) { - const text = patternTail + bytes.toString('latin1'); - lineContinued ||= text.includes('continued-in'); - patternTail = text.slice(-11); - } else { + if (!oversized) { pending = Buffer.concat([pending, bytes]); if (pending.length > 1024 * 1024) { - lineContinued = pending.includes('continued-in'); - patternTail = pending.subarray(-11).toString('latin1'); pending = Buffer.alloc(0); oversized = true; } @@ -66,12 +58,9 @@ function scanContinuationIndex(file, sessionId, previous, maxBytes = 1024 * 1024 let start = 0, newline; while ((newline = buffer.indexOf(10, start)) !== -1 && newline < n) { segment(buffer.subarray(start, newline)); - if (oversized) invalid ||= lineContinued; - else consume(pending); + if (!oversized) consume(pending); pending = Buffer.alloc(0); oversized = false; - lineContinued = false; - patternTail = ''; start = newline + 1; } segment(buffer.subarray(start, n)); @@ -84,22 +73,23 @@ function scanContinuationIndex(file, sessionId, previous, maxBytes = 1024 * 1024 tailUnresolved = invalid && !before; invalid = before; } - return JSON.stringify({ format: 2, version: 2, ids: [...ids], sealedIds, bytes: position, + return JSON.stringify({ format: 3, version: 3, ids: [...ids], sealedIds, bytes: position, complete: position === stat.size && (!pending.length || tailValid || oversized), size: stat.size, mtime, - unresolved: invalid || tailUnresolved || (oversized && lineContinued), invalid, - skipLine: oversized, lineContinued, patternTail, pending: pending.toString('base64'), tail: tailAt(position) }); + unresolved: invalid || tailUnresolved, invalid, + skipLine: oversized, pending: pending.toString('base64'), tail: tailAt(position) }); } finally { fs.closeSync(fd); } } async function resolveContinuations(sessionId, getNode, { maxDepth = 32, maxNodes = 128 } = {}) { const candidates = new Map(), visiting = new Set(), visited = new Set(); - let unresolved = false, nodes = 0, continued = false; + let unresolved = false, nodes = 0, continued = false, waitingForIndex = false; async function visit(id, depth) { if (visiting.has(id) || depth > maxDepth || ++nodes > maxNodes) { unresolved = true; return; } if (visited.has(id)) return; let node; try { node = await getNode(id); } catch { unresolved = true; return; } if (!node) { unresolved = true; return; } + if (node.waitingForIndex) { unresolved = true; waitingForIndex = true; return; } if (node.missing) return false; const index = node.index; if (index?.unresolved) unresolved = true; @@ -121,7 +111,7 @@ async function resolveContinuations(sessionId, getNode, { maxDepth = 32, maxNode } if (!validId(sessionId)) return { candidates: [], unresolved: true, continued: false }; if (await visit(sessionId, 0) === false) unresolved = true; - return { candidates: [...candidates.values()], unresolved, continued }; + return { candidates: [...candidates.values()], unresolved, continued, waitingForIndex }; } module.exports = { continuationId, scanContinuationIndex, resolveContinuations, validId }; diff --git a/test/continuation-regressions.test.js b/test/continuation-regressions.test.js index 16eed9f01..b1be70e96 100644 --- a/test/continuation-regressions.test.js +++ b/test/continuation-regressions.test.js @@ -177,16 +177,15 @@ test('R2 append indexing rejects a rewritten indexed tail', { timeout: 9000 }, t assert.deepEqual(current.ids, ['b']); }); -test('R2 small chunks retain split continuation byte patterns in oversized records', { timeout: 9000 }, async t => { +test('R2 small chunks ignore split continuation byte patterns in oversized records', { timeout: 9000 }, async t => { const h = setup(t, { old: [] }, { chunkBytes: 65536 }); const prefix = '{"padding":"', suffix = '","type":"continued-in",broken}\n'; const pad = 2 * 1024 * 1024 + 65530 - prefix.length - suffix.indexOf('continued-in'); append(h, prefix + 'x'.repeat(pad) + suffix); delete h.rows.get('old').continuationIndex; await h.restore(); - assert.deepEqual(h.spawned, []); + assert.deepEqual(h.spawned.map(call => call.id), ['old']); assert.deepEqual(h.prompts, []); - assert.match(h.dom.window.notice, /old/); }); test('R2 startup resolves an active continued session only once', { timeout: 9000 }, async t => { diff --git a/test/continuation-round4.test.js b/test/continuation-round4.test.js new file mode 100644 index 000000000..da735f969 --- /dev/null +++ b/test/continuation-round4.test.js @@ -0,0 +1,245 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const cache = require('../session-cache'); +const { readSessionFile } = require('../read-session-file'); +const { scanContinuationIndex } = require('../session-continuations'); +const { loadAppFunctions } = require('./app-source'); +const { setup } = require('./continuations-harness'); + +for (const [name, line] of [ + ['oversized tool output quoting continued-in', JSON.stringify({ type: 'assistant', message: { content: 'continued-in ' + 'x'.repeat(1024 * 1024) } })], + ['malformed ordinary line mentioning continued-in', '{"type":"assistant","message":"continued-in",broken}'], +]) { + test(`R4 R1 ${name} agrees across indexers and append restore`, { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + const previous = scanContinuationIndex(file, 'old'); + fs.appendFileSync(file, line + '\n'); + const full = JSON.parse(readSessionFile(file, h.folder, h.root).continuationIndex); + const appended = JSON.parse(scanContinuationIndex(file, 'old', previous, 4 * 1024 * 1024)); + assert.equal(full.unresolved, false, 'full index ignores unrelated bytes'); + assert.equal(appended.unresolved, false, 'append index ignores unrelated bytes'); + assert.deepEqual(appended.ids, full.ids); + h.db.setCachedContinuationIndex('old', JSON.stringify(appended)); + fs.appendFileSync(file, '{}\n'); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['old']); + assert.deepEqual(h.prompts, []); + }); +} + +test('R4 R1 oversized skip survives chunk boundary and later append without holding', { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + fs.appendFileSync(file, '{"type":"assistant","padding":"' + 'x'.repeat(1024 * 1024) + 'continued-in'); + let index = scanContinuationIndex(file, 'old', undefined, 2 * 1024 * 1024); + assert.equal(JSON.parse(index).unresolved, false); + fs.appendFileSync(file, 'continued-in"}\n'); + index = scanContinuationIndex(file, 'old', index); + assert.equal(JSON.parse(index).unresolved, false); + h.db.setCachedContinuationIndex('old', index); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['old']); +}); + +for (const type of ['"type":"continued-in"', '"type" \t: \t"continued-in"']) { + test(`R4 R1 malformed actual continuation stays unresolved (${type})`, { timeout: 9000 }, t => { + const h = setup(t, { old: [] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + fs.appendFileSync(file, '{' + type + ',"sessionId":"old",broken}\n'); + assert.equal(JSON.parse(readSessionFile(file, h.folder, h.root).continuationIndex).unresolved, true); + assert.equal(JSON.parse(scanContinuationIndex(file, 'old')).unresolved, true); + }); +} + +function indexingHarness(h, { settled = true } = {}) { + vm.runInContext(`restoreIndexingDone = false; restorePlanner = { isSettled: () => ${settled}, pending: () => [] };`, h.ctx); + h.ctx.loadProjects = async () => { + cache.refreshFolder(h.folder); + h.ctx.indexedRows = [...h.rows.values()]; + vm.runInContext('indexedRows.forEach(row => sessionMap.set(row.sessionId, row));', h.ctx); + }; + h.ctx.tickRestorePlanner = async () => {}; + return loadAppFunctions(h.ctx, { functions: ['markRestoreIndexingDone'] }); +} + +for (const settled of [true, false]) { + test(`R4 R2 indexing completion retries held unindexed continuation (planner settled=${settled})`, { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [], other: [] }, { + savedEntries: [{ sessionId: 'old', active: true }, { sessionId: 'other' }], + }); + const app = indexingHarness(h, { settled }); + h.rows.delete('new'); + vm.runInContext('sessionMap.delete("new");', h.ctx); + h.ctx._startupResumeResolutions = new Map(); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['other']); + await app.markRestoreIndexingDone(); + await h.app.persistWorkingSet(); + assert.deepEqual(h.spawned.map(call => call.id), ['other', 'new']); + assert.equal(vm.runInContext('activeSessionId', h.ctx), 'new'); + assert.deepEqual(h.settings().openWorkingSet.map(item => item.sessionId).sort(), ['new', 'other']); + assert.equal(h.settings().openWorkingSet.find(item => item.sessionId === 'new').active, true); + assert.deepEqual(h.prompts, []); + await app.markRestoreIndexingDone(); + assert.equal(h.spawned.length, 2, 'a second completion does not spawn twice'); + }); +} + +test('R4 R2 an unindexed target notice says waiting for indexing', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }); + h.rows.delete('new'); + await h.restore(); + assert.match(h.dom.window.notice, /waiting for indexing/i); + assert.doesNotMatch(h.dom.window.notice, /choose/i); +}); + +test('R4 R2 indexing completion during a continuation lookup still retries', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }); + const app = indexingHarness(h); + h.rows.delete('new'); + vm.runInContext('sessionMap.delete("new");', h.ctx); + const original = h.ctx.api.getSessionContinuations; + let ready, release; + const resolved = new Promise(resolve => { ready = resolve; }); + const paused = new Promise(resolve => { release = resolve; }); + let first = true; + h.ctx.api.getSessionContinuations = async id => { + const result = await original(id); + if (first) { + first = false; + ready(); + await paused; + } + return result; + }; + const restoring = h.restore(); + await resolved; + await app.markRestoreIndexingDone(); + release(); + await restoring; + assert.deepEqual(h.spawned.map(call => call.id), ['new']); + assert.deepEqual(h.prompts, []); +}); + +test('R4 R2 ambiguous continuations still ask for a sidebar choice after indexing', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['a', 'b'], a: [], b: [] }); + const app = indexingHarness(h); + await h.restore(); + await app.markRestoreIndexingDone(); + assert.deepEqual(h.spawned, []); + assert.deepEqual(h.prompts, []); + assert.match(h.dom.window.notice, /sidebar.*choose/i); + assert.doesNotMatch(h.dom.window.notice, /waiting for indexing/i); +}); + +test('R4 R2 a manual open cancels automatic continuation retry', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }); + const app = indexingHarness(h); + h.rows.delete('new'); + await h.restore(); + vm.runInContext('sessionOpenedOutsideRestore = true;', h.ctx); + await app.markRestoreIndexingDone(); + assert.deepEqual(h.spawned, []); +}); + +test('R4 R2 remembered automatic open does not cancel the indexing retry', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new'], new: [] }); + const app = indexingHarness(h); + h.rows.delete('new'); + vm.runInContext('sessionMap.delete("new"); activeSessionId = "old"; restoringWorkingSet = false;', h.ctx); + h.ctx.restoreWorkingSet = async () => { + vm.runInContext('restoringWorkingSet = true;', h.ctx); + await h.restore(); + vm.runInContext('restoringWorkingSet = false;', h.ctx); + }; + h.ctx.restoreAgentsViewAtStartup = () => {}; + const startup = loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions'] }); + await startup.restoreStartupSessions(); + await app.markRestoreIndexingDone(); + assert.deepEqual(h.spawned.map(call => call.id), ['new']); + assert.deepEqual(h.prompts, []); +}); + +test('R4 R1 a fresh format-2 false hold is rebuilt rather than reused', { timeout: 9000 }, async t => { + const h = setup(t, { old: [] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + fs.appendFileSync(file, '{"type":"assistant","message":"continued-in",broken}\n'); + const stat = fs.statSync(file); + h.db.setCachedContinuationIndex('old', JSON.stringify({ format: 2, complete: true, bytes: stat.size, + mtime: stat.mtime.toISOString(), ids: [], unresolved: true })); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), ['old']); +}); + +test('R4 R3 shipped continuation IPC keeps a live missing sibling', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new', 'fresh'], new: [] }); + const source = fs.readFileSync(path.join(__dirname, '../main.js'), 'utf8'); + const start = source.indexOf("ipcMain.handle('session-continuations'"); + const end = source.indexOf("ipcMain.handle('sessions-live-elsewhere'", start); + assert.ok(start >= 0 && end > start); + let handler; + const lookups = []; + const hasPty = () => false, pids = () => []; + vm.runInNewContext(source.slice(start, end), { + ipcMain: { handle: (_, callback) => { handler = callback; } }, + sessionCache: cache, + cliSessionState: { liveElsewhere: async (id, own, ownPids) => { + lookups.push(id); + assert.equal(own, hasPty); + assert.equal(ownPids, pids); + return id === 'fresh' ? { kind: 'bg', jobId: '1234abcd', pid: 7 } : null; + } }, + sessionHasPty: hasPty, ptyPids: pids, + }); + const result = await handler(null, 'old'); + assert.deepEqual(result.candidates.map(candidate => candidate.sessionId), ['new', 'fresh']); + assert.deepEqual(lookups, ['fresh']); +}); + +test('R4 R3 live missing target with an indexed sibling remains ambiguous', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new', 'fresh'], new: [] }, { live: { fresh: { kind: 'bg', jobId: '1234abcd', pid: 7 } } }); + const result = await h.ctx.api.getSessionContinuations('old'); + assert.deepEqual(result.candidates.map(candidate => candidate.sessionId), ['new', 'fresh']); + assert.equal(result.unresolved, false); + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.deepEqual(h.prompts, []); +}); + +test('R4 R3 a live missing background target uses attach when manually chosen', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new', 'fresh'], new: [] }, { + live: { fresh: { kind: 'bg', jobId: '1234abcd', pid: 7 } }, + answer: message => /Open fresh\?/.test(message), + }); + await h.click(); + assert.deepEqual(h.spawned.map(call => call.id), ['fresh']); + assert.equal(h.spawned[0].options.type, 'attach'); + assert.equal(h.spawned[0].options.jobId, '1234abcd'); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'fresh'); +}); + +for (const kind of ['bg', 'interactive', 'schedule']) { + test(`R4 R3 lone live missing ${kind} target follows the automatic live guard`, { timeout: 9000 }, async t => { + const h = setup(t, { old: ['fresh'] }, { live: { fresh: { kind, jobId: '1234abcd', pid: 7 } } }); + await h.restore(); + assert.deepEqual(h.spawned.map(call => call.id), kind === 'bg' ? ['fresh'] : []); + if (kind === 'bg') assert.equal(h.spawned[0].options.type, 'attach'); + else assert.match(h.dom.window.notice, /pid 7/); + assert.deepEqual(h.prompts, []); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'fresh'); + }); +} + +test('R4 R3 failed live lookup cannot discard a missing target', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new', 'fresh'], new: [] }); + const result = await cache.resolveSessionContinuations('old', { + getSessionLiveElsewhere: async () => { throw new Error('descriptor unavailable'); }, + }); + assert.equal(result.unresolved, true); +}); diff --git a/test/continuations-harness.js b/test/continuations-harness.js index 579f7d149..f1c31f157 100644 --- a/test/continuations-harness.js +++ b/test/continuations-harness.js @@ -45,7 +45,7 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } getSetting: async () => JSON.parse(fs.readFileSync(settingsFile, 'utf8')), setSetting: async (_, value) => fs.writeFileSync(settingsFile, JSON.stringify(value)), getSessionContinuations: async id => typeof cache.resolveSessionContinuations === 'function' - ? cache.resolveSessionContinuations(id, { chunkBytes }) : { candidates: [], unresolved: false }, + ? cache.resolveSessionContinuations(id, { chunkBytes, getSessionLiveElsewhere: async target => live[target] || null }) : { candidates: [], unresolved: false }, getSessionLiveElsewhere: async id => live[id] || null, getSessionsLiveElsewhere: async ids => Object.fromEntries(ids.filter(id => live[id]).map(id => [id, live[id]])), openTerminal: async (id, project, isNew, options) => { spawned.push({ id, project, options }); return { ok: true }; }, @@ -72,7 +72,7 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } vm.runInContext('rows.forEach(row => sessionMap.set(row.sessionId, row));', ctx); dom.window.showRestoreNotice = (_, text) => { dom.window.notice = text; }; const app = loadAppFunctions(ctx, { - declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight'], + declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone'], functions: ['runRestore', 'openSession', 'persistWorkingSet', 'pendingRestoreEntries', 'showLiveElsewhereNotice'], }); return { diff --git a/test/restore-continuations.test.js b/test/restore-continuations.test.js index 7f28c828e..436c176d6 100644 --- a/test/restore-continuations.test.js +++ b/test/restore-continuations.test.js @@ -38,7 +38,7 @@ for (const location of ['same folder', 'other folder', 'nested folder']) { assert.deepEqual(h.spawned, []); assert.deepEqual(h.prompts, []); assert.equal(h.settings().openWorkingSet[0].sessionId, 'old'); - assert.match(h.dom.window.notice, /sidebar/); + assert.match(h.dom.window.notice, /waiting for indexing/); }); } diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index 4a72e44cf..d3158a6d2 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -89,7 +89,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { function schedulePersistWorkingSet() {} function pollActiveSessions() {} `, ctx); - vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight']) + vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone']) .replace(/^const /gm, 'var ').replace(/^let /gm, 'var '), ctx); for (const name of ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore', 'openSession']) { vm.runInContext(functionSource(APP_SRC, name), ctx); diff --git a/test/restore-pending-persist.test.js b/test/restore-pending-persist.test.js index 1eb64eca4..77ef086ea 100644 --- a/test/restore-pending-persist.test.js +++ b/test/restore-pending-persist.test.js @@ -46,7 +46,7 @@ function setup() { `, ctx); const fns = loadAppFunctions(ctx, { functions: ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore'], - declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight'], + declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone'], }); vm.runInContext(`restoreSavedIndex = new Map(${JSON.stringify(SAVED.map((item, i) => [item.sessionId, i]))});`, ctx); const stored = () => settings.global.openWorkingSet.map(i => i.sessionId); diff --git a/test/restore-unavailable.test.js b/test/restore-unavailable.test.js index 9b436c3f5..4a7615e54 100644 --- a/test/restore-unavailable.test.js +++ b/test/restore-unavailable.test.js @@ -84,6 +84,7 @@ function setupTick({ mode, indexed, late = [], preplanned = true, finished = fal var sessionMap = new Map(${JSON.stringify(indexed.map((id) => [id, { sessionId: id, name: 'name-' + id }]))}); var restorePlanner = ${preplanned ? `createRestorePlanner({ savedSet: ${JSON.stringify(SAVED)}, askOnce: ${mode === 'ask'} })` : 'null'}; var restoreSavedIndex = new Map(); + var skippedWorkingSetEntries = new Map(); var SETTING_DEFAULTS = { restoreOnStartup: 'off' }; var loadProjects = async () => { for (const id of ${JSON.stringify(late)}) sessionMap.set(id, { sessionId: id, name: 'name-' + id }); }; var restoreMode = ${JSON.stringify(mode)}; diff --git a/test/schedule-live-resume.test.js b/test/schedule-live-resume.test.js index e857dd681..d75bde1e9 100644 --- a/test/schedule-live-resume.test.js +++ b/test/schedule-live-resume.test.js @@ -102,7 +102,7 @@ function setup(t, { sandbox = false } = {}) { `, ctx); const app = loadAppFunctions(ctx, { functions: ['openSession', 'runRestore', 'pendingRestoreEntries', 'persistWorkingSet', 'showRestoreNotice', 'showLiveElsewhereNotice'], - declarations: ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight'], + declarations: ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone'], }); ctx.sessionMap.set(SID, session); t.after(() => { diff --git a/test/terminal-refresh.test.js b/test/terminal-refresh.test.js index 971ed6d2d..6da6a0920 100644 --- a/test/terminal-refresh.test.js +++ b/test/terminal-refresh.test.js @@ -190,7 +190,7 @@ test('restore refreshes a new remote terminal once even when its saved id is alr ctx.window.activeSessionId = 'remote'; const guardPath = path.join(__dirname, '../public/resume-guard.js'); vm.runInContext(fs.readFileSync(guardPath, 'utf8'), ctx.context, { filename: guardPath }); - const { runRestore } = loadAppFunctions(ctx.context, { functions: ['runRestore'], declarations: ['restoreInFlight'] }); + const { runRestore } = loadAppFunctions(ctx.context, { functions: ['runRestore'], declarations: ['restoreInFlight', 'restoreIndexingDone'] }); await runRestore([{ sessionId: 'remote', active: true }]); await flush(ctx); assert.deepEqual(ctx.refreshes, [{ id: 'remote', cols: 120, rows: 40 }]); From 95114416b58ee030aabb8b17a607fb501654cda1 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 9 Oct 2026 20:41:29 +0200 Subject: [PATCH 7/7] fix(restore): keep reload from restoring the working set, treat unknown liveness as unresolved, reuse the full-read index --- .ai/contexts/cli-session-state.md | 14 +++-- .ai/contexts/session-cache.md | 21 ++++--- CHANGELOG.md | 2 +- docs/session-restore.md | 8 ++- main.js | 2 +- public/app.js | 8 ++- read-session-file.js | 24 ++++++-- session-cache.js | 6 +- test/continuation-round4.test.js | 13 ++-- test/continuation-round5.test.js | 94 +++++++++++++++++++++++++++++ test/continuations-harness.js | 4 +- test/open-session-terminal.test.js | 2 +- test/process-exit-status.test.js | 2 + test/restore-live-elsewhere.test.js | 2 +- test/schedule-live-resume.test.js | 2 +- 15 files changed, 170 insertions(+), 34 deletions(-) create mode 100644 test/continuation-round5.test.js diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 96bc09938..3c30eae3d 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -386,11 +386,15 @@ restore starts. Continuation and live-elsewhere checks are asynchronous: before they finish, no terminal entry has been inserted into `openSessions`. Starting `restoreWorkingSet` concurrently can see the same session as unopened and launch a second PTY / duplicate `claude --resume`. Awaiting that first open also lets the -working-set pass reuse its completed continuation lookup. An automatic remembered -open does not count as the manual action that cancels later indexing retries. - -For uncached local targets, continuation IPC checks the same live-elsewhere -descriptor lookup before declaring the transcript missing. A live target remains +working-set pass reuse its completed continuation lookup. The remembered open +retains the existing renderer-reload behaviour: opening outside restore cancels +the working-set planner, so it neither prompts nor resumes the other saved entries. +The separate continuation-retry cancellation flag is set only by non-automatic +opens; the automatic remembered open does not cancel a held indexing retry. + +For uncached local targets, continuation IPC uses `liveElsewhereChecked` before +declaring the transcript missing. `known:false` keeps the graph unresolved when +descriptors cannot be read. A live target remains a candidate before its first transcript record is written. An indexed sibling therefore cannot silently win over a live new continuation. diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 157e66651..bba86a30e 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -1700,9 +1700,11 @@ no transcript contents. A file found anywhere in that root remains unresolved until indexed; resolution does not index it on demand. Directory read failures or symbolic links that may hide files also keep absence unconfirmed. For a local target without a cache row, the IPC also supplies the existing -CLI live-elsewhere lookup. A live descriptor keeps the target as a terminal -candidate even before its first transcript record exists; lookup failures -remain unresolved. Remote mirror targets never query local CLI descriptors. +CLI `liveElsewhereChecked` lookup. A live descriptor keeps the target as a terminal +candidate even before its first transcript record exists; `known:false`, including +an unreadable descriptor directory, keeps resolution unresolved. A confirmed +`known:true, live:null` result permits the disk inventory to check absence. +Remote mirror targets never query local CLI descriptors. Only a target absent from the cache, liveness lookup and inventory is marked missing. The graph drops that target if its parent has another existing continuation. If every child is confirmed missing, that parent remains unresolved, including @@ -1710,12 +1712,14 @@ inside a longer chain: silently resuming a discontinued branch is unsafe, and manual opening already offers an explicit original-id choice. Cached rows with missing or unreadable transcripts retain the existing unresolved behaviour. -Chunk indexes also carry `version:3`, `size`, `sealedIds`, a bounded base64 +Full-scan and chunk indexes both carry `version:3`, `size`, `sealedIds`, a bounded base64 `pending` line, and the last 64 indexed bytes as hexadecimal `tail`. The tail is verified before reusing a cursor after growth; mismatch, shrink or a same-size mtime change starts at byte zero. This is a local tail check, not a proof that -an arbitrary rewrite preserved the entire prefix. Full-scan indexes lack -a cursor witness and restart from zero when stale. +an arbitrary rewrite preserved the entire prefix. Full reads retain the same +cursor witness and tail state, so the first scan after an append reads only +the appended bytes and the bounded witness. Unterminated valid records remain +tentative until sealed by a newline; partial tails are reprocessed on append. Lines are retained up to 1 MiB. Oversized records are ignored by both continuation indexers; chunk scans carry `skipLine` until the newline, including across appends. @@ -1737,6 +1741,9 @@ it returns. Passes starting after completion do not schedule another retry. It invalidates their startup lookup promises before resolving again, persists the resolved id and active state, and consumes each retry once. A failed project reload leaves retries available for the next completion event. A manual -open cancels automatic retry; a remembered automatic open does not. Entries still +open cancels automatic retry through `continuationRetryCancelled`; a remembered +automatic open does not. The separate `sessionOpenedOutsideRestore` guard keeps +its original behaviour: any open outside working-set restore cancels the planner, +including the automatic remembered open on a renderer reload. Entries still ambiguous after indexing retain the sidebar-choice notice. Entries still unindexed after the retry name that state and offer a manual sidebar retry. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e16d01c0..f1121a73b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### Fixed - On Windows, writing to a terminal that is closing no longer raises an uncaught main-process exception, including when closing an attached agent tab. (#517) -- Restoring a conversation that continued under another id now opens its continuation and saves the new id, waits and retries when indexing finishes, and keeps live continuations available before their first transcript is saved. Unrelated damaged or oversized records no longer block restore; multiple or unresolved continuations stay saved with a notice to open the old session and choose a continuation or explicitly reopen the original. (#518) +- Restoring a conversation that continued under another id now opens its continuation and saves the new id, waits and retries when indexing finishes, and keeps live continuations available before their first transcript is saved. Unrelated damaged or oversized records no longer block restore; multiple or unresolved continuations, including when process status cannot be read, stay saved with a notice to open the old session and choose a continuation or explicitly reopen the original. (#518) ## v0.0.91 — 2026-10-09 diff --git a/docs/session-restore.md b/docs/session-restore.md index df0223329..d56a16fc6 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -44,13 +44,19 @@ its previous launch. Sessions reopen one after another, half a second apart. restoring N session(s) from last time…*. - Opening a session yourself while restore is running cancels the rest of it. +Reloading the renderer with a remembered active session reopens that session and +cancels the working-set planner, so it does not ask to restore or reopen the +other saved entries. A manual open also cancels held continuation retries; +an automatic remembered open does not cancel those retries. + If a conversation continued under another id, restore follows its continuation chain and saves the final id with the saved entry's project and active state. One final id is selected automatically. A target absent from the index, the transcripts on disk and live CLI processes is ignored when its parent has another existing continuation. A transcript present on disk but not yet indexed still holds restore with a waiting-for-indexing notice; held entries retry once when indexing -finishes. If all of a node's targets are missing, +finishes. If process status cannot be checked, a missing target remains unresolved +instead of being discarded. If all of a node's targets are missing, restore stays unresolved rather than reopening that node automatically. Several final ids, a cycle or a malformed continuation record hold the saved entry without launching it. A non-blocking notice names the held conversation and diff --git a/main.js b/main.js index 3606c5187..94d51a505 100644 --- a/main.js +++ b/main.js @@ -3140,7 +3140,7 @@ const localTranscriptTracker = createLocalTranscriptTracker({ hasPty: sessionHas // see .ai/contexts/cli-session-state.md ("Live elsewhere") ipcMain.handle('session-live-elsewhere', (_event, sessionId) => cliSessionState.liveElsewhere(sessionId, sessionHasPty, ptyPids)); ipcMain.handle('session-continuations', (_event, sessionId) => sessionCache.resolveSessionContinuations(sessionId, { - getSessionLiveElsewhere: id => cliSessionState.liveElsewhere(id, sessionHasPty, ptyPids), + getSessionLiveElsewhere: id => cliSessionState.liveElsewhereChecked(id, sessionHasPty, ptyPids), })); ipcMain.handle('sessions-live-elsewhere', (_event, sessionIds) => cliSessionState.liveElsewhereMany(sessionIds, sessionHasPty, ptyPids)); diff --git a/public/app.js b/public/app.js index a40ab0027..eae573247 100644 --- a/public/app.js +++ b/public/app.js @@ -160,6 +160,7 @@ let restorePlanner = null; let restoreMode = 'off'; let restoreIndexingDone = false; let sessionOpenedOutsideRestore = false; +let continuationRetryCancelled = false; // see .ai/contexts/cli-session-state.md ("Live elsewhere") const skippedWorkingSetEntries = new Map(); @@ -278,7 +279,7 @@ async function runRestore(list, { retryAfterIndexing = !restoreIndexingDone } = if (activeItem && openSessions.has(activeItem.sessionId)) { showSession(activeItem.sessionId); } - if (retryAfterIndexing && restoreIndexingDone && skippedNow.some(item => item.continuation) && !sessionOpenedOutsideRestore) { + if (retryAfterIndexing && restoreIndexingDone && skippedNow.some(item => item.continuation) && !continuationRetryCancelled) { await markRestoreIndexingDone(); } } @@ -440,7 +441,7 @@ async function markRestoreIndexingDone() { if (!reloaded) return; await tickRestorePlanner(); const retry = held(); - if (!retry.length || sessionOpenedOutsideRestore) return; + if (!retry.length || continuationRetryCancelled) return; for (const entry of retry) { entry.retryContinuation = false; window._startupResumeResolutions?.delete(entry.item.sessionId); @@ -1387,7 +1388,8 @@ async function showTerminalHeader(session) { // Terminal lifecycle (createTerminalEntry, destroySession, showSession, setupDragAndDrop) → terminal-manager.js async function openSession(session, customOptions, { automatic = false, live, continuationResolved = false, allowBgAttach = false } = {}) { - if (!restoringWorkingSet && !automatic) sessionOpenedOutsideRestore = true; + if (!restoringWorkingSet) sessionOpenedOutsideRestore = true; + if (!automatic) continuationRetryCancelled = true; if (!continuationResolved && customOptions?.type !== 'attach' && (!openSessions.has(session.sessionId) || openSessions.get(session.sessionId).closed)) { const originalId = session.sessionId; session = await resolveResumeSession(session, { automatic, api: window.api, confirm: msg => window.confirm(msg), resolutions: window._startupResumeResolutions }); diff --git a/read-session-file.js b/read-session-file.js index 4af0c07f2..26558a7a6 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -167,7 +167,8 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { const cutoff = opts.dedupeSinceTimestamp || null; try { const stat = fs.statSync(filePath); - const content = fs.readFileSync(filePath, 'utf8'); + const fileBytes = fs.readFileSync(filePath); + const content = fileBytes.toString('utf8'); const lines = content.split('\n').filter(Boolean); let summary = ''; // Fallback title for a session whose only user turn is a slash command. @@ -179,6 +180,8 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let scheduleSlug = null; const continuationIds = new Set(); let continuationUnresolved = false; + let sealedContinuationIds = null; + let continuationInvalid = false; let incompleteTail = false; let customTitle = null; let aiTitle = null; @@ -193,15 +196,20 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { // mtime without any actual activity, so mtime can't be the displayed time. let firstTimestamp = null; let lastTimestamp = null; - for (const line of lines) { + for (const [lineNumber, line] of lines.entries()) { const continuationLine = Buffer.byteLength(line) <= 1024 * 1024; + const isTail = lineNumber === lines.length - 1 && !content.endsWith('\n'); + if (isTail) { + sealedContinuationIds = [...continuationIds]; + continuationInvalid = continuationUnresolved; + } // Per-line try/catch: a JSONL file being written concurrently by a live // Claude CLI session can have its tail captured mid-write — one truncated // line should not invalidate the whole file. Skip the malformed line and // keep parsing. let entry; try { entry = JSON.parse(line); } catch { - if (line === lines.at(-1) && !content.endsWith('\n')) incompleteTail = true; + if (isTail) incompleteTail = true; if (continuationLine && /"type"\s*:\s*"continued-in"/.test(line)) continuationUnresolved = true; continue; } @@ -291,6 +299,8 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { }; } + const pending = fileBytes.subarray(fileBytes.lastIndexOf(10) + 1); + const skipLine = pending.length > 1024 * 1024; return { sessionId: fileBase, folder, projectPath, summary, firstPrompt: summary, @@ -301,7 +311,13 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { modified: lastTimestamp || stat.mtime.toISOString(), fileMtime: stat.mtime.toISOString(), messageCount, textContent, slug, scheduleSlug, customTitle, aiTitle, - continuationIndex: JSON.stringify({ format: 3, ids: [...continuationIds], bytes: incompleteTail ? Buffer.byteLength(content.slice(0, content.lastIndexOf('\n') + 1)) : stat.size, complete: !incompleteTail, mtime: stat.mtime.toISOString(), unresolved: continuationUnresolved }), + continuationIndex: JSON.stringify({ format: 3, version: 3, ids: [...continuationIds], + sealedIds: sealedContinuationIds ?? [...continuationIds], bytes: fileBytes.length, + complete: fileBytes.length === stat.size && (!incompleteTail || skipLine), size: fileBytes.length, + mtime: stat.mtime.toISOString(), unresolved: continuationUnresolved, + invalid: sealedContinuationIds === null ? continuationUnresolved : continuationInvalid, + skipLine, pending: skipLine ? '' : pending.toString('base64'), + tail: fileBytes.subarray(Math.max(0, fileBytes.length - 64)).toString('hex') }), bridgeSessionId, entrypoint: typedInTerminal ? 'cli' : (entrypoint ?? ''), dailyMetrics, diff --git a/session-cache.js b/session-cache.js index 31c4b945e..1a9ef9215 100644 --- a/session-cache.js +++ b/session-cache.js @@ -1087,7 +1087,11 @@ async function resolveSessionContinuations(sessionId, { chunkBytes = 1024 * 1024 const row = getCachedSession?.(id); if (!row) { const { alias } = parseFolderKey(getCachedSession?.(sessionId)?.folder || ''); - if (alias === null && getSessionLiveElsewhere && await getSessionLiveElsewhere(id)) return { index: { ids: [] } }; + if (alias === null && getSessionLiveElsewhere) { + const checked = await getSessionLiveElsewhere(id); + if (!checked?.known) return null; + if (checked.live) return { index: { ids: [] } }; + } const root = alias === null ? PROJECTS_DIR : remoteRoots.get(alias); diskEntries ||= fs.promises.readdir(root, { recursive: true, withFileTypes: true }); const entries = await diskEntries; diff --git a/test/continuation-round4.test.js b/test/continuation-round4.test.js index da735f969..d30b51b18 100644 --- a/test/continuation-round4.test.js +++ b/test/continuation-round4.test.js @@ -139,13 +139,14 @@ test('R4 R2 ambiguous continuations still ask for a sidebar choice after indexin }); test('R4 R2 a manual open cancels automatic continuation retry', { timeout: 9000 }, async t => { - const h = setup(t, { old: ['new'], new: [] }); + const h = setup(t, { old: ['new'], new: [], other: [] }); const app = indexingHarness(h); h.rows.delete('new'); await h.restore(); - vm.runInContext('sessionOpenedOutsideRestore = true;', h.ctx); + vm.runInContext('restoringWorkingSet = false;', h.ctx); + await h.app.openSession(h.rows.get('other')); await app.markRestoreIndexingDone(); - assert.deepEqual(h.spawned, []); + assert.deepEqual(h.spawned.map(call => call.id), ['other']); }); test('R4 R2 remembered automatic open does not cancel the indexing retry', { timeout: 9000 }, async t => { @@ -189,11 +190,11 @@ test('R4 R3 shipped continuation IPC keeps a live missing sibling', { timeout: 9 vm.runInNewContext(source.slice(start, end), { ipcMain: { handle: (_, callback) => { handler = callback; } }, sessionCache: cache, - cliSessionState: { liveElsewhere: async (id, own, ownPids) => { + cliSessionState: { liveElsewhereChecked: async (id, own, ownPids) => { lookups.push(id); assert.equal(own, hasPty); assert.equal(ownPids, pids); - return id === 'fresh' ? { kind: 'bg', jobId: '1234abcd', pid: 7 } : null; + return { known: true, live: id === 'fresh' ? { kind: 'bg', jobId: '1234abcd', pid: 7 } : null }; } }, sessionHasPty: hasPty, ptyPids: pids, }); @@ -239,7 +240,7 @@ for (const kind of ['bg', 'interactive', 'schedule']) { test('R4 R3 failed live lookup cannot discard a missing target', { timeout: 9000 }, async t => { const h = setup(t, { old: ['new', 'fresh'], new: [] }); const result = await cache.resolveSessionContinuations('old', { - getSessionLiveElsewhere: async () => { throw new Error('descriptor unavailable'); }, + getSessionLiveElsewhere: async () => ({ known: false, reason: 'descriptor unavailable' }), }); assert.equal(result.unresolved, true); }); diff --git a/test/continuation-round5.test.js b/test/continuation-round5.test.js new file mode 100644 index 000000000..59a1d9163 --- /dev/null +++ b/test/continuation-round5.test.js @@ -0,0 +1,94 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { setup } = require('./continuations-harness'); +const { loadAppFunctions } = require('./app-source'); +const { readSessionFile } = require('../read-session-file'); +const { scanContinuationIndex } = require('../session-continuations'); +const cache = require('../session-cache'); +const cliSessionState = require('../cli-session-state'); + +test('R5 m1 shipped IPC cannot discard a missing target when descriptor directory is unreadable', { timeout: 9000 }, async t => { + const h = setup(t, { old: ['new', 'fresh'], new: [] }); + const descriptors = path.join(h.root, 'descriptors'); + fs.writeFileSync(descriptors, 'not a directory'); + cliSessionState.init({ dir: descriptors, activeSessions: new Map(), onIdle() {} }); + t.after(() => cliSessionState.stop()); + assert.equal((await cliSessionState.liveElsewhereChecked('fresh', () => false)).known, false); + const source = fs.readFileSync(path.join(__dirname, '../main.js'), 'utf8'); + const start = source.indexOf("ipcMain.handle('session-continuations'"); + const end = source.indexOf("ipcMain.handle('sessions-live-elsewhere'", start); + assert.ok(start >= 0 && end > start); + let handler; + vm.runInNewContext(source.slice(start, end), { + ipcMain: { handle: (_, callback) => { handler = callback; } }, + sessionCache: cache, cliSessionState, sessionHasPty: () => false, ptyPids: () => [], + }); + const result = await handler(null, 'old'); + assert.equal(result.unresolved, true, 'unreadable descriptors cannot confirm a missing sibling'); + assert.deepEqual(result.candidates.map(candidate => candidate.sessionId), ['new']); + h.ctx.api.getSessionContinuations = id => handler(null, id); + await h.restore(); + assert.deepEqual(h.spawned, []); + assert.deepEqual(h.prompts, []); + assert.equal(h.settings().openWorkingSet[0].sessionId, 'old'); +}); + +for (const mode of ['ask', 'auto']) { + test(`R5 M1 renderer reload with an active session cancels working-set restore (${mode})`, { timeout: 9000 }, async t => { + const h = setup(t, { old: [], other: [] }, { + savedEntries: [{ sessionId: 'old', active: true }, { sessionId: 'other' }], + }); + await h.ctx.api.setSetting('global', { ...h.settings(), restoreOnStartup: mode }); + h.ctx.api.getIndexingState = async () => ({ finished: false }); + h.ctx.restoreAgentsViewAtStartup = () => {}; + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/restore-plan.js'), 'utf8'), h.ctx); + vm.runInContext('restoringWorkingSet = false; activeSessionId = "old";', h.ctx); + const app = loadAppFunctions(h.ctx, { + declarations: ['restoreMode'], + functions: ['restoreStartupSessions', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'showColdCacheNotice', 'showNotRestoredNotice'], + }); + await app.restoreStartupSessions(); + assert.equal(h.dom.window.document.getElementById('restore-toast'), null, 'reload must not ask to restore saved sessions'); + assert.deepEqual(h.spawned.map(call => call.id), ['old'], 'reload reopens only the remembered active session'); + assert.equal(vm.runInContext('restorePlanner.isSettled()', h.ctx), true); + assert.deepEqual(h.prompts, []); + }); +} + +for (const suffix of [ + '\n', + JSON.stringify({ type: 'continued-in', sessionId: 'old', continuedInSessionId: 'tail' }), + '{"type":"continued-in","sessionId":"old","continuedInSessionId":"', + '{"type":"assistant","padding":"' + 'x'.repeat(1024 * 1024), +]) { + const name = suffix === '\n' ? 'sealed records' : suffix.includes('"tail"') ? 'valid unterminated tail' : suffix.includes('assistant') ? 'oversized partial tail' : 'partial continuation tail'; + test(`R5 m2 full read then append scans only appended bytes (${name})`, { timeout: 9000 }, t => { + const h = setup(t, { old: ['first'] }); + const file = path.join(h.root, h.folder, 'old.jsonl'); + fs.appendFileSync(file, suffix); + const previous = readSessionFile(file, h.folder, h.root).continuationIndex; + const size = fs.statSync(file).size; + const appended = name === 'partial continuation tail' ? 'tail"}\n' : name === 'oversized partial tail' ? '"}\n' : '\n'; + fs.appendFileSync(file, appended + JSON.stringify({ type: 'continued-in', sessionId: 'old', continuedInSessionId: 'new' }) + '\n'); + const reads = []; + const original = fs.readSync; + fs.readSync = function (fd, buffer, offset, length, position) { + reads.push({ position, length }); + return original.call(this, fd, buffer, offset, length, position); + }; + let index; + try { index = JSON.parse(scanContinuationIndex(file, 'old', previous)); } + finally { fs.readSync = original; } + assert.ok(reads.length >= 2); + assert.ok(reads.every(read => read.position >= size - 64), 'only the 64-byte cursor witness and appended bytes may be read'); + assert.equal(index.bytes, fs.statSync(file).size); + assert.equal(index.complete, true); + assert.equal(index.unresolved, false); + assert.deepEqual(index.ids, name.includes('tail') && !name.includes('oversized') ? ['first', 'tail', 'new'] : ['first', 'new']); + }); +} diff --git a/test/continuations-harness.js b/test/continuations-harness.js index f1c31f157..169dd61a7 100644 --- a/test/continuations-harness.js +++ b/test/continuations-harness.js @@ -45,7 +45,7 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } getSetting: async () => JSON.parse(fs.readFileSync(settingsFile, 'utf8')), setSetting: async (_, value) => fs.writeFileSync(settingsFile, JSON.stringify(value)), getSessionContinuations: async id => typeof cache.resolveSessionContinuations === 'function' - ? cache.resolveSessionContinuations(id, { chunkBytes, getSessionLiveElsewhere: async target => live[target] || null }) : { candidates: [], unresolved: false }, + ? cache.resolveSessionContinuations(id, { chunkBytes, getSessionLiveElsewhere: async target => ({ known: true, live: live[target] || null }) }) : { candidates: [], unresolved: false }, getSessionLiveElsewhere: async id => live[id] || null, getSessionsLiveElsewhere: async ids => Object.fromEntries(ids.filter(id => live[id]).map(id => [id, live[id]])), openTerminal: async (id, project, isNew, options) => { spawned.push({ id, project, options }); return { ok: true }; }, @@ -72,7 +72,7 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } vm.runInContext('rows.forEach(row => sessionMap.set(row.sessionId, row));', ctx); dom.window.showRestoreNotice = (_, text) => { dom.window.notice = text; }; const app = loadAppFunctions(ctx, { - declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone'], + declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled'], functions: ['runRestore', 'openSession', 'persistWorkingSet', 'pendingRestoreEntries', 'showLiveElsewhereNotice'], }); return { diff --git a/test/open-session-terminal.test.js b/test/open-session-terminal.test.js index e3bf79bec..c701887ae 100644 --- a/test/open-session-terminal.test.js +++ b/test/open-session-terminal.test.js @@ -50,7 +50,7 @@ async function withHarness(setup, fn) { }); setup(window, calls); const { openSession } = loadAppFunctions(ctx.context, { - declarations: ['ptyGenerations', 'pendingOpens'], + declarations: ['ptyGenerations', 'pendingOpens', 'continuationRetryCancelled'], functions: ['beginPtyOpen', 'settlePtyOpen', 'openSession'], }); await fn({ openSession, window, calls }); diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index 09611d522..e21827bfe 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -18,6 +18,7 @@ const APP_SRC = fs.readFileSync(path.join(ROOT, 'public', 'app.js'), 'utf8'); const MAIN_SRC = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8'); const PRELOAD_SRC = fs.readFileSync(path.join(ROOT, 'preload.js'), 'utf8'); const { ptyExitSignalName } = require('../pty-ops'); +const { extractDeclaration } = require('./app-source'); const { processExitLabel, exitBannerPhrase, exitBannerColour } = require('../public/process-exit'); const { setupTerminalDom } = require('./terminal-manager-harness'); @@ -98,6 +99,7 @@ function setup() { const ctx = dom.getInternalVMContext(); const run = (src, filename) => vm.runInContext(src, ctx, { filename }); run(PRELUDE, 'prelude.js'); + run(extractDeclaration(APP_SRC, 'continuationRetryCancelled'), 'app.js#continuationRetryCancelled'); run(fs.readFileSync(path.join(ROOT, 'public', 'process-exit.js'), 'utf8'), 'process-exit.js'); run(sliceBlock('function updateTerminalHeader() {'), 'app.js#updateTerminalHeader'); run(sliceBlock('async function openSession(session'), 'app.js#openSession'); diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index d3158a6d2..a94abf5e3 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -89,7 +89,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { function schedulePersistWorkingSet() {} function pollActiveSessions() {} `, ctx); - vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone']) + vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled']) .replace(/^const /gm, 'var ').replace(/^let /gm, 'var '), ctx); for (const name of ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore', 'openSession']) { vm.runInContext(functionSource(APP_SRC, name), ctx); diff --git a/test/schedule-live-resume.test.js b/test/schedule-live-resume.test.js index d75bde1e9..505cbcffb 100644 --- a/test/schedule-live-resume.test.js +++ b/test/schedule-live-resume.test.js @@ -102,7 +102,7 @@ function setup(t, { sandbox = false } = {}) { `, ctx); const app = loadAppFunctions(ctx, { functions: ['openSession', 'runRestore', 'pendingRestoreEntries', 'persistWorkingSet', 'showRestoreNotice', 'showLiveElsewhereNotice'], - declarations: ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone'], + declarations: ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled'], }); ctx.sessionMap.set(SID, session); t.after(() => {