mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user