fix(lifecycle): unblock a never-claimed task to pending, not branchless in_progress

A task blocked before it was ever claimed (a dependency-gated claim that got
escalated) has no branch. Legacy unblock() forced in_progress, which the
dispatcher refuses (state=in_progress but branch_name unset) -> a spawn-refused
loop. Add the blocked->pending transition and route no-branch tasks there so
they are freshly claimed (the claim gate then holds them cleanly while the
dependency is unmet). Branched tasks still resume in_progress. Artifacts
regenerated.
This commit is contained in:
Renn F
2026-06-04 02:01:09 +02:00
parent 6d1b56b5b0
commit 2631f2647c
5 changed files with 53 additions and 1 deletions
+1
View File
@@ -19,6 +19,7 @@
| backlog | pending | activate | any |
| blocked | cancelled | cancel | cell_pm, ceo, main_pm |
| blocked | in_progress | unblock | any |
| blocked | pending | unblock | any |
| claimed | cancelled | cancel | cell_pm, ceo, main_pm |
| claimed | in_progress | start | any |
| in_progress | awaiting_pm_review | submit_pm_review | any |
+6
View File
@@ -462,6 +462,12 @@
"source": "blocked",
"target": "in_progress"
},
{
"action": "unblock",
"roles": null,
"source": "blocked",
"target": "pending"
},
{
"action": "cancel",
"roles": [
+4
View File
@@ -244,6 +244,10 @@ _STATUS_TRANSITIONS: tuple[StatusTransition, ...] = (
StatusTransition(Status.IN_PROGRESS, Status.BLOCKED, "block", None),
StatusTransition(Status.IN_PROGRESS, Status.PAUSED, "pause", None),
StatusTransition(Status.BLOCKED, Status.IN_PROGRESS, "unblock", None),
# A task blocked before it was ever claimed (a dependency-gated claim that
# got escalated) has no branch — unblocking it returns it to the claim pool
# rather than a branchless in_progress the dispatcher refuses to spawn.
StatusTransition(Status.BLOCKED, Status.PENDING, "unblock", None),
StatusTransition(Status.PAUSED, Status.IN_PROGRESS, "resume", None),
# Dev verify + submit
StatusTransition(Status.IN_PROGRESS, Status.VERIFYING, "submit_verification", None),
+8 -1
View File
@@ -2349,7 +2349,14 @@ class TaskService(BaseService):
task.blocker_raised_by = None
# Clear resolver metadata — only meaningful while BLOCKED.
task.blocker_resolver_type = None
self._validate_and_set_status(task, TaskStatus.IN_PROGRESS, agent_role)
# A task with a branch was claimed before it blocked, so resume it
# in_progress. A task with NO branch was blocked before it was ever
# claimed (e.g. a dependency-gated claim that got escalated); it cannot
# resume in_progress (the dispatcher refuses a branchless in_progress
# task and loops), so return it to pending to be freshly claimed — the
# claim gate then holds it cleanly if its dependency is still unmet.
target = TaskStatus.IN_PROGRESS if task.branch_name else TaskStatus.PENDING
self._validate_and_set_status(task, target, agent_role)
await self.session.flush()
self.log.info(
+34
View File
@@ -523,6 +523,40 @@ async def test_unblock_with_restore_calls_legacy_unblock_when_restore_false() ->
unblock_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_unblock_no_branch_returns_to_pending() -> None:
# A task blocked before it was ever claimed (a dependency-gated claim that
# got escalated) has no branch — unblock must return it to pending, not a
# branchless in_progress the dispatcher refuses to spawn (spawn-loop).
raiser = uuid4()
task = _build_task(
status=TaskStatus.BLOCKED, branch_name=None, blocker_raised_by=raiser
)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_index_lifecycle_event_background", AsyncMock())
out = await svc.unblock(task.id)
assert out is task
assert task.status == TaskStatus.PENDING
assert task.assigned_to == raiser
@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.
task = _build_task(
status=TaskStatus.BLOCKED,
branch_name="feature/backend/abc12345",
blocker_raised_by=uuid4(),
)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_index_lifecycle_event_background", AsyncMock())
out = await svc.unblock(task.id)
assert out is task
assert task.status == TaskStatus.IN_PROGRESS
# ---------------------------------------------------------------------------
# cell_pm_complete
# ---------------------------------------------------------------------------