Files
roboco/roboco/services/gateway/choreographer/_impl.py
T
Renn F 091e4076a2 fix(gateway): reject Cell-PM-assigned subtasks that aren't task_type=planning
Bug B from the 2026-05-09 smoke run. main-pm called
delegate(assigned_to='be-pm', task_type='code'). The chain validator
let it through (be-pm IS in main-pm's allowed targets), the schema
let it through (task_type='code' is a valid enum value), and the
subtask got created mis-typed. Task 0 made it cosmetically work
because PMs can now plan code-typed parents — but the model is
wrong: a Cell PM owns the PLANNING of the slice; the code execution
is what they delegate to devs.

New gate in _delegate_static_guards: when assignee is a Cell PM
(be-pm/fe-pm/ux-pm), task_type MUST be 'planning'. Returns
invalid_state with a remediate hint pointing at the right type.
Devs are unrestricted (could be code OR documentation, depending
on the slice).

Tests: 3139 passing (+ 2 regression tests pinning the rule), 100%
coverage, ruff clean.
2026-05-09 03:29:04 +02:00

2264 lines
94 KiB
Python

"""Choreographer — composes existing services into intent-verb sequences.
This module has interface signatures only in Phase 0. Each verb's full
implementation lands in its respective phase (Phase 1: dev verbs, Phase 2:
QA verbs, Phase 3: doc + PM verbs, Phase 4: board verbs).
The signatures are stable contracts that the MCP servers and the
/api/v2/flow/* endpoints will call into. Phase 0 wires the dependency
injection so later phases just fill in the bodies.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, ClassVar
from uuid import UUID
import structlog
from roboco.services.gateway.claim_guards import (
already_active_guard,
paused_tasks_guard,
pm_cannot_execute_code_guard,
role_typed_claim_guard,
sibling_sequence_guard,
)
from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import (
BriefingInputs,
build_context_briefing,
build_evidence_for_task,
)
from roboco.services.gateway.merge_chain import parent_branch_for
from roboco.services.gateway.remediation import (
hint_for_missing_progress,
hint_for_missing_reflect,
hint_for_unaddressed_acceptance_criteria,
)
from roboco.services.gateway.tracing_gate import (
GateContext,
Requirement,
check_requirements,
)
logger = structlog.get_logger()
@dataclass(frozen=True)
class ChoreographerDeps:
"""All service dependencies bundled for Choreographer.
Frozen dataclass to avoid PLR0913 (too many arguments) and to make
dependency injection explicit. Each field is typed as Any in Phase 1 —
per-service Protocol typing lands alongside verb implementations that
actually exercise the methods.
"""
task: Any
work_session: Any
git: Any
a2a: Any
journal: Any
audit: Any
evidence_repo: Any
@dataclass(frozen=True)
class DelegateInputs:
"""Bundle of fields the ``delegate`` verb receives from the route layer.
`task_type` has no default — the v2 schema enforces this at the HTTP
boundary, but defaulting here too would let direct callers (tests,
other internal code) silently pick 'code' and recreate the
2026-05-08 deadlock.
"""
title: str
description: str
assigned_to: str
team: str
task_type: str
acceptance_criteria: list[str] | None = None
estimated_complexity: str = "medium"
class Choreographer:
"""Composes existing services into intent-verb sequences.
Constructor takes a ``ChoreographerDeps`` bundle (DI). Verb methods are
async. Each returns a standardized Envelope. Implementations land
progressively: see __init__ docstring.
Service deps are typed as ``Any`` in Phase 1 — per-service Protocol typing
lands alongside the verb implementations that exercise the methods.
"""
def __init__(self, deps: ChoreographerDeps) -> None:
"""Initialize Choreographer with bundled service dependencies.
Args:
deps: Frozen dataclass holding all 7 service dependencies.
"""
self._deps = deps
# --- Convenience properties so call-sites stay readable ---
@property
def task(self) -> Any:
return self._deps.task
@property
def work_session(self) -> Any:
return self._deps.work_session
@property
def git(self) -> Any:
return self._deps.git
@property
def a2a(self) -> Any:
return self._deps.a2a
@property
def journal(self) -> Any:
return self._deps.journal
@property
def audit(self) -> Any:
return self._deps.audit
@property
def evidence_repo(self) -> Any:
return self._deps.evidence_repo
async def _touch(self, task_id: UUID | None) -> None:
"""Best-effort heartbeat write; silent on missing task."""
if task_id is not None:
await self.task.heartbeat(task_id)
async def _emit_rejection(
self,
env: Envelope,
*,
agent_id: UUID,
task_id: UUID | None,
verb: str,
) -> Envelope:
"""Audit-log a rejection envelope; pass through unchanged on success.
Idempotent on success envelopes: the early `env.error is None`
return is the only fast path. Audit writes are best-effort —
failures must NEVER block the verb (the agent's response is the
contract; the audit row is observability-only).
Introspection (`current_state` + `valid_next_verbs`) is applied
at the call site via `Envelope.with_introspection(task, role)`
rather than here — this keeps the rejection path's signature
narrow and lets the helper stay framework-clean.
Stashes ``correlation_id`` from the structlog contextvars (bound
by ``CorrelationIdMiddleware`` for the inbound request) and a
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).
"""
if env.error is None:
return env
from uuid import uuid4 as _uuid4
details: dict[str, Any] = {
"verb": verb,
"reason": env.error,
"message": env.message,
"missing": env.missing or [],
"attempt_id": str(_uuid4()),
}
cid = structlog.contextvars.get_contextvars().get("correlation_id")
if cid is not None:
details["correlation_id"] = cid
try:
await self.audit.log_event(
event_type="gateway.rejected",
agent_id=agent_id,
task_id=task_id,
details=details,
)
except Exception as exc:
# Audit is best-effort: it must NEVER block the verb. The agent's
# response is the contract; the audit row is observability-only.
logger.warning("audit.log_event failed", error=str(exc), verb=verb)
return env
# --- Phase 1 (developer) verbs ---
async def give_me_work(self, agent_id: UUID) -> Envelope:
"""Return the agent's most-actionable task or signal idle."""
agent = await self._deps.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else "developer"
assigned = await self._deps.task.list_assigned_for_agent(agent_id)
if assigned:
t = assigned[0]
return Envelope.ok(
status=str(t.status),
task_id=str(t.id),
next=f"call i_will_work_on(task_id='{t.id}', plan='<plan>') to start",
context_briefing=await self._briefing_for(agent_id, t.id),
).with_introspection(task=t, role=role)
paused = await self._deps.task.list_paused_for_agent(agent_id)
if paused:
t = paused[0]
return Envelope.ok(
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),
).with_introspection(task=t, role=role)
return Envelope.ok(
status="idle",
task_id=None,
next="call i_am_idle() — no work available",
context_briefing=await self._briefing_for(agent_id, None),
)
async def _briefing_for(
self, agent_id: UUID, task_id: UUID | None
) -> dict[str, Any]:
"""Assemble context_briefing for agent_id, optionally scoped to task_id."""
repo = self._deps.evidence_repo
inputs = BriefingInputs(
unread_a2a=await repo.list_unread_a2a(agent_id),
unread_mentions=await repo.list_unread_mentions(agent_id),
pending_notifications=await repo.list_pending_notifications(agent_id),
task_metadata_gaps=(
await repo.task_metadata_gaps(task_id) if task_id else []
),
recent_team_activity=await repo.recent_team_activity(agent_id),
blockers_in_my_lane=await repo.blockers_in_lane(agent_id),
)
return build_context_briefing(inputs)
@staticmethod
def _run_role_guards(
role: str, task_type: str, *, skip_pm_code: bool, skip_role_typed: bool
) -> Envelope | None:
"""Sync role-based guards (pm_cannot_execute_code, role_typed)."""
if not skip_pm_code and (
guard := pm_cannot_execute_code_guard(role, task_type)
):
return guard
if not skip_role_typed and (guard := role_typed_claim_guard(role, task_type)):
return guard
return None
async def _run_claim_concurrency_guards(
self, agent_id: UUID, task: Any, *, skip_sequence: bool
) -> Envelope | None:
"""Async concurrency-based guards (already_active, paused, sequence)."""
in_progress = await self.task.list_in_progress_for_agent(agent_id)
if guard := already_active_guard(in_progress, task.id):
return guard
paused = await self.task.list_paused_for_agent(agent_id)
if guard := paused_tasks_guard(paused):
return guard
if not skip_sequence:
siblings = await self._fetch_siblings(task)
if guard := sibling_sequence_guard(task, siblings):
return guard
return None
async def _run_claim_guards(
self,
*,
agent_id: UUID,
task: Any,
skip_role_typed: bool = False,
skip_pm_code: bool = False,
skip_sequence: bool = False,
) -> Envelope | None:
"""Run claim-time guards (Gate Set A). Returns rejection or None.
Pre-gateway location: _helpers.py:124-204 + claim.py:121-180.
Optional skip flags isolate guards that don't apply to a given verb:
- skip_role_typed: i_will_plan/claim_review/claim_doc_task have their
own role checks; only i_will_work_on uses role_typed_claim_guard.
- skip_pm_code: claim_review/claim_doc_task call sites cannot be PMs
to begin with; pm_cannot_execute_code is meaningless there.
- skip_sequence: some verbs (resumption of an already-claimed task)
do not need to re-validate sibling order.
"""
agent = await self.task.agent_for(agent_id)
role = agent.role if agent is not None else "developer"
task_type = str(task.task_type)
if guard := self._run_role_guards(
role,
task_type,
skip_pm_code=skip_pm_code,
skip_role_typed=skip_role_typed,
):
return guard
return await self._run_claim_concurrency_guards(
agent_id, task, skip_sequence=skip_sequence
)
async def _fetch_siblings(self, task: Any) -> list[Any]:
"""Fetch sibling tasks for the sequence-order guard.
Returns ``[]`` when the task has no parent (root task) so the
guard short-circuits. Otherwise returns the parent's subtasks via
``TaskService.get_subtasks``.
"""
parent_id = getattr(task, "parent_task_id", None)
if parent_id is None:
return []
siblings: list[Any] = await self.task.get_subtasks(parent_id)
return siblings
async def _non_terminal_subtask_ids(self, parent_task_id: UUID) -> str:
"""Return a human-readable comma-separated list of non-terminal subtasks.
Used by Gate Set F closure-time guards to name exactly which
subtasks are blocking parent completion.
"""
terminal = {"completed", "cancelled"}
subtasks: list[Any] = await self.task.get_subtasks(parent_task_id)
non_terminal = [s for s in subtasks if str(s.status) not in terminal]
if not non_terminal:
return "(none — query out-of-sync, retry)"
# Format: "<id> (<status>)"
return ", ".join(f"{s.id} ({s.status})" for s in non_terminal)
async def _subtasks_not_terminal_envelope(
self,
agent_id: UUID,
task_id: UUID,
*,
context_phrase: str,
) -> Envelope | None:
"""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).
``context_phrase`` lets each caller name the action being blocked
(e.g., "bubbling up", "completing parent").
"""
if await self.task.all_subtasks_terminal(task_id):
return None
non_terminal = await self._non_terminal_subtask_ids(task_id)
return Envelope.tracing_gap(
missing=["subtasks not all terminal"],
remediate=(
f"all subtasks must be in completed/cancelled before"
f" {context_phrase}. Non-terminal subtasks: {non_terminal}"
),
context_briefing=await self._briefing_for(agent_id, task_id),
)
async def _i_will_work_on_pending(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
plan: str | None,
briefing: dict[str, Any],
) -> tuple[Envelope | None, Any]:
"""Pending-branch dispatch for i_will_work_on. Atomic: validates
the plan precondition BEFORE calling claim() so a missing-plan
rejection doesn't leave the task in `claimed` with no plan.
Pre-fix (2026-05-09 smoke Bug A): claim() ran first, then the
plan check failed → task was stuck in `claimed` because the
`_i_will_work_on_claimed` branch (the natural retry path) had
no plan-recovery logic. Now ordering is: guards → plan check →
claim → set_plan → start.
"""
if guard := await self._run_claim_guards(agent_id=agent_id, task=t):
return self._with_briefing(guard, briefing), t
# Plan precondition BEFORE any state mutation. Atomic invariant.
if not t.plan and not plan:
return Envelope.tracing_gap(
missing=["plan"],
remediate=(
f"call i_will_work_on(task_id='{task_id}',"
f" plan='<one-paragraph plan describing what you will do>')"
),
context_briefing=briefing,
), t
# claim() transitions pending → claimed; idempotent for same assignee.
# Branch creation runs inside _finalize_claim and rolls back on
# failure (audit P0-7 / S-01); we surface the failure as an envelope
# so the agent gets remediate instead of a 500.
try:
t = await self.task.claim(task_id, agent_id)
except Exception as exc:
return Envelope.invalid_state(
message=f"claim failed during finalization: {exc}",
remediate=(
"branch or workspace setup failed; the claim was rolled"
" back. Check workspace + token, then retry"
" i_will_work_on(task_id, plan)."
),
context_briefing=briefing,
), None
if t is None:
return Envelope.invalid_state(
message="claim failed",
remediate="task may already be claimed by another agent",
context_briefing=briefing,
), t
if plan:
t = await self.task.set_plan(task_id, plan)
t = await self.task.start(task_id, agent_id)
if t is None:
return self._start_failed_envelope(task_id, briefing), t
return None, t
@staticmethod
def _start_failed_envelope(task_id: UUID, briefing: dict[str, Any]) -> Envelope:
"""Rejection envelope when ``task.start()`` returns None.
``start()`` returns None on invalid status, ownership mismatch, or
missing plan. Surface the failure rather than fall through to an
OK envelope that dereferences ``None.status``.
"""
return Envelope.invalid_state(
message=f"start failed for task {task_id}",
remediate=(
"task not in a startable state"
" (claimed/paused/needs_revision) or no plan recorded"
),
context_briefing=briefing,
)
async def _i_will_work_on_needs_revision(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
briefing: dict[str, Any],
) -> tuple[Envelope | None, Any]:
"""needs_revision branch for i_will_work_on. Returns (rejection|None, task)."""
if t.assigned_to != agent_id:
t = await self.task.claim(task_id, agent_id)
if t is None:
return Envelope.invalid_state(
message="claim failed",
remediate="task may already be claimed by another agent",
context_briefing=briefing,
), None
t = await self.task.start(task_id, agent_id)
if t is None:
return self._start_failed_envelope(task_id, briefing), None
return None, t
async def _i_will_work_on_claimed(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
plan: str | None,
briefing: dict[str, Any],
) -> tuple[Envelope | None, Any]:
"""claimed branch for i_will_work_on. Returns (rejection|None, task).
Recovery path (Bug A from 2026-05-09 smoke): if the task is in
`claimed` without a plan (e.g. orchestrator restart, prior
partial-claim race) and the caller now supplies one, set it
before start() instead of failing with "no plan recorded". If
the task still has no plan and none is supplied, surface the
same tracing_gap shape `_i_will_work_on_pending` uses.
"""
guard = await self._run_claim_guards(
agent_id=agent_id, task=t, skip_sequence=True
)
if guard:
return self._with_briefing(guard, briefing), None
if not t.plan and not plan:
return Envelope.tracing_gap(
missing=["plan"],
remediate=(
f"call i_will_work_on(task_id='{task_id}',"
f" plan='<one-paragraph plan describing what you will do>')"
),
context_briefing=briefing,
), None
if plan and not t.plan:
t = await self.task.set_plan(task_id, plan)
t = await self.task.start(task_id, agent_id)
if t is None:
return self._start_failed_envelope(task_id, briefing), None
return None, t
async def i_will_work_on(
self, agent_id: UUID, task_id: UUID, plan: str | None = None
) -> Envelope:
"""Claim/start/recover any actionable state of agent_id's task_id."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=agent_id,
task_id=task_id,
verb="i_will_work_on",
)
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else "developer"
status = str(t.status)
briefing = await self._briefing_for(agent_id, task_id)
rejection: Envelope | None = None
if status == "needs_revision":
rejection, t = await self._i_will_work_on_needs_revision(
agent_id, task_id, t, briefing
)
elif status == "pending":
rejection, t = await self._i_will_work_on_pending(
agent_id, task_id, t, plan, briefing
)
elif status == "claimed" and t.assigned_to == agent_id:
rejection, t = await self._i_will_work_on_claimed(
agent_id, task_id, t, plan, briefing
)
elif status == "in_progress" and t.assigned_to == agent_id:
# Idempotent re-entry: respawned dev re-calling i_will_work_on
# on a task they already own in_progress. Skip start() (would
# reject — wrong source state) but fall through to the OK
# envelope at the end. Heartbeat fires there too, refreshing
# reaper activity.
pass
else:
rejection = Envelope.invalid_state(
message=f"task {task_id} is in {status}; cannot start work",
remediate="call give_me_work() to find an actionable task",
context_briefing=briefing,
)
if rejection is not None:
rejection.with_introspection(task=t, role=role)
return await self._emit_rejection(
rejection,
agent_id=agent_id,
task_id=task_id,
verb="i_will_work_on",
)
await self._touch(task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"edit + commit(message) for each meaningful change,"
" then open_pr(task_id) and i_am_done(task_id)"
),
context_briefing=briefing,
).with_introspection(task=t, role=role)
@staticmethod
def _with_briefing(env: Envelope, briefing: dict[str, Any]) -> Envelope:
"""Attach a context_briefing to an Envelope (mutate-and-return helper)."""
env.context_briefing = briefing
return env
async def open_pr(self, agent_id: UUID, task_id: UUID) -> Envelope:
"""Push the dev's branch and open a PR.
Atomic: validates ALL preconditions (assignee, commits,
no-prior-PR) BEFORE running any git side effects. If any check
fails, no PR is opened. After success, the dev calls
``i_am_done`` to actually transition the task to awaiting_qa.
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).
Idempotent on re-call: if a PR is already open, returns OK
pointing the dev at ``i_am_done`` without opening another.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=agent_id,
task_id=task_id,
verb="open_pr",
)
briefing = await self._briefing_for(agent_id, task_id)
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else "developer"
if t.assigned_to != agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"task {task_id} is not assigned to you",
remediate="call give_me_work() to find your work",
context_briefing=briefing,
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="open_pr",
)
if not t.commits:
return await self._emit_rejection(
Envelope.invalid_state(
message="no commits on this task yet",
remediate=(
"commit at least one change before submitting for QA — "
"call commit(message='<subject>')"
),
context_briefing=briefing,
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="open_pr",
)
if t.pr_number is not None:
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
f"PR #{t.pr_number} already open; call "
f"i_am_done(task_id, notes='...') when self-verified"
),
context_briefing=briefing,
).with_introspection(task=t, role=role)
await self._touch(task_id)
await self.git.push_branch(t.branch_name)
parent = parent_branch_for(t.branch_name)
pr = await self.git.create_pr(t.branch_name, parent=parent, is_root_pr=False)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
f"PR #{pr['pr_number']} opened; call "
f"i_am_done(task_id, notes='...') when self-verified"
),
context_briefing=briefing,
).with_introspection(task=t, role=role)
async def i_am_done(self, agent_id: UUID, task_id: UUID, notes: str) -> Envelope:
"""Submit work for QA.
Preconditions enforced by gates:
- tracing: progress entry, journal:reflect, acceptance criteria
- field-level: at least one commit, PR open
The dev must have called ``commit()`` (do_server) at least once and
``open_pr(task_id)`` to push + open the PR. Calling i_am_done
is the dev's explicit attestation that the work is complete; it
auto-runs the in_progress → verifying transition (which seeds
``self_verified``) and then verifying → awaiting_qa.
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
IS the self-verification.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=agent_id,
task_id=task_id,
verb="i_am_done",
)
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else "developer"
if t.assigned_to != agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via i_will_work_on(task_id) first",
context_briefing=await self._briefing_for(agent_id, task_id),
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="i_am_done",
)
# 1. Tracing-gate preconditions (progress / reflect / acceptance)
if rejection := await self._check_tracing_gates(agent_id, task_id, t):
rejection.with_introspection(task=t, role=role)
return await self._emit_rejection(
rejection, agent_id=agent_id, task_id=task_id, verb="i_am_done"
)
# 2. Field-level gates (Gate Set E) — commits + PR (self_verified
# auto-set in step 3, so it's not a precondition the dev must satisfy).
if rejection := await self._check_submit_qa_field_gates(agent_id, task_id, t):
rejection.with_introspection(task=t, role=role)
return await self._emit_rejection(
rejection, agent_id=agent_id, task_id=task_id, verb="i_am_done"
)
# 3. Auto-run in_progress → verifying (sets self_verified) if needed.
if str(t.status) == "in_progress":
verified = await self.task.submit_verification(agent_id, task_id, notes)
if verified is not None:
t = verified
# 4. Submit verifying → awaiting_qa.
submitted = await self.task.submit_qa(agent_id, task_id, notes)
if submitted is not None:
t = submitted
await self._notify_qa(agent_id, task_id, t)
await self._touch(task_id)
return await self._build_i_am_done_ok(agent_id, task_id, t)
async def _check_tracing_gates(
self, agent_id: UUID, task_id: UUID, t: Any
) -> Envelope | None:
"""Run progress / reflect / acceptance-criteria tracing gates."""
has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
gate_ctx = GateContext(journal_reflect_present=has_reflect)
gate = check_requirements(
t,
[
Requirement.PROGRESS_AT_LEAST_ONE,
Requirement.JOURNAL_REFLECT,
Requirement.ACCEPTANCE_CRITERIA_ADDRESSED,
],
gate_ctx,
)
if gate.passed:
return None
return await self._build_tracing_gap(agent_id, task_id, gate.missing)
async def _check_submit_qa_field_gates(
self, agent_id: UUID, task_id: UUID, t: Any
) -> Envelope | None:
"""Gate Set E field-level gates restored from tasks.py:903-940 at 0c3d15a.
Each missing field becomes its own tracing_gap entry with the
matching pre-gateway error code.
"""
missing: list[str] = []
hints: list[str] = []
# 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).
if not t.commits:
missing.append("NO_COMMITS")
hints.append(
"no commits on this task yet — call commit(message='<subject>')"
" before i_am_done"
)
if t.pr_number is None:
missing.append("NO_PR")
hints.append(
"no PR open — call open_pr(task_id) to push your"
" branch and open the PR, then retry i_am_done"
)
if not missing:
return None
return Envelope.tracing_gap(
missing=missing,
remediate=" ; ".join(hints),
context_briefing=await self._briefing_for(agent_id, task_id),
)
async def _build_i_am_done_ok(
self, agent_id: UUID, task_id: UUID, t: Any
) -> Envelope:
"""Assemble the success envelope for i_am_done / _with_catchup."""
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id
)
files_changed: list[str] = []
if t.work_session_id:
files_changed = await self.work_session.files_changed(t.work_session_id)
evidence = build_evidence_for_task(
t,
journal_highlights=journal_highlights,
files_changed=files_changed,
)
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else "developer"
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="idle until QA responds",
evidence=evidence.as_dict(),
context_briefing=await self._briefing_for(agent_id, task_id),
).with_introspection(task=t, role=role)
async def _build_tracing_gap(
self, agent_id: UUID, task_id: UUID, missing: list[str]
) -> Envelope:
"""Translate missing requirement keys into agent-facing hints."""
hints: list[str] = []
unaddressed: list[str] = []
for m in missing:
if m == "progress>=1":
hints.append(hint_for_missing_progress())
elif m == "journal:reflect":
hints.append(hint_for_missing_reflect(task_id=str(task_id)))
elif m.startswith("acceptance_criterion:"):
unaddressed.append(m.split(":", 1)[1])
if unaddressed:
hints.append(
hint_for_unaddressed_acceptance_criteria(
criteria=unaddressed,
task_id=str(task_id),
)
)
return Envelope.tracing_gap(
missing=missing,
remediate=" ; ".join(hints),
context_briefing=await self._briefing_for(agent_id, task_id),
)
async def _notify_qa(self, agent_id: UUID, task_id: UUID, t: Any) -> None:
"""Reassign + A2A-notify the QA agent for this task's team.
``submit_qa`` clears ``assigned_to`` to None. We then explicitly
reassign to the QA agent so the orchestrator's per-agent task
polling spawns QA (not the dev again) for the next stage.
"""
qa_agent = await self.task.qa_agent_for_team(t.team)
if qa_agent is not None:
await self.task.reassign(task_id, qa_agent.id)
skill = self._resolve_skill(qa_agent, ["code_review", "qa_review"])
await self.a2a.send(
from_agent=agent_id,
to_agent=qa_agent.id,
skill=skill,
task_id=task_id,
body=f"Ready for review. PR: {t.pr_url}",
)
def _resolve_skill(self, target_agent: Any, preference: list[str]) -> str:
"""Pick first skill in preference list that target_agent has.
Reads from either ``skills`` (gateway view, list of dicts with
``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
in ``preference`` when no match is found.
"""
skills_attr = getattr(target_agent, "skills", None)
capabilities_attr = getattr(target_agent, "capabilities", None)
raw = skills_attr if skills_attr is not None else capabilities_attr
have: set[str] = set()
for s in raw or []:
if isinstance(s, dict):
sid = s.get("id")
if sid:
have.add(sid)
elif isinstance(s, str):
have.add(s)
for skill in preference:
if skill in have:
return skill
return preference[0]
async def i_am_blocked(
self, agent_id: UUID, task_id: UUID, reason: str
) -> Envelope:
"""Escalate task_id and write a struggle journal entry; idle the agent."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=agent_id,
task_id=task_id,
verb="i_am_blocked",
)
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else "developer"
await self.journal.write_struggle(
agent_id=agent_id, task_id=task_id, content=reason
)
t = await self.task.escalate(agent_id, task_id, reason)
await self._touch(task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="idle — PM will resolve and notify",
context_briefing=await self._briefing_for(agent_id, task_id),
).with_introspection(task=t, role=role)
async def unclaim(self, agent_id: UUID, task_id: UUID) -> Envelope:
"""Voluntarily release a claimed/in_progress task back to pending.
Audit J33 — ``_pending_assignment_guard`` already remediates with
"or unclaim it first," but the verb didn't exist. This makes that
promise true. The work-in-progress branch survives; only the claim
is released so another agent (or the same one, fresh) can pick it
up. State and authorization checks live here; the DB write itself
is in ``TaskService.unclaim_for_agent``.
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(agent_id, task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=agent_id,
task_id=task_id,
verb="unclaim",
)
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else "developer"
if t.assigned_to != agent_id:
# The task was reassigned out from under this agent — most
# commonly by an upstream verb that legitimately changed
# ownership (cell_pm_complete propagating to the parent,
# main_pm_complete clearing assigned_to to None when
# escalating to CEO, or a PM unblocking with restore=True).
# The agent's local state is stale; tell it concretely.
current_owner = (
str(t.assigned_to) if t.assigned_to is not None else "<unassigned>"
)
return await self._emit_rejection(
Envelope.not_authorized(
message=(
f"task {task_id} is no longer assigned to you "
f"(current owner: {current_owner})"
),
remediate=(
"the task was reassigned by an upstream verb "
"(cell_pm_complete / main_pm_complete / unblock). "
"call give_me_work() to find your current work."
),
context_briefing=briefing,
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="unclaim",
)
after = await self.task.unclaim_for_agent(task_id, agent_id)
if after is None:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"cannot unclaim from status {t.status}",
remediate="only claimed/in_progress tasks can be unclaimed",
context_briefing=briefing,
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="unclaim",
)
# Deliberately no _touch — unclaim clears assigned_to, so there is
# no claimant heartbeat to refresh. (Asymmetric with `resume` by
# design: resume keeps the same claimant active and does heartbeat.)
return Envelope.ok(
status=str(after.status),
task_id=str(task_id),
next="task returned to pending; another agent (or you, fresh) can claim",
context_briefing=briefing,
).with_introspection(task=after, role=role)
async def resume(self, agent_id: UUID, task_id: UUID) -> Envelope:
"""Resume a paused task this agent owns; transitions paused → in_progress.
Audit J33 — ``i_am_idle`` auto-pauses owned in_progress tasks (so
the closure dispatcher can wake the agent when subtasks finish),
and the lifecycle table allows ``paused → in_progress``, but no
verb exposed that transition to agents. ``i_will_work_on`` is
explicitly limited to needs_revision/pending/claimed; overloading
it would muddy state-machine intent. ``resume`` keeps it explicit.
State and authorization checks live here; the DB write itself is
in ``TaskService.resume_for_agent``.
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(agent_id, task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=agent_id,
task_id=task_id,
verb="resume",
)
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else "developer"
if t.assigned_to != agent_id:
# See unclaim's matching branch for the rationale: the task
# was reassigned by an upstream verb. Surface the actual
# current owner so the agent can stop looping on a stale
# task_id and call give_me_work() instead.
current_owner = (
str(t.assigned_to) if t.assigned_to is not None else "<unassigned>"
)
return await self._emit_rejection(
Envelope.not_authorized(
message=(
f"task {task_id} is no longer assigned to you "
f"(current owner: {current_owner})"
),
remediate=(
"the task was reassigned by an upstream verb. "
"call give_me_work() to find your current work."
),
context_briefing=briefing,
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="resume",
)
after = await self.task.resume_for_agent(task_id, agent_id)
if after is None:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"cannot resume from status {t.status}",
remediate="only paused tasks can be resumed",
context_briefing=briefing,
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="resume",
)
# Heartbeat — agent is back to active work after the resume.
await self._touch(task_id)
return Envelope.ok(
status=str(after.status),
task_id=str(task_id),
next="resumed; continue working — call commit() when ready",
context_briefing=briefing,
).with_introspection(task=after, role=role)
async def i_am_idle(self, agent_id: UUID) -> Envelope:
"""Report no more work. Soft-block if there are unread A2As or @mentions.
Before marking the agent idle:
1. Bail with ``idle_with_unread`` when context_briefing has unread A2A
or @mentions (must address those first).
2. Refuse with INVALID_STATE if the agent has any pending tasks
assigned but never claimed — they must call i_will_work_on (dev/qa/
doc) or i_will_plan (pm) first. (Gate Set C, pre-gateway implicit
via the orchestrator's auto-respawn.)
3. Auto-pause every in_progress task this agent owns so the
orchestrator's PM-closure dispatcher can wake them when subtasks
finish, instead of leaving the parent stuck at ``in_progress``
forever.
"""
briefing = await self._briefing_for(agent_id, None)
if briefing.get("unread_a2a") or briefing.get("unread_mentions"):
return Envelope.ok(
status="idle_with_unread",
task_id=None,
next=(
"address unread A2A and @mentions in context_briefing"
" before going idle"
),
context_briefing=briefing,
)
if guard := await self._pending_assignment_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:
# Tell the agent how to come back to these tasks. Without this,
# an agent respawned for a paused task has no signal that
# `resume(task_id)` is the way back in.
joined = ", ".join(f"resume(task_id='{tid}')" for tid in paused_ids)
next_msg = (
"container will shut down; on respawn, "
f"call {joined} to continue paused work"
)
else:
next_msg = "container will shut down"
return Envelope.ok(
status="idle",
task_id=None,
next=next_msg,
context_briefing=briefing,
)
async def _pending_assignment_guard(
self, agent_id: UUID, briefing: dict[str, Any]
) -> Envelope | None:
"""Refuse i_am_idle when caller owns any pending (unclaimed) task.
Pre-gateway: the orchestrator would respawn the agent after
i_am_idle if they still owned pending work, leading to a tight
respawn loop. Now an explicit refusal lets the agent fix it via
i_will_work_on or i_will_plan before exiting.
"""
assigned = await self.task.list_assigned_for_agent(agent_id)
pending = [t for t in assigned if str(t.status) == "pending"]
if not pending:
return None
first = pending[0]
agent = await self.task.agent_for(agent_id)
verb = (
"i_will_plan"
if agent and agent.role in ("cell_pm", "main_pm")
else ("i_will_work_on")
)
return Envelope.invalid_state(
message=(
f"You have task {first.id} assigned but never claimed; "
"cannot idle until claimed or unclaimed."
),
remediate=(
f"call {verb}(task_id='{first.id}') to start work, or"
" unclaim it first; 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.
Restores the pre-Phase-4 auto-pause behavior: a PM that called
i_will_plan and is now idle leaves the parent at ``paused`` so the
closure dispatcher knows to respawn it when subtasks complete.
Returns the list of task IDs that were paused (as strings) so
``i_am_idle`` can tell the agent which ``resume(task_id)`` calls
await it on the next respawn. Empty list when nothing was active.
"""
in_progress = await self.task.list_in_progress_for_agent(agent_id)
paused_ids: list[str] = []
for t in in_progress:
await self.task.pause_for_agent(agent_id, t.id)
paused_ids.append(str(t.id))
return paused_ids
# --- Phase 2 (QA) verbs moved to ``qa.py`` (audit P2-2). ---
# --- Phase 3 (documenter + PM) verbs ---
# claim_doc_task + i_documented moved to ``doc.py`` (audit P2-2).
async def _i_will_plan_preflight(
self, pm_agent_id: UUID, task_id: UUID, t: Any, plan: str
) -> Envelope | None:
"""Run i_will_plan's role / status / plan / claim guards. None = pass.
Idempotent on re-entry: if the caller already owns the task in
claimed/in_progress (their previous spawn moved it forward), the
verb returns OK with current state instead of rejecting. Without
this, a respawned PM hits 'task in in_progress, expected pending'
and loops until the reaper drops the claim back to pending —
producing the cycle smoke 2026-05-04 captured.
"""
agent = await self.task.agent_for(pm_agent_id)
role = str(agent.role) if agent is not None else ""
# Role-only gate: i_will_plan is principle-level reserved for PMs.
# The status check below ((pending → in_progress) is a separate gate
# whose rejection must surface as `invalid_state`, not
# `not_authorized` — agents react to those two errors differently.
if role not in ("cell_pm", "main_pm"):
return Envelope.not_authorized(
message="only cell_pm or main_pm may call i_will_plan",
remediate="this verb is reserved for PMs",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role)
status = str(t.status)
if status != "pending":
# Idempotent re-entry: caller already owns this task in a
# post-claim state. Don't reject; the i_will_plan body will
# short-circuit on the same condition and return OK.
if status in ("claimed", "in_progress") and t.assigned_to == pm_agent_id:
return None
return Envelope.invalid_state(
message=f"task {task_id} is in {t.status}, expected pending",
remediate="call give_me_work() to find a pending task to plan",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if not plan or not plan.strip():
return Envelope.tracing_gap(
missing=["plan"],
remediate=(
f"call i_will_plan(task_id='{task_id}',"
" plan='<one-paragraph plan describing the breakdown>')"
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
# Gate Set A: ALREADY_ACTIVE / PAUSED guards only.
#
# `pm_cannot_execute_code_guard` is INTENTIONALLY skipped here:
# PMs PLAN code-typed parent tasks all the time (they decompose
# the work into developer-claimable subtasks via delegate).
# The "PMs cannot execute code" rule belongs to the EXECUTION
# verb (`i_will_work_on`), not the PLANNING verb (`i_will_plan`).
# Pre-fix this guard fired on i_will_plan and deadlocked any
# code-typed parent task — see the 2026-05-08 smoke-test trace.
#
# `role_typed_claim_guard` is also skipped because i_will_plan
# services only PM roles which aren't in its allow-table.
guard = await self._run_claim_guards(
agent_id=pm_agent_id,
task=t,
skip_role_typed=True,
skip_pm_code=True,
)
if guard:
return self._with_briefing(
guard, await self._briefing_for(pm_agent_id, task_id)
)
return None
async def i_will_plan(
self, pm_agent_id: UUID, task_id: UUID, plan: str
) -> Envelope:
"""PM mirror of i_will_work_on for parent tasks.
Transitions a pending task owned (or claimable by) this PM into
in_progress with the supplied plan. Required before a PM can call
``delegate`` to spawn subtasks.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=pm_agent_id,
task_id=task_id,
verb="i_will_plan",
)
agent = await self.task.agent_for(pm_agent_id)
role = str(agent.role) if agent is not None else "cell_pm"
rejection = await self._i_will_plan_preflight(pm_agent_id, task_id, t, plan)
if rejection is not None:
rejection.with_introspection(task=t, role=role)
return await self._emit_rejection(
rejection,
agent_id=pm_agent_id,
task_id=task_id,
verb="i_will_plan",
)
# Idempotent re-entry: respawned PM that already owns this task in
# claimed/in_progress short-circuits with the current state. Touch
# the heartbeat so the reaper sees fresh activity, then return OK
# pointing at delegate as the next call. Without this short-circuit
# the body would reach start() — which rejects because status is
# not 'claimed' on the in_progress branch — and emit a misleading
# invalid_state envelope.
status = str(t.status)
if status in ("claimed", "in_progress") and t.assigned_to == pm_agent_id:
await self._touch(task_id)
return Envelope.ok(
status=status,
task_id=str(task_id),
next=(
"task already claimed; delegate(parent_task_id, ...) for"
" each subtask, then i_am_idle"
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role)
# Always call claim() when status is pending — even if the PM is
# already in assigned_to (CEO pre-assigns root tasks at creation).
# claim() transitions pending → claimed; without that, start() below
# would refuse the claimed → in_progress transition and silently
# return None, leaving the task stuck in pending under a misleading
# OK envelope. claim() is idempotent for the same assignee.
if str(t.status) == "pending":
t = await self.task.claim(task_id, pm_agent_id)
if t is None:
return await self._emit_rejection(
Envelope.invalid_state(
message="claim failed",
remediate="task may already be claimed by another agent",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
),
agent_id=pm_agent_id,
task_id=task_id,
verb="i_will_plan",
)
await self.task.set_plan(task_id, plan)
t = await self.task.start(task_id, pm_agent_id)
if t is None:
# start() returns None on invalid status / ownership / missing
# plan. Surface the failure instead of pretending success.
return await self._emit_rejection(
Envelope.invalid_state(
message=f"start failed for task {task_id}",
remediate=(
"task not in a startable state"
" (claimed/paused/needs_revision) or no plan recorded"
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
),
agent_id=pm_agent_id,
task_id=task_id,
verb="i_will_plan",
)
await self._touch(task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"delegate(parent_task_id, title, description, assigned_to, team)"
" for each subtask, then i_am_idle"
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role)
async def delegate(
self,
pm_agent_id: UUID,
parent_task_id: UUID,
inputs: DelegateInputs,
) -> Envelope:
"""Create a subtask under parent_task_id with delegation-chain validation.
Main PM may delegate to a Cell PM slug; a Cell PM may delegate to
its own team's developers. Anything else is rejected with an
explicit hint about the chain.
"""
parent = await self.task.get(parent_task_id)
if parent is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {parent_task_id} not found"),
agent_id=pm_agent_id,
task_id=parent_task_id,
verb="delegate",
)
agent = await self.task.agent_for(pm_agent_id)
role = str(agent.role) if agent is not None else "cell_pm"
guard = await self._delegate_guard(
pm_agent_id, parent_task_id, parent, agent, inputs
)
if guard is not None:
guard.with_introspection(task=parent, role=role)
return await self._emit_rejection(
guard,
agent_id=pm_agent_id,
task_id=parent_task_id,
verb="delegate",
)
new_task = await self._create_subtask_from_inputs(
pm_agent_id, parent_task_id, parent, inputs
)
return Envelope.ok(
status="created",
task_id=str(new_task.id),
next="continue delegating subtasks, or i_am_idle when done",
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
).with_introspection(task=new_task, role=role)
# Gate Set B subtask cap (pre-gateway implicit, made explicit here).
# Soft warn at 8, hard block at 13. Cap enforced by ``_subtask_cap_guard``.
_SUBTASK_HARD_CAP: int = 12
async def _delegate_guard(
self,
pm_agent_id: UUID,
parent_task_id: UUID,
parent: Any,
agent: Any,
inputs: DelegateInputs,
) -> Envelope | None:
"""Return rejection Envelope if a delegate precondition fails; else None."""
if guard := await self._delegate_role_guards(
pm_agent_id, parent_task_id, agent, inputs
):
role = str(agent.role) if agent is not None else ""
return guard.with_introspection(task=parent, role=role)
if guard := await self._delegate_static_guards(
pm_agent_id, parent_task_id, parent, inputs
):
return guard
# Gate Set B: PARENT_NOT_CLAIMED + SUBTASK_CAP
return await self._delegate_lifecycle_guards(
pm_agent_id, parent_task_id, parent
)
async def _delegate_role_guards(
self,
pm_agent_id: UUID,
parent_task_id: UUID,
agent: Any,
inputs: DelegateInputs,
) -> Envelope | None:
"""Role + delegation-chain guards (the original two).
Role gate stays role-only here (not via is_verb_allowed) — the
parent-status check is a separate gate that must surface as
`invalid_state`, not `not_authorized`. See _i_will_plan_preflight
for the same rationale.
"""
if agent is None or agent.role not in ("cell_pm", "main_pm"):
return Envelope.not_authorized(
message="only cell_pm or main_pm may delegate",
remediate="this verb is reserved for PMs",
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
chain_error = self._validate_delegation_chain(agent.role, inputs.assigned_to)
if chain_error is not None:
return Envelope.not_authorized(
message=chain_error,
remediate=(
"Main PM delegates to be-pm/fe-pm/ux-pm. "
"Cell PM delegates to its own team's devs (e.g. backend "
"PM -> be-dev-1/be-dev-2)."
),
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
return None
async def _delegate_static_guards(
self,
pm_agent_id: UUID,
parent_task_id: UUID,
parent: Any,
inputs: DelegateInputs,
) -> Envelope | None:
"""project_id / enum guards. Pure data-shape checks.
The slug-validity check used to live here, but `_delegate_role_guards`
runs first and `_validate_delegation_chain` rejects any slug outside
the allowed delegation targets — which is a strict subset of
`AGENT_UUIDS` — so any AGENT_UUIDS check here was unreachable.
"""
if parent.project_id is None:
return Envelope.invalid_state(
message="parent task has no project_id",
remediate="parent task must have a project to inherit",
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
try:
self._resolve_delegate_enums(inputs)
except ValueError as exc:
return Envelope.invalid_state(
message=f"invalid enum value: {exc}",
remediate="check team/task_type/estimated_complexity",
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
if type_error := self._validate_assignee_task_type(
inputs.assigned_to, inputs.task_type
):
return Envelope.invalid_state(
message=type_error,
remediate=(
"Cell PMs (be-pm/fe-pm/ux-pm) own PLANNING tasks — they "
"decompose the slice and delegate code work to devs. Pass "
"task_type='planning' when delegating to a Cell PM."
),
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
return None
_CELL_PM_SLUGS: ClassVar[frozenset[str]] = frozenset({"be-pm", "fe-pm", "ux-pm"})
@staticmethod
def _validate_assignee_task_type(
assigned_to: str, task_type: str
) -> str | None:
"""Reject role-vs-type misclassifications.
Rule (2026-05-09 smoke Bug B): when delegating to a Cell PM, the
subtask must be `planning`-typed. The Cell PM owns the planning
of the slice and delegates code execution to devs; a code-typed
task assigned to a Cell PM conflates the two layers and made
the lifecycle harder to reason about (a code task that nobody
will execute, just plan).
"""
if assigned_to in Choreographer._CELL_PM_SLUGS and task_type != "planning":
return (
f"task_type={task_type!r} is invalid for assignee {assigned_to!r}: "
f"Cell PMs own planning tasks, not code/documentation/etc."
)
return None
async def _delegate_lifecycle_guards(
self,
pm_agent_id: UUID,
parent_task_id: UUID,
parent: Any,
) -> Envelope | None:
"""Gate Set B: PARENT_NOT_CLAIMED + SUBTASK_CAP.
Pre-gateway, the orchestrator enforced both implicitly: a PM only
ever called task_create after the orchestrator spawned them
post-claim, and naturally never created more than a handful of
subtasks in one spawn cycle.
With the gateway exposing ``delegate`` as a first-class verb,
these gates must be explicit.
"""
if str(parent.status) != "in_progress":
return Envelope.invalid_state(
message=(
f"parent task {parent_task_id} is in {parent.status}; "
"must be in_progress to accept subtasks"
),
remediate=(
f"call i_will_plan(parent_task_id='{parent_task_id}',"
" plan='...') before delegating subtasks"
),
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
if parent.assigned_to != pm_agent_id:
return Envelope.not_authorized(
message=(
f"parent task {parent_task_id} is assigned to "
f"{parent.assigned_to}, not you"
),
remediate=(
f"call i_will_plan(parent_task_id='{parent_task_id}',"
" plan='...') to claim before delegating subtasks"
),
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
existing = await self.task.get_subtasks(parent_task_id)
if len(existing) >= self._SUBTASK_HARD_CAP:
return Envelope.invalid_state(
message=(
f"parent already has {len(existing)} subtasks; "
f"cap is {self._SUBTASK_HARD_CAP}"
),
remediate=(
"consolidate or split into a separate parent task before"
" adding more subtasks"
),
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
return None
@staticmethod
def _resolve_delegate_enums(inputs: DelegateInputs) -> tuple[Any, Any, Any]:
"""Convert string inputs to Team/TaskType/Complexity enums.
Raises ValueError if any string is not a member of its enum.
"""
from roboco.models.base import Complexity, TaskType, Team
return (
Team(inputs.team),
TaskType(inputs.task_type),
Complexity(inputs.estimated_complexity),
)
async def _create_subtask_from_inputs(
self,
pm_agent_id: UUID,
parent_task_id: UUID,
parent: Any,
inputs: DelegateInputs,
) -> Any:
"""Resolve enums + AGENT_UUIDS slug and call TaskService.create_subtask."""
from roboco.models.task import TaskCreateRequest
from roboco.seeds.initial_data import AGENT_UUIDS
team_enum, type_enum, complexity_enum = self._resolve_delegate_enums(inputs)
assignee_id = UUID(AGENT_UUIDS[inputs.assigned_to])
req = TaskCreateRequest(
title=inputs.title,
description=inputs.description,
acceptance_criteria=inputs.acceptance_criteria or [],
team=team_enum,
created_by=pm_agent_id,
project_id=UUID(str(parent.project_id)),
parent_task_id=parent_task_id,
assigned_to=assignee_id,
task_type=type_enum,
estimated_complexity=complexity_enum,
)
return await self.task.create_subtask(req)
@staticmethod
def _validate_delegation_chain(pm_role: str, target_slug: str) -> str | None:
"""Return error string if delegation chain is invalid; else None."""
cell_pm_targets = {
"cell_pm": {
"backend": {"be-dev-1", "be-dev-2"},
"frontend": {"fe-dev-1", "fe-dev-2"},
"ux_ui": {"ux-dev-1", "ux-dev-2"},
},
}
main_pm_targets = {"be-pm", "fe-pm", "ux-pm"}
if pm_role == "main_pm":
if target_slug not in main_pm_targets:
return (
f"main_pm cannot delegate to {target_slug!r}; allowed: "
f"{sorted(main_pm_targets)}"
)
return None
if pm_role == "cell_pm":
allowed_for_any_team: set[str] = set()
for team_targets in cell_pm_targets["cell_pm"].values():
allowed_for_any_team |= team_targets
if target_slug not in allowed_for_any_team:
return (
f"cell_pm cannot delegate to {target_slug!r}; allowed: "
f"{sorted(allowed_for_any_team)}"
)
return None
return f"role {pm_role!r} cannot delegate"
async def submit_up(self, pm_agent_id: UUID, task_id: UUID, notes: str) -> Envelope:
"""Cell PM bubbles a finished cell-scope task up to the Main PM.
Opens a cell-level PR into the parent (Main PM) branch, transitions
the task to ``awaiting_pm_review``, and reassigns to the Main PM.
Required preconditions: caller owns the task, all subtasks
terminal, journal:decision logged, notes >= 20 chars.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=pm_agent_id,
task_id=task_id,
verb="submit_up",
)
agent = await self.task.agent_for(pm_agent_id)
role = str(agent.role) if agent is not None else "cell_pm"
guard = await self._submit_up_guard(pm_agent_id, task_id, t, notes)
if guard is not None:
guard.with_introspection(task=t, role=role)
return await self._emit_rejection(
guard,
agent_id=pm_agent_id,
task_id=task_id,
verb="submit_up",
)
parent_branch = parent_branch_for(t.branch_name)
await self.git.create_pr(t.branch_name, parent=parent_branch, is_root_pr=False)
t = await self.task.submit_pm_review(pm_agent_id, task_id, notes)
if t is None:
return await self._emit_rejection(
Envelope.invalid_state(
message="could not transition to awaiting_pm_review",
remediate="check task state — must be in_progress with PR ready",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
),
agent_id=pm_agent_id,
task_id=task_id,
verb="submit_up",
)
await self._handoff_to_main_pm(pm_agent_id, task_id)
return Envelope.ok(
status="awaiting_pm_review",
task_id=str(task_id),
next="idle until Main PM reviews",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role)
async def _submit_up_guard(
self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str
) -> Envelope | None:
"""Return a rejection Envelope if any submit_up precondition fails."""
ownership = await self._submit_up_ownership_guard(
pm_agent_id, task_id, t, notes
)
if ownership is not None:
return ownership
return await self._submit_up_state_guard(pm_agent_id, task_id, t)
async def _submit_up_ownership_guard(
self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str
) -> Envelope | None:
"""Role + assignment + notes-length guards for submit_up."""
from roboco.config import settings as roboco_settings
agent = await self.task.agent_for(pm_agent_id)
if agent is None or agent.role != "cell_pm":
return Envelope.not_authorized(
message="submit_up is reserved for cell_pm",
remediate="main_pm should call complete on root tasks instead",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if t.assigned_to != pm_agent_id:
return Envelope.not_authorized(
message="not assigned to you",
remediate="claim the task or wait for assignment",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if not notes or len(notes) < roboco_settings.docs_notes_min_chars:
return Envelope.tracing_gap(
missing=["notes>=min"],
remediate=(
"submit_up requires substantive notes describing the"
" cell's contribution (>= 20 chars)."
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
return None
async def _submit_up_state_guard(
self, pm_agent_id: UUID, task_id: UUID, t: Any
) -> Envelope | None:
"""Journal + subtask-closure + branch guards for submit_up."""
has_decision = await self.journal.has_decision_for_task(pm_agent_id, task_id)
if not has_decision:
from roboco.services.gateway.remediation import (
hint_for_missing_journal_decision,
)
return Envelope.tracing_gap(
missing=["journal:decision"],
remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if env := await self._subtasks_not_terminal_envelope(
pm_agent_id, task_id, context_phrase="bubbling up"
):
return env
if not t.branch_name:
return Envelope.invalid_state(
message="task has no branch; cannot open cell-level PR",
remediate="cell PMs must claim+plan their parent task first",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
return None
async def _handoff_to_main_pm(self, pm_agent_id: UUID, task_id: UUID) -> None:
"""Reassign the task to the Main PM and A2A-notify the handoff."""
main_pm = await self.task.main_pm_agent()
if main_pm is None:
return
main_pm_uuid = UUID(str(main_pm.id))
await self.task.reassign(task_id, main_pm_uuid)
await self.a2a.send(
from_agent=pm_agent_id,
to_agent=main_pm_uuid,
skill="task_management",
task_id=task_id,
body=f"Cell scope complete for {task_id}. Ready for Main PM review.",
)
async def pm_give_me_work(self, pm_agent_id: UUID) -> Envelope:
"""Return the PM's first assigned task in any active status, or idle.
Mirrors the developer's give_me_work but does not filter to dev-only
statuses — PMs care about all assigned tasks (planning, paused, in
progress, awaiting_pm_review).
"""
assigned = await self.task.list_assigned_for_agent(pm_agent_id)
if assigned:
t = assigned[0]
await self._touch(t.id)
return Envelope.ok(
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),
)
return Envelope.ok(
status="idle",
task_id=None,
next="no assigned work — call triage() or i_am_idle()",
context_briefing=await self._briefing_for(pm_agent_id, None),
)
@staticmethod
def _pm_next_hint(status: str, task_id: Any) -> str:
"""Compose a status-aware next hint for PMs."""
if status == "pending":
return f"call i_will_plan(task_id='{task_id}', plan='...') to start"
if status == "paused":
return (
f"check subtasks; when terminal, call complete(task_id='{task_id}')"
" or submit_up()"
)
if status == "blocked":
return f"investigate then unblock(task_id='{task_id}')"
if status == "awaiting_pm_review":
return f"review and complete(task_id='{task_id}')"
return f"act on task {task_id} — current status: {status}"
async def triage(self, pm_agent_id: UUID) -> Envelope:
"""Cell PM triage: blocked > awaiting_pm_review > idle."""
pm = await self.task.agent_for(pm_agent_id)
blocked = await self.task.list_blocked_for_team(pm.team)
if blocked:
t = blocked[0]
return Envelope.ok(
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),
)
awaiting = await self.task.list_awaiting_pm_review_for_team(pm.team)
if awaiting:
t = awaiting[0]
return Envelope.ok(
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),
)
return Envelope.ok(
status="idle",
task_id=None,
next="no PM work — call i_am_idle",
context_briefing=await self._briefing_for(pm_agent_id, None),
)
async def triage_all(self, pm_agent_id: UUID) -> Envelope:
"""Main PM triage: across all teams. blocked > awaiting_pm_review > idle."""
blocked = await self.task.list_blocked_all_teams()
if blocked:
t = blocked[0]
return Envelope.ok(
status=str(t.status),
task_id=str(t.id),
next=(
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),
)
awaiting = await self.task.list_awaiting_main_pm_all()
if awaiting:
t = awaiting[0]
return Envelope.ok(
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),
)
return Envelope.ok(
status="idle",
task_id=None,
next="no Main PM work",
context_briefing=await self._briefing_for(pm_agent_id, None),
)
async def unblock(
self, pm_agent_id: UUID, task_id: UUID, *, restore: bool = True
) -> Envelope:
"""PM unblocks task; restore=True (default) returns to pre_block_state."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=pm_agent_id,
task_id=task_id,
verb="unblock",
)
agent = await self.task.agent_for(pm_agent_id)
role = str(agent.role) if agent is not None else "cell_pm"
if str(t.status) != "blocked":
return await self._emit_rejection(
Envelope.invalid_state(
message=f"task {task_id} is in {t.status}, expected blocked",
remediate=(
"this task is not blocked; call triage() to find blocked tasks"
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role),
agent_id=pm_agent_id,
task_id=task_id,
verb="unblock",
)
has_decision = await self.journal.has_decision_for_task(pm_agent_id, task_id)
if not has_decision:
from roboco.services.gateway.remediation import (
hint_for_missing_journal_decision,
)
return await self._emit_rejection(
Envelope.tracing_gap(
missing=["journal:decision"],
remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role),
agent_id=pm_agent_id,
task_id=task_id,
verb="unblock",
)
t = await self.task.unblock_with_restore(pm_agent_id, task_id, restore=restore)
next_msg = (
"task restored to its pre-block state — original assignee will resume"
if restore
else "task back to in_progress; you'll need to re-engage the workflow"
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=next_msg,
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role)
async def _cell_pm_complete_guard(
self, pm_agent_id: UUID, task_id: UUID, t: Any
) -> Envelope | None:
"""Return a rejection Envelope if pre-merge guards fail; else None."""
if t.assigned_to != pm_agent_id:
return Envelope.not_authorized(
message="not assigned to you",
remediate="claim the task or wait for it to be assigned",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if str(t.status) != "awaiting_pm_review":
return Envelope.invalid_state(
message=(
f"task {task_id} is in {t.status}, expected awaiting_pm_review"
),
remediate="this task is not ready for completion",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
has_decision = await self.journal.has_decision_for_task(pm_agent_id, task_id)
if not has_decision:
from roboco.services.gateway.remediation import (
hint_for_missing_journal_decision,
)
return Envelope.tracing_gap(
missing=["journal:decision"],
remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if env := await self._subtasks_not_terminal_envelope(
pm_agent_id, task_id, context_phrase="completing parent"
):
return env
if t.pr_number is None:
return Envelope.invalid_state(
message="task has no PR; cannot merge",
remediate=(
"this state should not occur post-Phase-1;"
" investigate dev's i_am_done path"
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
return None
async def cell_pm_complete(
self, pm_agent_id: UUID, task_id: UUID, notes: str
) -> Envelope:
"""Cell PM completes a task — auto-merges leaf PR into parent branch."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=pm_agent_id,
task_id=task_id,
verb="cell_pm_complete",
)
guard = await self._cell_pm_complete_guard(pm_agent_id, task_id, t)
if guard is not None:
guard.with_introspection(task=t, role="cell_pm")
return await self._emit_rejection(
guard,
agent_id=pm_agent_id,
task_id=task_id,
verb="cell_pm_complete",
)
target = parent_branch_for(t.branch_name)
merge_result = await self.git.pr_merge(
t.pr_number, target=target, actor_agent_id=pm_agent_id
)
leaf_parent_id = t.parent_task_id
leaf_team = t.team
t = await self.task.cell_pm_complete(
pm_agent_id,
task_id,
notes,
merge_commit=merge_result.get("merge_commit_sha"),
)
# Now that the leaf is completed, propagate the completion up to the
# parent task: if the parent's subtasks are all terminal, hand the
# parent off to the cell_pm for that team so it gets respawned for
# the next stage (cell-PM PR merge or main-PM hand-off).
if leaf_parent_id is not None:
await self._maybe_advance_parent_to_pm_review(leaf_parent_id, leaf_team)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=f"merged into {target}; triage() for next item",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role="cell_pm")
async def _maybe_advance_parent_to_pm_review(
self, parent_task_id: UUID, leaf_team: Any
) -> None:
"""Promote a parent task to awaiting_pm_review once all subtasks finish.
Walks up from the just-completed leaf. If every direct subtask of
the parent is terminal, set the parent's ``assigned_to`` to the cell
PM for the parent's team so the orchestrator spawns the right PM
for the next stage (review/merge/escalate). Status itself is left
untouched here — the cell PM transitions it via ``complete``.
"""
parent = await self.task.get(parent_task_id)
if parent is None:
return
all_terminal = await self.task.all_subtasks_terminal(parent_task_id)
if not all_terminal:
return
team = parent.team or leaf_team
if team is None:
return
pm_agent = await self.task.cell_pm_for_team(team)
if pm_agent is None:
return
await self.task.reassign(parent_task_id, pm_agent.id)
async def _main_pm_complete_guard(
self, main_pm_agent_id: UUID, root_task_id: UUID, t: Any
) -> Envelope | None:
"""Return a rejection Envelope if pre-escalation guards fail; else None."""
if t.assigned_to != main_pm_agent_id:
return Envelope.not_authorized(
message="not assigned to you",
remediate="wait for assignment or claim",
context_briefing=await self._briefing_for(
main_pm_agent_id, root_task_id
),
)
if str(t.status) != "awaiting_pm_review":
return Envelope.invalid_state(
message=(
f"task {root_task_id} is in {t.status}, expected awaiting_pm_review"
),
remediate="this task is not ready for main-PM completion",
context_briefing=await self._briefing_for(
main_pm_agent_id, root_task_id
),
)
if t.parent_task_id is not None:
return Envelope.invalid_state(
message=(
"main_pm complete only operates on root tasks (no parent_task_id)"
),
remediate=(
"cell PM should complete this task;"
" main PM only completes root tasks"
),
context_briefing=await self._briefing_for(
main_pm_agent_id, root_task_id
),
)
has_decision = await self.journal.has_decision_for_task(
main_pm_agent_id, root_task_id
)
if not has_decision:
from roboco.services.gateway.remediation import (
hint_for_missing_journal_decision,
)
return Envelope.tracing_gap(
missing=["journal:decision"],
remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(
main_pm_agent_id, root_task_id
),
)
if env := await self._subtasks_not_terminal_envelope(
main_pm_agent_id, root_task_id, context_phrase="escalating to CEO"
):
return env
return None
async def main_pm_complete(
self, main_pm_agent_id: UUID, root_task_id: UUID, notes: str
) -> Envelope:
"""Main PM completes a root task; opens master PR + escalates to CEO."""
t = await self.task.get(root_task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {root_task_id} not found"),
agent_id=main_pm_agent_id,
task_id=root_task_id,
verb="main_pm_complete",
)
guard = await self._main_pm_complete_guard(main_pm_agent_id, root_task_id, t)
if guard is not None:
guard.with_introspection(task=t, role="main_pm")
return await self._emit_rejection(
guard,
agent_id=main_pm_agent_id,
task_id=root_task_id,
verb="main_pm_complete",
)
needs_pr = t.pr_number is None
if not needs_pr:
current_target = await self.git.pr_target(t.pr_number)
needs_pr = current_target != "master"
if needs_pr:
await self.git.create_pr(t.branch_name, parent="master", is_root_pr=True)
# 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).
t = await self.task.escalate_to_ceo(
task_id=root_task_id, agent_role="main_pm", notes=notes
)
# CEO acts via the UI, not as an agent the orchestrator spawns. Clear
# ``assigned_to`` so no agent gets respawned to chase this task while
# it sits in awaiting_ceo_approval.
await self.task.reassign(root_task_id, None)
return Envelope.ok(
status=str(t.status),
task_id=str(root_task_id),
next="idle until CEO approves (or rejects) via UI",
context_briefing=await self._briefing_for(main_pm_agent_id, root_task_id),
).with_introspection(task=t, role="main_pm")
async def complete(self, agent_id: UUID, task_id: UUID, notes: str) -> Envelope:
"""Dispatch to cell_pm_complete or main_pm_complete based on agent role."""
agent = await self.task.agent_for(agent_id)
if agent.role == "cell_pm":
return await self.cell_pm_complete(agent_id, task_id, notes)
if agent.role == "main_pm":
return await self.main_pm_complete(agent_id, task_id, notes)
t = await self.task.get(task_id)
rejection = Envelope.not_authorized(
message=f"role {agent.role} cannot complete tasks via this verb",
remediate="only cell_pm and main_pm can call complete",
context_briefing=await self._briefing_for(agent_id, task_id),
)
if t is not None:
rejection.with_introspection(task=t, role=str(agent.role))
return await self._emit_rejection(
rejection,
agent_id=agent_id,
task_id=task_id,
verb="complete",
)
async def escalate_up(
self, pm_agent_id: UUID, task_id: UUID, reason: str
) -> Envelope:
"""Escalate a task to the agent's escalation_target role."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=pm_agent_id,
task_id=task_id,
verb="escalate_up",
)
me = await self.task.agent_for(pm_agent_id)
role = str(me.role) if me is not None else "cell_pm"
has_decision = await self.journal.has_decision_for_task(pm_agent_id, task_id)
if not has_decision:
from roboco.services.gateway.remediation import (
hint_for_missing_journal_decision,
)
return await self._emit_rejection(
Envelope.tracing_gap(
missing=["journal:decision"],
remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role),
agent_id=pm_agent_id,
task_id=task_id,
verb="escalate_up",
)
target_slug = me.escalation_target if me else None
if not target_slug:
return await self._emit_rejection(
Envelope.invalid_state(
message="no escalation target configured for your role",
remediate="check agents_config.py ESCALATION_CHAIN for your slug",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role),
agent_id=pm_agent_id,
task_id=task_id,
verb="escalate_up",
)
t = await self.task.escalate(pm_agent_id, task_id, reason)
if t is None:
return await self._emit_rejection(
Envelope.invalid_state(
message=(
f"could not escalate task {task_id} to {target_slug}: "
"target agent not found or task missing"
),
remediate=(
f"verify {target_slug} exists in agents table and that the "
"task is still present"
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
),
agent_id=pm_agent_id,
task_id=task_id,
verb="escalate_up",
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=f"escalated to {target_slug}; idle until they respond",
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role=role)
# --- Phase 4 (board) verbs ---
async def escalate_to_ceo(
self, agent_id: UUID, task_id: UUID, reason: str
) -> Envelope:
"""Board/Main PM escalates task_id to CEO with reason."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
me = await self.task.agent_for(agent_id)
role = str(me.role) if me is not None else "main_pm"
if me.role not in ("main_pm", "product_owner", "head_marketing"):
return await self._emit_rejection(
Envelope.not_authorized(
message=f"role {me.role} cannot escalate to CEO directly",
remediate="use escalate_up() to go through your escalation chain",
context_briefing=await self._briefing_for(agent_id, task_id),
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
if str(t.status) != "awaiting_pm_review":
return await self._emit_rejection(
Envelope.invalid_state(
message=(
f"task {task_id} is in {t.status}, expected awaiting_pm_review"
),
remediate="this task is not at the gate for CEO approval",
context_briefing=await self._briefing_for(agent_id, task_id),
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
if not has_decision:
from roboco.services.gateway.remediation import (
hint_for_missing_journal_decision,
)
return await self._emit_rejection(
Envelope.tracing_gap(
missing=["journal:decision"],
remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(agent_id, task_id),
).with_introspection(task=t, role=role),
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
t = await self.task.escalate_to_ceo(task_id, agent_role=me.role, notes=reason)
# Same as main_pm_complete: CEO acts via UI, not as a spawnable agent.
await self.task.reassign(task_id, None)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="idle until CEO acts via UI",
context_briefing=await self._briefing_for(agent_id, task_id),
).with_introspection(task=t, role=role)
# board_triage + auditor_triage moved to ``board.py`` as the first
# per-role mixin extraction (audit P2-2). The Choreographer class is
# composed in ``__init__.py`` from BoardMixin + the rest of this
# _impl. Methods now resolve via Python's MRO.