Repository navigation
AI runs: the cron's claim pass honours the attempt ceiling — a queued run with no attempts left is abandoned, not re-claimed for ever (Q40, second half; migration 0479) - #904
Closed
NewWorldVenture wants to merge 5 commits into
Closed
NewWorldVenture wants to merge 5 commits into
NewWorldVenture wants to merge 5 commits into
Conversation
…nd half; migration 0479) `attempt` is the run's abandonment budget, and three readers agreed about it: the recovery arm of `claim_ai_runs` dead-letters an `executing` run whose lease ran out at `max_attempts`, `claimRun` refuses to lease a run at that ceiling, and since the first half of Q40 the executor hands the budget back only when a slice made progress. The claiming half of `claim_ai_runs` never read it: a run parked back in the queue with no attempts left was claimed by every tick for ever while every human-initiated path refused it, and the dead-letter arm never reached it because a parked run holds no lease. 0479 rewrites `claim_ai_runs` once more, in 0263's shape. The recovery statement gains a second arm — a `ready`/`scheduled_followup` run with no live lease, no cancel in flight and `attempt >= max_attempts` takes the existing `failed` arm and 0263's four-table reconcile — and the candidates query gains `attempt < max_attempts`, the same ceiling `claimRun` applies to one row. The expired-lease arms, the lease token, the increment, the ordering, `for update skip locked` and the grants are unchanged. Number reserved on #699 (5970781386); the migration-version pin moves to 0480. With the ceiling enforced, an unexecuted claim must not spend the budget: the cron route's hand-back of a run it claimed in a wave but never reached now returns the attempt the claim charged (`claimRuns` reports it). Proof: docs/audit/a-run-with-no-attempts-left-is-abandoned-check.sql runs the real function against five rows in one call with a control one attempt below the ceiling; it fails against 0263's body and passes against 0479's, applied once or twice, and three concurrent callers over 30 committed rows claimed each of the 20 eligible runs exactly once and abandoned each of the 10 spent runs exactly once. The in-memory stand-in for the function moves to tests/helpers/claim-ai-runs.ts and gains the ceiling; tests/a-run-with-no-attempts-left-is-abandoned.test.ts drives the real cron route, `claimRun` and the migration's shape (16 cases); the "two claim paths disagree" pin in a-successful-slice now pins that they agree. Probe headers for 0263 and the RPC grants name 0479 as the current writer of the function. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
This was referenced Oct 3, 2026
…0479) 0263's reconcile for a dead-lettered run wrote the steps, the request ledger and the timeline, and left approval_requests alone. A run that died with an approval in flight kept a card in "Needs your decision" until expires_at, and a parent who decided it approved work that would never start: foldIntoRun never re-opens a terminal run, so the decision marked the row approved and did nothing else. Both dead-letter arms of 0479 (expired lease, queued at the ceiling) can leave such approvals. 0479's reconcile gains a fourth bounded write: approvals still pending for the dead runs become `cancelled` (as cancelRun closes them; not `expired`, time did not run out) with decided_at set and no decider, found by run_id and by the step whose approval_id names them (0251 lets run_id be null). Nothing else in the function changes. Probe: three pending approvals added to the attempt-ceiling probe, two on the abandoned run (one by run_id, one reachable only through the step) asserted cancelled with decided_at and no decided_by, one on the control asserted still pending. On the PG16 harness: passes with the new body; the 0263 dead-letter, stranded-run and RPC-grants probes pass with it; against the previous body it fails at "the abandoned run's approval (by run_id) was left pending". Test pins: v_dead bound count 4, the approvals write's shape, the probe's new messages. 12 suites / 167 cases green; eslint clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…laude/ai-runs-claim-ceiling-20261003
On #901's branch the test carried its own 0263 emulation because tests/helpers/claim-ai-runs.ts only exists from this branch on. Merged down, it imports the shared stand-in like every other run-spine suite; both arms it exercises (an expired lease recovered and re-leased, an expired lease at the ceiling dead-lettered) read the same under 0479. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…laude/ai-runs-claim-ceiling-20261003
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #901 (→ #900). Migration number
0479reserved by the coordinator on #699 (comment 5970781386); file and scope announced before writing (5970918142).The defect (finalaudit Q40, "the two claim paths disagree")
family_automation_runs.attemptis the run's abandonment budget. Three readers agree about it: the recovery arm ofclaim_ai_runsdead-letters anexecutingrun whose lease ran out onceattempt >= max_attempts("Run abandoned after the maximum number of attempts.");claimRunrefuses to lease a run at that ceiling; and since #901 the executor hands the budget back only when a slice made progress, so the counter is the number of slices in a row that completed nothing.The claiming half of
claim_ai_runsnever read it. Its candidates were every dueready/scheduled_followuprun, and each claim added one. A run parked back in the queue with no attempts left was therefore claimed by every tick for ever (attempt 6, 7, 8 …) while the resume button, the kick after an approval answer and a step re-run all refused it, silently. The dead-letter arm never reached it, because it ran only for an expired lease on anexecutingrow, and a parked run holds no lease. Unreachable by people, unkillable by the worker.The fix
supabase/migrations/0479_a_run_with_no_attempts_left_is_abandoned_not_reclaimed.sql— onecreate or replace function public.claim_ai_runs(integer, integer), in 0263's shape:ready/scheduled_followuprun with no live lease, no cancel in flight andattempt >= max_attemptstakes the existingfailedarm (state, 0022's legacy status, error,completed_at) and 0263's reconcile (steps, request ledger,run_failedevent), so the family is told instead of being shown "Scheduled" for ever. A run whose cancel is in flight is left to endcancelled; a follow-up not yet due is abandoned now rather than on the day, since it would never have run;and attempt < max_attempts, the same ceilingclaimRunapplies to one row;pendingbecomecancelled(ascancelRuncloses them; notexpired, time did not run out) withdecided_atset and no decider, found byrun_idand by the step whoseapproval_idnames them (0251 letsrun_idbe null). 0263 left them open, so a run that died with an approval in flight kept a card in "Needs your decision" untilexpires_at, and a parent who decided it approved work that would never start:foldIntoRunnever re-opens a terminal run.Unchanged: the expired-lease arms, the lease token's type, the increment, the ordering,
for update skip locked, the grants (0263's revoke/grant pair restated byte for byte). Depends on 0250, 0263, 0253, 0292 (and 0093/0251 forapproval_requests); nothing in 0475–0478 is referenced.tests/migration-version-safety.test.tspin →0480.app/api/cron/ai-runs/route.ts,lib/ai/runs/store.ts— with the ceiling enforced, an unexecuted claim must not spend the budget. The tick claims a wave of four and gives each a slice only while 12 s remain; a run it never reached was handed backreadybut kept the attempt the claim had charged. Five such hand-backs under a backlog would have abandoned a run that was never tried.claimRunsnow reports the post-claimattempt, and the hand-back returns it (Math.max(0, attempt - 1)), which also makes the "exactly as it was found" comment true. The write's row-count gate (C1-S9-63) is untouched.Tests and probes
docs/audit/a-run-with-no-attempts-left-is-abandoned-check.sql(new, globbed byrun-probes.sh): five runs and three pending approvals, one call, one family, rolled back. A duereadyrun at 5/5 is not claimed and is abandoned with its step failed, its request failed, exactly onerun_failedevent and both of its approvals (one byrun_id, one reachable only through the step)cancelledwithdecided_atand nodecided_by; ascheduled_followupat 5/5 due in three days is abandoned too; a run at 5/5 with a cancel in flight and one holding a live lease are untouched; the control at 4/5 is claimed (executing, attempt 5, live lease) with its step, request, timeline and approval untouched. Setup is checked before the call.tests/helpers/claim-ai-runs.ts(new): the predicate-for-predicate in-memory stand-in for the function, moved out ofa-run-that-threw-is-not-stranded.test.tsand given the ceiling, plusclaimAiRunsMigration()which finds the current definition by name so a renumbering at merge time cannot break the pins.tests/a-run-with-no-attempts-left-is-abandoned.test.ts(new, 17 cases) drives the real cron route and the realclaimRun: five stalling slices (three per tick, as the route's own loop allows) then the next claim abandons instead of claiming; a progressing slice in the middle resets the count so abandonment comes after the tenth slice, not the fifth; what the cron abandons,claimRunhad already refused, and the refusal now carries a reason; the not-yet-due follow-up; cancel in flight / live lease left alone; one below the ceiling gets its slice (finishes if it finishes; abandoned only after a fifth stall); a run claimed in a wave but handed back unexecuted is not charged. Source pins on the migration (both arms, the ceiling, the reconcile bounded tov_deadfour times, the approvals write's shape, the grants) and on the probe's control.tests/a-successful-slice-must-not-spend-a-retry.test.ts: the "two claim paths still disagree" pin now pins that they agree, keeping 0250's original as the recorded premise.dead-letter-reconcile-check.sql) and the RPC grants name 0479 as the function's current writer.Evidence (local PostgreSQL 16.13 harness, disposable)
a ready run with no attempts left was claimed again) and passes against 0479's, in a rolled-back transaction; applied twice (reapply) it still passes; the two existingclaim_ai_runsprobes (0263 dead-letter, stranded run) and the RPC-grants probe pass against the new body.the abandoned run's approval (by run_id) was left pending.ready, max attempt 5, exactly onerun_failedevent per abandoned run, 10 requests failed / 20 still executing. No double claim, no double dead-letter.tsc --noEmitand ESLint clean. Second commit: 12 suites / 167 cases, eslint clean.Not done here
awaiting_approvalunder an abandoned run keep that state, exactly as 0263 leaves them for a dead worker; widening that list is a separate decision. Their approval is now closed, so nothing asks a parent to decide for them.claude/ai-runs-release-recoverable-20261003when AI runs: a slice that made progress no longer spends the run's abandonment budget (Q40, first half) #901 merges, and tomainwhen AI runs: a run whose slice threw is handed to the recovery pass, not stranded (LIBRARY-3E7235F73734, API-9BCA8F281320) #900 does.🤖 Generated with Claude Code
https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K