fix(drivers): isolate local query timeouts and preserve helper crash causes

Merged
AN
1d ago
+306-31

Issue for this PR

Closes #1493
Closes #1494

Type of change

  • Bug fix

What does this PR do?

Stacked on feat/analyst-harness-dab-2026-10 (#1441)

Why: when one query timed out on the local DuckDB/SQLite helper, the helper was restarted and every other queued call was rejected, even pure file reads that hold no state. And when the helper died (signal or non-zero exit), the cause was lost and the materialize tool carried on with a cap-clamp fallback.

Root cause: timeout retirement bumped a single generation counter, and any call from an older generation was rejected with no distinction between stateless and stateful sessions. Helper stderr was discarded and the unexpected-exit error carried no exit code or signal. The count/sample preflight in sql_materialize treated a helper crash as an ordinary estimate failure.

Before -> after:

  • Queued stateless reads (single SELECT/WITH/VALUES) now complete on the replacement helper after a sibling timeout. Stateful, in-memory and ambiguous sessions still get the state-loss rejection; helper-wide state (SQLite, DuckDB SET/PRAGMA/ATTACH/extensions) still disclosed to peers.
  • LOCAL_ENGINE_PROCESS_EXITED now reports engine, exit code, signal and a bounded stderr tail (no unverified OOM claim). Post-exit stderr draining is bounded to 100 ms; intentional timeout kills still report QUERY_TIMEOUT.
  • sql_materialize stops with the crash message on a helper crash in count/sample preflight; no extraction, no target table, no cap-clamp warning.

Deferred: deadline floors, multiple read-only helpers, separate queue/execution duration reporting, host-sized memory_limit (temp_directory is already configured).

Review follow-ups (P2, not fixed here): statement classification looks only at the outer SQL, so side-effecting SELECT functions (e.g. dynamic query(...) or settings table functions) are treated as stateless; the stderr reader is not cancelled when the 100 ms drain grace expires, so a descendant holding the pipe can delay parent exit.

How did you verify your code works?

  • packages/drivers: local-engine + local-engine-crash tests 79 pass / 0 fail; related driver tests (review, security, byte-limit, lines) 57 pass / 0 fail; tsgo typecheck clean
  • packages/opencode: sql-materialize + sql-materialize-bulk 60 pass / 0 fail; bun run typecheck clean
  • git diff --check clean. Only src/altimate/** (absent from the v1 baseline) changed under packages/opencode/src, so no upstream markers are required.
  • Independent Codex review: no P0/P1; two P2 noted above.

Screenshots / recordings

N/A

Checklist

  • Tests added
  • Typecheck passes

🤖 Generated with Claude Code


[!NOTE]
Medium Risk
Changes core local-engine session invalidation and error typing used by SQLite/DuckDB connectors; misclassified “stateless” SQL could run after a restart when session state was actually lost.

Overview
Refines local DuckDB/SQLite helper behavior when one query times out or the child process dies, so unrelated work is not always torn down and crashes are easier to diagnose.

Timeout / restart queueing: After a helper kill, stale queued calls are rejected only when the session may have lost state (lostGeneration + per-session stateful). Simple file-backed stateless reads (single SELECT/WITH/VALUES without mutating keywords) can still run on the respawned helper. In-memory stores, temp tables, ambiguous SQL, and helper-wide effects (SQLite’s shared session; DuckDB SET/PRAGMA/attachments/extensions/CREATE … SECRET) still trigger SESSION_STATE_LOST for affected peers.

Unexpected helper exit: Helper stderr is captured (8 KiB tail, ~100 ms drain cap) and surfaced via new LocalEngineProcessError (LOCAL_ENGINE_PROCESS_EXITED) with engine label, exit code, and signal—distinct from intentional deadline kills (QUERY_TIMEOUT). Timeout timer ignores already-retired children.

Tests: New local-engine-crash.test.ts and expanded deadline/peer scenarios for stateless reads, shared DuckDB settings/secrets, and read-only peers.

Reviewed by Cursor Bugbot for commit 3289405dc8fea2e24a733aa60a402a3a0c76b087. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Closes #1493 and #1494. Isolates local engine query timeouts so queued stateless reads (single SELECT/WITH/VALUES) continue on the replacement helper after a sibling timeout, while stateful, in-memory, and ambiguous sessions still get the state-loss rejection. Also preserves helper crash causes; DuckDB secrets (the CREATE ... SECRET DDL) count as helper-wide state.

  • Peer state-loss disclosure still fires for helper-wide state: SQLite sessions and DuckDB SET/PRAGMA, attachments, extensions, and secrets.
  • LocalEngineProcessError now reports engine, exit code, signal, and a bounded 8 KiB stderr tail; intentional timeout kills still report QUERY_TIMEOUT.
  • sql_materialize stops with the crash message when a preflight count/sample hits a helper crash, instead of falling back to cap clamp and extracting.

Known follow-ups

  • Stateless-read detection is conservative; LocalEngineProcessError.stateLost is always true, even when only stateless sessions were open; in-memory detection recognises only :memory: and empty paths; other sticky-flag refinements are not handled.