mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(gateway): push the branch before QA handoff so reviewers see the latest commits The commit content tool commits locally without pushing; only open_pr pushed the branch. On the first submission that was fine, but a fix committed while addressing needs_revision never reached origin (open_pr is skipped once the PR exists), so QA — which reviews the remote PR branch — re-reviewed the stale remote and re-failed the task on every cycle, a loop that never converged. i_am_done now pushes the task branch (idempotent; a no-op when nothing is unpushed) as part of the shared submit gate, covering both the normal and resume-from-verifying paths. A push failure blocks the handoff with a clear remediation rather than parking the task in awaiting_qa with commits that exist only in the developer's local workspace. * fix(orchestrator): don't reap a stale claim while the agent's container is alive The stale-claim reaper released any claimed/in_progress task whose last_heartbeat_at exceeded the TTL. The heartbeat only updates on certain gateway calls, so a developer deep in a long edit/test cycle outran the TTL and had its claim reaped mid-work — churning the task and risking a double spawn against the still-running container. The reaper now skips a task whose assignee still holds a live (ACTIVE) agent instance, trusting container liveness — the ground truth — over the heartbeat proxy. The check is defensive on missing fields so a heartbeat-only caller (and the reaper's existing unit tests) behave exactly as before. * fix(gateway): refuse to unblock a task while a dependency is unfinished A PM unblock on a dependency-gated task moved it straight to in_progress, overriding the dependency — letting a dependent proceed without its upstream's work (e.g. a frontend task built before its UX design lands). A dependency block is meant to clear on its own via _unblock_dependents the moment the upstream reaches a terminal state. unblock now refuses while any dependency is still non-terminal, returning a clear remediation that the block resolves automatically. Manual unblock remains available for genuine, non-dependency blockers. * fix(gateway): release a dependency-blocked claim to pending instead of looping A task that reached claimed/in_progress with an unfinished dependency was left in that state when the claim guard rejected, so the orchestrator's respawn loop kept reviving its assignee — which could make no progress — burning work for nothing. The claim guard now releases such a task back to pending. claimed -> blocked is not a legal transition, so pending — held by the dispatch dependency filter — is the lifecycle-correct resting state: the respawn loop ignores pending tasks, and _unblock_dependents re-dispatches it once the upstream reaches a terminal state. release_dependency_blocked_claim shares a _force_unclaim_to_pending core with unclaim_for_reaper so both record a truthful work-session abandon reason. * feat(security): warn at startup in header-trust mode + document the auth posture When ROBOCO_AGENT_AUTH_REQUIRED is not enabled the API accepts the X-Agent-Id / X-Agent-Role headers without a signed token, so any client that can reach it may act as any role (including 'ceo'). The API now logs a clear warning at startup in this mode, and the README gains a Security section documenting the auth posture and how to harden it. Acceptable only on a trusted private network — do not expose the API to untrusted networks. * fix(workspace): scope the refresh fetch to current + default branch ensure_workspace's healthy short-circuit ran an all-refs 'git fetch origin' to keep every origin/<branch> ref current. On a monorepo with many accumulated feature/* branches that exceeds the refresh timeout, the fetch silently fails, and the workspace keeps a stale base — so an agent builds on an out-of-date branch. The refresh now fetches only the workspace's current branch and the repo's default branch (resolved via origin/HEAD), with --no-tags --prune: it transfers near-nothing and can't time out. Readers need their own branch and the default; the integration branch is refreshed at branch-creation time. * fix(git): refresh a dependency-blocked task's branch off the current integration tip A cross-cell dependent (e.g. a frontend task waiting on the UX design) was branched off a base captured before its upstream merged into the integration branch, and the branch was never re-synced — so the agent built on a stale snapshot with none of the upstream's work. Two changes close the gap: - release_dependency_blocked_claim now clears branch_name, so the re-claim (after the dependency clears) re-runs branch creation. - create_branch, when the branch is already on disk with no commits of its own, resets it onto the freshly-pulled base — the dependent now builds on the current integration tip. A branch carrying real commits is left untouched, so no work is discarded; the cell->leaf cascade carries the upstream down to the dev branch automatically. * refactor(gateway): drop the sibling-sequence claim guard Sibling sequence no longer gates a claim. Cross-cell ordering is enforced by task dependencies — a cell task that depends on another is held until its upstream reaches a terminal state, a stronger, status-aware gate than the sequence-number check. That check was dormant in practice anyway: every fan-out child carries sequence 0, on which the guard short-circuited. `sequence` stays a sibling-ordering / dispatch-priority field (list_pending ordering and the panel). Removes sibling_sequence_guard and its _earlier_blocking_sibling helper, the now-unused skip_sequence parameter threaded through the claim verbs, and the sibling fetch that fed it. * feat(gateway): sort a cross-cell dependent after its upstream When the frontend cell task is wired to depend on its UX/UI sibling, set its sequence to the upstream's sequence + 1 so it sorts after the design it waits on — list_pending ordering and the panel now show UX ahead of the implementation it gates, in either delegation order. Adds TaskService.set_sequence (the sibling-ordering field is a service write; it carries no claim-gating semantics — dependencies gate claims). * feat(gateway): make the backend cell depend on UX too UX/UI design defines the screens and API contracts both implementation cells build against, so the backend cell — not just the frontend — waits on the UX/UI cell task in a product fan-out and sorts after it. Wires in either delegation order: a backend task delegated after UX gets the dependency directly; a UX task delegated after a still-pending backend sibling retro-wires it. Mirrors the existing frontend wiring (_depend_backend_on_ux and _depend_pending_backends_on_ux). Backend is held by the same dependency gate, so it costs no extra dispatch churn. * fix(websocket): forward notification acks instead of logging them incomplete The bridge handler serves both notification.sent and notification.acked, but acked events carry `agent_id` (the acking agent) rather than `recipient_id`, so every acknowledgement tripped the missing-field guard and logged "Incomplete notification event" instead of reaching the panel. Accept either field as the recipient. * feat(api): hint the full UUID when a truncated task id fails validation Agents copy the 8-character task prefix the system shows them (the commit prefix, task summaries) and send it as task_id, which fails UUID validation with an opaque "invalid length" 422 and wastes a call. The request-validation handler now detects a task_id UUID error and attaches a `remediate` hint telling the agent to retry with the full 36-character UUID from its task envelope. * fix(audit): record the blocked transition when a task is escalated Escalation sets a task to blocked by writing task.status directly, which bypassed the validated transition helper and so never emitted a task.blocked audit row — the lifecycle moved but the Auditor saw nothing. Extract the audit emit from the central transition helper into _emit_status_transition_audit and call it from the escalate path, capturing the prior status and outgoing owner before reassignment so the row is attributed correctly. * fix(docs): stop doubling the docs path so design specs index into RAG The documenter sometimes hands a doc path already rooted at docs/, and joining it onto DOCS_BASE_PATH (/app/docs) produced /app/docs/docs/..., so the file was never found and the spec never indexed — the frontend cell could not retrieve the UX design over RAG. Normalize the path before joining: trust an absolute path, otherwise strip a single redundant leading docs/ segment. * feat(security): let the control panel authenticate in secure mode With ROBOCO_AGENT_AUTH_REQUIRED=true every request must carry a valid HMAC token, which locked the human control panel out — it sends role headers but no token. nginx, the only trusted hop between the browser and the API, now injects the CEO token on /api and /ws, so the browser never holds the signing secret. The injected value is just the existing per-agent token issued for the CEO identity (issue_panel_token), so the token-verification path is unchanged. An empty value (dev/header-trust mode) renders to no header. `make panel-token` prints the value; set it as ROBOCO_PANEL_AGENT_TOKEN in .env before enabling secure mode. .env.example and the README Security section document the flow. * chore(compose): consolidate the two compose files into one docker-compose.yml and docker-compose.yaml had diverged: .yml — the file Docker actually uses — carried ROBOCO_PUBLIC_BASE_URL but was missing the /app/manifests bind-mount, while .yaml had the manifests mount but not the base URL. Merge the union into docker-compose.yml and delete the duplicate so there is one source of truth and no "multiple config files" warning. This activates the manifests mount in the deployed file: without it the orchestrator writes per-agent tool manifests to its ephemeral container fs, they never reach the host for the daemon to bind-mount, and agents fall back to all-verbs registration. Drop the stale .yaml reference from the config.py docstring, the labeler, and the CI path filters. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
1663 lines
60 KiB
Python
1663 lines
60 KiB
Python
"""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
|
|
|
|
from datetime import UTC, datetime
|
|
from itertools import product
|
|
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
|
|
|
|
|
|
def _make_deps(task_svc=None) -> ChoreographerDeps:
|
|
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 = []
|
|
# C8: default-fresh journal:decision so PM-decision gate passes.
|
|
# Parity tests that exercise the gate boundary stub their own value.
|
|
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
|
|
return ChoreographerDeps(**base)
|
|
|
|
|
|
@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.
|
|
env = await c.i_will_work_on(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, 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
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_learning_for_task.return_value = False
|
|
journal_svc.has_struggle_for_task.return_value = False
|
|
work_svc = 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
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
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
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
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
|
|
(AWAITING_PM_REVIEW only).
|
|
|
|
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
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
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
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
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
|
|
behavioral claim guards (already_active / paused / unmet_dependency)
|
|
run after the spec gate; they're not modelled by the spec.
|
|
"""
|
|
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()
|
|
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}"
|
|
)
|