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.
This commit is contained in:
Brennan Benson
2026-10-19 11:14:40 -07:00
committed by GitHub
parent b99392e10b
commit 6285797284
6 changed files with 41 additions and 11 deletions
@@ -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)
@@ -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)
+4
View File
@@ -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) {
@@ -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()]
}
@@ -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<AiVaultS
if (candidates.length === 0) {
return walked
}
noteAiVaultScanReading(args.options.signal, candidates.length)
// Parse every in-scope candidate (limit === candidate count never early-stops).
const bucketed = await parseSessionCandidates({
candidates,
-2
View File
@@ -21,7 +21,6 @@ import { cursorChatMetaRefusals, withCursorChatMetaScan } from './session-scanne
import type { AiVaultScanOptions } from './session-scanner-types'
import { clampPositiveInteger } from './session-scanner-values'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { noteAiVaultScanReading } from './ai-vault-scan-progress'
import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth'
import { withDevinSessionsDbScan } from './session-scanner-devin-db'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
@@ -75,7 +74,6 @@ async function scanAiVaultSessionStores(options: AiVaultScanOptions): Promise<Ai
const candidates = await sessionCandidatesFromDiscoveries(discoveries, options)
const parseCandidates = candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER)
noteAiVaultScanReading(options.signal, parseCandidates.length)
const parsedSessions = await parseSessionCandidates({
candidates: parseCandidates,