fix(lifecycle): pr_pass hands ownership to the owning PM — closes the passed-PR completion wedge

Removing the awaiting_pm_review re-claim edge (d87e2d9b, the #740
review-loop fix) exposed that the edge was load-bearing: pr_pass
cleared assigned_to/claimed_by to None and the re-claim was the only
way a PM ever re-acquired the task. Since then every assembled task
passing the PR gate wedged: the closure PM's complete rejected with
'not assigned to you', its fallback claim rejected (edge gone), and
its only exit was escalate_up — BLOCKING the task onto main-pm, who
is not the assignee either and burned spawns doing nothing. Live:
PR #741's task (7 ownership rejections, escalated 13:59Z), plus two
more tasks with 25 and 2 rejections in the same shape.

pr_pass now resolves the owning PM via _revision_pm_for_task and
assigns it, exactly as pr_fail always did — one chokepoint covering
cell (submit_up) and root (submit_root) tasks. mark_pr_created's
ready_for_pm branch (leaf docs-path, PR-arrives-second) had the same
clear-to-None wedge and now resolves via _resolve_pm_for_review like
its docs-first sibling. No claim edge is reintroduced; the #740
parity test is untouched.

Recovery for already-wedged tasks needs no DB surgery: new idempotent
TaskService.assign_review_pm + POST /tasks/{id}/assign-review-pm
(ASSIGN-gated, explicit commit) corrects an unassigned OR mis-assigned
awaiting_pm_review task to its owning PM; _dispatch_pm_review_work
ensures assignment before every spawn (cheap pre-check skips the
round-trip when the fetched assigned_to already matches), replacing
the dead _claim_task_for_agent call whose lifecycle claim the removed
edge now always rejects; _maybe_spawn_pm_closure routes through
_closure_review_pm, which keeps the team-resolved PM whenever the
assign route fails so a transient error can never spawn a stale
assignee.

Gate: 15506 passed, 459 skipped; xenon/ruff/mypy/vulture/bandit/
pip-audit/deptry/import-linter/foundation-check green.
This commit is contained in:
Renn F
2026-07-31 18:48:45 +02:00
parent 19d3c227c8
commit 3efc96e402
8 changed files with 840 additions and 61 deletions
+47
View File
@@ -797,6 +797,53 @@ async def test_unblock_unknown_returns_404(task_client: dict) -> None:
)
@pytest.mark.asyncio
async def test_assign_review_pm_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(f"/api/tasks/{uuid4()}/assign-review-pm", headers=_HDR)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_assign_review_pm_places_owning_pm(task_client: dict) -> None:
"""The orchestrator's recovery seam: a review task with no (or a stale)
owner is placed with the real team-resolved PM — CLAIM_RULES has no
claim() edge into AWAITING_PM_REVIEW for the normal route to do this.
Resolves the expected PM via the real ``main_pm_agent()`` query rather
than assuming this fixture's own agent — the test DB is shared/cumulative
across the module, and "earliest-created" main_pm may be an older row
from an earlier test.
"""
setup = task_client
client = setup["client"]
task = _seed_task(
setup,
status=TaskStatus.AWAITING_PM_REVIEW,
team=Team.MAIN_PM,
assigned_to=None,
)
await setup["db"].commit()
expected_pm = await TaskService(setup["db"]).main_pm_agent()
assert expected_pm is not None
response = await client.post(f"/api/tasks/{task.id}/assign-review-pm", headers=_HDR)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["assigned_to"] == str(expected_pm.id)
@pytest.mark.asyncio
async def test_assign_review_pm_rejects_non_review_status(task_client: dict) -> None:
setup = task_client
client = setup["client"]
task = _seed_task(setup, status=TaskStatus.IN_PROGRESS)
await setup["db"].commit()
response = await client.post(f"/api/tasks/{task.id}/assign-review-pm", headers=_HDR)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_pause_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]