mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(lifecycle): add the in-path PR-review gate status + reviewer verbs
Insert awaiting_pr_review between the assembled-PR submit and the PM merge,
giving the merge level the rejection capability it structurally lacks — today
only qa_fail and ceo_reject ever reach needs_revision, so a PM review is a
merge button with no teeth.
- New Status awaiting_pr_review + submit_for_review / pr_pass / pr_fail actions
(pr_pass -> awaiting_pm_review, pr_fail -> needs_revision, mirroring the QA gate).
- Reviewer verbs claim_gate_review / pr_pass / pr_fail, and a main-PM submit_root
verb (the root analogue of the cell PM's submit_up; opens the root->master PR).
- Extend the self-review-symmetry validator to the new sign-off actions.
- Mirror the value into the ORM TaskStatus enum + the A2A state map, and add the
postgres taskstatus enum value (migration 040, forward-only like 037).
- Regenerate the per-role verb tables; add gate spec tests.
Spec surface only; the gateway methods + dispatch are wired in follow-ups, so the
verbs are advertised but dormant (flow_server tolerates unregistered verbs).
* feat(identity): add the three cell PR-review-gate reviewers
The in-path gate needs a reviewer per cell so each cell's assembled cell->root
PR is reviewed by a stack-specialized agent, while pr-reviewer-1 serves the
root->master gate (and keeps doing inbound external PRs).
- be/fe/ux-pr-reviewer: PR_REVIEWER role, team-scoped (so dispatch routes each
cell's gate to its own reviewer); seeded identities + ROLE_TEAM_RULES + names.
AI agent count 22 -> 25.
- They reuse the existing roboco-agent-pr-reviewer image (AGENT_IMAGES maps the
three slugs to it, as be-dev-1/-2 share one image) — no new image.
- Tracing table: pr_pass/pr_fail require a learning entry (parity with
post_pr_review), submit_root mirrors submit_up, claim_gate_review is waived
(its tracing applies on pr_pass/pr_fail) — completes the verb surface added
in the prior commit.
- Update the roster-pinning identity tests.
* feat(gateway): wire the in-path PR-review gate end to end
Make the assembled-PR review gate operational across the choreographer, the
TaskService transitions, and the v1 flow surface.
- TaskService: submit_for_review (in_progress→awaiting_pr_review), pr_gate_claim
(no-transition reviewer claim), pr_pass (→awaiting_pm_review), pr_fail
(→needs_revision); mirror qa_pass/qa_fail (clear claim, actor-mismatch warn,
issues appended for the PM's revision). VerbRunner gains the matching atomic
handlers + a create_root_pr side effect.
- Repoint submit_up to compose submit_for_review (cell→root PR enters the gate),
and add a main-PM submit_root verb (opens the root→master PR, enters the gate).
- Split main_pm_complete: a code root must pass the gate first (requires
awaiting_pm_review; rejects an in_progress code root toward submit_root and no
longer reopens the PR), while a branchless coordination root still walks
straight through, ungated.
- PRGateMixin (claim_gate_review / pr_pass / pr_fail) composed onto the
Choreographer; flow_server forwarders + v1 routes (pr_reviewer + main_pm) +
request schemas.
- Tests: gate spec + the updated submit_up / main_pm_complete expectations + new
real-DB integration tests driving submit_for_review→pr_gate_claim→pr_pass and
pr_fail through the real enforcement layer.
* feat(orchestrator): dispatch the in-path PR-review gate
Make the gate live in the dispatch loop.
- _dispatch_pr_gate_work: route awaiting_pr_review tasks to reviewers by level —
a cell→root task to its cell reviewer (be/fe/ux-pr-reviewer), the root→master
task to pr-reviewer-1. The reviewer self-claims via claim_gate_review (no
pre-claim, mirroring the external-PR dispatcher); registered in
_dispatch_all_work. _select_agent_for_cell learns the pr_reviewer role.
- _build_pr_gate_prompt: anchors the reviewer to the parent objective + full
acceptance criteria + the FE<->BE contract, then pr_pass / pr_fail.
- _readiness_check_role_for_status: awaiting_pr_review -> pr_reviewer.
- Fail routing: pr_fail reassigns the failed assembled task to its PM
(_revision_pm_for_task: cell PM for a cell team, Main PM for the root), and the
revision dispatcher is generalized from coordination-roots-only to any
PM-owned needs_revision task so the gate-failed task is re-coordinated instead
of deadlocking.
* docs: document the in-path PR-review gate + the cell reviewers (22→25)
Reflect the shipped gate across the canonical + RAG docs.
- CLAUDE.md: agent count 22→25, the cell reviewers in the org chart, an
awaiting_pr_review state + the gate transitions + a gate note in the lifecycle
section, and submit_root / claim_gate_review / pr_pass / pr_fail in the verb
surface table.
- docs/rag/architecture: org-structure (count, cell-reviewer roster, cells
table), agent-uuids (be/fe/ux-pr-reviewer rows), agent-model (role + team
rows).
- docs/rag/roles/pr-reviewer: the in-path gate section + the gate verbs.
- Wrap reviewer.id with UUID(str(...)) in the gate DB tests for mypy.
* docs: finish the gate doc sweep across README + RAG + generated artifacts
Catch the remaining surfaces beyond the canonical docs.
- README + how-to: agent count 22→25, the 6-agent cells (+ PR Reviewer), the
main reviewer's root→master gate role.
- RAG: permissions + tool-permissions + task-tools list the gate verbs
(claim_gate_review / pr_pass / pr_fail) for pr_reviewer; regenerate the
lifecycle artifacts (intent-verbs, status-transitions, the per-role
lifecycle-*.md prompts, panel lifecycle.json) from the spec via
build_lifecycle_artifacts.py so they carry the new status + verbs.
* fix(migration): shorten the 040 revision id to fit alembic_version VARCHAR(32)
The revision id '040_taskstatus_awaiting_pr_review' is 33 chars; alembic's
alembic_version.version_num column is VARCHAR(32), so recording the migration on
a real 'alembic upgrade head' failed with 'value too long for type character
varying(32)' (surfaced on the NAS deploy). The test suite missed it: the test DB
is built via Base.metadata.create_all and the parity test only renders SQL
offline, so nothing actually applied the migration chain.
- Rename to '040_awaiting_pr_review' (22 chars).
- Add a guard test asserting every revision id fits the VARCHAR(32) column.
- Verified by applying the full chain 001->040 against real Postgres: it now
reaches head and records '040_awaiting_pr_review' without truncation.
* fix(migration): land the actual 040 revision-id shortening + guard test
The prior commit captured only the file rename (git add aborted on the deleted
old path), leaving the long revision id and missing the guard test. This commit
carries the real content: revision id '040_awaiting_pr_review' (22 chars) and the
revision-id length guard. Re-verified against real Postgres — the full chain
reaches head and records the short id without truncation.
* fix(product): flush cell deletes before inserts when re-mapping projects
Editing a product's cell->project map (PATCH /api/products/{id}) 409'd with
'duplicate key value violates unique constraint uq_product_projects_product_team'
whenever a team already had a mapping. _replace_cells clears the old rows and
appends the new ones, but within a single flush SQLAlchemy orders INSERTs before
DELETEs for the same table, so the new (product_id, team) rows collided with the
not-yet-deleted old ones. Flush the deletes first.
Pre-existing bug (unrelated to the PR-review gate); surfaced on the NAS. New
real-Postgres regression test re-maps all three cells to different projects —
it fails with the unique violation without the fix and passes with it. The
existing update test only changed WHICH team was mapped, so it never collided.
* fix(gateway): let main_pm submit_root past the shared submit-up guard
submit_root reused the cell PM's _submit_up_ownership_guard, which
hardcoded agent.role != cell_pm and rejected the Main PM with
"submit_up is reserved for cell_pm". A branch-bearing code root could
then never close: submit_root bounced to complete, while complete
required awaiting_pm_review (reachable only via submit_root) and bounced
back — a circular rejection.
Both callers already run the spec gate (can_invoke_intent), which
enforces submit_up→cell_pm and submit_root→main_pm, so the guard's role
re-check was redundant for submit_up and wrong for submit_root. Broaden
it to accept either PM role as a defense-in-depth non-PM reject.
Adds the first choreographer-level submit_root test (the gap that let
this ship).
* fix(gateway): proactively steer both PMs to their bubble-up verb
The submit_root deadlock had a sibling steering gap: nothing told a PM
which verb opens the gate. The delegate next-hint said only 'i_am_idle
when done', and complete's in_progress rejection named submit_root for
the Main PM but left the Cell PM with a bare 'not ready for completion'
— no submit_up pointer, the same guess-the-verb trap.
- delegate hint now names the role-correct verb (root → submit_root,
cell parent → submit_up) proactively, before any rejection.
- cell_pm_complete's in_progress rejection now steers to submit_up,
mirroring the Main PM's submit_root gate hint.
Tests cover both the cell-PM steer and the role-aware delegate hint.
* docs: correct who-merges-which-PR across the gate docs + complete description
Audit of the gate docs found the merge actors mis-stated in several
places — the exact ambiguity that risks 'the reviewer/PM merges the root
PR' confusion:
- complete IntentSpec description said 'Main PM merges root PR' — false;
main_pm_complete escalates and the CEO merges root→master. Corrected
(propagated to intent-verbs.md, lifecycle.json, generated role prompts
via build_lifecycle_artifacts.py).
- task-tools.md: submit_up target was awaiting_pm_review (should be
awaiting_pr_review); Main PM flow had no submit_root — added it.
- README.md: lifecycle diagram now shows the awaiting_pr_review gate.
- cell-pm.md / main-pm.md: dropped the stale 'submit_up hands work to the
Main PM who merges your cell branch' model — the cell PM merges its own
gated cell→root PR; the Main PM owns the root + submit_root; the CEO
merges master. Added submit_root to the main-pm manifest.
- git-commits.md, pr-creation.md, tool-permissions.md, git-tools.md:
stopped attributing root→master PR opening to complete (it's submit_root).
No behavior change; verb wiring + state machine verified gap-free this
session (the pr_fail→needs_revision→PM respawn loop closes correctly).
* fix(orchestrator): stop closure respawn waiting the reaper window
A PM that finished its subtasks and idled left its parent 'paused' with a
fresh last_heartbeat_at. _is_recently_paused gated closure respawn on
_claim_heartbeat_ttl — the REAPER window (stale_claim_reap_seconds: 600s
default, 1800s on the NAS) — so the parent sat untouched for up to 10-30
minutes before its PM was respawned to close it. The whole chain stalled
behind it.
The race that guard actually protects against (i_am_idle auto-pauses, then
the agent is marked IDLE + its container tears down) is seconds, and the
live-session case is already covered by _is_agent_active. Introduce a
dedicated short debounce (pm_closure_recently_paused_seconds, default 45s)
and gate closure on that instead.
The existing test fixture masked this by setting _claim_heartbeat_ttl to
claim_stale_seconds (180s), not the production reaper value. Fixture now
mirrors production; adds a regression test that a parent paused past the
debounce but within the reaper window respawns immediately.
* feat(gate): post the in-path review verdict on the assembled PR
The in-path gate previously left no trace on the PR it gated — pr_pass /
pr_fail were pure status transitions. Now each verdict is posted as a
GitHub review on the assembled PR itself (server-side, bot account), so
the decision is visible on the very PR the PM merges.
- pr_pass → APPROVE, pr_fail → REQUEST_CHANGES on a cell→root PR.
- The root→master PR ALWAYS gets a plain COMMENT, never APPROVE/REQUEST_
CHANGES: only the CEO acts on master, so the gate must never leave an
approval that could satisfy branch protection (letting someone else
merge) nor a blocking review that could impede the CEO's merge.
- Best-effort and AFTER the DB transition — a GitHub failure is logged,
never rolls back the gate decision. Reuses git.post_pr_review's existing
self-review→COMMENT downgrade for the org's own PRs.
Adds _project_slug_for to the ChoreographerHelpers protocol (mypy) and a
unit suite covering event selection, the master-bound COMMENT rule, the
no-PR skip, and failure-swallowing. Docs updated (pr-reviewer, task-tools).
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
890 lines
31 KiB
Python
890 lines
31 KiB
Python
"""Tests for PM Choreographer methods.
|
|
|
|
Covers: triage, triage_all, unblock, complete, escalate_up.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
|
|
|
|
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|
base = {
|
|
"task": AsyncMock(),
|
|
"work_session": AsyncMock(),
|
|
"git": AsyncMock(),
|
|
"a2a": AsyncMock(),
|
|
"journal": AsyncMock(),
|
|
"audit": AsyncMock(),
|
|
"evidence_repo": AsyncMock(),
|
|
}
|
|
base.update(overrides)
|
|
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 = []
|
|
# 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)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_triage_returns_blocked_first() -> None:
|
|
pm_id = uuid4()
|
|
blocked_task = MagicMock(id=uuid4(), status="blocked", title="b", team="backend")
|
|
pending_task = MagicMock(
|
|
id=uuid4(), status="awaiting_pm_review", title="p", team="backend"
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
|
|
task_svc.list_blocked_for_team.return_value = [blocked_task]
|
|
task_svc.list_awaiting_pm_review_for_team.return_value = [pending_task]
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.triage(pm_id)
|
|
body = env.as_dict()
|
|
assert body["task_id"] == str(blocked_task.id)
|
|
assert "unblock" in body["next"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_triage_returns_awaiting_review_when_no_blocked() -> None:
|
|
pm_id = uuid4()
|
|
pending_task = MagicMock(id=uuid4(), status="awaiting_pm_review", team="backend")
|
|
task_svc = AsyncMock()
|
|
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
|
|
task_svc.list_blocked_for_team.return_value = []
|
|
task_svc.list_awaiting_pm_review_for_team.return_value = [pending_task]
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.triage(pm_id)
|
|
body = env.as_dict()
|
|
assert body["task_id"] == str(pending_task.id)
|
|
assert "complete" in body["next"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_triage_returns_idle_when_no_work() -> None:
|
|
pm_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
|
|
task_svc.list_blocked_for_team.return_value = []
|
|
task_svc.list_awaiting_pm_review_for_team.return_value = []
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.triage(pm_id)
|
|
body = env.as_dict()
|
|
assert body["status"] == "idle"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_triage_all_includes_cross_team() -> None:
|
|
pm_id = uuid4()
|
|
blocked = MagicMock(id=uuid4(), status="blocked", team="backend", title="x")
|
|
task_svc = AsyncMock()
|
|
task_svc.list_blocked_all_teams.return_value = [blocked]
|
|
task_svc.list_awaiting_main_pm_all.return_value = []
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.triage_all(pm_id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None
|
|
assert body["task_id"] == str(blocked.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_triage_all_returns_idle() -> None:
|
|
pm_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.list_blocked_all_teams.return_value = []
|
|
task_svc.list_awaiting_main_pm_all.return_value = []
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.triage_all(pm_id)
|
|
assert env.status == "idle"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_restores_pre_block_state() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="blocked",
|
|
pre_block_state="awaiting_documentation",
|
|
pre_block_assignee=uuid4(),
|
|
pre_block_metadata={"some_field": "x"},
|
|
)
|
|
after = MagicMock(
|
|
**{
|
|
**t.__dict__,
|
|
"status": "awaiting_documentation",
|
|
"assigned_to": t.pre_block_assignee,
|
|
},
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.unblock_with_restore.return_value = after
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.unblock(pm_id, task_id, restore=True)
|
|
assert env.error is None
|
|
assert env.status == "awaiting_documentation"
|
|
task_svc.unblock_with_restore.assert_awaited_once_with(pm_id, task_id, restore=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_default_restores() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="blocked",
|
|
pre_block_state="awaiting_qa",
|
|
pre_block_assignee=uuid4(),
|
|
pre_block_metadata={},
|
|
)
|
|
after = MagicMock(**{**t.__dict__, "status": "awaiting_qa"})
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.unblock_with_restore.return_value = after
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
# restore omitted -> defaults to True
|
|
env = await c.unblock(pm_id, task_id)
|
|
assert env.status == "awaiting_qa"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_blocks_without_journal_decision() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(id=task_id, status="blocked")
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = False
|
|
journal_svc.latest_decision_at.return_value = None
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.unblock(pm_id, task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] == "tracing_gap"
|
|
assert "journal:decision" in body["missing"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_wrong_state_returns_invalid_state() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(id=task_id, status="in_progress")
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.unblock(pm_id, task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] == "invalid_state"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_restore_false_returns_legacy_message() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="blocked",
|
|
pre_block_state="awaiting_qa",
|
|
pre_block_assignee=uuid4(),
|
|
pre_block_metadata={},
|
|
)
|
|
after = MagicMock(**{**t.__dict__, "status": "in_progress"})
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.unblock_with_restore.return_value = after
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.unblock(pm_id, task_id, restore=False)
|
|
body = env.as_dict()
|
|
assert body["status"] == "in_progress"
|
|
assert "re-engage" in body["next"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_refused_while_a_dependency_is_unfinished() -> None:
|
|
"""A dependency block can't be force-cleared by a PM.
|
|
|
|
It auto-clears via _unblock_dependents once the upstream completes; manual
|
|
unblock would let the dependent proceed without the upstream's work.
|
|
"""
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
dep_id = uuid4()
|
|
t = MagicMock(id=task_id, status="blocked", dependency_ids=[dep_id])
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.unmet_dependency_ids.return_value = [dep_id]
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.unblock(pm_id, task_id)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "invalid_state"
|
|
assert "depends on" in body["message"]
|
|
# The task must not have been advanced out of blocked.
|
|
task_svc.unblock_with_restore.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_complete_merges_then_completes() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
parent_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=pm_id,
|
|
pr_number=8,
|
|
branch_name="feature/backend/abc--def",
|
|
parent_task_id=parent_id,
|
|
team="backend",
|
|
)
|
|
after = MagicMock(**{**t.__dict__, "status": "completed"})
|
|
# #181/#182: the merge target is the PARENT task's real branch_name —
|
|
# here under a DIFFERENT team prefix, which the old parent_branch_for
|
|
# would have mis-derived as feature/backend/abc.
|
|
parent = MagicMock(
|
|
id=parent_id, branch_name="feature/main_pm/abc", parent_task_id=None
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else t
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
task_svc.cell_pm_complete.return_value = after
|
|
git_svc = AsyncMock()
|
|
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "merge-abc"}
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.cell_pm_complete(pm_id, task_id, notes="reviewed and approved")
|
|
assert env.error is None
|
|
assert env.status == "completed"
|
|
git_svc.pr_merge.assert_awaited_once_with(
|
|
8, target="feature/main_pm/abc", actor_agent_id=pm_id
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_complete_blocks_if_subtasks_unfinished() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=pm_id,
|
|
parent_task_id=uuid4(),
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.all_subtasks_terminal.return_value = False
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.cell_pm_complete(
|
|
pm_id, task_id, notes="reviewed cell scope and approved merge"
|
|
)
|
|
body = env.as_dict()
|
|
assert body["error"] == "tracing_gap"
|
|
assert "subtasks" in str(body["missing"]).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_complete_requires_journal_decision() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=pm_id,
|
|
parent_task_id=uuid4(),
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = False
|
|
journal_svc.latest_decision_at.return_value = None
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.cell_pm_complete(pm_id, task_id, notes="x")
|
|
body = env.as_dict()
|
|
assert body["error"] == "tracing_gap"
|
|
assert "journal:decision" in body["missing"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_complete_no_pr_returns_invalid_state() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=pm_id,
|
|
pr_number=None,
|
|
branch_name="feature/backend/abc--def",
|
|
parent_task_id=uuid4(),
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.cell_pm_complete(
|
|
pm_id, task_id, notes="reviewed cell scope and approved merge"
|
|
)
|
|
assert env.as_dict()["error"] == "invalid_state"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_complete_not_assigned_returns_not_authorized() -> None:
|
|
pm_id = uuid4()
|
|
other = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(id=task_id, status="awaiting_pm_review", assigned_to=other)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.cell_pm_complete(pm_id, task_id, notes="x")
|
|
assert env.as_dict()["error"] == "not_authorized"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_complete_in_progress_steers_to_submit_up() -> None:
|
|
"""Mirror of the main-PM submit_root steer: a cell task still in_progress
|
|
must enter the gate via submit_up first. The rejection must NAME submit_up
|
|
so the cell PM isn't left guessing the verb (the gap that deadlocked the
|
|
main PM on submit_root)."""
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="in_progress",
|
|
assigned_to=pm_id,
|
|
parent_task_id=uuid4(),
|
|
branch_name="feature/backend/parent123",
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.cell_pm_complete(
|
|
pm_id, task_id, notes="cell scope assembled; ready to bubble up"
|
|
)
|
|
assert env.error is not None
|
|
assert "submit_up" in (env.remediate or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_complete_escalates_code_root_without_reopening_pr() -> None:
|
|
"""A code root reaches main_pm_complete already in awaiting_pm_review —
|
|
submit_root opened the root→master PR and the main reviewer pr_passed it —
|
|
so complete just escalates to the CEO and does NOT reopen the PR."""
|
|
main_pm_id = uuid4()
|
|
root_task_id = uuid4()
|
|
t = MagicMock(
|
|
id=root_task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=main_pm_id,
|
|
pr_number=99,
|
|
branch_name="feature/backend/root123",
|
|
parent_task_id=None,
|
|
team="backend",
|
|
)
|
|
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.escalate_to_ceo.return_value = after
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
git_svc = AsyncMock()
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.main_pm_complete(
|
|
main_pm_id, root_task_id, notes="root scope reviewed and ready for production"
|
|
)
|
|
assert env.error is None
|
|
assert env.status == "awaiting_ceo_approval"
|
|
git_svc.create_pr.assert_not_awaited()
|
|
task_svc.escalate_to_ceo.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_complete_rejects_in_progress_code_root_toward_submit_root() -> (
|
|
None
|
|
):
|
|
"""A code root must pass the in-path gate first. main_pm_complete rejects it
|
|
while still in_progress and points the Main PM at submit_root."""
|
|
main_pm_id = uuid4()
|
|
root_task_id = uuid4()
|
|
t = MagicMock(
|
|
id=root_task_id,
|
|
status="in_progress",
|
|
assigned_to=main_pm_id,
|
|
pr_number=None,
|
|
branch_name="feature/main_pm/root123",
|
|
parent_task_id=None,
|
|
team="main_pm",
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
git_svc = AsyncMock()
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.main_pm_complete(
|
|
main_pm_id, root_task_id, notes="root scope reviewed; ready for CEO sign-off"
|
|
)
|
|
assert env.error is not None
|
|
assert "submit_root" in (env.remediate or "")
|
|
task_svc.escalate_to_ceo.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_root_accepts_main_pm_and_enters_the_gate() -> None:
|
|
"""submit_root is the Main PM's entry to the in-path gate. It reuses the
|
|
cell PM's _submit_up_guard for ownership/state, so the ownership guard must
|
|
NOT hardcode-reject main_pm — otherwise submit_root and complete point at
|
|
each other and a code root can never close (the circular-rejection bug)."""
|
|
main_pm_id = uuid4()
|
|
root_task_id = uuid4()
|
|
in_prog = MagicMock(
|
|
id=root_task_id,
|
|
status="in_progress",
|
|
assigned_to=main_pm_id,
|
|
pr_number=None,
|
|
branch_name="feature/main_pm/root123",
|
|
parent_task_id=None,
|
|
team="main_pm",
|
|
)
|
|
gated = MagicMock(**{**in_prog.__dict__, "status": "awaiting_pr_review"})
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = in_prog
|
|
task_svc.submit_for_review.return_value = gated
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
task_svc.uncovered_parent_acceptance_criteria.return_value = []
|
|
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
|
|
task_svc.session.begin_nested = MagicMock(
|
|
return_value=MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock())
|
|
)
|
|
git_svc = AsyncMock()
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.submit_root(
|
|
main_pm_id, root_task_id, notes="root scope assembled; opening root→master PR"
|
|
)
|
|
assert env.error is None, env.as_dict()
|
|
assert env.status == "awaiting_pr_review"
|
|
task_svc.submit_for_review.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_complete_walks_branchless_coordination_root_to_ceo() -> None:
|
|
"""A branchless coordination root (product fan-out, no repo/PR) skips the
|
|
in-path gate: main_pm_complete walks it in_progress→awaiting_pm_review and
|
|
escalates to the CEO. No root→master PR is created (it has no branch)."""
|
|
main_pm_id = uuid4()
|
|
root_task_id = uuid4()
|
|
in_prog = MagicMock(
|
|
id=root_task_id,
|
|
status="in_progress",
|
|
assigned_to=main_pm_id,
|
|
pr_number=None,
|
|
branch_name=None,
|
|
parent_task_id=None,
|
|
team="main_pm",
|
|
)
|
|
awaiting = MagicMock(**{**in_prog.__dict__, "status": "awaiting_pm_review"})
|
|
after = MagicMock(**{**in_prog.__dict__, "status": "awaiting_ceo_approval"})
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = in_prog
|
|
task_svc.submit_pm_review.return_value = awaiting
|
|
task_svc.escalate_to_ceo.return_value = after
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
git_svc = AsyncMock()
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.main_pm_complete(
|
|
main_pm_id, root_task_id, notes="coordination root reviewed; ready for CEO"
|
|
)
|
|
assert env.error is None
|
|
assert env.status == "awaiting_ceo_approval"
|
|
git_svc.create_pr.assert_not_awaited()
|
|
task_svc.submit_pm_review.assert_awaited_once()
|
|
task_svc.escalate_to_ceo.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_complete_skips_pr_creation_if_already_master_targeted() -> None:
|
|
main_pm_id = uuid4()
|
|
root_task_id = uuid4()
|
|
t = MagicMock(
|
|
id=root_task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=main_pm_id,
|
|
pr_number=42,
|
|
branch_name="feature/backend/root123",
|
|
parent_task_id=None,
|
|
team="backend",
|
|
)
|
|
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.escalate_to_ceo.return_value = after
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
git_svc = AsyncMock()
|
|
git_svc.pr_target.return_value = "master"
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
await c.main_pm_complete(
|
|
main_pm_id, root_task_id, notes="root scope reviewed and ready"
|
|
)
|
|
git_svc.create_pr.assert_not_awaited()
|
|
task_svc.escalate_to_ceo.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_complete_rejects_non_root_task() -> None:
|
|
main_pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=main_pm_id,
|
|
parent_task_id=uuid4(), # has parent -> not a root task
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.main_pm_complete(main_pm_id, task_id, notes="x")
|
|
body = env.as_dict()
|
|
assert body["error"] == "invalid_state"
|
|
assert "root tasks" in body["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_complete_blocks_unfinished_subtasks() -> None:
|
|
main_pm_id = uuid4()
|
|
root_task_id = uuid4()
|
|
t = MagicMock(
|
|
id=root_task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=main_pm_id,
|
|
parent_task_id=None,
|
|
team="backend",
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.all_subtasks_terminal.return_value = False
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.main_pm_complete(main_pm_id, root_task_id, notes="x")
|
|
body = env.as_dict()
|
|
assert body["error"] == "tracing_gap"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_dispatches_cell_pm() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(
|
|
id=task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=pm_id,
|
|
parent_task_id=uuid4(),
|
|
pr_number=8,
|
|
branch_name="feature/backend/abc--def",
|
|
team="backend",
|
|
)
|
|
after = MagicMock(**{**t.__dict__, "status": "completed"})
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
task_svc.cell_pm_complete.return_value = after
|
|
git_svc = AsyncMock()
|
|
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "x"}
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.complete(
|
|
pm_id, task_id, notes="cell scope reviewed and approved for merge"
|
|
)
|
|
assert env.status == "completed"
|
|
task_svc.cell_pm_complete.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_dispatches_main_pm() -> None:
|
|
main_pm_id = uuid4()
|
|
root_task_id = uuid4()
|
|
t = MagicMock(
|
|
id=root_task_id,
|
|
status="awaiting_pm_review",
|
|
assigned_to=main_pm_id,
|
|
pr_number=None,
|
|
branch_name="feature/backend/root123",
|
|
parent_task_id=None,
|
|
team="backend",
|
|
)
|
|
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="main_pm")
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
task_svc.escalate_to_ceo.return_value = after
|
|
git_svc = AsyncMock()
|
|
git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "x"}
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.complete(
|
|
main_pm_id, root_task_id, notes="root scope reviewed and ready"
|
|
)
|
|
assert env.status == "awaiting_ceo_approval"
|
|
task_svc.escalate_to_ceo.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_rejects_non_pm_role() -> None:
|
|
dev_id = uuid4()
|
|
task_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.agent_for.return_value = MagicMock(role="developer")
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.complete(dev_id, task_id, notes="x")
|
|
body = env.as_dict()
|
|
assert body["error"] == "not_authorized"
|
|
assert "cell_pm" in body["remediate"] and "main_pm" in body["remediate"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_up_routes_by_escalation_target() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(id=task_id, status="blocked", assigned_to=pm_id, team="backend")
|
|
after = MagicMock(**{**t.__dict__, "assigned_to": uuid4()})
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
role="cell_pm",
|
|
escalation_target="main-pm",
|
|
)
|
|
task_svc.escalate.return_value = after
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.escalate_up(pm_id, task_id, reason="cross-cell coordination needed")
|
|
assert env.error is None
|
|
task_svc.escalate.assert_awaited_once_with(
|
|
pm_id,
|
|
task_id,
|
|
"cross-cell coordination needed",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_up_returns_invalid_state_when_target_lookup_fails() -> None:
|
|
"""Regression: escalate_up_to_role returning None used to crash on t.status."""
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(id=task_id, status="blocked", assigned_to=pm_id, team="backend")
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
role="cell_pm",
|
|
escalation_target="main-pm",
|
|
)
|
|
task_svc.escalate.return_value = None # target slug not found in DB
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.escalate_up(pm_id, task_id, reason="x")
|
|
body = env.as_dict()
|
|
assert body["error"] == "invalid_state"
|
|
assert "main-pm" in body["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_up_blocks_without_journal_decision() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(id=task_id, status="blocked")
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
# Spec gate runs before the journal:decision preflight; provide a
|
|
# valid PM role so the gate passes and the preflight is the
|
|
# load-bearing rejector.
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
role="cell_pm", escalation_target="main-pm"
|
|
)
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = False
|
|
journal_svc.latest_decision_at.return_value = None
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.escalate_up(pm_id, task_id, reason="x")
|
|
body = env.as_dict()
|
|
assert body["error"] == "tracing_gap"
|
|
assert "journal:decision" in body["missing"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_up_no_target_returns_invalid_state() -> None:
|
|
"""Verb-specific preflight: PM whose escalation_target is unconfigured.
|
|
|
|
The spec allows cell_pm/main_pm to call escalate_up regardless of
|
|
target slug presence (target metadata lives on the agent record, not
|
|
the lifecycle). The verb body's preflight is what surfaces the
|
|
invalid_state when no target is configured.
|
|
"""
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
t = MagicMock(id=task_id, status="blocked")
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
role="cell_pm",
|
|
escalation_target=None,
|
|
)
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.escalate_up(pm_id, task_id, reason="x")
|
|
body = env.as_dict()
|
|
assert body["error"] == "invalid_state"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_up_task_not_found() -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = None
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.escalate_up(pm_id, task_id, reason="x")
|
|
assert env.as_dict()["error"] == "not_found"
|