From 2afaef0eea08dae838479ab1aefa1b9a272670ee Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 13:31:30 +0200 Subject: [PATCH 1/6] (sidebar): test that a session moved into a worktree folder stays listed Reproduces #485 before the fix: a transcript the CLI moved from the repository's folder into a worktree folder carries the root as cwd in its first 256 KB, so the worktree folder resolves to no project and the running session's row is dropped. Covers a hashed long-path folder name, the read budget and memo, a vanished old folder, Touched paths written before the move, and the PTY exit handler. Claude-Session: https://claude.ai/code/session_01FDnFeSaiQWifMYd39ojyyt --- test/derive-project-path.test.js | 41 ++- test/worktree-moved-transcript.test.js | 427 +++++++++++++++++++++++++ 2 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 test/worktree-moved-transcript.test.js diff --git a/test/derive-project-path.test.js b/test/derive-project-path.test.js index e0d41e094..0079d7adf 100644 --- a/test/derive-project-path.test.js +++ b/test/derive-project-path.test.js @@ -162,29 +162,46 @@ test('extractCwdFromJsonl: finds cwd when file is larger than 256 KB and cwd is } }); -test('extractCwdFromJsonl: returns null when cwd only appears beyond the 256 KB scan window', () => { - // Documented accepted trade-off: real Claude Code transcripts always carry - // `cwd` on the very first JSONL line (the session-start summary record), so - // a cwd that lives only past the scan window indicates a malformed or exotic - // file that we deliberately do not support to avoid the re-read hot-loop. +test('deriveProjectPath: returns null when cwd only appears between the head and tail scan windows', () => { + // see .ai/contexts/session-cache.md ("Bounded cwd scan") const tmp = mkTmp(); try { const folder = path.join(tmp, encodeProjectPath(path.join(tmp, 'hidden-project'))); fs.mkdirSync(folder); const cwd = path.join(tmp, 'hidden-project'); - // Fill more than 256 KB with lines that have NO cwd field, then append - // the one line that has cwd — it lands beyond the scan window. const noHeader = JSON.stringify({ type: 'assistant', message: 'x'.repeat(80) }) + '\n'; const filePath = path.join(folder, 'late-cwd.jsonl'); const fillerCount = Math.ceil((CWD_SCAN_BYTES + 1024) / noHeader.length) + 10; const fd = fs.openSync(filePath, 'w'); - for (let i = 0; i < fillerCount; i++) { - fs.writeSync(fd, noHeader); - } - // cwd line is appended after the scan window + for (let i = 0; i < fillerCount; i++) fs.writeSync(fd, noHeader); fs.writeSync(fd, JSON.stringify({ type: 'summary', cwd }) + '\n'); + for (let i = 0; i < fillerCount; i++) fs.writeSync(fd, noHeader); fs.closeSync(fd); - assert.ok(fs.statSync(filePath).size > CWD_SCAN_BYTES, 'precondition: file must exceed scan window'); + assert.ok(fs.statSync(filePath).size > 2 * CWD_SCAN_BYTES, 'precondition: file must exceed both scan windows'); + assert.equal(deriveProjectPath(folder), null); + } finally { + cleanup(tmp); + } +}); + +test('deriveProjectPath: reads the tail window only after a head cwd naming the folder\'s repository', () => { + // see .ai/contexts/session-cache.md ("A transcript moved into a worktree folder") + const tmp = mkTmp(); + try { + const cwd = path.join(tmp, 'tail-project'); + const folder = path.join(tmp, encodeProjectPath(cwd)); + fs.mkdirSync(folder); + const filler = JSON.stringify({ type: 'assistant', message: 'x'.repeat(80) }) + '\n'; + const fillerCount = Math.ceil((CWD_SCAN_BYTES + 1024) / filler.length) + 10; + const write = (name, headCwd) => { + const fd = fs.openSync(path.join(folder, name), 'w'); + if (headCwd) fs.writeSync(fd, JSON.stringify({ type: 'user', cwd: headCwd }) + '\n'); + for (let i = 0; i < fillerCount; i++) fs.writeSync(fd, filler); + fs.writeSync(fd, JSON.stringify({ type: 'summary', cwd }) + '\n'); + fs.closeSync(fd); + }; + write('unrelated-head.jsonl', path.join(tmp, 'elsewhere')); + write('no-head-cwd.jsonl', null); assert.equal(deriveProjectPath(folder), null); } finally { cleanup(tmp); diff --git a/test/worktree-moved-transcript.test.js b/test/worktree-moved-transcript.test.js new file mode 100644 index 000000000..2f93429f2 --- /dev/null +++ b/test/worktree-moved-transcript.test.js @@ -0,0 +1,427 @@ +// see .ai/contexts/session-cache.md ("A transcript moved into a worktree folder", "A running session keeps its row") +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const vm = require('vm'); + +const sessionCache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); +const { deriveProjectPath, resolveSessionRealCwd } = require('../derive-project-path'); +const { listSessionTouchedFiles } = require('../session-touched-files'); + +const WINDOW = 256 * 1024; + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-wmt-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +const userLine = (cwd, content = 'hi') => JSON.stringify({ type: 'user', cwd, message: { role: 'user', content } }) + '\n'; + +function writeMovedTranscript(filePath, repo, worktree, { tailCwd = worktree } = {}) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const pad = 'x'.repeat(4000); + let body = ''; + for (let i = 0; i < 80; i++) body += userLine(repo, 'before the worktree ' + pad); + body += JSON.stringify({ type: 'worktree-state', worktreeSession: { worktreePath: worktree } }) + '\n'; + if (tailCwd) for (let i = 0; i < 3; i++) body += userLine(tailCwd, 'inside the worktree'); + fs.writeFileSync(filePath, body, 'utf8'); + assert.ok(fs.statSync(filePath).size > WINDOW + 50 * 1024, 'fixture must exceed the head scan window'); +} + +function makeLayout({ longRepo = false } = {}) { + const tmp = mkTmp(); + const projectsDir = path.join(tmp, 'projects'); + const repo = longRepo ? path.join(tmp, 'r'.repeat(120), 'repository-with-a-long-name-' + 'y'.repeat(40)) : path.join(tmp, 'repo'); + const worktree = path.join(repo, '.claude', 'worktrees', 'wt'); + fs.mkdirSync(worktree, { recursive: true }); + return { tmp, projectsDir, repo, worktree, rootFolder: encodeProjectPath(repo), wtFolder: encodeProjectPath(worktree) }; +} + +test('deriveProjectPath resolves a worktree folder whose only transcript was moved there from the root', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + writeMovedTranscript(path.join(folderPath, 'moved.jsonl'), l.repo, l.worktree); + const rejected = []; + assert.equal(deriveProjectPath(folderPath, l.wtFolder, { onRejected: (c) => rejected.push(c) }), l.repo); + assert.deepEqual(rejected, []); + } finally { + cleanup(l.tmp); + } +}); + +test('deriveProjectPath resolves a moved transcript when the folder name is hashed for a long path', () => { + const l = makeLayout({ longRepo: true }); + try { + assert.ok(l.wtFolder.length > 200, 'precondition: the worktree folder name is hashed'); + const folderPath = path.join(l.projectsDir, l.wtFolder); + writeMovedTranscript(path.join(folderPath, 'moved.jsonl'), l.repo, l.worktree); + assert.equal(deriveProjectPath(folderPath, l.wtFolder), l.repo); + assert.equal(resolveSessionRealCwd(l.projectsDir, 'moved'), l.worktree); + } finally { + cleanup(l.tmp); + } +}); + +test('deriveProjectPath still rejects a folder whose transcript never carries a cwd that encodes to it', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + writeMovedTranscript(path.join(folderPath, 'forged.jsonl'), l.repo, l.worktree, { tailCwd: path.join(l.repo, 'elsewhere') }); + const rejected = []; + assert.equal(deriveProjectPath(folderPath, l.wtFolder, { onRejected: (c) => rejected.push(c) }), null); + assert.deepEqual(rejected, [l.repo]); + } finally { + cleanup(l.tmp); + } +}); + +test('resolveSessionRealCwd finds the worktree cwd of a moved transcript', () => { + const l = makeLayout(); + try { + writeMovedTranscript(path.join(l.projectsDir, l.wtFolder, 'moved.jsonl'), l.repo, l.worktree); + assert.equal(resolveSessionRealCwd(l.projectsDir, 'moved'), l.worktree); + } finally { + cleanup(l.tmp); + } +}); + +test('the tail scan keeps a complete line that starts exactly at the tail window', () => { + const l = makeLayout(); + try { + const filePath = path.join(l.projectsDir, l.wtFolder, 'edge.jsonl'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const last = userLine(l.worktree); + const fillerLine = (n) => JSON.stringify({ type: 'assistant', message: 'x'.repeat(n) }) + '\n'; + const overhead = fillerLine(0).length; + fs.writeFileSync(filePath, userLine(l.repo) + fillerLine(WINDOW - overhead) + last + fillerLine(WINDOW - last.length - overhead)); + const size = fs.statSync(filePath).size; + assert.equal(fs.readFileSync(filePath).indexOf(last), size - WINDOW, 'precondition: the worktree line starts the tail window'); + assert.equal(resolveSessionRealCwd(l.projectsDir, 'edge'), l.worktree); + } finally { + cleanup(l.tmp); + } +}); + +function countReads(fn) { + const original = fs.readSync; + let bytes = 0; + fs.readSync = (...args) => { + const n = original.apply(fs, args); + bytes += n; + return n; + }; + try { + return { result: fn(), bytes }; + } finally { + fs.readSync = original; + } +} + +test('deriveProjectPath bounds the tail reads of one derivation and does not re-read unchanged unresolved transcripts', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + for (let i = 0; i < 10; i++) { + writeMovedTranscript(path.join(folderPath, `unresolved-${i}.jsonl`), l.repo, l.worktree, { tailCwd: null }); + } + const first = countReads(() => deriveProjectPath(folderPath, l.wtFolder)); + assert.equal(first.result, null); + assert.ok(first.bytes >= 10 * WINDOW, `the count must see the head reads; got ${first.bytes}`); + assert.ok(first.bytes <= 10 * WINDOW + 4 * (WINDOW + 1), `first derivation read ${first.bytes} bytes`); + + const unchangedPasses = []; + for (let pass = 0; pass < 3; pass++) unchangedPasses.push(countReads(() => deriveProjectPath(folderPath, l.wtFolder)).bytes); + const settled = countReads(() => deriveProjectPath(folderPath, l.wtFolder)); + assert.equal(settled.bytes, 0, `passes read ${JSON.stringify(unchangedPasses)} then ${settled.bytes} bytes`); + + fs.appendFileSync(path.join(folderPath, 'unresolved-3.jsonl'), userLine(l.worktree)); + const afterAppend = countReads(() => deriveProjectPath(folderPath, l.wtFolder)); + assert.equal(afterAppend.result, l.repo); + assert.ok(afterAppend.bytes <= 2 * WINDOW + 4096, `re-read ${afterAppend.bytes} bytes for one changed file`); + } finally { + cleanup(l.tmp); + } +}); + +test('deriveProjectPath resolves a transcript whose session started in a subdirectory of the repository', () => { + const l = makeLayout(); + try { + const sub = path.join(l.repo, 'packages', 'api'); + fs.mkdirSync(sub, { recursive: true }); + const folderPath = path.join(l.projectsDir, l.wtFolder); + writeMovedTranscript(path.join(folderPath, 'moved.jsonl'), sub, l.worktree); + assert.equal(deriveProjectPath(folderPath, l.wtFolder), l.repo); + } finally { + cleanup(l.tmp); + } +}); + +test('a folder left unresolved by the tail budget is not marked as indexed, so reconcile derives it again', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + for (let i = 0; i < 10; i++) { + writeMovedTranscript(path.join(folderPath, `unresolved-${i}.jsonl`), l.repo, l.worktree, { tailCwd: null }); + } + const metas = []; + const { db } = makeFakeDb({}); + db.setFolderMeta = (folder, projectPath, indexMtimeMs) => metas.push({ projectPath, indexMtimeMs }); + sessionCache.init({ PROJECTS_DIR: l.projectsDir, activeSessions: new Map(), getMainWindow: () => null, log: null, db }); + sessionCache.refreshFolder(l.wtFolder); + assert.deepEqual(metas[0], { projectPath: null, indexMtimeMs: 0 }); + for (let pass = 0; pass < 5 && metas[metas.length - 1].indexMtimeMs === 0; pass++) sessionCache.refreshFolder(l.wtFolder); + const last = metas[metas.length - 1]; + assert.equal(last.projectPath, null); + assert.ok(last.indexMtimeMs > 0, `a completely scanned folder is marked as indexed; got ${JSON.stringify(metas)}`); + } finally { + cleanup(l.tmp); + } +}); + +test('a moved transcript past the tail budget still resolves on a later derivation', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + for (let i = 0; i < 9; i++) { + writeMovedTranscript(path.join(folderPath, `a-unresolved-${i}.jsonl`), l.repo, l.worktree, { tailCwd: null }); + } + writeMovedTranscript(path.join(folderPath, 'z-moved.jsonl'), l.repo, l.worktree); + let result = null; + for (let pass = 0; pass < 5 && result === null; pass++) result = deriveProjectPath(folderPath, l.wtFolder); + assert.equal(result, l.repo); + } finally { + cleanup(l.tmp); + } +}); + +test('Touched leaves a relative path written before the move unresolved instead of resolving it in the worktree', async () => { + const l = makeLayout(); + try { + fs.writeFileSync(path.join(l.repo, 'rel.txt'), 'root'); + fs.writeFileSync(path.join(l.worktree, 'rel.txt'), 'worktree'); + const filePath = path.join(l.projectsDir, l.wtFolder, 'S1.jsonl'); + writeMovedTranscript(filePath, l.repo, l.worktree); + const body = fs.readFileSync(filePath, 'utf8'); + const write = JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ + { type: 'tool_use', id: 'toolu_w', name: 'Write', input: { file_path: 'rel.txt', content: 'x' } }, + ] } }) + '\n'; + fs.writeFileSync(filePath, userLine(l.repo) + write + body); + const r = await listSessionTouchedFiles('S1', { + projectsDir: l.projectsDir, + getCachedFolder: () => l.wtFolder, + isRemoteFolder: () => false, + isSensitive: async () => false, + }); + assert.equal(r.ok, true); + assert.deepEqual(r.files.map((f) => f.path), []); + assert.deepEqual(r.unresolved.map((u) => u.raw), ['rel.txt']); + } finally { + cleanup(l.tmp); + } +}); + +function makeFakeDb(rowsByFolder) { + const deleted = []; + const deletedFolders = []; + const db = { + deleteCachedFolder: (folder) => deletedFolders.push(folder), + getCachedByFolder: (folder) => rowsByFolder[folder] || [], + upsertCachedSessions: () => {}, + touchCachedModified: () => {}, + deleteCachedSession: (id) => deleted.push(id), + replaceSessionMetrics: () => {}, + deleteSearchFolder: (folder) => deletedFolders.push('search:' + folder), + deleteSearchSession: () => {}, + upsertSearchEntries: () => {}, + setFolderMeta: () => {}, + getFolderMeta: () => null, + getAllFolderMeta: () => new Map(), + getAllMeta: () => new Map(), + getAllCached: () => [], + getSetting: () => ({}), + getMeta: () => null, + setName: () => {}, + }; + return { db, deleted, deletedFolders }; +} + +function rowFor(sessionId, folder, folderPath, projectPath) { + return { + sessionId, folder, projectPath, modified: '2026-01-01T00:00:00.000Z', fileMtime: '2026-01-01T00:00:00.000Z', + filePath: path.join(folderPath, sessionId + '.jsonl'), parentSessionId: null, agentId: null, + }; +} + +function setupRootFolder(l, activeSessions) { + const folderPath = path.join(l.projectsDir, l.rootFolder); + fs.mkdirSync(folderPath, { recursive: true }); + fs.writeFileSync(path.join(folderPath, 'other.jsonl'), userLine(l.repo)); + const rows = { + [l.rootFolder]: [ + rowFor('live', l.rootFolder, folderPath, l.repo), + rowFor('gone', l.rootFolder, folderPath, l.repo), + ], + }; + const fake = makeFakeDb(rows); + sessionCache.init({ PROJECTS_DIR: l.projectsDir, activeSessions, getMainWindow: () => null, log: null, db: fake.db }); + return { ...fake, folderPath }; +} + +const running = () => new Map([['live', { exited: false, isPlainTerminal: false }]]); + +test('refreshFolder keeps the row of a running session whose transcript left the folder (full walk)', () => { + const l = makeLayout(); + try { + const { deleted } = setupRootFolder(l, running()); + sessionCache.refreshFolder(l.rootFolder); + assert.deepEqual(deleted, ['gone']); + } finally { + cleanup(l.tmp); + } +}); + +test('refreshFolder keeps the row of a running session whose transcript left the folder (targeted)', () => { + const l = makeLayout(); + try { + const { deleted } = setupRootFolder(l, running()); + sessionCache.refreshFolder(l.rootFolder, { files: new Set(['live.jsonl', 'gone.jsonl']) }); + assert.deepEqual(deleted, ['gone']); + } finally { + cleanup(l.tmp); + } +}); + +test('refreshFolder keeps the row of a running session when the transcript folder itself is removed', () => { + const l = makeLayout(); + try { + const { deleted, deletedFolders, folderPath } = setupRootFolder(l, running()); + fs.rmSync(folderPath, { recursive: true, force: true }); + sessionCache.refreshFolder(l.rootFolder); + assert.deepEqual(deletedFolders, []); + assert.deepEqual(deleted, ['gone']); + } finally { + cleanup(l.tmp); + } +}); + +test('dropFolderRows, the watcher\'s path for a vanished folder, keeps the row of a running session', () => { + const l = makeLayout(); + try { + const { deleted, deletedFolders } = setupRootFolder(l, running()); + sessionCache.dropFolderRows(l.rootFolder); + assert.deepEqual(deletedFolders, []); + assert.deepEqual(deleted, ['gone']); + } finally { + cleanup(l.tmp); + } +}); + +test('the watcher drops a vanished folder through dropFolderRows', () => { + const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + const flush = main.slice(main.indexOf('function flushChanges()'), main.indexOf('function recordChange(')); + assert.match(flush, /sessionCache\.dropFolderRows\(folder\)/); + assert.doesNotMatch(flush, /deleteCachedFolder\(/); +}); + +test('refreshFolder drops the row once the session has exited', () => { + const l = makeLayout(); + try { + const { deleted } = setupRootFolder(l, new Map([['live', { exited: true, isPlainTerminal: false }]])); + sessionCache.refreshFolder(l.rootFolder); + assert.deepEqual(deleted.sort(), ['gone', 'live']); + } finally { + cleanup(l.tmp); + } +}); + +test('releaseLiveSession re-checks the folder that kept the row, which then drops it', () => { + const l = makeLayout(); + try { + const activeSessions = running(); + const { deleted } = setupRootFolder(l, activeSessions); + sessionCache.refreshFolder(l.rootFolder); + assert.deepEqual(deleted, ['gone']); + activeSessions.delete('live'); + assert.equal(sessionCache.releaseLiveSession('live'), true); + assert.ok(deleted.includes('live'), `live must be dropped after exit; got ${JSON.stringify(deleted)}`); + } finally { + cleanup(l.tmp); + } +}); + +test('refreshFolder keeps a running session\'s row when its folder no longer resolves to a project', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + fs.mkdirSync(folderPath, { recursive: true }); + fs.writeFileSync(path.join(folderPath, 'stray.jsonl'), userLine(path.join(l.repo, 'elsewhere'))); + const rows = { + [l.wtFolder]: [ + rowFor('live', l.wtFolder, folderPath, l.repo), + rowFor('stray', l.wtFolder, folderPath, l.repo), + ], + }; + const { db, deleted, deletedFolders } = makeFakeDb(rows); + sessionCache.init({ PROJECTS_DIR: l.projectsDir, activeSessions: running(), getMainWindow: () => null, log: null, db }); + sessionCache.refreshFolder(l.wtFolder); + assert.deepEqual(deletedFolders, []); + assert.deepEqual(deleted, ['stray']); + } finally { + cleanup(l.tmp); + } +}); + +function runExitHandler({ releaseLiveSession }) { + const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8').replace(/\r\n/g, '\n'); + const start = main.indexOf(' ptyProcess.onExit('); + const end = main.indexOf('\n });', start) + '\n });'.length; + assert.ok(start > 0 && end > start, 'the PTY exit handler is found in main.js'); + const calls = { released: [], notified: 0, warned: [], deletedBeforeRelease: null }; + const activeSessions = new Map([['real', {}], ['orig', {}]]); + let handler = null; + const ctx = vm.createContext({ + ptyProcess: { onExit: (cb) => { handler = cb; } }, + session: { stopRequested: false, realSessionId: 'real', generation: 1 }, + sessionId: 'orig', + activeSessions, + ptyExitSignalName: () => null, + shutdownMcpServer: () => {}, + TRACE: { on: false }, + trace: () => {}, + mainWindow: null, + activityReporter: { sessionEnded: () => {} }, + sessionCache: { + releaseLiveSession: (id) => { + if (calls.deletedBeforeRelease === null) calls.deletedBeforeRelease = activeSessions.size === 0; + calls.released.push(id); + return releaseLiveSession(id); + }, + }, + notifyRendererProjectsChanged: () => { calls.notified++; }, + log: { warn: (m) => calls.warned.push(m) }, + }); + vm.runInContext(main.slice(start, end), ctx); + handler({ exitCode: 0, signal: 0 }); + return calls; +} + +test('the PTY exit handler releases both ids after dropping the session, and refreshes the sidebar when a row was released', () => { + const calls = runExitHandler({ releaseLiveSession: (id) => id === 'real' }); + assert.equal(calls.deletedBeforeRelease, true); + assert.deepEqual(calls.released, ['real', 'orig']); + assert.equal(calls.notified, 1); +}); + +test('the PTY exit handler survives a release that throws on a closed database', () => { + const calls = runExitHandler({ releaseLiveSession: () => { throw new Error('The database connection is not open'); } }); + assert.equal(calls.notified, 0); + assert.equal(calls.warned.length, 1); + assert.match(calls.warned[0], /database connection is not open/); +}); From da70e4779fa0894d6565295701a0b3d2eda750c0 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 13:31:31 +0200 Subject: [PATCH 2/6] (sidebar): keep a session that moved into a worktree folder When a session started at a repository's root enters a worktree, the CLI moves its transcript into the worktree's project folder. The head of that transcript still carries the root as cwd, which does not encode to the new folder, so deriveProjectPath rejected the folder, refreshFolder dropped it, and the running session vanished from the sidebar. When the first rejected cwd is the repository the folder may be a worktree of (compared through the encoder, so hashed long names match), the scan reads the rest of the head and then the last 256 KB; the trust rule is unchanged. Tail reads are budgeted per derivation and unresolved transcripts are memoised by path, size and mtime. Touched keeps the head-only cwd, so paths written before the move stay unresolved. A running session's cache row is no longer deleted by refreshes that follow from the disk, a vanished folder included, and is re-checked when its PTY exits. Claude-Session: https://claude.ai/code/session_01FDnFeSaiQWifMYd39ojyyt --- .ai/contexts/session-cache.md | 23 ++++++- CHANGELOG.md | 1 + derive-project-path.js | 124 ++++++++++++++++++++++++++++------ encode-project-path.js | 23 +++++-- main.js | 9 ++- session-cache.js | 61 +++++++++++++---- 6 files changed, 203 insertions(+), 38 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index fe0788ff9..efc236ef8 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -58,7 +58,7 @@ Where it applies: | Consumer of a transcript cwd | Status | |---|---| -| `deriveProjectPath` (sidebar project path, `session.projectPath`, `cache_meta`, `getKnownProjectPaths`, and the cold-start scan in `workers/scan-projects.js`) | Verified: a JSONL whose cwd does not verify is skipped, then the worktree collapse applies to the verified cwd; no verified JSONL gives `null` | +| `deriveProjectPath` (sidebar project path, `session.projectPath`, `cache_meta`, `getKnownProjectPaths`, and the cold-start scan in `workers/scan-projects.js`) | Verified: a JSONL with no cwd that verifies, in its head or tail window (see "A transcript moved into a worktree folder"), is skipped, then the worktree collapse applies to the verified cwd; no verified JSONL gives `null` | | `refreshFolder` reuse of a stored `cache_meta.projectPath`; `buildProjectsFromCache` (empty-folder section); `reconcileCacheFromFilesystem` | Verified by `storedProjectPathMatchesFolder` (the verified path, the repository of a worktree folder, or the remap record). A value stored before the upgrade is re-derived: `reconcileCacheFromFilesystem` refreshes a folder whose stored path fails the check even when its mtime is current, and `refreshFolder` rewrites a cached row whose `projectPath` differs from the folder's even when its file is unchanged. A folder with no verifiable transcript has its cached rows deleted | | `resolveSessionRealCwd` (resume and fork spawn cwd in `open-terminal`, Changes panel, panel terminal, terminal path links, subagent worktree discovery, the sandbox bind folder, which follows the spawn cwd) | Verified against the folder holding the session's JSONL, which main finds on disk; a transcript that does not verify is skipped for the next folder holding the same id; none left means resume starts in the requested project path as for a session without a recorded cwd | | `create-schedule-session` `mkdir enc(projectPath)` and `open-terminal` registration of `projectPath` | The path comes from the sidebar, so from `deriveProjectPath`; the registration stays a plain launch registration | @@ -69,6 +69,27 @@ A folder that holds transcripts with a cwd but none that verifies is logged once Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit hash, so a path of 200 characters or more can collide (`enc(P/long) === enc(P)`), seed included. The hash is taken over the raw characters of the path, as the CLI does; normalising before hashing would stop matching the CLI's folder names, so it was left alone (the verified cwd is already `path.resolve`d before it is encoded). A transcript written by the CLI whose cwd does not encode to its folder (a symlinked cwd named by its real path, say) no longer gives a project path or a resume directory. A `projectPath` persisted by session restore before the upgrade is not re-verified; the renderer's own strings are out of scope. +## Bounded cwd scan + +A transcript's cwd is read from bounded windows of the file (`CWD_SCAN_BYTES`, 256 KB, in `derive-project-path.js`), never from the whole of it. Reading the whole file froze the main process: `refreshFolder()` derives the project path on every watcher flush, so a 338 MB host-session JSONL meant a multi-second `readFileSync` per flush, back to back (witnessed 2026-06-11: main thread pegged ~65% CPU re-reading the same file in a loop, UI freezes). A cwd that appears only between the head and tail windows is not seen. + +## A transcript moved into a worktree folder + +When a session started at a repository's root enters a worktree (`EnterWorktree`), the CLI moves its transcript from `enc(P)` to `enc(P/.claude/worktrees/x)`; the old folder can disappear with it. The lines written before the move keep `cwd = P`, which does not encode to the new folder; only the lines written after carry the worktree's cwd. Witnessed 2026-10-07 with a 600 KB transcript: the first worktree cwd sat at byte 483 010, past the 256 KB head window, so `deriveProjectPath` returned `null` for the folder, `refreshFolder` dropped it, and the session, whose transcript had also left `enc(P)`, vanished from the sidebar while its PTY kept running. + +`extractVerifiedCwdFromJsonl(filePath, folderName, tailBudget)` returns the first cwd of the head window that verifies against the folder. The first rejected cwd ends the scan, as the head-only scan did, unless the folder may be a worktree folder of that cwd or of one of its ancestors (`mayBeWorktreeFolderOf`; a session started in `P/sub` moves the same way); then the rest of the head and, failing that, the file's last 256 KB are searched, last line first. The trust rule is unchanged: whichever line it comes from, a cwd is used only if it encodes to the folder holding the transcript. + +- **Long paths.** `encodeProjectPath` truncates a name over 200 characters and appends a hash of the whole path, so the folder of `P/.claude/worktrees/x` does not start with `enc(P)` when either is long. `mayBeWorktreeFolderOf` compares through `encodedFolderMayExtend` (`encode-project-path.js`), which applies the encoder's sanitising and its 200-character cut to `P//` and compares that prefix only. It is a gate for reading more of the file, not a trust decision, so a false positive costs one bounded read and nothing else. `storedProjectPathMatchesFolder` keeps its own exact prefix check. +- **Cost.** One derivation (`deriveProjectPath`) gives the tail reads a budget of four windows (1 MiB). A transcript whose tail was skipped for lack of budget is not memoised, and the derivation reports itself incomplete (`opts.onIncomplete`). `refreshFolder` then stores the folder's `indexMtimeMs` as 0 instead of its mtime, so `reconcileCacheFromFilesystem` derives it again on its next pass; each pass reads only the transcripts not yet memoised, so the folder resolves, or is marked indexed as unresolved, after a bounded number of passes. Every transcript found unresolved after a complete scan is memoised by path, size and mtime (`unresolvedMemo`, at most 4 096 entries), and is not read again until it changes. A remap rewrites the transcripts it remaps, so it changes their mtime and clears their memo. A file rewritten to the same size with its mtime restored keeps a stale memo; the CLI only appends, so this is not a case it produces. Measured on a folder of 50 rejected 660 KB transcripts: 7.9 ms per flush with the former head-only scan, which read every head on every flush; 0.64 ms per flush averaged over repeated flushes with the memo, the first pass reading the heads as before. +- **Callers.** `deriveProjectPath` (local) and `resolveSessionRealCwd` go through it; the remote path keeps the plain head scan. The Touched panel does not: it resolves a relative touched path against one cwd per transcript, and the worktree cwd of a moved transcript is wrong for the touches made before the move. It keeps the head-only `extractCwdFromJsonl`, so the moved transcript's head cwd, which does not verify, leaves those touches unresolved. +- **Residual.** A transcript whose last line alone exceeds 256 KB (a large tool result) has no complete line in the tail window. While its session runs, the row is kept (next section); after exit, the folder resolves again only once a later line carries the worktree's cwd. + +## A running session keeps its row + +`dropFolderRows(folder, { search })` (`session-cache.js`) deletes a folder's `session_cache` rows except those of a session with a live, non-plain PTY in `activeSessions` (`exited` false), and deletes the whole folder only when no row is kept. It is the path for every deletion that follows from the disk: a folder that has vanished (`refreshFolder`'s first branch and the watcher's `flushChanges` in `main.js`), a folder that stops resolving to a project, and a cold-scan folder with no verifiable transcript. `refreshFolder` applies the same rule to a single transcript missing from its folder (full walk and targeted). Deliberate deletions (hide or remove a project, drop a remote host) still use `deleteCachedFolder`. + +A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiveSession` for both ids after removing them from `activeSessions`; it refreshes that folder again, which drops the row if its transcript is still gone. That call is the exit handler's only database access and is wrapped in a `try`: at quit, `before-quit` kills the PTYs and `will-quit` closes the database, so a late exit event can meet a closed connection. A transcript that leaves a folder while its session runs therefore leaves the session in the sidebar until it reappears elsewhere, where the upsert on the same `sessionId` replaces the row's folder. + ## Non-obvious behaviors - **`resolveWorktreePath` collapses `/.worktrees/` → ``** when the parent dir exists. Consequence: many `~/.claude/projects/-home-...workspace-myproject--worktrees-X` folders derive to the same projectPath. Callers must dedupe (see `get-work-files` IPC for the pattern). diff --git a/CHANGELOG.md b/CHANGELOG.md index af8b81263..d681fabb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - Sessions a program started through the Claude Agent SDK, such as headless runs or review agents, no longer fill the sidebar and grid. Turn off **Hide SDK-launched Sessions** in Global Settings to list them again. Sessions you have open, scheduled tasks and sessions you continued by typing in them stay listed, and the activity heatmap still counts the hidden ones. (#486) ### Fixed - When the Claude CLI reports a session's id in a different letter case, the subagents that session started or finished during a turn now show up when the turn ends. (#487) +- A session that moves into a worktree while it runs stays in the sidebar, under its project, instead of disappearing while its terminal keeps running. A running session also stays listed when its transcript leaves its folder for any other reason. (#485) - Worktree folders from another host, or whose repository is not listed, show in the sidebar instead of being nested under the wrong folder or not shown at all. The worktrees of a repository hidden with Hide Project stay hidden. (#473) - Remote triggers refuse commands containing invisible format characters, default-ignorable characters or braille blanks, including joined emoji, emoji with variation selectors (such as hearts), soft hyphens and right-to-left marks. Fullwidth slash, exclamation and number-sign prefixes are refused too. (#440) diff --git a/derive-project-path.js b/derive-project-path.js index c7dd37329..dbaef4dea 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -1,13 +1,8 @@ const fs = require('fs'); const path = require('path'); -const { encodeProjectPath, verifiedTranscriptCwd } = require('./encode-project-path'); - -// Only the head of the file is scanned: every session/subagent transcript -// carries `cwd` on its first JSONL line. Reading the whole file here froze -// the main process — refreshFolder() derives the project path on every -// watcher flush, so a 338 MB host-session JSONL meant a multi-second -// readFileSync per flush, back to back (witnessed 2026-06-11: main thread -// pegged ~65% CPU re-reading the same file in a loop, UI freezes). +const { encodeProjectPath, encodedFolderMayExtend, verifiedTranscriptCwd } = require('./encode-project-path'); + +// see .ai/contexts/session-cache.md ("Bounded cwd scan") const CWD_SCAN_BYTES = 256 * 1024; function extractCwdFromJsonl(filePath) { @@ -33,6 +28,79 @@ function extractCwdFromJsonl(filePath) { return null; } +function* cwdsInWindow(fd, start, fileSize) { + const from = Math.max(0, start - 1); + const buf = Buffer.alloc(Math.min(CWD_SCAN_BYTES + start - from, fileSize - from)); + const bytesRead = fs.readSync(fd, buf, 0, buf.length, from); + const lines = buf.toString('utf8', 0, bytesRead).split('\n'); + if (from + bytesRead < fileSize) lines.pop(); + if (start > 0) lines.shift(); + for (const line of lines) { + if (!line) continue; + try { + const parsed = JSON.parse(line); + if (parsed.cwd) yield parsed.cwd; + } catch {} + } +} + +const WORKTREE_DIRS = ['.worktrees', '.claude-worktrees', path.join('.claude', 'worktrees')]; + +function mayBeWorktreeFolderOf(cwd, folderName) { + if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false; + for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) { + if (WORKTREE_DIRS.some((wt) => encodedFolderMayExtend(folderName, path.join(dir, wt) + path.sep))) return true; + if (path.dirname(dir) === dir) return false; + } +} + +// see .ai/contexts/session-cache.md ("A transcript moved into a worktree folder") +function extractVerifiedCwdFromJsonl(filePath, folderName, tailBudget = null) { + let fd; + let firstRejected = null; + try { + fd = fs.openSync(filePath, 'r'); + const size = fs.fstatSync(fd).size; + for (const cwd of cwdsInWindow(fd, 0, size)) { + const verified = verifiedTranscriptCwd(cwd, folderName); + if (verified) return { cwd: verified, rejected: null, complete: true }; + if (firstRejected !== null) continue; + firstRejected = cwd; + if (!mayBeWorktreeFolderOf(cwd, folderName)) return { cwd: null, rejected: cwd, complete: true }; + } + if (firstRejected !== null && size > CWD_SCAN_BYTES) { + if (tailBudget && tailBudget.bytes < CWD_SCAN_BYTES) return { cwd: null, rejected: firstRejected, complete: false }; + if (tailBudget) tailBudget.bytes -= CWD_SCAN_BYTES; + const tail = [...cwdsInWindow(fd, size - CWD_SCAN_BYTES, size)].reverse(); + for (const cwd of tail) { + const verified = verifiedTranscriptCwd(cwd, folderName); + if (verified) return { cwd: verified, rejected: null, complete: true }; + } + } + } catch {} finally { + if (fd !== undefined) { try { fs.closeSync(fd); } catch {} } + } + return { cwd: null, rejected: firstRejected, complete: true }; +} + +const DERIVE_TAIL_BUDGET_BYTES = 4 * CWD_SCAN_BYTES; +const UNRESOLVED_MEMO_MAX = 4096; +const unresolvedMemo = new Map(); + +function unresolvedCwdOf(filePath, folderName) { + let stat; + try { stat = fs.statSync(filePath); } catch { return null; } + const memo = unresolvedMemo.get(filePath); + if (memo && memo.folderName === folderName && memo.size === stat.size && memo.mtimeMs === stat.mtimeMs) return memo; + return { stat }; +} + +function rememberUnresolved(filePath, folderName, stat, rejected) { + unresolvedMemo.delete(filePath); + if (unresolvedMemo.size >= UNRESOLVED_MEMO_MAX) unresolvedMemo.delete(unresolvedMemo.keys().next().value); + unresolvedMemo.set(filePath, { folderName, size: stat.size, mtimeMs: stat.mtimeMs, rejected }); +} + function resolveWorktreePath(cwd) { if (!cwd) return cwd; // Detect worktree paths: /.claude-worktrees/, /.worktrees/, or /.claude/worktrees/ @@ -49,26 +117,44 @@ function deriveProjectPath(folderPath, folderName, opts) { const name = folderName || path.basename(folderPath); const remote = !!(opts && opts.remote); let firstRejected = null; - const trusted = (cwd) => { - if (remote) return typeof cwd === 'string' && cwd ? cwd : null; - const verified = verifiedTranscriptCwd(cwd, name); - if (!verified && cwd && firstRejected === null) firstRejected = cwd; - return verified; + const tailBudget = { bytes: DERIVE_TAIL_BUDGET_BYTES }; + let incomplete = false; + const trustedCwdOf = (filePath) => { + if (remote) { + const cwd = extractCwdFromJsonl(filePath); + return typeof cwd === 'string' && cwd ? cwd : null; + } + const memo = unresolvedCwdOf(filePath, name); + if (!memo) return null; + if (!memo.stat) { + if (memo.rejected && firstRejected === null) firstRejected = memo.rejected; + return null; + } + const { cwd, rejected, complete } = extractVerifiedCwdFromJsonl(filePath, name, tailBudget); + if (cwd) { + unresolvedMemo.delete(filePath); + return cwd; + } + if (rejected && firstRejected === null) firstRejected = rejected; + if (complete) rememberUnresolved(filePath, name, memo.stat, rejected); + else incomplete = true; + return null; }; - const result = deriveVerified(folderPath, trusted); + const result = deriveVerified(folderPath, trustedCwdOf); if (result === null && firstRejected !== null && opts && typeof opts.onRejected === 'function') { opts.onRejected(firstRejected); } + if (result === null && incomplete && opts && typeof opts.onIncomplete === 'function') opts.onIncomplete(); return result; } -function deriveVerified(folderPath, trusted) { +function deriveVerified(folderPath, trustedCwdOf) { try { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); // Check direct .jsonl files first for (const e of entries) { if (e.isFile() && e.name.endsWith('.jsonl')) { - const cwd = trusted(extractCwdFromJsonl(path.join(folderPath, e.name))); + const cwd = trustedCwdOf(path.join(folderPath, e.name)); if (cwd) return resolveWorktreePath(cwd); } } @@ -87,7 +173,7 @@ function deriveVerified(folderPath, trusted) { if (agentFiles.length > 0) jsonlPath = path.join(subDir, 'subagents', agentFiles[0]); } if (jsonlPath) { - const cwd = trusted(extractCwdFromJsonl(jsonlPath)); + const cwd = trustedCwdOf(jsonlPath); if (cwd) return resolveWorktreePath(cwd); } } @@ -170,11 +256,11 @@ function resolveSessionRealCwd(projectsDir, sessionId, preferredFolder) { for (const folder of folders) { const jsonl = path.join(projectsDir, folder, sessionId + '.jsonl'); if (!fs.existsSync(jsonl)) continue; - const cwd = verifiedTranscriptCwd(extractCwdFromJsonl(jsonl), folder); + const { cwd } = extractVerifiedCwdFromJsonl(jsonl, folder); if (cwd) return cwd; } } catch {} return null; } -module.exports = { deriveProjectPath, storedProjectPathMatchesFolder, resolveWorktreePath, extractCwdFromJsonl, resolveSessionRealCwd, sessionTranscriptExists, isGitRepo }; +module.exports = { deriveProjectPath, storedProjectPathMatchesFolder, resolveWorktreePath, extractCwdFromJsonl, extractVerifiedCwdFromJsonl, resolveSessionRealCwd, sessionTranscriptExists, isGitRepo }; diff --git a/encode-project-path.js b/encode-project-path.js index 57913d5a4..83b207f2d 100644 --- a/encode-project-path.js +++ b/encode-project-path.js @@ -3,14 +3,29 @@ const path = require('path'); // Mirror Claude CLI's project-folder naming so Switchboard-created folders // match the ones the CLI writes for the same project path. // Reverse-engineered from claude CLI 2.1.126. +const ENCODED_NAME_MAX = 200; + +function sanitizeProjectPath(projectPath) { + return projectPath.replace(/[^a-zA-Z0-9]/g, '-'); +} + function encodeProjectPath(projectPath) { - const sanitized = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); - if (sanitized.length <= 200) return sanitized; + const sanitized = sanitizeProjectPath(projectPath); + if (sanitized.length <= ENCODED_NAME_MAX) return sanitized; let h = 0; for (let i = 0; i < projectPath.length; i++) { h = (h << 5) - h + projectPath.charCodeAt(i) | 0; } - return sanitized.slice(0, 200) + '-' + Math.abs(h).toString(36); + return sanitized.slice(0, ENCODED_NAME_MAX) + '-' + Math.abs(h).toString(36); +} + +// see .ai/contexts/session-cache.md ("A transcript moved into a worktree folder") +function encodedFolderMayExtend(folderName, pathPrefix) { + const prefix = sanitizeProjectPath(pathPrefix); + if (folderName.length <= ENCODED_NAME_MAX) { + return folderName.length > prefix.length && folderName.startsWith(prefix); + } + return folderName.startsWith(prefix.slice(0, ENCODED_NAME_MAX)); } let remappedProjectReader = () => null; @@ -44,4 +59,4 @@ function decodeProjectFolderBestEffort(folder) { return folder.replace(/-/g, '/'); } -module.exports = { encodeProjectPath, decodeProjectFolderBestEffort, verifiedTranscriptCwd, setRemappedProjectReader }; +module.exports = { encodeProjectPath, encodedFolderMayExtend, decodeProjectFolderBestEffort, verifiedTranscriptCwd, setRemappedProjectReader }; diff --git a/main.js b/main.js index 7e9f2ba1b..818a7bf41 100644 --- a/main.js +++ b/main.js @@ -2431,6 +2431,13 @@ function wireSessionPty(session, sessionId, ptyProcess) { activeSessions.delete(sessionId); activityReporter.sessionEnded(realId); activityReporter.sessionEnded(sessionId); + try { + const releasedReal = sessionCache.releaseLiveSession(realId); + const releasedOriginal = sessionCache.releaseLiveSession(sessionId); + if (releasedReal || releasedOriginal) notifyRendererProjectsChanged(); + } catch (err) { + log.warn(`[session-cache] releasing kept row of ${realId} failed: ${err.message}`); + } }); } @@ -3181,7 +3188,7 @@ function startProjectsWatcher() { refreshFolder(folder, { files: scope }); } } else { - deleteCachedFolder(folder); + sessionCache.dropFolderRows(folder); } changed = true; } diff --git a/session-cache.js b/session-cache.js index d8e3b2de8..1484a2884 100644 --- a/session-cache.js +++ b/session-cache.js @@ -82,11 +82,48 @@ function warnRejectedCwd(folder, cwd) { if (log && log.warn) log.warn(`[session-cache] no transcript of folder ${folder} has a cwd that encodes to it; first rejected cwd: ${JSON.stringify(cwd)}`); } -function deriveFolderProjectPath(folderPath, folderKey) { +// see .ai/contexts/session-cache.md ("A running session keeps its row") +const keptLiveRows = new Map(); + +function isRunningSession(sessionId) { + const s = activeSessions && activeSessions.get(sessionId); + return !!s && !s.exited && !s.isPlainTerminal; +} + +function keepIfRunning(sessionId, folder) { + if (!isRunningSession(sessionId)) return false; + keptLiveRows.set(sessionId, folder); + return true; +} + +function dropFolderRows(folder, { search = false } = {}) { + const rows = getCachedByFolder(folder); + const dropped = rows.filter(r => !keepIfRunning(r.sessionId, folder)); + if (dropped.length === rows.length) { + deleteCachedFolder(folder); + if (search) deleteSearchFolder(folder); + return; + } + for (const { sessionId } of dropped) { + deleteCachedSession(sessionId); + if (search) deleteSearchSession(sessionId); + } +} + +function releaseLiveSession(sessionId) { + const folder = keptLiveRows.get(sessionId); + if (folder === undefined || isRunningSession(sessionId)) return false; + keptLiveRows.delete(sessionId); + refreshFolder(folder); + return true; +} + +function deriveFolderProjectPath(folderPath, folderKey, onIncomplete) { const { alias, folder } = parseFolderKey(folderKey); return deriveProjectPath(folderPath, folder, { remote: alias !== null, onRejected: (cwd) => warnRejectedCwd(folder, cwd), + onIncomplete, }); } @@ -131,7 +168,7 @@ function refreshFolder(folder, opts = {}) { // null when the key names a host no longer declared — treated as vanished. const folderPath = resolveFolderDir(folder); if (!folderPath || !fs.existsSync(folderPath)) { - deleteCachedFolder(folder); + dropFolderRows(folder); return; } @@ -145,13 +182,11 @@ function refreshFolder(folder, opts = {}) { && (parseFolderKey(folder).alias !== null || storedProjectPathMatchesFolder(knownMeta.projectPath, folder)) ? knownMeta.projectPath : null; - if (!projectPath) projectPath = deriveFolderProjectPath(folderPath, folder); + let incomplete = false; + if (!projectPath) projectPath = deriveFolderProjectPath(folderPath, folder, () => { incomplete = true; }); if (!projectPath) { - if (parseFolderKey(folder).alias === null) { - deleteCachedFolder(folder); - deleteSearchFolder(folder); - } - setFolderMeta(folder, null, getFolderIndexMtimeMs(folderPath)); + if (parseFolderKey(folder).alias === null) dropFolderRows(folder, { search: true }); + setFolderMeta(folder, null, incomplete ? 0 : getFolderIndexMtimeMs(folderPath)); return; } @@ -313,7 +348,7 @@ function refreshFolder(folder, opts = {}) { // drift on the next folder-level event. if (!targeted) { for (const sessionId of cachedMap.keys()) { - if (!currentIds.has(sessionId)) { + if (!currentIds.has(sessionId) && !keepIfRunning(sessionId, folder)) { sessionsToDelete.push(sessionId); changed = true; } @@ -325,6 +360,7 @@ function refreshFolder(folder, opts = {}) { const dbId = filePathToDbId.get(filePath); if (!dbId) continue; try { fs.statSync(filePath); } catch { + if (keepIfRunning(dbId, folder)) continue; sessionsToDelete.push(dbId); changed = true; } @@ -764,10 +800,7 @@ function sendIndexingFinished() { * worker was pointed at a remote mirror. Returns the session count written. */ function writeScannedFolder(r, unverifiedLocalFolder = null) { if (!r) { - if (unverifiedLocalFolder) { - deleteCachedFolder(unverifiedLocalFolder); - deleteSearchFolder(unverifiedLocalFolder); - } + if (unverifiedLocalFolder) dropFolderRows(unverifiedLocalFolder, { search: true }); return 0; } const { folder, projectPath, sessions, indexMtimeMs } = r; @@ -1039,6 +1072,8 @@ module.exports = { readSessionFile, readFolderFromFilesystem, refreshFolder, + dropFolderRows, + releaseLiveSession, reconcileCacheFromFilesystem, buildProjectsFromCache, backfillEntrypoints, From 1e28673e331b319810313d877bc197e51e480238 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 13:50:39 +0200 Subject: [PATCH 3/6] (sidebar): test that a failed read or a long listing cannot hide a moved transcript A transcript whose open fails once (EBUSY while the CLI holds it) must be read again on the next derivation and its folder left to be derived again, and the tail budget must reach a transcript listed after more unresolved ones than one derivation can read, even when the memo cannot keep them. Claude-Session: https://claude.ai/code/session_01FDnFeSaiQWifMYd39ojyyt --- test/worktree-moved-transcript.test.js | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/test/worktree-moved-transcript.test.js b/test/worktree-moved-transcript.test.js index 2f93429f2..3f7a824af 100644 --- a/test/worktree-moved-transcript.test.js +++ b/test/worktree-moved-transcript.test.js @@ -185,6 +185,85 @@ test('a folder left unresolved by the tail budget is not marked as indexed, so r } }); +test('a read error is not remembered as an unresolved transcript', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + const filePath = path.join(folderPath, 'busy.jsonl'); + writeMovedTranscript(filePath, l.repo, l.worktree); + const originalOpen = fs.openSync; + let failed = 0; + fs.openSync = (p, ...rest) => { + if (p === filePath && failed === 0) { + failed++; + throw Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' }); + } + return originalOpen(p, ...rest); + }; + let first; + try { + first = deriveProjectPath(folderPath, l.wtFolder); + } finally { + fs.openSync = originalOpen; + } + assert.equal(failed, 1, 'precondition: the injected error was hit'); + assert.equal(first, null); + assert.equal(deriveProjectPath(folderPath, l.wtFolder), l.repo); + } finally { + cleanup(l.tmp); + } +}); + +test('a refresh whose read failed leaves the folder to be derived again', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + const filePath = path.join(folderPath, 'busy.jsonl'); + writeMovedTranscript(filePath, l.repo, l.worktree); + const metas = []; + const { db } = makeFakeDb({}); + db.setFolderMeta = (folder, projectPath, indexMtimeMs) => metas.push({ projectPath, indexMtimeMs }); + sessionCache.init({ PROJECTS_DIR: l.projectsDir, activeSessions: new Map(), getMainWindow: () => null, log: null, db }); + const originalOpen = fs.openSync; + fs.openSync = (p, ...rest) => { + if (p === filePath) throw Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' }); + return originalOpen(p, ...rest); + }; + try { + sessionCache.refreshFolder(l.wtFolder); + } finally { + fs.openSync = originalOpen; + } + assert.deepEqual(metas[0], { projectPath: null, indexMtimeMs: 0 }); + sessionCache.refreshFolder(l.wtFolder); + assert.equal(metas[metas.length - 1].projectPath, l.repo); + } finally { + cleanup(l.tmp); + } +}); + +test('the tail budget moves on through the folder, so a transcript late in the listing is reached even when nothing is memoised', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + for (let i = 0; i < 12; i++) { + writeMovedTranscript(path.join(folderPath, `t-${i}.jsonl`), l.repo, l.worktree, { tailCwd: null }); + } + const listing = fs.readdirSync(folderPath).filter((n) => n.endsWith('.jsonl')); + const lastListed = path.join(folderPath, listing[listing.length - 1]); + writeMovedTranscript(lastListed, l.repo, l.worktree); + const others = listing.slice(0, -1).map((n) => path.join(folderPath, n)); + let result = null; + for (let pass = 0; pass < 6 && result === null; pass++) { + result = deriveProjectPath(folderPath, l.wtFolder); + for (const f of others) fs.appendFileSync(f, JSON.stringify({ type: 'assistant', message: 'still running' }) + '\n'); + } + assert.equal(result, l.repo); + } finally { + cleanup(l.tmp); + } +}); + test('a moved transcript past the tail budget still resolves on a later derivation', () => { const l = makeLayout(); try { From 1a11c19bd55bc74e78ed9e18482aca529698eadb Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 13:55:48 +0200 Subject: [PATCH 4/6] (sidebar): retry a transcript whose read failed, and resume the tail budget An open or read error in the cwd scan was returned as a complete "unresolved" result and memoised, so a transcript held busy by the CLI on the first read was never opened again while its size and mtime held. It is now reported incomplete: not memoised, and its folder is derived again. The next derivation of a folder starts its walk at the first transcript the tail budget skipped, so a valid one listed after more unresolved ones than a budget covers is reached even when the memo cannot hold them all. Claude-Session: https://claude.ai/code/session_01FDnFeSaiQWifMYd39ojyyt --- .ai/contexts/session-cache.md | 2 +- derive-project-path.js | 40 +++++++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index efc236ef8..30b46910b 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -80,7 +80,7 @@ When a session started at a repository's root enters a worktree (`EnterWorktree` `extractVerifiedCwdFromJsonl(filePath, folderName, tailBudget)` returns the first cwd of the head window that verifies against the folder. The first rejected cwd ends the scan, as the head-only scan did, unless the folder may be a worktree folder of that cwd or of one of its ancestors (`mayBeWorktreeFolderOf`; a session started in `P/sub` moves the same way); then the rest of the head and, failing that, the file's last 256 KB are searched, last line first. The trust rule is unchanged: whichever line it comes from, a cwd is used only if it encodes to the folder holding the transcript. - **Long paths.** `encodeProjectPath` truncates a name over 200 characters and appends a hash of the whole path, so the folder of `P/.claude/worktrees/x` does not start with `enc(P)` when either is long. `mayBeWorktreeFolderOf` compares through `encodedFolderMayExtend` (`encode-project-path.js`), which applies the encoder's sanitising and its 200-character cut to `P//` and compares that prefix only. It is a gate for reading more of the file, not a trust decision, so a false positive costs one bounded read and nothing else. `storedProjectPathMatchesFolder` keeps its own exact prefix check. -- **Cost.** One derivation (`deriveProjectPath`) gives the tail reads a budget of four windows (1 MiB). A transcript whose tail was skipped for lack of budget is not memoised, and the derivation reports itself incomplete (`opts.onIncomplete`). `refreshFolder` then stores the folder's `indexMtimeMs` as 0 instead of its mtime, so `reconcileCacheFromFilesystem` derives it again on its next pass; each pass reads only the transcripts not yet memoised, so the folder resolves, or is marked indexed as unresolved, after a bounded number of passes. Every transcript found unresolved after a complete scan is memoised by path, size and mtime (`unresolvedMemo`, at most 4 096 entries), and is not read again until it changes. A remap rewrites the transcripts it remaps, so it changes their mtime and clears their memo. A file rewritten to the same size with its mtime restored keeps a stale memo; the CLI only appends, so this is not a case it produces. Measured on a folder of 50 rejected 660 KB transcripts: 7.9 ms per flush with the former head-only scan, which read every head on every flush; 0.64 ms per flush averaged over repeated flushes with the memo, the first pass reading the heads as before. +- **Cost.** One derivation (`deriveProjectPath`) gives the tail reads a budget of four windows (1 MiB). A transcript whose tail was skipped for lack of budget, or whose open or read threw (`EBUSY` while the CLI holds it, say), is not memoised, and the derivation reports itself incomplete (`opts.onIncomplete`). The next derivation of the folder starts its walk of the folder's transcripts at the first one skipped for budget (`resumeAt`, per folder, at most 1 024 folders), so a valid transcript listed after more unresolved ones than one budget covers is reached even when the memo cannot hold them all. `refreshFolder` then stores the folder's `indexMtimeMs` as 0 instead of its mtime, so `reconcileCacheFromFilesystem` derives it again on its next pass; each pass reads only the transcripts not yet memoised, so the folder resolves, or is marked indexed as unresolved, after a bounded number of passes. Every transcript found unresolved after a complete scan is memoised by path, size and mtime (`unresolvedMemo`, at most 4 096 entries), and is not read again until it changes. A remap rewrites the transcripts it remaps, so it changes their mtime and clears their memo. A file rewritten to the same size with its mtime restored keeps a stale memo; the CLI only appends, so this is not a case it produces. Measured on a folder of 50 rejected 660 KB transcripts: 7.9 ms per flush with the former head-only scan, which read every head on every flush; 0.64 ms per flush averaged over repeated flushes with the memo, the first pass reading the heads as before. - **Callers.** `deriveProjectPath` (local) and `resolveSessionRealCwd` go through it; the remote path keeps the plain head scan. The Touched panel does not: it resolves a relative touched path against one cwd per transcript, and the worktree cwd of a moved transcript is wrong for the touches made before the move. It keeps the head-only `extractCwdFromJsonl`, so the moved transcript's head cwd, which does not verify, leaves those touches unresolved. - **Residual.** A transcript whose last line alone exceeds 256 KB (a large tool result) has no complete line in the tail window. While its session runs, the row is kept (next section); after exit, the folder resolves again only once a later line carries the worktree's cwd. diff --git a/derive-project-path.js b/derive-project-path.js index dbaef4dea..9749ac836 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -58,6 +58,7 @@ function mayBeWorktreeFolderOf(cwd, folderName) { function extractVerifiedCwdFromJsonl(filePath, folderName, tailBudget = null) { let fd; let firstRejected = null; + let failed = false; try { fd = fs.openSync(filePath, 'r'); const size = fs.fstatSync(fd).size; @@ -77,15 +78,26 @@ function extractVerifiedCwdFromJsonl(filePath, folderName, tailBudget = null) { if (verified) return { cwd: verified, rejected: null, complete: true }; } } - } catch {} finally { + } catch { + failed = true; + } finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch {} } } - return { cwd: null, rejected: firstRejected, complete: true }; + return { cwd: null, rejected: firstRejected, complete: !failed }; } const DERIVE_TAIL_BUDGET_BYTES = 4 * CWD_SCAN_BYTES; const UNRESOLVED_MEMO_MAX = 4096; const unresolvedMemo = new Map(); +const RESUME_MAX = 1024; +const resumeAt = new Map(); + +function rememberResume(folderPath, fileName) { + resumeAt.delete(folderPath); + if (fileName === null) return; + if (resumeAt.size >= RESUME_MAX) resumeAt.delete(resumeAt.keys().next().value); + resumeAt.set(folderPath, fileName); +} function unresolvedCwdOf(filePath, folderName) { let stat; @@ -136,11 +148,17 @@ function deriveProjectPath(folderPath, folderName, opts) { return cwd; } if (rejected && firstRejected === null) firstRejected = rejected; - if (complete) rememberUnresolved(filePath, name, memo.stat, rejected); - else incomplete = true; + if (complete) { + rememberUnresolved(filePath, name, memo.stat, rejected); + } else { + incomplete = true; + if (resumeName === null && path.dirname(filePath) === folderPath) resumeName = path.basename(filePath); + } return null; }; - const result = deriveVerified(folderPath, trustedCwdOf); + let resumeName = null; + const result = deriveVerified(folderPath, trustedCwdOf, remote ? null : resumeAt.get(folderPath)); + if (!remote) rememberResume(folderPath, result === null ? resumeName : null); if (result === null && firstRejected !== null && opts && typeof opts.onRejected === 'function') { opts.onRejected(firstRejected); } @@ -148,15 +166,15 @@ function deriveProjectPath(folderPath, folderName, opts) { return result; } -function deriveVerified(folderPath, trustedCwdOf) { +function deriveVerified(folderPath, trustedCwdOf, startName = null) { try { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); // Check direct .jsonl files first - for (const e of entries) { - if (e.isFile() && e.name.endsWith('.jsonl')) { - const cwd = trustedCwdOf(path.join(folderPath, e.name)); - if (cwd) return resolveWorktreePath(cwd); - } + const direct = entries.filter(e => e.isFile() && e.name.endsWith('.jsonl')).map(e => e.name); + const start = Math.max(0, direct.indexOf(startName)); + for (const name of [...direct.slice(start), ...direct.slice(0, start)]) { + const cwd = trustedCwdOf(path.join(folderPath, name)); + if (cwd) return resolveWorktreePath(cwd); } // Check session subdirectories (UUID folders with subagent .jsonl files) for (const e of entries) { From de0b3927698c3e622f713f4ba761b6b800598e1a Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 14:22:11 +0200 Subject: [PATCH 5/6] (sidebar): test that a failed stat or listing cannot hide a moved transcript A transcript whose stat fails once, or a folder or session subdirectory whose listing fails once, must leave the folder to be derived again, and a transcript that keeps failing to open must not hold back the walk past it. Claude-Session: https://claude.ai/code/session_01FDnFeSaiQWifMYd39ojyyt --- test/worktree-moved-transcript.test.js | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/test/worktree-moved-transcript.test.js b/test/worktree-moved-transcript.test.js index 3f7a824af..2c9fba016 100644 --- a/test/worktree-moved-transcript.test.js +++ b/test/worktree-moved-transcript.test.js @@ -242,6 +242,102 @@ test('a refresh whose read failed leaves the folder to be derived again', () => } }); +function refreshWithFailure(l, method, failsOn) { + const metas = []; + const { db } = makeFakeDb({}); + db.setFolderMeta = (folder, projectPath, indexMtimeMs) => metas.push({ projectPath, indexMtimeMs }); + sessionCache.init({ PROJECTS_DIR: l.projectsDir, activeSessions: new Map(), getMainWindow: () => null, log: null, db }); + const original = fs[method]; + let failed = 0; + fs[method] = (p, ...rest) => { + if (p === failsOn && failed === 0) { + failed++; + throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' }); + } + return original(p, ...rest); + }; + try { + sessionCache.refreshFolder(l.wtFolder); + } finally { + fs[method] = original; + } + assert.equal(failed, 1, `precondition: the injected ${method} error was hit`); + sessionCache.refreshFolder(l.wtFolder); + return metas; +} + +test('a failed stat of a transcript leaves the folder to be derived again', () => { + const l = makeLayout(); + try { + const filePath = path.join(l.projectsDir, l.wtFolder, 'moved.jsonl'); + writeMovedTranscript(filePath, l.repo, l.worktree); + const metas = refreshWithFailure(l, 'statSync', filePath); + assert.deepEqual(metas[0], { projectPath: null, indexMtimeMs: 0 }); + assert.equal(metas[metas.length - 1].projectPath, l.repo); + } finally { + cleanup(l.tmp); + } +}); + +test('a failed listing of the folder leaves it to be derived again', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + writeMovedTranscript(path.join(folderPath, 'moved.jsonl'), l.repo, l.worktree); + const metas = refreshWithFailure(l, 'readdirSync', folderPath); + assert.deepEqual(metas[0], { projectPath: null, indexMtimeMs: 0 }); + assert.equal(metas[metas.length - 1].projectPath, l.repo); + } finally { + cleanup(l.tmp); + } +}); + +test('a failed listing of a session subdirectory leaves the folder to be derived again', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + const subDir = path.join(folderPath, 'parent-session'); + writeMovedTranscript(path.join(subDir, 'subagents', 'agent-a.jsonl'), l.repo, l.worktree); + const metas = refreshWithFailure(l, 'readdirSync', subDir); + assert.deepEqual(metas[0], { projectPath: null, indexMtimeMs: 0 }); + assert.equal(metas[metas.length - 1].projectPath, l.repo); + } finally { + cleanup(l.tmp); + } +}); + +test('a transcript that keeps failing to open does not hold back the walk past it', () => { + const l = makeLayout(); + try { + const folderPath = path.join(l.projectsDir, l.wtFolder); + for (let i = 0; i < 12; i++) { + writeMovedTranscript(path.join(folderPath, `t-${i}.jsonl`), l.repo, l.worktree, { tailCwd: null }); + } + const listing = fs.readdirSync(folderPath).filter((n) => n.endsWith('.jsonl')); + const broken = path.join(folderPath, listing[0]); + const lastListed = path.join(folderPath, listing[listing.length - 1]); + writeMovedTranscript(lastListed, l.repo, l.worktree); + const others = listing.slice(1, -1).map((n) => path.join(folderPath, n)); + const originalOpen = fs.openSync; + fs.openSync = (p, ...rest) => { + if (p === broken) throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' }); + return originalOpen(p, ...rest); + }; + let result = null; + try { + for (let pass = 0; pass < 6 && result === null; pass++) { + result = deriveProjectPath(folderPath, l.wtFolder); + for (const f of others) fs.appendFileSync(f, JSON.stringify({ type: 'assistant', message: 'still running' }) + '\n'); + } + } finally { + fs.openSync = originalOpen; + } + assert.equal(result, l.repo); + } finally { + cleanup(l.tmp); + } +}); + test('the tail budget moves on through the folder, so a transcript late in the listing is reached even when nothing is memoised', () => { const l = makeLayout(); try { From 5881140e0b51e7b41b4124a031d0a90adc733953 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 14:28:26 +0200 Subject: [PATCH 6/6] (sidebar): retry a folder whose transcript stat or listing failed A transcript whose stat threw, or a folder or session subdirectory whose listing threw, ended the derivation as complete, so the folder was marked indexed and an unchanged transcript was never read again once the error cleared. Those failures now report the derivation incomplete. Only a transcript skipped for budget becomes the next walk's starting point, so one that keeps failing to open cannot hold the walk back. Claude-Session: https://claude.ai/code/session_01FDnFeSaiQWifMYd39ojyyt --- .ai/contexts/session-cache.md | 2 +- derive-project-path.js | 25 ++++++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 30b46910b..c8881d2c3 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -80,7 +80,7 @@ When a session started at a repository's root enters a worktree (`EnterWorktree` `extractVerifiedCwdFromJsonl(filePath, folderName, tailBudget)` returns the first cwd of the head window that verifies against the folder. The first rejected cwd ends the scan, as the head-only scan did, unless the folder may be a worktree folder of that cwd or of one of its ancestors (`mayBeWorktreeFolderOf`; a session started in `P/sub` moves the same way); then the rest of the head and, failing that, the file's last 256 KB are searched, last line first. The trust rule is unchanged: whichever line it comes from, a cwd is used only if it encodes to the folder holding the transcript. - **Long paths.** `encodeProjectPath` truncates a name over 200 characters and appends a hash of the whole path, so the folder of `P/.claude/worktrees/x` does not start with `enc(P)` when either is long. `mayBeWorktreeFolderOf` compares through `encodedFolderMayExtend` (`encode-project-path.js`), which applies the encoder's sanitising and its 200-character cut to `P//` and compares that prefix only. It is a gate for reading more of the file, not a trust decision, so a false positive costs one bounded read and nothing else. `storedProjectPathMatchesFolder` keeps its own exact prefix check. -- **Cost.** One derivation (`deriveProjectPath`) gives the tail reads a budget of four windows (1 MiB). A transcript whose tail was skipped for lack of budget, or whose open or read threw (`EBUSY` while the CLI holds it, say), is not memoised, and the derivation reports itself incomplete (`opts.onIncomplete`). The next derivation of the folder starts its walk of the folder's transcripts at the first one skipped for budget (`resumeAt`, per folder, at most 1 024 folders), so a valid transcript listed after more unresolved ones than one budget covers is reached even when the memo cannot hold them all. `refreshFolder` then stores the folder's `indexMtimeMs` as 0 instead of its mtime, so `reconcileCacheFromFilesystem` derives it again on its next pass; each pass reads only the transcripts not yet memoised, so the folder resolves, or is marked indexed as unresolved, after a bounded number of passes. Every transcript found unresolved after a complete scan is memoised by path, size and mtime (`unresolvedMemo`, at most 4 096 entries), and is not read again until it changes. A remap rewrites the transcripts it remaps, so it changes their mtime and clears their memo. A file rewritten to the same size with its mtime restored keeps a stale memo; the CLI only appends, so this is not a case it produces. Measured on a folder of 50 rejected 660 KB transcripts: 7.9 ms per flush with the former head-only scan, which read every head on every flush; 0.64 ms per flush averaged over repeated flushes with the memo, the first pass reading the heads as before. +- **Cost.** One derivation (`deriveProjectPath`) gives the tail reads a budget of four windows (1 MiB). A transcript whose tail was skipped for lack of budget, or whose stat, open or read threw (`EBUSY` while the CLI holds it, say), is not memoised, and the derivation reports itself incomplete (`opts.onIncomplete`); so does a failed listing of the folder or of a session subdirectory. The next derivation of the folder starts its walk of the folder's transcripts at the first one skipped for budget (`resumeAt`, per folder, at most 1 024 folders; a transcript that failed is retried but never becomes the starting point, so one that keeps failing cannot hold the walk back), so a valid transcript listed after more unresolved ones than one budget covers is reached even when the memo cannot hold them all. `refreshFolder` then stores the folder's `indexMtimeMs` as 0 instead of its mtime, so `reconcileCacheFromFilesystem` derives it again on its next pass; each pass reads only the transcripts not yet memoised, so the folder resolves, or is marked indexed as unresolved, after a bounded number of passes. Every transcript found unresolved after a complete scan is memoised by path, size and mtime (`unresolvedMemo`, at most 4 096 entries), and is not read again until it changes. A remap rewrites the transcripts it remaps, so it changes their mtime and clears their memo. A file rewritten to the same size with its mtime restored keeps a stale memo; the CLI only appends, so this is not a case it produces. Measured on a folder of 50 rejected 660 KB transcripts: 7.9 ms per flush with the former head-only scan, which read every head on every flush; 0.64 ms per flush averaged over repeated flushes with the memo, the first pass reading the heads as before. - **Callers.** `deriveProjectPath` (local) and `resolveSessionRealCwd` go through it; the remote path keeps the plain head scan. The Touched panel does not: it resolves a relative touched path against one cwd per transcript, and the worktree cwd of a moved transcript is wrong for the touches made before the move. It keeps the head-only `extractCwdFromJsonl`, so the moved transcript's head cwd, which does not verify, leaves those touches unresolved. - **Residual.** A transcript whose last line alone exceeds 256 KB (a large tool result) has no complete line in the tail window. While its session runs, the row is kept (next section); after exit, the folder resolves again only once a later line carries the worktree's cwd. diff --git a/derive-project-path.js b/derive-project-path.js index 9749ac836..2b8bd94b2 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -70,7 +70,7 @@ function extractVerifiedCwdFromJsonl(filePath, folderName, tailBudget = null) { if (!mayBeWorktreeFolderOf(cwd, folderName)) return { cwd: null, rejected: cwd, complete: true }; } if (firstRejected !== null && size > CWD_SCAN_BYTES) { - if (tailBudget && tailBudget.bytes < CWD_SCAN_BYTES) return { cwd: null, rejected: firstRejected, complete: false }; + if (tailBudget && tailBudget.bytes < CWD_SCAN_BYTES) return { cwd: null, rejected: firstRejected, complete: false, skipped: true }; if (tailBudget) tailBudget.bytes -= CWD_SCAN_BYTES; const tail = [...cwdsInWindow(fd, size - CWD_SCAN_BYTES, size)].reverse(); for (const cwd of tail) { @@ -101,7 +101,7 @@ function rememberResume(folderPath, fileName) { function unresolvedCwdOf(filePath, folderName) { let stat; - try { stat = fs.statSync(filePath); } catch { return null; } + try { stat = fs.statSync(filePath); } catch { return { failed: true }; } const memo = unresolvedMemo.get(filePath); if (memo && memo.folderName === folderName && memo.size === stat.size && memo.mtimeMs === stat.mtimeMs) return memo; return { stat }; @@ -137,12 +137,15 @@ function deriveProjectPath(folderPath, folderName, opts) { return typeof cwd === 'string' && cwd ? cwd : null; } const memo = unresolvedCwdOf(filePath, name); - if (!memo) return null; + if (memo.failed) { + incomplete = true; + return null; + } if (!memo.stat) { if (memo.rejected && firstRejected === null) firstRejected = memo.rejected; return null; } - const { cwd, rejected, complete } = extractVerifiedCwdFromJsonl(filePath, name, tailBudget); + const { cwd, rejected, complete, skipped } = extractVerifiedCwdFromJsonl(filePath, name, tailBudget); if (cwd) { unresolvedMemo.delete(filePath); return cwd; @@ -152,12 +155,12 @@ function deriveProjectPath(folderPath, folderName, opts) { rememberUnresolved(filePath, name, memo.stat, rejected); } else { incomplete = true; - if (resumeName === null && path.dirname(filePath) === folderPath) resumeName = path.basename(filePath); + if (skipped && resumeName === null && path.dirname(filePath) === folderPath) resumeName = path.basename(filePath); } return null; }; let resumeName = null; - const result = deriveVerified(folderPath, trustedCwdOf, remote ? null : resumeAt.get(folderPath)); + const result = deriveVerified(folderPath, trustedCwdOf, remote ? null : resumeAt.get(folderPath), () => { incomplete = true; }); if (!remote) rememberResume(folderPath, result === null ? resumeName : null); if (result === null && firstRejected !== null && opts && typeof opts.onRejected === 'function') { opts.onRejected(firstRejected); @@ -166,7 +169,7 @@ function deriveProjectPath(folderPath, folderName, opts) { return result; } -function deriveVerified(folderPath, trustedCwdOf, startName = null) { +function deriveVerified(folderPath, trustedCwdOf, startName = null, onListingFailed = () => {}) { try { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); // Check direct .jsonl files first @@ -195,9 +198,13 @@ function deriveVerified(folderPath, trustedCwdOf, startName = null) { if (cwd) return resolveWorktreePath(cwd); } } - } catch {} + } catch { + onListingFailed(); + } } - } catch {} + } catch { + onListingFailed(); + } return null; }