fix(gateway): cell PM completes its own cell task; drop main-PM handoff

submit_up bubbled the cell task to Main PM (_handoff_to_main_pm), but
main_pm_complete rejects any task with a parent_task_id ("only operates
on root tasks"), so the cell->root PR had no one to merge it and the
cell task wedged at awaiting_pm_review. _maybe_advance_parent_to_pm_review
already intends the CELL PM to complete it.

Cell PM now owns cell completion:
- submit_up no longer hands off to Main PM; the cell task stays assigned
  to the cell PM, which is respawned to complete() it. Removed the
  now-unused _handoff_to_main_pm.
- cell_pm_complete resolves the merge target from the parent task's real
  branch_name (shared merge_chain.resolve_parent_branch, also used by the
  PR side-effects) so the cell->root PR merges into feature/main_pm/...,
  not the team-mis-derived feature/<cellteam>/... (same root cause as the
  prior PR-base fix).
- submit_up description + next_hint updated; lifecycle artifacts regen.

Main PM still only completes the ROOT (root->master + escalate-to-CEO).
First run to reach cell-PM bubble-up exposed this.
This commit is contained in:
Renn F
2026-05-23 05:16:56 +02:00
parent 32b6b31dd6
commit e3def6b3a2
12 changed files with 128 additions and 81 deletions
@@ -10,7 +10,7 @@ other verb will be rejected with a Decision telling you the right one.
- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks.
- **i_will_plan**: PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.
- **resume**: Resume a paused task you own. paused -> in_progress.
- **submit_up**: Cell PM bubbles a finished cell-scope task up to Main PM.
- **submit_up**: Cell PM opens the cell→root PR and moves the cell task to awaiting_pm_review. The same Cell PM then completes it.
- **triage**: List actionable tasks in your scope.
- **unblock**: PM unblocks a blocked task; restores pre-block state.
- **unclaim**: Voluntarily release a claim back to pending. The work-in-progress branch is preserved.
+1 -1
View File
@@ -167,7 +167,7 @@ Resume a paused task you own. paused -> in_progress.
## submit_up
Cell PM bubbles a finished cell-scope task up to Main PM.
Cell PM opens the cell→root PR and moves the cell task to awaiting_pm_review. The same Cell PM then completes it.
**Allowed roles:** cell_pm
+1 -1
View File
@@ -255,7 +255,7 @@
"composes": [
"submit_pm_review"
],
"description": "Cell PM bubbles a finished cell-scope task up to Main PM.",
"description": "Cell PM opens the cell\u2192root PR and moves the cell task to awaiting_pm_review. The same Cell PM then completes it.",
"name": "submit_up",
"pre_side_effects": [
"create_pr"
+7 -2
View File
@@ -973,7 +973,10 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
"submit_up": IntentSpec(
name="submit_up",
allowed_roles=frozenset({Role.CELL_PM}),
description="Cell PM bubbles a finished cell-scope task up to Main PM.",
description=(
"Cell PM opens the cell→root PR and moves the cell task to"
" awaiting_pm_review. The same Cell PM then completes it."
),
composes=("submit_pm_review",),
extra_preconditions=(),
# The cell→root PR must exist BEFORE submit_pm_review runs — its
@@ -983,7 +986,9 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
# sees pr_created=True. Mirrors the dev's open_pr→i_am_done split.
pre_side_effects=("create_pr",),
side_effects=(),
next_hint=lambda _t: "idle until Main PM reviews",
# #182: the Cell PM owns cell completion — it merges the cell→root PR
# via complete(). Main PM only completes the ROOT task.
next_hint=lambda _t: "complete(task_id) to merge the cell→root PR",
),
"unblock": IntentSpec(
name="unblock",
+11 -18
View File
@@ -32,7 +32,7 @@ from roboco.services.gateway.evidence_builder import (
build_context_briefing,
build_evidence_for_task,
)
from roboco.services.gateway.merge_chain import parent_branch_for
from roboco.services.gateway.merge_chain import resolve_parent_branch
from roboco.services.gateway.remediation import (
hint_for_evidence_not_inspected,
hint_for_missing_doc_files,
@@ -3386,7 +3386,11 @@ class Choreographer:
verb="submit_up",
)
t = outcome
await self._handoff_to_main_pm(pm_agent_id, task_id)
# #182: do NOT hand the cell task to Main PM. The cell PM owns cell
# completion — it stays assigned to the cell PM, which is respawned to
# `complete` the task (merging the cell→root PR). Main PM only
# completes the ROOT (root→master + escalate-to-CEO).
# `_maybe_advance_parent_to_pm_review` already keeps it on the cell PM.
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
@@ -3492,21 +3496,6 @@ class Choreographer:
)
return None
async def _handoff_to_main_pm(self, pm_agent_id: UUID, task_id: UUID) -> None:
"""Reassign the task to the Main PM and A2A-notify the handoff."""
main_pm = await self.task.main_pm_agent()
if main_pm is None:
return
main_pm_uuid = UUID(str(main_pm.id))
await self.task.reassign(task_id, main_pm_uuid)
await self.a2a.send(
from_agent=pm_agent_id,
to_agent=main_pm_uuid,
skill="task_management",
task_id=task_id,
body=f"Cell scope complete for {task_id}. Ready for Main PM review.",
)
async def pm_give_me_work(self, pm_agent_id: UUID) -> Envelope:
"""Return the PM's first assigned task in any active status, or idle.
@@ -3771,7 +3760,11 @@ class Choreographer:
task_id=task_id,
verb="cell_pm_complete",
)
target = parent_branch_for(t.branch_name)
# #181/#182: resolve the merge target from the PARENT task's real
# branch_name. For a leaf this is the cell branch (same team — no
# change); for a cell task it is the root branch (feature/main_pm/…),
# which parent_branch_for would have mis-derived as feature/<cellteam>/…
target = await resolve_parent_branch(t, self.task)
merge_result = await self.git.pr_merge(
t.pr_number, target=target, actor_agent_id=pm_agent_id
)
@@ -20,7 +20,6 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from roboco.foundation.policy import lifecycle as spec
@@ -178,38 +177,18 @@ class VerbRunner:
async def _do_push_branch(self, task: Any, _agent: Any) -> Any:
return await self.git_service.push_branch(task.branch_name)
async def _parent_branch_for(self, task: Any) -> str:
"""Base/target branch for a child→parent PR: the parent task's own
branch_name.
#181: ``merge_chain.parent_branch_for`` derives the parent branch by
dropping the last ``--`` segment but REUSING the child's team segment.
That only holds within one team. Across a team boundary every
cellroot PR, where the cell is ``feature/backend/`` but the root is
``feature/main_pm/`` it yields a branch name that doesn't exist on
the remote, and GitHub rejects the PR with ``base: invalid``. The
parent task's stored ``branch_name`` is authoritative: branch creation
already cuts and pushes each child from it
(``TaskService._resolve_parent_branch``). Fall back to string
derivation only when there is no parent or it has no branch yet.
"""
from roboco.services.gateway.merge_chain import parent_branch_for
parent_id = getattr(task, "parent_task_id", None)
if parent_id is not None:
parent = await self.task_service.get(UUID(str(parent_id)))
if parent is not None and parent.branch_name:
return str(parent.branch_name)
return parent_branch_for(task.branch_name)
async def _do_create_pr(self, task: Any, _agent: Any) -> Any:
parent = await self._parent_branch_for(task)
from roboco.services.gateway.merge_chain import resolve_parent_branch
parent = await resolve_parent_branch(task, self.task_service)
return await self.git_service.create_pr(
task.branch_name, parent=parent, is_root_pr=False
)
async def _do_pr_merge(self, task: Any, agent: Any) -> Any:
target = await self._parent_branch_for(task)
from roboco.services.gateway.merge_chain import resolve_parent_branch
target = await resolve_parent_branch(task, self.task_service)
return await self.git_service.pr_merge(
task.pr_number, target=target, actor_agent_id=agent.id
)
+30 -1
View File
@@ -12,6 +12,8 @@ Merge chain:
from __future__ import annotations
import re
from typing import Any
from uuid import UUID
_TYPES = ("feature", "bug", "chore", "docs", "hotfix")
_TYPE_PATTERN = "|".join(_TYPES)
@@ -33,7 +35,16 @@ def branch_depth(branch: str) -> int:
def parent_branch_for(branch: str) -> str:
"""Return the merge target for `branch`."""
"""Return the merge target for `branch` by string surgery.
NOTE: this REUSES ``branch``'s own team segment for the parent, so it is
only correct within a single team. Across a team boundary every
cellroot hop, where the cell is ``feature/backend/`` but the root is
``feature/main_pm/`` it yields a non-existent ref. For PR base/target
resolution prefer :func:`resolve_parent_branch`, which reads the parent
task's real branch_name. This stays as the fallback for the
rootless / same-team / diff-base cases.
"""
if branch == "master":
return "master"
m = _BRANCH_RE.match(branch)
@@ -46,3 +57,21 @@ def parent_branch_for(branch: str) -> str:
return "master"
parent_segments = "--".join(segments[:-1])
return f"{type_}/{team}/{parent_segments}"
async def resolve_parent_branch(task: Any, task_service: Any) -> str:
"""Base/target branch for a child→parent PR: the parent task's own
branch_name.
The parent task's stored ``branch_name`` is authoritative — branch
creation already cuts and pushes each child from it
(``TaskService._resolve_parent_branch``). Unlike :func:`parent_branch_for`
it is correct across a team boundary (#181). Falls back to string
derivation only when there is no parent or the parent has no branch yet.
"""
parent_id = getattr(task, "parent_task_id", None)
if parent_id is not None:
parent = await task_service.get(UUID(str(parent_id)))
if parent is not None and parent.branch_name:
return str(parent.branch_name)
return parent_branch_for(task.branch_name)
@@ -704,25 +704,6 @@ async def test_submit_up_no_branch_rejected() -> None:
assert "no branch" in body["message"]
# ---------------------------------------------------------------------------
# _handoff_to_main_pm: main_pm_agent returns None (line 1567)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_handoff_to_main_pm_no_main_pm_returns_silently() -> None:
pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.main_pm_agent.return_value = None # No main PM in DB
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
# Should silently return without error.
await c._handoff_to_main_pm(pm_id, task_id)
# reassign + a2a never called.
task_svc.reassign.assert_not_called()
# ---------------------------------------------------------------------------
# _pm_next_hint: each branch (lines 1612-1616)
# ---------------------------------------------------------------------------
+10 -3
View File
@@ -251,18 +251,25 @@ async def test_unblock_restore_false_returns_legacy_message() -> None:
async def test_cell_pm_complete_merges_then_completes() -> None:
pm_id = uuid4()
task_id = uuid4()
parent_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=uuid4(),
parent_task_id=parent_id,
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "completed"})
# #181/#182: the merge target is the PARENT task's real branch_name —
# here under a DIFFERENT team prefix, which the old parent_branch_for
# would have mis-derived as feature/backend/abc.
parent = MagicMock(
id=parent_id, branch_name="feature/main_pm/abc", parent_task_id=None
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else t
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = after
git_svc = AsyncMock()
@@ -278,7 +285,7 @@ async def test_cell_pm_complete_merges_then_completes() -> None:
assert env.error is None
assert env.status == "completed"
git_svc.pr_merge.assert_awaited_once_with(
8, target="feature/backend/abc", actor_agent_id=pm_id
8, target="feature/main_pm/abc", actor_agent_id=pm_id
)
@@ -882,10 +882,13 @@ async def test_delegate_invalid_team_enum_rejected() -> None:
@pytest.mark.asyncio
async def test_submit_up_opens_pr_and_reassigns_to_main_pm() -> None:
async def test_submit_up_opens_pr_and_keeps_cell_pm_assignment() -> None:
"""#182: submit_up opens the cell→root PR and moves the cell task to
awaiting_pm_review, but does NOT hand it off to Main PM. The cell PM
owns cell completion (it is respawned to `complete` the task), so the
assignment stays put no reassign."""
pm_id = uuid4()
task_id = uuid4()
main_pm_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
@@ -907,7 +910,6 @@ async def test_submit_up_opens_pr_and_reassigns_to_main_pm() -> None:
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.all_subtasks_terminal.return_value = True
task_svc.submit_pm_review.return_value = after
task_svc.main_pm_agent.return_value = MagicMock(id=main_pm_id)
git_svc = AsyncMock()
git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"}
journal_svc = AsyncMock()
@@ -922,7 +924,7 @@ async def test_submit_up_opens_pr_and_reassigns_to_main_pm() -> None:
assert env.error is None
assert env.status == "awaiting_pm_review"
git_svc.create_pr.assert_awaited_once()
task_svc.reassign.assert_awaited_once()
task_svc.reassign.assert_not_called()
@pytest.mark.asyncio
@@ -438,12 +438,15 @@ async def test_cell_pm_complete_reassigns_parent_when_all_subtasks_done() -> Non
id=parent_id,
team="backend",
parent_task_id=None,
branch_name="feature/backend/abc",
)
task_svc = AsyncMock()
# First .get is for the leaf (status check), then for the parent walk-up.
task_svc.get.side_effect = [leaf, parent]
task_svc.all_subtasks_terminal.side_effect = [True, True]
# get(leaf) for the status check, get(parent) for the merge-target
# resolution (#181/#182) and the parent walk-up. Keyed by id so it is
# robust to call count/order.
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else leaf
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = after
task_svc.cell_pm_for_team.return_value = MagicMock(id=new_pm_id)
+49 -1
View File
@@ -2,8 +2,15 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.merge_chain import branch_depth, parent_branch_for
from roboco.services.gateway.merge_chain import (
branch_depth,
parent_branch_for,
resolve_parent_branch,
)
_DEPTH_ROOT = 1
_DEPTH_ONE_SUBTASK = 2
@@ -44,6 +51,47 @@ class TestParentBranchFor:
parent_branch_for("not-a-branch")
class TestResolveParentBranch:
"""#181/#182: base/target comes from the parent TASK's branch_name, which
is correct across a team boundary; parent_branch_for is the fallback."""
@pytest.mark.asyncio
async def test_uses_parent_task_branch_across_team(self) -> None:
root_id = uuid4()
task = MagicMock(
parent_task_id=root_id,
branch_name="feature/backend/ROOT0001--CELL0001",
)
task_service = AsyncMock()
# Root lives under a DIFFERENT team prefix than the cell.
task_service.get = AsyncMock(
return_value=MagicMock(branch_name="feature/main_pm/ROOT0001")
)
result = await resolve_parent_branch(task, task_service)
assert result == "feature/main_pm/ROOT0001"
task_service.get.assert_awaited_once_with(root_id)
@pytest.mark.asyncio
async def test_falls_back_when_no_parent(self) -> None:
task = MagicMock(parent_task_id=None, branch_name="feature/backend/ROOT0001")
task_service = AsyncMock()
# No parent → root→master.
assert await resolve_parent_branch(task, task_service) == "master"
task_service.get.assert_not_called()
@pytest.mark.asyncio
async def test_falls_back_when_parent_has_no_branch(self) -> None:
task = MagicMock(
parent_task_id=uuid4(),
branch_name="feature/backend/ROOT0001--CELL0001",
)
task_service = AsyncMock()
task_service.get = AsyncMock(return_value=MagicMock(branch_name=None))
# Parent exists but has no branch yet → string derivation.
result = await resolve_parent_branch(task, task_service)
assert result == "feature/backend/ROOT0001"
def test_branch_depth_master_is_zero() -> None:
"""Line 28: master returns 0."""
assert branch_depth("master") == 0