fix(gateway): four PM-lifecycle smoke regressions

* choreographer.escalate_up: AttributeError when target lookup returned
  None. Switched from role-based escalate_up_to_role (which mishandled
  slug-shaped escalation_target like "main-pm" because AgentRole only
  accepts underscore form) to slug-based task.escalate, with explicit
  None-handling that returns invalid_state instead of 500.

* prompts/roles/cell_pm.md + main_pm.md: enumerate the new lifecycle
  verbs (i_will_plan, delegate, submit_up, give_me_work, i_am_idle).
  Without this, PM agents fell back to calling i_will_work_on (the dev
  verb) and 404'd at /api/v2/flow/cell_pm/i_will_work_on. Workflow
  walkthroughs included.

* messaging.get_channel_by_slug + get_or_create_channel_by_slug: strip
  leading "#" so "#main-pm-board" resolves to the row stored as
  "main-pm-board". Agents follow Slack convention; gateway must accept
  it.

* agent_sdk session-end post-mortem hook: corrected payload shape from
  {content, kind:"reflect"} to {type:"task_reflection", title, content}
  so /api/journals/me/entries validates. Added pad-to-min-length so the
  50-char content gate doesn't reject thin post-mortems.

* test_choreographer_pm: updated escalate_up test to assert task.escalate
  is awaited, plus regression test for the None-target invalid_state path.

380 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Renn F
2026-05-03 00:54:05 +02:00
co-authored by Claude Opus 4.7
parent 4520293def
commit 27dccc7215
6 changed files with 116 additions and 45 deletions
+27 -4
View File
@@ -551,9 +551,9 @@ async def test_escalate_up_routes_by_escalation_target() -> None:
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
role="cell_pm",
escalation_target="main_pm",
escalation_target="main-pm",
)
task_svc.escalate_up_to_role.return_value = after
task_svc.escalate.return_value = after
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
@@ -561,14 +561,37 @@ async def test_escalate_up_routes_by_escalation_target() -> None:
env = await c.escalate_up(pm_id, task_id, reason="cross-cell coordination needed")
assert env.error is None
task_svc.escalate_up_to_role.assert_awaited_once_with(
task_svc.escalate.assert_awaited_once_with(
pm_id,
task_id,
"main_pm",
"cross-cell coordination needed",
)
@pytest.mark.asyncio
async def test_escalate_up_returns_invalid_state_when_target_lookup_fails() -> None:
"""Regression: escalate_up_to_role returning None used to crash on t.status."""
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="blocked", assigned_to=pm_id, team="backend")
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
role="cell_pm",
escalation_target="main-pm",
)
task_svc.escalate.return_value = None # target slug not found in DB
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="x")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "main-pm" in body["message"]
@pytest.mark.asyncio
async def test_escalate_up_blocks_without_journal_decision() -> None:
pm_id = uuid4()