From 2e0792d00d3225552fc8b210bf18853956a9f66a Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 28 Jun 2026 17:16:21 +0200 Subject: [PATCH] [F067] flow_main_pm: add missing /triage route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main_pm's manifest advertises triage (lifecycle.intents_for_role(MAIN_PM) includes it via _PM_ROLES, alongside triage_all) but flow_main_pm.py had no POST /triage route, so a main_pm agent calling triage hit a raw 404 that bypassed the per-verb circuit breaker. Added the route mirroring flow_cell_pm's /triage — wires to the existing team-scoped choreographer.triage (uses pm.team, works for any PM role; Main PM gets its own team's blocked/awaiting tasks). Fix direction: add-route, NOT remove-from-manifest — the manifest is spec-correct (intents_for_role by construction); removing triage would contradict the spec and leave main_pm with only cross-team triage_all. TDD: test_triage_route_exists_and_dispatches. --- roboco/api/routes/v1/flow_main_pm.py | 20 ++++++++++++++++ tests/unit/api/routes/v1/test_flow_main_pm.py | 23 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/roboco/api/routes/v1/flow_main_pm.py b/roboco/api/routes/v1/flow_main_pm.py index 679f6952..dfc61c5f 100644 --- a/roboco/api/routes/v1/flow_main_pm.py +++ b/roboco/api/routes/v1/flow_main_pm.py @@ -105,6 +105,26 @@ async def triage_all( return envelope_to_response(env, request) +@router.post("/triage") +async def triage( + request: Request, + _body: TriageRequest, + x_agent_id: _AgentIdHeader, + choreographer: _ChoreographerDep, +) -> dict: + """Team-scoped triage for the Main PM (own-team blocked / awaiting_pm_review). + + F067: the main_pm manifest advertises ``triage`` (lifecycle.intents_for_role + includes it for MAIN_PM via _PM_ROLES) alongside ``triage_all``; without a + route a main_pm agent calling `triage` hit a raw 404 that bypassed the + per-verb circuit breaker. ``choreographer.triage`` is team-scoped (uses + ``pm.team``) and works for any PM role — Main PM gets its own team's + blocked/awaiting tasks; ``triage_all`` is the cross-team sweep. + """ + env = await choreographer.triage(x_agent_id) + return envelope_to_response(env, request) + + @router.post("/submit_root") async def submit_root( request: Request, diff --git a/tests/unit/api/routes/v1/test_flow_main_pm.py b/tests/unit/api/routes/v1/test_flow_main_pm.py index 4146541f..f6beef65 100644 --- a/tests/unit/api/routes/v1/test_flow_main_pm.py +++ b/tests/unit/api/routes/v1/test_flow_main_pm.py @@ -362,3 +362,26 @@ async def test_resume_dispatches() -> None: ) assert resp.status_code == _HTTP_200 mock_chore.resume.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_triage_route_exists_and_dispatches() -> None: + """F067: POST /api/v1/flow/main_pm/triage must exist and wire to + choreographer.triage. The main_pm manifest (from lifecycle.intents_for_role) + advertises `triage` alongside `triage_all`, so a main_pm agent calling + `triage` must hit a real route — not a raw 404 that bypasses the circuit + breaker. Mirrors flow_cell_pm's /triage route (the choreographer.triage + impl is team-scoped and works for any PM role). + """ + mock_chore = MagicMock() + mock_chore.triage = AsyncMock(return_value=_make_envelope(status="idle")) + client = TestClient(_build_app(mock_chore)) + resp = client.post( + "/api/v1/flow/main_pm/triage", + json={}, + headers=_HEADERS, + ) + assert resp.status_code == _HTTP_200, resp.text + body = resp.json() + assert body["status"] == "idle" + mock_chore.triage.assert_awaited_once()