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
@@ -495,6 +495,47 @@ async def test_cell_pm_complete_not_assigned_returns_not_authorized() -> None:
assert env.as_dict()["error"] == "not_authorized"
@pytest.mark.asyncio
async def test_cell_pm_complete_passes_ownership_guard_after_pr_pass_handoff() -> None:
"""#740 (d87e2d9b) removed the illegal awaiting_pm_review -> claimed
re-claim edge, which used to be the PM's only way back to ownership
after the PR gate — pr_pass cleared assigned_to to None, so the owning
PM's complete() dead-ended on this exact guard forever
(not_authorized). pr_pass now hands off to the resolved owning PM
instead (see TaskService.pr_pass), so a task in the real post-gate
shape — assigned_to == the calling PM, no subtasks — must clear this
guard rather than bounce."""
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=pm_id,
pr_number=8,
branch_name="feature/backend/abc--def",
parent_task_id=None,
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "completed"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = after
git_svc = AsyncMock()
git_svc.is_pr_merged_for_task.return_value = False
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "merge-abc"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(pm_id, task_id, notes="reviewed and approved")
assert env.as_dict().get("error") != "not_authorized"
assert env.error is None
@pytest.mark.asyncio
async def test_cell_pm_complete_in_progress_steers_to_submit_up() -> None:
"""Mirror of the main-PM submit_root steer: a cell task still in_progress