mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(board): LEARN decisions name the item, not its per-cycle index A cycle's reject reasons are rendered into the NEXT cycle's exploration prompt, but the ref recorded alongside each reason was the item's stored id (item-0/item-1) — a per-cycle index that means something different every cycle and appears nowhere the explorer can resolve. The reason survived the loop; what it was about did not. Record the item's title instead, via a shared learn_ref() helper (falls back to the id when title-less, and reads target_task_title for Scales, whose items name the live task they mutate). * chore(lint): satisfy ruff 0.16 — keyword-only signatures and markdown formatting The dev toolchain resolved ruff 0.16.0, which stabilises PLR0917 (too many positional arguments) and formats python code blocks inside markdown. Both fired repo-wide and neither had anything to do with the code they flagged. - 36 signatures gain a `*` so their tail arguments are keyword-only, and the 104 call sites that passed them positionally are converted. mypy was the safety net for the static ones; the full suite caught nine more that only bind at runtime (the MCP tool functions, whose real callers already pass named JSON arguments). - 28 markdown files reformatted by 0.16's code-block formatter. - One RUF036 (`None` mid-union) autofixed in the GitLab provider. * fix(gateway): log the reason when a verb rejects A rejected envelope rides an HTTP 200, its body is never logged, and there is no trace table — so in the access log a verb an agent could not satisfy looks identical to one that worked. On 2026-07-25 four Board Programs (Periscope, Sentinel, Scales, Barfly) each POSTed their propose verb three or four times, persisted nothing, and left their exploration tasks PENDING; the reason was unrecoverable afterwards, from the logs or from the agents' own transcripts. Log error/message/remediate/missing plus the calling agent at envelope_to_response — the one chokepoint every v1 flow and do route returns through. Success envelopes stay silent. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
324 lines
11 KiB
Python
324 lines
11 KiB
Python
"""End-to-end test: task pending -> awaiting_ceo_approval through the gateway.
|
|
|
|
Uses a minimal FastAPI app that includes all six v1 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.v1 import do as do_module
|
|
from roboco.api.routes.v1 import flow_cell_pm as flow_cell_pm_module
|
|
from roboco.api.routes.v1 import flow_dev as flow_dev_module
|
|
from roboco.api.routes.v1 import flow_doc as flow_doc_module
|
|
from roboco.api.routes.v1 import flow_main_pm as flow_main_pm_module
|
|
from roboco.api.routes.v1 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 v1 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, **_kwargs: object) -> 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,
|
|
**_kwargs: 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,
|
|
_ac_verdicts: object = None,
|
|
_criteria_verified: object = None,
|
|
) -> 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,
|
|
**_kwargs: object,
|
|
) -> Envelope:
|
|
# The route forwards scope / task_id / structured / section as keyword
|
|
# args; the stub absorbs them via **_kwargs (it asserts lifecycle
|
|
# transitions, not journal-entry / section rendering). ``section``
|
|
# mirrors the note(scope='handoff') write-path signature.
|
|
_ = agent_id
|
|
_ = text
|
|
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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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"
|