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