fix(gateway): make i_will_work_on plan-precondition atomic; recover claimed-no-plan

Bug A from the 2026-05-09 smoke run. be-dev-1 called i_will_work_on
without `plan` on a pending task; claim() ran first (transitioned to
`claimed`), then the plan check failed → tracing_gap. The natural
retry path then dispatched to `_i_will_work_on_claimed`, which had no
plan-recovery logic and called start() against a still-plan-less task,
returning `start failed` forever. The dev kept looping; the parent
escalated up; the whole slice ended `blocked`.

Two changes (Task-5 atomicity pattern applied to i_will_work_on):

1. `_i_will_work_on_pending`: move the plan precondition BEFORE
   `claim()`. A missing-plan first call now returns tracing_gap with
   the task untouched in `pending`, so the agent's retry-with-plan
   succeeds cleanly.

2. `_i_will_work_on_claimed`: now accepts `plan` and calls set_plan
   before start() if the task has no plan yet. Recovery path for any
   already-stuck task (e.g. left over from the earlier image, or an
   orchestrator restart that left a partial claim).

Also wires `plan` through the dispatcher to the claimed branch.

Tests: 3137 passing (3135 + 2 regression tests pinning the atomic
invariant), 100% coverage, ruff clean.
This commit is contained in:
Renn F
2026-05-09 03:15:02 +02:00
parent b601441da7
commit d819c28893
2 changed files with 95 additions and 15 deletions
@@ -1084,3 +1084,57 @@ async def test_open_pr_does_not_create_pr_if_no_commits() -> None:
assert "no commits" in body["message"]
git_svc.create_pr.assert_not_called()
git_svc.push_branch.assert_not_called()
@pytest.mark.asyncio
async def test_i_will_work_on_missing_plan_does_not_claim_pending_task() -> None:
"""Atomic invariant (Task 5 pattern, Bug A from 2026-05-09 smoke):
if `plan` is missing on the FIRST i_will_work_on call against a
pending task, the task must NOT be claimed. Pre-fix the verb ran
claim() BEFORE checking plan, leaving the task in `claimed` with
no plan — and `_i_will_work_on_claimed` had no recovery path so
the agent looped forever on `start failed`.
"""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(task_id, status="pending")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan=None)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "plan" in body["missing"]
task_svc.claim.assert_not_called(), (
"claim() ran before plan precondition was satisfied — atomicity broken"
)
@pytest.mark.asyncio
async def test_i_will_work_on_claimed_with_no_plan_accepts_recovery_plan() -> None:
"""Recovery path (Bug A from 2026-05-09 smoke): if the task is in
`claimed` state without a plan (e.g. from a prior partial-claim race
or an orchestrator restart), a fresh i_will_work_on call WITH plan
must set the plan and then start, not just call start() against a
plan-less task.
"""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(
task_id, status="claimed", assigned_to=agent_id, plan=None
)
started = MagicMock(
status="in_progress",
assigned_to=agent_id,
plan="recovery plan",
id=task_id,
title="t",
task_type="code",
)
task_svc.set_plan.return_value = started
task_svc.start.return_value = started
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="recovery plan")
body = env.as_dict()
assert body["error"] is None, f"expected success, got {body}"
task_svc.set_plan.assert_awaited_once()