mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* refactor(analytics): reduce cyclomatic complexity in usage/pricing/rollup
Collapse the three near-identical get_by_* aggregation methods in
UsageService into a shared _aggregate_by helper parameterized by group
column and key name, and centralize token null-coalescing in a
_row_tokens helper. Extract the per-row upsert in _sweep_daily_rollup
into _upsert_rollup_row, and the pricing-table lookup into
_lookup_prices. All blocks now rank <= B and both modules rank A, so the
xenon gate passes; behavior is unchanged and existing tests stay green.
* feat(billing): make token pricing provider-aware
Distinguish three cases when a model has no per-token rate: a non-Anthropic
model (local Ollama, or an Ollama Cloud ":cloud" model billed by flat
subscription / GPU-time) legitimately has no per-token cost and returns 0.0
silently; an unpriced Anthropic ("claude"-named) model also returns 0.0 but
logs a warning, since that is real spend being undercounted and catches new or
renamed Claude models missing from the table. Folds the old ollama/ prefix
special-case into the general non-Anthropic path so there is one code path,
and replaces the blanket 'no pricing data' warning that fired even for
self-hosted models.
* fix(tasks): preserve ownership when force-unclaiming to pending
The stale-claim reaper and the dependency-blocked release both routed through
_force_unclaim_to_pending, which nulled assigned_to and left the task in a
pending state owned by nobody — no dispatcher re-spawns an ownerless pending
task, so it went dormant. The dispatcher-side claimed_by fallback only masked
half the cases.
Capture the owner before releasing the claim and keep both assigned_to and
claimed_by pointed at it (mirroring the unblock restore), releasing only the
live claim (active_claimant_id + heartbeat) and the WorkSession. The same agent
now resumes the task once it re-dispatches. Updates the reaper test that
asserted the old orphaning behavior and adds owner-preservation coverage for
both the reaper and dependency-release paths.
* fix(tasks): unblock restores the owner into both ownership fields
Audit follow-up to the force-unclaim ownership fix. unblock() only restored
assigned_to from blocker_raised_by, which block() stashes solely from
assigned_to. A task claimed via give_me_work (claimed_by set, assigned_to null)
therefore unblocked into a split-owner state — assigned_to null but claimed_by
set — that both the dev dispatcher and the PM pool-router race to pick up. It
also left claimed_by pointing at the resolver PM after an escalation.
Resolve the owner as blocker_raised_by or assigned_to or claimed_by and write
it to both fields, matching the force-unclaim and reassign convention so the
original worker resumes cleanly. Adds coverage for the give_me_work-claim case
and asserts owner restoration on the existing in_progress-resume test.
* test(orchestrator): cover dev owner resolution and the claimed_by fallback
_resolve_dev_owner_uuid had no coverage. Add the status-dependent precedence
(claimed/blocked prefer the live claimant; other statuses prefer the
PM-assigned owner) and the half-reap fallback where a pending task with
assigned_to nulled still resolves its owner from claimed_by instead of going
dormant.
* fix(tasks): wire the pre-block snapshot so unblock(restore=True) works
The restore=True path on a PM unblock was a no-op: pre_block_state /
pre_block_assignee (migration 006) were read by unblock_with_restore but never
written, so it always fell through to legacy unblock() and the restore flag did
nothing.
Snapshot the resting status + owner at every block entry (dependency block,
soft block, escalation) before mutating, capturing only the first block in a
chain so a re-block doesn't overwrite the original state. Escalation snapshots
the outgoing owner, not the escalation target, so restore returns the original
worker. The restore path applies the same branchless guard legacy unblock()
relies on — a snapshotted in_progress with no branch diverts to pending instead
of looping the dispatcher — and is extracted into _apply_pre_block_restore to
keep complexity under the gate. Adds coverage for snapshot capture, restore,
the branchless divert, and escalation owner restoration.
* test(tasks): update orphan-reconciler and dependency-release tests for owner preservation
Both the startup orphan reconciler and the dependency-blocked claim release
route through unclaim_for_reaper / _force_unclaim_to_pending, which now preserve
the owner instead of nulling assigned_to. Update the two tests that asserted the
old orphaning behavior to assert the owner is kept (so the same agent resumes)
while the live claim is released.
* chore(tests): scrub internal work-item labels from test names, docstrings, comments
Rename four test files that carried audit work-item IDs in their filenames
(test_p0_7_branch_atomicity, test_p2_8_orphan_reconciler,
test_p2_9_autogen_prompt_layer, test_p2_7_attempt_id) to describe what they
test, and strip the matching P-/D-/S- cluster labels from docstrings, comments,
and assertion messages across the test suite and two orchestrator comments.
These are internal references with no meaning in the codebase; behavior is
unchanged.
* style: reformat assertion line shortened by the internal-ref scrub
* build: waive unreachable torch CVE-2025-3000 in pip-audit gate
torch is a transitive CPU-pinned dep (piragi / sentence-transformers) never
loaded at runtime — the stack uses Ollama over HTTP for all embeddings/LLM, so
the vulnerable torch.jit.script path is unreachable. CVE-2025-3000 is MEDIUM,
local-only, with no published fix. Documented --ignore-vuln waiver; revisit when
a fixed torch ships.
* fix(orchestrator): route unplaceable pending tasks to main-pm instead of dropping them
_get_routing_target returned None when a 'dev'-classified task had no cell
agent (no team, or a non-cell team like fullstack/system) or when the routing
classification was unrecognized. _route_unassigned_pm_task logged 'no routing
target found' and returned, leaving the task ownerless and pending — and no
dispatcher re-spawns an unrouted pending task, so it went dormant for 10+ min
until the stuck-task detector caught it.
Fall back to main-pm (the same default cell_pm routing and escalation already
use) so the task is always owned and triaged, never stranded. Logs the fallback
so unplaceable tasks stay visible. Adds a test asserting no (routing, team)
combination ever resolves to None.
* fix(panel): make intake chat markdown inherit the bubble's text color
MarkdownBody is shared by the assistant (text-foreground) and user
(text-primary-foreground) bubbles. [&_*]:!text-inherit only colored the prose
div's descendants, so the prose div itself kept the prose typography body color
(gray) and children inherited that — unreadable on the muted assistant bubble.
Add !text-inherit on the prose div itself so it inherits the bubble's color
too; descendants then inherit the correct foreground. Fixes both bubbles without
hardcoding a color.
* fix(prompter): keep a board-reviewed product on the board team so Approve & Start shows
A product coordination root confirmed via 'Board review & Start' is assigned to
a board reviewer (product-owner) for review, but create_task_from_draft set
team=main_pm for every product unconditionally. The CEO's Approve & Start gate
keys on team=board, so the button never appeared — and because the owner stayed
a board agent while the team said main_pm, the dispatcher routed it to the board
path (nothing left to do after review) and the task stranded at pending, with
the board agent fruitlessly trying to escalate it up.
Route a product by its assignee: a board reviewer keeps it team=board (so the
gate appears and approve_and_start later hands it to Main PM), while a main-pm
assignee — the 'Approve & Start' straight-through path — is team=main_pm. Adds
_assignee_is_board mirroring TaskService's board-role check, and a test.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
868 lines
30 KiB
Python
868 lines
30 KiB
Python
"""Tier 3 — end-to-end happy paths against the real test DB.
|
|
|
|
Each test exercises the spec → choreographer → TaskService → DB stack
|
|
with Alembic migrations applied. Catches "spec says X, DB constraint
|
|
says Y" mismatches the unit-tier parametrized parity suite cannot
|
|
detect.
|
|
|
|
Companion to ``tests/integration/test_full_lifecycle_real_db.py``.
|
|
That file walks one task through the dev chain end to end; this file
|
|
isolates each major lifecycle path into
|
|
its own test so a regression on, say, QA-fail does not also blow up
|
|
the doc-handoff test.
|
|
|
|
Mocks: only the git layer (workspace + PR ops) is stubbed because the
|
|
test DB has no checkout. The spec, choreographer, VerbRunner, and
|
|
TaskService are real — those are the layers Task 30 verifies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import AsyncMock
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
|
from roboco.foundation.policy.lifecycle import Status
|
|
from roboco.models.base import (
|
|
AgentRole,
|
|
AgentStatus,
|
|
TaskNature,
|
|
TaskStatus,
|
|
TaskType,
|
|
Team,
|
|
)
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
from roboco.services.task import TaskService
|
|
|
|
# A developer fresh claim must carry a substantive step checklist.
|
|
_STEPS = [
|
|
{
|
|
"title": "Implement the change",
|
|
"description": (
|
|
"edit the target file, add tests, run them, and stage the "
|
|
"change for commit on the task branch"
|
|
),
|
|
}
|
|
]
|
|
|
|
_GOOD_PLAN = (
|
|
"Implement the task on its feature branch: edit the target module, add or "
|
|
"update unit tests covering the change, run the suite locally, then commit "
|
|
"on the branch and open a PR. Keep the diff focused on the acceptance "
|
|
"criteria and verify it before submitting for QA."
|
|
)
|
|
_GOOD_TC = ["Follow the existing module's patterns; keep the change minimal."]
|
|
_GOOD_RISKS = [
|
|
{
|
|
"risk": "Scope creep balloons the diff and slows review.",
|
|
"mitigation": "Touch only the files the acceptance criteria require.",
|
|
}
|
|
]
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
_BRANCH = "feature/backend/healthz"
|
|
_PR_NUMBER = 8
|
|
_PR_URL = "https://github.com/example/life/pull/8"
|
|
# QA notes must clear settings.qa_notes_min_chars (default 80).
|
|
_QA_PASS_NOTES = (
|
|
"Reviewed the diff; route returns 200 OK with timestamp. Tests cover "
|
|
"both acceptance criteria. Approving."
|
|
)
|
|
_QA_FAIL_NOTES_PREFIX = (
|
|
"Reviewed the diff; route returns 500 on the timestamp branch and the "
|
|
"second acceptance criterion is not exercised by the new tests. "
|
|
)
|
|
|
|
|
|
class _StubGit:
|
|
"""Deterministic GitService stub.
|
|
|
|
Mirrors ``test_full_lifecycle_real_db.py``'s _StubGit. Mutates the
|
|
test's TaskTable row directly so the choreographer reads consistent
|
|
pr_number / commits state without disk or network I/O.
|
|
"""
|
|
|
|
def __init__(self, session: Any, task: TaskTable) -> None:
|
|
self._session = session
|
|
self._task = task
|
|
|
|
async def commit(
|
|
self,
|
|
*,
|
|
branch_name: str,
|
|
message: str,
|
|
task_id: UUID,
|
|
files: list[str] | None = None,
|
|
actor_agent_id: Any = None,
|
|
) -> dict[str, Any]:
|
|
del branch_name, files, actor_agent_id
|
|
sha = uuid4().hex[:40]
|
|
commits = list(self._task.commits or [])
|
|
commits.append({"sha": sha, "message": message, "task_id": str(task_id)})
|
|
self._task.commits = commits
|
|
await self._session.flush()
|
|
return {
|
|
"sha": sha,
|
|
"message": message,
|
|
"files_changed": 1,
|
|
"insertions": 1,
|
|
"deletions": 0,
|
|
}
|
|
|
|
async def push_branch(
|
|
self, branch_name: str, *, actor_agent_id: Any = None
|
|
) -> tuple[str, int]:
|
|
del branch_name, actor_agent_id
|
|
return ("ok", 0)
|
|
|
|
async def push_task_branch(self, agent_id: UUID, task_id: UUID) -> int:
|
|
del agent_id, task_id
|
|
return 0
|
|
|
|
async def create_pr(
|
|
self,
|
|
branch_name: str,
|
|
*,
|
|
parent: str,
|
|
is_root_pr: bool,
|
|
actor_agent_id: Any = None,
|
|
) -> dict[str, Any]:
|
|
del branch_name, parent, actor_agent_id
|
|
self._task.pr_number = _PR_NUMBER
|
|
self._task.pr_url = _PR_URL
|
|
self._task.pr_created = True
|
|
await self._session.flush()
|
|
return {"pr_number": _PR_NUMBER, "pr_url": _PR_URL, "is_root_pr": is_root_pr}
|
|
|
|
async def diff(
|
|
self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None
|
|
) -> str:
|
|
del branch_name, base, actor_agent_id
|
|
return "stub diff"
|
|
|
|
async def list_changed_files(
|
|
self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None
|
|
) -> list[str]:
|
|
del branch_name, base, actor_agent_id
|
|
return []
|
|
|
|
async def pr_target(self, pr_number: int, *, actor_agent_id: Any = None) -> str:
|
|
del pr_number, actor_agent_id
|
|
return "main"
|
|
|
|
async def pr_merge(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
del args, kwargs
|
|
return {
|
|
"merged": True,
|
|
"sha": uuid4().hex[:40],
|
|
"merge_commit_sha": uuid4().hex[:40],
|
|
}
|
|
|
|
|
|
def _mock_evidence_repo() -> Any:
|
|
repo = AsyncMock()
|
|
for method in (
|
|
"list_unread_a2a",
|
|
"list_unread_mentions",
|
|
"list_pending_notifications",
|
|
"task_metadata_gaps",
|
|
"recent_team_activity",
|
|
"blockers_in_lane",
|
|
"journal_highlights_for_task",
|
|
):
|
|
getattr(repo, method).return_value = []
|
|
return repo
|
|
|
|
|
|
def _mock_journal_with_reflect() -> Any:
|
|
"""Journal stub that reports reflect/learning/decision entries present.
|
|
|
|
``latest_decision_at`` is anchored to ``datetime.now(UTC)`` so the C8
|
|
recency window on the PM-decision gate accepts it.
|
|
"""
|
|
journal = AsyncMock()
|
|
journal.has_reflect_for_task.return_value = True
|
|
journal.has_learning_for_task.return_value = True
|
|
journal.has_decision_for_task.return_value = True
|
|
journal.has_struggle_for_task.return_value = False
|
|
journal.latest_decision_at.return_value = datetime.now(UTC)
|
|
return journal
|
|
|
|
|
|
def _mock_work_session() -> Any:
|
|
"""WorkSession stub: stable file list, no unpushed commits."""
|
|
ws = AsyncMock()
|
|
ws.files_changed.return_value = ["roboco/api/routes/health.py"]
|
|
ws.has_unpushed_commits.return_value = False
|
|
return ws
|
|
|
|
|
|
def _build_choreographer(
|
|
db_session: Any, task: TaskTable, task_service: TaskService
|
|
) -> Choreographer:
|
|
"""Wire a real Choreographer with the supplied TaskService + stubbed git.
|
|
|
|
Caller owns the TaskService so it can use it for direct DB reads
|
|
(``task_service.get(task_id)``) — sharing one instance keeps the
|
|
session contract clean and avoids "two TaskServices, two views"
|
|
surprises.
|
|
"""
|
|
deps = ChoreographerDeps(
|
|
task=task_service,
|
|
work_session=_mock_work_session(),
|
|
git=_StubGit(db_session, task),
|
|
a2a=AsyncMock(),
|
|
journal=_mock_journal_with_reflect(),
|
|
audit=AsyncMock(),
|
|
evidence_repo=_mock_evidence_repo(),
|
|
)
|
|
return Choreographer(deps)
|
|
|
|
|
|
async def _seed_agents_and_project(
|
|
db_session: AsyncSession,
|
|
) -> dict[str, Any]:
|
|
"""Seed system + project + dev/qa/doc/cell_pm agents.
|
|
|
|
Slugs match ``agents_config.ESCALATION_CHAIN`` so ``i_am_blocked``
|
|
and ``escalate_up`` find a real escalation target. The agents-config
|
|
chain is the source of truth at runtime; matching it here exercises
|
|
the same lookup the gateway uses in production.
|
|
"""
|
|
system_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="System",
|
|
slug=f"system-{uuid4().hex[:8]}",
|
|
role=AgentRole.SYSTEM,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="system",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(system_agent)
|
|
await db_session.flush()
|
|
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="Lifecycle Test Project",
|
|
slug=f"life-{uuid4().hex[:8]}",
|
|
git_url="https://github.com/example/life.git",
|
|
default_branch="main",
|
|
protected_branches=["main"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=system_agent.id,
|
|
is_active=True,
|
|
)
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
|
|
dev_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE Dev 1",
|
|
slug="be-dev-1",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=["python"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
qa_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE QA",
|
|
slug="be-qa",
|
|
role=AgentRole.QA,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="qa",
|
|
capabilities=["review"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
doc_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE Doc",
|
|
slug="be-doc",
|
|
role=AgentRole.DOCUMENTER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="doc",
|
|
capabilities=["docs"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
cell_pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE Cell PM",
|
|
slug="be-pm",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="cell_pm",
|
|
capabilities=["coord"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add_all([dev_agent, qa_agent, doc_agent, cell_pm_agent])
|
|
await db_session.flush()
|
|
|
|
return {
|
|
"system_agent": system_agent,
|
|
"project": project,
|
|
"dev_agent": dev_agent,
|
|
"qa_agent": qa_agent,
|
|
"doc_agent": doc_agent,
|
|
"cell_pm_agent": cell_pm_agent,
|
|
}
|
|
|
|
|
|
def _build_task(
|
|
*,
|
|
project_id: UUID,
|
|
creator_id: UUID,
|
|
assignee_id: UUID | None,
|
|
status: TaskStatus,
|
|
) -> TaskTable:
|
|
"""Construct a backend code-typed task pinned to ``status``.
|
|
|
|
``acceptance_criteria_status`` carries the stub artefact rows the
|
|
pre-merge gate inspects; supplying them here keeps the per-test
|
|
setup readable.
|
|
"""
|
|
return TaskTable(
|
|
id=uuid4(),
|
|
title="Add /healthz endpoint",
|
|
description="Return 200 OK from /healthz",
|
|
status=status,
|
|
priority=2,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
team=Team.BACKEND,
|
|
project_id=project_id,
|
|
created_by=creator_id,
|
|
assigned_to=assignee_id,
|
|
branch_name=_BRANCH,
|
|
acceptance_criteria=["Returns 200", "Includes timestamp"],
|
|
acceptance_criteria_status=[
|
|
{"criterion": "Returns 200", "referencing_artifact_id": "stub"},
|
|
{"criterion": "Includes timestamp", "referencing_artifact_id": "stub"},
|
|
],
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def lifecycle_setup(
|
|
db_session: AsyncSession,
|
|
) -> AsyncIterator[dict[str, Any]]:
|
|
"""Seed agents + project + a single PENDING task assigned to the dev."""
|
|
seeded = await _seed_agents_and_project(db_session)
|
|
task = _build_task(
|
|
project_id=seeded["project"].id,
|
|
creator_id=seeded["system_agent"].id,
|
|
assignee_id=seeded["dev_agent"].id,
|
|
status=TaskStatus.PENDING,
|
|
)
|
|
db_session.add(task)
|
|
await db_session.flush()
|
|
seeded["task"] = task
|
|
yield seeded
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Dev path: pending → claimed → in_progress → verifying → awaiting_qa
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dev_full_chain_through_awaiting_qa(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""pending → claimed → in_progress → verifying → awaiting_qa.
|
|
|
|
Drives ``i_will_work_on`` (claim+plan+start), a stubbed commit,
|
|
``open_pr``, then ``i_am_done`` which auto-runs submit_verification
|
|
+ submit_qa. Asserts the final DB row sits at ``awaiting_qa``.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
task_service = TaskService(db_session)
|
|
stub_git = _StubGit(db_session, task)
|
|
deps = ChoreographerDeps(
|
|
task=task_service,
|
|
work_session=_mock_work_session(),
|
|
git=stub_git,
|
|
a2a=AsyncMock(),
|
|
journal=_mock_journal_with_reflect(),
|
|
audit=AsyncMock(),
|
|
evidence_repo=_mock_evidence_repo(),
|
|
)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_will_work_on(
|
|
dev_agent.id,
|
|
task.id,
|
|
plan=_GOOD_PLAN,
|
|
steps=_STEPS,
|
|
technical_considerations=_GOOD_TC,
|
|
risks=_GOOD_RISKS,
|
|
)
|
|
assert env.error is None, f"i_will_work_on failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
# Commit + record progress so open_pr's commits-precondition holds.
|
|
await stub_git.commit(
|
|
branch_name=_BRANCH,
|
|
message=f"[{str(task.id)[:8]}] feat(api): add /healthz",
|
|
task_id=task.id,
|
|
)
|
|
await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz")
|
|
|
|
env = await c.open_pr(dev_agent.id, task.id)
|
|
assert env.error is None, f"open_pr failed: {env.message}"
|
|
|
|
env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works")
|
|
assert env.error is None, f"i_am_done failed: {env.message}"
|
|
assert env.status == Status.AWAITING_QA.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_QA.value
|
|
# i_am_done auto-runs submit_qa, which hands the task off to the
|
|
# backend QA agent (production behaviour — see ``_notify_qa``).
|
|
# Resolve via the same lookup the choreographer uses so the
|
|
# assertion is robust to other test fixtures that may have seeded
|
|
# additional QA agents on the BACKEND team (e.g. smoke_test_batch
|
|
# commits its agents, so they outlive their session).
|
|
resolved_qa = await task_service.qa_agent_for_team(Team.BACKEND)
|
|
assert resolved_qa is not None
|
|
assert final.assigned_to == resolved_qa.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. QA pass path: awaiting_qa → claimed → awaiting_documentation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_pass_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_qa → claim_review → pass_review → awaiting_documentation.
|
|
|
|
``claim_review`` keeps status at AWAITING_QA (specialised qa_claim
|
|
sets assignment without transitioning) so the spec's source-status
|
|
requirement on ``qa_pass`` still matches downstream.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
qa_agent = lifecycle_setup["qa_agent"]
|
|
doc_agent = lifecycle_setup["doc_agent"]
|
|
|
|
# Pin the task at awaiting_qa with a PR + commit so the QA gates
|
|
# (pr exists, commits non-empty) all pass.
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
task.self_verified = True
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.claim_review(qa_agent.id, task.id)
|
|
assert env.error is None, f"claim_review failed: {env.message}"
|
|
after_claim = await task_service.get(task.id)
|
|
assert after_claim is not None
|
|
assert str(after_claim.status) == Status.AWAITING_QA.value
|
|
assert after_claim.assigned_to == qa_agent.id
|
|
|
|
env = await c.pass_review(qa_agent.id, task.id, notes=_QA_PASS_NOTES)
|
|
assert env.error is None, f"pass_review failed: {env.message}"
|
|
assert env.status == Status.AWAITING_DOCUMENTATION.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_DOCUMENTATION.value
|
|
# pass_review reassigns to the team's documenter for handoff. Look
|
|
# up via the same path the choreographer uses (robust to other
|
|
# tests' committed BACKEND documenters).
|
|
resolved_doc = await task_service.documenter_for_team(Team.BACKEND)
|
|
assert resolved_doc is not None
|
|
assert final.assigned_to == resolved_doc.id
|
|
del doc_agent # asserted indirectly via documenter_for_team.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. QA fail path: awaiting_qa → claimed → needs_revision
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_fail_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_qa → claim_review → fail_review(issues) → needs_revision.
|
|
|
|
``fail_review`` reassigns to the original developer so they can
|
|
revise; that lookup walks ``quick_context``'s
|
|
``original_developer:<slug>`` marker, which the dev path stamps
|
|
on i_will_work_on. Here we set it directly so the assertion is
|
|
deterministic without re-running the dev chain.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
qa_agent = lifecycle_setup["qa_agent"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
task.self_verified = True
|
|
# ``extract_original_developer`` parses a UUID off this line; the
|
|
# spec layer's slug-based self-review check is a separate code path
|
|
# (``_extract_original_developer`` in qa.py) which only fires when
|
|
# an actor's slug equals this value, so a UUID here doesn't trip it.
|
|
task.quick_context = f"original_developer:{dev_agent.id}"
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.claim_review(qa_agent.id, task.id)
|
|
assert env.error is None, f"claim_review failed: {env.message}"
|
|
|
|
issues = ["Returns 500 on the timestamp branch", "Missing test for the second AC"]
|
|
# fail_review's notes are derived from issues; QA pass-gate also
|
|
# requires notes >= 80 chars, so we send a leading explanation as
|
|
# the issues list — the verb concatenates them and easily clears
|
|
# the threshold.
|
|
long_issues = [_QA_FAIL_NOTES_PREFIX + issues[0], issues[1]]
|
|
env = await c.fail_review(qa_agent.id, task.id, issues=long_issues)
|
|
assert env.error is None, f"fail_review failed: {env.message}"
|
|
assert env.status == Status.NEEDS_REVISION.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.NEEDS_REVISION.value
|
|
# fail_qa reassigns to the original developer so they can revise.
|
|
assert final.assigned_to == dev_agent.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Doc path: awaiting_documentation → claimed → awaiting_pm_review
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_doc_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_documentation → claim_doc_task → i_documented → awaiting_pm_review.
|
|
|
|
``claim_doc_task`` keeps status at AWAITING_DOCUMENTATION (doc_claim
|
|
is assignment-only, mirroring qa_claim). ``i_documented`` flips to
|
|
AWAITING_PM_REVIEW and reassigns to the cell PM for that team.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
doc_agent = lifecycle_setup["doc_agent"]
|
|
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
|
|
|
|
task.status = TaskStatus.AWAITING_DOCUMENTATION
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.pr_created = True
|
|
task.qa_verified = True
|
|
task.assigned_to = None # documenter must claim from unassigned.
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.claim_doc_task(doc_agent.id, task.id)
|
|
assert env.error is None, f"claim_doc_task failed: {env.message}"
|
|
after_claim = await task_service.get(task.id)
|
|
assert after_claim is not None
|
|
assert str(after_claim.status) == Status.AWAITING_DOCUMENTATION.value
|
|
assert after_claim.assigned_to == doc_agent.id
|
|
|
|
env = await c.i_documented(
|
|
doc_agent.id,
|
|
task.id,
|
|
notes="Documented /healthz behaviour in docs/api/health.md",
|
|
files=["docs/api/health.md"],
|
|
)
|
|
assert env.error is None, f"i_documented failed: {env.message}"
|
|
assert env.status == Status.AWAITING_PM_REVIEW.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_PM_REVIEW.value
|
|
# i_documented hands off to the cell PM for the team. Resolve via
|
|
# the same lookup the choreographer uses (robust to other tests'
|
|
# committed BACKEND cell PMs).
|
|
resolved_pm = await task_service.cell_pm_for_team(Team.BACKEND)
|
|
assert resolved_pm is not None
|
|
assert final.assigned_to == resolved_pm.id
|
|
del cell_pm_agent # asserted indirectly via cell_pm_for_team.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. PM complete (Cell PM, simple task): awaiting_pm_review → completed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pm_complete_simple_task(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_pm_review → cell_pm complete → completed.
|
|
|
|
A cell PM completing a non-root awaiting_pm_review task transitions
|
|
straight to COMPLETED — there is no cell→main escalation in
|
|
``complete`` (the cell→main hand-off, when intended, uses
|
|
``submit_up``).
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
|
|
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.pr_created = True
|
|
task.qa_verified = True
|
|
task.docs_complete = True
|
|
task.assigned_to = cell_pm_agent.id
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.complete(
|
|
cell_pm_agent.id, task.id, notes="LGTM — merging the leaf PR."
|
|
)
|
|
assert env.error is None, f"complete failed: {env.message}"
|
|
assert env.status == Status.COMPLETED.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.COMPLETED.value
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. PM escalate: awaiting_pm_review → awaiting_ceo_approval
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pm_escalate_to_ceo_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_pm_review → main_pm complete on a root task → awaiting_ceo_approval.
|
|
|
|
Main PM completing a root task (no parent) opens the master PR if
|
|
needed and calls ``task.escalate_to_ceo``, leaving the task at
|
|
AWAITING_CEO_APPROVAL with ``assigned_to=None``. CEO approval is
|
|
human-in-the-loop (UI-driven), so the test stops there.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
project = lifecycle_setup["project"]
|
|
system_agent = lifecycle_setup["system_agent"]
|
|
|
|
main_pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="Main PM",
|
|
slug="main-pm",
|
|
role=AgentRole.MAIN_PM,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="main_pm",
|
|
capabilities=["coord"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(main_pm_agent)
|
|
await db_session.flush()
|
|
del project, system_agent # only needed for fixture wiring above.
|
|
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.pr_created = True
|
|
task.qa_verified = True
|
|
task.docs_complete = True
|
|
task.parent_task_id = None # explicit — escalate_to_ceo refuses subtasks.
|
|
task.assigned_to = main_pm_agent.id
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
# SQLAlchemy column-typed `id` attributes need an explicit UUID
|
|
# cast for mypy under the project's strict config — the values are
|
|
# already real ``uuid.UUID`` at runtime.
|
|
env = await c.complete(
|
|
UUID(str(main_pm_agent.id)),
|
|
UUID(str(task.id)),
|
|
notes="Root task ready for CEO approval — escalating.",
|
|
)
|
|
assert env.error is None, f"complete failed: {env.message}"
|
|
assert env.status == Status.AWAITING_CEO_APPROVAL.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_CEO_APPROVAL.value
|
|
# main_pm_complete clears assigned_to so the orchestrator does not
|
|
# respawn an agent while the task waits on the human CEO.
|
|
assert final.assigned_to is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Block + unblock + restore: in_progress → blocked → in_progress
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_block_then_unblock_restore(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""in_progress → i_am_blocked → unblock(restore=True) → in_progress.
|
|
|
|
``i_am_blocked`` runs the spec's ``block`` action which delegates
|
|
to ``task_service.escalate``: the task is reassigned to the dev's
|
|
escalation target (``be-pm`` per ``ESCALATION_CHAIN``) and marked
|
|
BLOCKED with the original dev stashed in ``blocker_raised_by``.
|
|
PM ``unblock(restore=True)`` then falls through to the legacy
|
|
``unblock`` path (no ``pre_block_state`` snapshot exists for chain
|
|
escalations), restoring assignment to the dev and flipping back
|
|
to IN_PROGRESS.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
# Drive into in_progress via the real claim+start sequence.
|
|
env = await c.i_will_work_on(
|
|
dev_agent.id,
|
|
task.id,
|
|
plan=_GOOD_PLAN,
|
|
steps=_STEPS,
|
|
technical_considerations=_GOOD_TC,
|
|
risks=_GOOD_RISKS,
|
|
)
|
|
assert env.error is None, f"i_will_work_on failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
env = await c.i_am_blocked(
|
|
dev_agent.id,
|
|
task.id,
|
|
reason="external dependency on auth library upgrade",
|
|
)
|
|
assert env.error is None, f"i_am_blocked failed: {env.message}"
|
|
assert env.status == Status.BLOCKED.value
|
|
|
|
blocked = await task_service.get(task.id)
|
|
assert blocked is not None
|
|
assert str(blocked.status) == Status.BLOCKED.value
|
|
# Escalation reassigns to the cell PM (be-pm) and stashes the dev
|
|
# as blocker_raised_by so unblock can hand the task back.
|
|
assert blocked.assigned_to == cell_pm_agent.id
|
|
assert blocked.blocker_raised_by == dev_agent.id
|
|
|
|
env = await c.unblock(cell_pm_agent.id, task.id, restore=True)
|
|
assert env.error is None, f"unblock failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
restored = await task_service.get(task.id)
|
|
assert restored is not None
|
|
assert str(restored.status) == Status.IN_PROGRESS.value
|
|
# legacy unblock restores assigned_to from blocker_raised_by — the
|
|
# original dev gets the task back so the orchestrator respawns them.
|
|
assert restored.assigned_to == dev_agent.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. Pause + resume: in_progress → paused → in_progress
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_then_resume(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""in_progress → i_am_idle (auto-pause) → resume → in_progress.
|
|
|
|
There is no agent-driven ``pause`` verb. ``i_am_idle`` auto-pauses
|
|
every in_progress task the agent owns so the closure dispatcher can
|
|
wake them on respawn. ``resume`` (composes=("resume",)) then flips
|
|
the same task back to IN_PROGRESS for the same assignee.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.i_will_work_on(
|
|
dev_agent.id,
|
|
task.id,
|
|
plan=_GOOD_PLAN,
|
|
steps=_STEPS,
|
|
technical_considerations=_GOOD_TC,
|
|
risks=_GOOD_RISKS,
|
|
)
|
|
assert env.error is None, f"i_will_work_on failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
env = await c.i_am_idle(dev_agent.id)
|
|
assert env.error is None, f"i_am_idle failed: {env.message}"
|
|
assert env.status == "idle"
|
|
|
|
paused = await task_service.get(task.id)
|
|
assert paused is not None
|
|
assert str(paused.status) == Status.PAUSED.value
|
|
# Auto-pause keeps assigned_to so resume can find the same claimant.
|
|
assert paused.assigned_to == dev_agent.id
|
|
|
|
env = await c.resume(dev_agent.id, task.id)
|
|
assert env.error is None, f"resume failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
resumed = await task_service.get(task.id)
|
|
assert resumed is not None
|
|
assert str(resumed.status) == Status.IN_PROGRESS.value
|
|
assert resumed.assigned_to == dev_agent.id
|