From 97a3b74978170f1637895b37544495c18e8b946b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:06:10 -0700 Subject: [PATCH] fix(ai-vault): wait for Cursor read reservations --- .../session-scanner-cursor-local-pipeline.ts | 39 ++++++------- .../session-scanner-cursor-read-budget.ts | 57 +++++++++++++++++++ ...sion-scanner-cursor-storage-budget.test.ts | 50 ++++++++++++++++ 3 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-cursor-read-budget.ts diff --git a/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts index c6cc37bb62e..19be36938f6 100644 --- a/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts +++ b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts @@ -23,6 +23,12 @@ import { selectCursorScopedGroups, type CursorCandidateSelectionGroup } from './session-scanner-cursor-selection' +import { + createCursorVerifiedReadBudget, + reserveCursorVerifiedReadBytes, + settleCursorVerifiedReadReservation, + type CursorVerifiedReadBudget +} from './session-scanner-cursor-read-budget' const CURSOR_PARSE_CONCURRENCY = 8 @@ -100,11 +106,9 @@ type VerifiedReadTracker = { truncated: NonNullable } > - budgetsByStorageKey: Map + budgetsByStorageKey: Map } -type VerifiedReadBudget = { chargedBytes: number; reservedBytes: number } - function createVerifiedReadTracker( discoveries: readonly SessionFileDiscovery[] | undefined ): VerifiedReadTracker { @@ -132,12 +136,15 @@ function createVerifiedReadTracker( return { byStorageKey, budgetsByStorageKey: new Map() } } -function verifiedReadBudget(tracker: VerifiedReadTracker, storageKey: string): VerifiedReadBudget { +function verifiedReadBudget( + tracker: VerifiedReadTracker, + storageKey: string +): CursorVerifiedReadBudget { const existing = tracker.budgetsByStorageKey.get(storageKey) if (existing) { return existing } - const created = { chargedBytes: 0, reservedBytes: 0 } + const created = createCursorVerifiedReadBudget() tracker.budgetsByStorageKey.set(storageKey, created) return created } @@ -229,17 +236,13 @@ async function parseSidecarWithAggregateCap( candidate.file.sizeBytes === undefined ? CURSOR_SIDECAR_MAX_BYTES : Math.min(Math.max(0, candidate.file.sizeBytes), CURSOR_SIDECAR_MAX_BYTES) - throwIfAiVaultScanCancelled(args.signal) - const availableBytes = - CURSOR_REMOTE_MAX_AGGREGATE_BYTES - budget.chargedBytes - budget.reservedBytes - if (availableBytes <= 0 || estimatedBytes > availableBytes) { + const reservedBytes = await reserveCursorVerifiedReadBytes(budget, estimatedBytes, args.signal) + if (reservedBytes === null) { if (storage) { storage.truncated.sidecarBytes = true } return null } - const reservedBytes = Math.min(CURSOR_SIDECAR_MAX_BYTES, availableBytes) - budget.reservedBytes += reservedBytes let result try { @@ -252,7 +255,7 @@ async function parseSidecarWithAggregateCap( maxBytes: reservedBytes }) } catch (error) { - settleVerifiedReadReservation( + settleCursorVerifiedReadReservation( budget, reservedBytes, isVerifiedReadTooLargeError(error) @@ -270,7 +273,7 @@ async function parseSidecarWithAggregateCap( } const readBytes = result.cacheHit ? 0 : (result.returnedBytes ?? 0) - settleVerifiedReadReservation(budget, reservedBytes, readBytes) + settleCursorVerifiedReadReservation(budget, reservedBytes, readBytes) if (!result.cacheHit && storage) { storage.counters.boundedReads += 1 storage.counters.returnedBytes += readBytes @@ -291,16 +294,6 @@ async function parseSidecarWithAggregateCap( : null } -function settleVerifiedReadReservation( - budget: VerifiedReadBudget, - reservedBytes: number, - chargedBytes: number -): void { - budget.reservedBytes -= reservedBytes - const available = CURSOR_REMOTE_MAX_AGGREGATE_BYTES - budget.chargedBytes - budget.reservedBytes - budget.chargedBytes += Math.min(chargedBytes, Math.max(0, available)) -} - function isVerifiedReadTooLargeError(error: unknown): boolean { return error instanceof Error && error.message === 'file_too_large' } diff --git a/src/main/ai-vault/session-scanner-cursor-read-budget.ts b/src/main/ai-vault/session-scanner-cursor-read-budget.ts new file mode 100644 index 00000000000..5f585a2e5e0 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-read-budget.ts @@ -0,0 +1,57 @@ +import { + CURSOR_REMOTE_MAX_AGGREGATE_BYTES, + CURSOR_SIDECAR_MAX_BYTES +} from '../../shared/cursor-sidecar-scan' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' + +export type CursorVerifiedReadBudget = { + chargedBytes: number + reservedBytes: number + changed: Promise + notifyChanged: () => void +} + +export function createCursorVerifiedReadBudget(): CursorVerifiedReadBudget { + let notifyChanged = (): void => undefined + const changed = new Promise((resolve) => { + notifyChanged = resolve + }) + return { chargedBytes: 0, reservedBytes: 0, changed, notifyChanged } +} + +export async function reserveCursorVerifiedReadBytes( + budget: CursorVerifiedReadBudget, + estimatedBytes: number, + signal?: AbortSignal +): Promise { + while (true) { + throwIfAiVaultScanCancelled(signal) + const remainingBytes = CURSOR_REMOTE_MAX_AGGREGATE_BYTES - budget.chargedBytes + const availableBytes = remainingBytes - budget.reservedBytes + if (availableBytes > 0 && estimatedBytes <= availableBytes) { + const reservedBytes = Math.min(CURSOR_SIDECAR_MAX_BYTES, availableBytes) + budget.reservedBytes += reservedBytes + return reservedBytes + } + if (remainingBytes <= 0 || estimatedBytes > remainingBytes) { + return null + } + await budget.changed + } +} + +export function settleCursorVerifiedReadReservation( + budget: CursorVerifiedReadBudget, + reservedBytes: number, + chargedBytes: number +): void { + budget.reservedBytes -= reservedBytes + const available = CURSOR_REMOTE_MAX_AGGREGATE_BYTES - budget.chargedBytes - budget.reservedBytes + budget.chargedBytes += Math.min(chargedBytes, Math.max(0, available)) + budget.notifyChanged() + let notifyChanged = (): void => undefined + budget.changed = new Promise((resolve) => { + notifyChanged = resolve + }) + budget.notifyChanged = notifyChanged +} diff --git a/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts b/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts index 590c9a1a81c..c3547ac8f0b 100644 --- a/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts +++ b/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts @@ -78,6 +78,56 @@ describe('Cursor verified-read budgets by storage context', () => { } ) + it.each(['native', 'wsl:ubuntu'])( + 'waits for temporary %s reservations when the statted batch still fits', + async (storageKey) => { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-storage-reservations-')) + roots.push(root) + const chatsRoot = join(root, '.cursor', 'chats') + const fillerBytes = CURSOR_SIDECAR_MAX_BYTES - 100 + const fillerFiles = await Promise.all( + Array.from({ length: 63 }, (_, index) => + addSession( + chatsRoot, + `filler-${String(index).padStart(2, '0')}`, + sidecarPayload(fillerBytes), + 10_000 + ) + ) + ) + const tailFiles = await Promise.all( + Array.from({ length: 8 }, (_, index) => + addSession(chatsRoot, `tail-${index}`, sidecarPayload(1_000), 1_000) + ) + ) + const files = [...fillerFiles, ...tailFiles] + const discovery = sidecarDiscovery(storageKey, chatsRoot, await realpath(chatsRoot), files) + const span = startSpan('cursor-storage-reservation-wait-test') + + try { + const result = await processLocalCursorCandidates({ + candidates: discoveryCandidates(discovery), + discoveries: [discovery], + executionHostId: 'local', + issues: [], + limit: 100, + parseStats: createSessionParseStats(), + platform: 'linux', + scopeLimit: 100, + span + }) + + expect(result.sessions).toHaveLength(files.length) + expect(discovery.cursorDiscoveryCounters?.boundedReads).toBe(files.length) + expect(discovery.cursorDiscoveryCounters?.returnedBytes).toBe(63 * fillerBytes + 8_000) + expect(discovery.cursorDiscoveryTruncated?.sidecarBytes).toBe(false) + } finally { + span.end() + } + }, + 30_000 + ) + it('stops after the first verified read when cancellation lands during parsing', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-cursor-parse-cancel-')) roots.push(root)