Files
roboco/tests/integration/v2/test_full_pending_to_completed.py
T
Renn F 4829f93a68 fix(gateway): unblock task claim; full Phase 0/1/2 remediation
Resolves the 100% claim-failure rate introduced by the gateway rewrite
  (commit 62bda0c plus 78 follow-ups). Live smoke runs hit
  `404 /api/v2/flow/developer/...` on every dev verb plus a manifest
  fallback that silently exposed off-role verbs to PMs — confirmed
  firing simultaneously in NAS agent logs (be-dev-1, be-pm, main-pm).

  Audit reports under docs/internal/audit_2026_05_04/ catalogue 49
  defects across gateway, services, prompts, MCP transport, substrate,
  and tests (8 detail reports + master synthesis). Six smoking guns;
  three proven in production logs.

  Phase 0 — unblock claim:
  - URL prefix /api/v2/flow/dev → /developer; slug-map board roles
    (product_owner, head_marketing) → /board (D-01)
  - _i_will_work_on AttributeError on None across pending /
    needs_revision / claimed re-entry branches (D-02)
  - Seed last_heartbeat_at in _qa_or_doc_claim (D-03)
  - Drop misleading i_have_committed verb; dev flow uses commit() (D-04)
  - Manifest mount via compose; flow_server + do_server fail loud
    instead of exposing all-verbs fallback (D-12)
  - MCP _post() surfaces envelope body on 4xx so agents see remediate
    hints (D-13)
    on git failure so retries aren't blocked by half-state (S-01)

  Phase 1 — lifecycle stability:
  - _resolve_skill falls back to AgentTable.capabilities (D-06)
  - main_pm_complete uses kwargs for escalate_to_ceo (D-07)
  - i_am_done auto-runs submit_verification when in_progress (D-08)
  - active_claimant_id wired in claim/unclaim paths — single-claimant
    invariant now functional (D-05)
  - qa_pass/qa_fail assert claimed_by parity with qa_agent_id (D-18)
  - Prompt-drift sweep: fail() shape, i_am_done(task_id, notes),
    subtask cap (12 hard / 8 soft), error-code symbology rewritten in
    base.md + per-role anti-patterns (D-10/11/29/30/31, D-37)

  Phase 2 — invariants + architecture:
  - Real-DB integration test exercising claim → in_progress → commit
    → submit_for_qa → i_am_done → awaiting_qa (P2-1)
  - choreographer.py → package; 3 of 6 role mixins extracted
    (board, doc, qa). _impl.py 2,526 → 2,080 lines (-18%). Continuation
    plan in docs/internal/audit_2026_05_04/p2_2_decompose_plan.md (P2-2)
  - Closure guards consolidated via _subtasks_not_terminal_envelope (P2-3)
  - TaskService.unclaim_for_reaper routed through canonical
    _validate_and_set_status; in_progress → pending added to
    VALID_TRANSITIONS (P2-4)
  - Dead code removed: i_am_done_with_catchup verb, _run_catch_up helper
    (P2-5)
  - 6 state-machine invariants asserted via property test (P2-6)
  - attempt_id (uuid4) stamped on every gateway.rejected audit row (P2-7)
  - _reconcile_orphan_claims_on_startup rolls back tasks left CLAIMED
    with branch_name=NULL from prior crashes (P2-8)
  - scripts/regenerate_verb_tables.py introspects Pydantic schemas +
    role_config; compose_prompt injects per-role tables as a layer.
    Eliminates the prompt-drift class structurally (P2-9)

  Other:
  - D-48: orchestrator mounts host's ~/.claude.json when present so
    agents don't boot from backup recovery on every spawn
  - D-49: dev dispatcher rejects role-mismatched spawns (e.g. doc task
    assigned to dev agent)

  Tests: 553 pass · ruff + mypy clean. Live NAS smoke verification
  pending — needs the stack brought back up.
2026-05-04 23:43:55 +02:00

325 lines
10 KiB
Python

