From 62857972847a216bb4d3fdc5fc045ac001b5c3da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:14:40 -0700 Subject: [PATCH] fix(session-history): the "Reading N / M files" count reaches its total (#30683) The scan's first pass announced its whole candidate slice (limit x 2) as files to read, then stopped once it had enough sessions, and nothing took the unread files back out of the total. The parse loop now owns its share of the total: an unfiltered pass announces its list and takes back what it never read; a filtered walk keeps announcing per selected batch. --- .../ai-vault-scan-progress-flow.test.ts | 20 ++++++++++++++----- src/main/ai-vault/ai-vault-scan-progress.ts | 7 +++++++ src/main/ai-vault/ai-vault-scan-tally.ts | 4 ++++ .../session-scanner-candidate-parse.ts | 17 ++++++++++++++-- .../ai-vault/session-scanner-scope-walk.ts | 2 -- src/main/ai-vault/session-scanner.ts | 2 -- 6 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts b/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts index 8f40cb10b6f..9002fae03bd 100644 --- a/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts +++ b/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts @@ -1,4 +1,4 @@ -import { link, mkdir, mkdtemp, rm } from 'node:fs/promises' +import { link, mkdir, mkdtemp, rm, utimes } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -35,11 +35,14 @@ afterEach(async () => { }) /** Runs a real scan in this process the way the scanner child does, keeping every report. */ -async function scanInChild(): Promise<{ reports: AiVaultScanWork[]; final: AiVaultScanWork }> { +async function scanInChild( + limit = 20 +): Promise<{ reports: AiVaultScanWork[]; final: AiVaultScanWork }> { tempRoot = await mkdtemp(join(tmpdir(), 'orca-scan-strip-')) const roots = isolatedScanRoots(tempRoot) - for (const name of ['a', 'b', 'c']) { - await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${name}.jsonl`), [ + for (const [index, name] of ['a', 'b', 'c'].entries()) { + const path = join(roots.claudeProjectsDir, 'project', `${name}.jsonl`) + await writeJsonlFile(path, [ { type: 'user', sessionId: name, @@ -48,6 +51,7 @@ async function scanInChild(): Promise<{ reports: AiVaultScanWork[]; final: AiVau message: { role: 'user', content: `ask ${name}` } } ]) + await utimes(path, 1_000 + index, 1_000 + index) } // A second path to one transcript, as Codex homes and linked profiles produce: still one file. await mkdir(join(roots.claudeProjectsDir, 'linked')) @@ -63,7 +67,7 @@ async function scanInChild(): Promise<{ reports: AiVaultScanWork[]; final: AiVau (work) => reports.push(work), () => (now += 1_000) ) - await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20, signal }) + await scanAiVaultSessions({ ...roots, platform: 'darwin', limit, signal }) return { reports, final: aiVaultScanWork(signal)! } } @@ -129,6 +133,12 @@ describe('Session History scan progress, scanner child to window', () => { }) }) + it('reaches its total when the first pass stops before reading every file it planned', async () => { + // The newest file fills a limit of 1, so the pass never reads the older ones it announced. + const { final } = await scanInChild(1) + expect(final.read).toEqual({ done: 1, total: 1 }) + }) + it('keeps a replacement entry when the scan it replaced fails late', () => { const earlier = startLocalAiVaultScanProgress('scope', 'earlier', () => 500) earlier.finish(null) diff --git a/src/main/ai-vault/ai-vault-scan-progress.ts b/src/main/ai-vault/ai-vault-scan-progress.ts index 2dd0492672f..f261ce06bc2 100644 --- a/src/main/ai-vault/ai-vault-scan-progress.ts +++ b/src/main/ai-vault/ai-vault-scan-progress.ts @@ -97,6 +97,13 @@ export function noteAiVaultScanReading(signal: AbortSignal | undefined, total: n reporter?.workChanged() } +/** A pass stopped early: `count` transcripts it announced will not be read. */ +export function noteAiVaultScanUnread(signal: AbortSignal | undefined, count: number): void { + const reporter = signal ? reporters.get(signal) : undefined + reporter?.tally.dropUnread(count) + reporter?.workChanged() +} + export function noteAiVaultScanFilesRead(signal: AbortSignal | undefined, count: number): void { const reporter = signal ? reporters.get(signal) : undefined reporter?.tally.addRead(count) diff --git a/src/main/ai-vault/ai-vault-scan-tally.ts b/src/main/ai-vault/ai-vault-scan-tally.ts index 18f2acbf766..b4b2ce971fd 100644 --- a/src/main/ai-vault/ai-vault-scan-tally.ts +++ b/src/main/ai-vault/ai-vault-scan-tally.ts @@ -54,6 +54,10 @@ export class AiVaultScanTally { this.readDone += count } + dropUnread(count: number): void { + this.readTotal -= count + } + snapshot(): AiVaultScanWork { const agents: AiVaultScanWork['agents'] = [] for (const [agent, tally] of this.agents) { diff --git a/src/main/ai-vault/session-scanner-candidate-parse.ts b/src/main/ai-vault/session-scanner-candidate-parse.ts index 3488d5d0ab7..a1803335291 100644 --- a/src/main/ai-vault/session-scanner-candidate-parse.ts +++ b/src/main/ai-vault/session-scanner-candidate-parse.ts @@ -17,7 +17,11 @@ import { canStopParsingSessions } from './session-scan-cutoff' import type { SessionFileCandidate, SessionParseResult } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { noteAiVaultScanFilesRead, noteAiVaultScanReading } from './ai-vault-scan-progress' +import { + noteAiVaultScanFilesRead, + noteAiVaultScanReading, + noteAiVaultScanUnread +} from './ai-vault-scan-progress' const SESSION_PARSE_CONCURRENCY = 8 @@ -38,8 +42,14 @@ export async function parseSessionCandidates(args: { const sessions = new ScannedSessionCollection() let nextYieldIndex = 256 let nextCutoffIndex = 0 + // Why: the loop alone knows what it will read, so it owns its share of the read total; + // a filtered walk announces each selected batch, an unfiltered pass its whole list. + if (!args.selectCandidate) { + noteAiVaultScanReading(args.signal, args.candidates.length) + } - for (let index = 0; index < args.candidates.length;) { + let index = 0 + while (index < args.candidates.length) { if (index >= nextYieldIndex) { await yieldToEventLoop() nextYieldIndex = index + 256 @@ -112,6 +122,9 @@ export async function parseSessionCandidates(args: { }) } + if (!args.selectCandidate) { + noteAiVaultScanUnread(args.signal, args.candidates.length - index) + } return [...sessions.values()] } diff --git a/src/main/ai-vault/session-scanner-scope-walk.ts b/src/main/ai-vault/session-scanner-scope-walk.ts index 6d6248e679e..15d68c58492 100644 --- a/src/main/ai-vault/session-scanner-scope-walk.ts +++ b/src/main/ai-vault/session-scanner-scope-walk.ts @@ -1,7 +1,6 @@ import { createAiVaultScopeCwdMatcher } from '../../shared/ai-vault-scope-cwd-match' import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import { noteAiVaultScanReading } from './ai-vault-scan-progress' import { yieldToEventLoop } from '../../shared/event-loop-yield' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' import { @@ -71,7 +70,6 @@ export async function scanInScopeSessions(args: ScopeScanArgs): Promise