/** * processParsing — worker-pool error handling contract. * * U20 design pivot (PR #1693): there is NO sequential-parser fallback * when the worker pool fails. The pool's resilience layers (respawn * budget, circuit breaker, quarantine, slot-attribution, cumulative * timeout) are the sole contract for handling worker failures. When * those exhaust, `processParsing` propagates the error to the caller * — `runChunkedParseAndResolve` and the analyze entry point above it. * * This file replaces the previous sequential-fallback tests (which * asserted that processParsing caught WorkerPoolDispatchError and * called processParsingSequential on the remaining files). The new * contract is "errors propagate, no rescue." * * Why removing the fallback was the right call: * - The fallback ran the SAME tree-sitter parser the worker just * crashed on, but on the main thread. A native crash (SIGSEGV * from a tree-sitter binding) in the worker would re-trigger the * same SIGSEGV on the main thread, killing the whole analyze * instead of just the worker. * - It hid pool failures behind a degraded-but-completing analyze * run, making them harder to detect and diagnose. * - U2's chunk-cache write suppression keeps cross-run retry * working: a quarantined file's chunk stays uncached, so the * next analyze with a fresh pool gets another chance. */ import { describe, expect, it, vi } from 'vitest'; import { processParsing } from '../../src/core/ingestion/parsing-processor.js'; import type { WorkerPool } from '../../src/core/ingestion/workers/worker-pool.js'; import { WorkerPoolDispatchError } from '../../src/core/ingestion/workers/worker-pool.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; describe('processParsing — worker-pool error propagation (U20)', () => { it('propagates a raw worker-pool throw to the caller without rescuing', async () => { const graph = createKnowledgeGraph(); const workerPool: WorkerPool = { size: 1, dispatch: vi.fn(async () => []), dispatchGroups: vi.fn(async () => { throw new Error('replacement worker failed'); }), terminate: vi.fn(async () => undefined), }; await expect( processParsing( graph, [{ path: 'src/a.ts', content: 'export function a() { return 1; }\n' }], createSymbolTable(), workerPool, () => {}, ), ).rejects.toThrow('replacement worker failed'); // No sequential fallback ran, so the graph stays empty. expect( graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'a'), ).toBe(false); }); it('propagates WorkerPoolDispatchError with quarantinedPaths intact', async () => { const graph = createKnowledgeGraph(); const workerPool: WorkerPool = { size: 1, dispatch: vi.fn(async () => []), dispatchGroups: vi.fn(async () => { throw new WorkerPoolDispatchError( 'Worker pool circuit breaker tripped: 2 consecutive failures on slot 0', ['src/poison.ts'], ); }), terminate: vi.fn(async () => undefined), }; const rejection = processParsing( graph, [ { path: 'src/poison.ts', content: 'export function poison() { return 0; }\n' }, { path: 'src/a.ts', content: 'export function a() { return 1; }\n' }, ], createSymbolTable(), workerPool, () => {}, ); await expect(rejection).rejects.toBeInstanceOf(WorkerPoolDispatchError); const err = await rejection.then( () => undefined, (e: unknown) => e as WorkerPoolDispatchError, ); expect(err?.quarantinedPaths).toEqual(['src/poison.ts']); // No sequential fallback ran for either file. The caller (analyze // entry point) is responsible for surfacing this as a hard // failure. expect( graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'a'), ).toBe(false); expect( graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'poison'), ).toBe(false); }); it('worker-path returns successfully when the pool reports a quarantine snapshot without throwing', async () => { // Quarantine is a normal session-scoped signal: the pool filters // quarantined files out of dispatch, returns the survivors' // results, and reports the cumulative set via getQuarantinedPaths. // processParsing's worker-path completes successfully on this // partial-coverage signal — the quarantined file is missing from // the graph, but no error is thrown. The chunk-loop caller uses // the quarantine snapshot to decide whether to write the chunk // cache (U2 in parse-impl.ts). const graph = createKnowledgeGraph(); const workerPool: WorkerPool = { size: 1, dispatch: vi.fn(async () => []), dispatchGroups: vi.fn(async () => []), terminate: vi.fn(async () => undefined), getQuarantinedPaths: () => ['src/poison.ts'], }; const progressDetails: string[] = []; const result = await processParsing( graph, [ { path: 'src/poison.ts', content: 'export function poison() { return 0; }\n' }, { path: 'src/a.ts', content: 'export function a() { return 1; }\n' }, ], createSymbolTable(), workerPool, (_current, _total, detail) => { progressDetails.push(detail); }, ); // Worker path returned the merged chunk data (never null — the pre-U20 // null sentinel meant "ran sequential fallback", which no longer exists). // The progress log surfaces the quarantine count for operator visibility. expect(result).not.toBeNull(); expect(progressDetails).toContain('1 worker-quarantined file(s) skipped'); }); });