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
+13 -1
View File
@@ -899,10 +899,22 @@ async def test_pr_review_gate_pass_path(
assert reviewer_row.status == AgentStatus.ACTIVE
assert reviewer_row.current_task_id == task.id
# Resolved via the same team-based query pr_pass itself uses (rather than
# assumed to be this fixture's own cell_pm_agent) — the shared/cumulative
# integration DB may carry other BACKEND/CELL_PM agents from earlier
# tests, and _agent_with_role_and_team has no ordering guarantee.
expected_pm = await svc.cell_pm_for_team(Team.BACKEND)
assert expected_pm is not None
passed = await svc.pr_pass(reviewer_id, task.id, notes="integration verified")
assert passed is not None
assert str(passed.status) == Status.AWAITING_PM_REVIEW.value
assert passed.assigned_to is None # cleared so the PM-closure dispatch routes
# Hands off to the owning cell PM (team-resolved) rather than clearing —
# AWAITING_PM_REVIEW has no claim() edge, so an unassigned task here has
# no way back to a PM.
assert passed.assigned_to == expected_pm.id
assert passed.claimed_by == expected_pm.id
assert passed.active_claimant_id is None
# pr_pass releases the reviewer's fleet marker too.
reviewer_row = await db_session.get(AgentTable, reviewer_id)
assert reviewer_row is not None
+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"]