Files
roboco/tests/unit/gateway/test_choreographer_dev.py
T
b9082a5c70 Fix: agent idle deadlock and lifecycle hardening (#96)
* fix(panel): cap dialog height and pin footer so actions stay reachable

Shared DialogContent now caps at max-h-[85vh] with overflow-y-auto, and the
footer is sticky to the bottom. Long content (e.g. a pasted change-request
note) no longer pushes the submit/cancel buttons past the viewport — the body
scrolls while the actions stay visible. No-op on dialogs that already fit.

* feat(notifications): suppress duplicate same-purpose notifications at send

A notification is not created when an unacknowledged one with the same purpose
— same sender, same type, same task, overlapping recipients — already exists.
Body text is not compared, so rewording cannot defeat it; a different type,
task, sender, or an already-acked recipient all still send through. Stops
agents that loop re-issuing the same signal from piling up unread that
soft-blocks the recipient's idle path.

* fix(gateway): stop board/PM lifecycle verbs from 500-crashing

Two unguarded crashes that wedged the org in respawn/escalate loops:

- escalate_to_ceo dereferenced None.status when the verb runner declined the
  escalation (task not in awaiting_pm_review — e.g. a board agent escalating a
  blocked task). It now returns a clean invalid_state. The message/remediate
  build moved to a helper so the function stays within the complexity gate.

- The coordination-root git ops (pr_target, pr_merge, PR update, branch-token
  resolve) called UUID(str(task.project_id)) directly, which raised on a
  coordination/integration task (project_id is None — 'badly formed hexadecimal
  UUID string'). They now resolve through _project_for_task, which falls back to
  the product's repo for project-less roots.

* refactor(intake): split out _block_to_chunk per-block classifier

Extract the per-block classification from _blocks_to_chunks so each function
stays within the xenon cyclomatic-complexity gate (was rank C). Behaviour is
unchanged — verified by the existing intake_driver tests.

* feat(gateway): make the i_am_idle unread soft-block satisfiable

The soft-block on unread A2A / @mentions had no clearing path, so once those
briefing fields populated an agent could never idle — a whole-org deadlock.
Keep the guard (it is correct) and add the missing clear paths:

- New read_messages content verb (schema -> route -> handler ->
  a2a.mark_all_read -> MCP tool -> role do_tools): bulk-zeroes the caller's
  unread A2A and stamps read_at. The idle hint now points to it.
- list_unread_mentions returns UNACKED MENTION-type notifications (each @mention
  already raises one via messaging._notify_mentions) instead of raw,
  unconditional mentions, so they clear via the existing notify_ack. No schema
  migration needed.

The soft-block is now satisfiable: A2A via read_messages, mentions and
notifications via notify_ack.

* fix(tests): repair notification-dedup db.scalar mocks + prompter agent seeding

The notification send-dedup added a db.scalar() purpose-lookup to
_create_notification; the two hand-rolled _FakeDb test stubs (test_notification,
test_a2a_priority_tristate) had no scalar() method → AttributeError. Add
scalar() returning None (no duplicate) so creation proceeds.

Separately, the prompter '& Start' route tests assign the draft to a fixed
product-owner / main-pm AGENT_UUID but only seeded system + CEO, so the
assigned_to FK failed in isolation (and main-pm flaked in the full suite). Seed
both via idempotent merge() in _seed_project_and_ceo.

* fix(git): gitignore .pnpm-store + flag GH001 push rejection as permanent

A dev once committed the ~115 MB pnpm store → GitHub GH001 (>100 MB) pre-receive
reject → open_pr retry-loop. Two root fixes:
- Add .pnpm-store/ to .gitignore — an ignored dir can't be staged by any git add.
- push() restates a GH001 / file-size rejection as an unmistakable PERMANENT
  error pointing at i_am_blocked, so the agent stops blind-retrying a push that
  can never succeed (it otherwise mis-reads the raw output as a transient timeout).
  The per-verb retry cap (open_pr: 5) already bounded the burn; this ends it.

* fix(gateway): accept a PM decision note as satisfying the complete/submit_up reflect gate

A cell/main PM that wrote a fresh decision but no separate reflect note bounced
on the reflect tracing-gate indefinitely (re-confirmed live: cell PMs looped on
cell_pm_complete -> journal:reflect until reaped, burning tokens — worse because
each respawn resets the per-verb retry cap). For a PM closing/submitting a task
the decision note already documents the close; the separate reflect is the
redundant artifact weak-model PMs forget. Accept a fresh decision as satisfying
reflect for complete + submit_up — the gate still requires a decision +
substantive notes, so the close stays documented.

NOTE (enforcement tradeoff, flagged for CEO review): this intentionally relaxes
the PM complete/submit_up gate. It does NOT touch the developer i_am_done gate.

* feat(gateway): refuse i_am_idle when a PM still owns a task awaiting its review

A cell/main PM once tried to 'send work back' by DMing the developer and going
idle — but a DM changes no task state, so the task stayed awaiting_pm_review and
the orchestrator just re-dispatched the PM in a loop. i_am_idle now refuses (like
the pending-assignment guard) when a PM owns an awaiting_pm_review task, with a
clear remediation: complete() to finish, or reassign()/delegate() to route it
back. PM-only; devs/QA/doc unaffected. Pairs with the reflect-gate relaxation so
the PM can actually complete instead of looping.

* feat(gateway): push a prior-work handoff digest into task-scoped briefings

A freshly spawned or respawned agent previously started cold on every
lifecycle hand-off: the prior worker's PR, commits, acceptance status and
journal highlights lived in task evidence but were pull-on-demand, so each
new role agent re-explored the codebase from scratch — wasted tokens and
fragile context loss across respawns.

build_task_handoff() composes a compact, DB-only digest (no git diff) and
_briefing_for() now attaches it to context_briefing whenever the caller
already holds the task row. The digest is built only from a passed-in task,
so there are zero extra fetches: every resumption entry point (give_me_work
and pm_give_me_work, i_will_work_on, i_will_plan, triage/triage_all,
i_am_done, submit_up, escalate_up, complete) threads the loaded task, while
id-only correction/rejection paths cleanly omit it.

Every field is type-guarded so a partial row never leaks a non-serialisable
value into the envelope.

* docs(prompts): tell agents to resume from the briefing handoff before re-exploring

The base prompt described the success envelope but never told agents to act
on context_briefing, so a respawned or hand-off agent would re-scan the
whole repo and re-derive the plan even when the briefing already carried the
prior worker's PR, commits, acceptance status and journal highlights.

Adds a 'Resume from your briefing' section that walks each task_handoff
field and instructs the agent to continue from it — and to read the unread
A2A / mention / notification lists, which are messages addressed to them.

Pairs with the gateway change that now pushes task_handoff into every
task-scoped briefing.

* feat(tasks): remember cleared dependencies so the unblock briefing can surface them

When an upstream dependency completed, _unblock_dependents removed its id from
the dependent's dependency_ids to let it be claimed — destroying the only
record of which upstream task had just landed. The revived dependent then
re-discovered that work from cold.

Adds tasks.completed_dependency_ids (Alembic 026, uuid[] default '{}'):
_unblock_dependents now appends the cleared id there instead of only dropping
it, and the briefing handoff digest surfaces it so the agent picking the task
back up knows its blocker cleared because that upstream work shipped. The base
prompt documents the field.

Migration round-trip verified against postgres (upgrade adds the column,
downgrade drops it).

* docs(prompts): instruct PMs to split oversized tasks into per-concern subtasks

A subtask carrying a long acceptance list or spanning multiple layers/files
drove repeated QA failures and a PM revision loop — QA can't pass a partial,
and the dev keeps re-touching unrelated parts. Nothing in the PM prompts told
them to decompose by size/concern.

cell_pm gets a 'Sizing' rule: one subtask = one focused concern with ~2-4
criteria and its own dev->QA pass; decompose anything larger before
delegating, sequencing with dependencies. main_pm gets a matching reminder to
scope each cell's slice to that cell's layer rather than handing a cell a
cross-layer monolith that just pushes the problem down a level.

* fix(gateway): mirror the task= kwarg on ChoreographerHelpers helper signatures

The handoff-digest change added a keyword-only task= parameter to
_briefing_for and _build_tracing_gap in _impl, but the ChoreographerHelpers
base that the role mixins inherit still declared the old signatures, so the
composed Choreographer had two incompatible base definitions (mypy [misc]).
Sync the base declarations to match.

* fix(tasks): keep the owner on a substitute-out so the task isn't orphaned

build_substitute_update unconditionally nulled assigned_to, so any
substitute that routes to PENDING (max_retries, low_context, out_of_scope_*)
— the path a verb hitting repeated 500s or its retry limit takes — left the
task pending AND unassigned. The dispatcher only respawns a pending task when
it has an owner, so the task went dormant: no agent ever picked it back up.

Keep the task with its current owner instead. A substitute-out is almost
always a transient stall, so the task re-dispatches to the SAME agent, which
resumes from the briefing handoff. Only the task_complete -> PM-review handoff
changes owner (unchanged).

* feat(a2a): suppress duplicate unread A2A messages at send

A respawned or retrying agent could re-emit the same DM, stacking identical
copies on the recipient's inbox and re-bumping the unread count — noise that
the recipient then has to clear. The notification path already dedups; A2A did
not.

send_chat_message now suppresses a send when an identical message from the
same sender is still unread in the conversation, keyed on (conversation,
sender, message_kind, content). Genuinely different messages are never
collapsed (verified: distinct content still produces distinct rows), so this
avoids the earlier per-pair over-suppression. No migration.

* fix(panel): default the notifications view to Unread, not All

Landing on the All tab buried new notifications under everything already
seen — the most-reported annoyance. The Unread tab is the actionable view, so
make it the default; the All/Pending tabs are one click away.

* fix(panel): show clone progress during intake prep instead of a frozen pill

The first clone of a repo can take a few minutes, during which the intake
form showed only a static 'Preparing the agent…' button — indistinguishable
from a hang. Add a progress region while preparing: an elapsed timer, a
saturating progress bar (approaches but never reaches 100% until the agent
actually answers), and staged copy (spinning up → cloning → first-clone-takes-
a-while → reading the codebase) so the wait reads as work, not a freeze.

* feat(docs): index workspace-authored docs that never reached the RAG store

Docs written through roboco_docs_write land at /app/docs on the orchestrator
and index fine. But a documenter can also write docs with Edit/Write directly
in its own clone (README, CHANGELOG, workspace markdown); those resolve to a
/app/docs path that doesn't exist on the orchestrator, so the indexer reads
nothing and the docs never become searchable — a cross-container miss with no
shared mount to bridge it.

On docs completion, capture each listed doc's committed content out of the
branch (new GitService.read_file_at_branch, via git show) and write it
server-side under /app/docs before indexing, so workspace-authored docs reach
RAG too. Docs already present server-side are skipped; absolute paths and
unreadable/uncommitted files are passed over best-effort.

* feat(prompter): survive a browser reload by reconnecting to the live intake chat

The intake chat lived entirely in React state, so a page reload wiped it and
dropped the human back to the scope form — even though the agent container
outlives the page. Now the chat persists a small TTL'd slice (session id,
messages, scope, draft) to localStorage and, on mount, reconnects: it asks the
new GET /live/{id}/status whether the session is still running and, if so,
restores the history and reopens the SSE stream; if dead or expired it clears
and shows the form. A full reload doesn't run React effect cleanup, so the
navigate-away reap never fires on refresh and the session stays up.

Backend adds the status endpoint + PrompterLiveRegistry.is_alive; localStorage
is cleared on confirm, start-another, and SPA navigate-away.

* chore: remove internal session-bookkeeping refs from code comments (part 1)

Strip leaked task/finding numbers, Wave/Phase/cluster/audit labels from
docstrings and comments across services, foundation policy, runtime, mcp,
api schemas, and agent_sdk — they mean nothing to a repo reader and expose
process internals. Wording preserved; only the labels dropped. Done by hand,
one comment at a time (no scripted rewrite). _impl.py follows separately.

* chore: remove internal session-bookkeeping refs from code comments (part 2)

Finishes the manual scrub: the choreographer _impl.py docstrings/comments plus
the remaining dogfood-run ('smoke-N') labels across runtime, mcp, foundation,
api schemas, services, and agent factories. Reworded to describe the bug or
behaviour in plain words; every label dropped. The repo source is now free of
task/finding numbers, Wave/Phase/cluster/audit/smoke labels. By hand, one
comment at a time.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-10 12:04:24 +02:00

787 lines
27 KiB
Python

"""Tests for the developer-facing Choreographer methods."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
# #172: a developer fresh claim must carry a substantive step checklist.
_STEPS = [
{
"title": "Implement the change",
"description": (
"edit the target file, add tests, run them, and stage the "
"change for commit on the task branch"
),
}
]
# Full parity: a fresh dev claim authors the same rich plan a PM does.
# These satisfy _dev_plan_gate (plan/approach >= 150 chars,
# technical_considerations, risks).
_GOOD_PLAN = (
"Append the timestamp HTML comment to the very bottom of README.md without "
"touching any other line, then commit it on the task branch and open a PR. "
"Verify the diff is a single-line addition before submitting for QA."
)
_GOOD_TC = ["Use a trailing newline so the comment sits on its own line."]
_GOOD_RISKS = [
{
"risk": "An accidental reformat of README.md balloons the diff.",
"mitigation": "Append only; assert the diff touches one line pre-commit.",
}
]
def _make_deps(**overrides: AsyncMock) -> ChoreographerDeps:
task = overrides.get("task", AsyncMock())
# VerbRunner uses task.session.begin_nested() as a savepoint context
# manager. AsyncMock auto-attributes any access (so hasattr always
# returns True); we always overwrite session to a MagicMock with the
# correct async-context-manager protocol.
task.session = MagicMock()
task.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
work_session = overrides.get("work_session", AsyncMock())
git = overrides.get("git", AsyncMock())
a2a = overrides.get("a2a", AsyncMock())
journal = overrides.get("journal", AsyncMock())
audit = overrides.get("audit", AsyncMock())
evidence_repo = overrides.get("evidence_repo", AsyncMock())
# Ensure evidence_repo returns empty lists by default
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
):
getattr(evidence_repo, method).return_value = []
return ChoreographerDeps(
task=task,
work_session=work_session,
git=git,
a2a=a2a,
journal=journal,
audit=audit,
evidence_repo=evidence_repo,
)
@pytest.mark.asyncio
async def test_give_me_work_returns_assigned_task() -> None:
agent_id = uuid4()
task_obj = MagicMock(id=uuid4(), status="pending", title="t1")
task_svc = AsyncMock()
task_svc.list_pending_for_agent.return_value = []
task_svc.list_assigned_for_agent.return_value = [task_obj]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.give_me_work(agent_id)
body = env.as_dict()
assert body["status"] == "pending"
assert body["task_id"] == str(task_obj.id)
assert "i_will_work_on" in body["next"]
@pytest.mark.asyncio
async def test_give_me_work_returns_paused_when_no_assigned() -> None:
agent_id = uuid4()
paused_obj = MagicMock(id=uuid4(), status="paused")
task_svc = AsyncMock()
task_svc.list_pending_for_agent.return_value = []
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = [paused_obj]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.give_me_work(agent_id)
body = env.as_dict()
assert body["task_id"] == str(paused_obj.id)
assert "resume" in body["next"]
@pytest.mark.asyncio
async def test_give_me_work_returns_idle_when_no_work() -> None:
agent_id = uuid4()
task_svc = AsyncMock()
task_svc.list_pending_for_agent.return_value = []
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.give_me_work(agent_id)
body = env.as_dict()
assert body["status"] == "idle"
assert "i_am_idle" in body["next"]
@pytest.mark.asyncio
async def test_i_will_work_on_pending_with_plan() -> None:
agent_id = uuid4()
task_id = uuid4()
pending_task = MagicMock(
id=task_id,
status="pending",
plan=None,
assigned_to=None,
parent_task_id=None,
sequence=0,
task_type="code",
commits=[],
pr_number=None,
branch_name="feature/backend/abc",
quick_context=None,
)
in_progress_task = MagicMock(
id=task_id, status="in_progress", plan={"text": "do x"}, assigned_to=agent_id
)
task_svc = AsyncMock()
task_svc.get.return_value = pending_task
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", 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", plan=None, assigned_to=agent_id
)
task_svc.set_plan.return_value = MagicMock(
id=task_id, status="claimed", plan={"text": "do x"}, assigned_to=agent_id
)
task_svc.start.return_value = in_progress_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
assert env.error is None
assert env.status == "in_progress"
task_svc.claim.assert_awaited_once_with(task_id, agent_id)
task_svc.set_plan.assert_awaited_once()
task_svc.start.assert_awaited_once_with(task_id, agent_id)
@pytest.mark.asyncio
async def test_i_will_work_on_pending_no_plan_returns_tracing_gap() -> None:
agent_id = uuid4()
task_id = uuid4()
pending_task = MagicMock(
id=task_id,
status="pending",
plan=None,
assigned_to=None,
description="task description",
parent_task_id=None,
sequence=0,
task_type="code",
commits=[],
pr_number=None,
branch_name="feature/backend/abc",
quick_context=None,
)
task_svc = AsyncMock()
task_svc.get.return_value = pending_task
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", 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", plan=None, assigned_to=agent_id
)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan=None)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "plan" in body["missing"]
@pytest.mark.asyncio
async def test_i_will_work_on_needs_revision_re_starts() -> None:
"""needs_revision dev path: spec composes (claim, set_plan, start), so
claim now runs even when the task is already assigned to the dev (the
spec source-status for claim includes NEEDS_REVISION). Migration
behavior change vs. the pre-spec verb body, which skipped claim if
already assigned."""
agent_id = uuid4()
task_id = uuid4()
nr_task = MagicMock(
id=task_id,
status="needs_revision",
assigned_to=agent_id,
plan={"x": 1},
task_type="code",
commits=[],
pr_number=None,
branch_name="feature/backend/abc",
quick_context=None,
parent_task_id=None,
sequence=0,
team="backend",
)
claimed = MagicMock(
id=task_id, status="claimed", assigned_to=agent_id, plan={"x": 1}
)
in_progress_task = MagicMock(
id=task_id, status="in_progress", assigned_to=agent_id, plan={"x": 1}
)
task_svc = AsyncMock()
task_svc.get.return_value = nr_task
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", 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 = claimed
task_svc.set_plan.return_value = claimed
task_svc.start.return_value = in_progress_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
assert env.status == "in_progress"
task_svc.start.assert_awaited_once_with(task_id, agent_id)
@pytest.mark.asyncio
async def test_i_will_work_on_task_not_found_returns_not_found() -> None:
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id)
body = env.as_dict()
assert body["error"] == "not_found"
@pytest.mark.asyncio
async def test_i_will_work_on_invalid_state_returns_invalid_state() -> None:
"""Completed task: spec rejects via can_invoke_action on the first
composed action (claim) — completed is not in claim's source_statuses,
so the message comes from the spec, not the verb body."""
agent_id = uuid4()
task_id = uuid4()
completed_task = MagicMock(
id=task_id,
status="completed",
assigned_to=agent_id,
task_type="code",
commits=[],
pr_number=None,
branch_name="feature/backend/abc",
quick_context=None,
parent_task_id=None,
sequence=0,
team="backend",
)
task_svc = AsyncMock()
task_svc.get.return_value = completed_task
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", team="backend", slug=None
)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id)
body = env.as_dict()
assert body["error"] == "invalid_state"
# Spec produces "task is in 'completed', 'claim' requires: ..."
assert "completed" in body["message"]
@pytest.mark.asyncio
async def test_i_will_work_on_blocks_when_journal_note_at_claim_missing() -> None:
"""Pre-gateway parity P1: i_will_work_on requires a journal:note at claim.
The composed (claim, set_plan, start) sequence runs first — the claim
sticks. Then the post-claim tracing gate fires because no journal:note
exists for (agent, task), and the agent gets a tracing_gap with a
remediation hint to write a note and retry.
"""
agent_id = uuid4()
task_id = uuid4()
pending_task = MagicMock(
id=task_id,
status="pending",
plan=None,
assigned_to=None,
parent_task_id=None,
sequence=0,
task_type="code",
commits=[],
pr_number=None,
branch_name="feature/backend/abc",
quick_context=None,
)
in_progress_task = MagicMock(
id=task_id,
status="in_progress",
plan={"text": "do x"},
assigned_to=agent_id,
)
task_svc = AsyncMock()
# `get` is called twice: once at verb entry, once by _post_claim_journal_gate.
task_svc.get.side_effect = [pending_task, in_progress_task]
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", 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", plan=None, assigned_to=agent_id
)
task_svc.set_plan.return_value = MagicMock(
id=task_id, status="claimed", plan={"text": "do x"}, assigned_to=agent_id
)
task_svc.start.return_value = in_progress_task
journal_svc = AsyncMock()
journal_svc.has_note_for_task.return_value = False
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:note_at_claim" in body["missing"]
assert "note(scope='note'" in body["remediate"]
# The composed action ran — claim+set_plan+start were called even though
# the post-claim gate failed.
task_svc.claim.assert_awaited_once_with(task_id, agent_id)
task_svc.start.assert_awaited_once_with(task_id, agent_id)
# test_i_am_done_with_catchup_full_chain removed (audit P2-5/D-16):
# i_am_done_with_catchup verb deleted. submit_for_qa now does push + PR
# explicitly; i_am_done auto-runs submit_verification + submit_qa.
@pytest.mark.asyncio
async def test_i_am_done_blocks_when_acceptance_criteria_unaddressed() -> None:
"""Without a reflect note, unaddressed criteria block i_am_done.
The reflect note is treated as the addressing artifact for any
criterion not explicitly cited via acceptance_criteria_status —
so this test deliberately omits reflect to surface the criterion
rejection.
"""
agent_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
plan={"x": 1},
branch_name="feature/backend/abc",
work_session_id=uuid4(),
self_verified=False,
progress_updates=[{"message": "p"}],
acceptance_criteria=["AC1", "AC2"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "c1"}
],
# Spec's PRECONDITION_COMMITS now runs before the tracing gate;
# supply a commit so the tracing gap (acceptance criteria) is
# the load-bearing rejection.
commits=[{"sha": "abc"}],
pr_number=8,
pr_url="https://x/pr/8",
team="backend",
documents=[],
dev_notes="",
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", team="backend", slug=None
)
journal_svc = AsyncMock()
journal_svc.has_reflect_for_task.return_value = False
# JOURNAL_DURING_WORK_AT_LEAST_ONE is satisfied so the load-bearing
# rejection here is the unaddressed AC2 criterion, not the new
# mid-flight cadence gate.
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_learning_for_task.return_value = False
journal_svc.has_struggle_for_task.return_value = False
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.i_am_done(agent_id, task_id, "done")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert any("AC2" in m for m in body["missing"])
@pytest.mark.asyncio
async def test_i_am_done_reflect_note_addresses_acceptance_criteria() -> None:
"""A reflect note clears the acceptance-criteria gate.
Per `_check_acceptance_criteria`, the reflect note is the agent's
attestation that the work meets every criterion; once it's present,
unaddressed criteria no longer block the submission. The other
tracing requirements (commits, PR, progress) still apply.
"""
agent_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
plan={"x": 1},
branch_name="feature/backend/abc",
work_session_id=uuid4(),
self_verified=False,
progress_updates=[{"message": "p"}],
acceptance_criteria=["AC1", "AC2"],
acceptance_criteria_status=[], # nothing cited explicitly
commits=[{"sha": "abc"}],
pr_number=8,
pr_url="https://x/pr/8",
team="backend",
documents=[],
dev_notes="",
qa_notes="",
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", team="backend", slug=None
)
task_svc.submit_verification.return_value = t
task_svc.submit_for_qa.return_value = t
journal_svc = AsyncMock()
journal_svc.has_reflect_for_task.return_value = True
# Satisfy JOURNAL_DURING_WORK_AT_LEAST_ONE so the test's narrow assertion
# (criteria-gap cleared) isn't masked by an unrelated tracing failure.
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_learning_for_task.return_value = False
journal_svc.has_struggle_for_task.return_value = False
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.i_am_done(agent_id, task_id, "done")
body = env.as_dict()
# criteria gate is cleared by reflect; if anything else fails, it
# must NOT be the AC2 criterion.
if body.get("error") == "tracing_gap":
assert not any("AC2" in m for m in body.get("missing", []))
@pytest.mark.asyncio
async def test_i_am_done_blocks_when_journal_reflect_missing() -> None:
"""Tracing-gate (journal:reflect) fires after the spec gate accepts."""
agent_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
plan={"x": 1},
branch_name="feature/backend/abc",
work_session_id=uuid4(),
self_verified=False,
progress_updates=[{"message": "p"}],
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "c1"}
],
# Spec's PRECONDITION_COMMITS runs before the tracing gate; supply
# a commit so the missing journal:reflect is the load-bearing gap.
commits=[{"sha": "abc"}],
pr_number=8,
pr_url="https://x/pr/8",
team="backend",
documents=[],
dev_notes="",
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", team="backend", slug=None
)
journal_svc = AsyncMock()
journal_svc.has_reflect_for_task.return_value = False # no reflect
# Satisfy JOURNAL_DURING_WORK_AT_LEAST_ONE so journal:reflect is the
# load-bearing gap surfaced to the assertion.
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_learning_for_task.return_value = False
journal_svc.has_struggle_for_task.return_value = False
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.i_am_done(agent_id, task_id, "done")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:reflect" in body["missing"]
@pytest.mark.asyncio
async def test_i_am_done_not_assigned_returns_tracing_gap() -> None:
"""Spec's PRECONDITION_OWNERSHIP rejects with tracing_gap (owns_task).
Pre-spec migration the verb returned not_authorized via an inline
ownership check; that's now driven by the spec's extra precondition
so the rejection_kind is tracing_gap.
"""
agent_id = uuid4()
other_agent = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=other_agent,
commits=[{"sha": "abc"}],
team="backend",
quick_context=None,
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", team="backend", slug=None
)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_done(agent_id, task_id, "x")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "owns_task" in body["missing"]
@pytest.mark.asyncio
async def test_i_am_done_skill_resolution_picks_existing_skill() -> None:
"""Resolver picks first matching skill.
Falls back to the first preference entry when no match is found.
"""
deps = _make_deps()
c = Choreographer(deps)
qa = MagicMock(id=uuid4(), skills=[{"id": "qa_review"}, {"id": "test_validation"}])
skill = c._resolve_skill(qa, ["code_review", "qa_review"])
assert skill == "qa_review"
qa_with_canonical = MagicMock(id=uuid4(), skills=[{"id": "code_review"}])
skill2 = c._resolve_skill(qa_with_canonical, ["code_review", "qa_review"])
assert skill2 == "code_review"
qa_with_neither = MagicMock(id=uuid4(), skills=[{"id": "other_skill"}])
skill3 = c._resolve_skill(qa_with_neither, ["code_review", "qa_review"])
assert skill3 == "code_review" # fallback to first
@pytest.mark.asyncio
async def test_i_am_blocked_escalates_and_journals() -> None:
agent_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
pre_block_state=None,
task_type="code",
team="backend",
)
after = MagicMock(id=task_id, status="blocked", assigned_to=agent_id)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", team="backend", slug=None
)
task_svc.escalate.return_value = after
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.i_am_blocked(agent_id, task_id, "external API down")
assert env.error is None
assert env.status == "blocked"
journal_svc.write_struggle.assert_awaited_once()
task_svc.escalate.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_blocked_task_not_found_returns_not_found() -> None:
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_blocked(agent_id, task_id, "x")
body = env.as_dict()
assert body["error"] == "not_found"
@pytest.mark.asyncio
async def test_i_am_idle_with_unread_a2a_soft_blocks() -> None:
agent_id = uuid4()
deps = _make_deps()
deps.evidence_repo.list_unread_a2a.return_value = [{"from": "x", "task_id": "t1"}]
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
assert body["status"] == "idle_with_unread"
assert "read_messages" in body["next"]
@pytest.mark.asyncio
async def test_i_am_idle_with_unread_mentions_soft_blocks() -> None:
agent_id = uuid4()
deps = _make_deps()
deps.evidence_repo.list_unread_mentions.return_value = [
{"channel": "#x", "from": "y"}
]
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
assert body["status"] == "idle_with_unread"
@pytest.mark.asyncio
async def test_i_am_idle_clean_returns_idle() -> None:
agent_id = uuid4()
task_svc = AsyncMock()
deps = _make_deps(task=task_svc)
# All evidence_repo lists are already empty per _make_deps default
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
assert env.status == "idle"
task_svc.mark_agent_idle.assert_awaited_once_with(agent_id)
def _passing_i_am_done_task(agent_id: Any, task_id: Any) -> Any:
"""A task that clears every i_am_done gate (so the flow reaches the push)."""
return MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
plan={"x": 1},
branch_name="feature/backend/abc",
work_session_id=uuid4(),
self_verified=False,
progress_updates=[{"message": "p"}],
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "c1"}
],
commits=[{"sha": "abc"}],
pr_number=8,
pr_url="https://x/pr/8",
team="backend",
documents=[],
dev_notes="",
qa_notes="",
)
def _passing_i_am_done_deps(task: Any, **overrides: AsyncMock) -> ChoreographerDeps:
"""Task + journal mocks set up so i_am_done passes through to the push."""
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(
id=task.assigned_to, role="developer", team="backend", slug=None
)
task_svc.submit_verification.return_value = task
task_svc.submit_qa.return_value = task
task_svc.submit_for_qa.return_value = task
journal_svc = AsyncMock()
journal_svc.has_reflect_for_task.return_value = True
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_learning_for_task.return_value = False
journal_svc.has_struggle_for_task.return_value = False
return _make_deps(task=task_svc, journal=journal_svc, **overrides)
@pytest.mark.asyncio
async def test_i_am_done_pushes_branch_before_qa_handoff() -> None:
"""i_am_done pushes the task branch so QA reviews the latest commits.
A fix committed during a revision cycle is local-only until pushed; without
this push QA re-reviews the stale remote and re-fails the task every cycle.
"""
agent_id = uuid4()
task_id = uuid4()
git_svc = AsyncMock()
git_svc.push_task_branch.return_value = 1
deps = _passing_i_am_done_deps(
_passing_i_am_done_task(agent_id, task_id), git=git_svc
)
c = Choreographer(deps)
env = await c.i_am_done(agent_id, task_id, "done")
assert env.error is None
git_svc.push_task_branch.assert_awaited_once_with(agent_id, task_id)
@pytest.mark.asyncio
async def test_i_am_done_blocks_when_branch_push_fails() -> None:
"""A failed push aborts i_am_done — a task must not reach awaiting_qa with
commits that live only in the developer's local workspace."""
agent_id = uuid4()
task_id = uuid4()
git_svc = AsyncMock()
git_svc.push_task_branch.side_effect = RuntimeError("fetch timed out")
deps = _passing_i_am_done_deps(
_passing_i_am_done_task(agent_id, task_id), git=git_svc
)
c = Choreographer(deps)
env = await c.i_am_done(agent_id, task_id, "done")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "push" in body["message"].lower()
# The QA transition must not have run.
deps.task.submit_qa.assert_not_awaited()
deps.task.submit_for_qa.assert_not_awaited()