fix(task): restore pre-block owner when an admin override leaves blocked

A developer that hits a wall calls i_am_blocked, which escalates the code task
to its cell PM (assigned_to=PM, BLOCKED) and snapshots the dev as
pre_block_assignee — the intended dev->cell-PM triage handoff. The in-band
recovery (unblock(restore=True)) hands ownership back to the dev. But the
OUT-OF-BAND paths — the operator PATCH /tasks/{id} status override and the
orchestrator's own _auto_recover_blocked_parent / _auto_resume_paused_parent —
go through admin_set_status, which set only status and never restored the owner.
The task re-entered pending/in_progress still owned by the PM, and the dispatcher
then execute-spawned the PM on a code task it cannot do ('break this down and
delegate' against the task itself) -> respawn loop.

admin_set_status now, when taking a task out of 'blocked' into pending/in_progress
with a pre-block snapshot present, routes through the existing
_apply_pre_block_restore primitive (the same one unblock(restore=True) uses) to
hand ownership back to the executor. Every other override is unchanged, and the
escalate/apply_escalation/block-down path is untouched, so the dev->cell-PM
handoff still works. + 2 regression tests.
This commit is contained in:
Renn F
2026-06-16 00:14:01 +02:00
parent 3d8c0e1c54
commit 55ff05e6ec
2 changed files with 62 additions and 0 deletions
+47
View File
@@ -543,6 +543,53 @@ async def test_unblock_no_branch_returns_to_pending() -> None:
assert task.assigned_to == raiser
@pytest.mark.asyncio
async def test_admin_set_status_out_of_blocked_restores_pre_block_owner() -> None:
# A code task a dev escalated to its cell PM (assigned_to=PM, BLOCKED,
# snapshot=dev). Taking it out of blocked via the admin override (operator
# PATCH, or the orchestrator's auto-recover/auto-resume) must hand ownership
# back to the dev — otherwise it re-enters pending/in_progress still owned by
# the PM and the dispatcher execute-spawns the PM on a dev code task (loop).
dev = uuid4()
pm = uuid4()
task = _build_task(
status=TaskStatus.BLOCKED,
assigned_to=pm,
claimed_by=pm,
branch_name="feature/frontend/abc--def--ghi",
pre_block_state="in_progress",
pre_block_assignee=dev,
)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.admin_set_status(task.id, TaskStatus.IN_PROGRESS)
assert out is task
assert task.status == TaskStatus.IN_PROGRESS
assert task.assigned_to == dev
assert task.claimed_by == dev
assert task.pre_block_assignee is None
assert task.pre_block_state is None
@pytest.mark.asyncio
async def test_admin_set_status_non_blocked_is_bare_status_set() -> None:
# The restore branch fires ONLY on blocked -> pending/in_progress with a
# snapshot. Every other override stays a plain status set; the owner is
# untouched (no spurious restore/divert).
owner = uuid4()
task = _build_task(
status=TaskStatus.AWAITING_PM_REVIEW,
assigned_to=owner,
claimed_by=owner,
)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.admin_set_status(task.id, TaskStatus.COMPLETED)
assert out is task
assert task.status == TaskStatus.COMPLETED
assert task.assigned_to == owner
@pytest.mark.asyncio
async def test_unblock_with_branch_resumes_in_progress() -> None:
# A task claimed (has a branch) before it blocked resumes in_progress.