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_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. - **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. - **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. - **triage**: List actionable tasks in your scope.
- **unblock**: PM unblocks a blocked task; restores pre-block state. - **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. - **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 ## 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 **Allowed roles:** cell_pm
+1 -1
View File
@@ -255,7 +255,7 @@
"composes": [ "composes": [
"submit_pm_review" "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", "name": "submit_up",
"pre_side_effects": [ "pre_side_effects": [
"create_pr" "create_pr"
+7 -2
View File
@@ -973,7 +973,10 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
"submit_up": IntentSpec( "submit_up": IntentSpec(
name="submit_up", name="submit_up",
allowed_roles=frozenset({Role.CELL_PM}), 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",), composes=("submit_pm_review",),
extra_preconditions=(), extra_preconditions=(),
# The cell→root PR must exist BEFORE submit_pm_review runs — its # 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. # sees pr_created=True. Mirrors the dev's open_pr→i_am_done split.
pre_side_effects=("create_pr",), pre_side_effects=("create_pr",),
side_effects=(), 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( "unblock": IntentSpec(
name="unblock", name="unblock",
+11 -18
View File
@@ -32,7 +32,7 @@ from roboco.services.gateway.evidence_builder import (
build_context_briefing, build_context_briefing,
build_evidence_for_task, 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 ( from roboco.services.gateway.remediation import (
hint_for_evidence_not_inspected, hint_for_evidence_not_inspected,
hint_for_missing_doc_files, hint_for_missing_doc_files,
@@ -3386,7 +3386,11 @@ class Choreographer:
verb="submit_up", verb="submit_up",
) )
t = outcome 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( return Envelope.ok(
status=str(t.status), status=str(t.status),
task_id=str(task_id), task_id=str(task_id),
@@ -3492,21 +3496,6 @@ class Choreographer:
) )
return None 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: 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. """Return the PM's first assigned task in any active status, or idle.
@@ -3771,7 +3760,11 @@ class Choreographer:
task_id=task_id, task_id=task_id,
verb="cell_pm_complete", 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( merge_result = await self.git.pr_merge(
t.pr_number, target=target, actor_agent_id=pm_agent_id 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 collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from uuid import UUID
from roboco.foundation.policy import lifecycle as spec 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: async def _do_push_branch(self, task: Any, _agent: Any) -> Any:
return await self.git_service.push_branch(task.branch_name) 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: 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( return await self.git_service.create_pr(
task.branch_name, parent=parent, is_root_pr=False task.branch_name, parent=parent, is_root_pr=False
) )
async def _do_pr_merge(self, task: Any, agent: Any) -> Any: 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( return await self.git_service.pr_merge(
task.pr_number, target=target, actor_agent_id=agent.id task.pr_number, target=target, actor_agent_id=agent.id
) )
+30 -1
View File
@@ -12,6 +12,8 @@ Merge chain:
from __future__ import annotations from __future__ import annotations
import re import re
from typing import Any
from uuid import UUID
_TYPES = ("feature", "bug", "chore", "docs", "hotfix") _TYPES = ("feature", "bug", "chore", "docs", "hotfix")
_TYPE_PATTERN = "|".join(_TYPES) _TYPE_PATTERN = "|".join(_TYPES)
@@ -33,7 +35,16 @@ def branch_depth(branch: str) -> int:
def parent_branch_for(branch: str) -> str: 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": if branch == "master":
return "master" return "master"
m = _BRANCH_RE.match(branch) m = _BRANCH_RE.match(branch)
@@ -46,3 +57,21 @@ def parent_branch_for(branch: str) -> str:
return "master" return "master"
parent_segments = "--".join(segments[:-1]) parent_segments = "--".join(segments[:-1])
return f"{type_}/{team}/{parent_segments}" 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"] 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) # _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: async def test_cell_pm_complete_merges_then_completes() -> None:
pm_id = uuid4() pm_id = uuid4()
task_id = uuid4() task_id = uuid4()
parent_id = uuid4()
t = MagicMock( t = MagicMock(
id=task_id, id=task_id,
status="awaiting_pm_review", status="awaiting_pm_review",
assigned_to=pm_id, assigned_to=pm_id,
pr_number=8, pr_number=8,
branch_name="feature/backend/abc--def", branch_name="feature/backend/abc--def",
parent_task_id=uuid4(), parent_task_id=parent_id,
team="backend", team="backend",
) )
after = MagicMock(**{**t.__dict__, "status": "completed"}) 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 = 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.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = after task_svc.cell_pm_complete.return_value = after
git_svc = AsyncMock() git_svc = AsyncMock()
@@ -278,7 +285,7 @@ async def test_cell_pm_complete_merges_then_completes() -> None:
assert env.error is None assert env.error is None
assert env.status == "completed" assert env.status == "completed"
git_svc.pr_merge.assert_awaited_once_with( 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 @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() pm_id = uuid4()
task_id = uuid4() task_id = uuid4()
main_pm_id = uuid4()
t = MagicMock( t = MagicMock(
id=task_id, id=task_id,
status="in_progress", 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.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.all_subtasks_terminal.return_value = True task_svc.all_subtasks_terminal.return_value = True
task_svc.submit_pm_review.return_value = after 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 = AsyncMock()
git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"} git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"}
journal_svc = AsyncMock() 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.error is None
assert env.status == "awaiting_pm_review" assert env.status == "awaiting_pm_review"
git_svc.create_pr.assert_awaited_once() git_svc.create_pr.assert_awaited_once()
task_svc.reassign.assert_awaited_once() task_svc.reassign.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -438,12 +438,15 @@ async def test_cell_pm_complete_reassigns_parent_when_all_subtasks_done() -> Non
id=parent_id, id=parent_id,
team="backend", team="backend",
parent_task_id=None, parent_task_id=None,
branch_name="feature/backend/abc",
) )
task_svc = AsyncMock() task_svc = AsyncMock()
# First .get is for the leaf (status check), then for the parent walk-up. # get(leaf) for the status check, get(parent) for the merge-target
task_svc.get.side_effect = [leaf, parent] # resolution (#181/#182) and the parent walk-up. Keyed by id so it is
task_svc.all_subtasks_terminal.side_effect = [True, True] # 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_complete.return_value = after
task_svc.cell_pm_for_team.return_value = MagicMock(id=new_pm_id) 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 __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest 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_ROOT = 1
_DEPTH_ONE_SUBTASK = 2 _DEPTH_ONE_SUBTASK = 2
@@ -44,6 +51,47 @@ class TestParentBranchFor:
parent_branch_for("not-a-branch") 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: def test_branch_depth_master_is_zero() -> None:
"""Line 28: master returns 0.""" """Line 28: master returns 0."""
assert branch_depth("master") == 0 assert branch_depth("master") == 0