Files
roboco/tests/integration/test_full_lifecycle_real_db.py
T
ff35a646fa Chore: reduce analytics complexity (#100)
* 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>
2026-06-11 04:36:17 +02:00

534 lines
17 KiB
Python

"""Real-DB end-to-end test driving the gateway through the full lifecycle.
The end-to-end integration test that would have caught every smoking
gun in the 2026-05-04 audit. Drives a single task
from pending → completed using a real `db_session` (Postgres-backed
fixture from the top-level conftest), a real `Choreographer`, and a
real `TaskService`. Git is replaced with a deterministic stub
(`_StubGit`) that mutates the same task row the choreographer reads,
so PR/commit state is consistent between the choreographer and the
test's assertions. Journal/A2A/audit/evidence are mocked because they
don't gate the lifecycle paths under test.
When extended to all roles, this test catches:
- URL prefix mismatch (route-level coverage in test_v1_role_dep)
- i_will_work_on AttributeError on None (claim → start sequence is real)
- heartbeat seeding (reaper cutoff)
- active_claimant_id wired (single-claimant invariant)
- i_am_done auto-runs submit_verification
- QA pass clears active_claimant_id
- branch creation atomicity rollback
"""
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.models.base import (
AgentRole,
AgentStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.task import TaskService
# #172: 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"
class _StubGit:
"""Deterministic GitService stub.
Mutates the test's TaskTable row directly to mirror what the real
`git.create_pr` / `git.commit` do via `_record_pr_atomically` and
`_workspace_for_branch`. The choreographer reads pr_number/commits
off the task object — keeping them in sync here means the gates
behave the same as production without any 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 # type: ignore[assignment]
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
# Mirrors git._record_pr_atomically — production sets pr_created
# via mark_pr_created which is what the parallel-completion gate
# in _maybe_advance_to_pm_review reads.
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, **kwargs: Any) -> dict[str, Any]:
del kwargs
return {"merged": True, "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: empty 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
@pytest_asyncio.fixture
async def lifecycle_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict[str, Any]]:
"""Seed a project + dev agent + a single pending task ready to claim."""
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",
slug=f"be-dev-{uuid4().hex[:8]}",
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=f"be-qa-{uuid4().hex[:8]}",
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=f"be-doc-{uuid4().hex[:8]}",
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=f"be-pm-{uuid4().hex[:8]}",
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()
task = TaskTable(
id=uuid4(),
title="Add /healthz endpoint",
description="Return 200 OK from /healthz",
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev_agent.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"},
],
)
db_session.add(task)
await db_session.flush()
yield {
"project": project,
"dev_agent": dev_agent,
"qa_agent": qa_agent,
"doc_agent": doc_agent,
"cell_pm_agent": cell_pm_agent,
"task": task,
}
@pytest.mark.asyncio
async def test_dev_can_claim_pending_task_via_gateway(
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
) -> None:
"""give_me_work → i_will_work_on lands the task in in_progress.
Verifies in one shot: None-handling, heartbeat seed, branch
atomicity, and active_claimant_id wired.
"""
task = lifecycle_setup["task"]
dev_agent = lifecycle_setup["dev_agent"]
task_service = TaskService(db_session)
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(),
)
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"claim failed: {env.message}"
assert env.status == "in_progress"
refreshed = await task_service.get(task.id)
assert refreshed is not None
assert str(refreshed.status) == "in_progress"
assert refreshed.assigned_to == dev_agent.id
assert refreshed.last_heartbeat_at is not None, "heartbeat seed"
assert refreshed.active_claimant_id == dev_agent.id, "claim lock"
@pytest.mark.asyncio
async def test_dev_full_chain_through_awaiting_qa(
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
) -> None:
"""claim → commit → open_pr → i_am_done lands in awaiting_qa.
Drives the full developer-side closure path. Verifies:
- open_pr records pr_number on the task (commits + PR pre-flight)
- i_am_done auto-runs submit_verification → verifying → awaiting_qa
- Heartbeat refreshes after each verb (`_touch`)
- active_claimant_id remains set through dev's tenure
"""
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)
# 1. Claim
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
assert env.status == "in_progress"
# 2. Commit (via stub git directly + record progress on task — the gateway
# path through ContentActions.commit calls task.add_progress, which we
# simulate here so open_pr's commits-precondition is satisfied).
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")
# 3. open_pr — push + open PR. After this, task.pr_number is set.
env = await c.open_pr(dev_agent.id, task.id)
assert env.error is None, f"open_pr failed: {env.message}"
refreshed = await task_service.get(task.id)
assert refreshed is not None
assert refreshed.pr_number == _PR_NUMBER, "PR recorded on task"
# 4. i_am_done — auto-runs in_progress → verifying → awaiting_qa.
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 == "awaiting_qa", (
"i_am_done must auto-run submit_verification + submit_qa"
)
final = await task_service.get(task.id)
assert final is not None
assert str(final.status) == "awaiting_qa"
assert final.self_verified is True, "self_verified set by auto-verify"
@pytest.mark.asyncio
async def test_full_chain_through_doc_handoff(
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
) -> None:
"""Extend the dev chain: QA pass → documenter → awaiting_pm_review.
Verifies QA pass clears active_claimant_id,
docs_complete transitions to awaiting_pm_review, and reassignment
to the cell PM happens on hand-off.
"""
task = lifecycle_setup["task"]
dev_agent = lifecycle_setup["dev_agent"]
qa_agent = lifecycle_setup["qa_agent"]
doc_agent = lifecycle_setup["doc_agent"]
cell_pm_agent = lifecycle_setup["cell_pm_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)
# Drive the dev side first (same as test_dev_full_chain_through_awaiting_qa).
await c.i_will_work_on(
dev_agent.id,
task.id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
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")
await c.open_pr(dev_agent.id, task.id)
env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works")
assert env.error is None
assert env.status == "awaiting_qa"
# QA path: claim_review → pass.
env = await c.claim_review(qa_agent.id, task.id)
assert env.error is None, f"claim_review failed: {env.message}"
qa_notes = (
"Reviewed the diff; route returns 200 OK with timestamp. Tests cover "
"both acceptance criteria. Approving."
)
env = await c.pass_review(qa_agent.id, task.id, notes=qa_notes)
assert env.error is None, f"pass_review failed: {env.message}"
assert env.status == "awaiting_documentation"
after_qa = await task_service.get(task.id)
assert after_qa is not None
assert after_qa.active_claimant_id is None, (
"QA pass must clear active_claimant_id for next role"
)
# Documenter path: claim_doc_task → i_documented.
env = await c.claim_doc_task(doc_agent.id, task.id)
assert env.error is None, f"claim_doc_task failed: {env.message}"
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 == "awaiting_pm_review", (
"i_documented must transition awaiting_documentation → awaiting_pm_review"
)
after_docs = await task_service.get(task.id)
assert after_docs is not None
assert after_docs.assigned_to == cell_pm_agent.id, (
"docs_complete must reassign to the cell PM for the team"
)
# TODO: follow-up — final stages (cell_pm complete + main_pm complete +
# CEO approval) require additional setup: a parent task hierarchy for
# the merge chain, plus a real `git.pr_merge` simulation that updates
# the underlying repo. The _StubGit class covers the API surface; what's
# missing is the seeded parent task + main_pm agent.