Skip to content

Circuit-break an engine after its first spawn failure#36

Open
mlava wants to merge 2 commits into
NateBJones-Projects:mainfrom
mlava:fix/infra-circuit-breaker
Open

Circuit-break an engine after its first spawn failure#36
mlava wants to merge 2 commits into
NateBJones-Projects:mainfrom
mlava:fix/infra-circuit-breaker

Conversation

@mlava

@mlava mlava commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #35 — review the last commit only (f18e906); the first commit is #35's INFRA classification, which this builds on.

Problem

preflight_engine_bins cannot catch every spawn failure by construction: wrapper-script engines (e.g. engines/opencode-sandboxed.sh) resolve and execute fine, then exit 127 because the binary inside the wrapper is missing. In the field (2026-07-14), a 16-task run burned 32 doomed spawns — every task, both attempts — before the summary printed.

Fix

A missing binary fails identically for every task on that engine, so the first INFRA verdict trips a per-engine circuit breaker:

  • remaining tasks on that engine fail fast: verdict INFRA, zero attempts, no worker spawned, no taskdir prepared, no model-log row
  • each skipped task's worker log gets a breadcrumb naming the engine and the fix (bin/PATH in config)
  • Spawn failures are infrastructure evidence, not model evidence #35's summary hint counts breaker-skipped tasks automatically

One broken engine costs one attempt, not tasks × attempts. Engines unaffected by the failure keep running normally — the breaker is per-engine, taken under the runner lock.

Test

End-to-end (tests/test_infra_circuit_breaker.py): three tasks on a wrapper engine that exits 127, max_parallel: 1 for determinism. Asserts spawn-1 fail INFRA 1, spawn-2/3 fail INFRA 0 with breaker breadcrumbs and no attempt 1 exited line in their logs, and an empty model log. Full suite: no new failures (the pre-existing test_design_reference/test_scoreboard_page failures on main reproduce with this change stashed).

🤖 Generated with Claude Code

mlava and others added 2 commits July 15, 2026 08:26
When an engine binary is missing, the wrapper exits 127 (or 126) without
ever invoking a model — but every such attempt wrote a FAIL row to the
per-model log and then spent the task's retry on a failure a retry cannot
fix. In the field this poisoned a scoreboard with 36 rows in one run,
dragging an innocent model's pass rate from 1.00 to 0.40, and the operator
had to hand-edit runs.jsonl to correct the record.

The fix draws one line: no model evidence, no model row.

- worker_never_ran() names the two signatures: exit 126/127 with no token
  usage (wrapper/shell command-not-found), or an error before exec with no
  exit code. Token usage always wins — a late 127 from a wrapper's cleanup
  after a real model run still counts as model evidence.
- verdict_for() returns INFRA for these attempts, checked before verify.ok
  so a stale artifact passing the check cannot credit a model that never
  ran.
- _log_attempt() skips the model-log row (and the steering observation)
  for such attempts, writing a breadcrumb to the worker log instead. This
  single choke point also covers _record_prepare_error, whose rows carried
  a model name for a model that never ran.
- INFRA is not in the retry set, so the retry is skipped, with a log line
  saying why and what to fix; the run summary counts INFRA tasks and points
  at the engine bin/PATH config.

Tests: unit coverage for every branch of the classification (including the
rc=1-no-tokens case that must KEEP reaching the log, and timeout staying
model evidence), plus an end-to-end run against a wrapper that exits 127 —
asserting verdict INFRA, attempts == 1, an empty model log, and the summary
hint. The pre-existing test_design_reference/test_scoreboard_page failures
on main are untouched by this change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The startup preflight (preflight_engine_bins) cannot catch every spawn
failure: wrapper-script engines resolve and execute fine, then exit 127
because the binary INSIDE the wrapper is missing. In the field a 16-task
run burned 32 doomed spawns that way — every task, both attempts — before
the summary printed.

A missing binary fails identically for every task on that engine, so the
first INFRA verdict now trips a per-engine circuit breaker: remaining tasks
on that engine fail fast with verdict INFRA and zero attempts — no worker
spawned, no taskdir prepared, no model-log row — and a worker-log
breadcrumb names the engine and the fix. One broken engine costs one
attempt, not tasks x attempts.

Builds on the INFRA classification from the previous commit; the summary
hint there counts breaker-skipped tasks automatically.

Test: end-to-end run with three tasks on a wrapper engine that exits 127 —
asserts the first task shows INFRA/1 attempt, the queued two show INFRA/0
attempts with breaker breadcrumbs and no 'attempt 1 exited' line, and the
model log stays empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@justfinethanku

Copy link
Copy Markdown
Contributor

Review verdict for #35 + #36 together (since #36 contains #35): the evidence rule — spawn failures are infrastructure evidence, not model evidence — is exactly right and we want it in main. But @jeffhamons' #33 builds a second run-scoped circuit breaker (API-terminal errors: 402/balance exhausted/auth expired) in the same two functions, and merging both as written leaves two parallel mechanisms with contradictory logging conventions.

Full maintainer constraints are posted on #33 — short version: one mechanism, N detectors; log everything credit nothing (visible INFRA/ENGINE_DOWN rows excluded from model stats, not suppressed rows); the display contract must render the new states truthfully; false positives are worse than false negatives (an ordinary worker exiting 127 mid-script or discussing billing in its output must not trip a breaker — #36's single-probe admission gate and a trustworthy infra signal beyond bare tokenless-126/127 both still apply).

@mlava @jeffhamons — would you two put your heads together and produce the canonical version? Co-authored PR or a clearly layered stack, whatever suits you; authorship is preserved either way. You attacked the same real problem from opposite ends and the union of your two detectors on one breaker is the right architecture. Happy to review a design sketch in either thread before you build. (#37 stays separate — review coming on its own thread.)

@mlava

mlava commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

In — happy to build the canonical version with @jeffhamons. Sketch below against the five constraints; Jeff, correct anything I've got wrong about #33's internals and let's split the work.

One breaker, N detectors. A single run-scoped engine-health breaker on the runner, per-engine, taken under the runner lock (#36's skeleton, generalized). Detectors classify a finished attempt and return a typed reason; the breaker only ever learns "engine X is down because Y".

Log everything, credit nothing. This reverses #35's "no model evidence, no model row": every attempt — including breaker-skipped tasks — writes a visible eval row with its verdict; aggregation excludes INFRA/ENGINE_DOWN from model stats, same doctrine as the reserved-fixture/unattributed quarantine.

Display contract. task_state_bucket(), the run summary (with the actionable hint naming the engine and the fix), final artifact pages, and Ringside all learn both states.

Admission. Single-probe: while an engine's run-health is unknown, the first spawn is the probe; queued same-engine tasks hold until it either shows life (successful spawn / token evidence) or trips the breaker — killing the race where a whole wave spawns before the first verdict lands.

In-flight tasks. v1 claims only "no new spawns, no retries": workers already running finish and get classified by the detectors on exit. Engine-aware cancellation can layer on later without changing the row/verdict contract (the #33 point-5 choice).

Packaging. Mild preference for one co-authored PR superseding #33/#35/#36 — the mechanism is genuinely one thing, and #35's row-suppression rule dies in favor of visible-but-unranked rows, so the stack would need rebuilding from the bottom anyway. But a layered stack works if you'd rather, Jeff.

If the maintainers are happy with this shape we'll build against it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants