mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(gateway): unclaim releases a pending-assigned task — escape trap (#176)
An agent assigned a `pending` task it never claimed was structurally
trapped: from pending-assigned, unclaim returned None ("cannot unclaim
from status pending"), i_am_idle rejected ("assigned but never claimed"),
i_am_blocked rejected ("block requires in_progress"). Any persistent
claim-time rejection (a gate the agent cannot satisfy, a transient
validation error) therefore looped the agent until budget-reap AND left
the task orphaned (pending, assigned, no progress). Observed in smoke-16
and smoke-17.
unclaim_for_agent now releases a pending task assigned to the caller:
no status change (already pending → no lifecycle transition, no
WorkSession to abandon since it was never claimed), just clear
assigned_to/active_claimant_id so the dispatcher can reassign. The
choreographer spec gate already permits unclaim from pending (composes=()
— role-only), so the service branch is the whole fix. Updated the now-
stale unclaim remediate string; rewrote the test that encoded the buggy
trap and added a paused-status negative case.
This commit is contained in:
@@ -2222,7 +2222,10 @@ class Choreographer:
|
|||||||
return await self._emit_rejection(
|
return await self._emit_rejection(
|
||||||
Envelope.invalid_state(
|
Envelope.invalid_state(
|
||||||
message=f"cannot unclaim from status {t.status}",
|
message=f"cannot unclaim from status {t.status}",
|
||||||
remediate="only claimed/in_progress tasks can be unclaimed",
|
remediate=(
|
||||||
|
"only a task assigned to you in pending / claimed / "
|
||||||
|
"in_progress can be unclaimed"
|
||||||
|
),
|
||||||
context_briefing=briefing,
|
context_briefing=briefing,
|
||||||
).with_introspection(task=t, role=role_str),
|
).with_introspection(task=t, role=role_str),
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
|
|||||||
@@ -1997,6 +1997,20 @@ class TaskService(BaseService):
|
|||||||
task = await self.get(task_id)
|
task = await self.get(task_id)
|
||||||
if task is None or task.assigned_to != agent_id:
|
if task is None or task.assigned_to != agent_id:
|
||||||
return None
|
return None
|
||||||
|
# #176: an agent assigned a `pending` task it never claimed (any
|
||||||
|
# persistent claim-time rejection — e.g. a gate the agent cannot
|
||||||
|
# satisfy) is otherwise trapped: unclaim/i_am_idle/i_am_blocked all
|
||||||
|
# reject from pending-assigned, so it loops until budget-reap and
|
||||||
|
# the task is left orphaned (pending, assigned, no progress).
|
||||||
|
# Releasing the assignment is a no-status-change escape (the row is
|
||||||
|
# already pending; no transition, so no lifecycle validation and no
|
||||||
|
# WorkSession to abandon — it was never claimed). The task returns
|
||||||
|
# to the pool for the dispatcher to reassign.
|
||||||
|
if task.status == TaskStatus.PENDING:
|
||||||
|
task.assigned_to = cast("Any", None)
|
||||||
|
task.active_claimant_id = cast("Any", None)
|
||||||
|
await self.session.flush()
|
||||||
|
return task
|
||||||
if task.status not in (TaskStatus.CLAIMED, TaskStatus.IN_PROGRESS):
|
if task.status not in (TaskStatus.CLAIMED, TaskStatus.IN_PROGRESS):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -247,14 +247,36 @@ async def test_unclaim_for_agent_returns_none_when_wrong_assignee(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unclaim_for_agent_returns_none_when_not_claimed(
|
async def test_unclaim_for_agent_releases_pending_assignment(
|
||||||
task_setup: dict, db_session: AsyncSession
|
task_setup: dict, db_session: AsyncSession
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""#176: an agent assigned a pending task it never claimed must be
|
||||||
|
able to unclaim it (escape the pending-assigned trap). The row stays
|
||||||
|
pending; only the assignment is released so the dispatcher can
|
||||||
|
reassign it instead of orphaning the task + looping the agent."""
|
||||||
svc = task_setup["svc"]
|
svc = task_setup["svc"]
|
||||||
task = await svc.create(_req(task_setup))
|
task = await svc.create(_req(task_setup))
|
||||||
task.assigned_to = task_setup["agent_id"]
|
task.assigned_to = task_setup["agent_id"]
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
# status is PENDING, not CLAIMED/IN_PROGRESS
|
# status is PENDING, never claimed — the smoke-17 trap scenario.
|
||||||
|
out = await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"])
|
||||||
|
assert out is not None
|
||||||
|
assert out.status == TaskStatus.PENDING
|
||||||
|
assert out.assigned_to is None
|
||||||
|
assert out.active_claimant_id is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unclaim_for_agent_returns_none_when_paused(
|
||||||
|
task_setup: dict, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""A non-pending/claimed/in_progress status (e.g. paused — which has
|
||||||
|
`resume`, not `unclaim`) is still not unclaimable."""
|
||||||
|
svc = task_setup["svc"]
|
||||||
|
task = await svc.create(_req(task_setup))
|
||||||
|
task.status = TaskStatus.PAUSED
|
||||||
|
task.assigned_to = task_setup["agent_id"]
|
||||||
|
await db_session.flush()
|
||||||
assert await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
|
assert await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user