Files
roboco/tests/unit/api/test_correlation_id.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

276 lines
9.7 KiB
Python

"""End-to-end correlation_id propagation: header -> envelope -> audit row.
Audit F20 surfaced that ``X-Correlation-ID`` was bound to structlog by the
``CorrelationIdMiddleware`` but never travelled past the API boundary:
* The MCP shims (``flow_server`` / ``do_server``) didn't forward it, so
every MCP -> API hop got a fresh server-generated UUID.
* The Envelope returned to the agent had no slot to carry the id back.
* The ``audit_log`` rows the choreographer writes had no correlation_id
field, so post-mortem joins across logs and audit trail were impossible.
These tests pin the contract:
1. Envelope holds an optional ``correlation_id`` and round-trips it via
``as_dict()``.
2. The v1 flow route reads ``request.state.correlation_id`` (set by
``CorrelationIdMiddleware``) and stamps it onto the envelope before
returning.
3. Both MCP shims attach an ``X-Correlation-ID`` header on every POST,
mirroring how they attach ``X-Agent-ID`` / ``X-Agent-Role``.
4. The choreographer's ``_emit_rejection`` audit writer pulls the
correlation_id from the structlog contextvars (where the middleware
binds it) and stuffs it into the audit row's ``details`` dict.
"""
from __future__ import annotations
import asyncio
import importlib
import json
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
import structlog
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.middleware import CorrelationIdMiddleware
from roboco.api.routes.v1.flow_dev import router as flow_dev_router
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.envelope import Envelope
if TYPE_CHECKING:
from types import ModuleType
_HTTP_200 = 200
_DEV_AGENT_HEADERS = {
"X-Agent-ID": "00000000-0000-0000-0000-000000000001",
"X-Agent-Role": "developer",
}
# --- Envelope ----------------------------------------------------------------
def test_envelope_ok_carries_correlation_id_when_stamped() -> None:
"""correlation_id is set post-construction by the transport layer."""
env = Envelope.ok(status="idle", next="call i_am_idle()")
env.correlation_id = "test-id-123"
assert env.correlation_id == "test-id-123"
assert env.as_dict()["correlation_id"] == "test-id-123"
def test_envelope_error_carries_correlation_id_when_stamped() -> None:
env = Envelope.invalid_state(message="bad state", remediate="do X first")
env.correlation_id = "abc"
assert env.correlation_id == "abc"
assert env.as_dict()["correlation_id"] == "abc"
def test_envelope_correlation_id_defaults_to_none() -> None:
env = Envelope.ok(status="idle", next="call i_am_idle()")
assert env.correlation_id is None
# When None we still emit the key so consumers don't have to special-case.
assert env.as_dict()["correlation_id"] is None
# --- Route -> Envelope wiring ------------------------------------------------
def _build_app() -> tuple[FastAPI, MagicMock]:
app = FastAPI()
app.add_middleware(CorrelationIdMiddleware)
app.include_router(flow_dev_router)
mock_chore = MagicMock()
mock_envelope = Envelope.ok(status="idle", next="...")
mock_chore.give_me_work = AsyncMock(return_value=mock_envelope)
app.dependency_overrides[get_choreographer] = lambda: mock_chore
return app, mock_chore
def test_route_stamps_request_correlation_id_onto_envelope() -> None:
app, _ = _build_app()
client = TestClient(app)
r = client.post(
"/api/v1/flow/developer/give_me_work",
json={},
headers={**_DEV_AGENT_HEADERS, "X-Correlation-ID": "trace-xyz"},
)
assert r.status_code == _HTTP_200
assert r.json()["correlation_id"] == "trace-xyz"
# Middleware also echoes the header back, so ops can grep for it.
assert r.headers["X-Correlation-ID"] == "trace-xyz"
def test_route_stamps_generated_correlation_id_when_header_missing() -> None:
app, _ = _build_app()
client = TestClient(app)
r = client.post(
"/api/v1/flow/developer/give_me_work",
json={},
headers=_DEV_AGENT_HEADERS,
)
assert r.status_code == _HTTP_200
body_id = r.json()["correlation_id"]
header_id = r.headers["X-Correlation-ID"]
# Middleware generates a UUID and binds it. The route must read the
# SAME id back from request.state and stamp it onto the envelope.
assert body_id is not None
assert body_id == header_id
# --- MCP shims ---------------------------------------------------------------
def _reload_mcp_module(monkeypatch: pytest.MonkeyPatch, dotted: str) -> ModuleType:
"""Set the env vars MCP servers expect at import-time and reload the module.
Both servers read AGENT_ID / AGENT_ROLE / ORCHESTRATOR_URL once at
import; we have to re-import after monkey-patching so the test sees
the patched values. The reload itself is the lazy import — keeping
importlib at the top-level keeps PLC0415 happy.
Also writes a stub manifest file and points the MCP server at it,
since both servers now refuse to register any tools without one.
"""
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
manifest_path = Path(tempfile.mkdtemp()) / "tool-manifest.json"
manifest_path.write_text(
json.dumps(
{
"agent_id": "00000000-0000-0000-0000-000000000001",
"role": "developer",
"team": "backend",
"workspace_path": "/tmp/test",
"flow_tools": [
"give_me_work",
"i_will_work_on",
"open_pr",
"i_am_done",
"i_am_blocked",
"unclaim",
"resume",
"i_am_idle",
],
"do_tools": ["commit", "note", "say", "dm", "evidence"],
"read_tools": [],
"write_tools": [],
"bash_allowed": True,
"subagent_allowed": False,
"subagent_model": None,
"env": {},
}
)
)
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
module = importlib.import_module(dotted)
return importlib.reload(module)
@pytest.fixture
def flow_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
return _reload_mcp_module(monkeypatch, "roboco.mcp.flow_server")
@pytest.fixture
def do_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
return _reload_mcp_module(monkeypatch, "roboco.mcp.do_server")
def _fake_client(payload: dict[str, Any]) -> MagicMock:
fake_response = MagicMock()
fake_response.json.return_value = payload
fake_client = MagicMock()
fake_client.__enter__ = MagicMock(return_value=fake_client)
fake_client.__exit__ = MagicMock(return_value=False)
fake_client.post.return_value = fake_response
return fake_client
def test_flow_server_attaches_correlation_id_header(
flow_module: ModuleType,
) -> None:
fake = _fake_client({"status": "idle"})
with patch("httpx.Client", return_value=fake):
flow_module.give_me_work()
_args, kwargs = fake.post.call_args
headers = kwargs["headers"]
assert headers["X-Agent-ID"] == "00000000-0000-0000-0000-000000000001"
assert headers["X-Agent-Role"] == "developer"
assert "X-Correlation-ID" in headers
assert headers["X-Correlation-ID"] # non-empty
def test_flow_server_generates_unique_correlation_id_per_call(
flow_module: ModuleType,
) -> None:
fake = _fake_client({"status": "idle"})
with patch("httpx.Client", return_value=fake):
flow_module.give_me_work()
flow_module.give_me_work()
first = fake.post.call_args_list[0].kwargs["headers"]["X-Correlation-ID"]
second = fake.post.call_args_list[1].kwargs["headers"]["X-Correlation-ID"]
assert first != second
def test_do_server_attaches_correlation_id_header(
do_module: ModuleType,
) -> None:
fake = _fake_client({"status": "noted"})
with patch("httpx.Client", return_value=fake):
do_module.note("hi")
_args, kwargs = fake.post.call_args
headers = kwargs["headers"]
assert headers["X-Agent-ID"] == "00000000-0000-0000-0000-000000000001"
assert headers["X-Agent-Role"] == "developer"
assert "X-Correlation-ID" in headers
assert headers["X-Correlation-ID"]
# --- Audit-row stash ---------------------------------------------------------
def test_choreographer_emit_rejection_includes_correlation_id_in_details() -> None:
"""Audit row's `details` dict carries correlation_id from contextvars."""
audit = MagicMock()
audit.log_event = AsyncMock()
deps = ChoreographerDeps(
task=MagicMock(),
work_session=MagicMock(),
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
audit=audit,
evidence_repo=MagicMock(),
)
chore = Choreographer(deps)
bad_env = Envelope.invalid_state(message="oops", remediate="do X")
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(correlation_id="cid-9")
try:
asyncio.run(
chore._emit_rejection(
bad_env,
agent_id=UUID("00000000-0000-0000-0000-000000000001"),
task_id=None,
verb="i_will_work_on",
)
)
finally:
structlog.contextvars.clear_contextvars()
audit.log_event.assert_awaited_once()
kwargs = audit.log_event.await_args.kwargs
assert kwargs["details"]["correlation_id"] == "cid-9"