Files
roboco/tests/unit/gateway/test_pm_decision_window.py
T
Renn F 8cf697816f feat(content): obligate role note sections like journals
Completes the note(scope='handoff') write-path (WIP 23e6ee57): every role
with a dedicated note section is now obligated to populate it, the same way
journals are obligated.

Obligations (foundation.policy.tracing):
- DEV_NOTES / PR_REVIEWER_NOTES / QUICK_CONTEXT_MIN_CHARS requirements +
  checkers, wired onto i_am_done (dev_notes), delegate (quick_context), and
  pr_pass / pr_fail / post_pr_review (pr_reviewer_notes).
- Fixes a latent bug: the docs-notes checker read dev_notes instead of
  doc_notes (the documenter's section); the i_documented shim now feeds
  doc_notes to match.

Auditor: a session-scoped note obligation on i_am_idle — the auditor owns no
delivery task and has no delivery verb, so it must have recorded an
observation within the window before going idle (JournalService.has_recent_entry).

Write-then-gate: persisted sections (dev_notes / quick_context) are
pre-written by the agent's note(scope='handoff') before the gated verb;
argument-borne sections (doc_notes / pr_reviewer_notes) are checked through a
SimpleNamespace shim, the same pattern qa_notes already uses.

Config: dev/pr_reviewer/quick_context min-chars (40/40/30), panel-tunable.
Plus per-gap remediation hints and full coverage (write-path routing,
ownership, validation->remediation, each obligation, the doc_notes fix).

Full make-quality green: 9777 passed, 95.6% coverage.
2026-06-21 20:31:42 +02:00

235 lines
7.8 KiB
Python

"""Tests for the windowed satisfaction of the PM-decision tracing gate (C8).
The pre-gateway expectation is that PMs write a *fresh* journal:decision
around each decision point — not once at task creation and then forever.
``_check_pm_decision_required`` enforces this by treating only decisions
whose ``created_at`` is within ``settings.pm_decision_window_seconds`` of
``utc_now`` as satisfying the gate; stale or absent decisions fall through
to the standard tracing_gap envelope (with ``journal:decision`` in the
missing list).
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings as _roboco_settings
from roboco.services.gateway.choreographer import (
Choreographer,
ChoreographerDeps,
)
from roboco.services.gateway.choreographer import _impl as _choreo_impl
def _freeze_clock(monkeypatch: pytest.MonkeyPatch, at: datetime) -> None:
"""Pin ``roboco.services.gateway.choreographer._impl.datetime.now()``.
Used to make boundary assertions (decision age == window) precise —
without freezing the clock, microsecond drift between the test's
``datetime.now(UTC)`` and the call inside ``_check_pm_decision_required``
pushes the age slightly past the window and flakes the test.
The stand-in accepts the same ``tz`` positional that the production
call site passes (``datetime.now(UTC)``) and returns the fixed
instant regardless — that's the whole point of freezing.
"""
def _frozen_now(tz: Any) -> datetime:
_ = tz # accepted for signature parity with datetime.now(tz)
return at
monkeypatch.setattr(_choreo_impl, "datetime", SimpleNamespace(now=_frozen_now))
def _make_deps(**overrides: Any) -> ChoreographerDeps:
"""Mirror tests/unit/gateway/test_choreographer_pm_extras.py::_make_deps.
Async-mocks every service the Choreographer depends on. The session
context-manager is stubbed because VerbRunner uses
``task.session.begin_nested()`` — not exercised here, but kept for
parity with the rest of the suite.
"""
base = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
task = base["task"]
task.session = MagicMock()
task.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
repo = base["evidence_repo"]
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 ChoreographerDeps(**base)
def _make_task(task_id: Any) -> Any:
"""A task stub that the tracing gate accepts as-is.
`_check_pm_decision_required` consults the (agent, task) journal lookup
for the decision window and, for ``delegate``, the persisted
``quick_context`` resumption section — so the stub carries a substantive
quick_context to satisfy that obligation.
"""
return MagicMock(
id=task_id,
status="in_progress",
quick_context="Decomposition planned; cells implement their slice next.",
)
# ---------------------------------------------------------------------------
# 1. No decision → tracing_gap with `journal:decision` in missing.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_no_decision_emits_tracing_gap() -> None:
agent_id = uuid4()
task_id = uuid4()
journal_svc = AsyncMock()
journal_svc.latest_decision_at.return_value = None
deps = _make_deps(journal=journal_svc)
c = Choreographer(deps)
env = await c._check_pm_decision_required(
"delegate", agent_id, task_id, _make_task(task_id)
)
assert env is not None
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision" in body["missing"]
journal_svc.latest_decision_at.assert_awaited_once_with(agent_id, task_id)
# ---------------------------------------------------------------------------
# 2. Recent decision (within window) → gate returns None (pass-through).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_recent_decision_within_window_passes() -> None:
agent_id = uuid4()
task_id = uuid4()
journal_svc = AsyncMock()
journal_svc.latest_decision_at.return_value = datetime.now(UTC) - timedelta(
seconds=60
)
deps = _make_deps(journal=journal_svc)
c = Choreographer(deps)
env = await c._check_pm_decision_required(
"delegate", agent_id, task_id, _make_task(task_id)
)
assert env is None
# ---------------------------------------------------------------------------
# 3. Stale decision (outside window) → tracing_gap.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stale_decision_outside_window_emits_tracing_gap() -> None:
agent_id = uuid4()
task_id = uuid4()
journal_svc = AsyncMock()
# Default window is 300s; 301s old must fall outside.
journal_svc.latest_decision_at.return_value = datetime.now(UTC) - timedelta(
seconds=_roboco_settings.pm_decision_window_seconds + 1
)
deps = _make_deps(journal=journal_svc)
c = Choreographer(deps)
env = await c._check_pm_decision_required(
"delegate", agent_id, task_id, _make_task(task_id)
)
assert env is not None
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision" in body["missing"]
# ---------------------------------------------------------------------------
# 4. Decision at exactly the window boundary → passes (inclusive ``<=``).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_decision_at_exact_window_boundary_passes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
task_id = uuid4()
now = datetime(2026, 5, 12, 12, 0, 0, tzinfo=UTC)
_freeze_clock(monkeypatch, now)
journal_svc = AsyncMock()
journal_svc.latest_decision_at.return_value = now - timedelta(
seconds=_roboco_settings.pm_decision_window_seconds
)
deps = _make_deps(journal=journal_svc)
c = Choreographer(deps)
env = await c._check_pm_decision_required(
"delegate", agent_id, task_id, _make_task(task_id)
)
assert env is None
# ---------------------------------------------------------------------------
# 5. Override the window via settings; decision at 90s must fail when
# window is shrunk to 60s.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_window_respects_settings_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
task_id = uuid4()
journal_svc = AsyncMock()
journal_svc.latest_decision_at.return_value = datetime.now(UTC) - timedelta(
seconds=90
)
deps = _make_deps(journal=journal_svc)
c = Choreographer(deps)
monkeypatch.setattr(_roboco_settings, "pm_decision_window_seconds", 60)
env = await c._check_pm_decision_required(
"delegate", agent_id, task_id, _make_task(task_id)
)
assert env is not None
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision" in body["missing"]