Fail fast on terminal engine-down errors (402/balance exhausted/auth-expired)#33
Conversation
A Grok Build 402 "usage balance exhausted" incident caused every task on that engine to burn both retry attempts (~5s each) before failing, and all of it landed in the scoreboard as ordinary model failures. - detect_engine_down_reason() classifies worker output (tail-scoped, ~2000 chars) for billing/auth terminal patterns: HTTP 402, "payment required", "balance exhausted", insufficient credits/quota, expired auth/token/ session/api-key, invalid api key. - RingerRunner tracks which engines are down for the run; once one task reveals it, every other task on that engine (running or queued) fails fast with status "engine-down" / verdict ENGINE_DOWN instead of burning a second attempt or a fresh taskdir. - aggregate_model_log_rows / aggregate_model_scoreboard_rows now exclude ENGINE_DOWN-verdict tasks entirely, so a billing outage no longer drags down a model's pass_rate/first_try_pass_rate. - engines/mock_worker.py gains a MOCK_ENGINE_DOWN directive for testing. Deviation from the "verification executes the artifact" invariant: the engine-down path skips running the check entirely (nothing to verify from a billing/auth failure, and PASS is never claimed). Stdin-closed, explicit sandbox mode, and "logs carry raw worker output" are unaffected — the new [ringer.py]-prefixed log lines follow the same convention as existing attempt-lifecycle lines. 🤖 Generated by JeffOS
|
Review verdict: we want this capability, and we want it once. @mlava's #35/#36 solve the sibling problem — spawn-level failures (missing binary, exit 126/127) — with a second run-scoped circuit breaker living in the same two functions as yours. Merging both as written gives the codebase two parallel engine-health mechanisms with contradictory logging conventions, so instead of us picking a winner: @jeffhamons @mlava — would you two put your heads together and design the canonical mechanism? One PR (co-authored, or stacked with clear layering — your call; authorship is preserved either way). Constraints from the maintainers, which are firm:
Fail-fast-on-dead-engine is a real waste-killer and we'd like it in main soon — happy to review a joint design sketch in either thread before you build. Thanks to you both for attacking the same real problem from opposite ends. |
Summary
Fixes an incident (2026-07-12) where a Grok Build engine returning
402 Payment Required: usage balance exhaustedcaused every task in a 5-lane run to burn both retry attempts (~5s each) before failing — 10 wasted worker invocations logged to the scoreboard as ordinary model failures.detect_engine_down_reason()classifies worker stdout/stderr (tail-scoped to the last ~2000 chars, where a terminal harness error actually lands) for billing/auth patterns: HTTP 402 (plain text and JSONstatus/codefields), "payment required", "balance exhausted", insufficient credits/quota, expired auth/token/session/API-key, invalid API key.RingerRunnertracks which engines are down for the run. The first task to hit a terminal error marks its engine down; every other task on that engine — already running or still queued — fails fast with a distinctengine-downstatus /ENGINE_DOWNverdict instead of burning a second attempt or even preparing a taskdir/worktree.aggregate_model_log_rows/aggregate_model_scoreboard_rowsnow excludeENGINE_DOWN-verdict tasks entirely (same pattern as the existing reserved-fixture guard), so a billing outage no longer drags down a model'spass_rate/first_try_pass_rate.engines/mock_worker.pygains aMOCK_ENGINE_DOWNdirective for deterministic testing.Design notes for reviewers
[ringer.py]-prefixed log lines follow the same convention as the existing attempt-lifecycle lines.docs/MODEL-NOTES.mdandengines/opencode-sandboxed.shuntouched (other owners' files per repo convention).Test plan
tests/test_engine_down.py: pattern-matching unit tests (positive/negative cases including the exact incident string), an aggregation-exclusion unit test, and a full subprocess end-to-end test (3 tasks,max_parallel=1) proving task-one burns exactly 1 attempt and task-two/three never even get a taskdir.git stashthat they fail identically on the unmodified baseline.ruff checkon changed files: no new findings.