diff --git a/roboco/config.py b/roboco/config.py index 103e12c9..f09be1b6 100644 --- a/roboco/config.py +++ b/roboco/config.py @@ -342,6 +342,20 @@ class Settings(BaseSettings): "override via ROBOCO_STALE_CLAIM_REAP_SECONDS" ), ) + # Wave C8 (2026-05-12). Pre-gateway parity: PMs wrote a fresh + # journal:decision around each decision point, not once at task + # creation. The PM-decision tracing gate (delegate, unblock, + # escalate_up, escalate_to_ceo) treats decisions older than this + # window as missing, forcing a new note(scope='decision', ...) on + # each pass through the gate. + pm_decision_window_seconds: int = Field( + default=300, + ge=1, + description=( + "Recency window (seconds) for PM journal:decision to satisfy " + "gating verbs; override via ROBOCO_PM_DECISION_WINDOW_SECONDS" + ), + ) spawn_cooldown_seconds: int = Field( default=60, ge=1, diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 585f817f..e48cdb9e 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -1396,11 +1396,28 @@ class Choreographer: foundation table. Verbs requiring more (``complete``, ``submit_up``) use the verb-specific helpers below which thread the additional state (reflect, notes, subtasks) into GateContext. + + Wave C8 (2026-05-12) — pre-gateway parity: the gate requires the + *most recent* journal:decision for (agent, task) to be no older + than ``settings.pm_decision_window_seconds``. Older decisions are + treated as missing so PMs write a fresh decision around each + decision point rather than once at task creation. """ + from roboco.config import settings as _settings from roboco.foundation.policy import tracing as _tr - has_decision = await self.journal.has_decision_for_task(agent_id, task_id) - ctx = _tr.GateContext(journal_decision_present=has_decision) + # C8: recency-window only. Per-verb-group consumption tracking + # (one decision satisfies exactly one delegate/unblock/escalate + # call, then is consumed) is out of scope — Choreographer is + # per-request so multi-call state would need a persistent store. + latest = await self.journal.latest_decision_at(agent_id, task_id) + window_seconds = _settings.pm_decision_window_seconds + fresh = ( + latest is not None + and (datetime.now(UTC) - latest).total_seconds() <= window_seconds + ) + + ctx = _tr.GateContext(journal_decision_present=fresh) result = _tr.check_requirements( task=t, requirements=list(_tr.requirements_for(verb)), @@ -2078,9 +2095,7 @@ class Choreographer: await self._write_auto_pause_checkpoint(agent_id, t) return paused_ids - async def _write_auto_pause_checkpoint( - self, agent_id: UUID, task: Any - ) -> None: + async def _write_auto_pause_checkpoint(self, agent_id: UUID, task: Any) -> None: """Write a synthetic checkpoint for a task that was auto-paused on i_am_idle. Wave C7 (2026-05-12) — captures state-at-pause so the panel's @@ -2094,9 +2109,7 @@ class Choreographer: """ commit_refs = [c.sha for c in (task.commits or [])[-3:]] commit_count = len(task.commits or []) - state_summary = ( - f"auto-paused on i_am_idle (commits: {commit_count})" - ) + state_summary = f"auto-paused on i_am_idle (commits: {commit_count})" remaining_work = commit_refs if commit_refs else ["no commits yet"] try: await self.task.add_checkpoint( diff --git a/roboco/services/journal.py b/roboco/services/journal.py index 5088a3d2..7225330f 100644 --- a/roboco/services/journal.py +++ b/roboco/services/journal.py @@ -770,6 +770,29 @@ class JournalService(BaseService): agent_id, task_id, JournalEntryType.DECISION_LOG ) + async def latest_decision_at( + self, agent_id: UUID, task_id: UUID + ) -> datetime | None: + """Return ``created_at`` of the most recent DECISION_LOG entry for + (agent, task), or ``None`` if no decision exists. + + Backs the windowed-satisfaction variant of the PM-decision + tracing gate (Wave C8): the choreographer treats decisions older + than ``settings.pm_decision_window_seconds`` as missing so PMs + write a fresh decision around each decision point. + """ + query = ( + select(func.max(JournalEntryTable.created_at)) + .join(JournalTable, JournalEntryTable.journal_id == JournalTable.id) + .where( + JournalTable.agent_id == agent_id, + JournalEntryTable.task_id == task_id, + JournalEntryTable.type == JournalEntryType.DECISION_LOG, + ) + ) + result = await self.session.execute(query) + return result.scalar() + async def has_note_for_task(self, agent_id: UUID, task_id: UUID) -> bool: """True iff a GENERAL (scope='note') entry exists for (agent, task). diff --git a/tests/foundation/test_lifecycle_consumer_parity.py b/tests/foundation/test_lifecycle_consumer_parity.py index 2f91824a..3ce35905 100644 --- a/tests/foundation/test_lifecycle_consumer_parity.py +++ b/tests/foundation/test_lifecycle_consumer_parity.py @@ -7,6 +7,7 @@ test that makes drift between the spec and the verb body impossible. from __future__ import annotations +from datetime import UTC, datetime from itertools import product from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -41,6 +42,9 @@ def _make_deps(task_svc=None) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Parity tests that exercise the gate boundary stub their own value. + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) @@ -521,6 +525,7 @@ async def test_i_am_done_matches_spec( journal_svc.has_reflect_for_task.return_value = True # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False work_svc = deps.work_session @@ -869,6 +874,7 @@ async def test_complete_matches_spec(role: str, status: str) -> None: git_svc.pr_target.return_value = "master" journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps_kwargs = { "task": task_svc, "work_session": AsyncMock(), @@ -966,6 +972,7 @@ async def test_escalate_up_matches_spec(role: str, status: str) -> None: # journal:decision is not on the spec — satisfy it so the verb-specific # preflight does not surface a non-spec tracing_gap on the allowed branch. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task_svc=task_svc) deps = ChoreographerDeps( task=task_svc, @@ -1063,6 +1070,7 @@ async def test_escalate_to_ceo_matches_spec(role: str, status: str) -> None: ) journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task_svc=task_svc) deps = ChoreographerDeps( task=task_svc, @@ -1164,6 +1172,7 @@ async def test_submit_up_matches_spec(role: str, status: str) -> None: git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task_svc=task_svc) deps = ChoreographerDeps( task=task_svc, diff --git a/tests/integration/test_foundation_phase1_smoke.py b/tests/integration/test_foundation_phase1_smoke.py index 43c6972c..046de551 100644 --- a/tests/integration/test_foundation_phase1_smoke.py +++ b/tests/integration/test_foundation_phase1_smoke.py @@ -9,6 +9,7 @@ assignee"]`` ever lands in the DB. from __future__ import annotations import subprocess +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -54,6 +55,11 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes for + # callers that don't override the journal mock. + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/integration/test_full_lifecycle_real_db.py b/tests/integration/test_full_lifecycle_real_db.py index 661f7001..b3d4772c 100644 --- a/tests/integration/test_full_lifecycle_real_db.py +++ b/tests/integration/test_full_lifecycle_real_db.py @@ -22,6 +22,7 @@ When extended to all roles, this test catches: 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 @@ -143,12 +144,17 @@ def _mock_evidence_repo() -> Any: def _mock_journal_with_reflect() -> Any: - """Journal stub that reports reflect/learning/decision entries present.""" + """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 diff --git a/tests/integration/test_lifecycle_real_db.py b/tests/integration/test_lifecycle_real_db.py index 5eab4494..80e342f0 100644 --- a/tests/integration/test_lifecycle_real_db.py +++ b/tests/integration/test_lifecycle_real_db.py @@ -18,6 +18,7 @@ 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 @@ -149,12 +150,17 @@ def _mock_evidence_repo() -> Any: def _mock_journal_with_reflect() -> Any: - """Journal stub that reports reflect/learning/decision entries present.""" + """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 diff --git a/tests/integration/test_migration_013_drop_role.py b/tests/integration/test_migration_013_drop_role.py index f25b3c07..9b75a4a9 100644 --- a/tests/integration/test_migration_013_drop_role.py +++ b/tests/integration/test_migration_013_drop_role.py @@ -39,10 +39,7 @@ async def test_role_enum_dropped_after_upgrade(db_session) -> None: # type: ign # The conftest fixture should handle that — verify by reading the # existing tests' conftest. result = await db_session.execute( - text( - "SELECT typname FROM pg_type " - "WHERE typname IN ('role', 'agentrole')" - ) + text("SELECT typname FROM pg_type WHERE typname IN ('role', 'agentrole')") ) rows = {row[0] for row in result} assert "agentrole" in rows, "agentrole must remain (it's the live enum)" diff --git a/tests/unit/api/routes/v2/test_i_will_plan_rich_required.py b/tests/unit/api/routes/v2/test_i_will_plan_rich_required.py index 3959eb4a..138b864f 100644 --- a/tests/unit/api/routes/v2/test_i_will_plan_rich_required.py +++ b/tests/unit/api/routes/v2/test_i_will_plan_rich_required.py @@ -56,8 +56,7 @@ def test_i_will_plan_rejects_missing_approach() -> None: assert resp.status_code == _HTTP_UNPROCESSABLE, resp.text detail = resp.json() assert any( - "approach" in str(err.get("loc", [])) - for err in detail.get("detail", []) + "approach" in str(err.get("loc", [])) for err in detail.get("detail", []) ), detail @@ -106,9 +105,7 @@ def test_i_will_plan_schema_accepts_rich_plan() -> None: "Single-cell decomposition for the smoke test: be-pm handles " "git workflow validation end to end." ), - sub_tasks=[ - {"title": "Backend slice", "description": "Branch + edit + PR"} - ], + sub_tasks=[{"title": "Backend slice", "description": "Branch + edit + PR"}], risks=[], open_questions=[], ) diff --git a/tests/unit/gateway/test_acceptance_criteria_status_writer.py b/tests/unit/gateway/test_acceptance_criteria_status_writer.py index 83cc900c..44e62753 100644 --- a/tests/unit/gateway/test_acceptance_criteria_status_writer.py +++ b/tests/unit/gateway/test_acceptance_criteria_status_writer.py @@ -8,6 +8,7 @@ mapping so the panel can render checkmarks. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -45,6 +46,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) @@ -108,6 +116,7 @@ async def test_i_am_done_writes_criteria_status_on_success() -> None: journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False @@ -184,6 +193,7 @@ async def test_i_am_done_with_no_criteria_does_not_write_status() -> None: journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False @@ -241,6 +251,7 @@ async def test_i_am_done_skips_write_when_all_criteria_already_addressed() -> No journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False @@ -283,6 +294,7 @@ async def test_i_am_done_gate_rejection_skips_criteria_write() -> None: journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = False journal_svc.has_decision_for_task.return_value = False + journal_svc.latest_decision_at.return_value = None journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False @@ -337,6 +349,7 @@ async def test_i_am_done_uses_reflect_note_artifact_when_commit_sha_is_none() -> journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False diff --git a/tests/unit/gateway/test_audit_on_rejection.py b/tests/unit/gateway/test_audit_on_rejection.py index 92791f82..0fe0be3d 100644 --- a/tests/unit/gateway/test_audit_on_rejection.py +++ b/tests/unit/gateway/test_audit_on_rejection.py @@ -19,6 +19,7 @@ not propagate or alter the envelope returned to the agent. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -49,6 +50,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_auto_pause_checkpoint.py b/tests/unit/gateway/test_auto_pause_checkpoint.py index ea1dc726..566ac405 100644 --- a/tests/unit/gateway/test_auto_pause_checkpoint.py +++ b/tests/unit/gateway/test_auto_pause_checkpoint.py @@ -8,6 +8,7 @@ checkpoint summarizing state at pause-time so the panel reflects reality. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -38,6 +39,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_choreographer_auditor.py b/tests/unit/gateway/test_choreographer_auditor.py index fe6f2863..24549566 100644 --- a/tests/unit/gateway/test_choreographer_auditor.py +++ b/tests/unit/gateway/test_choreographer_auditor.py @@ -5,6 +5,7 @@ Covers: auditor_triage (read-only anomaly surfacing). from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -35,6 +36,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_choreographer_board.py b/tests/unit/gateway/test_choreographer_board.py index deed5ce7..7ed8385d 100644 --- a/tests/unit/gateway/test_choreographer_board.py +++ b/tests/unit/gateway/test_choreographer_board.py @@ -6,6 +6,7 @@ state gate (awaiting_pm_review only), and journal:decision tracing gate. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -49,6 +50,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) @@ -68,6 +76,7 @@ async def test_board_escalate_to_ceo_succeeds_for_product_owner() -> None: task_svc.escalate_to_ceo.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -97,6 +106,7 @@ async def test_board_escalate_to_ceo_succeeds_for_head_marketing() -> None: task_svc.escalate_to_ceo.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -156,6 +166,7 @@ async def test_board_escalate_to_ceo_requires_journal_decision() -> None: task_svc.agent_for.return_value = MagicMock(role="product_owner") journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = False + journal_svc.latest_decision_at.return_value = None deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -197,6 +208,7 @@ async def test_board_escalate_to_ceo_succeeds_for_main_pm() -> None: task_svc.escalate_to_ceo.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) diff --git a/tests/unit/gateway/test_choreographer_claim_guards.py b/tests/unit/gateway/test_choreographer_claim_guards.py index aa2cb7bb..7ed283d6 100644 --- a/tests/unit/gateway/test_choreographer_claim_guards.py +++ b/tests/unit/gateway/test_choreographer_claim_guards.py @@ -14,6 +14,7 @@ roboco/mcp/tasks/handlers/claim.py:121-180 for the sibling sequence check. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -56,6 +57,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_choreographer_completion_guards.py b/tests/unit/gateway/test_choreographer_completion_guards.py index 32f37021..72c7789a 100644 --- a/tests/unit/gateway/test_choreographer_completion_guards.py +++ b/tests/unit/gateway/test_choreographer_completion_guards.py @@ -12,6 +12,7 @@ These tests verify: from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -55,6 +56,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) @@ -83,6 +91,7 @@ async def test_cell_pm_complete_blocks_when_subtask_pending() -> None: task_svc.get_subtasks.return_value = [sub] journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -118,6 +127,7 @@ async def test_cell_pm_complete_allows_when_all_terminal() -> None: task_svc.cell_pm_complete.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True git_svc = AsyncMock() git_svc.pr_merge.return_value = {"merge_commit_sha": "abc"} @@ -155,6 +165,7 @@ async def test_main_pm_complete_blocks_when_subtask_pending() -> None: task_svc.get_subtasks.return_value = [sub] journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -193,6 +204,7 @@ async def test_submit_up_blocks_when_subtask_pending() -> None: task_svc.get_subtasks.return_value = [sub] journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) diff --git a/tests/unit/gateway/test_choreographer_delegate_guards.py b/tests/unit/gateway/test_choreographer_delegate_guards.py index dcc11348..6e6629e8 100644 --- a/tests/unit/gateway/test_choreographer_delegate_guards.py +++ b/tests/unit/gateway/test_choreographer_delegate_guards.py @@ -10,6 +10,7 @@ and start a parent task. That implicit gate is restored explicitly here: from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -44,6 +45,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_choreographer_dev.py b/tests/unit/gateway/test_choreographer_dev.py index a7c372d4..a1c2822b 100644 --- a/tests/unit/gateway/test_choreographer_dev.py +++ b/tests/unit/gateway/test_choreographer_dev.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -392,6 +393,7 @@ async def test_i_am_done_blocks_when_acceptance_criteria_unaddressed() -> None: # rejection here is the unaddressed AC2 criterion, not the new # mid-flight cadence gate. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) @@ -445,6 +447,7 @@ async def test_i_am_done_reflect_note_addresses_acceptance_criteria() -> None: # Satisfy JOURNAL_DURING_WORK_AT_LEAST_ONE so the test's narrow assertion # (criteria-gap cleared) isn't masked by an unrelated tracing failure. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) @@ -495,6 +498,7 @@ async def test_i_am_done_blocks_when_journal_reflect_missing() -> None: # Satisfy JOURNAL_DURING_WORK_AT_LEAST_ONE so journal:reflect is the # load-bearing gap surfaced to the assertion. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) diff --git a/tests/unit/gateway/test_choreographer_doc.py b/tests/unit/gateway/test_choreographer_doc.py index 25d55df9..92159075 100644 --- a/tests/unit/gateway/test_choreographer_doc.py +++ b/tests/unit/gateway/test_choreographer_doc.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -32,6 +33,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_choreographer_idle_guards.py b/tests/unit/gateway/test_choreographer_idle_guards.py index c096e1d3..c7f02f7e 100644 --- a/tests/unit/gateway/test_choreographer_idle_guards.py +++ b/tests/unit/gateway/test_choreographer_idle_guards.py @@ -11,6 +11,7 @@ dispatcher would respawn them. The gateway makes this explicit: from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -41,6 +42,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_choreographer_impl_branches.py b/tests/unit/gateway/test_choreographer_impl_branches.py index 32e98e56..1a87bfa1 100644 --- a/tests/unit/gateway/test_choreographer_impl_branches.py +++ b/tests/unit/gateway/test_choreographer_impl_branches.py @@ -7,6 +7,7 @@ states (claim failures, start failures, missing parents, etc.). from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -94,6 +95,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) @@ -588,6 +596,7 @@ async def test_submit_up_submit_pm_review_fails() -> None: task_svc.submit_pm_review.return_value = None # service returns None journal = AsyncMock() journal.has_decision_for_task.return_value = True + journal.latest_decision_at.return_value = datetime.now(UTC) git = AsyncMock() git.create_pr = AsyncMock() deps = _make_deps(task=task_svc, journal=journal, git=git) @@ -645,6 +654,7 @@ async def test_submit_up_no_branch_rejected() -> None: task_svc.all_subtasks_terminal.return_value = True journal = AsyncMock() journal.has_decision_for_task.return_value = True + journal.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal) c = Choreographer(deps) env = await c.submit_up(pm_id, task_id, notes="x" * 30) @@ -913,6 +923,7 @@ async def test_main_pm_complete_missing_journal_decision() -> None: task_svc.get.return_value = task journal = AsyncMock() journal.has_decision_for_task.return_value = False + journal.latest_decision_at.return_value = None deps = _make_deps(task=task_svc, journal=journal) c = Choreographer(deps) env = await c.main_pm_complete(main_pm_id, task_id, notes="x" * 30) diff --git a/tests/unit/gateway/test_choreographer_pm.py b/tests/unit/gateway/test_choreographer_pm.py index 2c27f977..da996675 100644 --- a/tests/unit/gateway/test_choreographer_pm.py +++ b/tests/unit/gateway/test_choreographer_pm.py @@ -5,6 +5,7 @@ Covers: triage, triage_all, unblock, complete, escalate_up. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -35,6 +36,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) @@ -142,6 +150,7 @@ async def test_unblock_restores_pre_block_state() -> None: task_svc.unblock_with_restore.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -168,6 +177,7 @@ async def test_unblock_default_restores() -> None: task_svc.unblock_with_restore.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -185,6 +195,7 @@ async def test_unblock_blocks_without_journal_decision() -> None: task_svc.get.return_value = t journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = False + journal_svc.latest_decision_at.return_value = None deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -226,6 +237,7 @@ async def test_unblock_restore_false_returns_legacy_message() -> None: task_svc.unblock_with_restore.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -257,6 +269,7 @@ async def test_cell_pm_complete_merges_then_completes() -> None: git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "merge-abc"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -284,6 +297,7 @@ async def test_cell_pm_complete_blocks_if_subtasks_unfinished() -> None: task_svc.all_subtasks_terminal.return_value = False journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -311,6 +325,7 @@ async def test_cell_pm_complete_requires_journal_decision() -> None: task_svc.all_subtasks_terminal.return_value = True journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = False + journal_svc.latest_decision_at.return_value = None deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -337,6 +352,7 @@ async def test_cell_pm_complete_no_pr_returns_invalid_state() -> None: task_svc.all_subtasks_terminal.return_value = True journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -384,6 +400,7 @@ async def test_main_pm_complete_opens_master_pr_and_escalates() -> None: git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "https://x/y/pull/99"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -423,6 +440,7 @@ async def test_main_pm_complete_skips_pr_creation_if_already_master_targeted() - git_svc.pr_target.return_value = "master" journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -471,6 +489,7 @@ async def test_main_pm_complete_blocks_unfinished_subtasks() -> None: task_svc.all_subtasks_terminal.return_value = False journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -502,6 +521,7 @@ async def test_complete_dispatches_cell_pm() -> None: git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "x"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -536,6 +556,7 @@ async def test_complete_dispatches_main_pm() -> None: git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "x"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -577,6 +598,7 @@ async def test_escalate_up_routes_by_escalation_target() -> None: task_svc.escalate.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -604,6 +626,7 @@ async def test_escalate_up_returns_invalid_state_when_target_lookup_fails() -> N task_svc.escalate.return_value = None # target slug not found in DB journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -628,6 +651,7 @@ async def test_escalate_up_blocks_without_journal_decision() -> None: ) journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = False + journal_svc.latest_decision_at.return_value = None deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -657,6 +681,7 @@ async def test_escalate_up_no_target_returns_invalid_state() -> None: ) journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) diff --git a/tests/unit/gateway/test_choreographer_pm_extras.py b/tests/unit/gateway/test_choreographer_pm_extras.py index 628bfe72..27a06a79 100644 --- a/tests/unit/gateway/test_choreographer_pm_extras.py +++ b/tests/unit/gateway/test_choreographer_pm_extras.py @@ -6,6 +6,7 @@ auto-pause behavior of i_am_idle for PMs. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 @@ -54,6 +55,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) @@ -170,6 +178,7 @@ async def test_i_will_plan_blocks_when_journal_decision_at_claim_missing() -> No task_svc.start.return_value = started journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = False + journal_svc.latest_decision_at.return_value = None deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -395,12 +404,9 @@ async def test_i_will_plan_idempotent_when_already_in_progress_for_caller() -> N plan="re-entry plan", rich_plan={ "approach": ( - "Idempotent re-entry: task already in progress, " - "refresh heartbeat." + "Idempotent re-entry: task already in progress, refresh heartbeat." ), - "sub_tasks": [ - {"title": "Re-entry subtask", "description": "Resume work"} - ], + "sub_tasks": [{"title": "Re-entry subtask", "description": "Resume work"}], }, ) @@ -459,8 +465,7 @@ async def test_i_will_plan_recovery_when_already_claimed_for_caller() -> None: plan="re-entry plan", rich_plan={ "approach": ( - "Recovery re-entry: task claimed but not started; " - "run set_plan + start." + "Recovery re-entry: task claimed but not started; run set_plan + start." ), "sub_tasks": [ {"title": "Recovery subtask", "description": "Resume from claimed"} @@ -804,6 +809,7 @@ async def test_submit_up_opens_pr_and_reassigns_to_main_pm() -> None: git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -833,6 +839,7 @@ async def test_submit_up_blocks_when_subtasks_not_terminal() -> None: task_svc.all_subtasks_terminal.return_value = False journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -874,6 +881,7 @@ async def test_submit_up_blocks_without_journal_decision() -> None: task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend") journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = False + journal_svc.latest_decision_at.return_value = None deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) diff --git a/tests/unit/gateway/test_choreographer_qa.py b/tests/unit/gateway/test_choreographer_qa.py index 13b6cbdc..685d7d54 100644 --- a/tests/unit/gateway/test_choreographer_qa.py +++ b/tests/unit/gateway/test_choreographer_qa.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -32,6 +33,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_choreographer_reassignment.py b/tests/unit/gateway/test_choreographer_reassignment.py index eca119dc..5849ea42 100644 --- a/tests/unit/gateway/test_choreographer_reassignment.py +++ b/tests/unit/gateway/test_choreographer_reassignment.py @@ -11,6 +11,7 @@ stage as part of every transition. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -54,6 +55,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) @@ -117,6 +125,7 @@ async def test_i_am_done_reassigns_task_to_qa_agent() -> None: # JOURNAL_DURING_WORK_AT_LEAST_ONE: at least one decision/learning/struggle # must exist between claim and submit. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False @@ -179,6 +188,7 @@ async def test_i_am_done_skips_reassign_when_no_qa_agent() -> None: journal_svc.has_reflect_for_task.return_value = True # JOURNAL_DURING_WORK_AT_LEAST_ONE. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False @@ -360,6 +370,7 @@ async def test_main_pm_complete_clears_assignment_for_ceo() -> None: git_svc.pr_target.return_value = "master" journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -391,6 +402,7 @@ async def test_board_escalate_to_ceo_clears_assignment() -> None: task_svc.escalate_to_ceo.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -439,6 +451,7 @@ async def test_cell_pm_complete_reassigns_parent_when_all_subtasks_done() -> Non git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "abc"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -477,6 +490,7 @@ async def test_cell_pm_complete_skips_parent_reassign_when_subtasks_pending() -> git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "abc"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -507,6 +521,7 @@ async def test_cell_pm_complete_skips_parent_walk_up_for_root_task() -> None: git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "abc"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) diff --git a/tests/unit/gateway/test_choreographer_submit_qa_gates.py b/tests/unit/gateway/test_choreographer_submit_qa_gates.py index 846949b3..b803e3f7 100644 --- a/tests/unit/gateway/test_choreographer_submit_qa_gates.py +++ b/tests/unit/gateway/test_choreographer_submit_qa_gates.py @@ -20,6 +20,7 @@ catch-up behavior for the explicit-opt-in case. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -61,6 +62,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) @@ -127,6 +135,7 @@ async def test_i_am_done_auto_runs_submit_verification_when_in_progress() -> Non journal_svc.has_reflect_for_task.return_value = True # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False work_svc = AsyncMock() @@ -163,6 +172,7 @@ async def test_i_am_done_blocks_when_no_commits() -> None: journal_svc.has_reflect_for_task.return_value = True # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) @@ -201,6 +211,7 @@ async def test_i_am_done_blocks_when_no_pr() -> None: journal_svc.has_reflect_for_task.return_value = True # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) @@ -240,6 +251,7 @@ async def test_i_am_done_blocks_when_no_progress() -> None: journal_svc.has_reflect_for_task.return_value = True # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) @@ -284,6 +296,7 @@ async def test_i_am_done_proceeds_when_all_gates_pass() -> None: journal_svc.has_reflect_for_task.return_value = True # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False work_svc = AsyncMock() diff --git a/tests/unit/gateway/test_claim_arg_order.py b/tests/unit/gateway/test_claim_arg_order.py index 4c68e308..d0167d48 100644 --- a/tests/unit/gateway/test_claim_arg_order.py +++ b/tests/unit/gateway/test_claim_arg_order.py @@ -17,6 +17,7 @@ forwards into the service. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -59,6 +60,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_delegate_incomplete_input.py b/tests/unit/gateway/test_delegate_incomplete_input.py index 2a13b81b..ca1de292 100644 --- a/tests/unit/gateway/test_delegate_incomplete_input.py +++ b/tests/unit/gateway/test_delegate_incomplete_input.py @@ -10,6 +10,7 @@ field-by-field guide (the spec §5.2.1 interrogation pattern). from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -44,6 +45,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_give_me_work_pre_assigned.py b/tests/unit/gateway/test_give_me_work_pre_assigned.py index 6217a124..ffed8e45 100644 --- a/tests/unit/gateway/test_give_me_work_pre_assigned.py +++ b/tests/unit/gateway/test_give_me_work_pre_assigned.py @@ -13,6 +13,7 @@ Pre-assigned pending tasks must be returned FIRST by pm_give_me_work from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -43,6 +44,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_heartbeat_on_rejection.py b/tests/unit/gateway/test_heartbeat_on_rejection.py index 28c151a5..2e11f99d 100644 --- a/tests/unit/gateway/test_heartbeat_on_rejection.py +++ b/tests/unit/gateway/test_heartbeat_on_rejection.py @@ -17,6 +17,7 @@ Constraints (from spec): from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -55,6 +56,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: __aexit__=AsyncMock(return_value=False), ) ) + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_heartbeat_wired.py b/tests/unit/gateway/test_heartbeat_wired.py index db4d1831..12c2ed89 100644 --- a/tests/unit/gateway/test_heartbeat_wired.py +++ b/tests/unit/gateway/test_heartbeat_wired.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -126,6 +127,7 @@ async def test_i_am_done_calls_heartbeat() -> None: journal_svc.has_reflect_for_task.return_value = True # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle entry. journal_svc.has_decision_for_task.return_value = True + journal_svc.latest_decision_at.return_value = datetime.now(UTC) journal_svc.has_learning_for_task.return_value = False journal_svc.has_struggle_for_task.return_value = False evidence_repo = AsyncMock() diff --git a/tests/unit/gateway/test_i_will_plan_sub_tasks_gate.py b/tests/unit/gateway/test_i_will_plan_sub_tasks_gate.py index 977adc83..e7f5b68c 100644 --- a/tests/unit/gateway/test_i_will_plan_sub_tasks_gate.py +++ b/tests/unit/gateway/test_i_will_plan_sub_tasks_gate.py @@ -12,6 +12,7 @@ fixtures, orchestrator-internal) cannot bypass it. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -57,6 +58,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_p2_7_attempt_id.py b/tests/unit/gateway/test_p2_7_attempt_id.py index 6736965d..70bca4b9 100644 --- a/tests/unit/gateway/test_p2_7_attempt_id.py +++ b/tests/unit/gateway/test_p2_7_attempt_id.py @@ -7,6 +7,7 @@ a correlation_id from a single inbound request. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock from uuid import UUID, uuid4 @@ -37,6 +38,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "journal_highlights_for_task", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_pm_decision_window.py b/tests/unit/gateway/test_pm_decision_window.py new file mode 100644 index 00000000..7afe8d0d --- /dev/null +++ b/tests/unit/gateway/test_pm_decision_window.py @@ -0,0 +1,228 @@ +"""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` only consults the (agent, task) journal + lookup — the task object itself is opaque to that check. + """ + return MagicMock(id=task_id, status="in_progress") + + +# --------------------------------------------------------------------------- +# 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"] diff --git a/tests/unit/gateway/test_resume.py b/tests/unit/gateway/test_resume.py index 1d86c779..c9bc162d 100644 --- a/tests/unit/gateway/test_resume.py +++ b/tests/unit/gateway/test_resume.py @@ -14,6 +14,7 @@ that gap. These tests pin the four behaviors: from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -44,6 +45,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "blockers_in_lane", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/gateway/test_unclaim.py b/tests/unit/gateway/test_unclaim.py index 9eb19b5f..93b15af9 100644 --- a/tests/unit/gateway/test_unclaim.py +++ b/tests/unit/gateway/test_unclaim.py @@ -12,6 +12,7 @@ exists. These tests pin the four behaviors: from __future__ import annotations +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -42,6 +43,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "blockers_in_lane", ): getattr(repo, method).return_value = [] + # C8: default-fresh journal:decision so PM-decision gate passes. + # Tests that exercise the gate boundary stub their own value. + # The check matches MagicMock and AsyncMock (the two default sentinel + # types pytest's unittest.mock leaves on un-stubbed return_values). + _ldef = base["journal"].latest_decision_at.return_value + if type(_ldef).__name__ in ("MagicMock", "AsyncMock"): + base["journal"].latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps(**base) diff --git a/tests/unit/mcp_servers/test_circuit_incomplete_input.py b/tests/unit/mcp_servers/test_circuit_incomplete_input.py index cb75e6bf..79aad961 100644 --- a/tests/unit/mcp_servers/test_circuit_incomplete_input.py +++ b/tests/unit/mcp_servers/test_circuit_incomplete_input.py @@ -36,9 +36,7 @@ _MINIMAL_MANIFEST = { @pytest.fixture() -def flow_module( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> types.ModuleType: +def flow_module(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> types.ModuleType: """Import flow_server with minimal env vars needed for constant inspection.""" manifest_path = tmp_path / "tool-manifest.json" manifest_path.write_text(json.dumps(_MINIMAL_MANIFEST)) diff --git a/tests/unit/services/test_journal.py b/tests/unit/services/test_journal.py index d8cb41e1..180e02c6 100644 --- a/tests/unit/services/test_journal.py +++ b/tests/unit/services/test_journal.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -20,6 +21,21 @@ def _service_with_count(count: int) -> JournalService: return JournalService(session) +def _service_with_scalar(value: object) -> JournalService: + """Build a JournalService whose scalar query returns `value`. + + Mirrors `_service_with_count` but lets the test inject any value + (including a `datetime` or `None`) for the single-column query path + used by `latest_decision_at`. + """ + result = MagicMock() + result.scalar.return_value = value + session = MagicMock() + session.execute = AsyncMock(return_value=result) + session.flush = AsyncMock() + return JournalService(session) + + @pytest.mark.asyncio async def test_has_decision_for_task_true_when_count_positive() -> None: svc = _service_with_count(1) @@ -97,3 +113,49 @@ async def test_write_struggle_handles_empty_content_gracefully() -> None: args, _kwargs = add_struggle_mock.call_args params = args[1] assert params.title == "Struggle" + + +# --------------------------------------------------------------------------- +# latest_decision_at — windowed-satisfaction support for the PM-decision gate +# (C8). Returns the `created_at` of the newest DECISION_LOG entry for an +# (agent, task) pair, or None if no decision exists. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_latest_decision_at_returns_none_when_no_decision() -> None: + """No DECISION_LOG entries → scalar query returns None → method returns None.""" + svc = _service_with_scalar(None) + assert await svc.latest_decision_at(uuid4(), uuid4()) is None + + +@pytest.mark.asyncio +async def test_latest_decision_at_returns_timestamp_of_single_decision() -> None: + """One DECISION_LOG entry → returns its `created_at`.""" + expected = datetime(2026, 5, 12, 10, 0, 0, tzinfo=UTC) + svc = _service_with_scalar(expected) + out = await svc.latest_decision_at(uuid4(), uuid4()) + assert out == expected + + +@pytest.mark.asyncio +async def test_latest_decision_at_returns_most_recent_when_multiple_decisions() -> None: + """SQL `max(created_at)` returns the newest; method passes it through. + + The DB does the max() reduction in the query — the mock returns + whatever the scalar would; we assert the method respects that value. + """ + newest = datetime(2026, 5, 12, 12, 30, 0, tzinfo=UTC) + svc = _service_with_scalar(newest) + out = await svc.latest_decision_at(uuid4(), uuid4()) + assert out == newest + + +@pytest.mark.asyncio +async def test_latest_decision_at_filters_by_agent_id() -> None: + """The query filters by (agent_id, task_id) — a decision by another + agent on the same task must NOT count. The mock returns None to + represent the post-filter empty set.""" + svc = _service_with_scalar(None) + out = await svc.latest_decision_at(uuid4(), uuid4()) + assert out is None