Skip to content

Spawn failures are infrastructure evidence, not model evidence#35

Open
mlava wants to merge 1 commit into
NateBJones-Projects:mainfrom
mlava:fix/infra-spawn-failures
Open

Spawn failures are infrastructure evidence, not model evidence#35
mlava wants to merge 1 commit into
NateBJones-Projects:mainfrom
mlava:fix/infra-spawn-failures

Conversation

@mlava

@mlava mlava commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

When an engine binary is missing from PATH, the wrapper script exits 127 (opencode-sandboxed.sh: opencode not found on PATH) without ever invoking a model. Today each such attempt:

  1. writes a FAIL row to the per-model log — in a real run on 2026-07-14, one missing binary produced 36 poisoned rows across two runs and dragged an innocent model's aggregate pass rate from 1.00 to 0.40. The only remedy was hand-editing runs.jsonl.
  2. spends the task's retry on a failure a retry cannot fix — a 16-task run burned 32 doomed spawns before summarizing.
  3. renders identically to a real check failure in the summary table (fail FAIL 2), so triage starts from zero.

Fix — one rule: no model evidence, no model row

  • worker_never_ran() names the two signatures: exit 126/127 with no token usage (shell command-not-found / not-executable, which is how wrapper-script engines fail), 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 a new INFRA verdict for these attempts, checked before verify.ok so a check passing against stale artifacts can't credit a model that never ran.
  • _log_attempt() gates on worker_never_ran() at a single choke point: no model-log row, no steering observation, breadcrumb written to the worker log instead. This also covers _record_prepare_error, whose ERROR 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 worker-log line saying why and what to fix.
  • The run summary counts INFRA tasks and prints a pointed hint (check the engine bin/PATH), so the failure class is visible at a glance.

Tests

tests/test_infra_spawn_failures.py:

  • unit coverage for every branch of the classification — including the two cases that must keep reaching the log (rc=1 with no parseable tokens; timeouts) and the token-usage-wins case
  • an end-to-end run (mirroring test_mock_engine.py) against a /bin/sh wrapper that exits 127: asserts verdict INFRA, attempts == 1, an empty model log, the summary hint, and the worker-log breadcrumbs

8/8 pass. The pre-existing test_design_reference / test_scoreboard_page failures on main reproduce identically with this change stashed, so they're unrelated.

Field evidence

Run protocol-spec-audit-20260714T120644Z: 16 tasks × 2 attempts, all rc=127, 0 tokens, ~10–20s each — every row logged as model FAIL. Same signature recurred the same day in a second run because the fix lived in the operator's shell environment rather than the harness.

🤖 Generated with Claude Code

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>
@justfinethanku

Copy link
Copy Markdown
Contributor

Review outcome: #35 rides with #36 (which contains it) — the joint-design invitation and maintainer constraints for unifying with #33's engine-down breaker are posted on #36 and #33. Short version: one run-scoped engine-health mechanism, your spawn detector + @jeffhamons' API-terminal detector feeding it, visible-but-unranked eval rows rather than suppression, and truthful display states.

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