feat(gateway): C8 PM-decision gate windowed satisfaction

_check_pm_decision_required now requires the latest journal:decision
within pm_decision_window_seconds (default 300). Older decisions no
longer satisfy the gate. Adds JournalService.latest_decision_at.

Future-tighten (out of scope): per-verb-group consumption tracking
would need persistent state — Choreographer is per-request today.
This commit is contained in:
Renn F
2026-05-12 06:27:33 +02:00
parent 89eacf028e
commit 41ef7f6b4e
38 changed files with 631 additions and 29 deletions
@@ -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=[],
)
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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()
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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()
@@ -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)
@@ -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)
@@ -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"]
+8
View File
@@ -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)
+8
View File
@@ -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)
@@ -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))
+62
View File
@@ -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