diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 31bd52c2..f39e0a34 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -71,6 +71,8 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree - **Open question #2 (which file keeps being written)**: established by measurement above — the mirror, not the parent. That is exactly why the mirror is never discarded: dropping it would silently erase every message written after the compaction, for as long as the session keeps being used. The union design keeps both files' rows, forever, each independently refreshed. - **Open question #3 (existing databases)**: repaired on the next index pass, not left alone. `bridgeSessionId` and `mergedIntoSessionId` are added purely via the schema-reconciliation block (not a numbered migration — deliberately, to avoid coupling `migrations.length` to unrelated migration-ordering tests; see `db-schema-reconcile.test.js`'s "foreign higher-version" precedent for why reconciliation is the version-independent mechanism). Their absence sets `mustReindex = true`, which wipes `session_cache` + `cache_meta` + the `initial_scan_complete` marker, forcing every folder through the now-merging indexer on the next scan — the same repair path already used when `fileMtime` (v7) or the fork subagent columns (v4) were introduced. +- **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. See `test/session-restore-cold-cache.test.js`. + ## Remote SSH hosts (issue #201) A declared SSH host's `~/.claude/projects` is mirrored into diff --git a/eslint.config.js b/eslint.config.js index eb3b3891..17f28f2c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -251,7 +251,7 @@ const rendererCrossFileGlobals = { setAppShortcuts: 'readonly', // Working-set restore decision (public/restore-plan.js) - planWorkingSetRestore: 'readonly', + createRestorePlanner: 'readonly', }; module.exports = [ diff --git a/public/app.js b/public/app.js index be5ad8ca..1aca3bfd 100644 --- a/public/app.js +++ b/public/app.js @@ -121,9 +121,10 @@ let restoringWorkingSet = false; let persistWorkingSetTimer = null; const RESTORE_STAGGER_MS = 500; -// Cold-cache retry: see .work-files/switchboard/restore-cold-cache-report.md -let restorePendingRetry = false; -let restoreRetryDone = false; +// Cold-cache retry-until-indexed: see .ai/contexts/session-cache.md. +let restorePlanner = null; +let restoreMode = 'off'; +let restoreIndexingDone = false; let sessionOpenedOutsideRestore = false; // Serialise concurrent read-modify-write calls so two async persist paths @@ -179,34 +180,46 @@ async function runRestore(list) { async function restoreWorkingSet() { const g = await window.api.getSetting('global'); - const mode = (g && g.restoreOnStartup) || 'ask'; + restoreMode = (g && g.restoreOnStartup) || 'ask'; const savedSet = (g && g.openWorkingSet) || []; document.getElementById('restore-cold-toast')?.remove(); - if (mode === 'off') return; + if (restoreMode === 'off') { + restorePlanner = null; + return; + } + + // one planner per startup — see .ai/contexts/session-cache.md ("Working-set restore") + if (!restorePlanner) { + restorePlanner = createRestorePlanner({ savedSet, askOnce: restoreMode === 'ask' }); + } - // Decision logic: restore-plan.js — see .work-files/switchboard/restore-cold-cache-report.md - const plan = planWorkingSetRestore({ - savedSet, + await tickRestorePlanner(); +} + +async function tickRestorePlanner() { + if (!restorePlanner) return; + + const plan = restorePlanner.tick({ sessionMap, openSessions, - retryDone: restoreRetryDone, + indexingDone: restoreIndexingDone, sessionOpenedOutsideRestore, }); - if (plan.action === 'nothing') return; - - if (plan.action === 'defer') { - restorePendingRetry = true; - if (mode === 'ask') showColdCacheNotice(savedSet.length); + if (plan.action === 'wait') { + if (restoreMode === 'ask') showColdCacheNotice(plan.remaining); return; } + document.getElementById('restore-cold-toast')?.remove(); + + if (plan.action === 'nothing') return; + const candidates = plan.candidates; - restorePendingRetry = false; - if (mode === 'auto') { + if (restoreMode === 'auto') { restoringWorkingSet = true; try { await runRestore(candidates); @@ -217,7 +230,7 @@ async function restoreWorkingSet() { return; } - // mode === 'ask': show a non-modal toast bar + // restoreMode === 'ask': non-modal toast; askOnce means it never re-asks mid-index const toast = document.createElement('div'); toast.id = 'restore-toast'; toast.className = 'restore-toast'; @@ -251,21 +264,12 @@ function showColdCacheNotice(count) { document.body.appendChild(toast); toast.querySelector('.restore-toast-dismiss').addEventListener('click', () => { toast.remove(); - restorePendingRetry = false; - restoreRetryDone = true; + restorePlanner?.dismiss(); }); } async function maybeRetryRestoreWorkingSet() { - if (!restorePendingRetry || restoreRetryDone) return; - if (sessionMap.size === 0) return; - restoreRetryDone = true; - restorePendingRetry = false; - if (sessionOpenedOutsideRestore) { - document.getElementById('restore-cold-toast')?.remove(); - return; - } - await restoreWorkingSet(); + await tickRestorePlanner(); } // Expose for tests @@ -1393,6 +1397,9 @@ let indexingBannerDismissed = false; function updateIndexingBanner(payload) { if (!payload || !payload.coldStart) return; if (payload.done) { + // indexing over: the planner's other stop condition — see .ai/contexts/session-cache.md + restoreIndexingDone = true; + tickRestorePlanner(); if (payload.error) { // A failed scan used to just hide the banner, leaving the tiny status // text as the only trace of the failure. Show it where the user was diff --git a/public/restore-plan.js b/public/restore-plan.js index d8111698..bed157f2 100644 --- a/public/restore-plan.js +++ b/public/restore-plan.js @@ -1,27 +1,79 @@ -// Dual-mode helper — see .work-files/switchboard/restore-cold-cache-report.md +// Dual-mode helper — see .ai/contexts/session-cache.md ("Working-set restore: retry until indexing is done") -function planWorkingSetRestore({ savedSet, sessionMap, openSessions, retryDone, sessionOpenedOutsideRestore }) { - if (!savedSet || savedSet.length === 0) { - return { action: 'nothing', candidates: [], notYetIndexedCount: 0 }; - } +function createRestorePlanner({ savedSet, maxTicks = 50, askOnce = false } = {}) { + const items = new Map((savedSet || []).map(item => [item.sessionId, item])); + const remaining = new Set(items.keys()); + let ticks = 0; + let settled = remaining.size === 0; - const candidates = savedSet.filter(item => - sessionMap.has(item.sessionId) && !openSessions.has(item.sessionId) - ); + function finish() { + remaining.clear(); + settled = true; + } - if (candidates.length > 0) { - return { action: 'restore', candidates, notYetIndexedCount: 0 }; + function dismiss() { + finish(); } - const notYetIndexedCount = savedSet.filter(item => !sessionMap.has(item.sessionId)).length; + function tick({ sessionMap, openSessions, indexingDone, sessionOpenedOutsideRestore } = {}) { + if (settled) return { action: 'nothing', candidates: [], remaining: 0 }; + + if (sessionOpenedOutsideRestore) { + finish(); + return { action: 'nothing', candidates: [], remaining: 0 }; + } + + for (const id of remaining) { + if (openSessions && openSessions.has(id)) remaining.delete(id); + } + if (remaining.size === 0) { + finish(); + return { action: 'nothing', candidates: [], remaining: 0 }; + } + + const indexedIds = [...remaining].filter(id => sessionMap && sessionMap.has(id)); + const allIndexed = indexedIds.length === remaining.size; + + if (askOnce) { + if (allIndexed || indexingDone) { + const candidates = indexedIds.map(id => items.get(id)); + finish(); + return candidates.length > 0 + ? { action: 'restore', candidates, remaining: 0 } + : { action: 'nothing', candidates: [], remaining: 0 }; + } + ticks++; + if (ticks >= maxTicks) { + finish(); + return { action: 'nothing', candidates: [], remaining: 0 }; + } + return { action: 'wait', candidates: [], remaining: remaining.size }; + } + + if (indexedIds.length > 0) { + const candidates = indexedIds.map(id => items.get(id)); + for (const id of indexedIds) remaining.delete(id); + if (remaining.size === 0) settled = true; + return { action: 'restore', candidates, remaining: remaining.size }; + } + + if (indexingDone) { + finish(); + return { action: 'nothing', candidates: [], remaining: 0 }; + } + + ticks++; + if (ticks >= maxTicks) { + finish(); + return { action: 'nothing', candidates: [], remaining: 0 }; + } - if (notYetIndexedCount > 0 && !retryDone && !sessionOpenedOutsideRestore) { - return { action: 'defer', candidates: [], notYetIndexedCount }; + return { action: 'wait', candidates: [], remaining: remaining.size }; } - return { action: 'nothing', candidates: [], notYetIndexedCount }; + return { tick, dismiss }; } if (typeof module !== 'undefined' && module.exports) { - module.exports = { planWorkingSetRestore }; + module.exports = { createRestorePlanner }; } diff --git a/test/session-restore-cold-cache.test.js b/test/session-restore-cold-cache.test.js index 8cbac229..8fa25d89 100644 --- a/test/session-restore-cold-cache.test.js +++ b/test/session-restore-cold-cache.test.js @@ -1,320 +1,169 @@ -// Tests for the cold-cache restore retry (issue #205). +// Tests for the cold-cache working-set restore planner (issue #205, audit +// finding 5, .work-files/switchboard/audit-fable-2026-09-11.md). // -// planWorkingSetRestore (public/restore-plan.js) is the actual decision this -// bug lives in: telling "not indexed yet" apart from "genuinely deleted" when -// a saved working-set item is missing from sessionMap. It is require()'d -// directly below — never re-implemented — so reverting the fix in -// public/restore-plan.js (or the app.js call site) turns these tests red. -// See .work-files/switchboard/restore-cold-cache-report.md. +// createRestorePlanner (public/restore-plan.js) is the actual decision this +// bug lives in: telling "not indexed yet" apart from "genuinely deleted" for +// each saved working-set id, across the many progressive `projects-changed` +// ticks a large history produces while populateCacheViaWorker streams +// sessionMap one folder at a time. It is require()'d directly below — never +// re-implemented — so reverting the fix in public/restore-plan.js turns +// these tests red. // -// The DOM/IO orchestration around it (app.js's restoreWorkingSet() / -// maybeRetryRestoreWorkingSet() — toasts, retry-once bookkeeping) still can't -// be loaded via vm.runInContext without prohibitive DOM scaffolding (see -// test/session-restore.test.js, same codebase precedent). The wiring harness -// in the second half of this file reproduces only that bookkeeping and always -// delegates the actual candidate-selection decision to the real, required -// planWorkingSetRestore — nothing about *what gets restored* is duplicated. - +// app.js's tickRestorePlanner()/restoreWorkingSet() orchestration (DOM +// toasts, which call sites tick the planner) still can't be loaded via +// vm.runInContext without prohibitive DOM scaffolding (see +// test/session-restore.test.js, same codebase precedent) and is out of +// scope here — only the wiring fact that updateIndexingBanner() ticks the +// planner on payload.done is asserted, by reading the app.js source. + +const fs = require('node:fs'); +const path = require('node:path'); const test = require('node:test'); const assert = require('node:assert/strict'); -const { planWorkingSetRestore } = require('../public/restore-plan'); - -// --------------------------------------------------------------------------- -// planWorkingSetRestore — direct coverage of the real, shipped decision. -// --------------------------------------------------------------------------- +const { createRestorePlanner } = require('../public/restore-plan'); -// Acceptance test 1 (issue #205): working set non-empty, sessionMap empty at -// decision time, then populated — the sessions must come back. +function sm(ids) { + return new Map(ids.map(id => [id, { sessionId: id }])); +} -test('planWorkingSetRestore: sessionMap empty at decision time → defer, not drop', () => { - const savedSet = [ - { sessionId: 'sa', projectPath: '/a', active: false }, - { sessionId: 'sb', projectPath: '/b', active: true }, - ]; - const plan = planWorkingSetRestore({ - savedSet, - sessionMap: new Map(), // cold cache: initial scan hasn't written anything yet - openSessions: new Map(), - retryDone: false, - sessionOpenedOutsideRestore: false, - }); +const SAVED_AB = [ + { sessionId: 'sa', projectPath: '/a', active: false }, + { sessionId: 'sb', projectPath: '/b', active: true }, +]; - assert.equal(plan.action, 'defer', 'must not silently drop the working set while the cache is cold'); - assert.equal(plan.candidates.length, 0); - assert.equal(plan.notYetIndexedCount, 2); -}); +// --------------------------------------------------------------------------- +// Auto mode (incremental restore) +// --------------------------------------------------------------------------- -test('planWorkingSetRestore: same saved set, sessionMap now populated → restore both', () => { - const savedSet = [ - { sessionId: 'sa', projectPath: '/a', active: false }, - { sessionId: 'sb', projectPath: '/b', active: true }, - ]; - const plan = planWorkingSetRestore({ - savedSet, - sessionMap: new Map([ - ['sa', { sessionId: 'sa' }], - ['sb', { sessionId: 'sb' }], - ]), - openSessions: new Map(), - retryDone: true, // the one retry firing, per app.js's guard - sessionOpenedOutsideRestore: false, - }); +test('auto: partial index at tick 1 restores the indexed subset, waits for the rest, then finishes', () => { + const planner = createRestorePlanner({ savedSet: SAVED_AB }); + const openSessions = new Map(); + // tick 1: only 'sa' indexed so far. + let plan = planner.tick({ sessionMap: sm(['sa']), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); assert.equal(plan.action, 'restore'); - assert.deepEqual(plan.candidates.map(i => i.sessionId).sort(), ['sa', 'sb'], 'both saved sessions come back once indexed'); -}); + assert.deepEqual(plan.candidates.map(i => i.sessionId), ['sa']); + assert.equal(plan.remaining, 1, 'sb still pending'); -// Acceptance test 2: a saved session genuinely absent from the finished -// index is dropped, the others still restore. - -test('planWorkingSetRestore: a session absent from the finished index is dropped, others restored', () => { - const savedSet = [ - { sessionId: 'sa', projectPath: '/a', active: false }, - { sessionId: 'gone', projectPath: '/x', active: true }, - ]; - const plan = planWorkingSetRestore({ - savedSet, - sessionMap: new Map([['sa', { sessionId: 'sa' }]]), // 'gone' never shows up — finished index - openSessions: new Map(), - retryDone: true, - sessionOpenedOutsideRestore: false, - }); + // tick 2: nothing new yet -> wait, not nothing. + plan = planner.tick({ sessionMap: sm(['sa']), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'wait'); + // tick 3: 'sb' shows up -> restores the rest. + plan = planner.tick({ sessionMap: sm(['sa', 'sb']), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); assert.equal(plan.action, 'restore'); - assert.equal(plan.candidates.length, 1, 'the missing session is dropped'); - assert.equal(plan.candidates[0].sessionId, 'sa', 'the surviving session is restored'); -}); + assert.deepEqual(plan.candidates.map(i => i.sessionId), ['sb']); + assert.equal(plan.remaining, 0); -// Guard branches - -test('planWorkingSetRestore: empty saved set → nothing, never defer', () => { - const plan = planWorkingSetRestore({ - savedSet: [], sessionMap: new Map(), openSessions: new Map(), - retryDone: false, sessionOpenedOutsideRestore: false, - }); + // tick 4: fully resolved -> nothing, forever. + plan = planner.tick({ sessionMap: sm(['sa', 'sb']), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); assert.equal(plan.action, 'nothing'); - assert.equal(plan.notYetIndexedCount, 0); -}); - -test('planWorkingSetRestore: everything already open → nothing (not the cold-cache case)', () => { - const savedSet = [{ sessionId: 'sa', projectPath: '/a', active: true }]; - const plan = planWorkingSetRestore({ - savedSet, - sessionMap: new Map([['sa', { sessionId: 'sa' }]]), - openSessions: new Map([['sa', { closed: false }]]), // already open - retryDone: false, - sessionOpenedOutsideRestore: false, - }); - - assert.equal(plan.action, 'nothing', 'nothing missing from the index -- no retry warranted'); }); -test('planWorkingSetRestore: retryDone → never defers again, even if still not indexed', () => { - const savedSet = [{ sessionId: 'sa', projectPath: '/a', active: true }]; - const plan = planWorkingSetRestore({ - savedSet, sessionMap: new Map(), openSessions: new Map(), - retryDone: true, sessionOpenedOutsideRestore: false, - }); - - assert.equal(plan.action, 'nothing', 'the one retry was already spent -- must not defer forever'); -}); - -test('planWorkingSetRestore: sessionOpenedOutsideRestore → never defers, even if still not indexed', () => { - const savedSet = [{ sessionId: 'sa', projectPath: '/a', active: true }]; - const plan = planWorkingSetRestore({ - savedSet, sessionMap: new Map(), openSessions: new Map(), - retryDone: false, sessionOpenedOutsideRestore: true, - }); - - assert.equal(plan.action, 'nothing', 'the user acted themselves -- no automatic restore behind them'); +test('auto: none indexed at tick 1 -> wait, never nothing', () => { + const planner = createRestorePlanner({ savedSet: SAVED_AB }); + const plan = planner.tick({ sessionMap: sm([]), openSessions: new Map(), indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'wait', 'must not silently drop the working set while the cache is cold'); + assert.equal(plan.remaining, 2); }); -test('planWorkingSetRestore: candidates already available take priority over deferring', () => { - const savedSet = [ - { sessionId: 'sa', projectPath: '/a', active: false }, - { sessionId: 'sc', projectPath: '/c', active: true }, - ]; - const plan = planWorkingSetRestore({ - savedSet, - sessionMap: new Map([['sa', { sessionId: 'sa' }]]), // 'sc' not indexed yet - openSessions: new Map(), - retryDone: false, - sessionOpenedOutsideRestore: false, - }); +test('auto: indexingDone with ids still missing -> nothing (deleted sessions), never an infinite wait', () => { + const planner = createRestorePlanner({ savedSet: SAVED_AB }); + // 'sa' shows up, indexing finishes, 'sb' never appeared. + let plan = planner.tick({ sessionMap: sm(['sa']), openSessions: new Map(), indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'restore'); - assert.equal(plan.action, 'restore', 'restore what is ready now rather than waiting'); - assert.equal(plan.candidates.length, 1); - assert.equal(plan.candidates[0].sessionId, 'sa'); + plan = planner.tick({ sessionMap: sm(['sa']), openSessions: new Map(), indexingDone: true, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'nothing', 'indexing finished, the rest is presumed deleted, not still pending'); }); -// --------------------------------------------------------------------------- -// Wiring — mirrors app.js's restoreWorkingSet()/maybeRetryRestoreWorkingSet() -// orchestration (toast display, retry-once bookkeeping). The candidate -// decision itself is always delegated to the real planWorkingSetRestore -// required above. -// --------------------------------------------------------------------------- - -function makeWiringHarness({ mode = 'ask', savedSet = [] } = {}) { - const settingsStore = { global: { restoreOnStartup: mode, openWorkingSet: savedSet } }; +test('auto: tick cap reached -> nothing', () => { + const planner = createRestorePlanner({ savedSet: [{ sessionId: 'sa', projectPath: '/a', active: true }], maxTicks: 3 }); const openSessions = new Map(); - const sessionMap = new Map(); - - const runRestoreLog = []; - const toastCalls = []; - const coldToastCalls = []; - const removedToastIds = []; - - let restorePendingRetry = false; - let restoreRetryDone = false; - let sessionOpenedOutsideRestore = false; - - function removeToast(id) { removedToastIds.push(id); } - - async function runRestore(list) { - runRestoreLog.push(list.slice()); - for (const item of list) openSessions.set(item.sessionId, { closed: false }); + let plan; + for (let i = 0; i < 3; i++) { + plan = planner.tick({ sessionMap: sm([]), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); } + assert.equal(plan.action, 'nothing', 'bounded — must give up eventually even with no signal'); +}); - function showColdCacheNotice(count) { - removeToast('restore-cold-toast'); - coldToastCalls.push({ count }); - } - - async function restoreWorkingSet() { - const g = settingsStore.global; - const modeVal = (g && g.restoreOnStartup) || 'ask'; - const savedSetVal = (g && g.openWorkingSet) || []; - - removeToast('restore-cold-toast'); - if (modeVal === 'off') return; - - const plan = planWorkingSetRestore({ - savedSet: savedSetVal, - sessionMap, - openSessions, - retryDone: restoreRetryDone, - sessionOpenedOutsideRestore, - }); - - if (plan.action === 'nothing') return; - - if (plan.action === 'defer') { - restorePendingRetry = true; - if (modeVal === 'ask') showColdCacheNotice(savedSetVal.length); - return; - } - - restorePendingRetry = false; - - if (modeVal === 'auto') { - await runRestore(plan.candidates); - return; - } - - toastCalls.push({ candidates: plan.candidates }); - } - - async function maybeRetryRestoreWorkingSet() { - if (!restorePendingRetry || restoreRetryDone) return; - if (sessionMap.size === 0) return; - restoreRetryDone = true; - restorePendingRetry = false; - if (sessionOpenedOutsideRestore) { - removeToast('restore-cold-toast'); - return; - } - await restoreWorkingSet(); - } - - return { - sessionMap, - openSessions, - runRestoreLog, - toastCalls, - coldToastCalls, - removedToastIds, - restoreWorkingSet, - maybeRetryRestoreWorkingSet, - isRetryDone: () => restoreRetryDone, - isRetryPending: () => restorePendingRetry, - markUserOpenedSession() { sessionOpenedOutsideRestore = true; }, - }; -} - -test('wiring: cold cache defers, then the retry restores once sessionMap is populated', async () => { - const h = makeWiringHarness({ - mode: 'auto', - savedSet: [ - { sessionId: 'sa', projectPath: '/a', active: false }, - { sessionId: 'sb', projectPath: '/b', active: true }, - ], - }); - - await h.restoreWorkingSet(); - assert.equal(h.runRestoreLog.length, 0); - assert.equal(h.isRetryPending(), true); - - h.sessionMap.set('sa', { sessionId: 'sa' }); - h.sessionMap.set('sb', { sessionId: 'sb' }); - await h.maybeRetryRestoreWorkingSet(); - - assert.equal(h.runRestoreLog.length, 1); - assert.deepEqual(h.runRestoreLog[0].map(i => i.sessionId).sort(), ['sa', 'sb']); +test('auto: dismiss stops the wait', () => { + const planner = createRestorePlanner({ savedSet: SAVED_AB }); + planner.dismiss(); + const plan = planner.tick({ sessionMap: sm([]), openSessions: new Map(), indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'nothing'); }); -test('wiring: ask mode shows a distinct notice for a cold cache, not silence', async () => { - const h = makeWiringHarness({ mode: 'ask', savedSet: [{ sessionId: 'sa', projectPath: '/a', active: true }] }); +test('auto: a session opened outside restore cancels the plan', () => { + const planner = createRestorePlanner({ savedSet: SAVED_AB }); + const plan = planner.tick({ sessionMap: sm(['sa']), openSessions: new Map(), indexingDone: false, sessionOpenedOutsideRestore: true }); + assert.equal(plan.action, 'nothing', 'no automatic restore behind the user once they have acted'); - await h.restoreWorkingSet(); + // Stays settled afterwards even if the flag clears and data arrives. + const plan2 = planner.tick({ sessionMap: sm(['sa', 'sb']), openSessions: new Map(), indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan2.action, 'nothing'); +}); - assert.equal(h.toastCalls.length, 0); - assert.equal(h.coldToastCalls.length, 1, 'a distinct cold-cache notice, not the normal toast, and not nothing'); +test('auto: ids already open (opened before the planner ran) are treated as satisfied', () => { + const planner = createRestorePlanner({ savedSet: SAVED_AB }); + const openSessions = new Map([['sa', { closed: false }]]); + const plan = planner.tick({ sessionMap: sm(['sa', 'sb']), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'restore'); + assert.deepEqual(plan.candidates.map(i => i.sessionId), ['sb'], 'sa is already open, not re-restored'); }); -test('wiring: ask mode with a genuinely empty saved set shows nothing (contrast with the cold-cache case)', async () => { - const h = makeWiringHarness({ mode: 'ask', savedSet: [] }); +test('empty saved set -> nothing, never waits', () => { + const planner = createRestorePlanner({ savedSet: [] }); + const plan = planner.tick({ sessionMap: sm([]), openSessions: new Map(), indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'nothing'); +}); - await h.restoreWorkingSet(); +// --------------------------------------------------------------------------- +// askOnce mode (the "ask" toast) — one decision, not one per tick. +// --------------------------------------------------------------------------- - assert.equal(h.toastCalls.length, 0); - assert.equal(h.coldToastCalls.length, 0); -}); +test('askOnce: waits until every saved id is indexed, then a single restore with all candidates', () => { + const planner = createRestorePlanner({ savedSet: SAVED_AB, askOnce: true }); + const openSessions = new Map(); -test('wiring: the retry never consumes itself on a no-op tick (sessionMap still empty)', async () => { - const h = makeWiringHarness({ mode: 'auto', savedSet: [{ sessionId: 'sa', projectPath: '/a', active: true }] }); + let plan = planner.tick({ sessionMap: sm(['sa']), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'wait', 'must not ask about a partial picture'); - await h.restoreWorkingSet(); - await h.maybeRetryRestoreWorkingSet(); // sessionMap still empty - assert.equal(h.isRetryDone(), false, 'a no-op tick must not burn the one retry attempt'); + plan = planner.tick({ sessionMap: sm(['sa', 'sb']), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'restore'); + assert.deepEqual(plan.candidates.map(i => i.sessionId).sort(), ['sa', 'sb']); - h.sessionMap.set('sa', { sessionId: 'sa' }); - await h.maybeRetryRestoreWorkingSet(); - assert.equal(h.runRestoreLog.length, 1, 'the retry still succeeds once real data arrives'); + // Never asks again. + plan = planner.tick({ sessionMap: sm(['sa', 'sb']), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'nothing'); }); -test('wiring: the retry fires at most once', async () => { - const h = makeWiringHarness({ mode: 'auto', savedSet: [{ sessionId: 'sa', projectPath: '/a', active: true }] }); +test('askOnce: indexingDone with some ids missing asks once with what indexed so far', () => { + const planner = createRestorePlanner({ savedSet: SAVED_AB, askOnce: true }); + const openSessions = new Map(); - await h.restoreWorkingSet(); - h.sessionMap.set('sa', { sessionId: 'sa' }); - await h.maybeRetryRestoreWorkingSet(); - assert.equal(h.runRestoreLog.length, 1); + let plan = planner.tick({ sessionMap: sm(['sa']), openSessions, indexingDone: false, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'wait'); - h.openSessions.delete('sa'); // pretend it closed again - await h.maybeRetryRestoreWorkingSet(); - assert.equal(h.runRestoreLog.length, 1, 'the retry never fires a second time'); + plan = planner.tick({ sessionMap: sm(['sa']), openSessions, indexingDone: true, sessionOpenedOutsideRestore: false }); + assert.equal(plan.action, 'restore'); + assert.deepEqual(plan.candidates.map(i => i.sessionId), ['sa']); }); -test('wiring: a session opened outside the restore flow cancels the pending retry', async () => { - const h = makeWiringHarness({ mode: 'auto', savedSet: [{ sessionId: 'sa', projectPath: '/a', active: true }] }); - - await h.restoreWorkingSet(); - assert.equal(h.isRetryPending(), true); +// --------------------------------------------------------------------------- +// Wiring — updateIndexingBanner(payload) in app.js must tick the planner +// when payload.done is true. app.js cannot be loaded headlessly (DOM), so +// this is a source assertion rather than a behavioral one; see header. +// --------------------------------------------------------------------------- - h.markUserOpenedSession(); - h.sessionMap.set('sa', { sessionId: 'sa' }); - await h.maybeRetryRestoreWorkingSet(); +test('wiring: updateIndexingBanner ticks the restore planner on payload.done', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8'); + const fnStart = src.indexOf('function updateIndexingBanner'); + assert.ok(fnStart !== -1, 'updateIndexingBanner must exist'); + const fnEnd = src.indexOf('\nfunction dismissIndexingBanner', fnStart); + assert.ok(fnEnd !== -1, 'dismissIndexingBanner must follow updateIndexingBanner'); + const body = src.slice(fnStart, fnEnd); - assert.equal(h.runRestoreLog.length, 0, 'no automatic restore behind the user once they have acted'); - assert.equal(h.isRetryDone(), true, 'consumed but skipped -- never fires again'); + assert.match(body, /if\s*\(payload\.done\)\s*\{/, 'must branch on payload.done'); + assert.match(body, /tickRestorePlanner\(\)/, 'must tick the planner when indexing finishes'); });