A model-provider 429 is indistinguishable from work: the fleet ran 90 minutes on two dead lanes with no signal #384
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
toasterson/Anima!384
Loading…
Reference in a new issue
No description provided.
Delete branch "claude/wi-019f9aa3-a-model-provider-429-is-indistinguishabl"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Anima work item
019f9aa3-1e67-7362-8a65-2b135a9b6992.What happened (2026-07-25, ~17:00–19:00)
Throughput went to zero. Nothing in Anima said so. Every surface — runner table, session table, delivery board — reported a healthy, busy fleet.
The actual cause was not in Anima's code. Both model providers were out of quota:
Probed directly from inside each runner container.
GET /v1/modelsreturns 200 in 0.18s — connectivity is fine, entitlement is not, so every naive health check passes.Why nothing caught it
The ACP handshake does not touch the model. A session therefore reaches
runningand emits its first three events (session_capabilities,config_options,available_commands_update) normally, then the first prompt turn hits the 429 and the agent swallows it. Observed shape:Four events and no
acp.turn_completed, forever. Compare a healthy session (WI-248 at 17:28):acp.tool_call_update×N thenacp.turn_completed {"stop_reason":"end_turn"}.Then the reaper abandons the session, the WI returns to dispatchable, and the scheduler sends it straight back into the same wall. This is the re-dispatch half of the fleet silent-failure class (PR #362) — but the trigger is new and is not code: it is a billing state.
A dead lane and an idle lane are the same picture. That is the defect.
Why it took the whole fleet down
ANIMA_SCHED_DEFAULT_EXECUTOR=opencode, andscheduler.rs:241routes every WI whoseexecutoris empty to the default:All 14 dispatchable items had
executor = ''. So 100% of the backlog funnelled into the one lane with no quota, whilerunner-1(4 claude-code slots) andakh-gateway-thoth(6 tecton slots) sat idle — 10 of 13 slots unreachable by data, not by capacity. Pinning one item (Akh-Medu WI-250) totectonmoved itready → workingwithin one scheduler tick, confirming the slots were always there.Note the compounding: single default executor + no priority ordering (WI-347) + no quota signal means one exhausted API key silently halts everything.
Verification that this is real, not inferred
12-hour session history by lane:
tecton stayed healthy because it does not use either exhausted provider — it reaches
akhomedontcp://127.0.0.1:8201. It pushed real PRs today (akh-medu #240, #253 from WI-220 and WI-249). That is the control group: the outage tracks the provider, not Anima.What to build
Classify the failure. A turn that ends with no
acp.turn_completedmust not be recorded the same as one that ends withstop_reason=end_turn. Ties into PR #362 — this is the concrete case that makes it urgent.Lane health must include entitlement, not just reachability. A per-executor preflight that actually spends one token against the configured model, with the result surfaced as lane state (
live/no-quota/unreachable). A 200 on/v1/modelsmust never be allowed to read as green.Do not re-dispatch into a lane known to be out of quota. Hold the WI as dispatchable and leave the lane marked, rather than burning slots and worktrees in a loop. Today's loop swept 20 stranded worktrees on one runner restart.
Surface it. "All lanes out of quota" is the single most important thing the dashboard can say, and today it said nothing. Relates to the Attention Inbox (ADR 0015).
Reconsider the single default executor. A default that points at one provider makes that provider a single point of failure for the entire backlog.
Acceptance
no-quotawithin one scheduler interval.Evidence trail
Reproduce the probe (from inside the runner container, key via env, never inline):
Operator action needed today (outside this WI): top up ollama.com usage, or wait out the Anthropic subscription window, or repoint
OPENCODE_MODELat a provider with headroom.REFINEMENT 2026-07-25 19:15 — opencode does not fail on 429, it retries silently and holds the slot
The ollama session limit was a rolling window that reset on its own at ~19:12. Decisive detail: the three stranded sessions (WI-11, WI-248, WI-337) resumed without re-dispatch — same session ids, event counts jumping 4 -> 32 / 4 -> 41 / 4 -> 8 with tool calls flowing.
So opencode was internally retrying the 429 for ~34 minutes, emitting nothing. That is worse than failing:
This changes the fix. Recommendation 3 (do not re-dispatch into a no-quota lane) is secondary — nothing was being re-dispatched. The primary fix is to make the retry state observable: the runner must surface provider backoff/retry as session state (e.g. a session.blocked{reason=no_quota,retry_after} event), so a slot held on a 429 is visibly different from a slot doing work.
Corollary for detection: a 429 outage self-heals, so post-hoc log forensics will find nothing. The signal has to be captured live, at the moment the provider returns it.
Probe result at 19:15Z: ollama=200 (recovered), anthropic=429 (still capped).
9b9a1cd4b8to7ef5d85293Rebased onto
main. The blocker was a wire collision, not a textual conflict, so recording the call here.The collision
Both sides claimed field 5 on
Heartbeat:main:repeated ExecutorQuota executor_quotas = 5;repeated LaneStatus lane_status = 5;Resolution: keep both,
lane_statusmoves to field 6executor_quotaskeeps 5 — it is merged and deployed, so renumbering it would break every attached runner mid-flight.lane_statustakes 6.They are complementary, not alternatives, and neither derives the other:
executor_quotasis a slow-timer measurement of account usage (the provider's usage endpoint, polled off the heartbeat path).lane_statusis a state derived from real turn outcomes.UNREACHABLEhas no quota analogue at all.The proto diff against
mainis now purely additive — no field renumbered, no message removed.Merge notes
Heartbeatcarries both;update_heartbeattakes both (quotas: Vec<ExecutorQuota>,lane_status: &[LaneStatus]).main's per-executor slot accounting and gain the branch's lane gate:claim_slotandclaim_slot_for_reviewnow requireexecutor_effective_free_for(...) > 0 && !h.lane_health.is_sick(executor).RunnerSnapshotcarriesexecutor_quotasandlane_healthside by side.slot.rs: ablocked:close takes precedence over thefailed-no-attempt:tagging from WI 019fa2fc — both comments retained on the merged match.agent_pb/dashboard_pb/runner_pb); verifiedmain's committed gen was already in sync first, so the diff here is only this branch's own proto change.Verification:
cargo check -p anima-runner -p anima-server --all-targetsclean;cargo test -p anima-server --lib165 passed (incl. 5 newlane_healthtests);cargo test -p anima-runner73 passed;npm run buildinanima-pwaclean. The server suite needing a Postgres container per test was not run.Context: 2026-07-28 sweep of the 11 open PRs that could no longer rebase onto
main.7ef5d85293to6e5662b902WIP: A model-provider 429 is indistinguishable from work: the fleet ran 90 minutes on two dead lanes with no signalto A model-provider 429 is indistinguishable from work: the fleet ran 90 minutes on two dead lanes with no signal6e5662b902to78ec05adf778ec05adf7to246cbf0fb0