diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index bc404a6b3..28a080eb2 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -37,7 +37,7 @@ import { } from './utils/template-arguments.js'; import type { LanguageProvider } from './language-provider.js'; import type { ParsedFile } from 'gitnexus-shared'; -import { WorkerPool, WorkerPoolDispatchError } from './workers/worker-pool.js'; +import { WorkerPool } from './workers/worker-pool.js'; import { logger } from '../logger.js'; import type { ParseWorkerResult, @@ -882,128 +882,75 @@ export const processParsing = async ( `[scope-resolution prof] worker pool engaged for ${files.length} files — cross-phase tree cache will be empty; scope-resolution re-parses.`, ); } - try { - const data = await processParsingWithWorkers( - graph, - files, - symbolTable, - astCache, - workerPool, - reportProgress, - outRawResults, - ); - // Session-scoped quarantine (worker-pool resilience Layer 3): surface - // any files this pool has decided are unsafe for workers so the - // operator can see what was skipped. The pool already filtered them - // out of dispatch; we only need to log + progress-report. Quarantine - // is session-scoped per pool instance — a fresh `createWorkerPool` - // call clears it. - // - // Dedup: log full path list only for entries newly quarantined since - // the previous dispatch on the same pool. The per-chunk progress - // message still surfaces the count for UX continuity, but the - // structured `quarantinedFiles` payload is only emitted when there - // is new signal — prevents O(quarantine × chunks) log spam. - const quarantineSnapshot = workerPool.getQuarantinedPaths?.() ?? []; - const quarantineSet = new Set(quarantineSnapshot); - if (quarantineSet.size > 0) { - const quarantinedInChunk = files.filter((file) => quarantineSet.has(file.path)); - if (quarantinedInChunk.length > 0) { - const seenForPool = loggedQuarantineByPool.get(workerPool) ?? new Set(); - const newlyQuarantined = quarantinedInChunk - .map((file) => file.path) - .filter((p) => !seenForPool.has(p)); - for (const p of newlyQuarantined) seenForPool.add(p); - loggedQuarantineByPool.set(workerPool, seenForPool); - if (newlyQuarantined.length > 0) { - logger.warn( - { - newlyQuarantined, - cumulativeQuarantine: quarantineSet.size, - chunkSkipped: quarantinedInChunk.length, - }, - `Worker quarantine: ${newlyQuarantined.length} new file(s) skipped this chunk ` + - `(${quarantinedInChunk.length} skipped total, ${quarantineSet.size} cumulative).`, - ); - } - reportProgress?.( - lastProgress, - files.length, - `${quarantinedInChunk.length} worker-quarantined file(s) skipped`, - ); - - // U20.U1: Sequential gap-fill. The worker pool's Layer 3 - // quarantine filtered these files out of dispatch, so the - // worker results above are missing their symbols/imports/ - // calls/heritage. Without this reparse, the graph for THIS - // run is silently incomplete — exactly the silent-corruption - // class the Codex adversarial review of PR #1693 flagged. - // - // Running `processParsingSequential` on JUST the quarantined - // files mirrors the WorkerPoolDispatchError catch-block - // shape below (line 961). Worker-extracted data for the - // surviving files flows through `data` to the caller's - // deferred-extraction merge; sequential output for the - // quarantined files writes directly to the graph here. - // - // The chunk-loop caller's cache write at parse-impl.ts:500- - // 507 will NOT cache this chunk because U20.U2's guard - // detects the same quarantine intersection — so next run - // gets a cache miss and a fresh pool gives the file - // another chance. + // U20 design pivot: the worker pool's resilience layers + // (respawn budget, circuit breaker, quarantine, slot-attribution, + // cumulative timeout) are the SOLE contract for handling worker + // failures. There is no sequential-parser fallback for either + // partial quarantine or full pool failure — the operator must see + // a clear hard signal when workers can't recover, instead of a + // silently-degraded graph from a possibly-crashing main-thread + // sequential parser. A failing tree-sitter native binding that + // quarantined a worker would, under the previous design, re-trigger + // the same SIGSEGV on the main thread; we avoid that risk entirely. + // + // - Partial quarantine: the file is missing from this run's graph; + // the per-chunk warn log below surfaces it; U2's chunk-cache + // write-guard in parse-impl.ts keeps the chunk uncached so the + // next analyze gets a cache miss and a fresh pool retries. + // - Full pool failure: `WorkerPoolDispatchError` propagates from + // `processParsingWithWorkers` up through this function. The + // analyze run errors out instead of falling back to sequential. + const data = await processParsingWithWorkers( + graph, + files, + symbolTable, + astCache, + workerPool, + reportProgress, + outRawResults, + ); + // Session-scoped quarantine (worker-pool resilience Layer 3): surface + // any files this pool has decided are unsafe for workers so the + // operator can see what was skipped. The pool already filtered them + // out of dispatch; we only need to log + progress-report. Quarantine + // is session-scoped per pool instance — a fresh `createWorkerPool` + // call clears it. + // + // Dedup: log full path list only for entries newly quarantined since + // the previous dispatch on the same pool. The per-chunk progress + // message still surfaces the count for UX continuity, but the + // structured `quarantinedFiles` payload is only emitted when there + // is new signal — prevents O(quarantine × chunks) log spam. + const quarantineSnapshot = workerPool.getQuarantinedPaths?.() ?? []; + const quarantineSet = new Set(quarantineSnapshot); + if (quarantineSet.size > 0) { + const quarantinedInChunk = files.filter((file) => quarantineSet.has(file.path)); + if (quarantinedInChunk.length > 0) { + const seenForPool = loggedQuarantineByPool.get(workerPool) ?? new Set(); + const newlyQuarantined = quarantinedInChunk + .map((file) => file.path) + .filter((p) => !seenForPool.has(p)); + for (const p of newlyQuarantined) seenForPool.add(p); + loggedQuarantineByPool.set(workerPool, seenForPool); + if (newlyQuarantined.length > 0) { logger.warn( { - reparsedPaths: quarantinedInChunk.map((f) => f.path), - count: quarantinedInChunk.length, + newlyQuarantined, + cumulativeQuarantine: quarantineSet.size, + chunkSkipped: quarantinedInChunk.length, }, - `Running sequential reparse for ${quarantinedInChunk.length} worker-quarantined ` + - `file(s) to keep this run's graph complete.`, - ); - await processParsingSequential( - graph, - quarantinedInChunk, - symbolTable, - astCache, - scopeTreeCache, - reportProgress, + `Worker quarantine: ${newlyQuarantined.length} new file(s) skipped this chunk ` + + `(${quarantinedInChunk.length} skipped total, ${quarantineSet.size} cumulative).`, ); } - } - return data; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - let fallbackFiles = files; - if (err instanceof WorkerPoolDispatchError && err.fallbackExcludePaths.length > 0) { - const excluded = new Set(err.fallbackExcludePaths); - fallbackFiles = files.filter((file) => !excluded.has(file.path)); - logger.warn( - { - skippedPaths: err.fallbackExcludePaths, - }, - 'Skipping worker-excluded files in sequential fallback:', - ); reportProgress?.( lastProgress, files.length, - `Skipping ${files.length - fallbackFiles.length} worker-excluded file(s) in sequential fallback`, + `${quarantinedInChunk.length} worker-quarantined file(s) skipped`, ); } - logger.warn({ message }, 'Worker pool parsing stopped; continuing with sequential parser:'); - reportProgress?.( - lastProgress, - files.length, - `Sequential fallback after worker issue: ${message}`, - ); - await processParsingSequential( - graph, - fallbackFiles, - symbolTable, - astCache, - scopeTreeCache, - reportProgress, - ); - return null; } + return data; } // Fallback: sequential parsing (no pre-extracted data) diff --git a/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts b/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts index 88dcfc9a5..ff55ca04a 100644 --- a/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts +++ b/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts @@ -1,5 +1,5 @@ /** - * U20.U3 — Integration regression test for chunk-cache corruption on + * U20 — Integration regression test for chunk-cache corruption on * worker quarantine. * * Pins the fix for the Codex adversarial review finding on PR #1693 @@ -27,20 +27,27 @@ * nodes give the merge step deterministic content to add to the * graph). * - * Assertions: - * - U1 (sequential gap-fill): the graph contains a `Function` node - * named `poison` after the run. The custom worker never emits - * anything for `poison.ts` (it crashes on it), so the only path - * for that symbol to land in the graph is `processParsing`'s - * sequential reparse of the quarantined-in-chunk file — which - * uses the real tree-sitter parser against the actual - * `export function poison() {}` source. + * U20 design pivot — no sequential fallback. The U1 sequential + * reparse for quarantined chunk files was removed: relying on the + * worker pool's resilience layers (respawn budget, circuit breaker, + * quarantine, slot-attribution, cumulative timeout) as the SOLE + * contract avoids re-triggering tree-sitter native crashes on the + * main thread and gives operators a clear hard signal when workers + * exhaust. Quarantined files are missing from this run's graph; + * they're surfaced in the per-chunk warn log; U2's cache-skip keeps + * the chunk uncached so the next analyze with a fresh pool retries. + * + * Assertions exercised here: + * - Worker-path runs and produces results for surviving files + * (good_a, good_c) via the synthesized worker output. + * - The quarantined file (poison.ts) is NOT in the graph — no + * sequential reparse fired. * - U2 (cache-write suppression): `parseCache.entries` does NOT * contain the chunk hash after the run. `parseCache.usedKeys` * DOES contain it (chunk was processed; the cache write was * specifically skipped). A cross-run scenario verifies that a * subsequent dispatch with a fresh pool re-attempts the chunk - * (cache miss). + * (cache miss) and the cache stays empty for that chunk. * * Why integration over unit: * - The fix lives at the boundary between processParsing @@ -56,10 +63,7 @@ * reused inline here (the READY_PREAMBLE + test-worker script * composition). * - * Wall-clock budget: well under 5 s under normal CI conditions. The - * test bounds Worker startup at the pool's WORKER_READY_TIMEOUT_MS - * (5 s) and dispatch at subBatchIdleTimeoutMs default (30 s) — both - * generous given the worker never blocks the test on real work. + * Wall-clock budget: well under 5 s under normal CI conditions. */ import { describe, it, expect, afterEach, beforeEach } from 'vitest'; import { tmpdir } from 'node:os'; @@ -183,7 +187,7 @@ const FIXTURE_FILES = { 'src/good_c.ts': 'export function good_c() { return 3; }\n', }; -describe('U20.U3: parse-impl quarantine + chunk-cache integration (PR #1693 Codex finding)', () => { +describe('U20: parse-impl quarantine + chunk-cache integration (PR #1693 Codex finding)', () => { let tempDir: string; let repoDir: string; let workerPath: string; @@ -212,7 +216,7 @@ describe('U20.U3: parse-impl quarantine + chunk-cache integration (PR #1693 Code rmSync(tempDir, { recursive: true, force: true }); }); - it('runs sequential reparse for poison.ts AND suppresses cache write for the chunk', async () => { + it('worker quarantine leaves poison.ts out of the graph AND suppresses chunk-cache write', async () => { const filePaths = Object.keys(FIXTURE_FILES); const scanned = filePaths.map((rel) => ({ path: rel, @@ -260,21 +264,23 @@ describe('U20.U3: parse-impl quarantine + chunk-cache integration (PR #1693 Code }, ); - // U1 assertion: poison.ts's symbols are in the graph despite the - // worker never emitting anything for that file. The only path for - // a Function node named `poison` to land here is processParsing's - // sequential reparse of the quarantined-in-chunk file (using the - // real tree-sitter parser against the actual source). const nodes = Array.from(graph.nodes.values()); - const poisonNode = nodes.find( - (n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'poison', - ); - expect(poisonNode).toBeDefined(); - // The other two files' symbols came from the custom worker's - // synthesized output via the normal worker-path merge. Pin them - // too so a regression that drops worker results entirely doesn't - // silently pass this test. + // Quarantine contract: poison.ts is genuinely missing from the + // graph for this run. The custom worker crashed on it; no + // sequential reparse rescued it; the operator sees the per-chunk + // quarantine warn log. A future analyze with a fresh pool gets + // another chance via U2's cache-skip below. + expect( + nodes.some( + (n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'poison', + ), + ).toBe(false); + + // Surviving files' symbols come from the custom worker's + // synthesized output via the normal worker-path merge. Pinning + // them here catches a regression that would drop worker results + // entirely when quarantine fires. expect( nodes.some( (n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'good_a', @@ -369,17 +375,24 @@ describe('U20.U3: parse-impl quarantine + chunk-cache integration (PR #1693 Code }, ); - // Cache stayed empty (still no entry for this chunk hash). + // Cache stayed empty (still no entry for this chunk hash) — the + // load-bearing cross-run protection. expect(parseCache.entries.has(expectedChunkHash)).toBe(false); expect(parseCache.usedKeys.has(expectedChunkHash)).toBe(true); - // Graph is still complete — sequential gap-fill ran again on - // poison.ts in the second pass. + // Worker path ran again; surviving files in the graph; poison + // still absent per the U20 contract (workers are the sole + // resilience layer, no sequential reparse). const nodes2 = Array.from(graph2.nodes.values()); + expect( + nodes2.some( + (n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'good_a', + ), + ).toBe(true); expect( nodes2.some( (n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'poison', ), - ).toBe(true); + ).toBe(false); } }); }); diff --git a/gitnexus/test/unit/parsing-worker-fallback.test.ts b/gitnexus/test/unit/parsing-worker-fallback.test.ts index 3308cf03f..8542c3011 100644 --- a/gitnexus/test/unit/parsing-worker-fallback.test.ts +++ b/gitnexus/test/unit/parsing-worker-fallback.test.ts @@ -1,3 +1,30 @@ +/** + * 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 { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { processParsing } from '../../src/core/ingestion/parsing-processor.js'; @@ -6,157 +33,9 @@ import { WorkerPoolDispatchError } from '../../src/core/ingestion/workers/worker import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; -describe('processParsing worker fallback', () => { - it('continues sequentially with visible progress when the worker pool times out', async () => { +describe('processParsing — worker-pool error propagation (U20)', () => { + it('propagates a raw worker-pool throw to the caller without rescuing', async () => { const graph = createKnowledgeGraph(); - const progressCounts: number[] = []; - const progressDetails: string[] = []; - const workerPool: WorkerPool = { - size: 1, - dispatch: vi.fn(async (_items, onProgress?: (filesProcessed: number) => void) => { - onProgress?.(1); - throw new Error('injected worker idle timeout'); - }), - terminate: vi.fn(async () => undefined), - }; - - const result = await processParsing( - graph, - [{ path: 'src/a.ts', content: 'export function a() { return 1; }\n' }], - createSymbolTable(), - createASTCache(), - createASTCache(), - (current, _total, detail) => { - progressCounts.push(current); - progressDetails.push(detail); - }, - workerPool, - ); - - expect(result).toBeNull(); - expect(progressDetails).toContain( - 'Sequential fallback after worker issue: injected worker idle timeout', - ); - expect(progressCounts).toEqual([...progressCounts].sort((a, b) => a - b)); - expect( - graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'a'), - ).toBe(true); - }); - - it('skips worker-timeout singleton files during sequential fallback', async () => { - const graph = createKnowledgeGraph(); - const progressDetails: string[] = []; - const workerPool: WorkerPool = { - size: 1, - dispatch: vi.fn(async () => { - throw new WorkerPoolDispatchError('injected worker idle timeout', ['src/stuck.ts']); - }), - terminate: vi.fn(async () => undefined), - }; - - const result = await processParsing( - graph, - [ - { path: 'src/stuck.ts', content: 'export function stuck() { return 0; }\n' }, - { path: 'src/a.ts', content: 'export function a() { return 1; }\n' }, - ], - createSymbolTable(), - createASTCache(), - createASTCache(), - (_current, _total, detail) => { - progressDetails.push(detail); - }, - workerPool, - ); - - expect(result).toBeNull(); - expect(progressDetails).toContain('Skipping 1 worker-excluded file(s) in sequential fallback'); - expect( - graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'a'), - ).toBe(true); - expect( - graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'stuck'), - ).toBe(false); - }); - - it('skips worker-error in-flight file during sequential fallback', async () => { - const graph = createKnowledgeGraph(); - const progressDetails: string[] = []; - const workerPool: WorkerPool = { - size: 1, - dispatch: vi.fn(async () => { - throw new WorkerPoolDispatchError('Worker 0 error: native crash', ['src/crashed.ts']); - }), - terminate: vi.fn(async () => undefined), - }; - - const result = await processParsing( - graph, - [ - { path: 'src/crashed.ts', content: 'export function crashed() { return 0; }\n' }, - { path: 'src/a.ts', content: 'export function a() { return 1; }\n' }, - ], - createSymbolTable(), - createASTCache(), - createASTCache(), - (_current, _total, detail) => { - progressDetails.push(detail); - }, - workerPool, - ); - - expect(result).toBeNull(); - expect(progressDetails).toContain('Skipping 1 worker-excluded file(s) in sequential fallback'); - expect( - graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'a'), - ).toBe(true); - expect( - graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'crashed'), - ).toBe(false); - }); - - it('skips worker-exit in-flight file during sequential fallback', async () => { - const graph = createKnowledgeGraph(); - const progressDetails: string[] = []; - const workerPool: WorkerPool = { - size: 1, - dispatch: vi.fn(async () => { - throw new WorkerPoolDispatchError( - 'Worker 0 exited with code 134. Likely OOM or native addon failure (in-flight: src/oom.ts).', - ['src/oom.ts'], - ); - }), - terminate: vi.fn(async () => undefined), - }; - - const result = await processParsing( - graph, - [ - { path: 'src/oom.ts', content: 'export function oom() { return 0; }\n' }, - { path: 'src/a.ts', content: 'export function a() { return 1; }\n' }, - ], - createSymbolTable(), - createASTCache(), - createASTCache(), - (_current, _total, detail) => { - progressDetails.push(detail); - }, - workerPool, - ); - - expect(result).toBeNull(); - expect(progressDetails).toContain('Skipping 1 worker-excluded file(s) in sequential fallback'); - expect( - graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'a'), - ).toBe(true); - expect( - graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'oom'), - ).toBe(false); - }); - - it('runs full sequential fallback when the worker pool throws a non-WorkerPoolDispatchError', async () => { - const graph = createKnowledgeGraph(); - const progressDetails: string[] = []; const workerPool: WorkerPool = { size: 1, dispatch: vi.fn(async () => { @@ -165,10 +44,87 @@ describe('processParsing worker fallback', () => { terminate: vi.fn(async () => undefined), }; + await expect( + processParsing( + graph, + [{ path: 'src/a.ts', content: 'export function a() { return 1; }\n' }], + createSymbolTable(), + createASTCache(), + createASTCache(), + () => {}, + 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 fallbackExcludePaths intact', async () => { + const graph = createKnowledgeGraph(); + const workerPool: WorkerPool = { + size: 1, + dispatch: 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(), + createASTCache(), + createASTCache(), + () => {}, + workerPool, + ); + + await expect(rejection).rejects.toBeInstanceOf(WorkerPoolDispatchError); + const err = await rejection.catch((e) => e as WorkerPoolDispatchError); + expect(err.fallbackExcludePaths).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 () => []), + terminate: vi.fn(async () => undefined), + getQuarantinedPaths: () => ['src/poison.ts'], + }; + + const progressDetails: string[] = []; const result = await processParsing( graph, [ - { path: 'src/keep.ts', content: 'export function keep() { return 0; }\n' }, + { path: 'src/poison.ts', content: 'export function poison() { return 0; }\n' }, { path: 'src/a.ts', content: 'export function a() { return 1; }\n' }, ], createSymbolTable(), @@ -180,18 +136,10 @@ describe('processParsing worker fallback', () => { workerPool, ); - expect(result).toBeNull(); - expect(progressDetails).toContain( - 'Sequential fallback after worker issue: replacement worker failed', - ); - expect( - progressDetails.some((d) => d.startsWith('Skipping ') && d.includes('worker-excluded file')), - ).toBe(false); - expect( - graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'a'), - ).toBe(true); - expect( - graph.nodes.some((node) => node.label === 'Function' && node.properties.name === 'keep'), - ).toBe(true); + // Worker path returned successfully (not null — null was the + // pre-U20 sentinel for "ran sequential fallback"). The progress + // log surfaces the quarantine count for operator visibility. + expect(result).not.toBeNull(); + expect(progressDetails).toContain('1 worker-quarantined file(s) skipped'); }); });