fix(kernel): a dead leased attempt serves no retry delay - #158
Conversation
Closes #155. `abandonment_actions` records an attempt that died without producing a result a gate could judge -- a crashed worker or an expired lease. It already declines to charge such an attempt a semantic iteration, on the grounds that the failure is not the step's own. It nevertheless made it serve the retry backoff, which is the same category error: the backoff curve exists to damp a step that keeps failing on its merits, not one whose worker was killed. That delay also made recovery order a race. The dead lane sat in Backoff with wake_at_ms a few milliseconds out; the scheduler's due_waits only fires once that passes, and due_waits is returned ahead of any starts. So whether the recovered lane or an idle sibling claimed the one free worker depended on when the next pass happened to land: PROBE pass now=1788503129020 states=[("lane-b", Backoff { wake_at_ms: 1788503129025 }), ("lane-a", Runnable)] due_waits=0 Five milliseconds decided it. worker_capacity.rs:149 saw lane-a instead of the retried lane-b in ~15% of runs (4 failures in 27), and on GitHub's runners too. With this change: 60 consecutive passes, 0 failures. One existing assertion moved. parallel_tests spelled "the sibling must not pass an unfinished lane" as "every action is an ArmTimer", which held only because there was a delay to arm a timer for. The lane is now due at once, so the pass wakes it instead. The assertion now tests the invariant directly -- no action may start the sibling, and the recovered lane must be woken first -- which is strictly stronger than the mechanism it replaced. Kernel 130 passed, SDK 464 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
|
Warning Review limit reachedNext included review available in 41 minutes. View limit detailsLimit details: You’ve used the included review currently available. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (2)
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing. Comment |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Closes #155. `abandonment_actions` records an attempt that died without producing a result a gate could judge -- a crashed worker or an expired lease. It already declines to charge such an attempt a semantic iteration, on the grounds that the failure is not the step's own. It nevertheless made it serve the retry backoff, which is the same category error: the backoff curve exists to damp a step that keeps failing on its merits, not one whose worker was killed. That delay also made recovery order a race. The dead lane sat in Backoff with wake_at_ms a few milliseconds out; the scheduler's due_waits only fires once that passes, and due_waits is returned ahead of any starts. So whether the recovered lane or an idle sibling claimed the one free worker depended on when the next pass happened to land: PROBE pass now=1788503129020 states=[("lane-b", Backoff { wake_at_ms: 1788503129025 }), ("lane-a", Runnable)] due_waits=0 Five milliseconds decided it. worker_capacity.rs:149 saw lane-a instead of the retried lane-b in ~15% of runs (4 failures in 27), and on GitHub's runners too. With this change: 60 consecutive passes, 0 failures. One existing assertion moved. parallel_tests spelled "the sibling must not pass an unfinished lane" as "every action is an ArmTimer", which held only because there was a delay to arm a timer for. The lane is now due at once, so the pass wakes it instead. The assertion now tests the invariant directly -- no action may start the sibling, and the recovered lane must be woken first -- which is strictly stronger than the mechanism it replaced. Kernel 130 passed, SDK 464 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Co-authored-by: kjgbot <kjgbot@agentrelay.dev> Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
…ucible Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Closes #155. Unblocks #134, #139 and #157, which are all red on this one test and nothing else.
What was actually wrong
Not what I first thought — I got this wrong twice before instrumenting, and both wrong answers are recorded on #155 so nobody retries them.
abandonment_actionsrecords an attempt that died without producing a result a gate could judge: a crashed worker, or an expired lease. It already declines to charge such an attempt a semantic iteration, explicitly because the failure is not the step's own. But it still made it serve the retry backoff — the same category error. The backoff curve exists to damp a step that keeps failing on its merits, not one whose worker was killed.That delay is what made recovery order a race:
The dead lane sits in
Backoffwithwake_at_msfive milliseconds out. The scheduler'sdue_waitsonly fires once that passes, anddue_waitsis returned ahead of anystarts— so whether the recovered lane or an idle sibling claimed the single free worker depended purely on when the next pass landed relative to that deadline.The fix is one expression: a dead leased attempt is due immediately.
Evidence
Chance of 60 clean runs at the old rate is ≈ 6e-5. It also failed on GitHub's runners, on #134 and #157 independently, so this was not local.
One assertion moved — please look at this bit
parallel_tests::overlapping_agent_surfaces_are_serialized_in_authored_orderfailed after the change. Its intent is "the conflicting sibling must not pass an unfinished lane", but it spelled that as "every action is anArmTimer" — which only held because there was a delay to arm a timer for. With no delay the lane is due at once, so the pass emitsAppend(WaitCompleted, lane-b)and no timer.I verified what the pass actually yields rather than assuming:
Sole action, and critically no lane-a start — the invariant holds. So the assertion now tests the invariant directly: no action may start the sibling, and the recovered lane must be woken first. That is strictly stronger than the mechanism it replaced, but it is still me editing a test that judges my own change, so it deserves a reviewer's eye.
Scope and the known risk
@khaliqgant chose this option over two narrower ones. The accepted trade-off: a step that crashes repeatedly now retries with no delay and can hot-loop.
max_iterationsdoes not bound it, because crashed attempts deliberately do not consume the allowance. If that bites, the narrower fix is to have only an explicitrun.resumepreempt the backoff, leaving autonomous retry timing alone.Needs an independent signoff at
5f26664— I wrote it, so not mine.🤖 Generated with Claude Code
https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR