fix(kanban): hold claim on iteration-budget exhaustion (#71175) - #71189
fix(kanban): hold claim on iteration-budget exhaustion (#71175)#71189JonthanaHanh wants to merge 2 commits into
Conversation
…71175) When a goal-mode Kanban worker exhausts its iteration budget, _record_task_failure was called with release_claim=True, which freed the claim while the worker process was still alive. The dispatcher then spawned a second worker into the same worktree. Change to release_claim=False so the claim stays held until the worker actually exits. The goal loop continues with a fresh budget until goal_max_turns is exhausted. Fixes NousResearch#71175
|
I verified the current head So the patch does prevent the immediate ready/unclaimed respawn, but it closes Could the regression coverage assert the run-identity behavior (or add the proposed |
|
We hit this same class in production and landed a fix locally, so here's a data point plus one edge case that The edge case: not releasing the claim closes the window where the dispatcher hands the task to a replacement worker. But if a replacement does get in — through any other path, or during the window before this fix — the stale worker's later failure mutation still lands on whatever is in the row at that moment. It clears the replacement's claim, closes the replacement's run, and increments the failure counter against work the replacement is still doing. The claim-release is one way in; the underlying issue is that the failure write itself isn't bound to the attempt that produced it. What we did instead: fence the failure to the exact worker attempt, so a replacement makes the whole thing a no-op.
row = conn.execute(
"SELECT consecutive_failures, status, max_retries, "
"current_run_id, claim_lock FROM tasks WHERE id = ?", (task_id,),
).fetchone()
if row is None:
return False
if expected_run_id is not None and row["current_run_id"] != int(expected_run_id):
return False
if expected_claim_lock is not None and row["claim_lock"] != expected_claim_lock:
return FalseThe same fence goes into if expected_run_id is None:
# unchanged legacy path
...
else:
run_id = int(expected_run_id)
...
conn.execute(
"UPDATE tasks SET current_run_id = NULL "
"WHERE id = ? AND current_run_id = ?",
(task_id, run_id),
)So an accepted failure closes only the run it belongs to and never clears a pointer that has moved on. Both params default to The two approaches aren't exclusive: yours removes the main way a replacement appears, the fence makes the failure write safe if one appears anyway. If it's useful I'm happy to open a follow-up PR for the fencing part on top of this, or leave it — your call, since this is your PR and I don't want to fragment the fix. Context: this came out of a local hardening pass on a Kanban-heavy deployment; the fenced version has been running with the corresponding tests green. Related to my #73188, which applies the same ownership-proof idea to |
Problem
When a goal-mode Kanban worker exhausts its per-turn iteration budget,
_record_task_failureis called withrelease_claim=True. This releases the claim and closes the run while the worker process is still alive. The dispatcher then sees an unclaimedreadycard and spawns a second worker into the same worktree — two workers edit the same files with no coordination.Fix
Change
release_claim=Truetorelease_claim=Falseinagent/turn_finalizer.py:169. The claim stays held so the dispatcher cannot spawn a duplicate worker. The goal loop continues with a fresh iteration budget untilgoal_max_turnsis exhausted.Test update
Updated
test_pending_response_records_kanban_timeoutto expectrelease_claim=Falseinstead ofTrue.Fixes #71175