2026-05-11 02:15:47 +02:00
|
|
|
"""Tier 2 - choreographer verb output <-> spec.Decision parity.
|
|
|
|
|
|
|
|
|
|
For every (role x verb x status x task_type) combo, the choreographer's
|
|
|
|
|
envelope must match what spec.can_invoke_intent predicts. This is the
|
|
|
|
|
test that makes drift between the spec and the verb body impossible.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-05-12 06:27:33 +02:00
|
|
|
from datetime import UTC, datetime
|
2026-05-11 02:15:47 +02:00
|
|
|
from itertools import product
|
2026-06-14 13:43:46 +02:00
|
|
|
from typing import Any
|
2026-05-11 02:15:47 +02:00
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from roboco.foundation.policy import lifecycle as spec
|
|
|
|
|
from roboco.services.gateway.choreographer import (
|
|
|
|
|
Choreographer,
|
|
|
|
|
ChoreographerDeps,
|
|
|
|
|
)
|
|
|
|
|
from roboco.services.gateway.choreographer._impl import DelegateInputs
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 13:43:46 +02:00
|
|
|
def _make_deps(task_svc: Any = None) -> ChoreographerDeps:
|
2026-05-11 02:15:47 +02:00
|
|
|
base = {
|
|
|
|
|
"task": task_svc or AsyncMock(),
|
|
|
|
|
"work_session": AsyncMock(),
|
|
|
|
|
"git": AsyncMock(),
|
|
|
|
|
"a2a": AsyncMock(),
|
|
|
|
|
"journal": AsyncMock(),
|
|
|
|
|
"audit": AsyncMock(),
|
|
|
|
|
"evidence_repo": AsyncMock(),
|
|
|
|
|
}
|
|
|
|
|
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 = []
|
2026-05-12 06:27:33 +02:00
|
|
|
# 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)
|
2026-05-11 02:15:47 +02:00
|
|
|
return ChoreographerDeps(**base)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status, task_type",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
["code"],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_i_will_work_on_matches_spec(
|
|
|
|
|
role: str, status: str, task_type: str
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Every (role, status, task_type) combo: envelope.error matches spec.Decision.
|
|
|
|
|
|
|
|
|
|
Tasks in claimed/in_progress are kept assigned to a DIFFERENT agent so
|
|
|
|
|
the verb's idempotent re-entry / claimed-recovery paths do not bypass
|
|
|
|
|
the spec gate. Those re-entry paths are behavioral concerns the spec
|
|
|
|
|
does not model and are pinned by separate unit tests.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
other_agent_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type=task_type,
|
|
|
|
|
# Always assign to a different agent so the idempotent / recovery
|
|
|
|
|
# branches in the verb body (which intentionally bypass the spec
|
|
|
|
|
# gate) do not fire.
|
|
|
|
|
assigned_to=other_agent_id if status in ("claimed", "in_progress") else None,
|
|
|
|
|
plan="some plan" if status in ("in_progress",) else None,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/x",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.list_in_progress_for_agent.return_value = []
|
|
|
|
|
task_svc.list_paused_for_agent.return_value = []
|
|
|
|
|
task_svc.get_subtasks.return_value = []
|
|
|
|
|
task_svc.claim.return_value = MagicMock(
|
|
|
|
|
id=task_id, status="claimed", assigned_to=agent_id, plan=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.set_plan.return_value = MagicMock(
|
|
|
|
|
id=task_id, status="claimed", assigned_to=agent_id, plan="my plan"
|
|
|
|
|
)
|
|
|
|
|
task_svc.start.return_value = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status="in_progress",
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
plan="my plan",
|
|
|
|
|
task_type=task_type,
|
|
|
|
|
)
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(plan="my plan", actor_id=agent_id)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "i_will_work_on", task, ctx)
|
|
|
|
|
# Per-role claim authority (CLAIM_RULES) is now enforced inside
|
|
|
|
|
# spec.can_invoke_action when action == "claim", dispatched by
|
|
|
|
|
# can_invoke_intent. The verb's single gate is can_invoke_intent.
|
2026-07-26 15:07:28 +02:00
|
|
|
env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id, plan="my plan")
|
2026-05-11 02:15:47 +02:00
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Verb may still fail downstream of the gate (e.g. claim() returns
|
|
|
|
|
# None, runner exception); but the spec gate should not reject.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status} task_type={task_type}: "
|
|
|
|
|
f"spec.can_invoke_intent allowed but envelope returned "
|
|
|
|
|
f"not_authorized: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status} task_type={task_type}: "
|
|
|
|
|
f"can_invoke_intent rejected with {expected.rejection_kind}, "
|
|
|
|
|
f"got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status, task_type",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
["planning", "code"],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_i_will_plan_matches_spec(role: str, status: str, task_type: str) -> None:
|
|
|
|
|
"""Every (role, status, task_type) combo: envelope.error matches spec.Decision.
|
|
|
|
|
|
|
|
|
|
Mirror of ``test_i_will_work_on_matches_spec`` for the PM planning verb.
|
|
|
|
|
Tasks in claimed/in_progress are kept assigned to a DIFFERENT agent so
|
|
|
|
|
the verb's idempotent re-entry / claimed-recovery paths do not bypass
|
|
|
|
|
the spec gate. Those re-entry paths are behavioral concerns the spec
|
|
|
|
|
does not model and are pinned by separate unit tests.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
other_agent_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type=task_type,
|
|
|
|
|
# Always assign to a different agent so the idempotent / recovery
|
|
|
|
|
# branches in the verb body (which intentionally bypass the spec
|
|
|
|
|
# gate) do not fire.
|
|
|
|
|
assigned_to=other_agent_id if status in ("claimed", "in_progress") else None,
|
|
|
|
|
plan="some plan" if status in ("in_progress",) else None,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/x",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.list_in_progress_for_agent.return_value = []
|
|
|
|
|
task_svc.list_paused_for_agent.return_value = []
|
|
|
|
|
task_svc.get_subtasks.return_value = []
|
|
|
|
|
task_svc.claim.return_value = MagicMock(
|
|
|
|
|
id=task_id, status="claimed", assigned_to=agent_id, plan=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.set_plan.return_value = MagicMock(
|
|
|
|
|
id=task_id, status="claimed", assigned_to=agent_id, plan="my plan"
|
|
|
|
|
)
|
|
|
|
|
task_svc.start.return_value = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status="in_progress",
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
plan="my plan",
|
|
|
|
|
task_type=task_type,
|
|
|
|
|
)
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(plan="my plan", actor_id=agent_id)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "i_will_plan", task, ctx)
|
|
|
|
|
# Per-role claim authority (CLAIM_RULES) is now enforced inside
|
|
|
|
|
# spec.can_invoke_action when action == "claim", dispatched by
|
|
|
|
|
# can_invoke_intent. The verb's single gate is can_invoke_intent.
|
|
|
|
|
env = await c.i_will_plan(agent_id, task_id, plan="my plan")
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Verb may still fail downstream of the gate (e.g. claim() returns
|
|
|
|
|
# None, runner exception); but the spec gate should not reject.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status} task_type={task_type}: "
|
|
|
|
|
f"spec.can_invoke_intent allowed but envelope returned "
|
|
|
|
|
f"not_authorized: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status} task_type={task_type}: "
|
|
|
|
|
f"can_invoke_intent rejected with {expected.rejection_kind}, "
|
|
|
|
|
f"got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_delegate_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for delegate's role+state gate.
|
|
|
|
|
|
|
|
|
|
delegate composes ``create_subtask`` (PM-only, parent must be
|
|
|
|
|
in_progress). The verb body has additional gates the spec doesn't
|
|
|
|
|
model (delegation chain, assignee-vs-task_type, parent-ownership,
|
|
|
|
|
subtask cap), so this parity test only asserts the spec's role+state
|
|
|
|
|
rejection is correctly mirrored. Chain/assignee/lifecycle-guard
|
|
|
|
|
rejections are pinned by separate unit tests in
|
|
|
|
|
test_choreographer_pm_extras / test_choreographer_delegate_guards.
|
|
|
|
|
|
|
|
|
|
Inputs use a valid main_pm -> be-pm planning chain so when the spec
|
|
|
|
|
gate passes, downstream chain/assignee guards pass for main_pm; for
|
|
|
|
|
other roles the spec gate is what rejects.
|
|
|
|
|
"""
|
|
|
|
|
pm_id = uuid4()
|
|
|
|
|
parent_id = uuid4()
|
|
|
|
|
project_id = uuid4()
|
|
|
|
|
parent = MagicMock(
|
|
|
|
|
id=parent_id,
|
|
|
|
|
project_id=project_id,
|
|
|
|
|
status=status,
|
|
|
|
|
# Parent owned by the caller so _delegate_lifecycle_guards's
|
|
|
|
|
# ownership check passes when the spec gate allows.
|
|
|
|
|
assigned_to=pm_id,
|
|
|
|
|
title="parent",
|
|
|
|
|
team="backend",
|
|
|
|
|
)
|
|
|
|
|
new_task = MagicMock(id=uuid4())
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = parent
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=pm_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.get_subtasks.return_value = []
|
|
|
|
|
task_svc.create_subtask.return_value = new_task
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=pm_id)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "delegate", parent, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.delegate(
|
|
|
|
|
pm_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
DelegateInputs(
|
|
|
|
|
title="Backend planning",
|
|
|
|
|
description="Plan backend slice for feature X",
|
|
|
|
|
assigned_to="be-pm",
|
|
|
|
|
team="backend",
|
|
|
|
|
task_type="planning",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows; chain (main_pm -> be-pm planning) is also valid for
|
|
|
|
|
# role=main_pm. For other PM roles, downstream chain/assignee guards
|
|
|
|
|
# may still reject — but never with the spec's role-only message.
|
|
|
|
|
spec_role_msg = f"role '{role}' may not call 'delegate'"
|
|
|
|
|
assert body.get("message") != spec_role_msg, (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced the spec's role-rejection message: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status, commits_count, has_pr, owned",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
[0, 1],
|
|
|
|
|
[False, True],
|
|
|
|
|
[False, True],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_open_pr_matches_spec(
|
|
|
|
|
role: str, status: str, commits_count: int, has_pr: bool, owned: bool
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Spec parity for open_pr's role + extra-preconditions gate.
|
|
|
|
|
|
|
|
|
|
open_pr's IntentSpec has ``composes=()`` — it's a side-effect-only
|
|
|
|
|
verb (push_branch + create_pr). The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role in _DEV_ROLES (DEVELOPER only),
|
|
|
|
|
- PRECONDITION_OWNERSHIP (task.assigned_to == ctx.actor_id),
|
|
|
|
|
- PRECONDITION_COMMITS (>=1 commit),
|
|
|
|
|
- PRECONDITION_NO_PR (pr_number is None).
|
|
|
|
|
|
|
|
|
|
The verb's idempotent re-entry path (owner + pr_number set returns OK)
|
|
|
|
|
intentionally bypasses the spec gate, since the spec would otherwise
|
|
|
|
|
reject with `tracing_gap` on `no_prior_pr`. That branch is pinned by
|
|
|
|
|
test_choreographer_dev / test_open_pr unit tests; here we exercise
|
|
|
|
|
the non-idempotent combos so the spec gate is the load-bearing check.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
other_agent_id = uuid4()
|
|
|
|
|
assigned_to = agent_id if owned else other_agent_id
|
|
|
|
|
# Skip the verb's idempotent shortcut: owner-with-PR returns OK
|
|
|
|
|
# without invoking the spec gate. That's a behavioral concession the
|
|
|
|
|
# spec doesn't model, pinned by separate unit tests.
|
|
|
|
|
if owned and has_pr:
|
|
|
|
|
pytest.skip("idempotent re-entry path bypasses spec gate by design")
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
assigned_to=assigned_to,
|
|
|
|
|
commits=[{"sha": f"abc{i}"} for i in range(commits_count)],
|
|
|
|
|
pr_number=7 if has_pr else None,
|
|
|
|
|
pr_url="https://gh/x/7" if has_pr else None,
|
|
|
|
|
branch_name="feature/backend/abc12345",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
git_svc = AsyncMock()
|
|
|
|
|
git_svc.push_branch.return_value = ("feature/backend/abc12345", 1)
|
|
|
|
|
git_svc.create_pr.return_value = {
|
|
|
|
|
"pr_number": 42,
|
|
|
|
|
"pr_url": "https://gh/x/42",
|
|
|
|
|
"is_root_pr": False,
|
|
|
|
|
}
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
deps = ChoreographerDeps(
|
|
|
|
|
task=task_svc,
|
|
|
|
|
work_session=deps.work_session,
|
|
|
|
|
git=git_svc,
|
|
|
|
|
a2a=deps.a2a,
|
|
|
|
|
journal=deps.journal,
|
|
|
|
|
audit=deps.audit,
|
|
|
|
|
evidence_repo=deps.evidence_repo,
|
|
|
|
|
)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "open_pr", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.open_pr(agent_id, task_id)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb may still surface a non-error envelope (OK)
|
|
|
|
|
# OR a downstream invalid_state if the runner hits an exception
|
|
|
|
|
# we didn't fully wire in this test mock. The spec gate itself
|
|
|
|
|
# must NOT be the source of any not_authorized / tracing_gap.
|
|
|
|
|
assert body["error"] not in ("not_authorized", "tracing_gap"), (
|
|
|
|
|
f"role={role} status={status} commits={commits_count} "
|
|
|
|
|
f"has_pr={has_pr} owned={owned}: spec.can_invoke_intent "
|
|
|
|
|
f"allowed but envelope rejected at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status} commits={commits_count} "
|
|
|
|
|
f"has_pr={has_pr} owned={owned}: can_invoke_intent rejected "
|
|
|
|
|
f"with {expected.rejection_kind}, got {body['error']!r}; "
|
|
|
|
|
f"full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status, commits_count, owned",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
[0, 1],
|
|
|
|
|
[False, True],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_i_am_done_matches_spec(
|
|
|
|
|
role: str, status: str, commits_count: int, owned: bool
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Spec parity for i_am_done's role + extra-preconditions gate.
|
|
|
|
|
|
|
|
|
|
i_am_done's IntentSpec composes ``(submit_verification, submit_qa)``
|
|
|
|
|
with ``extra_preconditions=(PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS)``.
|
|
|
|
|
The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role in _DEV_ROLES (DEVELOPER only),
|
|
|
|
|
- PRECONDITION_OWNERSHIP (task.assigned_to == ctx.actor_id),
|
|
|
|
|
- PRECONDITION_COMMITS (>=1 commit),
|
|
|
|
|
- first composed action submit_verification's source_status (IN_PROGRESS).
|
|
|
|
|
|
|
|
|
|
The verb's recovery branch (owner + status==verifying runs submit_qa
|
|
|
|
|
directly, bypassing the spec gate) intentionally short-circuits — the
|
|
|
|
|
spec doesn't model partial-progress recovery. We skip that single combo
|
|
|
|
|
so the parity check is honest about what the spec gate evaluates.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
other_agent_id = uuid4()
|
|
|
|
|
assigned_to = agent_id if owned else other_agent_id
|
|
|
|
|
# Skip the verb's recovery shortcut: owner-with-status==verifying runs
|
|
|
|
|
# submit_qa directly without invoking the spec gate. Pinned by separate
|
|
|
|
|
# unit tests; here we exercise the non-recovery combos so the spec gate
|
|
|
|
|
# is the load-bearing check.
|
|
|
|
|
if owned and status == "verifying":
|
|
|
|
|
pytest.skip("recovery re-entry path bypasses spec gate by design")
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
assigned_to=assigned_to,
|
|
|
|
|
commits=[{"sha": f"abc{i}"} for i in range(commits_count)],
|
|
|
|
|
pr_number=7,
|
|
|
|
|
pr_url="https://gh/x/7",
|
|
|
|
|
branch_name="feature/backend/abc12345",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
# Tracing-gate fields satisfied so a downstream tracing_gap doesn't
|
|
|
|
|
# mask the spec-layer outcome on the allowed branch.
|
|
|
|
|
plan={"x": 1},
|
|
|
|
|
progress_updates=[{"message": "p"}],
|
|
|
|
|
acceptance_criteria=[],
|
|
|
|
|
acceptance_criteria_status=[],
|
|
|
|
|
documents=[],
|
|
|
|
|
dev_notes="",
|
|
|
|
|
work_session_id=None,
|
|
|
|
|
self_verified=False,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.submit_verification.return_value = MagicMock(
|
|
|
|
|
id=task_id, status="verifying", assigned_to=agent_id
|
|
|
|
|
)
|
|
|
|
|
task_svc.submit_qa.return_value = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status="awaiting_qa",
|
|
|
|
|
assigned_to=None,
|
|
|
|
|
team="backend",
|
|
|
|
|
pr_url="https://gh/x/7",
|
|
|
|
|
work_session_id=None,
|
|
|
|
|
)
|
|
|
|
|
task_svc.qa_agent_for_team.return_value = None
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
journal_svc = deps.journal
|
|
|
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
|
|
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
|
|
|
|
journal_svc.has_decision_for_task.return_value = True
|
2026-05-12 06:27:33 +02:00
|
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
2026-05-11 02:15:47 +02:00
|
|
|
journal_svc.has_learning_for_task.return_value = False
|
|
|
|
|
journal_svc.has_struggle_for_task.return_value = False
|
|
|
|
|
work_svc = deps.work_session
|
|
|
|
|
work_svc.files_changed.return_value = []
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id, notes="done")
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "i_am_done", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.i_am_done(agent_id, task_id, "done")
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb may still surface a non-error envelope (OK)
|
|
|
|
|
# OR a downstream tracing_gap from defense-in-depth gates (PR/commits/
|
|
|
|
|
# progress) — but the spec gate itself must NOT be the source of any
|
|
|
|
|
# not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status} commits={commits_count} "
|
|
|
|
|
f"owned={owned}: spec.can_invoke_intent allowed but envelope "
|
|
|
|
|
f"surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status} commits={commits_count} "
|
|
|
|
|
f"owned={owned}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_i_am_blocked_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for i_am_blocked's role + state gate.
|
|
|
|
|
|
|
|
|
|
i_am_blocked's IntentSpec composes ``(block,)`` with no
|
|
|
|
|
``extra_preconditions``. The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role in (_DEV_ROLES | _QA_ROLES | _DOC_ROLES),
|
|
|
|
|
- composed ``block`` action's source_status (IN_PROGRESS only).
|
|
|
|
|
|
|
|
|
|
The verb body has no idempotent / recovery short-circuits, so every
|
|
|
|
|
combo flows through the spec gate. The journal:struggle write is a
|
|
|
|
|
side effect outside the lifecycle action and lives in the verb body
|
|
|
|
|
after the spec gate accepts; it does not affect parity outcomes.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
# Ownership doesn't gate i_am_blocked (no PRECONDITION_OWNERSHIP),
|
|
|
|
|
# but assigning the task to the caller keeps the downstream
|
|
|
|
|
# task_service.escalate mock realistic.
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/x",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
pre_block_state=None,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.escalate.return_value = MagicMock(
|
|
|
|
|
id=task_id, status="blocked", assigned_to=agent_id
|
|
|
|
|
)
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id, notes="external API down")
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "i_am_blocked", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.i_am_blocked(agent_id, task_id, "external API down")
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb may still surface a non-error envelope (OK)
|
|
|
|
|
# or a downstream invalid_state if the runner hits an exception
|
|
|
|
|
# we didn't fully wire in this test mock. The spec gate itself
|
|
|
|
|
# must NOT be the source of any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_unclaim_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for unclaim's role gate.
|
|
|
|
|
|
|
|
|
|
unclaim's IntentSpec has ``composes=()`` — no atomic action runs, so
|
|
|
|
|
the spec gate enforces only role membership (no source-status
|
|
|
|
|
constraint). The verb body owns dispatch via
|
|
|
|
|
``task.unclaim_for_agent``; the service-level guard refuses with
|
|
|
|
|
None when the status isn't claimed/in_progress, surfacing as
|
|
|
|
|
invalid_state from the verb body. That service-layer rejection is
|
|
|
|
|
NOT the spec's concern.
|
|
|
|
|
|
|
|
|
|
Tasks are kept ``assigned_to=agent_id`` so the verb's
|
|
|
|
|
reassignment-rejection branch (Task 6 fix in commit a5d358d) does not
|
|
|
|
|
fire — that branch is a Choreographer-level guard the spec doesn't
|
|
|
|
|
model and is pinned by separate unit tests in test_unclaim.py.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
# Owned by caller so the reassignment-rejection branch does not
|
|
|
|
|
# fire; we want the spec gate to be the only rejector here.
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/x",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
# Service-level guard: returns the post-unclaim task on success, or
|
|
|
|
|
# None on state drift. For the parity test we always return a stub
|
|
|
|
|
# so the verb body's None-branch (invalid_state) doesn't mask the
|
|
|
|
|
# spec-layer outcome on the allowed branch.
|
|
|
|
|
task_svc.unclaim_for_agent.return_value = MagicMock(
|
|
|
|
|
id=task_id, status="pending", assigned_to=None
|
|
|
|
|
)
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "unclaim", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.unclaim(agent_id, task_id)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb may still surface a non-error envelope (OK)
|
|
|
|
|
# or a downstream invalid_state if unclaim_for_agent returns
|
|
|
|
|
# None — but the spec gate itself must NOT be the source of
|
|
|
|
|
# any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_resume_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for resume's role + state gate.
|
|
|
|
|
|
|
|
|
|
resume's IntentSpec composes ``("resume",)``. The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role in (_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES),
|
|
|
|
|
- composed ``resume`` action's source_status (PAUSED only).
|
|
|
|
|
|
|
|
|
|
Tasks are kept ``assigned_to=agent_id`` so the verb's
|
|
|
|
|
reassignment-rejection branch (Task 6 fix in commit a5d358d) does not
|
|
|
|
|
fire — that branch is a Choreographer-level guard the spec doesn't
|
|
|
|
|
model and is pinned by separate unit tests in test_resume.py.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
# Owned by caller so the reassignment-rejection branch does not
|
|
|
|
|
# fire; we want the spec gate to be the only rejector here.
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/x",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.resume_for_agent.return_value = MagicMock(
|
|
|
|
|
id=task_id, status="in_progress", assigned_to=agent_id
|
|
|
|
|
)
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "resume", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.resume(agent_id, task_id)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb may still surface a non-error envelope (OK)
|
|
|
|
|
# or a downstream invalid_state if the runner hits an exception
|
|
|
|
|
# we didn't fully wire in this test mock. The spec gate itself
|
|
|
|
|
# must NOT be the source of any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_complete_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for complete's role + state gate (the dispatcher layer).
|
|
|
|
|
|
|
|
|
|
complete's IntentSpec composes ``("complete",)``; the spec gate
|
|
|
|
|
enforces:
|
|
|
|
|
|
|
|
|
|
- role in _PM_ROLES (CELL_PM, MAIN_PM),
|
|
|
|
|
- composed ``complete`` action's source_status (AWAITING_PM_REVIEW only).
|
|
|
|
|
|
|
|
|
|
The dispatcher routes to ``cell_pm_complete`` / ``main_pm_complete``
|
|
|
|
|
after the gate accepts. Both lower-level methods retain their own
|
|
|
|
|
pre-flight guards (PR mergeability, journal:decision presence,
|
|
|
|
|
subtasks-terminal) — those model preconditions the spec doesn't
|
|
|
|
|
cover, and may emit non-spec rejection kinds (tracing_gap,
|
|
|
|
|
invalid_state). The parity assertion is therefore one-sided: when
|
|
|
|
|
the spec rejects, the envelope MUST surface that exact
|
|
|
|
|
rejection_kind; when the spec allows, the envelope may still be
|
|
|
|
|
rejected by a downstream guard, but the spec gate itself must NOT
|
|
|
|
|
be the source of any not_authorized rejection.
|
|
|
|
|
|
|
|
|
|
Tasks are kept ``assigned_to=agent_id`` so the lower-level guards'
|
|
|
|
|
"not assigned to you" branch does not fire on the allowed path.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
# Owned by caller so the lower-level _*_pm_complete_guard
|
|
|
|
|
# "not assigned to you" branch does not fire on the allowed
|
|
|
|
|
# path; we want the spec gate to be the only rejector here.
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=8,
|
|
|
|
|
branch_name="feature/backend/abc--def",
|
|
|
|
|
# Cell-PM path needs a parent_task_id; main-PM path needs None.
|
|
|
|
|
# Pick parent_task_id=None so main_pm_complete's own non-root
|
|
|
|
|
# guard doesn't fire on cell_pm. cell_pm_complete doesn't
|
|
|
|
|
# require parent_task_id either — _maybe_advance_parent_to_pm_review
|
|
|
|
|
# short-circuits when leaf_parent_id is None.
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
)
|
|
|
|
|
after = MagicMock(
|
|
|
|
|
id=task_id, status="completed", assigned_to=agent_id, team="backend"
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.cell_pm_complete.return_value = after
|
|
|
|
|
task_svc.escalate_to_ceo.return_value = MagicMock(
|
|
|
|
|
id=task_id, status="awaiting_ceo_approval", assigned_to=None, team="backend"
|
|
|
|
|
)
|
|
|
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
|
|
|
git_svc = AsyncMock()
|
|
|
|
|
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "x"}
|
|
|
|
|
git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "x"}
|
|
|
|
|
git_svc.pr_target.return_value = "master"
|
|
|
|
|
journal_svc = AsyncMock()
|
|
|
|
|
journal_svc.has_decision_for_task.return_value = True
|
2026-05-12 06:27:33 +02:00
|
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
2026-05-11 02:15:47 +02:00
|
|
|
deps_kwargs = {
|
|
|
|
|
"task": task_svc,
|
|
|
|
|
"work_session": AsyncMock(),
|
|
|
|
|
"git": git_svc,
|
|
|
|
|
"a2a": AsyncMock(),
|
|
|
|
|
"journal": journal_svc,
|
|
|
|
|
"audit": AsyncMock(),
|
|
|
|
|
"evidence_repo": AsyncMock(),
|
|
|
|
|
}
|
|
|
|
|
repo = deps_kwargs["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 = []
|
|
|
|
|
deps = ChoreographerDeps(**deps_kwargs)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "complete", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.complete(agent_id, task_id, notes="reviewed and approved")
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. The dispatcher routes to cell_pm_complete or
|
|
|
|
|
# main_pm_complete; downstream guards may still reject (e.g.
|
|
|
|
|
# tracing_gap on missing journal:decision), but the spec gate
|
|
|
|
|
# itself must NOT be the source of any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_escalate_up_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for escalate_up's role gate.
|
|
|
|
|
|
|
|
|
|
escalate_up's IntentSpec has ``composes=()`` — no atomic action runs,
|
|
|
|
|
so the spec gate enforces only role membership (cell_pm or main_pm),
|
|
|
|
|
no source-status constraint. The verb body owns dispatch via
|
|
|
|
|
``task.escalate(...)`` and keeps two verb-specific preflight guards
|
|
|
|
|
the spec does not model: ``journal:decision`` presence, and
|
|
|
|
|
``escalation_target`` configuration on the agent record. Both are
|
|
|
|
|
satisfied here so the spec gate is the load-bearing rejector.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
)
|
|
|
|
|
after = MagicMock(
|
|
|
|
|
id=task_id, status="blocked", assigned_to=agent_id, team="backend"
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
# escalation_target is not on the spec — set it so the verb-specific
|
|
|
|
|
# preflight does not surface a non-spec invalid_state on the allowed
|
|
|
|
|
# branch and mask the spec-layer outcome.
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None, escalation_target="main-pm"
|
|
|
|
|
)
|
|
|
|
|
task_svc.escalate.return_value = after
|
|
|
|
|
journal_svc = AsyncMock()
|
|
|
|
|
# journal:decision is not on the spec — satisfy it so the verb-specific
|
|
|
|
|
# preflight does not surface a non-spec tracing_gap on the allowed branch.
|
|
|
|
|
journal_svc.has_decision_for_task.return_value = True
|
2026-05-12 06:27:33 +02:00
|
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
2026-05-11 02:15:47 +02:00
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
deps = ChoreographerDeps(
|
|
|
|
|
task=task_svc,
|
|
|
|
|
work_session=deps.work_session,
|
|
|
|
|
git=deps.git,
|
|
|
|
|
a2a=deps.a2a,
|
|
|
|
|
journal=journal_svc,
|
|
|
|
|
audit=deps.audit,
|
|
|
|
|
evidence_repo=deps.evidence_repo,
|
|
|
|
|
)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id, notes="please help")
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "escalate_up", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.escalate_up(agent_id, task_id, reason="please help")
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb-specific preflight (journal:decision +
|
|
|
|
|
# escalation_target) is wired to pass; the verb may still surface
|
|
|
|
|
# OK or a downstream invalid_state if task.escalate returns None,
|
|
|
|
|
# but the spec gate itself must NOT be the source of any
|
|
|
|
|
# not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_escalate_to_ceo_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for escalate_to_ceo's role + state gate.
|
|
|
|
|
|
|
|
|
|
escalate_to_ceo's IntentSpec composes ``("escalate_to_ceo",)``. The
|
|
|
|
|
spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role in {main_pm, product_owner, head_marketing},
|
|
|
|
|
- composed ``escalate_to_ceo`` action's source_status
|
2026-06-08 05:17:15 +02:00
|
|
|
(AWAITING_PM_REVIEW or BLOCKED).
|
2026-05-11 02:15:47 +02:00
|
|
|
|
|
|
|
|
The verb body keeps the journal:decision preflight (the spec doesn't
|
|
|
|
|
model journal side effects); satisfied here so the spec gate is the
|
|
|
|
|
load-bearing rejector. After the runner returns the verb body
|
|
|
|
|
reassigns to None (CEO acts via UI).
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
# Ownership doesn't gate escalate_to_ceo (no PRECONDITION_OWNERSHIP).
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
)
|
|
|
|
|
after = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status="awaiting_ceo_approval",
|
|
|
|
|
assigned_to=None,
|
|
|
|
|
team="backend",
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.escalate_to_ceo.return_value = after
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
journal_svc = AsyncMock()
|
|
|
|
|
journal_svc.has_decision_for_task.return_value = True
|
2026-05-12 06:27:33 +02:00
|
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
2026-05-11 02:15:47 +02:00
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
deps = ChoreographerDeps(
|
|
|
|
|
task=task_svc,
|
|
|
|
|
work_session=deps.work_session,
|
|
|
|
|
git=deps.git,
|
|
|
|
|
a2a=deps.a2a,
|
|
|
|
|
journal=journal_svc,
|
|
|
|
|
audit=deps.audit,
|
|
|
|
|
evidence_repo=deps.evidence_repo,
|
|
|
|
|
)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id, notes="ready for CEO sign-off")
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "escalate_to_ceo", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.escalate_to_ceo(agent_id, task_id, reason="ready for CEO sign-off")
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb may still surface a non-error envelope (OK)
|
|
|
|
|
# or a downstream tracing_gap from journal:decision absence (we
|
|
|
|
|
# wire it to pass); the spec gate itself must NOT be the source
|
|
|
|
|
# of any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_submit_up_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for submit_up's role + state gate.
|
|
|
|
|
|
|
|
|
|
submit_up's IntentSpec composes ``("submit_pm_review",)`` with side
|
|
|
|
|
effects ``("create_pr",)``. The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role == cell_pm,
|
|
|
|
|
- composed ``submit_pm_review`` action's source_status
|
|
|
|
|
(IN_PROGRESS only).
|
|
|
|
|
|
|
|
|
|
The verb body keeps ``_submit_up_guard`` (ownership + notes-length +
|
|
|
|
|
journal:decision + subtasks-terminal + branch-present). All of those
|
|
|
|
|
are satisfied here so the spec gate is the load-bearing rejector.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
# Owned by caller so the verb-specific ownership preflight does
|
|
|
|
|
# not surface a non-spec rejection on the allowed branch.
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
)
|
|
|
|
|
after = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status="awaiting_pm_review",
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
team="backend",
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.submit_pm_review.return_value = after
|
|
|
|
|
task_svc.all_subtasks_terminal.return_value = True
|
|
|
|
|
task_svc.main_pm_agent.return_value = MagicMock(id=uuid4())
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
git_svc = AsyncMock()
|
|
|
|
|
git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"}
|
|
|
|
|
journal_svc = AsyncMock()
|
|
|
|
|
journal_svc.has_decision_for_task.return_value = True
|
2026-05-12 06:27:33 +02:00
|
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
2026-05-11 02:15:47 +02:00
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
deps = ChoreographerDeps(
|
|
|
|
|
task=task_svc,
|
|
|
|
|
work_session=deps.work_session,
|
|
|
|
|
git=git_svc,
|
|
|
|
|
a2a=deps.a2a,
|
|
|
|
|
journal=journal_svc,
|
|
|
|
|
audit=deps.audit,
|
|
|
|
|
evidence_repo=deps.evidence_repo,
|
|
|
|
|
)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
notes = "cell completed all subtasks; ready for main pm review"
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id, notes=notes)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "submit_up", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.submit_up(agent_id, task_id, notes=notes)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb may still surface a non-error envelope (OK)
|
|
|
|
|
# or a downstream tracing_gap from one of the _submit_up_guard
|
|
|
|
|
# preconditions (which we wire to pass); the spec gate itself
|
|
|
|
|
# must NOT be the source of any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_claim_review_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for claim_review's role + claim source-status gate.
|
|
|
|
|
|
|
|
|
|
claim_review's IntentSpec composes ``("claim", "start")`` and is
|
|
|
|
|
restricted to QA. The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role == qa,
|
|
|
|
|
- claim's source_statuses (PENDING / NEEDS_REVISION / AWAITING_QA
|
|
|
|
|
/ AWAITING_DOCUMENTATION),
|
|
|
|
|
- CLAIM_RULES narrowing (qa only allowed from PENDING / AWAITING_QA).
|
|
|
|
|
|
|
|
|
|
The verb body owns dispatch via ``task.qa_claim`` (not the runner's
|
|
|
|
|
claim+start chain) because the runtime semantic is "QA inspects,
|
|
|
|
|
status stays at awaiting_qa" — see qa.py module docstring. The
|
2026-06-05 16:35:22 +02:00
|
|
|
behavioral claim guards (already_active / paused / unmet_dependency)
|
|
|
|
|
run after the spec gate; they're not modelled by the spec.
|
2026-05-11 02:15:47 +02:00
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
assigned_to=None,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
work_session_id=None,
|
|
|
|
|
)
|
|
|
|
|
after = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
team="backend",
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
work_session_id=None,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.qa_claim.return_value = after
|
|
|
|
|
task_svc.list_in_progress_for_agent.return_value = []
|
|
|
|
|
task_svc.list_paused_for_agent.return_value = []
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "claim_review", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.claim_review(agent_id, task_id)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb may still surface a non-error envelope (OK)
|
|
|
|
|
# or a downstream behavioral guard rejection; the spec gate itself
|
|
|
|
|
# must NOT be the source of any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_pass_review_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for pass_review's role + state gate.
|
|
|
|
|
|
|
|
|
|
pass_review's IntentSpec composes ``("qa_pass",)`` and is QA-only.
|
|
|
|
|
The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role == qa,
|
|
|
|
|
- composed ``qa_pass`` action's source_status (AWAITING_QA),
|
|
|
|
|
- self-review block (qa_pass.self_review_block=True; the verb body
|
|
|
|
|
builds a Context with actor_slug + original_developer_slug so the
|
|
|
|
|
spec naturally rejects self-review).
|
|
|
|
|
|
|
|
|
|
The verb-specific gates (notes-length / journal:learning /
|
|
|
|
|
qa_evidence_inspected) live in the verb body — none are modelled by
|
|
|
|
|
the spec. They're wired to pass here so the spec gate is the load-
|
|
|
|
|
bearing rejector. ``_verify_qa_owner`` runs FIRST (before the spec
|
|
|
|
|
gate), so the parity check uses ``assigned_to=agent_id`` to keep
|
|
|
|
|
that pre-spec ownership check passing.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
# Owned by caller so _verify_qa_owner does not surface a non-spec
|
|
|
|
|
# not_authorized before the spec gate runs.
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
qa_evidence_inspected=True,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
pr_url="https://x/pr/8",
|
|
|
|
|
work_session_id=None,
|
|
|
|
|
)
|
|
|
|
|
after = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status="awaiting_documentation",
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
team="backend",
|
|
|
|
|
pr_url="https://x/pr/8",
|
|
|
|
|
qa_evidence_inspected=True,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.qa_pass.return_value = after
|
|
|
|
|
task_svc.documenter_for_team.return_value = None
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
journal_svc = deps.journal
|
|
|
|
|
journal_svc.has_learning_for_task.return_value = True
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
notes = (
|
|
|
|
|
"Reviewed PR carefully. Branch convention correct. Commit prefix "
|
|
|
|
|
"verified. README diff matches spec. All acceptance criteria met."
|
|
|
|
|
)
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id, notes=notes)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "pass_review", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.pass_review(agent_id, task_id, notes=notes)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb-specific notes-length / journal:learning /
|
|
|
|
|
# qa_evidence_inspected are wired to pass; the spec gate itself
|
|
|
|
|
# must NOT be the source of any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_fail_review_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for fail_review's role + state gate.
|
|
|
|
|
|
|
|
|
|
fail_review's IntentSpec composes ``("qa_fail",)`` and is QA-only.
|
|
|
|
|
The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role == qa,
|
|
|
|
|
- composed ``qa_fail`` action's source_status (AWAITING_QA),
|
|
|
|
|
- self-review block (qa_fail.self_review_block=True).
|
|
|
|
|
|
|
|
|
|
Same shape as pass_review: ownership precedes the spec gate, and
|
|
|
|
|
the verb-specific notes-length / journal:learning /
|
|
|
|
|
qa_evidence_inspected gates live in the verb body.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
# Owned by caller so _verify_qa_owner does not surface a non-spec
|
|
|
|
|
# not_authorized before the spec gate runs.
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
qa_evidence_inspected=True,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
work_session_id=None,
|
|
|
|
|
)
|
|
|
|
|
dev_id = uuid4()
|
|
|
|
|
after = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status="needs_revision",
|
|
|
|
|
assigned_to=dev_id,
|
|
|
|
|
team="backend",
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.qa_fail.return_value = after
|
|
|
|
|
task_svc.session = MagicMock()
|
2026-07-11 22:54:42 +02:00
|
|
|
task_svc.session.add = MagicMock()
|
|
|
|
|
task_svc.session.flush = AsyncMock()
|
2026-05-11 02:15:47 +02:00
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
journal_svc = deps.journal
|
|
|
|
|
journal_svc.has_learning_for_task.return_value = True
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
issues = [
|
|
|
|
|
"Missing unit test coverage for /healthz endpoint — add at least one",
|
|
|
|
|
"Lint errors in /api/foo.py: unused import and missing return type",
|
|
|
|
|
]
|
|
|
|
|
notes = "Issues:\n" + "\n".join(f"- {i}" for i in issues)
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id, notes=notes, issues=tuple(issues))
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "fail_review", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.fail_review(agent_id, task_id, issues=issues)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb-specific gates wired to pass; the spec gate
|
|
|
|
|
# itself must NOT be the source of any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_claim_doc_task_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for claim_doc_task's role + claim source-status gate.
|
|
|
|
|
|
|
|
|
|
claim_doc_task's IntentSpec composes ``("claim", "start")`` and is
|
|
|
|
|
restricted to documenter. The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role == documenter,
|
|
|
|
|
- claim's source_statuses,
|
|
|
|
|
- CLAIM_RULES narrowing (documenter only from PENDING /
|
|
|
|
|
AWAITING_DOCUMENTATION).
|
|
|
|
|
|
|
|
|
|
The verb body owns dispatch via ``task.doc_claim`` (not the runner's
|
|
|
|
|
claim+start chain) because the runtime semantic is "documenter
|
|
|
|
|
inspects, status stays at awaiting_documentation" — see doc.py
|
|
|
|
|
module docstring.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
assigned_to=None,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
work_session_id=None,
|
|
|
|
|
)
|
|
|
|
|
after = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
team="backend",
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
work_session_id=None,
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.doc_claim.return_value = after
|
|
|
|
|
task_svc.list_in_progress_for_agent.return_value = []
|
|
|
|
|
task_svc.list_paused_for_agent.return_value = []
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id)
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "claim_doc_task", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.claim_doc_task(agent_id, task_id)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb may still surface a non-error envelope or a
|
|
|
|
|
# downstream behavioral guard rejection; the spec gate itself
|
|
|
|
|
# must NOT be the source of any not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"role, status",
|
|
|
|
|
list(
|
|
|
|
|
product(
|
|
|
|
|
[r.value for r in spec.Role if r != spec.Role.AUDITOR],
|
|
|
|
|
[s.value for s in spec.Status],
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def test_i_documented_matches_spec(role: str, status: str) -> None:
|
|
|
|
|
"""Spec parity for i_documented's role + state gate.
|
|
|
|
|
|
|
|
|
|
i_documented's IntentSpec composes ``("docs_complete",)`` and is
|
|
|
|
|
documenter-only. The spec gate enforces:
|
|
|
|
|
|
|
|
|
|
- role == documenter,
|
|
|
|
|
- composed ``docs_complete`` action's source_status
|
|
|
|
|
(AWAITING_DOCUMENTATION),
|
|
|
|
|
- self-review block (docs_complete.self_review_block=True; the
|
|
|
|
|
verb body builds a Context with actor_slug +
|
|
|
|
|
original_developer_slug so the spec naturally rejects
|
|
|
|
|
self-review).
|
|
|
|
|
|
|
|
|
|
The verb-specific gates (notes-length / files-list) live in the
|
|
|
|
|
verb body — not modelled by the spec. They're wired to pass here so
|
|
|
|
|
the spec gate is the load-bearing rejector. ``_verify_doc_owner``
|
|
|
|
|
runs FIRST (before the spec gate), so the parity check uses
|
|
|
|
|
``assigned_to=agent_id`` to keep that pre-spec ownership check
|
|
|
|
|
passing.
|
|
|
|
|
"""
|
|
|
|
|
agent_id = uuid4()
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
task = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status=status,
|
|
|
|
|
task_type="code",
|
|
|
|
|
# Owned by caller so _verify_doc_owner does not surface a non-spec
|
|
|
|
|
# not_authorized before the spec gate runs.
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
commits=[],
|
|
|
|
|
pr_number=None,
|
|
|
|
|
branch_name="feature/backend/abc",
|
|
|
|
|
parent_task_id=None,
|
|
|
|
|
sequence=0,
|
|
|
|
|
team="backend",
|
|
|
|
|
title="t",
|
|
|
|
|
quick_context=None,
|
|
|
|
|
documents=[],
|
|
|
|
|
)
|
|
|
|
|
after = MagicMock(
|
|
|
|
|
id=task_id,
|
|
|
|
|
status="awaiting_pm_review",
|
|
|
|
|
assigned_to=agent_id,
|
|
|
|
|
team="backend",
|
|
|
|
|
)
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
|
|
|
id=agent_id, role=role, team="backend", slug=None
|
|
|
|
|
)
|
|
|
|
|
task_svc.docs_complete.return_value = after
|
|
|
|
|
task_svc.cell_pm_for_team.return_value = None
|
|
|
|
|
task_svc.session = MagicMock()
|
|
|
|
|
task_svc.session.flush = AsyncMock()
|
|
|
|
|
task_svc.session.begin_nested = MagicMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
__aenter__=AsyncMock(return_value=None),
|
|
|
|
|
__aexit__=AsyncMock(return_value=False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
deps = _make_deps(task_svc=task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
notes = "Wrote backend/guides/feature-x.md with usage examples + config notes."
|
|
|
|
|
files = ["backend/guides/feature-x.md"]
|
|
|
|
|
ctx = spec.Context(actor_id=agent_id, notes=notes, files=tuple(files))
|
|
|
|
|
expected = spec.can_invoke_intent(spec.Role(role), "i_documented", task, ctx)
|
|
|
|
|
|
|
|
|
|
env = await c.i_documented(agent_id, task_id, notes=notes, files=files)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
if expected.allowed:
|
|
|
|
|
# Spec allows. Verb-specific notes-length / files-list are wired
|
|
|
|
|
# to pass; the spec gate itself must NOT be the source of any
|
|
|
|
|
# not_authorized rejection.
|
|
|
|
|
assert body["error"] != "not_authorized", (
|
|
|
|
|
f"role={role} status={status}: spec.can_invoke_intent allowed "
|
|
|
|
|
f"but envelope surfaced not_authorized at the spec layer: {body}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
assert body["error"] == expected.rejection_kind, (
|
|
|
|
|
f"role={role} status={status}: can_invoke_intent rejected with "
|
|
|
|
|
f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}"
|
|
|
|
|
)
|