diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 1ed31dd11..3c30eae3d 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -347,3 +347,60 @@ 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 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 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. Several ids, unresolved graphs and +failed lookups hold the saved entry and join the non-blocking restore notice, +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. +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. + +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. 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. + +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 711420b59..cb5be26b0 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -628,3 +628,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..bba86a30e 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -1671,3 +1671,79 @@ 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 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 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. +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. + +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. +For a local target without a cache row, the IPC also supplies the existing +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 +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. + +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 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. +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 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 7e862328c..f1121a73b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +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, 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/db.js b/db.js index 4b56998c1..96b2f17ab 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 @@ -443,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 = ?'), @@ -564,7 +567,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 ); } }); @@ -904,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 f5f746240..d56a16fc6 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -44,5 +44,37 @@ 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 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 +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 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 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 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 01c5eadd1..94d51a505 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, @@ -3139,6 +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, { + getSessionLiveElsewhere: id => cliSessionState.liveElsewhereChecked(id, sessionHasPty, ptyPids), +})); 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..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(); @@ -188,10 +189,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); @@ -218,33 +219,55 @@ 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 = []; 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; + const originalSession = s; + 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, retryContinuation: retryAfterIndexing }); + skippedNow.push({ session: originalSession, continuation: true, waitingForIndex, retryAfterIndexing }); + 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 { @@ -256,6 +279,9 @@ async function runRestore(list) { if (activeItem && openSessions.has(activeItem.sessionId)) { showSession(activeItem.sessionId); } + if (retryAfterIndexing && restoreIndexingDone && skippedNow.some(item => item.continuation) && !continuationRetryCancelled) { + await markRestoreIndexingDone(); + } } async function restoreWorkingSet() { @@ -387,16 +413,24 @@ 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 && !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('. ')); } 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 { @@ -404,7 +438,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 || continuationRetryCancelled) 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() { @@ -1340,8 +1387,26 @@ 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 (!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 }); + 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 +1421,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 +1452,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); @@ -1639,23 +1711,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 c2620d841..745669e89 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,36 @@ 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, resolutions, onHold } = {}) { + if (!session || session.type === 'terminal' || session.remoteAlias) return session; + let result; + 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) { + 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; + } + if (!candidates.length) return session; + 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 + '?')) { + return { ...session, sessionId: candidate.sessionId }; + } + } + return confirm('No continuation selected.\n\nOpen the original session ' + session.sessionId + '?') ? session : 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..26558a7a6 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 @@ -166,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. @@ -176,6 +178,11 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let textContent = ''; let slug = null; 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; let agentId = null; @@ -189,13 +196,26 @@ 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 { continue; } + try { entry = JSON.parse(line); } catch { + if (isTail) incompleteTail = true; + if (continuationLine && /"type"\s*:\s*"continued-in"/.test(line)) continuationUnresolved = true; + continue; + } + const continuation = continuationLine ? continuationId(entry, fileBase) : null; + if (continuation) continuationIds.add(continuation); + 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; @@ -279,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, @@ -289,6 +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, 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 7cbde1498..1a9ef9215 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. @@ -19,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) { @@ -50,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; } @@ -237,6 +239,7 @@ function refreshFolder(folder, opts = {}) { filesToScan = enumerateSessionFiles(folderPath); } + let continuationBytesLeft = 1024 * 1024; const currentIds = new Set(); let changed = false; @@ -287,9 +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, folder, projectPath, summary: h.summary || cachedEntry.summary, firstPrompt: h.firstPrompt || cachedEntry.firstPrompt, @@ -1069,7 +1079,44 @@ function populateCacheViaWorker() { return populatePromise; } +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 => { + if (!validId(id)) return null; + const row = getCachedSession?.(id); + if (!row) { + const { alias } = parseFolderKey(getCachedSession?.(sessionId)?.folder || ''); + 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; + 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 !== 3 || !index.complete || index.bytes !== stat.size || index.mtime !== stat.mtime.toISOString()) { + do { + serialized = scanContinuationIndex(file, id, serialized, chunkBytes); + index = JSON.parse(serialized); + await new Promise(resolve => setImmediate(resolve)); + } while (!index.complete && index.bytes < index.size); + setCachedContinuationIndex(id, serialized); + } + return { index, modified: getCachedSession(id)?.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..660eca261 --- /dev/null +++ b/session-continuations.js @@ -0,0 +1,117 @@ +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; +} + +// 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 fd = fs.openSync(file, 'r'); + 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 === 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 : []); + 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; + 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 (/"type"\s*:\s*"continued-in"/.test(line.toString('utf8'))) invalid = true; + return false; + } + }; + const segment = bytes => { + if (!oversized) { + pending = Buffer.concat([pending, bytes]); + if (pending.length > 1024 * 1024) { + 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; + let start = 0, newline; + while ((newline = buffer.indexOf(10, start)) !== -1 && newline < n) { + segment(buffer.subarray(start, newline)); + if (!oversized) consume(pending); + pending = Buffer.alloc(0); + oversized = false; + start = newline + 1; + } + segment(buffer.subarray(start, n)); + } + 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; + } + 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, 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, 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; + 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); + let allMissing = true; + for (const child of children) { + 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 }; + if (await visit(sessionId, 0) === false) unresolved = true; + 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 new file mode 100644 index 000000000..b1be70e96 --- /dev/null +++ b/test/continuation-regressions.test.js @@ -0,0 +1,217 @@ +'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 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.map(call => call.id), ['old']); + assert.deepEqual(h.prompts, []); +}); + +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/continuation-round4.test.js b/test/continuation-round4.test.js new file mode 100644 index 000000000..d30b51b18 --- /dev/null +++ b/test/continuation-round4.test.js @@ -0,0 +1,246 @@ +'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: [], other: [] }); + const app = indexingHarness(h); + h.rows.delete('new'); + await h.restore(); + vm.runInContext('restoringWorkingSet = false;', h.ctx); + await h.app.openSession(h.rows.get('other')); + await app.markRestoreIndexingDone(); + 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 => { + 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: { liveElsewhereChecked: async (id, own, ownPids) => { + lookups.push(id); + assert.equal(own, hasPty); + assert.equal(ownPids, pids); + return { known: true, live: 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 () => ({ 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 new file mode 100644 index 000000000..169dd61a7 --- /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, 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 }; }, + }; + 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', 'restoreIndexingDone', 'continuationRetryCancelled'], + 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 e68fd2e21..c701887ae 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) } }; @@ -40,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 }; @@ -48,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 375dc3efe..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'); @@ -84,9 +85,11 @@ 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 = { + getSessionContinuations: async () => ({ candidates: [], unresolved: false, continued: false }), onProcessExited: (cb) => { exitHandler = cb; }, openTerminal: async () => { if (h.whileOpening) h.whileOpening(); @@ -96,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-continuations.test.js b/test/restore-continuations.test.js new file mode 100644 index 000000000..436c176d6 --- /dev/null +++ b/test/restore-continuations.test.js @@ -0,0 +1,215 @@ +'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 { 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, /waiting for indexing/); + }); +} + +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'], + ['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 holds ${name} and spawns nothing on dismissal`, { timeout: 9000 }, async t => { + const h = setup(t, graph); + await h.restore(); + assert.deepEqual(h.spawned, []); + 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.dom.window.notice, /old/); + } + }); +} + +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.click(); + 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 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.equal(h.prompts.length, 0); + assert.match(h.dom.window.notice, /sidebar/); +}); + +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 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.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 => { + 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-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index 77fb7057c..a94abf5e3 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; }, @@ -88,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', '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/restore-pending-persist.test.js b/test/restore-pending-persist.test.js index a62857c96..77ef086ea 100644 --- a/test/restore-pending-persist.test.js +++ b/test/restore-pending-persist.test.js @@ -20,8 +20,10 @@ 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 = { + 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)); }, }; @@ -44,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/resume-guard.test.js b/test/resume-guard.test.js index f87ef0c37..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 \}\)/); - 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..505cbcffb 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 }; }, @@ -101,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', 'continuationRetryCancelled'], }); ctx.sessionMap.set(SID, session); t.after(() => { 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']); +}); + 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 }]);