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>
This commit is contained in:
Renzo F
2026-06-10 12:04:24 +02:00
committed by GitHub
co-authored by Renn F
parent 48556a032b
commit b9082a5c70
69 changed files with 1647 additions and 337 deletions
@@ -1,10 +1,9 @@
"""Gateway choreographer package.
Audit P2-2: the single-file 2,540-line ``choreographer.py`` is being
split into per-role mixins composed onto a single ``Choreographer``
class. ``board.py`` is the first extraction (Board + Auditor verbs);
the rest still live in ``_impl.py`` and will move incrementally per
the plan in ``docs/internal/audit_2026_05_04/p2_2_decompose_plan.md``.
The single-file 2,540-line ``choreographer.py`` is being split into
per-role mixins composed onto a single ``Choreographer`` class.
``board.py`` is the first extraction (Board + Auditor verbs); the rest
still live in ``_impl.py`` and will move incrementally.
The public surface (``Choreographer``, ``ChoreographerDeps``,
``DelegateInputs``) is re-exported here so every caller's import path
+224 -111
View File
@@ -31,6 +31,7 @@ from roboco.services.gateway.evidence_builder import (
BriefingInputs,
build_context_briefing,
build_evidence_for_task,
build_task_handoff,
)
from roboco.services.gateway.merge_chain import resolve_parent_branch
from roboco.services.gateway.remediation import (
@@ -49,7 +50,7 @@ logger = structlog.get_logger()
# Minimum character length enforced on rich_plan["approach"] by the PM
# sub-tasks gate. Must match the Pydantic min_length on
# IWillPlanRequest.approach. Raised 20→150 (smoke-15): plans were vague
# IWillPlanRequest.approach. Raised 20→150: plans were vague
# because 20 chars is a one-liner; the approach + sub_tasks are also the
# progress checklist, so they must be substantive.
_PM_APPROACH_MIN_LEN = 150
@@ -61,7 +62,7 @@ _PM_SUBTASK_DESC_MIN_LEN = 60
def _thin_subtask_hint(sub_tasks: list[Any]) -> str | None:
"""Return a hint if any PM sub_task is title-only / thin (#171).
"""Return a hint if any PM sub_task is title-only / thin.
Each sub_task is a delegate target AND a progress-checklist item, so
a title with no real description is not a plan. Returns None when
@@ -210,7 +211,7 @@ class ChoreographerDeps:
journal: Any
audit: Any
evidence_repo: Any
# Task #156: messaging is optional so existing callsites + tests that
# messaging is optional so existing callsites + tests that
# don't exercise session propagation don't have to plumb it in. The
# delegate() path uses it to thread parent sessions onto new subtasks.
messaging: Any = None
@@ -261,8 +262,8 @@ class _IAmDoneContext:
class _ReassignedCtx:
"""Bundle of fields the ``_reassigned_rejection`` helper inspects.
Shared between ``unclaim`` and ``resume`` (Task 6 fix in commit
a5d358d). Frozen so the helper site can't mutate caller state and
Shared between ``unclaim`` and ``resume``. Frozen so the helper site
can't mutate caller state and
to keep PLR0913 (too many positional args) at bay.
"""
@@ -280,14 +281,14 @@ class DelegateInputs:
Mirrors :data:`roboco.foundation.policy.task_completeness.TASK_AT_CREATE`:
`task_type` and `nature` have no defaults — the v1 schema enforces both at
the HTTP boundary (Task 15), and defaulting here too would let direct
the HTTP boundary, and defaulting here too would let direct
callers (tests, internal code) silently pick `'code'`/`'technical'` and
recreate the 2026-05-08 deadlock.
recreate the no-acceptance-criteria deadlock.
Optional fields (`acceptance_criteria=None`, `nature=None`) survive the
construction step and are then rejected by the gateway-side
`task_completeness.check` before reaching `_create_subtask_from_inputs`
(Task 19): the rejection takes the form of `Envelope.incomplete_input`
`task_completeness.check` before reaching `_create_subtask_from_inputs`:
the rejection takes the form of `Envelope.incomplete_input`
so the agent receives a structured field-by-field guide.
"""
@@ -373,7 +374,7 @@ class Choreographer:
) -> None:
"""Append a server-emitted progress entry on a lifecycle milestone.
Task #155: agents call ``progress()`` inconsistently. Server-side
Agents call ``progress()`` inconsistently. Server-side
auto-emit on natural milestones (open_pr, i_am_done) guarantees
the panel + audit view always have entries at the major
transitions, regardless of how chatty the agent is. Best-effort:
@@ -394,8 +395,8 @@ class Choreographer:
) -> Envelope | None:
"""Build the "task reassigned by upstream verb" rejection envelope.
Shared between ``unclaim`` and ``resume`` (Task 6 fix in commit
a5d358d). The spec doesn't model "task got reassigned out from
Shared between ``unclaim`` and ``resume``. The spec doesn't model
"task got reassigned out from
under you by an upstream verb" — when the spec gate accepts but
``task.assigned_to != agent_id``, this helper produces the
envelope with the load-bearing "current owner" hint and
@@ -469,13 +470,13 @@ class Choreographer:
task_id: UUID,
briefing: dict[str, Any],
) -> Envelope | None:
"""Wave A1 gate: PMs must supply a substantive approach + sub_tasks.
"""Plan-depth gate: PMs must supply a substantive approach + sub_tasks.
Enforces both fields at the choreographer layer so direct service-layer
callers (MCP server, test fixtures, orchestrator-internal Python) cannot
persist a plan that bypassed the HTTP Pydantic boundary.
Smoke-15: a 20-char approach and title-only sub_tasks were "no
A 20-char approach and title-only sub_tasks were "no
effort" plans. approach must be >= _PM_APPROACH_MIN_LEN and every
sub_task needs a real title + a description that says what the
step does (it is both a delegate target and a progress-checklist
@@ -549,12 +550,11 @@ class Choreographer:
per-attempt id into the audit row's ``details`` JSONB. The
attempt_id is unique per rejection event so post-mortem queries
can group "all attempts on task X within a window" without
confusing two distinct calls that share a correlation_id (audit
P2-7/D-N).
confusing two distinct calls that share a correlation_id.
"""
if env.error is None:
return env
# Wave C3 (2026-05-12): refresh heartbeat on every rejection so an
# Refresh heartbeat on every rejection so an
# agent stuck in a verb-rejection loop (e.g., tracing_gap while
# retrying) does not look idle to the reaper. Best-effort: a
# heartbeat failure must never alter the envelope returned to the
@@ -596,7 +596,7 @@ class Choreographer:
def _claim_verb_hint(role: str, task: Any) -> str:
"""Role + status aware 'how to start this task' hint.
Task #162 facet (d): give_me_work hard-coded
give_me_work used to hard-code
``i_will_work_on(...)`` for every role/status. A documenter
handed an awaiting_documentation task (or QA an awaiting_qa
task) was told to call a dev verb it doesn't have — it looped.
@@ -652,7 +652,7 @@ class Choreographer:
status=str(t.status),
task_id=str(t.id),
next=self._claim_verb_hint(role, t),
context_briefing=await self._briefing_for(agent_id, t.id),
context_briefing=await self._briefing_for(agent_id, t.id, task=t),
).with_introspection(task=t, role=role)
assigned = await self._drop_dependency_held(
await self._deps.task.list_assigned_for_agent(agent_id)
@@ -663,7 +663,7 @@ class Choreographer:
status=str(t.status),
task_id=str(t.id),
next=self._claim_verb_hint(role, t),
context_briefing=await self._briefing_for(agent_id, t.id),
context_briefing=await self._briefing_for(agent_id, t.id, task=t),
).with_introspection(task=t, role=role)
paused = await self._deps.task.list_paused_for_agent(agent_id)
if paused:
@@ -672,7 +672,7 @@ class Choreographer:
status=str(t.status),
task_id=str(t.id),
next=f"call resume(task_id='{t.id}') to continue paused work",
context_briefing=await self._briefing_for(agent_id, t.id),
context_briefing=await self._briefing_for(agent_id, t.id, task=t),
).with_introspection(task=t, role=role)
return Envelope.ok(
status="idle",
@@ -682,10 +682,23 @@ class Choreographer:
)
async def _briefing_for(
self, agent_id: UUID, task_id: UUID | None
self, agent_id: UUID, task_id: UUID | None, *, task: Any | None = None
) -> dict[str, Any]:
"""Assemble context_briefing for agent_id, optionally scoped to task_id."""
"""Assemble context_briefing for agent_id, optionally scoped to task_id.
``task`` is the already-loaded row (every claim / give_me_work / done
path holds it). The prior-work handoff is built only when it is passed —
no extra fetch — so task-scoped error paths that carry only an id simply
omit the digest rather than pay a redundant read for it.
"""
repo = self._deps.evidence_repo
task_handoff: dict[str, Any] | None = None
if task_id is not None and task is not None:
# Push the prior-work digest so a freshly spawned / respawned agent
# resumes from the previous worker's PR + commits + journal rather
# than re-exploring the codebase cold on every lifecycle hand-off.
handoff_highlights = await repo.journal_highlights_for_task(task_id)
task_handoff = build_task_handoff(task, handoff_highlights)
inputs = BriefingInputs(
unread_a2a=await repo.list_unread_a2a(agent_id),
unread_mentions=await repo.list_unread_mentions(agent_id),
@@ -695,6 +708,7 @@ class Choreographer:
),
recent_team_activity=await repo.recent_team_activity(agent_id),
blockers_in_my_lane=await repo.blockers_in_lane(agent_id),
task_handoff=task_handoff,
)
return build_context_briefing(inputs)
@@ -710,7 +724,7 @@ class Choreographer:
does NOT model. Role/state/task_type checks now route through
``spec.can_invoke_action`` (CLAIM_RULES + ActionSpec.allowed_task_types)
in the verb's spec gate; the former role-typed and
pm_cannot_execute_code guards have been deleted (Task 27, 2026-05-10).
pm_cannot_execute_code guards have been deleted.
Pre-gateway location: _helpers.py:124-204.
"""
@@ -757,7 +771,7 @@ class Choreographer:
"""Return a tracing_gap rejection if any subtask of ``task_id`` is non-terminal.
Centralizes the closure-time "all subtasks terminal" gate that fires
in submit_up, cell_pm_complete, main_pm_complete (audit P2-3/D-15).
in submit_up, cell_pm_complete, main_pm_complete.
``context_phrase`` lets each caller name the action being blocked
(e.g., "bubbling up", "completing parent").
"""
@@ -859,7 +873,7 @@ class Choreographer:
task_id=task_id,
verb=verb_name,
)
# Wave C4 (2026-05-12) — pre-gateway parity. Ensure WorkSession row
# Pre-gateway parity: ensure the WorkSession row
# exists on the stuck-claimed recovery path too (same guarantee as
# _claim_plan_start_run). Re-entry guard inside ensure_work_session.
await self.task.ensure_work_session(task_id, agent_id)
@@ -965,7 +979,7 @@ class Choreographer:
task_id=ctx.task_id,
verb=verb_name,
)
# Wave C4 (2026-05-12) — pre-gateway parity. Create the WorkSession
# Pre-gateway parity: create the WorkSession
# row so downstream subsystems (panel, PR, merge chain) can track
# this agent's per-task git activity. work_session_id stored on the
# task; one WorkSession per (agent, task) claim cycle; re-entry
@@ -987,7 +1001,7 @@ class Choreographer:
risks: list[dict[str, Any]] | None,
open_questions: list[dict[str, Any]] | None,
) -> dict[str, Any]:
"""Assemble the panel-shaped rich plan (#172) from a dev's inputs."""
"""Assemble the panel-shaped rich plan from a dev's inputs."""
return {
"approach": plan or "",
"sub_tasks": steps or [],
@@ -1014,10 +1028,10 @@ class Choreographer:
the DB. Idempotent re-entry: a respawned dev re-calling on a
task they already own in_progress just refreshes the heartbeat.
#172: ``steps`` is the developer's execution checklist (same
``steps`` is the developer's execution checklist (same
SubTask shape as a PM's sub_tasks). Persisted into
``task.plan.sub_tasks`` via the panel-shaped path so it renders
identically AND feeds plan-driven progress (#173). A developer
identically AND feeds plan-driven progress. A developer
on a fresh claim must supply substantive steps —
``_dev_steps_gate`` enforces depth; the re-entry / recovery
paths short-circuit before the gate so a respawned dev is never
@@ -1033,7 +1047,7 @@ class Choreographer:
)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else "developer"
briefing = await self._briefing_for(agent_id, task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
try:
role = spec_module.Role(role_str)
except ValueError:
@@ -1047,10 +1061,10 @@ class Choreographer:
task_id=task_id,
verb="i_will_work_on",
)
# #172/full-parity: a dev authors the same rich plan a PM does. The
# Full parity: a dev authors the same rich plan a PM does. The
# dev's `plan` doubles as the Approach; `steps` become sub_tasks. Built
# via the panel-shaped path so the Plan tab renders identically and
# feeds #173 progress. With no rich fields (re-entry/recovery) this
# feeds progress. With no rich fields (re-entry/recovery) this
# falls through to unchanged string behaviour.
rich_plan = self._build_rich_plan(
plan, steps, technical_considerations, risks, open_questions
@@ -1118,7 +1132,7 @@ class Choreographer:
# Recovery re-entry: task stuck in `claimed` (orchestrator restart
# or partial-claim race) and the agent already owns it. The spec
# `claim` source-statuses exclude CLAIMED, so run only set_plan +
# start (Bug A from the 2026-05-09 smoke test).
# start.
if str(t.status) == "claimed" and t.assigned_to == agent_id:
envelope = await self._resume_from_claimed(ctx)
return await self._post_claim_journal_gate(
@@ -1253,8 +1267,7 @@ class Choreographer:
Renamed from ``submit_for_qa`` (2026-05-08): the old name
suggested this verb advanced the lifecycle, but it only opens
the PR. Agents misread the name, called it expecting a QA
handoff, then never called i_am_done — orphaning PRs (e.g.
PR #12 in the smoke-test trace).
handoff, then never called i_am_done — orphaning open PRs.
Idempotent on re-call: if the caller already owns the task and
a PR is already open, return OK pointing at ``i_am_done`` rather
@@ -1269,7 +1282,7 @@ class Choreographer:
task_id=task_id,
verb="open_pr",
)
briefing = await self._briefing_for(agent_id, task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else "developer"
# Idempotent re-entry: caller owns the task and a PR is already
@@ -1344,8 +1357,8 @@ class Choreographer:
"""Refresh the task, auto-emit milestone progress, build the OK envelope.
git_service.create_pr writes pr_number / pr_url onto the task row;
the runner doesn't bubble that update back so we re-fetch. Task
#155 milestone progress fires server-side so the panel + audit
the runner doesn't bubble that update back so we re-fetch.
Milestone progress fires server-side so the panel + audit
log always show "opened PR #N" regardless of agent chattiness.
"""
refreshed = await self.task.get(task_id)
@@ -1389,8 +1402,8 @@ class Choreographer:
in the verb body.
The previous strict path required a separate ``submit_for_verification``
verb that wasn't on any manifest, making i_am_done unreachable
(audit D-08). Removed that requirement; the act of calling i_am_done
verb that wasn't on any manifest, making i_am_done unreachable.
Removed that requirement; the act of calling i_am_done
IS the self-verification.
"""
t = await self.task.get(task_id)
@@ -1403,7 +1416,7 @@ class Choreographer:
)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else "developer"
briefing = await self._briefing_for(agent_id, task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
ctx = _IAmDoneContext(
agent_id=agent_id,
task_id=task_id,
@@ -1471,7 +1484,7 @@ class Choreographer:
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._ensure_branch_pushed(ctx):
return await self._reject_i_am_done(ctx, rejection)
# Wave C5 (2026-05-12) — pre-gateway parity. Persist per-criterion
# Pre-gateway parity: persist per-criterion
# status now that all gates have passed. The write runs AFTER the
# verdict so it cannot change i_am_done's rejection behavior.
await self._write_criteria_status(ctx.agent_id, ctx.task_id, ctx.task)
@@ -1553,7 +1566,7 @@ class Choreographer:
) -> None:
"""Persist per-criterion addressing status to task.acceptance_criteria_status.
Wave C5 (2026-05-12) — pre-gateway parity. The i_am_done gate uses
Pre-gateway parity: the i_am_done gate uses
journal:reflect as a blanket addressing artifact when it is present
(one reflect note covers all criteria — spec §9 item 1). We surface
that decision as a structured per-criterion list so the panel and
@@ -1612,7 +1625,7 @@ class Choreographer:
)
await self._notify_qa(ctx.agent_id, ctx.task_id, t)
await self._touch(ctx.task_id)
# Task #155: server-side milestone progress so the panel always
# Server-side milestone progress so the panel always
# records the QA handoff regardless of agent's progress() habits.
await self._record_milestone_progress(
ctx.task_id,
@@ -1688,7 +1701,7 @@ class Choreographer:
)
if result.passed:
return None
return await self._build_tracing_gap(agent_id, task_id, result.missing)
return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)
async def _post_claim_journal_gate(
self,
@@ -1756,7 +1769,7 @@ class Choreographer:
)
if result.passed:
return None
return await self._build_tracing_gap(agent_id, task_id, result.missing)
return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)
async def _check_pm_decision_required(
self, verb: str, agent_id: UUID, task_id: UUID, t: Any
@@ -1769,7 +1782,7 @@ class Choreographer:
``submit_up``) use the verb-specific helpers below which thread
the additional state (reflect, notes, subtasks) into GateContext.
Wave C8 (2026-05-12) — pre-gateway parity: the gate requires the
Pre-gateway parity: the gate requires the
*most recent* journal:decision for (agent, task) to be no older
than ``settings.pm_decision_window_seconds``. Older decisions are
treated as missing so PMs write a fresh decision around each
@@ -1797,7 +1810,7 @@ class Choreographer:
)
if result.passed:
return None
return await self._build_tracing_gap(agent_id, task_id, result.missing)
return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)
async def _check_complete_gates(
self, agent_id: UUID, task_id: UUID, notes: str
@@ -1821,7 +1834,14 @@ class Choreographer:
task_view = SimpleNamespace(notes=notes)
ctx = _tr.GateContext(
journal_decision_present=has_decision,
journal_reflect_present=has_reflect,
# A PM closing/submitting a task documents it in its *decision* note;
# a separate *reflect* adds little for a coordination/review close and
# is exactly the artifact weak-model PMs forget — looping on the
# reflect gate until reaped (re-confirmed live 2026-06-10). Accept a
# decision as satisfying reflect for the PM complete/submit_up close;
# the gate still requires a decision + substantive notes, so the close
# stays documented — only the redundant second-artifact demand drops.
journal_reflect_present=has_reflect or has_decision,
notes_min_chars=getattr(_settings, "notes_min_chars", 20),
)
result = _tr.check_requirements(
@@ -1856,7 +1876,14 @@ class Choreographer:
task_view = SimpleNamespace(notes=notes)
ctx = _tr.GateContext(
journal_decision_present=has_decision,
journal_reflect_present=has_reflect,
# A PM closing/submitting a task documents it in its *decision* note;
# a separate *reflect* adds little for a coordination/review close and
# is exactly the artifact weak-model PMs forget — looping on the
# reflect gate until reaped (re-confirmed live 2026-06-10). Accept a
# decision as satisfying reflect for the PM complete/submit_up close;
# the gate still requires a decision + substantive notes, so the close
# stays documented — only the redundant second-artifact demand drops.
journal_reflect_present=has_reflect or has_decision,
notes_min_chars=getattr(_settings, "notes_min_chars", 20),
)
requirements: list[_tr.Requirement] = [
@@ -1886,7 +1913,7 @@ class Choreographer:
# NOTE: self_verified is no longer a precondition — i_am_done auto-runs
# the in_progress → verifying transition which sets it. The previous
# NOT_SELF_VERIFIED gate required a separate submit_for_verification
# verb that wasn't on any manifest (audit D-08).
# verb that wasn't on any manifest.
if not t.commits:
missing.append("NO_COMMITS")
hints.append(
@@ -1904,7 +1931,7 @@ class Choreographer:
return Envelope.tracing_gap(
missing=missing,
remediate=" ; ".join(hints),
context_briefing=await self._briefing_for(agent_id, task_id),
context_briefing=await self._briefing_for(agent_id, task_id, task=t),
)
async def _build_i_am_done_ok(
@@ -1912,7 +1939,7 @@ class Choreographer:
) -> Envelope:
"""Assemble the success envelope for i_am_done / _with_catchup.
Task #154: files_changed sourced from git (authoritative) so the
files_changed sourced from git (authoritative) so the
i_am_done envelope shows the same file list QA / docs / PMs will
see — independent of legacy ``add_files_modified`` plumbing.
"""
@@ -1934,7 +1961,7 @@ class Choreographer:
task_id=str(task_id),
next="idle until QA responds",
evidence=evidence.as_dict(),
context_briefing=await self._briefing_for(agent_id, task_id),
context_briefing=await self._briefing_for(agent_id, task_id, task=t),
).with_introspection(task=t, role=role)
@staticmethod
@@ -2015,11 +2042,16 @@ class Choreographer:
return simple_hints.get(missing_key)
async def _build_tracing_gap(
self, agent_id: UUID, task_id: UUID, missing: list[str]
self,
agent_id: UUID,
task_id: UUID,
missing: list[str],
*,
task: Any | None = None,
) -> Envelope:
"""Translate missing requirement keys into agent-facing hints.
Task #159: multi-missing remediate uses a numbered list so the
Multi-missing remediate uses a numbered list so the
agent sees each requirement as a distinct step instead of a
single semicolon-joined sentence the model parses as one
instruction. Each missing key with no hint in
@@ -2065,7 +2097,7 @@ class Choreographer:
return Envelope.tracing_gap(
missing=missing,
remediate=remediate,
context_briefing=await self._briefing_for(agent_id, task_id),
context_briefing=await self._briefing_for(agent_id, task_id, task=task),
)
async def _notify_qa(self, agent_id: UUID, task_id: UUID, t: Any) -> None:
@@ -2094,7 +2126,7 @@ class Choreographer:
``id`` keys) or ``capabilities`` (SQLAlchemy AgentTable, list of
strings). The DB-side AgentTable has no ``skills`` attribute,
so a naive ``target_agent.skills`` raises AttributeError on
production agents (audit D-06). Falls back to the first entry
production agents. Falls back to the first entry
in ``preference`` when no match is found.
"""
skills_attr = getattr(target_agent, "skills", None)
@@ -2183,7 +2215,7 @@ class Choreographer:
)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else "developer"
briefing = await self._briefing_for(agent_id, task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
try:
role = spec_module.Role(role_str)
except ValueError:
@@ -2254,7 +2286,7 @@ class Choreographer:
invalid_state when the status drifted between get and write.
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(agent_id, task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
@@ -2405,7 +2437,7 @@ class Choreographer:
from roboco.seeds.initial_data import AGENT_UUIDS
t = await self.task.get(task_id)
briefing = await self._briefing_for(agent_id, task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
@@ -2492,7 +2524,7 @@ class Choreographer:
atomic chain wrapped in a savepoint.
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(agent_id, task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
@@ -2602,8 +2634,8 @@ class Choreographer:
status="idle_with_unread",
task_id=None,
next=(
"address unread A2A and @mentions in context_briefing"
" before going idle"
"clear your inbox, then retry i_am_idle(): read_messages()"
" for unread A2A, notify_ack() per @mention notification"
),
context_briefing=briefing,
)
@@ -2611,6 +2643,10 @@ class Choreographer:
return await self._emit_rejection(
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
)
if guard := await self._pm_unfinished_review_guard(agent_id, briefing):
return await self._emit_rejection(
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
)
paused_ids = await self._auto_pause_in_progress_tasks(agent_id)
await self.task.mark_agent_idle(agent_id)
if paused_ids:
@@ -2672,6 +2708,38 @@ class Choreographer:
context_briefing=briefing,
)
async def _pm_unfinished_review_guard(
self, agent_id: UUID, briefing: dict[str, Any]
) -> Envelope | None:
"""Refuse i_am_idle when a PM still owns a task awaiting its own 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 PM was just re-dispatched in a loop. A PM that
owns an awaiting_pm_review task must act on it (complete to finish, or
reassign/delegate to route it back) before it can idle.
"""
agent = await self.task.agent_for(agent_id)
if not agent or agent.role not in ("cell_pm", "main_pm"):
return None
assigned = await self.task.list_assigned_for_agent(agent_id)
review = next(
(t for t in assigned if str(t.status) == "awaiting_pm_review"), None
)
if review is None:
return None
return Envelope.invalid_state(
message=(
f"you still own task {review.id} awaiting your review; a DM does"
" not route work, so idling just re-dispatches you."
),
remediate=(
"complete(task_id) to finish it, or reassign()/delegate() to"
" send it back — then retry i_am_idle()."
),
context_briefing=briefing,
)
async def _auto_pause_in_progress_tasks(self, agent_id: UUID) -> list[str]:
"""Pause every in_progress task assigned to this agent.
@@ -2683,7 +2751,7 @@ class Choreographer:
``i_am_idle`` can tell the agent which ``resume(task_id)`` calls
await it on the next respawn. Empty list when nothing was active.
Wave C7 (2026-05-12) — pre-gateway parity: a synthetic checkpoint is
Pre-gateway parity: a synthetic checkpoint is
written for each paused task so the panel's Checkpoints column reflects
reality. Checkpoint failure is swallowed; it must never block the pause.
"""
@@ -2698,7 +2766,7 @@ class Choreographer:
async def _write_auto_pause_checkpoint(self, agent_id: UUID, task: Any) -> None:
"""Write a synthetic checkpoint for a task that was auto-paused on i_am_idle.
Wave C7 (2026-05-12) — captures state-at-pause so the panel's
Captures state-at-pause so the panel's
Checkpoints column is never empty after an auto-pause. Agents that
want an explicit checkpoint before idling can call note(scope='note',
text='checkpoint: ...') first; this synthetic write covers the bare
@@ -2736,11 +2804,11 @@ class Choreographer:
agent_id=str(agent_id),
)
# --- Phase 2 (QA) verbs moved to ``qa.py`` (audit P2-2). ---
# --- QA verbs moved to ``qa.py``. ---
# --- Phase 3 (documenter + PM) verbs ---
# claim_doc_task + i_documented moved to ``doc.py`` (audit P2-2).
# claim_doc_task + i_documented moved to ``doc.py``.
_RICH_PLAN_FIELDS: ClassVar[tuple[str, ...]] = (
"approach",
@@ -2802,7 +2870,7 @@ class Choreographer:
)
agent = await self.task.agent_for(pm_agent_id)
role_str = str(agent.role) if agent is not None else "cell_pm"
briefing = await self._briefing_for(pm_agent_id, task_id)
briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
try:
role = spec_module.Role(role_str)
except ValueError:
@@ -2817,7 +2885,7 @@ class Choreographer:
verb="i_will_plan",
)
effective_plan = self._resolve_effective_plan(plan, rich_plan)
# Task #153: spec_ctx carries the resolved (possibly-dict) plan so the
# spec_ctx carries the resolved (possibly-dict) plan so the
# verb runner's set_plan handler persists the panel-shaped rich shape.
# Passing the raw `plan` string here was the bug that left the Plan tab
# empty: the runner uses spec_ctx, not _ClaimPlanStartContext.
@@ -2894,7 +2962,7 @@ class Choreographer:
)
agent = await self.task.agent_for(pm_agent_id)
role_str = str(agent.role) if agent is not None else "cell_pm"
briefing = await self._briefing_for(pm_agent_id, parent_task_id)
briefing = await self._briefing_for(pm_agent_id, parent_task_id, task=parent)
try:
role = spec_module.Role(role_str)
except ValueError:
@@ -2923,12 +2991,12 @@ class Choreographer:
task_id=parent_task_id,
verb="delegate",
)
# Task 19: foundation/policy/task_completeness gate runs BEFORE the
# The foundation/policy/task_completeness gate runs BEFORE the
# static/lifecycle guards. Auto-fill helpers patch unambiguous fields
# (team-from-slug, priority-from-parent), then `check(TASK_AT_CREATE,
# ...)` rejects under-filled payloads with `Envelope.incomplete_input`
# — the spec §5.2.1 interrogation pattern. Defense-in-depth: the
# service-layer raise from Task 18 still catches non-gateway callers.
# service-layer raise still catches non-gateway callers.
completeness_env = self._delegate_completeness_check(
inputs, parent, briefing, role_str
)
@@ -3066,7 +3134,7 @@ class Choreographer:
@staticmethod
def _is_cross_team_planning(new_type: str, new_team: str, sib_team: str) -> bool:
"""Task #157: planning subtasks on different teams are NOT
"""Planning subtasks on different teams are NOT
over-decomposition — main_pm fans planning out to per-cell PMs.
Both teams must be non-empty so an empty-team escape hatch can't
bypass the cap defensively.
@@ -3113,7 +3181,7 @@ class Choreographer:
1. **Same-type concurrency cap**: a parent may have AT MOST one
non-terminal subtask of types ``code`` / ``planning`` /
``documentation`` at any given time, regardless of assignee.
Task #157 exception: ``planning`` subtasks on different
Exception: ``planning`` subtasks on different
teams are allowed in parallel — that's main_pm's legitimate
cross-cell fanout.
2. **Same-assignee same-type** (fallback): a PM never delegates two
@@ -3178,7 +3246,7 @@ class Choreographer:
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
if str(inputs.task_type) == "documentation":
# Task #163: the lifecycle auto-handles documentation. After a
# The lifecycle auto-handles documentation. After a
# `code` subtask passes QA it transitions to
# awaiting_documentation and a *documenter* is spawned
# automatically. A PM-created `documentation` subtask assigned
@@ -3212,7 +3280,7 @@ class Choreographer:
"""Reject role-vs-type misclassifications.
Rules:
- (2026-05-09 smoke Bug B): delegating to a Cell PM requires
- delegating to a Cell PM requires
``task_type='planning'``. Cell PMs decompose; they don't execute.
- (2026-05-11 smoke): delegating to a Developer requires
``task_type in {'code', 'documentation', 'research'}``. Devs
@@ -3394,7 +3462,7 @@ class Choreographer:
briefing: dict[str, Any],
role_str: str,
) -> Envelope | None:
"""Task 19: foundation/policy/task_completeness gate for delegate.
"""The foundation/policy/task_completeness gate for delegate.
Auto-fills unambiguous fields (team-from-slug, priority-from-parent)
without overwriting explicit values, then runs `check(TASK_AT_CREATE,
@@ -3408,7 +3476,7 @@ class Choreographer:
The `acceptance_criteria=inputs.acceptance_criteria or []` collapse
at `_create_subtask_from_inputs` was removed alongside this gate,
so under-filled payloads now hit the service-layer raise (Task 18)
so under-filled payloads now hit the service-layer raise
instead of being silently substituted. This method is the
gateway-side defense; the service raise is defense-in-depth for
non-gateway callers.
@@ -3460,7 +3528,7 @@ class Choreographer:
) -> Envelope:
"""Run subtask creation and translate completeness raises into envelopes.
The defensive raises inside `_create_subtask_from_inputs` (Task 18)
The defensive raises inside `_create_subtask_from_inputs`
catch under-filled payloads that slipped past the gateway gate. Without
this translator they surface as Starlette 500s — which means the agent
never sees `field_hints`, retries indefinitely, and looks like a
@@ -3666,7 +3734,7 @@ class Choreographer:
and `inputs.nature` are guaranteed non-None / non-empty here. The
defensive `TaskCompletenessError` raises preserve correctness if
a future caller bypasses the gateway path — defense-in-depth in
line with Task 18's service-layer raise.
line with the service-layer raise.
"""
from roboco.foundation.policy.task_completeness import TaskCompletenessError
from roboco.models.base import TaskNature
@@ -3675,10 +3743,10 @@ class Choreographer:
team_enum, type_enum, complexity_enum = self._resolve_delegate_enums(inputs)
assignee_id = UUID(AGENT_UUIDS[inputs.assigned_to])
# Task 19: the `or []` collapse was removed. The gateway runs
# The `or []` collapse was removed. The gateway runs
# `_delegate_completeness_check` BEFORE this helper, so empty/None
# acceptance_criteria here means a non-gateway caller bypassed the
# check. Raise so the service-layer raise (Task 18) can attach the
# check. Raise so the service-layer raise can attach the
# field hints — never silently substitute.
if not inputs.acceptance_criteria:
raise TaskCompletenessError(
@@ -3728,7 +3796,7 @@ class Choreographer:
estimated_complexity=complexity_enum,
)
new_task = await self.task.create_subtask(req)
# Task #156: thread the parent's existing session links onto the
# Thread the parent's existing session links onto the
# new subtask so the assigned agent (dev/qa/doc) lands in the
# group chat the PM has already been talking in. Pre-gateway
# parity — sessions were wired to the whole tree at creation
@@ -3788,7 +3856,7 @@ class Choreographer:
returns, the task is handed off to the Main PM (reassign + a2a).
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(pm_agent_id, task_id)
briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
@@ -3852,7 +3920,7 @@ class Choreographer:
verb="submit_up",
)
t = outcome
# #182: do NOT hand the cell task to Main PM. The cell PM owns cell
# Do NOT hand the cell task to Main PM. The cell PM owns cell
# completion — it stays assigned to the cell PM, which is respawned to
# `complete` the task (merging the cell→root PR). Main PM only
# completes the ROOT (root→master + escalate-to-CEO).
@@ -3969,7 +4037,7 @@ class Choreographer:
statuses — PMs care about all assigned tasks (planning, paused, in
progress, awaiting_pm_review).
Pre-assigned pending tasks are checked first (Wave B6, 2026-05-12).
Pre-assigned pending tasks are checked first.
Smoke run 3 showed Main PM getting idle even though c7935d2c was
pending and assigned_to=main-pm because list_assigned_for_agent
ordered by priority/updated_at and could rank a pre-assigned pending
@@ -3985,7 +4053,7 @@ class Choreographer:
status=str(t.status),
task_id=str(t.id),
next=self._pm_next_hint(str(t.status), t.id),
context_briefing=await self._briefing_for(pm_agent_id, t.id),
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
)
assigned = await self.task.list_assigned_for_agent(pm_agent_id)
if assigned:
@@ -3995,7 +4063,7 @@ class Choreographer:
status=str(t.status),
task_id=str(t.id),
next=self._pm_next_hint(str(t.status), t.id),
context_briefing=await self._briefing_for(pm_agent_id, t.id),
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
)
return Envelope.ok(
status="idle",
@@ -4030,7 +4098,7 @@ class Choreographer:
status=str(t.status),
task_id=str(t.id),
next=f"investigate the block, then unblock(task_id='{t.id}')",
context_briefing=await self._briefing_for(pm_agent_id, t.id),
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
)
awaiting = await self.task.list_awaiting_pm_review_for_team(pm.team)
if awaiting:
@@ -4039,7 +4107,7 @@ class Choreographer:
status=str(t.status),
task_id=str(t.id),
next=f"review and complete(task_id='{t.id}')",
context_briefing=await self._briefing_for(pm_agent_id, t.id),
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
)
return Envelope.ok(
status="idle",
@@ -4060,7 +4128,7 @@ class Choreographer:
f"escalation/cross-cell help required: investigate, then "
f"unblock(task_id='{t.id}') or escalate_up()"
),
context_briefing=await self._briefing_for(pm_agent_id, t.id),
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
)
awaiting = await self.task.list_awaiting_main_pm_all()
if awaiting:
@@ -4069,7 +4137,7 @@ class Choreographer:
status=str(t.status),
task_id=str(t.id),
next=f"complete(task_id='{t.id}') opens master PR + escalates to CEO",
context_briefing=await self._briefing_for(pm_agent_id, t.id),
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
)
return Envelope.ok(
status="idle",
@@ -4158,7 +4226,7 @@ class Choreographer:
async def _own_review_hint(self, pm_agent_id: UUID, exclude_task_id: UUID) -> str:
"""Remediate suffix naming the PM's OWN task ready to complete.
Smoke-15 wedge (#170): a PM looped firing complete/unblock at the
A PM looped firing complete/unblock at the
wrong (parent) task_id while its own leaf sat at
``awaiting_pm_review``, never named in any rejection — minimax
never found the one correct call. Surface it explicitly.
@@ -4252,7 +4320,7 @@ class Choreographer:
task_id=task_id,
verb="cell_pm_complete",
)
# #181/#182: resolve the merge target from the PARENT task's real
# Resolve the merge target from the PARENT task's real
# branch_name. For a leaf this is the cell branch (same team — no
# change); for a cell task it is the root branch (feature/main_pm/…),
# which parent_branch_for would have mis-derived as feature/<cellteam>/…
@@ -4329,7 +4397,7 @@ class Choreographer:
main_pm_agent_id, root_task_id
),
)
# #183: accept in_progress too. A root resumed from paused (its
# Accept in_progress too. A root resumed from paused (its
# subtasks all done) sits in in_progress — there is no submit_up for
# roots to move it to awaiting_pm_review. main_pm_complete itself
# opens the root→master PR and walks it through awaiting_pm_review
@@ -4403,7 +4471,7 @@ class Choreographer:
if needs_pr:
await self.git.create_pr(t.branch_name, parent="master", is_root_pr=True)
# #183: escalate_to_ceo requires source=awaiting_pm_review, but a root
# escalate_to_ceo requires source=awaiting_pm_review, but a root
# resumed from paused is in_progress and nothing else moves it there
# (submit_up is cell-PM-only). The root→master PR now exists, so walk
# the root through awaiting_pm_review here. Uses the TaskService
@@ -4437,7 +4505,7 @@ class Choreographer:
# Use kwargs — service signature is (task_id, agent_role="cell_pm",
# notes=None). Positional was passing agent_id as task_id and the
# actual task_id as agent_role (audit D-07).
# actual task_id as agent_role.
t = await self.task.escalate_to_ceo(
task_id=root_task_id, agent_role="main_pm", notes=notes
)
@@ -4470,7 +4538,7 @@ class Choreographer:
spec doesn't model yet.
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(agent_id, task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
@@ -4531,7 +4599,7 @@ class Choreographer:
``composes=()`` (no atomic action for the runner to run).
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(pm_agent_id, task_id)
briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
@@ -4642,6 +4710,45 @@ class Choreographer:
# --- Phase 4 (board) verbs ---
async def _escalate_did_not_apply(
self,
*,
runner_error: str | None,
task: Any,
role_str: str,
briefing: dict[str, Any],
agent_id: UUID,
task_id: UUID,
) -> Envelope:
"""Rejection for an escalate_to_ceo the runner did not apply.
``runner_error`` set → the run_intent savepoint raised; otherwise the
service declined (task not in awaiting_pm_review). Either way a clean
invalid_state, never the unhandled ``None.status`` 500.
"""
if runner_error is not None:
message = f"verb runner failed: {runner_error}"
remediate = "check workspace + retry; if persistent, escalate"
else:
message = (
"escalate_to_ceo did not apply — the task is not in a state"
" that escalates to the CEO (needs awaiting_pm_review)"
)
remediate = (
"resolve or re-route the task; only awaiting_pm_review tasks"
" escalate to the CEO"
)
return await self._emit_rejection(
Envelope.invalid_state(
message=message,
remediate=remediate,
context_briefing=briefing,
).with_introspection(task=task, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
async def escalate_to_ceo(
self, agent_id: UUID, task_id: UUID, reason: str
) -> Envelope:
@@ -4660,7 +4767,7 @@ class Choreographer:
main_pm_complete).
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(agent_id, task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
@@ -4714,19 +4821,25 @@ class Choreographer:
)
runner = self._verb_runner()
runner_error: str | None = None
try:
t = await runner.run_intent("escalate_to_ceo", t, me, spec_ctx)
updated = await runner.run_intent("escalate_to_ceo", t, me, spec_ctx)
except Exception as exc:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"verb runner failed: {exc}",
remediate="check workspace + retry; if persistent, escalate",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
updated, runner_error = None, str(exc)
# run_intent returns None when the service declines the escalation (e.g. a
# board agent escalating a task not in awaiting_pm_review). Without this
# guard the OK path below dereferenced ``None.status`` — an unhandled 500
# (the board's escalate-from-blocked crash loop).
if updated is None:
return await self._escalate_did_not_apply(
runner_error=runner_error,
task=t,
role_str=role_str,
briefing=briefing,
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
t = updated
# Same as main_pm_complete: CEO acts via UI, not as a spawnable agent.
await self.task.reassign(task_id, None)
return Envelope.ok(
@@ -4737,6 +4850,6 @@ class Choreographer:
).with_introspection(task=t, role=role_str)
# board_triage + auditor_triage moved to ``board.py`` as the first
# per-role mixin extraction (audit P2-2). The Choreographer class is
# per-role mixin extraction. The Choreographer class is
# composed in ``__init__.py`` from BoardMixin + the rest of this
# _impl. Methods now resolve via Python's MRO.
@@ -57,6 +57,8 @@ class ChoreographerHelpers:
self,
agent_id: UUID,
task_id: UUID | None,
*,
task: Any | None = None,
) -> dict[str, Any]:
raise NotImplementedError
@@ -86,5 +88,7 @@ class ChoreographerHelpers:
agent_id: UUID,
task_id: UUID,
missing: list[str],
*,
task: Any | None = None,
) -> Envelope:
raise NotImplementedError
@@ -1,4 +1,4 @@
"""Board + Auditor verbs (P2-2 first per-role split).
"""Board + Auditor verbs (first per-role split).
Mixin extracted from ``_impl.py`` to prove the per-role pattern. Relies
on ``self.task`` and ``self._briefing_for`` from the base class via
+7 -7
View File
@@ -1,11 +1,11 @@
"""Documenter verbs (audit P2-2 second per-role split).
"""Documenter verbs (second per-role split).
Mixin for ``claim_doc_task`` and ``i_documented``. Inherits typed
helpers via ``ChoreographerHelpers`` under ``TYPE_CHECKING``; runtime
class is the composed ``Choreographer``.
Tasks 22 (lifecycle canonical spec): both verbs route their role/state
gate through ``spec.can_invoke_intent``. The verb-specific helpers
Both verbs route their role/state gate through
``spec.can_invoke_intent``. The verb-specific helpers
(``_verify_doc_owner``, ``_check_doc_gates``) STAY they encode the
notes-length / files-list / journal:reflect gates the spec doesn't
model. The self-review block on ``docs_complete`` lives at the atomic-
@@ -14,7 +14,7 @@ and naturally fires when the verb body builds a Context with
``actor_slug == original_developer_slug``; no verb-body retrofits
needed.
P2 Task 10: ``_check_doc_gates`` delegates the actual requirement
``_check_doc_gates`` delegates the actual requirement
checking to ``foundation.policy.tracing.check_requirements`` the
verbrequired-set mapping lives in ``VERB_REQUIREMENTS`` (single
source of truth). The hint translation lives in the shared
@@ -60,7 +60,7 @@ def _doc_refs_for(files: list[str], agent_id: UUID) -> list[dict[str, Any]]:
``i_documented`` receives a flat list of file paths, but
``Task.documents`` is ``list[DocRef]`` persisted as dicts readers do
``DocRef(**d)`` / ``d["path"]`` and the doc indexer does ``d.get``.
Stamping bare strings 500s ``list_docs`` and breaks indexing (#169).
Stamping bare strings 500s ``list_docs`` and breaks indexing.
"""
now = datetime.now(UTC).isoformat()
slug = str(agent_id)
@@ -192,7 +192,7 @@ class DocMixin(_Base):
# "start") but doc_claim is the runtime-correct specialized form
# that keeps status at AWAITING_DOCUMENTATION. See module docstring.
t = await self.task.doc_claim(doc_agent_id, task_id)
# Task #162: the documenter's clone is separate from the dev's;
# The documenter's clone is separate from the dev's;
# the task branch already exists (dev created it) so no checkout
# ran in the doc's workspace. Put the doc on the task branch now
# so roboco_docs_write / commit don't fail BRANCH_MISMATCH.
@@ -214,7 +214,7 @@ class DocMixin(_Base):
async def _claim_doc_evidence(self, task: Any, task_id: UUID) -> dict[str, Any]:
"""Build the evidence dict surfaced inline on claim_doc_task ok envelopes.
Task #154: files_changed sourced from git (authoritative) instead
files_changed sourced from git (authoritative) instead
of ``work_session.files_modified``, which the gateway commit()
does not populate. The docs writer sees an accurate file list.
"""
+5 -5
View File
@@ -1,4 +1,4 @@
"""QA verbs (audit P2-2 third per-role split).
"""QA verbs (third per-role split).
Mixin for ``claim_review``, ``pass_review``, ``fail_review`` and the
verb-specific helper ``_qa_pass_gate_check``. The helper stays with
@@ -8,8 +8,8 @@ Inherits from ``ChoreographerHelpers`` under ``TYPE_CHECKING`` only so
mypy resolves ``self.task`` etc. as typed; at runtime the composed
``Choreographer`` supplies the real attributes via MRO.
Tasks 21 (lifecycle canonical spec): all three verbs now route their
role/state gate through ``spec.can_invoke_intent``. The verb-specific
All three verbs route their role/state gate through
``spec.can_invoke_intent``. The verb-specific
helpers (``_verify_qa_owner``, ``_qa_pass_gate_check``) STAY they
encode notes-length / journal:learning / qa_evidence_inspected gates
the spec doesn't model. The self-review block lives at the atomic-
@@ -18,7 +18,7 @@ action layer (``_ATOMIC_ACTIONS["qa_pass" | "qa_fail"]
builds a Context with ``actor_slug == original_developer_slug``; no
verb-body retrofits needed.
P2 Task 9: ``_qa_pass_gate_check`` delegates the actual requirement
``_qa_pass_gate_check`` delegates the actual requirement
checking to ``foundation.policy.tracing.check_requirements`` the
verbrequired-set mapping lives in ``VERB_REQUIREMENTS`` (single
source of truth). The hint translation lives in the shared
@@ -185,7 +185,7 @@ class QAMixin(_Base):
authoritative source) + journal_highlights so the QA agent has
the full PR context up-front and can't miss a piece.
Task #154: files_changed comes from ``git.list_changed_files``
files_changed comes from ``git.list_changed_files``
instead of ``work_session.files_modified``. The legacy
``add_files_modified`` HTTP path that populated files_modified
is not called by the gateway ``commit()``, so the work_session