"""End-to-end test: task pending -> awaiting_ceo_approval through the gateway.
Uses a minimal FastAPI app that includes all six v2 routers (no lifespan / no DB).
Choreographer and ContentActions are replaced by stateful async mocks that track
the simulated task lifecycle through every role hand-off.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer, get_content_actions
from roboco.api.routes.v2 import do as do_module
from roboco.api.routes.v2 import flow_cell_pm as flow_cell_pm_module
from roboco.api.routes.v2 import flow_dev as flow_dev_module
from roboco.api.routes.v2 import flow_doc as flow_doc_module
from roboco.api.routes.v2 import flow_main_pm as flow_main_pm_module
from roboco.api.routes.v2 import flow_qa as flow_qa_module
from roboco.services.gateway.envelope import Envelope
_HTTP_200 = 200
_PR_NUMBER = 8
# Shared mutable state threaded through all mock methods.
_State = dict[str, object]
def _build_app() -> FastAPI:
"""Minimal FastAPI app with all v2 routers — no lifespan, no DB."""
app = FastAPI()
app.include_router(flow_dev_module.router)
app.include_router(flow_qa_module.router)
app.include_router(flow_doc_module.router)
app.include_router(flow_cell_pm_module.router)
app.include_router(flow_main_pm_module.router)
app.include_router(do_module.router)
return app
class _MockChoreographer:
"""Stateful stub for Choreographer; methods advance shared lifecycle state."""
def __init__(self, state: _State) -> None:
self._state = state
async def give_me_work(self, _agent_id: object) -> Envelope:
return Envelope.ok(
status=str(self._state["task_status"]),
task_id=str(uuid4()),
next="claim it",
)
async def i_will_work_on(
self,
_agent_id: object,
_task_id: object,
_plan: object = None,
) -> Envelope:
self._state["task_status"] = "in_progress"
return Envelope.ok(
status="in_progress",
task_id=str(uuid4()),
next="commit + i_am_done",
)
async def i_am_done(
self,
_agent_id: object,
_task_id: object,
_notes: object,
) -> Envelope:
self._state["task_status"] = "awaiting_qa"
return Envelope.ok(
status="awaiting_qa",
task_id=str(uuid4()),
next="idle until QA",
evidence={"pr_url": "https://x/pr/8", "pr_number": _PR_NUMBER},
)
async def claim_review(
self,
_agent_id: object,
_task_id: object,
) -> Envelope:
self._state["task_status"] = "claimed"
return Envelope.ok(
status="claimed",
task_id=str(uuid4()),
next="review then pass",
evidence={"pr_url": "https://x/pr/8", "pr_number": _PR_NUMBER},
)
async def pass_review(
self,
_agent_id: object,
_task_id: object,
_notes: object,
) -> Envelope:
self._state["task_status"] = "awaiting_documentation"
return Envelope.ok(
status="awaiting_documentation",
task_id=str(uuid4()),
next="idle",
)
async def claim_doc_task(
self,
_agent_id: object,
_task_id: object,
) -> Envelope:
self._state["task_status"] = "claimed"
return Envelope.ok(
status="claimed",
task_id=str(uuid4()),
next="document then i_documented",
)
async def i_documented(
self,
_agent_id: object,
_task_id: object,
_notes: object,
_files: object,
) -> Envelope:
self._state["task_status"] = "awaiting_pm_review"
return Envelope.ok(
status="awaiting_pm_review",
task_id=str(uuid4()),
next="idle until PM completes",
)
async def complete(
self,
_agent_id: object,
_task_id: object,
_notes: object,
) -> Envelope:
self._state["task_status"] = "completed"
return Envelope.ok(
status="completed",
task_id=str(uuid4()),
next="done",
)
async def main_pm_complete(
self,
_agent_id: object,
_root_task_id: object,
_notes: object,
) -> Envelope:
self._state["task_status"] = "awaiting_ceo_approval"
return Envelope.ok(
status="awaiting_ceo_approval",
task_id=str(uuid4()),
next="idle until CEO",
)
class _MockContentActions:
"""Stateful stub for ContentActions; only note() is exercised here."""
async def note(
self,
*,
agent_id: object,
text: object,
scope: str = "note",
task_id: object = None,
) -> Envelope:
_ = agent_id
_ = text
_ = scope
_ = task_id
return Envelope.ok(status="noted", task_id=None, next="continue")
@pytest.fixture
def stateful_app() -> Iterator[tuple[FastAPI, _State]]:
"""FastAPI app with stateful mocks (Choreographer + ContentActions)."""
app = _build_app()
state: _State = {"task_status": "pending"}
chor = _MockChoreographer(state)
actions = _MockContentActions()
app.dependency_overrides[get_choreographer] = lambda: chor
app.dependency_overrides[get_content_actions] = lambda: actions
yield app, state
app.dependency_overrides.clear()
async def test_pending_to_awaiting_ceo_approval(stateful_app: tuple) -> None:
"""Drive task pending -> awaiting_ceo_approval through all gateway roles."""
app, _state = stateful_app
client = TestClient(app)
dev_id = str(uuid4())
qa_id = str(uuid4())
doc_id = str(uuid4())
cell_pm_id = str(uuid4())
main_pm_id = str(uuid4())
# ------------------------------------------------------------------
# 1. Dev: give_me_work -> i_will_work_on -> note -> i_am_done
# ------------------------------------------------------------------
r = client.post(
"/api/v2/flow/developer/give_me_work",
headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={},
)
assert r.status_code == _HTTP_200
r = client.post(
"/api/v2/flow/developer/i_will_work_on",
headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={"task_id": str(uuid4()), "plan": "edit x then y"},
)
assert r.json()["status"] == "in_progress"
r = client.post(
"/api/v2/do/note",
headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={"text": "Reflected: did the work as planned.", "scope": "reflect"},
)
assert r.json()["error"] is None
r = client.post(
"/api/v2/flow/developer/i_am_done",
headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={"task_id": str(uuid4()), "notes": "all done"},
)
assert r.json()["status"] == "awaiting_qa"
# Inline evidence in the envelope kills issue #15 (QA false-failing "no PR")
assert r.json()["evidence"]["pr_number"] == _PR_NUMBER
# ------------------------------------------------------------------
# 2. QA: claim_review -> note -> pass
# ------------------------------------------------------------------
r = client.post(
"/api/v2/flow/qa/claim_review",
headers={"X-Agent-ID": qa_id, "X-Agent-Role": "qa"},
json={"task_id": str(uuid4())},
)
assert r.json()["evidence"]["pr_number"] == _PR_NUMBER
r = client.post(
"/api/v2/do/note",
headers={"X-Agent-ID": qa_id, "X-Agent-Role": "developer"},
json={
"text": "Reviewed; all acceptance criteria addressed.",
"scope": "learning",
},
)
assert r.json()["error"] is None
long_notes = (
"Reviewed PR #8 carefully. Branch convention correct. Commit message "
"includes task ID. README diff matches spec. No security concerns."
)
r = client.post(
"/api/v2/flow/qa/pass",
headers={"X-Agent-ID": qa_id, "X-Agent-Role": "qa"},
json={"task_id": str(uuid4()), "notes": long_notes},
)
assert r.json()["status"] == "awaiting_documentation"
# ------------------------------------------------------------------
# 3. Doc: claim_doc_task -> i_documented
# ------------------------------------------------------------------
r = client.post(
"/api/v2/flow/documenter/claim_doc_task",
headers={"X-Agent-ID": doc_id, "X-Agent-Role": "documenter"},
json={"task_id": str(uuid4())},
)
assert r.status_code == _HTTP_200
r = client.post(
"/api/v2/flow/documenter/i_documented",
headers={"X-Agent-ID": doc_id, "X-Agent-Role": "documenter"},
json={
"task_id": str(uuid4()),
"notes": "Wrote backend/guides/feature-x.md covering usage and config.",
"files": ["backend/guides/feature-x.md"],
},
)
assert r.json()["status"] == "awaiting_pm_review"
# ------------------------------------------------------------------
# 4. Cell PM: note -> complete (auto-merges leaf PR)
# ------------------------------------------------------------------
r = client.post(
"/api/v2/do/note",
headers={"X-Agent-ID": cell_pm_id, "X-Agent-Role": "developer"},
json={"text": "Decision: approve and merge.", "scope": "decision"},
)
assert r.json()["error"] is None
r = client.post(
"/api/v2/flow/cell_pm/complete",
headers={"X-Agent-ID": cell_pm_id, "X-Agent-Role": "cell_pm"},
json={"task_id": str(uuid4()), "notes": "Approved and merged"},
)
assert r.json()["status"] == "completed"
# ------------------------------------------------------------------
# 5. Main PM: note -> complete root (opens master PR + escalates to CEO)
# ------------------------------------------------------------------
r = client.post(
"/api/v2/do/note",
headers={"X-Agent-ID": main_pm_id, "X-Agent-Role": "developer"},
json={"text": "Root task ready for prod.", "scope": "decision"},
)
assert r.json()["error"] is None
r = client.post(
"/api/v2/flow/main_pm/complete",
headers={"X-Agent-ID": main_pm_id, "X-Agent-Role": "main_pm"},
json={"task_id": str(uuid4()), "notes": "Ready for prod"},
)
assert r.json()["status"] == "awaiting_ceo_approval"