mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(gateway): make i_will_plan / i_will_work_on idempotent on re-entry
Smoke 2026-05-04 captured the cycle the prior 63d0adf fix didn't close:
- Spawn 1: i_will_plan succeeds, task pending → claimed → in_progress.
- Agent goes idle (LLM thinking, container exits, respawned).
- Spawn 2: i_will_plan called again. _i_will_plan_preflight rejects
'task in in_progress, expected pending'. Agent has no recovery path.
- Heartbeat eventually goes stale, reaper drops claim back to pending,
spawn 3 fires, loop repeats indefinitely.
Fix: when a respawned PM/dev re-enters the verb on a task they already
own in claimed/in_progress, return OK with current state and refresh
the heartbeat instead of rejecting. The verb is now genuinely
idempotent for the caller, which matches what 'I will plan' should
mean — record intent + advance state, regardless of how many times
the agent says it. Different-caller contention still rejects.
Refactored i_will_work_on's pending branch into _i_will_work_on_pending
helper to satisfy PLR0911 after the new branch raised return count.
This commit is contained in:
@@ -222,6 +222,99 @@ async def test_i_will_plan_surfaces_start_failure_instead_of_faking_ok() -> None
|
||||
assert "start failed" in body["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_will_plan_idempotent_when_already_in_progress_for_caller() -> None:
|
||||
"""Regression: respawned PM re-calling i_will_plan on a task they
|
||||
already moved to in_progress must NOT be rejected. Returns OK with
|
||||
current state. Smoke 2026-05-04 captured the cycle the old reject
|
||||
caused: respawn → reject → reaper drops claim → respawn → loop.
|
||||
"""
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
in_progress = MagicMock(
|
||||
id=task_id,
|
||||
status="in_progress",
|
||||
plan={"text": "x"},
|
||||
assigned_to=pm_id,
|
||||
task_type="planning",
|
||||
parent_task_id=None,
|
||||
sequence=0,
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = in_progress
|
||||
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
|
||||
task_svc.list_in_progress_for_agent.return_value = [in_progress]
|
||||
task_svc.list_paused_for_agent.return_value = []
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_will_plan(pm_id, task_id, plan="re-entry plan")
|
||||
|
||||
assert env.error is None
|
||||
assert env.status == "in_progress"
|
||||
# Heartbeat refreshed so the reaper sees activity.
|
||||
task_svc.heartbeat.assert_awaited()
|
||||
# Did NOT re-call claim or start — already past those.
|
||||
task_svc.claim.assert_not_awaited()
|
||||
task_svc.start.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_will_plan_idempotent_when_already_claimed_for_caller() -> None:
|
||||
"""Same regression but task is in claimed (post-claim, pre-start)."""
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
claimed = MagicMock(
|
||||
id=task_id,
|
||||
status="claimed",
|
||||
plan=None,
|
||||
assigned_to=pm_id,
|
||||
task_type="planning",
|
||||
parent_task_id=None,
|
||||
sequence=0,
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = claimed
|
||||
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
|
||||
task_svc.list_in_progress_for_agent.return_value = []
|
||||
task_svc.list_paused_for_agent.return_value = []
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_will_plan(pm_id, task_id, plan="re-entry plan")
|
||||
|
||||
assert env.error is None
|
||||
assert env.status == "claimed"
|
||||
task_svc.heartbeat.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_will_plan_still_rejects_in_progress_for_other_agent() -> None:
|
||||
"""Idempotency only applies to the caller. Different PM still rejected."""
|
||||
pm_id = uuid4()
|
||||
other_pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
in_progress = MagicMock(
|
||||
id=task_id,
|
||||
status="in_progress",
|
||||
plan={"text": "x"},
|
||||
assigned_to=other_pm_id, # different PM owns it
|
||||
task_type="planning",
|
||||
parent_task_id=None,
|
||||
sequence=0,
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = in_progress
|
||||
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_will_plan(pm_id, task_id, plan="x")
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_will_plan_returns_tracing_gap_without_plan() -> None:
|
||||
pm_id = uuid4()
|
||||
|
||||
Reference in New Issue
Block a user