Summary
Found while fixing #2418 (native load_file_hashes collapsed a read failure into "no prior build state", silently wiping a healthy graph on a transient SQLITE_BUSY/schema fault). #2418 fixes the Rust loader itself to correctly return an error instead of None in that case, and that error now propagates through NativeDatabase::build_graph's #[napi] wrapper as a thrown napi::Error — up through session.runBuildGraph() and tryNativeOrchestrator(ctx) in src/domain/graph/builder/stages/native-orchestrator.ts, neither of which catch anything.
But pipeline.ts's own call site does:
try {
const nativeResult = await tryNativeOrchestrator(ctx);
if (nativeResult === 'early-exit') return;
if (nativeResult) return nativeResult;
} catch (err) {
warn(`Native build orchestrator failed, falling back to JS pipeline: ${toErrorMessage(err)}`);
// ...falls through to the JS pipeline, does NOT re-throw...
}
This unconditionally swallows any error from the native orchestrator — including the one #2418 makes it throw for unreadable prior state — and falls back to the JS pipeline instead of surfacing it. Contrast with the earlier JS-side fast-skip pre-flight a few lines above in the same function, which explicitly checks isUnreadableBuildStateError(err) before deciding whether to re-throw or fall through, specifically because "falling through here would... produce exactly the wipe this error prevents."
Why this isn't just #2418 again
isUnreadableBuildStateError checks e instanceof DbError — a TS class. A Rust-thrown error surfaces at this boundary as a plain napi-generated Error, never a DbError instance, so the existing check can't recognize it even if added to this catch block unchanged. Correctly closing this gap needs either:
- matching on the error message content (fragile — ties TS logic to Rust's exact wording), or
- a structured signal from the Rust side (e.g. a distinguishable error code/prefix
NativeDatabase::build_graph could set, that TS parses), or
- some other explicit contract between the two error-reporting layers.
That's a real design decision, not a one-line fix, which is why it's being filed separately rather than folded into #2418's precise, narrow ask (fix the Rust loader itself).
Impact
In practice, whether this matters depends on what the JS-pipeline fallback does next: if it re-runs its own detectChanges/loadFileHashes (already fixed by #2414) against the same corrupted file_hashes table, it may independently throw the correctly-tagged error anyway — meaning the user-visible outcome could still be correct via a different path. This hasn't been verified either way. Worth confirming with a real end-to-end reproduction (a corrupted/locked file_hashes table on a --engine native incremental build going through the full pipeline.ts buildGraph() flow, not just the Rust unit-test level) before scoping a fix.
Suggested approach
Not investigated/scoped here — options include the ones listed above, or restructuring so the native orchestrator's own error surfaces a stable, checkable signal (e.g. a specific Error subclass or .code property set at the napi boundary or immediately after catching nativeDb.buildGraph()'s throw) that both this catch site and the pre-flight's can share.
Summary
Found while fixing #2418 (native
load_file_hashescollapsed a read failure into "no prior build state", silently wiping a healthy graph on a transientSQLITE_BUSY/schema fault). #2418 fixes the Rust loader itself to correctly return an error instead ofNonein that case, and that error now propagates throughNativeDatabase::build_graph's#[napi]wrapper as a thrownnapi::Error— up throughsession.runBuildGraph()andtryNativeOrchestrator(ctx)insrc/domain/graph/builder/stages/native-orchestrator.ts, neither of which catch anything.But
pipeline.ts's own call site does:This unconditionally swallows any error from the native orchestrator — including the one #2418 makes it throw for unreadable prior state — and falls back to the JS pipeline instead of surfacing it. Contrast with the earlier JS-side fast-skip pre-flight a few lines above in the same function, which explicitly checks
isUnreadableBuildStateError(err)before deciding whether to re-throw or fall through, specifically because "falling through here would... produce exactly the wipe this error prevents."Why this isn't just #2418 again
isUnreadableBuildStateErrorcheckse instanceof DbError— a TS class. A Rust-thrown error surfaces at this boundary as a plain napi-generatedError, never aDbErrorinstance, so the existing check can't recognize it even if added to this catch block unchanged. Correctly closing this gap needs either:NativeDatabase::build_graphcould set, that TS parses), orThat's a real design decision, not a one-line fix, which is why it's being filed separately rather than folded into #2418's precise, narrow ask (fix the Rust loader itself).
Impact
In practice, whether this matters depends on what the JS-pipeline fallback does next: if it re-runs its own
detectChanges/loadFileHashes(already fixed by #2414) against the same corruptedfile_hashestable, it may independently throw the correctly-tagged error anyway — meaning the user-visible outcome could still be correct via a different path. This hasn't been verified either way. Worth confirming with a real end-to-end reproduction (a corrupted/lockedfile_hashestable on a--engine nativeincremental build going through the fullpipeline.tsbuildGraph()flow, not just the Rust unit-test level) before scoping a fix.Suggested approach
Not investigated/scoped here — options include the ones listed above, or restructuring so the native orchestrator's own error surfaces a stable, checkable signal (e.g. a specific Error subclass or
.codeproperty set at the napi boundary or immediately after catchingnativeDb.buildGraph()'s throw) that both this catch site and the pre-flight's can share.