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:
@@ -342,6 +342,20 @@ class Settings(BaseSettings):
|
|||||||
"override via ROBOCO_STALE_CLAIM_REAP_SECONDS"
|
"override via ROBOCO_STALE_CLAIM_REAP_SECONDS"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# Wave C8 (2026-05-12). Pre-gateway parity: PMs wrote a fresh
|
||||||
|
# journal:decision around each decision point, not once at task
|
||||||
|
# creation. The PM-decision tracing gate (delegate, unblock,
|
||||||
|
# escalate_up, escalate_to_ceo) treats decisions older than this
|
||||||
|
# window as missing, forcing a new note(scope='decision', ...) on
|
||||||
|
# each pass through the gate.
|
||||||
|
pm_decision_window_seconds: int = Field(
|
||||||
|
default=300,
|
||||||
|
ge=1,
|
||||||
|
description=(
|
||||||
|
"Recency window (seconds) for PM journal:decision to satisfy "
|
||||||
|
"gating verbs; override via ROBOCO_PM_DECISION_WINDOW_SECONDS"
|
||||||
|
),
|
||||||
|
)
|
||||||
spawn_cooldown_seconds: int = Field(
|
spawn_cooldown_seconds: int = Field(
|
||||||
default=60,
|
default=60,
|
||||||
ge=1,
|
ge=1,
|
||||||
|
|||||||
@@ -1396,11 +1396,28 @@ class Choreographer:
|
|||||||
foundation table. Verbs requiring more (``complete``,
|
foundation table. Verbs requiring more (``complete``,
|
||||||
``submit_up``) use the verb-specific helpers below which thread
|
``submit_up``) use the verb-specific helpers below which thread
|
||||||
the additional state (reflect, notes, subtasks) into GateContext.
|
the additional state (reflect, notes, subtasks) into GateContext.
|
||||||
|
|
||||||
|
Wave C8 (2026-05-12) — pre-gateway parity: the gate requires the
|
||||||
|
*most recent* journal:decision for (agent, task) to be no older
|
||||||
|
than ``settings.pm_decision_window_seconds``. Older decisions are
|
||||||
|
treated as missing so PMs write a fresh decision around each
|
||||||
|
decision point rather than once at task creation.
|
||||||
"""
|
"""
|
||||||
|
from roboco.config import settings as _settings
|
||||||
from roboco.foundation.policy import tracing as _tr
|
from roboco.foundation.policy import tracing as _tr
|
||||||
|
|
||||||
has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
|
# C8: recency-window only. Per-verb-group consumption tracking
|
||||||
ctx = _tr.GateContext(journal_decision_present=has_decision)
|
# (one decision satisfies exactly one delegate/unblock/escalate
|
||||||
|
# call, then is consumed) is out of scope — Choreographer is
|
||||||
|
# per-request so multi-call state would need a persistent store.
|
||||||
|
latest = await self.journal.latest_decision_at(agent_id, task_id)
|
||||||
|
window_seconds = _settings.pm_decision_window_seconds
|
||||||
|
fresh = (
|
||||||
|
latest is not None
|
||||||
|
and (datetime.now(UTC) - latest).total_seconds() <= window_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = _tr.GateContext(journal_decision_present=fresh)
|
||||||
result = _tr.check_requirements(
|
result = _tr.check_requirements(
|
||||||
task=t,
|
task=t,
|
||||||
requirements=list(_tr.requirements_for(verb)),
|
requirements=list(_tr.requirements_for(verb)),
|
||||||
@@ -2078,9 +2095,7 @@ class Choreographer:
|
|||||||
await self._write_auto_pause_checkpoint(agent_id, t)
|
await self._write_auto_pause_checkpoint(agent_id, t)
|
||||||
return paused_ids
|
return paused_ids
|
||||||
|
|
||||||
async def _write_auto_pause_checkpoint(
|
async def _write_auto_pause_checkpoint(self, agent_id: UUID, task: Any) -> None:
|
||||||
self, agent_id: UUID, task: Any
|
|
||||||
) -> None:
|
|
||||||
"""Write a synthetic checkpoint for a task that was auto-paused on i_am_idle.
|
"""Write a synthetic checkpoint for a task that was auto-paused on i_am_idle.
|
||||||
|
|
||||||
Wave C7 (2026-05-12) — captures state-at-pause so the panel's
|
Wave C7 (2026-05-12) — captures state-at-pause so the panel's
|
||||||
@@ -2094,9 +2109,7 @@ class Choreographer:
|
|||||||
"""
|
"""
|
||||||
commit_refs = [c.sha for c in (task.commits or [])[-3:]]
|
commit_refs = [c.sha for c in (task.commits or [])[-3:]]
|
||||||
commit_count = len(task.commits or [])
|
commit_count = len(task.commits or [])
|
||||||
state_summary = (
|
state_summary = f"auto-paused on i_am_idle (commits: {commit_count})"
|
||||||
f"auto-paused on i_am_idle (commits: {commit_count})"
|
|
||||||
)
|
|
||||||
remaining_work = commit_refs if commit_refs else ["no commits yet"]
|
remaining_work = commit_refs if commit_refs else ["no commits yet"]
|
||||||
try:
|
try:
|
||||||
await self.task.add_checkpoint(
|
await self.task.add_checkpoint(
|
||||||
|
|||||||
@@ -770,6 +770,29 @@ class JournalService(BaseService):
|
|||||||
agent_id, task_id, JournalEntryType.DECISION_LOG
|
agent_id, task_id, JournalEntryType.DECISION_LOG
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def latest_decision_at(
|
||||||
|
self, agent_id: UUID, task_id: UUID
|
||||||
|
) -> datetime | None:
|
||||||
|
"""Return ``created_at`` of the most recent DECISION_LOG entry for
|
||||||
|
(agent, task), or ``None`` if no decision exists.
|
||||||
|
|
||||||
|
Backs the windowed-satisfaction variant of the PM-decision
|
||||||
|
tracing gate (Wave C8): the choreographer treats decisions older
|
||||||
|
than ``settings.pm_decision_window_seconds`` as missing so PMs
|
||||||
|
write a fresh decision around each decision point.
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
select(func.max(JournalEntryTable.created_at))
|
||||||
|
.join(JournalTable, JournalEntryTable.journal_id == JournalTable.id)
|
||||||
|
.where(
|
||||||
|
JournalTable.agent_id == agent_id,
|
||||||
|
JournalEntryTable.task_id == task_id,
|
||||||
|
JournalEntryTable.type == JournalEntryType.DECISION_LOG,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
async def has_note_for_task(self, agent_id: UUID, task_id: UUID) -> bool:
|
async def has_note_for_task(self, agent_id: UUID, task_id: UUID) -> bool:
|
||||||
"""True iff a GENERAL (scope='note') entry exists for (agent, task).
|
"""True iff a GENERAL (scope='note') entry exists for (agent, task).
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ test that makes drift between the spec and the verb body impossible.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from itertools import product
|
from itertools import product
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -41,6 +42,9 @@ def _make_deps(task_svc=None) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
getattr(repo, method).return_value = []
|
||||||
|
# C8: default-fresh journal:decision so PM-decision gate passes.
|
||||||
|
# Parity tests that exercise the gate boundary stub their own value.
|
||||||
|
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
|
||||||
return ChoreographerDeps(**base)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
@@ -521,6 +525,7 @@ async def test_i_am_done_matches_spec(
|
|||||||
journal_svc.has_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
work_svc = deps.work_session
|
work_svc = deps.work_session
|
||||||
@@ -869,6 +874,7 @@ async def test_complete_matches_spec(role: str, status: str) -> None:
|
|||||||
git_svc.pr_target.return_value = "master"
|
git_svc.pr_target.return_value = "master"
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
journal_svc.has_decision_for_task.return_value = True
|
||||||
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||||
deps_kwargs = {
|
deps_kwargs = {
|
||||||
"task": task_svc,
|
"task": task_svc,
|
||||||
"work_session": AsyncMock(),
|
"work_session": AsyncMock(),
|
||||||
@@ -966,6 +972,7 @@ async def test_escalate_up_matches_spec(role: str, status: str) -> None:
|
|||||||
# journal:decision is not on the spec — satisfy it so the verb-specific
|
# journal:decision is not on the spec — satisfy it so the verb-specific
|
||||||
# preflight does not surface a non-spec tracing_gap on the allowed branch.
|
# preflight does not surface a non-spec tracing_gap on the allowed branch.
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
journal_svc.has_decision_for_task.return_value = True
|
||||||
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||||
deps = _make_deps(task_svc=task_svc)
|
deps = _make_deps(task_svc=task_svc)
|
||||||
deps = ChoreographerDeps(
|
deps = ChoreographerDeps(
|
||||||
task=task_svc,
|
task=task_svc,
|
||||||
@@ -1063,6 +1070,7 @@ async def test_escalate_to_ceo_matches_spec(role: str, status: str) -> None:
|
|||||||
)
|
)
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
journal_svc.has_decision_for_task.return_value = True
|
||||||
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||||
deps = _make_deps(task_svc=task_svc)
|
deps = _make_deps(task_svc=task_svc)
|
||||||
deps = ChoreographerDeps(
|
deps = ChoreographerDeps(
|
||||||
task=task_svc,
|
task=task_svc,
|
||||||
@@ -1164,6 +1172,7 @@ async def test_submit_up_matches_spec(role: str, status: str) -> None:
|
|||||||
git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"}
|
git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"}
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
journal_svc.has_decision_for_task.return_value = True
|
||||||
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||||
deps = _make_deps(task_svc=task_svc)
|
deps = _make_deps(task_svc=task_svc)
|
||||||
deps = ChoreographerDeps(
|
deps = ChoreographerDeps(
|
||||||
task=task_svc,
|
task=task_svc,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ assignee"]`` ever lands in the DB.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -54,6 +55,11 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
getattr(repo, method).return_value = []
|
||||||
|
# C8: default-fresh journal:decision so PM-decision gate passes for
|
||||||
|
# callers that don't override the journal mock.
|
||||||
|
_ldef = base["journal"].latest_decision_at.return_value
|
||||||
|
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
|
||||||
|
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
|
||||||
return ChoreographerDeps(**base)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ When extended to all roles, this test catches:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
@@ -143,12 +144,17 @@ def _mock_evidence_repo() -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def _mock_journal_with_reflect() -> Any:
|
def _mock_journal_with_reflect() -> Any:
|
||||||
"""Journal stub that reports reflect/learning/decision entries present."""
|
"""Journal stub that reports reflect/learning/decision entries present.
|
||||||
|
|
||||||
|
``latest_decision_at`` is anchored to ``datetime.now(UTC)`` so the C8
|
||||||
|
recency window on the PM-decision gate accepts it.
|
||||||
|
"""
|
||||||
journal = AsyncMock()
|
journal = AsyncMock()
|
||||||
journal.has_reflect_for_task.return_value = True
|
journal.has_reflect_for_task.return_value = True
|
||||||
journal.has_learning_for_task.return_value = True
|
journal.has_learning_for_task.return_value = True
|
||||||
journal.has_decision_for_task.return_value = True
|
journal.has_decision_for_task.return_value = True
|
||||||
journal.has_struggle_for_task.return_value = False
|
journal.has_struggle_for_task.return_value = False
|
||||||
|
journal.latest_decision_at.return_value = datetime.now(UTC)
|
||||||
return journal
|
return journal
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ TaskService are real — those are the layers Task 30 verifies.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
@@ -149,12 +150,17 @@ def _mock_evidence_repo() -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def _mock_journal_with_reflect() -> Any:
|
def _mock_journal_with_reflect() -> Any:
|
||||||
"""Journal stub that reports reflect/learning/decision entries present."""
|
"""Journal stub that reports reflect/learning/decision entries present.
|
||||||
|
|
||||||
|
``latest_decision_at`` is anchored to ``datetime.now(UTC)`` so the C8
|
||||||
|
recency window on the PM-decision gate accepts it.
|
||||||
|
"""
|
||||||
journal = AsyncMock()
|
journal = AsyncMock()
|
||||||
journal.has_reflect_for_task.return_value = True
|
journal.has_reflect_for_task.return_value = True
|
||||||
journal.has_learning_for_task.return_value = True
|
journal.has_learning_for_task.return_value = True
|
||||||
journal.has_decision_for_task.return_value = True
|
journal.has_decision_for_task.return_value = True
|
||||||
journal.has_struggle_for_task.return_value = False
|
journal.has_struggle_for_task.return_value = False
|
||||||
|
journal.latest_decision_at.return_value = datetime.now(UTC)
|
||||||
return journal
|
return journal
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ async def test_role_enum_dropped_after_upgrade(db_session) -> None: # type: ign
|
|||||||
# The conftest fixture should handle that — verify by reading the
|
# The conftest fixture should handle that — verify by reading the
|
||||||
# existing tests' conftest.
|
# existing tests' conftest.
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
text(
|
text("SELECT typname FROM pg_type WHERE typname IN ('role', 'agentrole')")
|
||||||
"SELECT typname FROM pg_type "
|
|
||||||
"WHERE typname IN ('role', 'agentrole')"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
rows = {row[0] for row in result}
|
rows = {row[0] for row in result}
|
||||||
assert "agentrole" in rows, "agentrole must remain (it's the live enum)"
|
assert "agentrole" in rows, "agentrole must remain (it's the live enum)"
|
||||||
|
|||||||
@@ -56,8 +56,7 @@ def test_i_will_plan_rejects_missing_approach() -> None:
|
|||||||
assert resp.status_code == _HTTP_UNPROCESSABLE, resp.text
|
assert resp.status_code == _HTTP_UNPROCESSABLE, resp.text
|
||||||
detail = resp.json()
|
detail = resp.json()
|
||||||
assert any(
|
assert any(
|
||||||
"approach" in str(err.get("loc", []))
|
"approach" in str(err.get("loc", [])) for err in detail.get("detail", [])
|
||||||
for err in detail.get("detail", [])
|
|
||||||
), 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 "
|
"Single-cell decomposition for the smoke test: be-pm handles "
|
||||||
"git workflow validation end to end."
|
"git workflow validation end to end."
|
||||||
),
|
),
|
||||||
sub_tasks=[
|
sub_tasks=[{"title": "Backend slice", "description": "Branch + edit + PR"}],
|
||||||
{"title": "Backend slice", "description": "Branch + edit + PR"}
|
|
||||||
],
|
|
||||||
risks=[],
|
risks=[],
|
||||||
open_questions=[],
|
open_questions=[],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mapping so the panel can render checkmarks.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -45,6 +46,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +116,7 @@ async def test_i_am_done_writes_criteria_status_on_success() -> None:
|
|||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_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 = AsyncMock()
|
||||||
journal_svc.has_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_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 = AsyncMock()
|
||||||
journal_svc.has_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_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 = AsyncMock()
|
||||||
journal_svc.has_reflect_for_task.return_value = False
|
journal_svc.has_reflect_for_task.return_value = False
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_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 = AsyncMock()
|
||||||
journal_svc.has_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_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 __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -49,6 +50,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ checkpoint summarizing state at pause-time so the panel reflects reality.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -38,6 +39,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Covers: auditor_triage (read-only anomaly surfacing).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -35,6 +36,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ state gate (awaiting_pm_review only), and journal:decision tracing gate.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -49,6 +50,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
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
|
task_svc.escalate_to_ceo.return_value = after
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.escalate_to_ceo.return_value = after
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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")
|
task_svc.agent_for.return_value = MagicMock(role="product_owner")
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = False
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.escalate_to_ceo.return_value = after
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -56,6 +57,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ These tests verify:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -55,6 +56,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
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]
|
task_svc.get_subtasks.return_value = [sub]
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.cell_pm_complete.return_value = after
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
git_svc = AsyncMock()
|
git_svc = AsyncMock()
|
||||||
git_svc.pr_merge.return_value = {"merge_commit_sha": "abc"}
|
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]
|
task_svc.get_subtasks.return_value = [sub]
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
@@ -193,6 +204,7 @@ async def test_submit_up_blocks_when_subtask_pending() -> None:
|
|||||||
task_svc.get_subtasks.return_value = [sub]
|
task_svc.get_subtasks.return_value = [sub]
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ and start a parent task. That implicit gate is restored explicitly here:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -44,6 +45,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
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
|
# rejection here is the unaddressed AC2 criterion, not the new
|
||||||
# mid-flight cadence gate.
|
# mid-flight cadence gate.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
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
|
# Satisfy JOURNAL_DURING_WORK_AT_LEAST_ONE so the test's narrow assertion
|
||||||
# (criteria-gap cleared) isn't masked by an unrelated tracing failure.
|
# (criteria-gap cleared) isn't masked by an unrelated tracing failure.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
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
|
# Satisfy JOURNAL_DURING_WORK_AT_LEAST_ONE so journal:reflect is the
|
||||||
# load-bearing gap surfaced to the assertion.
|
# load-bearing gap surfaced to the assertion.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -32,6 +33,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ dispatcher would respawn them. The gateway makes this explicit:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -41,6 +42,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ states (claim failures, start failures, missing parents, etc.).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -94,6 +95,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
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
|
task_svc.submit_pm_review.return_value = None # service returns None
|
||||||
journal = AsyncMock()
|
journal = AsyncMock()
|
||||||
journal.has_decision_for_task.return_value = True
|
journal.has_decision_for_task.return_value = True
|
||||||
|
journal.latest_decision_at.return_value = datetime.now(UTC)
|
||||||
git = AsyncMock()
|
git = AsyncMock()
|
||||||
git.create_pr = AsyncMock()
|
git.create_pr = AsyncMock()
|
||||||
deps = _make_deps(task=task_svc, journal=journal, git=git)
|
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
|
task_svc.all_subtasks_terminal.return_value = True
|
||||||
journal = AsyncMock()
|
journal = AsyncMock()
|
||||||
journal.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
env = await c.submit_up(pm_id, task_id, notes="x" * 30)
|
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
|
task_svc.get.return_value = task
|
||||||
journal = AsyncMock()
|
journal = AsyncMock()
|
||||||
journal.has_decision_for_task.return_value = False
|
journal.has_decision_for_task.return_value = False
|
||||||
|
journal.latest_decision_at.return_value = None
|
||||||
deps = _make_deps(task=task_svc, journal=journal)
|
deps = _make_deps(task=task_svc, journal=journal)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
env = await c.main_pm_complete(main_pm_id, task_id, notes="x" * 30)
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -35,6 +36,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,6 +150,7 @@ async def test_unblock_restores_pre_block_state() -> None:
|
|||||||
task_svc.unblock_with_restore.return_value = after
|
task_svc.unblock_with_restore.return_value = after
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
@@ -168,6 +177,7 @@ async def test_unblock_default_restores() -> None:
|
|||||||
task_svc.unblock_with_restore.return_value = after
|
task_svc.unblock_with_restore.return_value = after
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
@@ -185,6 +195,7 @@ async def test_unblock_blocks_without_journal_decision() -> None:
|
|||||||
task_svc.get.return_value = t
|
task_svc.get.return_value = t
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = False
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.unblock_with_restore.return_value = after
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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"}
|
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "merge-abc"}
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.all_subtasks_terminal.return_value = False
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.all_subtasks_terminal.return_value = True
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = False
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.all_subtasks_terminal.return_value = True
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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"}
|
git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "https://x/y/pull/99"}
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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"
|
git_svc.pr_target.return_value = "master"
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.all_subtasks_terminal.return_value = False
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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"}
|
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "x"}
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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"}
|
git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "x"}
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_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_reflect_for_task.return_value = True
|
journal_svc.has_reflect_for_task.return_value = True
|
||||||
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
@@ -577,6 +598,7 @@ async def test_escalate_up_routes_by_escalation_target() -> None:
|
|||||||
task_svc.escalate.return_value = after
|
task_svc.escalate.return_value = after
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.escalate.return_value = None # target slug not found in DB
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
@@ -628,6 +651,7 @@ async def test_escalate_up_blocks_without_journal_decision() -> None:
|
|||||||
)
|
)
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = False
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
@@ -657,6 +681,7 @@ async def test_escalate_up_no_target_returns_invalid_state() -> None:
|
|||||||
)
|
)
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ auto-pause behavior of i_am_idle for PMs.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
@@ -54,6 +55,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
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
|
task_svc.start.return_value = started
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = False
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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",
|
plan="re-entry plan",
|
||||||
rich_plan={
|
rich_plan={
|
||||||
"approach": (
|
"approach": (
|
||||||
"Idempotent re-entry: task already in progress, "
|
"Idempotent re-entry: task already in progress, refresh heartbeat."
|
||||||
"refresh heartbeat."
|
|
||||||
),
|
),
|
||||||
"sub_tasks": [
|
"sub_tasks": [{"title": "Re-entry subtask", "description": "Resume work"}],
|
||||||
{"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",
|
plan="re-entry plan",
|
||||||
rich_plan={
|
rich_plan={
|
||||||
"approach": (
|
"approach": (
|
||||||
"Recovery re-entry: task claimed but not started; "
|
"Recovery re-entry: task claimed but not started; run set_plan + start."
|
||||||
"run set_plan + start."
|
|
||||||
),
|
),
|
||||||
"sub_tasks": [
|
"sub_tasks": [
|
||||||
{"title": "Recovery subtask", "description": "Resume from claimed"}
|
{"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"}
|
git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"}
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.all_subtasks_terminal.return_value = False
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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")
|
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = False
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -32,6 +33,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ stage as part of every transition.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -54,6 +55,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
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
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: at least one decision/learning/struggle
|
||||||
# must exist between claim and submit.
|
# must exist between claim and submit.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_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_svc.has_reflect_for_task.return_value = True
|
||||||
# JOURNAL_DURING_WORK_AT_LEAST_ONE.
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_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"
|
git_svc.pr_target.return_value = "master"
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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
|
task_svc.escalate_to_ceo.return_value = after
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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"}
|
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "abc"}
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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"}
|
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "abc"}
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
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"}
|
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "abc"}
|
||||||
journal_svc = AsyncMock()
|
journal_svc = AsyncMock()
|
||||||
journal_svc.has_decision_for_task.return_value = True
|
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)
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ catch-up behavior for the explicit-opt-in case.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -61,6 +62,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
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_svc.has_reflect_for_task.return_value = True
|
||||||
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
work_svc = AsyncMock()
|
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_svc.has_reflect_for_task.return_value = True
|
||||||
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
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_svc.has_reflect_for_task.return_value = True
|
||||||
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
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_svc.has_reflect_for_task.return_value = True
|
||||||
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
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_svc.has_reflect_for_task.return_value = True
|
||||||
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
work_svc = AsyncMock()
|
work_svc = AsyncMock()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ forwards into the service.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -59,6 +60,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ field-by-field guide (the spec §5.2.1 interrogation pattern).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -44,6 +45,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -43,6 +44,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Constraints (from spec):
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -55,6 +56,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
__aexit__=AsyncMock(return_value=False),
|
__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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
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_svc.has_reflect_for_task.return_value = True
|
||||||
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle entry.
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle entry.
|
||||||
journal_svc.has_decision_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_learning_for_task.return_value = False
|
||||||
journal_svc.has_struggle_for_task.return_value = False
|
journal_svc.has_struggle_for_task.return_value = False
|
||||||
evidence_repo = AsyncMock()
|
evidence_repo = AsyncMock()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ fixtures, orchestrator-internal) cannot bypass it.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -57,6 +58,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ a correlation_id from a single inbound request.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
@@ -37,6 +38,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"journal_highlights_for_task",
|
"journal_highlights_for_task",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -44,6 +45,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"blockers_in_lane",
|
"blockers_in_lane",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ exists. These tests pin the four behaviors:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -42,6 +43,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|||||||
"blockers_in_lane",
|
"blockers_in_lane",
|
||||||
):
|
):
|
||||||
getattr(repo, method).return_value = []
|
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)
|
return ChoreographerDeps(**base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,7 @@ _MINIMAL_MANIFEST = {
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def flow_module(
|
def flow_module(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> types.ModuleType:
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> types.ModuleType:
|
|
||||||
"""Import flow_server with minimal env vars needed for constant inspection."""
|
"""Import flow_server with minimal env vars needed for constant inspection."""
|
||||||
manifest_path = tmp_path / "tool-manifest.json"
|
manifest_path = tmp_path / "tool-manifest.json"
|
||||||
manifest_path.write_text(json.dumps(_MINIMAL_MANIFEST))
|
manifest_path.write_text(json.dumps(_MINIMAL_MANIFEST))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -20,6 +21,21 @@ def _service_with_count(count: int) -> JournalService:
|
|||||||
return JournalService(session)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_has_decision_for_task_true_when_count_positive() -> None:
|
async def test_has_decision_for_task_true_when_count_positive() -> None:
|
||||||
svc = _service_with_count(1)
|
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
|
args, _kwargs = add_struggle_mock.call_args
|
||||||
params = args[1]
|
params = args[1]
|
||||||
assert params.title == "Struggle"
|
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