Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ const rendererCrossFileGlobals = {
setAppShortcuts: 'readonly',

// Working-set restore decision (public/restore-plan.js)
planWorkingSetRestore: 'readonly',
createRestorePlanner: 'readonly',
};

module.exports = [
Expand Down
63 changes: 35 additions & 28 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
82 changes: 67 additions & 15 deletions public/restore-plan.js
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading