Files
roboco/roboco/services/gateway/choreographer/_impl.py
T
Renn F 9927d248ea [feature] wire cell-task wave chain + by-osmosis edge (sequencing S3)
Kind 2 (cell-task wave chain): a new cell-task under root-subtask UT_n
depends on every cell-task under every root-subtask in UT_n.dependency_ids
(the kind-1 wave-chain edges), so its branch carries the previous wave's
merged cell work. Re-derived from the root-subtask's deps, not the cell-task's
own dependency_ids (which also carry UX/product-fanout edges the by-osmosis
edge must not pick up). A root may fan to several cell-tasks (different cells),
so the previous wave's cell-task is a SET.

Kind 4 (by-osmosis): the first dev task (sequence 0) under a cell-task depends
on each predecessor cell-task's tail (max-sequence) dev task, so the new wave's
first branch carries the previous wave's fully-merged tail. Subsequent dev
tasks inherit the tail via kind 3 or the merged base.

Both wired from _create_subtask_from_inputs, dispatched on parent.team
(MAIN_PM -> kind 2; cell team -> kind 4). Pure helpers
(cell_task_wave_chain_depends_on, by_osmosis_tail_dev_tasks) unit-tested in
test_sequencing.py; TaskService methods integration-tested. Idempotent +
best-effort throughout (add_dependency dedupes; missing predecessors are
no-ops). Also fixes a latent mypy-tests gap (estimated_complexity required on
direct TaskCreateRequest calls in the S2 tests).
2026-06-28 04:22:27 +02:00

6436 lines
282 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/v1/flow/* endpoints will call into. Phase 0 wires the dependency
injection so later phases just fill in the bodies.
"""
from __future__ import annotations
import contextlib
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID
import structlog
from roboco.exceptions import MergeConflictError
from roboco.foundation.policy import lifecycle as spec_module
from roboco.foundation.policy.batch import is_batch_umbrella
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.content.validators import reject_trivial
from roboco.services.gateway.choreographer._verb_runner import VerbRunner
from roboco.services.gateway.claim_guards import (
already_active_guard,
paused_tasks_guard,
unmet_dependency_guard,
)
from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import (
BriefingInputs,
build_context_briefing,
build_evidence_for_task,
build_task_handoff,
shape_memory_query,
)
from roboco.services.gateway.merge_chain import resolve_parent_branch
from roboco.services.gateway.remediation import (
hint_for_evidence_not_inspected,
hint_for_missing_doc_files,
hint_for_missing_journal_decision,
hint_for_missing_journal_learning,
hint_for_missing_progress,
hint_for_missing_qa_notes,
hint_for_missing_reflect,
hint_for_short_dev_notes,
hint_for_short_doc_notes,
hint_for_short_pr_reviewer_notes,
hint_for_short_quick_context,
hint_for_unaddressed_acceptance_criteria,
)
logger = structlog.get_logger()
if TYPE_CHECKING:
# The composed ``Choreographer`` resolves mixin helpers (``_project_slug_for``,
# etc.) via MRO, but ``_LegacyChoreographer`` itself does not inherit
# ``ChoreographerHelpers`` — so mypy can't see those names on ``self`` here.
# The cast below reaches the typed view the mixins use (``_Base`` pattern).
from roboco.services.gateway.choreographer._protocol import ChoreographerHelpers
# 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: 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
# Each PM sub_task is a real work step (it becomes a delegate target AND a
# progress-checklist item). A title alone is not a plan — require a
# description that actually says what the step does.
_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.
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
every sub_task carries a title and a substantive description.
"""
for i, st in enumerate(sub_tasks):
if not isinstance(st, dict):
return f"sub_task #{i + 1} must be an object {{title, description}}."
title = str(st.get("title", "")).strip()
desc = str(st.get("description") or "").strip()
if not title:
return f"sub_task #{i + 1} has no title."
if len(desc) < _PM_SUBTASK_DESC_MIN_LEN:
return (
f"sub_task #{i + 1} ('{title[:40]}') description is too thin "
f"({len(desc)} chars) — need >= {_PM_SUBTASK_DESC_MIN_LEN} "
"characters describing what the step actually does."
)
return None
def _normalize_sub_task(st: dict[str, Any], order: int) -> dict[str, Any]:
"""Shape a sub_task entry to panel/src/types/index.ts::SubTask."""
from uuid import uuid4 as _uuid4
return {
"id": str(st.get("id") or _uuid4()),
"title": str(st.get("title", "")),
"description": st.get("description") or None,
"completed": bool(st.get("completed", False)),
"order": order,
"estimated_hours": st.get("estimated_hours"),
"notes": st.get("notes"),
}
def _normalize_risk(r: dict[str, Any]) -> dict[str, Any]:
"""Shape a risk entry to panel/src/types/index.ts (description/mitigation/severity).
Accepts either {description, mitigation, severity?} (panel shape) or
{risk, mitigation} (pre-gateway agent shape). ``severity`` always
serializes to a non-None string — the TaskPlanResponse schema declares
``risks: list[dict[str, str]]`` and a None severity from an agent who
omits the field triggers a panel-load 500 on every poll.
"""
description = r.get("description") or r.get("risk") or ""
severity_raw = r.get("severity")
severity = str(severity_raw) if severity_raw not in (None, "") else "medium"
return {
"description": str(description),
"mitigation": str(r.get("mitigation", "")),
"severity": severity,
}
def _normalize_open_question(q: Any) -> dict[str, Any] | None:
"""Shape an open_question entry to panel shape.
Returns None for entries we can't interpret (e.g., None or a number).
Accepts a bare string (the agent's short-form question), {question,
answered, answer} (pre-gateway shape), or {question, answer,
answered_by, answered_at} (panel shape).
"""
if isinstance(q, str):
return {
"question": q,
"answer": None,
"answered_by": None,
"answered_at": None,
}
if not isinstance(q, dict):
return None
return {
"question": str(q.get("question", "")),
"answer": q.get("answer"),
"answered_by": q.get("answered_by"),
"answered_at": q.get("answered_at"),
}
def _build_panel_shaped_plan(
plan_text: str, rich_plan: dict[str, Any]
) -> dict[str, Any]:
"""Build the Task.plan dict in the exact shape the panel UI consumes.
Panel reference: panel/src/types/index.ts::TaskPlan. Each list entry is
normalized so the panel renders without optional-field JS errors.
"""
sub_tasks = [
_normalize_sub_task(st, i)
for i, st in enumerate(rich_plan.get("sub_tasks") or [])
if isinstance(st, dict)
]
risks = [
_normalize_risk(r)
for r in (rich_plan.get("risks") or [])
if isinstance(r, dict)
]
open_questions = [
normalized
for q in (rich_plan.get("open_questions") or [])
if (normalized := _normalize_open_question(q)) is not None
]
return {
"text": plan_text,
"approach": rich_plan.get("approach", ""),
"sub_tasks": sub_tasks,
"technical_considerations": rich_plan.get("technical_considerations", []),
"risks": risks,
"open_questions": open_questions,
}
def _extract_original_developer(task: Any) -> str | None:
"""Pull the original_developer slug out of a task's quick_context, if any.
The ``quick_context`` blob carries handoff hints written by prior
actors; "original_developer:<slug>" is one such hint. Used by the
spec's self-review precondition (a documenter who is also the
original developer cannot self-doc).
"""
return markers.get_original_developer(task)
@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
# 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
# Per-cell project routing for the delegate verb. Optional so existing
# callsites / tests that don't exercise Product routing don't have to plumb
# it in; when None, delegate falls back to parent-project inheritance.
product: Any = None
# Orchestrator access for the rate-limited i_am_blocked path.
# Implements get_provider_for_agent(slug) -> str | None,
# get_active_agent_slugs_for_provider(provider) -> list[str], and
# async mark_waiting_long(slug, waiting_for, task_id, context).
# Optional: when None the parking step is skipped (e.g. in unit tests
# that don't need to verify orchestrator interactions).
orchestrator: Any = None
# StreamEventBus for publishing RATE_LIMIT_HIT events.
# Optional so existing callsites that don't exercise the rate-limit path
# don't have to plumb it in.
stream_bus: Any = None
@dataclass(frozen=True)
class _ClaimPlanStartContext:
"""Bundle of fields shared by ``i_will_work_on`` / ``i_will_plan`` helpers.
Both verbs compose the same (claim, set_plan, start) sequence and
share gating + recovery branches; they only differ in role gate
(DEV vs PM) and verb name on rejections / next_hint. Frozen so the
helper sites can't mutate caller state and to keep PLR0913 (too
many positional args) at bay.
"""
agent_id: UUID
task_id: UUID
task: Any
role_str: str
briefing: dict[str, Any]
plan: str | dict[str, Any] | None
verb_name: str
@dataclass(frozen=True)
class _IAmDoneContext:
"""Bundle of fields the ``i_am_done`` helper sites all need.
Frozen so the helper sites can't mutate caller state and to keep
PLR0913 (too many positional args) at bay across the dispatcher
body and its recovery branch.
"""
agent_id: UUID
task_id: UUID
task: Any
role_str: str
briefing: dict[str, Any]
notes: str
@dataclass(frozen=True)
class _ReassignedCtx:
"""Bundle of fields the ``_reassigned_rejection`` helper inspects.
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.
"""
task: Any
agent_id: UUID
task_id: UUID
role_str: str
briefing: dict[str, Any]
upstream_hint: str
@dataclass(frozen=True)
class DelegateInputs:
"""Bundle of fields the ``delegate`` verb receives from the route layer.
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, and defaulting here too would let direct
callers (tests, internal code) silently pick `'code'`/`'technical'` and
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`:
the rejection takes the form of `Envelope.incomplete_input`
so the agent receives a structured field-by-field guide.
"""
title: str
description: str
assigned_to: str
team: str
task_type: str
nature: str | None = None
acceptance_criteria: list[str] | None = None
estimated_complexity: str = "medium"
project_id: UUID | None = None
# Parent AC ids this subtask is responsible for — the decomposition coverage
# link. Empty/None means the child covers no specific parent criteria yet.
covers_parent_criteria: list[str] | None = None
# Dev-task collision surface (multi-level sequencing — edge kind 3). The
# cell PM states what each dev task touches so the choreographer can run
# SequencingService and wire the dev-task collision DAG. Optional: a
# delegate without surfaces joins no collision edges (parallel).
intends_to_touch: list[str] | None = None
adds_migration: bool = False
touches_shared: bool = False
# Explicit dependency override — wired verbatim as dependency_ids on the
# created dev task (an edge the surface rules would miss).
depends_on: list[UUID] | None = None
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
@property
def messaging(self) -> Any:
return self._deps.messaging
@property
def product(self) -> Any:
return self._deps.product
@property
def orchestrator(self) -> Any:
return self._deps.orchestrator
@property
def stream_bus(self) -> Any:
return self._deps.stream_bus
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 _record_milestone_progress(
self,
task_id: UUID,
agent_id: UUID,
message: str,
percentage: int | None = None,
) -> None:
"""Append a server-emitted progress entry on a lifecycle milestone.
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:
a missing task_id or write failure must not break the verb path
— progress is observability, not correctness.
"""
with contextlib.suppress(Exception):
await self.task.add_progress(
task_id=task_id,
agent_id=agent_id,
message=message,
percentage=percentage,
)
@staticmethod
def _reassigned_rejection(
ctx: _ReassignedCtx,
) -> Envelope | None:
"""Build the "task reassigned by upstream verb" rejection envelope.
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
verb-specific upstream remediate text. Returns ``None`` when the
caller still owns the task.
"""
task = ctx.task
if task.assigned_to == ctx.agent_id:
return None
current_owner = (
str(task.assigned_to) if task.assigned_to is not None else "<unassigned>"
)
return Envelope.not_authorized(
message=(
f"task {ctx.task_id} is no longer assigned to you "
f"(current owner: {current_owner})"
),
remediate=ctx.upstream_hint,
context_briefing=ctx.briefing,
).with_introspection(task=task, role=ctx.role_str)
async def _handle_pm_reentry(
self,
ctx: _ClaimPlanStartContext,
t: Any,
pm_agent_id: UUID,
task_id: UUID,
role_str: str,
briefing: dict[str, Any],
) -> Envelope | None:
"""Handle two distinct re-entry contracts for i_will_plan.
Idempotent heartbeat: the PM already owns the task in in_progress —
touch the heartbeat and return OK without re-running the spec gate.
This is the crash-recovery path: a PM container that respawns after a
mid-run crash re-calls i_will_plan with thin args ("resume") and must
receive OK so it can proceed from where it left off.
Crash-recovery claim: task is stuck in claimed after a crash — skip
re-claim (claimed is not a valid source for the claim transition) and
run set_plan+start to complete the interrupted sequence.
Returns None when neither condition applies, signalling the caller to
continue to the normal claim-plan-start path. PLR0911 budget is the
secondary reason this lives in a helper; the domain contract above is
the primary one.
"""
status = str(t.status)
if status == "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=spec_module._INTENT_VERBS["i_will_plan"].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
if status == "claimed" and t.assigned_to == pm_agent_id:
envelope = await self._resume_from_claimed(ctx)
return await self._post_claim_journal_gate(
"i_will_plan", pm_agent_id, task_id, envelope
)
return None
async def _pm_sub_tasks_gate(
self,
*,
role_str: str,
rich_plan: dict[str, Any] | None,
task: Any,
agent_id: UUID,
task_id: UUID,
briefing: dict[str, Any],
) -> Envelope | None:
"""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.
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
item). Returns a rejection Envelope when the caller is a PM role
and any field is absent/thin; returns None when the gate passed.
"""
if role_str not in ("cell_pm", "main_pm"):
return None
missing: list[str] = []
field_hints: dict[str, str] = {}
approach_raw = (rich_plan or {}).get("approach", "")
if len(str(approach_raw).strip()) < _PM_APPROACH_MIN_LEN:
missing.append("approach")
field_hints["approach"] = (
f"approach must be a non-empty string of at least "
f"{_PM_APPROACH_MIN_LEN} characters describing HOW you will "
"decompose and route this task — not a one-liner."
)
sub_tasks = (rich_plan or {}).get("sub_tasks") or []
if not sub_tasks:
missing.append("sub_tasks")
field_hints["sub_tasks"] = (
"PMs must list at least one sub_task — a non-empty list of "
"{title, description}. Each becomes a delegate target AND a "
"progress-checklist item."
)
elif thin := _thin_subtask_hint(sub_tasks):
missing.append("sub_tasks")
field_hints["sub_tasks"] = thin
if not missing:
return None
return await self._emit_rejection(
Envelope.incomplete_input(
missing=missing,
field_hints=field_hints,
remediate=(
"re-issue i_will_plan(task_id, plan, approach, "
"sub_tasks=[{'title': '...', 'description': '...'}, ...]) "
f"with approach >= {_PM_APPROACH_MIN_LEN} chars and every "
f"sub_task description >= {_PM_SUBTASK_DESC_MIN_LEN} chars."
),
context_briefing=briefing,
).with_introspection(task=task, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="i_will_plan",
)
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.
"""
if env.error is None:
return env
# 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
# agent. _touch already guards task_id=None.
try:
await self._touch(task_id)
except Exception as exc:
logger.warning(
"heartbeat touch failed on rejection", error=str(exc), verb=verb
)
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
@classmethod
def _free_text_soup(
cls, checks: tuple[tuple[str, Any, int], ...]
) -> Envelope | None:
"""Return a bare ``invalid_state`` envelope for the first soupy field.
``checks`` is a tuple of ``(field_name, value, min_chars)``. A value
that is ``None`` or empty/whitespace is skipped — presence is gated
elsewhere; this rejects *filler* in text the agent actually supplied
(``wip``, ``asdf``, ``tbd``, ``...``). A list value has each item
checked. The returned envelope carries no introspection or audit row:
the caller folds it into its existing rejection ``return`` so the soup
check adds no extra return (verbs stay under the complexity bound), and
the agent always gets a remediable envelope, never a 422 (which would
trip the do-server circuit breaker).
"""
for name, value, min_chars in checks:
items = value if isinstance(value, list) else [value]
for idx, item in enumerate(items):
if item is None or not str(item).strip():
continue
label = f"{name}[{idx}]" if isinstance(value, list) else name
env = cls._soup_reason(str(item), label, min_chars)
if env is not None:
return env
return None
async def _guard_free_text(
self,
*,
checks: tuple[tuple[str, Any, int], ...],
task: Any,
agent_id: UUID,
role_str: str,
verb: str,
) -> Envelope | None:
"""Emit-on-soup wrapper over :meth:`_free_text_soup`.
For verbs that have return-count headroom: stamps introspection, audits
via ``_emit_rejection``, and returns the rejection (or ``None`` clean).
Verbs already at the return bound call ``_free_text_soup`` directly and
fold the result into an existing rejection return instead.
"""
env = self._free_text_soup(checks)
if env is None:
return None
return await self._emit_rejection(
env.with_introspection(task=task, role=role_str),
agent_id=agent_id,
task_id=getattr(task, "id", None),
verb=verb,
)
@staticmethod
def _soup_reason(value: str, field: str, min_chars: int) -> Envelope | None:
"""Build an ``invalid_state`` envelope when ``value`` is filler, else None."""
try:
reject_trivial(value, field=field, min_chars=min_chars)
except ValueError as exc:
return Envelope.invalid_state(
message=str(exc),
remediate=(
f"write a substantive {field} (>={min_chars} chars, no filler "
"like 'asdf'/'wip'/'tbd'/'...'); state what actually happened."
),
context_briefing={},
)
return None
@staticmethod
def _soup_or_decision_env(
soup: Envelope | None, decision: Any, briefing: dict[str, Any]
) -> Envelope | None:
"""Pick the rejection envelope: soup first, then the spec decision.
Lets a verb fold the free-text soup check into its existing
spec-gate rejection ``return`` with a single branch — the two
fallback ``or``s live here, keeping the verb body under the
cyclomatic bound. Returns ``None`` when neither rejects; the caller
stamps introspection + emits.
"""
if soup is not None:
return soup
if not decision.allowed:
return Envelope.from_decision(decision, briefing=briefing)
return None
# --- Phase 1 (developer) verbs ---
@staticmethod
def _claim_verb_hint(role: str, task: Any) -> str:
"""Role + status aware 'how to start this task' hint.
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.
Map to the verb that actually claims the task for this role.
"""
tid = str(getattr(task, "id", ""))
status = str(getattr(task, "status", ""))
if status == "awaiting_documentation":
return f"call claim_doc_task(task_id='{tid}') to start"
if status == "awaiting_qa":
return f"call claim_review(task_id='{tid}') to start"
if role in ("cell_pm", "main_pm", "product_owner", "head_marketing"):
return f"call i_will_plan(task_id='{tid}', plan='<plan>') to start"
return f"call i_will_work_on(task_id='{tid}', plan='<plan>') to start"
async def _drop_dependency_held(self, tasks: list[Any]) -> list[Any]:
"""Drop pre-assigned PENDING tasks whose non-terminal dependencies are
still unresolved.
``give_me_work``'s ``list_assigned_for_agent`` fallback includes PENDING
rows with no dependency filter, so without this a held pre-assigned
subtask (e.g. a frontend dev's task waiting on the UX/UI design) would
still be offered and the agent only bounced at claim time. Mirrors the
gate in ``TaskService.list_pending_for_agent`` and ``_run_claim_guards``.
Only PENDING rows are gated — an already-claimed task is past the gate.
"""
offerable: list[Any] = []
for task in tasks:
dep_ids = list(getattr(task, "dependency_ids", []) or [])
if (
str(task.status) == "pending"
and dep_ids
and await self._deps.task.unmet_dependency_ids(dep_ids)
):
continue
offerable.append(task)
return offerable
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"
# Pre-assigned pending tasks take priority. Smoke run 3 (2026-05-12)
# showed agents missing tasks that were seeded with assigned_to=<them>
# and status=pending because the earlier code only walked
# list_assigned_for_agent (ordered by priority/updated_at — pending
# could rank behind in_progress rows) and the PM path checked
# awaiting_* queues but not the pre-assigned pending case.
pre_assigned = await self._deps.task.list_pending_for_agent(agent_id)
if pre_assigned:
t = pre_assigned[0]
return Envelope.ok(
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, task=t),
).with_introspection(task=t, role=role)
assigned = await self._drop_dependency_held(
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=self._claim_verb_hint(role, t),
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:
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, task=t),
).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,
*,
task: Any | None = None,
include_ac_coverage: bool = False,
) -> dict[str, Any]:
"""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.
``include_ac_coverage`` is set only on PM decomposition touchpoints
(give_me_work / i_will_plan / delegate / submit_up) so the PM sees, per
parent criterion, what is still unclaimed and can pass
``covers_parent_criteria`` on delegate. Off everywhere else so a leaf's
own criteria never surface as bogus "unclaimed" noise to a developer.
"""
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),
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),
task_handoff=task_handoff,
company_goals=await repo.company_goals(),
)
briefing = build_context_briefing(inputs)
memory = await self._institutional_memory(agent_id, task)
if memory:
# "What the company already knows about work like this" — distilled
# lessons + approved playbooks, pushed so the agent never has to ask.
briefing = {**briefing, "institutional_memory": memory}
if include_ac_coverage and task_id is not None:
coverage = await self.task.parent_ac_coverage(task_id)
if coverage:
# Compact decomposition view: surface each parent criterion's id
# so the PM can map subtasks to it via covers_parent_criteria,
# and call out the ones still unclaimed. Mirrors how sizing_hint
# is merged ad-hoc — context_briefing is free-form dict[str, Any].
briefing = {
**briefing,
"parent_ac_coverage": coverage,
"unclaimed_parent_acs": [
c["id"] for c in coverage if not c["claimed"]
],
}
return briefing
async def _institutional_memory(
self, agent_id: UUID, task: Any | None
) -> list[dict[str, Any]]:
"""Org-memory keystone: top-K relevant past lessons/playbooks for this
claim, role-shaped and relevance-floored. Empty unless
``org_memory_enabled`` and a task is in hand (nothing to query on
otherwise). Best-effort — never breaks the briefing."""
from roboco.config import settings as _settings
if not _settings.org_memory_enabled or task is None:
return []
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else ""
title = str(getattr(task, "title", "") or "")
raw_type = getattr(task, "task_type", None)
if raw_type is None:
task_type = ""
elif hasattr(raw_type, "value"):
task_type = str(raw_type.value)
else:
task_type = str(raw_type)
query = shape_memory_query(role, title, task_type)
memory: list[dict[str, Any]] = await self._deps.evidence_repo.similar_memory(
query=query,
top_k=_settings.org_memory_top_k,
min_score=_settings.org_memory_min_score,
)
return memory
# PM coordinator roles plan + delegate many roots in parallel; the actual
# work then runs in the delegated children/cells, not in the PM's own hands.
# So the single-active-task concurrency invariants (already_active / paused)
# that keep a *developer* to one task at a time must NOT gate a coordinator
# — only a genuine upstream sequence dependency (unmet_dependency) may hold a
# PM's root back. Without this exemption a PM that claimed one root could
# never plan a second and thrashed between its claimed roots, respawning
# forever and burning tokens for zero progress.
_COORDINATOR_ROLES: ClassVar[frozenset[str]] = frozenset({"main_pm", "cell_pm"})
async def _run_claim_guards(
self,
*,
agent_id: UUID,
task: Any,
role_str: str | None = None,
) -> Envelope | None:
"""Run concurrency-invariant claim guards. Returns rejection or None.
Scope: only system-level concurrency invariants the lifecycle spec
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.
``role_str`` selects whether the single-active-task guards apply: a PM
coordinator (``_COORDINATOR_ROLES``) is exempt from ``already_active`` /
``paused`` (it holds many roots in parallel by design) but the sequence
guard ``unmet_dependency`` still applies to everyone. A ``None`` role
keeps the full guards (safe default for non-PM callers).
Pre-gateway location: _helpers.py:124-204.
"""
if role_str not in self._COORDINATOR_ROLES:
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, task.id):
return guard
dep_ids = list(task.dependency_ids)
if dep_ids:
unmet = await self.task.unmet_dependency_ids(dep_ids)
if guard := unmet_dependency_guard(task, unmet):
# Park the dependency-gated task back to pending so the
# orchestrator stops respawning its assignee (the respawn loop
# targets only claimed/in_progress) and the dispatch dependency
# filter holds it until the upstream completes. No-op unless the
# task is currently claimed/in_progress.
await self.task.release_dependency_blocked_claim(task.id)
return guard
return None
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.
``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 _parent_acs_covered_envelope(
self,
agent_id: UUID,
task_id: UUID,
*,
context_phrase: str,
) -> Envelope | None:
"""Reject roll-up if any parent acceptance criterion is unsatisfied.
A parent AC is satisfied only when a COMPLETED child declared it via
``covers_parent_criteria`` (→ ``parent_ac_refs``). Safe-by-construction:
``uncovered_parent_acceptance_criteria`` returns nothing unless coverage
is declared on the children, so this is inert for tasks decomposed before
coverage tracking and bites only once a PM maps children to parent
criteria. The backstop that stops a half-built parent (criteria silently
dropped at decompose-time) from rolling up "done" — the PR #175 hole.
"""
uncovered = await self.task.uncovered_parent_acceptance_criteria(task_id)
# isinstance keeps the gate inert under partial test mocks (an AsyncMock
# TaskService returns a truthy stub, not a concrete list) and on any
# unexpected return — enforce only on a real, non-empty list.
if not isinstance(uncovered, list) or not uncovered:
return None
listing = "; ".join(uncovered)
return Envelope.tracing_gap(
missing=["parent acceptance criteria not satisfied"],
remediate=(
f"{len(uncovered)} parent acceptance criteria are not covered by a "
f"completed subtask before {context_phrase}: {listing}. Delegate "
"(or reassign) subtasks covering them and let those pass QA + "
"complete first."
),
context_briefing=await self._briefing_for(agent_id, task_id),
)
def _verb_runner(self) -> VerbRunner:
"""Construct a VerbRunner bound to this Choreographer's services.
Cheap to allocate; one per verb invocation keeps the runner
stateless across requests.
"""
return VerbRunner(task_service=self.task, git_service=self.git)
async def _resume_from_claimed(
self,
ctx: _ClaimPlanStartContext,
) -> Envelope:
"""Recover from a stuck `claimed` state owned by the same agent.
spec's composed `claim` action's source-statuses do not include
CLAIMED, so the spec gate would reject re-claiming an
already-owned task. This branch keeps the spec contract intact
(we never call `claim` again) but lets the agent recover by
running just set_plan (if a plan was supplied or stored) and
start. Shared between ``i_will_work_on`` and ``i_will_plan`` —
``ctx.verb_name`` selects the verb-specific labels / next_hint.
"""
agent_id = ctx.agent_id
task_id = ctx.task_id
t = ctx.task
briefing = ctx.briefing
role_str = ctx.role_str
plan = ctx.plan
verb_name = ctx.verb_name
if not t.plan and not plan:
return await self._emit_rejection(
Envelope.tracing_gap(
missing=["plan"],
remediate=(
f"call {verb_name}(task_id='{task_id}',"
f" plan='<one-paragraph plan describing what you will do>')"
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb=verb_name,
)
# Concurrency guards still apply on resumption (paused / already-active
# in another task).
if guard := await self._run_claim_guards(
agent_id=agent_id,
task=t,
role_str=role_str,
):
return await self._emit_rejection(
self._with_briefing(guard, briefing).with_introspection(
task=t, role=role_str
),
agent_id=agent_id,
task_id=task_id,
verb=verb_name,
)
try:
if plan and not t.plan:
t = await self.task.set_plan(task_id, plan)
t = await self.task.start(task_id, agent_id)
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),
agent_id=agent_id,
task_id=task_id,
verb=verb_name,
)
if t is None:
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=briefing,
).with_introspection(task=None, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb=verb_name,
)
# 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)
await self._touch(task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS[verb_name].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _claim_plan_start_gate(
self,
ctx: _ClaimPlanStartContext,
role: spec_module.Role,
spec_ctx: spec_module.Context,
) -> Envelope | None:
"""Run all gates for an ``i_will_work_on`` / ``i_will_plan`` call.
Order: spec.can_invoke_intent -> behavioral claim guards
(already_active / paused / unmet_dependency). Any rejection
short-circuits with the appropriate envelope.
Per-role claim authority (CLAIM_RULES) is enforced inside
spec.can_invoke_action when action == "claim", called by
can_invoke_intent, so no separate spec.can_claim call is needed.
A PM recovering a NEEDS_REVISION coordination task is scoped by
give_me_work routing (it only ever offers an agent its OWN assigned
tasks), exactly as developer leaf-revisions are — not by a gateway-only
ownership gate, which would diverge from the spec (the parity invariant
requires gateway authorization == spec authorization).
"""
t, briefing, role_str = ctx.task, ctx.briefing, ctx.role_str
verb_name = ctx.verb_name
decision = spec_module.can_invoke_intent(role, verb_name, t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=ctx.agent_id,
task_id=ctx.task_id,
verb=verb_name,
)
# Behavioral pre-flight guards the spec doesn't yet model:
# - already_active: agent has another in_progress task elsewhere
# - paused_tasks: agent has a paused task they should resume first
# - unmet_dependency: an upstream dependency is still non-terminal
# The role/state/task_type checks already passed via the spec gate
# above. These migrate into spec.extra_preconditions in a later
# task; until then, keep them imperative so concurrency invariants
# stay enforced.
if guard := await self._run_claim_guards(
agent_id=ctx.agent_id,
task=t,
role_str=role_str,
):
return await self._emit_rejection(
self._with_briefing(guard, briefing).with_introspection(
task=t, role=role_str
),
agent_id=ctx.agent_id,
task_id=ctx.task_id,
verb=verb_name,
)
return None
async def _claim_plan_start_run(
self, ctx: _ClaimPlanStartContext, agent: Any, spec_ctx: spec_module.Context
) -> Envelope:
"""Execute composed (claim, set_plan, start) via the verb runner.
Caller has already validated all gates. Translates runner
exceptions and ``None`` returns into invalid_state envelopes
so the agent gets a remediation instead of a 500. Shared
between ``i_will_work_on`` and ``i_will_plan``.
"""
t, briefing, role_str = ctx.task, ctx.briefing, ctx.role_str
verb_name = ctx.verb_name
runner = self._verb_runner()
try:
t = await runner.run_intent(verb_name, t, agent, 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),
agent_id=ctx.agent_id,
task_id=ctx.task_id,
verb=verb_name,
)
if t is None:
# A composed atomic action returned None (e.g. start() rejected
# because of an ownership/state mismatch the spec gate could not
# see). Surface as invalid_state so the agent gets a remediation
# rather than a 500.
return await self._emit_rejection(
Envelope.invalid_state(
message=f"start failed for task {ctx.task_id}",
remediate=(
"task not in a startable state"
" (claimed/paused/needs_revision) or no plan recorded"
),
context_briefing=briefing,
).with_introspection(task=None, role=role_str),
agent_id=ctx.agent_id,
task_id=ctx.task_id,
verb=verb_name,
)
# 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
# guard inside ensure_work_session prevents duplicate rows.
await self.task.ensure_work_session(ctx.task_id, ctx.agent_id)
await self._touch(ctx.task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(ctx.task_id),
next=spec_module._INTENT_VERBS[verb_name].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
@staticmethod
def _build_rich_plan(
plan: str | None,
steps: list[dict[str, Any]] | None,
technical_considerations: list[str] | None,
risks: list[dict[str, Any]] | None,
open_questions: list[dict[str, Any]] | None,
) -> dict[str, Any]:
"""Assemble the panel-shaped rich plan from a dev's inputs."""
return {
"approach": plan or "",
"sub_tasks": steps or [],
"technical_considerations": technical_considerations or [],
"risks": risks or [],
"open_questions": open_questions or [],
}
async def i_will_work_on(
self,
agent_id: UUID,
task_id: UUID,
plan: str | None = None,
steps: list[dict[str, Any]] | None = None,
technical_considerations: list[str] | None = None,
risks: list[dict[str, Any]] | None = None,
open_questions: list[dict[str, Any]] | None = None,
) -> Envelope:
"""Claim a task and start work on it.
Atomic: spec.can_invoke_intent runs before any state mutation;
the composed (claim, set_plan, start) sequence is wrapped in a
savepoint by the runner so a mid-sequence failure rolls back
the DB. Idempotent re-entry: a respawned dev re-calling on a
task they already own in_progress just refreshes the heartbeat.
``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. 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
re-blocked for steps it already submitted.
"""
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 = str(agent.role) if agent is not None else "developer"
briefing = await self._briefing_for(agent_id, task_id, task=t)
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="i_will_work_on",
)
# 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 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
)
effective_plan: str | dict[str, Any] | None = self._resolve_effective_plan(
plan or "", rich_plan
)
spec_ctx = spec_module.Context(
plan=effective_plan,
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
)
ctx = _ClaimPlanStartContext(
agent_id=agent_id,
task_id=task_id,
task=t,
role_str=role_str,
briefing=briefing,
plan=effective_plan,
verb_name="i_will_work_on",
)
if reentry := await self._dev_reentry(
ctx, t, agent_id, task_id, role_str, briefing
):
return reentry
return await self._fresh_dev_claim(
ctx,
role,
spec_ctx,
agent,
rich_plan,
role_str,
t,
agent_id,
task_id,
briefing,
)
async def _dev_reentry(
self,
ctx: _ClaimPlanStartContext,
t: Any,
agent_id: UUID,
task_id: UUID,
role_str: str,
briefing: dict[str, Any],
) -> Envelope | None:
"""Re-entry short-circuits for i_will_work_on (extracted to keep
i_will_work_on under the cyclomatic-complexity gate; mirrors
_handle_pm_reentry). Returns an Envelope to short-circuit, or None
to fall through to the fresh-claim path.
"""
# Idempotent re-entry: agent already owns the task in_progress.
# Short-circuit before the spec gate (in_progress is not a source
# state for the composed `claim` action).
if str(t.status) == "in_progress" and t.assigned_to == agent_id:
await self._touch(task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS["i_will_work_on"].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
# 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.
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(
"i_will_work_on", agent_id, task_id, envelope
)
return None
async def _fresh_dev_claim(
self,
ctx: _ClaimPlanStartContext,
role: Any,
spec_ctx: Any,
agent: Any,
rich_plan: dict[str, Any],
role_str: str,
t: Any,
agent_id: UUID,
task_id: UUID,
briefing: dict[str, Any],
) -> Envelope:
"""Fresh (non-re-entry) i_will_work_on tail: spec gate → dev-plan
gate → claim/plan/start → post-claim journal gate. Extracted so
i_will_work_on stays within the return-count budget; the dev-plan
gate mirrors _pm_sub_tasks_gate's placement (after the spec gate).
"""
if rejection := await self._claim_plan_start_gate(ctx, role, spec_ctx):
return rejection
if rejection := await self._dev_plan_gate(
role_str=role_str,
rich_plan=rich_plan,
task=t,
agent_id=agent_id,
task_id=task_id,
briefing=briefing,
):
return rejection
envelope = await self._claim_plan_start_run(ctx, agent, spec_ctx)
return await self._post_claim_journal_gate(
"i_will_work_on", agent_id, task_id, envelope
)
@staticmethod
def _dev_plan_field_gaps(rich_plan: dict[str, Any]) -> dict[str, str]:
"""Collect missing/thin rich-plan fields for a fresh dev claim.
Full parity with PMs: approach (the dev's `plan`, >= min chars),
substantive sub_tasks (the `steps` checklist), technical_considerations
and risks. open_questions stay optional. Returns {field: hint}.
"""
gaps: dict[str, str] = {}
approach = str(rich_plan.get("approach") or "").strip()
if len(approach) < _PM_APPROACH_MIN_LEN:
gaps["plan"] = (
f"plan must be >= {_PM_APPROACH_MIN_LEN} chars describing HOW "
"you will implement this (it is the plan's Approach)."
)
steps = rich_plan.get("sub_tasks") or []
if not steps:
gaps["steps"] = (
"a non-empty execution checklist — list of {title, "
"description}; each step is also a progress-checklist item."
)
elif thin := _thin_subtask_hint(steps):
gaps["steps"] = thin
if not rich_plan.get("technical_considerations"):
gaps["technical_considerations"] = (
"list >= 1 architectural / library / approach note (strings)."
)
if not rich_plan.get("risks"):
gaps["risks"] = (
"list >= 1 {risk, mitigation} entry — what could go wrong and "
"how you'll handle it."
)
return gaps
async def _dev_plan_gate(
self,
*,
role_str: str,
rich_plan: dict[str, Any],
task: Any,
agent_id: UUID,
task_id: UUID,
briefing: dict[str, Any],
) -> Envelope | None:
"""A developer's FRESH claim must author the same rich plan a PM does,
so the task's Plan tab is fully populated for audit/tracing. Enforces
approach + steps + technical_considerations + risks (open_questions
optional). Non-developer callers and re-entry are unaffected — the
re-entry/recovery paths return before this is reached. Returns a
rejection Envelope when the plan is thin; None when it passes.
"""
if role_str != "developer":
return None
gaps = self._dev_plan_field_gaps(rich_plan)
if not gaps:
return None
return await self._emit_rejection(
Envelope.incomplete_input(
missing=sorted(gaps),
field_hints=gaps,
remediate=(
"re-issue i_will_work_on(task_id, plan='<how, >= "
f"{_PM_APPROACH_MIN_LEN} chars>', "
"steps=[{'title': '...', 'description': '...'}, ...], "
"technical_considerations=['...'], "
"risks=[{'risk': '...', 'mitigation': '...'}]) — the same "
"rich plan a PM authors, so your task's Plan tab is filled."
),
context_briefing=briefing,
).with_introspection(task=task, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="i_will_work_on",
)
@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: spec.can_invoke_intent runs first and enforces ALL
preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS,
PRECONDITION_NO_PR) BEFORE any git side effect. 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 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
than the spec's ``tracing_gap`` for ``no_prior_pr``. Two calls
in a row should not surface a misleading "open a PR" hint.
"""
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, 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
# open. The spec would otherwise reject with PRECONDITION_NO_PR
# tracing_gap, but agents calling open_pr twice should get the
# existing PR's i_am_done hint, not a misleading "open a PR" remediate.
if t.pr_number is not None and t.assigned_to == agent_id:
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_str)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
)
if rejection := self._open_pr_preflight_rejection(
agent_id, task_id, t, role_str, briefing, spec_ctx
):
return await self._emit_rejection(
rejection, agent_id=agent_id, task_id=task_id, verb="open_pr"
)
await self._touch(task_id)
runner = self._verb_runner()
try:
await runner.run_intent("open_pr", t, agent, spec_ctx)
except Exception as exc:
return await self._emit_rejection(
self._open_pr_failure_env(exc, t, briefing, role_str),
agent_id=agent_id,
task_id=task_id,
verb="open_pr",
)
return await self._open_pr_success_envelope(
agent_id, task_id, t, briefing, role_str
)
def _open_pr_preflight_rejection(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
briefing: dict[str, Any],
spec_ctx: Any,
) -> Envelope | None:
"""Role + reassignment + spec-gate rejection for open_pr (or None).
A stale/superseded agent (task reassigned away) is steered to
give_me_work with a clear not_authorized BEFORE the spec gate would
emit an owns_task tracing_gap the agent retries forever (same pattern
as i_am_done / resume).
"""
try:
role = spec_module.Role(role_str)
except ValueError:
return Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
reassigned = self._reassigned_rejection(
_ReassignedCtx(
task=t,
agent_id=agent_id,
task_id=task_id,
role_str=role_str,
briefing=briefing,
upstream_hint=(
"this task was reassigned away from you; you are no longer "
"its owner. Call give_me_work() to find your current task, "
"or i_am_idle() if there is none. Do NOT retry open_pr."
),
)
)
if reassigned is not None:
return reassigned
decision = spec_module.can_invoke_intent(role, "open_pr", t, spec_ctx)
if not decision.allowed:
return Envelope.from_decision(
decision, briefing=briefing
).with_introspection(task=t, role=role_str)
return None
@staticmethod
def _open_pr_failure_env(
exc: Exception, t: Any, briefing: dict[str, Any], role_str: str
) -> Envelope:
"""Map an open_pr runner failure to its rejection envelope.
Empty-diff subtask: a branch with zero commits relative to its base
makes GitHub 422 with "No commits between ...". open_pr can never
succeed here (an overlapping-decomposition leaf whose work was
delivered by the parent), so a generic invalid_state "retry" loops the
dev forever (15x on one task in a single run). Steer to a terminal
i_am_blocked hand-off so the PM completes/cancels the redundant leaf;
every other failure keeps the generic retry hint.
"""
if "No commits between" in str(exc):
return Envelope.invalid_state(
message=(
"this branch has no commits relative to its base — there is "
"nothing to open a PR for"
),
remediate=(
"do NOT retry open_pr — your branch has no diff, so this "
"leaf's work was almost certainly delivered by its parent "
"task. Call i_am_blocked(reason='no commits to PR — work "
"delivered via parent') so your PM can complete or cancel "
"this redundant leaf."
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
return 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)
async def _open_pr_success_envelope(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
briefing: dict[str, Any],
role_str: str,
) -> Envelope:
"""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.
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)
t = refreshed if refreshed is not None else t
if t.pr_number is not None:
await self._record_milestone_progress(
task_id,
agent_id,
f"opened PR #{t.pr_number}",
percentage=70,
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS["open_pr"].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def i_am_done(self, agent_id: UUID, task_id: UUID, notes: str) -> Envelope:
"""Submit work for QA.
Atomic: ``spec.can_invoke_intent`` runs first and enforces the
intent's role membership and the ``PRECONDITION_OWNERSHIP`` /
``PRECONDITION_COMMITS`` extra preconditions BEFORE any state
mutation. After the spec gate accepts, two additional gate sets
run as defense-in-depth (the spec doesn't yet model them):
- tracing-gate preconditions (progress entry, journal:reflect,
acceptance criteria addressed)
- field-level submit-qa gates (currently: PR open; commits and
ownership are already covered by the spec extras above)
Once all gates pass, ``VerbRunner.run_intent("i_am_done", ...)``
dispatches the (submit_verification, submit_qa) atomic chain
wrapped in a savepoint so a mid-sequence failure rolls back the
DB. Recovery re-entry: a task already in ``verifying`` owned by
the caller has its first composed action (submit_verification,
source IN_PROGRESS) rejected by the spec gate. We short-circuit
before the spec gate and run only ``submit_qa`` — the spec
doesn't model partial-progress recovery, so that branch lives
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.
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 = str(agent.role) if agent is not None else "developer"
briefing = await self._briefing_for(agent_id, task_id, task=t)
ctx = _IAmDoneContext(
agent_id=agent_id,
task_id=task_id,
task=t,
role_str=role_str,
briefing=briefing,
notes=notes,
)
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._reject_i_am_done(
ctx,
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
),
)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
notes=notes,
)
# Recovery re-entry: task already in `verifying` owned by the caller
# (e.g. orchestrator restart between submit_verification and
# submit_qa). The spec gate would reject because the first composed
# action `submit_verification` requires source IN_PROGRESS. Run only
# submit_qa via the runner-equivalent path, then continue with the
# standard tracing/field gates beforehand.
if str(t.status) == "verifying" and t.assigned_to == agent_id:
return await self._i_am_done_resume_from_verifying(ctx)
# Stale/superseded agent: the task was reassigned out from under this
# agent (PM reassign / reaper unclaim / escalation redirect) while its
# container kept running. Without this, the spec gate's
# PRECONDITION_OWNERSHIP emits an owns_task *tracing_gap*, which the
# agent reads as a fixable precondition and retries i_am_done forever
# (41 such loops in one run). A clear "no longer yours" not_authorized
# steers to give_me_work — terminal for this agent. (resume/unclaim
# already do this; i_am_done did not.) Folded into the soup/spec return
# below to stay within the return-count budget.
reassigned = self._reassigned_rejection(
_ReassignedCtx(
task=t,
agent_id=agent_id,
task_id=task_id,
role_str=role_str,
briefing=briefing,
upstream_hint=(
"this task was reassigned away from you; you are no longer "
"its owner. Call give_me_work() to find your current task, "
"or i_am_idle() if there is none. Do NOT retry i_am_done."
),
)
)
# i_am_done notes is optional and supplementary (the real summary lives
# in commits + journal:reflect), so guard it lightly — a banned token
# ('wip'/'x') is soup, but a terse real word like 'done' is fine.
soup = self._free_text_soup(checks=(("notes", notes, 4),))
decision = spec_module.can_invoke_intent(role, "i_am_done", t, spec_ctx)
if env := (reassigned or self._soup_or_decision_env(soup, decision, briefing)):
return await self._reject_i_am_done(ctx, env)
if gate_rejection := await self._i_am_done_gate(ctx):
return gate_rejection
return await self._i_am_done_run(ctx, agent, spec_ctx)
async def _reject_i_am_done(self, ctx: _IAmDoneContext, env: Envelope) -> Envelope:
"""Stamp introspection + emit audit row for an i_am_done rejection."""
env.with_introspection(task=ctx.task, role=ctx.role_str)
return await self._emit_rejection(
env, agent_id=ctx.agent_id, task_id=ctx.task_id, verb="i_am_done"
)
async def _i_am_done_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Run defense-in-depth tracing + field-level gates the spec doesn't model.
Also pushes the branch to origin so a task cannot reach awaiting_qa
with commits that exist only in the developer's local workspace.
Returns the rejection envelope if any gate fails; None on pass. Shared
by the normal and resume-from-verifying paths so both push.
"""
guards = (
lambda: self._check_tracing_gates(ctx.agent_id, ctx.task_id, ctx.task),
lambda: self._check_submit_qa_field_gates(
ctx.agent_id, ctx.task_id, ctx.task
),
lambda: self._ensure_branch_pushed(ctx),
lambda: self._check_quality_gate(ctx),
lambda: self._toolchain_broken_guard(ctx.agent_id, ctx.task),
lambda: self._conventions_gate(ctx),
)
for guard in guards:
if rejection := await guard():
return await self._reject_i_am_done(ctx, rejection)
# 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)
return None
async def _check_quality_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Run the project's fast quality gate (lint + typecheck) in the dev's
workspace before the task reaches QA, so a red gate is caught at the
dev's desk instead of in QA review or CI. The full test suite stays on
CI. Fail-open: a gate-infrastructure error (missing workspace or
toolchain) is logged and never blocks the submit; only an actual check
failure blocks.
"""
try:
result = await self.git.run_pre_submit_quality_gate(ctx.agent_id, ctx.task)
except Exception as exc:
logger.warning(
"quality_gate_skipped", task_id=str(ctx.task_id), error=str(exc)
)
return None
if result.passed:
return None
return Envelope.invalid_state(
message=f"quality gate failed before QA — {result.summary}",
remediate=(
"Fix these in your workspace, commit, and call i_am_done again "
"— QA reviews working code, not a red gate:\n\n" + result.output_excerpt
),
context_briefing=ctx.briefing,
)
async def _toolchain_broken_guard(
self, agent_id: UUID, task: Any
) -> Envelope | None:
"""Refuse a delivery gate when the acting agent's workspace cannot run
the project's suite (interpreter mismatch).
A role that cannot execute the suite degrades to "verifying by reading
source", which the QA + PR-review gates exist to prevent. Inert when the
flag is off; only a recorded ``broken`` status blocks — a missing or
``unknown`` status never strands a task (fail-open).
"""
from roboco.config import settings as _settings
if not _settings.toolchain_match_enabled:
return None
status = await self.git.toolchain_status_for_task(agent_id, task)
if status == "unknown":
# Fail-open (precision over recall) but never silent: a recorded
# 'unknown' means provisioning ran yet the smoke could not confirm
# the suite is collectable under the interpreter — a possible hollow
# pass. Surface it so an operator can see the gate proceeded blind.
logger.warning(
"toolchain.unverified_gate_pass",
agent_id=str(agent_id),
task_id=str(getattr(task, "id", "")),
detail=(
"gate proceeding past an unverified toolchain — the "
"workspace smoke could not confirm the project's suite is "
"collectable under the provisioned interpreter"
),
)
if status != "broken":
return None
return Envelope.invalid_state(
message=(
"the project's test suite cannot be executed in this workspace "
"(interpreter mismatch) — verifying by reading source is hollow"
),
remediate=(
"the workspace Python does not match the project's requirement; "
"call i_am_blocked(reason='toolchain') so the environment is "
"rebuilt against the right interpreter — do NOT pass on a "
"source read"
),
context_briefing={},
)
async def _conventions_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Block i_am_done on unresolved block-level architectural violations.
Also persists the findings for the panel's violations feed — even the
ones that block this submit.
"""
from roboco.config import settings as _settings
if not _settings.conventions_enabled:
return None
result = await self.git.conventions_check_for_task(ctx.agent_id, ctx.task)
await self._record_convention_findings(ctx.task, result)
return self._conventions_rejection(result, ctx.briefing)
@staticmethod
async def _record_convention_findings(task: Any, result: dict[str, Any]) -> None:
"""Persist the task's findings for the feed, best-effort.
Runs in its OWN committed session so a finding that *blocks* the submit
is captured regardless of the verb's transaction outcome.
"""
project_id = getattr(task, "project_id", None)
task_id = getattr(task, "id", None)
if project_id is None or task_id is None:
return
try:
from roboco.db.base import get_session_factory
from roboco.services.conventions import get_conventions_service
factory = get_session_factory()
async with factory() as db:
await get_conventions_service(db).record_findings(
UUID(str(project_id)),
UUID(str(task_id)),
result.get("findings", []),
)
await db.commit()
except Exception as exc:
logger.warning(
"Recording convention findings failed (non-fatal)", error=str(exc)
)
async def _conventions_guard(
self, agent_id: UUID, task: Any, briefing: dict[str, Any]
) -> Envelope | None:
"""Run the conventions validator on the actor's changed files (gated).
A ``block`` finding (a misplaced definition, a lint suppression) or a
validator that could not run returns a rejection with the offending
``file:line`` + fix hint. ``warn`` findings never block. Inert when the
flag is off. Shared by the i_am_done and pr_pass gates.
"""
from roboco.config import settings as _settings
if not _settings.conventions_enabled:
return None
result = await self.git.conventions_check_for_task(agent_id, task)
return self._conventions_rejection(result, briefing)
@staticmethod
def _conventions_rejection(
result: dict[str, Any], briefing: dict[str, Any]
) -> Envelope | None:
"""Turn a validator result into a rejection Envelope, or None to pass."""
if result.get("could_not_run"):
return Envelope.invalid_state(
message=(
"the architectural-conventions validator could not run on "
"your changed files — this blocks rather than passing silently"
),
remediate=(
"the validator failed to analyze the diff (a parse or grammar "
"error). resolve it and call the verb again; if it persists, "
"call i_am_blocked"
),
context_briefing=briefing,
)
blocks = [f for f in result.get("findings", []) if f.get("level") == "block"]
if not blocks:
return None
listing = "\n".join(
f"- {f.get('file')}:{f.get('line')}{f.get('fix_hint')}" for f in blocks
)
return Envelope.invalid_state(
message=(
f"{len(blocks)} architectural-convention violation(s) must be "
"fixed before this can proceed"
),
remediate=(
"place each definition in the module the architecture map assigns "
"it, then commit and call the verb again. if a finding is a false "
"positive, add a waiver to .roboco/conventions.yml in your branch "
"for the PR to review:\n\n" + listing
),
context_briefing=briefing,
)
async def _ensure_branch_pushed(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Push the task branch to origin before it reaches awaiting_qa.
QA reviews the remote PR branch. A fix committed during a revision
cycle lives only in the developer's local workspace until pushed —
without this, QA re-reviews the stale remote and fails the same task
every cycle (a non-converging loop). Idempotent: a no-op when nothing
is unpushed, so first-submit (already pushed by open_pr) is unaffected.
"""
try:
await self.git.push_task_branch(ctx.agent_id, ctx.task_id)
except Exception as exc:
return Envelope.invalid_state(
message=f"could not push your branch to origin: {exc}",
remediate=(
"your latest commits are local-only and QA reviews the "
"pushed PR branch. resolve the push error (often a "
"transient network / fetch timeout) and call i_am_done "
"again."
),
context_briefing=ctx.briefing,
)
return None
@staticmethod
def _extract_first_commit_sha(t: Any) -> str | None:
"""Read the first commit hash off the task, dict or model alike."""
commits: list[Any] = list(getattr(t, "commits", []) or [])
if not commits:
return None
first = commits[0]
if isinstance(first, dict):
return first.get("hash") or first.get("sha")
return getattr(first, "hash", None) or getattr(first, "sha", None)
@staticmethod
def _already_addressed_criteria(existing_status: list[dict[str, Any]]) -> set[str]:
"""Criteria already carrying a non-empty referencing_artifact_id."""
return {
s["criterion"]
for s in existing_status
if isinstance(s, dict) and s.get("referencing_artifact_id")
}
@staticmethod
def _find_existing_entry(
existing_status: list[dict[str, Any]], criterion: str
) -> dict[str, Any] | None:
"""First existing entry whose criterion key matches; None otherwise."""
for entry in existing_status:
if isinstance(entry, dict) and entry.get("criterion") == criterion:
return entry
return None
@staticmethod
def _new_criterion_entry(
criterion: str, has_reflect: bool, first_commit_sha: str | None, now_iso: str
) -> dict[str, Any]:
"""Build the per-criterion status row for an unaddressed criterion."""
addressed = has_reflect or first_commit_sha is not None
if not addressed:
artifact_ref: str | None = None
else:
artifact_ref = first_commit_sha if first_commit_sha else "reflect-note"
return {
"criterion": criterion,
"addressed": addressed,
"artifact_ref": artifact_ref,
"checked_at": now_iso,
}
async def _write_criteria_status(
self, agent_id: UUID, task_id: UUID, t: Any
) -> None:
"""Persist per-criterion addressing status to task.acceptance_criteria_status.
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
audit log can render per-criterion checkmarks.
Already-addressed entries (those already carrying a non-empty
referencing_artifact_id) are preserved as-is. Only criteria not yet
addressed receive a new entry. The artifact_ref for newly-addressed
criteria is the first commit sha if one exists, otherwise "reflect-note".
"""
criteria: list[str] = list(getattr(t, "acceptance_criteria", []) or [])
if not criteria:
return
existing_status: list[dict[str, Any]] = list(
getattr(t, "acceptance_criteria_status", []) or []
)
already_addressed = self._already_addressed_criteria(existing_status)
if already_addressed >= set(criteria):
return
first_commit_sha = self._extract_first_commit_sha(t)
has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
now_iso = datetime.now(UTC).isoformat()
new_status: list[dict[str, Any]] = []
for criterion in criteria:
if criterion in already_addressed:
preserved = self._find_existing_entry(existing_status, criterion)
if preserved is not None:
new_status.append(preserved)
continue
new_status.append(
self._new_criterion_entry(
criterion, has_reflect, first_commit_sha, now_iso
)
)
await self.task.set_acceptance_criteria_status(task_id, new_status)
async def _i_am_done_run(
self, ctx: _IAmDoneContext, agent: Any, spec_ctx: spec_module.Context
) -> Envelope:
"""Dispatch the spec-composed (submit_verification, submit_qa) chain."""
runner = self._verb_runner()
try:
t = await runner.run_intent("i_am_done", ctx.task, agent, spec_ctx)
except Exception as exc:
return await self._reject_i_am_done(
ctx,
Envelope.invalid_state(
message=f"verb runner failed: {exc}",
remediate="check workspace + retry; if persistent, escalate",
context_briefing=ctx.briefing,
),
)
await self._notify_qa(ctx.agent_id, ctx.task_id, t)
await self._touch(ctx.task_id)
# 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,
ctx.agent_id,
"submitted for QA review",
percentage=90,
)
return await self._build_i_am_done_ok(ctx.agent_id, ctx.task_id, t)
async def _i_am_done_resume_from_verifying(self, ctx: _IAmDoneContext) -> Envelope:
"""Recovery path: task is already in `verifying` owned by caller.
The spec's i_am_done composes (submit_verification, submit_qa) and
the runner dispatches the FIRST action; submit_verification's
source_status is IN_PROGRESS so a `verifying` task hits invalid_state
through the spec gate. Run submit_qa directly, plus the same tracing
+ field-level gates the standard path enforces.
"""
if gate_rejection := await self._i_am_done_gate(ctx):
return gate_rejection
try:
submitted = await self.task.submit_qa(ctx.agent_id, ctx.task_id, ctx.notes)
except Exception as exc:
return await self._reject_i_am_done(
ctx,
Envelope.invalid_state(
message=f"submit_qa failed: {exc}",
remediate="check workspace + retry; if persistent, escalate",
context_briefing=ctx.briefing,
),
)
t = submitted if submitted is not None else ctx.task
await self._notify_qa(ctx.agent_id, ctx.task_id, t)
await self._touch(ctx.task_id)
return await self._build_i_am_done_ok(ctx.agent_id, ctx.task_id, t)
async def _check_tracing_gates(
self, agent_id: UUID, task_id: UUID, t: Any
) -> Envelope | None:
"""Run progress / reflect / acceptance-criteria / during-work tracing gates.
The spec composes (submit_verification, submit_qa) for i_am_done and
the auto-run submit_verification flips self_verified=True before
submit_qa runs. SELF_VERIFIED therefore acts as a defense-in-depth
backstop *after* the spec — checking it pre-flight would block the
auto-verify path. It is filtered here and re-asserted by the spec
action's own preconditions.
"""
from roboco.config import settings as _settings
from roboco.foundation.policy import tracing as _tr
has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
has_learning = await self.journal.has_learning_for_task(agent_id, task_id)
has_struggle = await self.journal.has_struggle_for_task(agent_id, task_id)
during_work_count = sum([has_decision, has_learning, has_struggle])
ctx = _tr.GateContext(
journal_reflect_present=has_reflect,
journal_decision_present=has_decision,
journal_learning_present=has_learning,
journal_struggle_present=has_struggle,
journal_during_work_count=during_work_count,
# DEV_NOTES_MIN_CHARS reads the persisted task.dev_notes — the dev
# pre-writes it via note(scope='handoff') before i_am_done.
dev_notes_min_chars=_settings.dev_notes_min_chars,
)
requirements: list[_tr.Requirement] = [
r
for r in _tr.requirements_for("i_am_done")
if r is not _tr.Requirement.SELF_VERIFIED
]
result = _tr.check_requirements(
task=t,
requirements=requirements,
ctx=ctx,
)
if result.passed:
return None
return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)
async def _post_claim_journal_gate(
self,
verb: str,
agent_id: UUID,
task_id: UUID,
envelope: Envelope,
) -> Envelope:
"""Apply the claim-time journal tracing gate AFTER a successful claim.
Pre-gateway parity (spec §11 P1, P3): the (claim, set_plan, start)
sequence is allowed to commit so the agent owns the task, then we
verify the matching journal entry exists. If absent, the agent
gets a tracing_gap with a remediation hint — they journal and
retry the verb (idempotent re-entry shortcuts back to OK once the
entry is present).
If `envelope` is already an error (claim failed or the runner
rejected), we pass it through untouched — no point demanding a
journal note when the claim itself didn't stick.
"""
if envelope.error is not None:
return envelope
t = await self.task.get(task_id)
if t is None:
return envelope
gap = await self._check_claim_journal_at_claim(verb, agent_id, task_id, t)
return gap if gap is not None else envelope
async def _check_claim_journal_at_claim(
self, verb: str, agent_id: UUID, task_id: UUID, t: Any
) -> Envelope | None:
"""Post-claim tracing gate for i_will_work_on / i_will_plan.
Pre-gateway parity (spec §11 P1, P3): developers wrote a
journal:note on every claim; PMs wrote a journal:decision on
plan. The check runs AFTER the composed (claim, set_plan, start)
sequence has succeeded — the claim itself stays. If the journal
entry is missing, the agent receives a tracing_gap envelope and
must journal then retry the verb (similar to how i_am_done's
post-claim gates work).
``PLAN`` is filtered out of the required-set because the spec's
composed action has already enforced PRECONDITION_PLAN before
reaching this point — re-asserting it here would be redundant
and produce a misleading hint when the only real failure is the
missing journal entry.
"""
from roboco.foundation.policy import tracing as _tr
ctx = _tr.GateContext()
if verb == "i_will_work_on":
has_note = await self.journal.has_note_for_task(agent_id, task_id)
ctx = _tr.GateContext(journal_note_at_claim_present=has_note)
elif verb == "i_will_plan":
has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
ctx = _tr.GateContext(journal_decision_present=has_decision)
requirements: list[_tr.Requirement] = [
r for r in _tr.requirements_for(verb) if r is not _tr.Requirement.PLAN
]
result = _tr.check_requirements(
task=t,
requirements=requirements,
ctx=ctx,
)
if result.passed:
return None
return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)
async def _ensure_pm_decision(
self, agent_id: UUID, task_id: UUID, rationale: str | None
) -> None:
"""Auto-record a journal:decision from a PM verb's own rationale.
The write-then-gate pattern (mirrors i_am_blocked → write_struggle):
a PM verb that already carries a substantive rationale — complete /
submit_up / submit_root ``notes``, escalate ``reason``, or a
synthesized unblock context line — records it as the decision
*before* the journal:decision tracing gate runs. A loaded or weak
model that forgot the separate note(scope='decision') call then no
longer stalls in a tracing_gap → respawn loop; the gate still runs
as defense-in-depth and now passes off the just-written entry.
Idempotent within the decision window: when a fresh decision already
satisfies the gate, no duplicate is written. Best-effort — a journal
write failure is logged and swallowed so the verb falls through to
the normal gate (which rejects as before), never crashing the verb.
"""
from roboco.config import settings as _settings
text = (rationale or "").strip()
if not text:
return
try:
latest = await self.journal.latest_decision_at(agent_id, task_id)
window = _settings.pm_decision_window_seconds
if (
latest is not None
and (datetime.now(UTC) - latest).total_seconds() <= window
):
return
await self.journal.write_decision(
agent_id=agent_id, task_id=task_id, content=text
)
except Exception as exc: # best-effort; gate rejects normally on failure
logger.warning(
"auto-record pm decision failed",
error=str(exc),
task_id=str(task_id),
)
async def _check_pm_decision_required(
self, verb: str, agent_id: UUID, task_id: UUID, t: Any
) -> Envelope | None:
"""Standard PM-verb tracing gate driven by VERB_REQUIREMENTS.
Used by ``unblock``, ``escalate_up``, ``escalate_to_ceo``, and
``delegate`` — each declares only ``JOURNAL_DECISION`` in the
foundation table. Verbs requiring more (``complete``,
``submit_up``) use the verb-specific helpers below which thread
the additional state (reflect, notes, subtasks) into GateContext.
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
decision point rather than once at task creation.
"""
from roboco.config import settings as _settings
from roboco.foundation.policy import tracing as _tr
# C8: recency-window only. Per-verb-group consumption tracking
# (one decision satisfies exactly one delegate/unblock/escalate
# call, then is consumed) is out of scope — Choreographer is
# per-request so multi-call state would need a persistent store.
latest = await self.journal.latest_decision_at(agent_id, task_id)
window_seconds = _settings.pm_decision_window_seconds
fresh = (
latest is not None
and (datetime.now(UTC) - latest).total_seconds() <= window_seconds
)
# QUICK_CONTEXT_MIN_CHARS applies only to ``delegate`` (its
# required-set is the only one carrying it); the PM pre-writes the
# parent's quick_context via note(scope='handoff') before delegating.
# Setting the threshold here is inert for unblock / escalate, which do
# not require it.
ctx = _tr.GateContext(
journal_decision_present=fresh,
quick_context_min_chars=_settings.quick_context_min_chars,
)
result = _tr.check_requirements(
task=t,
requirements=list(_tr.requirements_for(verb)),
ctx=ctx,
)
if result.passed:
return None
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
) -> Envelope | None:
"""Tracing gate for cell-PM and main-PM ``complete`` verbs.
VERB_REQUIREMENTS["complete"] = JOURNAL_DECISION + JOURNAL_REFLECT
+ NOTES_MIN_CHARS. ``SUBTASKS_TERMINAL`` is enforced separately by
``_subtasks_not_terminal_envelope`` in the cell/main complete
guards because that gate emits a richer remediation message
listing the non-terminal subtasks; keeping it inline preserves
that UX.
"""
from types import SimpleNamespace
from roboco.config import settings as _settings
from roboco.foundation.policy import tracing as _tr
has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
task_view = SimpleNamespace(notes=notes)
ctx = _tr.GateContext(
journal_decision_present=has_decision,
# 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(
task=task_view,
requirements=list(_tr.requirements_for("complete")),
ctx=ctx,
)
if result.passed:
return None
return await self._build_tracing_gap(agent_id, task_id, result.missing)
async def _check_submit_up_gates(
self, agent_id: UUID, task_id: UUID, notes: str
) -> Envelope | None:
"""Tracing gate for ``submit_up`` (cell PM bubble-up).
VERB_REQUIREMENTS["submit_up"] = SUBTASKS_TERMINAL + JOURNAL_DECISION
+ JOURNAL_REFLECT + NOTES_MIN_CHARS. The notes value is threaded
through a SimpleNamespace shim because the verb hasn't persisted
it to the task yet. ``SUBTASKS_TERMINAL`` is filtered out here
because the inline ``_subtasks_not_terminal_envelope`` that
follows enumerates the non-terminal subtask ids — strictly richer
remediation than the generic foundation hint.
"""
from types import SimpleNamespace
from roboco.config import settings as _settings
from roboco.foundation.policy import tracing as _tr
has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
task_view = SimpleNamespace(notes=notes)
ctx = _tr.GateContext(
journal_decision_present=has_decision,
# 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] = [
r
for r in _tr.requirements_for("submit_up")
if r is not _tr.Requirement.SUBTASKS_TERMINAL
]
result = _tr.check_requirements(
task=task_view,
requirements=requirements,
ctx=ctx,
)
if result.passed:
return None
return await self._build_tracing_gap(agent_id, task_id, result.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.
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, task=t),
)
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.
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.
"""
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id
)
files_changed: list[str] = []
if t.branch_name:
files_changed = await self.git.list_changed_files(branch_name=t.branch_name)
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, task=t),
).with_introspection(task=t, role=role)
@staticmethod
def _hint_for_missing_key(missing_key: str, task_id: UUID) -> str | None:
"""Map a single tracing-requirement key to its agent-facing hint.
Returns ``None`` for keys that need composite handling (i.e.,
``acceptance_criterion:<name>``, which the caller batches into
a single multi-criterion hint).
"""
from roboco.config import settings as _roboco_settings
tid = str(task_id)
notes_min = getattr(_roboco_settings, "notes_min_chars", 20)
simple_hints: dict[str, str] = {
"progress>=1": hint_for_missing_progress(),
"journal:reflect": hint_for_missing_reflect(task_id=tid),
"journal:decision": hint_for_missing_journal_decision(),
"qa_notes>=min": hint_for_missing_qa_notes(),
"journal:learning": hint_for_missing_journal_learning(),
"qa_evidence_inspected": hint_for_evidence_not_inspected(task_id=tid),
"docs_notes>=min": hint_for_short_doc_notes(
min_chars=_roboco_settings.docs_notes_min_chars
),
"docs_files_non_empty": hint_for_missing_doc_files(),
"dev_notes>=min": hint_for_short_dev_notes(
min_chars=getattr(_roboco_settings, "dev_notes_min_chars", 40),
task_id=tid,
),
"pr_reviewer_notes>=min": hint_for_short_pr_reviewer_notes(
min_chars=getattr(_roboco_settings, "pr_reviewer_notes_min_chars", 40),
),
"quick_context>=min": hint_for_short_quick_context(
min_chars=getattr(_roboco_settings, "quick_context_min_chars", 30),
task_id=tid,
),
"journal:note_at_claim": (
"pre-gateway parity P1: write a journal:note at claim. "
f"Call note(scope='note', task_id='{tid}', "
"text='<initial assessment>') describing your read of the "
"task, then retry i_will_work_on."
),
"journal:decision_at_claim": (
"pre-gateway parity P3: PMs write a journal:decision on plan. "
f"Call note(scope='decision', task_id='{tid}', "
"text='<delegation rationale>') with your planning rationale, "
"then retry i_will_plan."
),
"notes>=min": (
f"`notes` must be at least {notes_min} chars describing the "
"merge / escalation rationale; pass a longer notes argument "
"and retry."
),
"subtasks_terminal": (
"all subtasks must be in a terminal state (completed or "
"cancelled) before this transition; wait for the closure "
"dispatcher to bring you back when ready."
),
"journal:during_work>=1": (
"no journal:decision / :learning / :struggle entry exists "
"for this task yet. Pre-gateway parity: developers write "
"at least one work-progress journal entry before submit. "
f"Call note(scope='decision'|'learning'|'struggle', "
f"task_id='{tid}', text='<what you decided or learned>') "
"with a substantive entry, then retry i_am_done. "
"NOTE: scope='reflect' does NOT count for this requirement — "
"reflect is the post-work summary; during_work demands "
"an entry written while the work was happening."
),
"journal:struggle": (
f"call note(scope='struggle', task_id='{tid}', "
"text='<what is blocking you>') with the blocker details, "
"then retry."
),
"commits>=1": (
"no commits linked to this task yet. Use commit(message=...) "
"to record your changes, then retry."
),
"pr_open": (
"no PR has been opened for this task. Call open_pr() to "
"push the branch + open the PR, then retry."
),
"self_verified": (
"task has not been self-verified. i_am_done normally runs "
"submit_verification automatically; if you see this gap, "
"retry i_am_done after the previous call returned."
),
}
return simple_hints.get(missing_key)
async def _build_tracing_gap(
self,
agent_id: UUID,
task_id: UUID,
missing: list[str],
*,
task: Any | None = None,
) -> Envelope:
"""Translate missing requirement keys into agent-facing hints.
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
``_hint_for_missing_key`` still surfaces as a literal
``missing[]`` entry (defense-in-depth: the agent gets at least
the key name even if no hint is registered).
"""
hints: list[str] = []
unaddressed: list[str] = []
unhinted: list[str] = []
for m in missing:
if m.startswith("acceptance_criterion:"):
unaddressed.append(m.split(":", 1)[1])
continue
hint = self._hint_for_missing_key(m, task_id)
if hint is not None:
hints.append(hint)
else:
unhinted.append(m)
if unaddressed:
hints.append(
hint_for_unaddressed_acceptance_criteria(
criteria=unaddressed,
task_id=str(task_id),
)
)
# Fallback hints for missing keys without a registered hint —
# the agent at least sees the literal token instead of nothing.
for token in unhinted:
hints.append(
f"requirement {token!r} not satisfied — see lifecycle docs "
f"or escalate via i_am_blocked if you do not know how to "
f"satisfy this."
)
if len(hints) <= 1:
remediate = hints[0] if hints else ""
else:
numbered = "\n".join(f"{i + 1}. {h}" for i, h in enumerate(hints))
remediate = (
f"Multiple requirements missing — address ALL of the "
f"following before retrying:\n{numbered}"
)
return Envelope.tracing_gap(
missing=missing,
remediate=remediate,
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:
"""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. 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]
@staticmethod
def _build_struggle_body(
reason: str, blocker_type: str | None, what_needed: str | None
) -> str:
"""Render the reason + optional Blocker Type / What Needed sections."""
# Pre-gateway parity (G8 part b): typed blocker_type / what_needed
# render as structured markdown blocks instead of a flat sentence.
if not (blocker_type or what_needed):
return reason
parts = [reason.strip()] if reason.strip() else []
if blocker_type:
parts.append(f"## Blocker Type\n{blocker_type}")
if what_needed:
parts.append(f"## What Needed\n{what_needed}")
return "\n\n".join(parts)
async def _run_i_am_blocked_intent(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
agent: Any,
spec_ctx: spec_module.Context,
role_str: str,
briefing: dict[str, Any],
) -> tuple[Any, Envelope | None]:
"""Dispatch (block,) via the verb runner; return (task, rejection)."""
runner = self._verb_runner()
try:
updated = await runner.run_intent("i_am_blocked", t, agent, spec_ctx)
except Exception as exc:
return t, 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),
agent_id=agent_id,
task_id=task_id,
verb="i_am_blocked",
)
return updated, None
@staticmethod
def _parse_retry_after(what_needed: str | None) -> float | None:
"""Extract a retry-after seconds value from ``what_needed``, or None.
Agents may embed the Retry-After seconds in the ``what_needed``
field as a numeric string (e.g. ``"30"`` or ``"60.5"``). This
helper tries to parse it; any non-numeric or absent value returns
``None``, which maps to the nullable ``retryAfterSeconds`` in the
RATE_LIMIT_HIT event.
"""
if what_needed is None:
return None
try:
return float(what_needed.strip())
except (ValueError, AttributeError):
return None
async def _handle_rate_limited_parking(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
agent: Any,
role_str: str,
briefing: dict[str, Any],
what_needed: str | None,
) -> Envelope:
"""Rate-limited fast path: park agents, publish event, persist state.
Called from ``i_am_blocked`` when ``reason == 'rate_limited'``.
The task stays in its current status (``in_progress``) — no block
transition occurs. Instead, every orchestrator-tracked active agent
sharing the same provider as the calling agent is parked via
``mark_waiting_long(waiting_for='rate_limit_lifted')``. A
``RATE_LIMIT_HIT`` event is published so downstream consumers (the
orchestrator backpressure layer, the panel) can react.
"""
from roboco.models.events import Event, EventType
agent_slug: str | None = (
getattr(agent, "slug", None) if agent is not None else None
)
provider: str = "unknown"
affected_agents: list[str] = []
orch = self.orchestrator
if orch is not None and agent_slug is not None:
with contextlib.suppress(Exception):
prov = orch.get_provider_for_agent(agent_slug)
if prov:
provider = prov
with contextlib.suppress(Exception):
affected_agents = list(
orch.get_active_agent_slugs_for_provider(provider)
)
for slug in affected_agents:
with contextlib.suppress(Exception):
await orch.mark_waiting_long(
slug,
waiting_for="rate_limit_lifted",
task_id=str(task_id),
context={"provider": provider, "triggered_by": agent_slug},
)
retry_after_seconds = self._parse_retry_after(what_needed)
# Persist rate-limit state to Redis so downstream decide_spawn()
# calls can gate new spawns for this provider. Skipped when the
# provider is "unknown" (orchestrator not wired or not tracking the
# agent) to avoid polluting the tracker with meaningless keys.
if provider != "unknown":
with contextlib.suppress(Exception):
from roboco.services.gateway.rate_limit_tracker import (
RateLimitStateTracker,
)
await RateLimitStateTracker(provider).activate(
retry_after=retry_after_seconds,
affected_agents=affected_agents,
)
bus = self.stream_bus
if bus is not None:
event = Event(
type=EventType.RATE_LIMIT_HIT,
data={
"provider": provider,
"affectedAgents": affected_agents,
"retryAfterSeconds": retry_after_seconds,
"timestamp": datetime.now(UTC).isoformat(),
},
source_agent=str(agent_id),
)
with contextlib.suppress(Exception):
await bus.publish(event)
await self._touch(task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"agent parked waiting for rate_limit_lifted; "
"will be respawned when the limit clears"
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def i_am_blocked(
self,
agent_id: UUID,
task_id: UUID,
reason: str,
blocker_type: str | None = None,
what_needed: str | None = None,
) -> Envelope:
"""Escalate task_id and write a struggle journal entry; idle the agent.
Atomic: ``spec.can_invoke_intent`` runs first and enforces role
membership (developer/qa/documenter) and the source-status
constraint of the composed ``block`` action (in_progress only).
After the spec gate accepts, the journal:struggle entry is written
from the verb body, then either:
- ``reason == 'rate_limited'``: the task is **not** transitioned to
``blocked``; instead every active agent on the same provider is
parked via ``mark_waiting_long(waiting_for='rate_limit_lifted')``
and a ``RATE_LIMIT_HIT`` event is published.
- any other reason: ``VerbRunner.run_intent("i_am_blocked", ...)``
dispatches the ``(block,)`` atomic chain wrapped in a savepoint,
transitioning the task to ``blocked``.
"""
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 = str(agent.role) if agent is not None else "developer"
briefing = await self._briefing_for(agent_id, task_id, task=t)
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="i_am_blocked",
)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
notes=reason,
)
soup = self._free_text_soup(checks=(("reason", reason, 8),))
decision = spec_module.can_invoke_intent(role, "i_am_blocked", t, spec_ctx)
if env := self._soup_or_decision_env(soup, decision, briefing):
return await self._emit_rejection(
env.with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="i_am_blocked",
)
# Journal:struggle is a side effect outside the lifecycle action; it
# stays in the verb body, written before the runner dispatches `block`
# so a later runner failure still leaves an audit trail.
await self.journal.write_struggle(
agent_id=agent_id,
task_id=task_id,
content=self._build_struggle_body(reason, blocker_type, what_needed),
)
# Rate-limited fast path: skip the block state transition and park
# all affected agents instead.
if reason.strip().lower() == "rate_limited":
return await self._handle_rate_limited_parking(
agent_id=agent_id,
task_id=task_id,
t=t,
agent=agent,
role_str=role_str,
briefing=briefing,
what_needed=what_needed,
)
t, rejection = await self._run_i_am_blocked_intent(
agent_id, task_id, t, agent, spec_ctx, role_str, briefing
)
if rejection is not None:
return rejection
await self._touch(task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS["i_am_blocked"].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
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.
Spec gate runs first (role membership only — unclaim's IntentSpec
has ``composes=()``, so the spec does not enforce a source-status
constraint). After the gate accepts, the reassignment-rejection
branch (introduced in commit a5d358d) catches "task was reassigned
out from under you by an upstream verb" — the spec doesn't model
that case. Then the verb body owns dispatch via
``task.unclaim_for_agent`` because ``composes=()`` (no atomic action
for the runner to run); the service-level None return surfaces as
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, task=t)
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 = str(agent.role) if agent is not None else "developer"
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="unclaim",
)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
)
decision = spec_module.can_invoke_intent(role, "unclaim", t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=agent_id,
task_id=task_id,
verb="unclaim",
)
reassigned = self._reassigned_rejection(
_ReassignedCtx(
task=t,
agent_id=agent_id,
task_id=task_id,
role_str=role_str,
briefing=briefing,
upstream_hint=(
"the task was reassigned by an upstream verb "
"(cell_pm_complete / main_pm_complete / unblock). "
"call give_me_work() to find your current work."
),
)
)
if reassigned is not None:
return await self._emit_rejection(
reassigned, agent_id=agent_id, task_id=task_id, verb="unclaim"
)
# Verb body owns dispatch — unclaim's IntentSpec has composes=(),
# so VerbRunner has no atomic action to run.
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 a task assigned to you in pending / claimed / "
"in_progress can be unclaimed"
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
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=spec_module._INTENT_VERBS["unclaim"].next_hint(after),
context_briefing=briefing,
).with_introspection(task=after, role=role_str)
@staticmethod
def _validate_reassign(
t: Any, agent_id: UUID, new_assignee: str
) -> Envelope | None:
"""Intra-cell guard for ``reassign`` (verb body owns it — composes=()).
The task must be claimed/in_progress and in the caller's own cell, and
``new_assignee`` must be a developer of that same cell. Returns a
rejection envelope, or None when the hand-off is allowed.
"""
from roboco.agents_config import get_agent_role, get_agent_team
from roboco.seeds.initial_data import AGENT_UUIDS
caller_team = get_agent_team(str(agent_id))
task_team = getattr(t.team, "value", t.team)
status = str(getattr(t.status, "value", t.status))
if status not in ("claimed", "in_progress"):
return Envelope.invalid_state(
message=f"cannot reassign a task in status {status!r}",
remediate=(
"reassign only a claimed or in_progress task; review/terminal"
" states are owned by their lifecycle role"
),
context_briefing={},
)
if task_team is None or task_team != caller_team:
return Envelope.not_authorized(
message=f"task team {task_team!r} is not your cell ({caller_team!r})",
remediate="you can only reassign tasks inside your own cell",
context_briefing={},
)
if new_assignee not in AGENT_UUIDS:
return Envelope.invalid_state(
message=f"unknown agent slug {new_assignee!r}",
remediate=(
"new_assignee must be a developer slug in your cell, e.g. be-dev-2"
),
context_briefing={},
)
if get_agent_role(new_assignee) != "developer":
return Envelope.not_authorized(
message=f"{new_assignee!r} is not a developer",
remediate=(
"reassign hands work to a developer in your cell;"
" only dev slugs are valid"
),
context_briefing={},
)
if get_agent_team(new_assignee) != caller_team:
return Envelope.not_authorized(
message=f"{new_assignee!r} is not in your cell ({caller_team!r})",
remediate="reassign only to a developer in your own cell",
context_briefing={},
)
return None
async def reassign(
self, agent_id: UUID, task_id: UUID, new_assignee: str
) -> Envelope:
"""A cell PM hands a claimed/in_progress task to another dev in its cell.
Intra-cell only (see ``_validate_reassign``). The branch is keyed to the
task, not the agent, so it survives — the new developer continues the
work-in-progress and is respawned by the orchestrator.
"""
from roboco.seeds.initial_data import AGENT_UUIDS
t = await self.task.get(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"),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else "cell_pm"
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
)
decision = spec_module.can_invoke_intent(role, "reassign", t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
guard = self._validate_reassign(t, agent_id, new_assignee)
if guard is not None:
return await self._emit_rejection(
guard.with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
after = await self.task.reassign_active_claim(
task_id, UUID(AGENT_UUIDS[new_assignee])
)
if after is None:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"cannot reassign from status {t.status}",
remediate="only a claimed / in_progress task can be reassigned",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
return Envelope.ok(
status=str(after.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS["reassign"].next_hint(after),
context_briefing=briefing,
).with_introspection(task=after, role=role_str)
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.
Spec gate runs first and enforces role membership plus the
composed ``resume`` action's source-status constraint (PAUSED
only). After the gate accepts, the reassignment-rejection branch
catches "task was reassigned by an upstream verb" — the spec
doesn't model that case, so the existing envelope text (preserved
from commit a5d358d) is the load-bearing hint. Then
``VerbRunner.run_intent("resume", ...)`` dispatches the (resume,)
atomic chain wrapped in a savepoint.
"""
t = await self.task.get(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"),
agent_id=agent_id,
task_id=task_id,
verb="resume",
)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else "developer"
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="resume",
)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
)
decision = spec_module.can_invoke_intent(role, "resume", t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=agent_id,
task_id=task_id,
verb="resume",
)
reassigned = self._reassigned_rejection(
_ReassignedCtx(
task=t,
agent_id=agent_id,
task_id=task_id,
role_str=role_str,
briefing=briefing,
upstream_hint=(
"the task was reassigned by an upstream verb. "
"call give_me_work() to find your current work."
),
)
)
if reassigned is not None:
return await self._emit_rejection(
reassigned, agent_id=agent_id, task_id=task_id, verb="resume"
)
runner = self._verb_runner()
runner_failure_msg: str | None = None
try:
after = await runner.run_intent("resume", t, agent, spec_ctx)
except Exception as exc:
after = None
runner_failure_msg = f"verb runner failed: {exc}"
if after is None:
# Either the runner raised (recorded above) or the service
# returned None despite the spec gate accepting (race: status
# drifted between get and write). Both surface as invalid_state.
return await self._emit_rejection(
Envelope.invalid_state(
message=runner_failure_msg
or f"cannot resume from status {t.status} (drift)",
remediate="only paused tasks can be resumed",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
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=spec_module._INTENT_VERBS["resume"].next_hint(after),
context_briefing=briefing,
).with_introspection(task=after, role=role_str)
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. Board/advisory roles (product_owner,
head_marketing, auditor) are exempt: they review without claiming.
(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=(
"clear your inbox, then retry i_am_idle(): read_messages()"
" for unread A2A, notify_ack() per @mention notification"
),
context_briefing=briefing,
)
# Pre-idle guards, evaluated in order — the first that returns an
# Envelope short-circuits to a rejection (kept as a loop so adding a
# guard doesn't push this verb over the return-count bound).
idle_guards = (
self._pending_assignment_guard,
self._pm_unfinished_review_guard,
self._pm_uncovered_decomposition_guard,
self._pm_uncovered_required_cells_guard,
self._auditor_note_guard,
)
for guard_fn in idle_guards:
if guard := await guard_fn(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
agent = await self.task.agent_for(agent_id)
pending = await self._pending_blocking_idle(agent, pending)
if not pending:
return None
first = pending[0]
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 _pending_blocking_idle(self, agent: Any, pending: list[Any]) -> list[Any]:
"""Pending tasks that should block i_am_idle, after role exemptions.
Board/advisory roles (product_owner, head_marketing, auditor) review and
advise without ever claiming — they have no i_will_work_on / i_will_plan
verb, and their one-shot dispatch is meant to leave the coordination task
pending for the CEO to reassign. They must idle freely, so nothing blocks
them. Developers own a whole per-dev code queue up front, so a leaf still
waiting behind an earlier non-terminal sibling in its own lane must not
pin them — the orchestrator spawns it when the lane clears (see
``_pending_not_lane_held``). Other roles: every pending task blocks.
"""
if not agent:
return pending
if agent.role in ("product_owner", "head_marketing", "auditor"):
return []
if agent.role == "developer":
return await self._pending_not_lane_held(pending)
return pending
async def _pending_not_lane_held(self, pending: list[Any]) -> list[Any]:
"""Drop a dev's pending code leaves that are waiting behind an earlier
non-terminal sibling in the same dev's lane (per-dev sequenced queues).
Those leaves are spawned by the orchestrator when the lane clears, so
they must not pin the dev to idle. ``is not True`` keeps this inert under
partial test mocks (an AsyncMock returns a truthy stub, not a real bool)
— only a leaf the service positively confirms is lane-held is dropped.
"""
live: list[Any] = []
for t in pending:
if await self.task.has_earlier_incomplete_code_sibling(t) is not True:
live.append(t)
return live
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,
)
# The auditor must have recorded an observation within this window before
# it may go idle (session-scoped note obligation; see _auditor_note_guard).
_AUDITOR_IDLE_NOTE_WINDOW_SECONDS: int = 3600
async def _auditor_note_guard(
self, agent_id: UUID, briefing: dict[str, Any]
) -> Envelope | None:
"""Refuse i_am_idle when the auditor has not recorded an observation.
Every role with a dedicated note section is obligated to populate it,
the same way journals are obligated. The auditor's section is
``auditor_notes``, but it owns no delivery task and has no delivery
verb to hang the obligation on — so the obligation is session-scoped:
before going idle the auditor must have recorded an observation within
the last ``_AUDITOR_IDLE_NOTE_WINDOW_SECONDS`` (a reflect journal entry,
or a note(scope='handoff') section write — either leaves a journal
entry). Inert for every other role.
"""
agent = await self.task.agent_for(agent_id)
if agent is None or str(agent.role) != "auditor":
return None
if await self.journal.has_recent_entry(
agent_id, self._AUDITOR_IDLE_NOTE_WINDOW_SECONDS
):
return None
return Envelope.invalid_state(
message=(
"as the auditor you must record an observation before going "
"idle — your auditor_notes section is the artifact the company "
"reviews, so it cannot be left empty."
),
remediate=(
"call note(scope='reflect', text='<what you observed and any "
"concern>') for a journal observation, or "
"note(scope='handoff', task_id='<task>', section={'summary': "
"'<observation>', 'severity': 'info'|'watch'|'risk'}) to fill a "
"task's auditor_notes — then retry i_am_idle()."
),
context_briefing=briefing,
)
async def _pm_uncovered_decomposition_guard(
self, agent_id: UUID, briefing: dict[str, Any]
) -> Envelope | None:
"""Refuse i_am_idle when a PM left a parent criterion unclaimed (Spec 2).
The decomposition floor: a PM that has mapped *some* children to parent
acceptance criteria (``covers_parent_criteria``) cannot idle while
another parent criterion still has no subtask responsible for it — the
"two leaves, half the ACs silently dropped" pattern from PR #175.
Distinct from the roll-up gate (Spec 4), which fires at submit_up /
complete / escalate and demands a *completed* child; this fires earlier,
at PM exit, and asks only that every criterion be *claimed*.
Safe-by-construction: ``unclaimed_parent_acceptance_criteria`` returns
``[]`` until the PM declares coverage on at least one child, so this is
inert for legacy / not-yet-adopted decompositions and never blocks a PM
who never touched coverage at all.
"""
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)
for parent in assigned:
if str(parent.status) in self._TERMINAL_STATUSES:
continue
unclaimed = await self.task.unclaimed_parent_acceptance_criteria(parent.id)
# isinstance keeps the gate inert under partial test mocks (an
# AsyncMock TaskService returns a truthy stub, not a concrete list).
if not isinstance(unclaimed, list) or not unclaimed:
continue
listing = "; ".join(unclaimed)
return Envelope.invalid_state(
message=(
f"task {parent.id} has {len(unclaimed)} acceptance criteria with"
" no subtask responsible for them; cannot idle mid-decomposition."
),
remediate=(
"delegate (or reassign) subtasks whose covers_parent_criteria"
f" include these criteria, then retry i_am_idle: {listing}"
),
context_briefing=briefing,
)
return None
async def _pm_uncovered_required_cells_guard(
self, agent_id: UUID, briefing: dict[str, Any]
) -> Envelope | None:
"""Refuse i_am_idle when a PM left an explicitly-named cell undelegated.
The brief / acceptance criteria / Board handoff can name specific cells
(recorded as a ``required_cells:`` marker on the parent). The Main PM
must create a subtask for each — its discretion covers only un-named
scope; a genuinely-unnecessary named cell must be confirmed via
escalate_up/dm, not silently dropped. This is the structured companion
to the prompt rule (commit 60de3499), firing at PM exit. Inert when no
parent carries the marker, so legacy decompositions are never blocked.
"""
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)
for parent in assigned:
if str(parent.status) in self._TERMINAL_STATUSES:
continue
uncovered = await self.task.uncovered_required_cells(parent.id)
# isinstance keeps the gate inert under partial test mocks (an
# AsyncMock TaskService returns a truthy stub, not a concrete list).
if not isinstance(uncovered, list) or not uncovered:
continue
listing = ", ".join(uncovered)
return Envelope.invalid_state(
message=(
f"task {parent.id} names cells with no subtask: {listing}; "
"cannot idle while an explicitly-named cell is undelegated."
),
remediate=(
f"delegate a subtask for each named cell ({listing}); if one is "
"genuinely unnecessary, confirm via escalate_up/dm rather than "
"dropping it, then retry i_am_idle"
),
context_briefing=briefing,
)
return None
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.
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.
"""
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))
await self._write_auto_pause_checkpoint(agent_id, t)
return paused_ids
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.
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
i_am_idle case which is what all current agents do.
Failure is logged and swallowed — the pause already happened and the
caller must not be affected by a checkpoint DB error.
"""
try:
commits = task.commits or []
# commits may be hydrated as CommitRef objects or as plain dicts
# (JSON column round-trip); the identifier field is `hash` (a stray
# `sha` only ever appears on a gateway return value, never persisted).
commit_refs = [
(c.get("hash") or c.get("sha"))
if isinstance(c, dict)
else (getattr(c, "hash", None) or getattr(c, "sha", None))
for c in commits[-3:]
]
commit_refs = [ref for ref in commit_refs if ref]
commit_count = len(commits)
state_summary = f"auto-paused on i_am_idle (commits: {commit_count})"
remaining_work = commit_refs if commit_refs else ["no commits yet"]
await self.task.add_checkpoint(
task_id=task.id,
agent_id=agent_id,
state_summary=state_summary,
remaining_work=remaining_work,
)
except Exception:
log = structlog.get_logger(__name__)
log.warning(
"auto_pause_checkpoint_failed",
task_id=str(task.id),
agent_id=str(agent_id),
)
# --- QA verbs moved to ``qa.py``. ---
# --- Phase 3 (documenter + PM) verbs ---
# claim_doc_task + i_documented moved to ``doc.py``.
_RICH_PLAN_FIELDS: ClassVar[tuple[str, ...]] = (
"approach",
"sub_tasks",
"technical_considerations",
"risks",
"open_questions",
)
@staticmethod
def _resolve_effective_plan(
plan: str, rich_plan: dict[str, Any] | None
) -> str | dict[str, Any]:
"""Use the panel-shaped dict when any rich field is populated.
Requires `plan` (narrative paragraph) to be non-empty — the rich
structure is layered on top, not a replacement. An empty paragraph
falls through as ``""`` so the spec's plan-required precondition
emits tracing_gap.
"""
if not plan or not rich_plan:
return plan
if any(rich_plan.get(k) for k in Choreographer._RICH_PLAN_FIELDS):
return _build_panel_shaped_plan(plan, rich_plan)
return plan
async def i_will_plan(
self,
pm_agent_id: UUID,
task_id: UUID,
plan: str,
rich_plan: dict[str, Any] | None = None,
) -> Envelope:
"""PM mirror of i_will_work_on for parent tasks.
Atomic: spec.can_invoke_intent runs before any state mutation;
the composed (claim, set_plan, start) sequence is wrapped in a
savepoint by the runner so a mid-sequence failure rolls back
the DB.
PM callers must supply ``approach`` (>= 20 chars) and a non-empty
``sub_tasks`` list inside ``rich_plan`` — these are enforced in
``_pm_sub_tasks_gate``. Developer callers may omit ``sub_tasks``
(their plan is execution-shaped) but still need ``approach`` via
the HTTP schema layer.
Control flow: re-entry check → if not re-entry → sub_tasks gate
→ spec gate → claim+plan+start. The re-entry check must come first
so a respawned PM calling with thin args ("resume", no sub_tasks)
is short-circuited before the gate can reject them.
"""
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 = str(agent.role) if agent is not None else "cell_pm"
briefing = await self._briefing_for(
pm_agent_id, task_id, task=t, include_ac_coverage=True
)
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=pm_agent_id,
task_id=task_id,
verb="i_will_plan",
)
effective_plan = self._resolve_effective_plan(plan, rich_plan)
# 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.
spec_ctx = spec_module.Context(
plan=effective_plan,
actor_id=pm_agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
)
ctx = _ClaimPlanStartContext(
agent_id=pm_agent_id,
task_id=task_id,
task=t,
role_str=role_str,
briefing=briefing,
plan=effective_plan,
verb_name="i_will_plan",
)
# Re-entry check runs first — a respawned PM with thin args ("resume",
# no sub_tasks) must short-circuit here before any gate.
if reentry := await self._handle_pm_reentry(
ctx, t, pm_agent_id, task_id, role_str, briefing
):
return reentry
# Lifecycle spec gate runs BEFORE the sub_tasks gate so wrong-state
# cases (e.g., task in backlog/claimed/completed) return invalid_state
# — the lifecycle's verdict — instead of being masked by the
# PM-decomposition check. Parity test
# `test_lifecycle_consumer_parity.py::test_i_will_plan_matches_spec`
# asserts this order.
if rejection := await self._claim_plan_start_gate(ctx, role, spec_ctx):
return rejection
# Spec gate passed; now enforce the verb-specific PM-decomposition
# contract. PMs decompose; their plan MUST include approach + at
# least one sub_task. Devs execute; sub_tasks may be empty.
if rejection := await self._pm_sub_tasks_gate(
role_str=role_str,
rich_plan=rich_plan,
task=t,
agent_id=pm_agent_id,
task_id=task_id,
briefing=briefing,
):
return rejection
envelope = await self._claim_plan_start_run(ctx, agent, spec_ctx)
return await self._post_claim_journal_gate(
"i_will_plan", pm_agent_id, task_id, envelope
)
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.
Atomic: spec.can_invoke_intent runs first for the role+state gate.
Delegate-specific gates the spec doesn't model (chain validation,
assignee-vs-task_type, enum coercion, parent-ownership, subtask
cap) run after the spec gate. Main PM may delegate to a Cell PM
slug; a Cell PM may delegate to its own team's developers. The
atomic ``create_subtask`` action is special — its handler raises
NotImplementedError because it requires DelegateInputs — so the
verb body owns the dispatch to ``_create_subtask_from_inputs``.
"""
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 = str(agent.role) if agent is not None else "cell_pm"
briefing = await self._briefing_for(pm_agent_id, parent_task_id, task=parent)
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=parent, role=role_str),
agent_id=pm_agent_id,
task_id=parent_task_id,
verb="delegate",
)
spec_ctx = spec_module.Context(
actor_id=pm_agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(parent),
)
soup = self._free_text_soup(
checks=(
("title", inputs.title, 5),
("description", inputs.description, 10),
)
)
decision = spec_module.can_invoke_intent(role, "delegate", parent, spec_ctx)
if env := self._soup_or_decision_env(soup, decision, briefing):
return await self._emit_rejection(
env.with_introspection(task=parent, role=role_str),
agent_id=pm_agent_id,
task_id=parent_task_id,
verb="delegate",
)
# 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 still catches non-gateway callers.
completeness_env = self._delegate_completeness_check(
inputs, parent, briefing, role_str
)
if completeness_env is not None:
return await self._emit_rejection(
completeness_env,
agent_id=pm_agent_id,
task_id=parent_task_id,
verb="delegate",
)
# Write-then-gate: the delegated subtask's title + description (the
# PM's own articulation of the work) is recorded as the
# journal:decision the tracing guard below requires.
await self._ensure_pm_decision(
pm_agent_id,
parent_task_id,
f"Delegating subtask '{inputs.title}': {inputs.description}",
)
# Spec gate passed. Run delegate-specific guards the spec doesn't
# model: tracing (journal:decision), chain validation, enum
# coercion + assignee-vs-task_type, and parent-ownership/subtask-cap.
guard = await self._delegate_extra_guards(
pm_agent_id, parent_task_id, parent, role_str, inputs
)
if guard is not None:
return await self._emit_rejection(
guard.with_introspection(task=parent, role=role_str),
agent_id=pm_agent_id,
task_id=parent_task_id,
verb="delegate",
)
return await self._create_subtask_and_envelope(
pm_agent_id, parent, inputs, briefing, role_str
)
# 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_extra_guards(
self,
pm_agent_id: UUID,
parent_task_id: UUID,
parent: Any,
role_str: str,
inputs: DelegateInputs,
) -> Envelope | None:
"""Delegate-specific guards the spec doesn't model.
Order: tracing (journal:decision per VERB_REQUIREMENTS) ->
chain validation -> static (project_id, enum coercion,
assignee-vs-task_type) -> lifecycle (parent ownership + subtask
cap). Each returns an Envelope rejection or None to allow.
The role-only check is no longer here — ``spec.can_invoke_intent``
handles role+state in the verb body before this is called.
"""
# Pre-gateway PM.md required journal:decision before each delegate.
if env := await self._check_pm_decision_required(
"delegate", pm_agent_id, parent_task_id, parent
):
return env
chain_error = self._validate_delegation_chain(role_str, 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),
)
if guard := await self._delegate_static_guards(
pm_agent_id, parent_task_id, parent, inputs
):
return guard
# Sibling dedup: catch the PM-decomposition bug where the same
# parent gets two subtasks for the same role + task_type
# (observed on smoke run 2026-05-11: Main PM created two planning
# tasks for be-pm; Cell PM created two code tasks for be-dev-1).
if guard := await self._delegate_sibling_dedup_guard(parent_task_id, inputs):
return guard
# Split-before-claim: reject an egregiously-bundled code leaf so the PM
# splits it before any dev can claim it.
if guard := self._delegate_sizing_guard(inputs):
return guard
# Gate Set B: PARENT_NOT_CLAIMED + SUBTASK_CAP
return await self._delegate_lifecycle_guards(
pm_agent_id, parent_task_id, parent
)
_TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset({"completed", "cancelled"})
# Per-parent concurrency cap by spine task_type. `planning` and
# `documentation` stay sequential (one non-terminal at a time). `code` has
# NO per-parent cap: a PM delegates a full per-dev queue up front (Spec 3
# per-dev sequenced queues) — both cell devs build in parallel, each works
# its own queue in sequence order, and the orchestrator's per-lane dispatch
# barrier (`_blocked_by_earlier_lane_sibling`) keeps a dev to one live task
# at a time. Total fan-out is still bounded by `_SUBTASK_HARD_CAP`.
_SPINE_TYPE_CAPS: ClassVar[dict[str, int]] = {
"planning": 1,
"documentation": 1,
}
# Split-before-claim sizing thresholds for a `code` leaf (by acceptance-
# criteria count). Above the nudge count we flag a possible split in the
# success envelope; above the hard count we reject so the PM splits the
# bundle before any dev can claim it.
_SIZING_NUDGE_AC_COUNT: ClassVar[int] = 5
_SIZING_HARD_AC_COUNT: ClassVar[int] = 8
@classmethod
def _code_leaf_ac_count(cls, inputs: DelegateInputs) -> int | None:
"""Acceptance-criteria count for a ``code`` subtask, else None.
Only ``code`` leaves are sized this way — ``planning`` briefs (main_pm
→ cell_pm) legitimately carry many criteria and are exempt.
"""
if str(inputs.task_type or "") != "code":
return None
return len(inputs.acceptance_criteria or [])
@classmethod
def _delegate_sizing_guard(cls, inputs: DelegateInputs) -> Envelope | None:
"""Hard-block an egregiously-bundled code leaf (split-before-claim).
A ``code`` subtask carrying more than ``_SIZING_HARD_AC_COUNT``
acceptance criteria bundles too many independent concerns into one leaf:
QA can't pass a partial, the dev re-touches unrelated parts, and criteria
get dropped. Reject it so the PM splits the bundle before a dev ever
claims it. Moderate bundling is allowed but flagged (see
:meth:`_sizing_hint`).
"""
ac_count = cls._code_leaf_ac_count(inputs)
if ac_count is None or ac_count <= cls._SIZING_HARD_AC_COUNT:
return None
return Envelope.invalid_state(
message=(
f"code subtask bundles {ac_count} acceptance criteria — too many "
f"independent concerns for one leaf "
f"(cap {cls._SIZING_HARD_AC_COUNT})."
),
remediate=(
"Split this into smaller code subtasks before delegating — one "
"concern each, 2-4 acceptance criteria per subtask. Hand "
"independent concerns to both cell devs in parallel; sequence "
"dependent ones. A bundled leaf drives multi-round QA failures "
"and drops criteria."
),
context_briefing={},
)
@classmethod
def _sizing_hint(cls, inputs: DelegateInputs) -> str | None:
"""Soft nudge for a code leaf in the moderate sizing band.
Fires above the nudge count and below the hard cap (the hard cap rejects
outright). Surfaced in the delegate success envelope, never blocking.
"""
ac_count = cls._code_leaf_ac_count(inputs)
if ac_count is None or ac_count <= cls._SIZING_NUDGE_AC_COUNT:
return None
return (
f"This code subtask carries {ac_count} acceptance criteria. If they "
"span more than one independent concern, consider splitting it so "
"each concern is its own subtask — and hand independents to both "
"devs in parallel. (Allowed, just flagged.)"
)
@staticmethod
def _spine_type_dup_envelope(
new_type: str, sibling: Any, sib_assignee: str, cap: int = 1
) -> Envelope:
"""Rule-1 rejection: a sequential spine (planning/documentation) is full.
Only ``planning`` and ``documentation`` are cap-limited now (1 at a
time); ``code`` has no per-parent cap (Spec 3 per-dev queues), so this
only ever fires for the sequential spines. ``cap`` is kept for the
message but is 1 in every live call.
"""
return Envelope.invalid_state(
message=(
f"parent already has {cap} non-terminal "
f"task_type={new_type!r} subtask(s) "
f"(e.g. {sibling.id}, assigned_to={sib_assignee!r}, "
f"status={sibling.status}). The {new_type!r} spine is "
"sequential — only one non-terminal at a time."
),
remediate=(
"Drive an existing sibling to completion / cancel it before "
"delegating another of the same type. If the work is genuinely "
"parallel, split this parent into two sibling parents instead.\n\n"
"**DO NOT work around this by delegating again with a "
"different task_type** (e.g. 'documentation' or "
"'research' as a 'verification' subtask). The lifecycle "
"handles QA, documentation, and PM-review automatically "
"after the code subtask finishes — you do not create "
"auxiliary subtasks for those roles. Call i_am_idle() "
"now and wait for an existing child to come back."
),
context_briefing={},
)
@staticmethod
def _same_assignee_dup_envelope(
new_type: str, new_assignee: str, sibling: Any
) -> Envelope:
"""Rule-2 rejection: same assignee already owns same task_type."""
return Envelope.invalid_state(
message=(
f"sibling subtask already assigned to "
f"{new_assignee!r} with task_type={new_type!r}: "
f"id={sibling.id} status={sibling.status}"
),
remediate=(
"Either drive the existing sibling to completion / "
"cancel it, or split this work into a subtask of "
"the existing sibling rather than a new sibling."
),
context_briefing={},
)
@staticmethod
def _is_cross_team_planning(new_type: str, new_team: str, sib_team: str) -> bool:
"""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.
"""
return (
new_type == "planning"
and bool(new_team)
and bool(sib_team)
and new_team != sib_team
)
@classmethod
def _sibling_cap_envelope(
cls,
siblings: list[Any],
new_type: str,
new_team: str,
new_assignee: str,
new_title: str = "",
) -> Envelope | None:
"""Apply Rule-2 (same-assignee) then Rule-1 (spine concurrency cap).
Rule 2: prevent accidental duplicate delegation to one agent — for
``planning`` / ``documentation`` no two same-type subtasks per parent;
for ``code`` only an exact same-title repeat (a dev may own a sequenced
queue otherwise).
Rule 1: a parent may hold at most ``_SPINE_TYPE_CAPS[type]`` non-terminal
subtasks of a spine type. ``code`` has no per-parent cap (a full per-dev
queue is delegated up front; the per-lane dispatch barrier and
``_SUBTASK_HARD_CAP`` bound it); ``planning`` and ``documentation`` stay
at 1. ``planning`` on a different team does not count toward the cap —
that's main_pm's legitimate cross-cell fanout (see
:meth:`_is_cross_team_planning`).
"""
live = [
s
for s in siblings
if str(getattr(s, "status", "")) not in cls._TERMINAL_STATUSES
]
return cls._same_assignee_rejection(
live, new_type, new_assignee, new_title
) or cls._spine_cap_rejection(live, new_type, new_team)
@staticmethod
def _norm_title(title: str) -> str:
"""Lower-case, whitespace-collapsed title for duplicate comparison."""
return " ".join((title or "").lower().split())
@classmethod
def _same_assignee_rejection(
cls, live: list[Any], new_type: str, new_assignee: str, new_title: str = ""
) -> Envelope | None:
"""Rule 2: prevent accidental duplicate delegation to one agent.
For ``planning`` / ``documentation`` (sequential spines) a PM never
gives one agent two non-terminal subtasks of the same type under one
parent. For ``code`` a dev legitimately owns a *queue* of sequenced
subtasks (Spec 3 per-dev parallelism), so same-type alone is allowed —
only an exact same-title duplicate (the accidental re-delegation bug,
e.g. "Cell PM created two code tasks for be-dev-1") is rejected.
"""
if not new_assignee:
return None
norm_new = cls._norm_title(new_title)
for sibling in live:
if str(getattr(sibling, "assigned_to", "") or "") != new_assignee:
continue
if str(getattr(sibling, "task_type", "")) != new_type:
continue
if new_type == "code":
# A distinct queue item is fine; an exact title repeat is not.
sib_title = cls._norm_title(str(getattr(sibling, "title", "") or ""))
if norm_new and sib_title == norm_new:
return cls._same_assignee_dup_envelope(
new_type, new_assignee, sibling
)
continue
return cls._same_assignee_dup_envelope(new_type, new_assignee, sibling)
return None
@classmethod
def _spine_cap_rejection(
cls, live: list[Any], new_type: str, new_team: str
) -> Envelope | None:
"""Rule 1: at most ``_SPINE_TYPE_CAPS[type]`` non-terminal same-type."""
cap = cls._SPINE_TYPE_CAPS.get(new_type)
if cap is None:
return None
same_spine = [
s
for s in live
if str(getattr(s, "task_type", "")) == new_type
and not cls._is_cross_team_planning(
new_type, new_team, str(getattr(s, "team", "") or "")
)
]
if len(same_spine) < cap:
return None
blocker = same_spine[0]
return cls._spine_type_dup_envelope(
new_type, blocker, str(getattr(blocker, "assigned_to", "") or ""), cap=cap
)
async def _delegate_sibling_dedup_guard(
self,
parent_task_id: UUID,
inputs: DelegateInputs,
) -> Envelope | None:
"""Block PM-decomposition over-spread (the smoke-run runaway pattern).
Two rules:
1. **Same-type concurrency cap**: a parent may hold at most
``_SPINE_TYPE_CAPS[type]`` non-terminal subtasks of a spine type —
``planning`` and ``documentation`` stay at 1. ``code`` has no
per-parent cap (a full per-dev sequenced queue is delegated up front;
concurrency is bounded by the per-lane dispatch barrier and
``_SUBTASK_HARD_CAP``). Exception: ``planning`` on a different team
does not count — that's main_pm's legitimate cross-cell fanout.
2. **Same-assignee duplicate** (fallback): a PM never delegates two
``planning``/``documentation`` subtasks of the same type to one
agent under one parent; for ``code`` a dev may own a queue, so only
an exact same-title repeat is blocked (accidental re-delegation).
Both rules surface an existing sibling id so the PM can finish
or cancel it instead of guessing.
"""
siblings = await self.task.get_subtasks(parent_task_id)
return self._sibling_cap_envelope(
list(siblings),
str(inputs.task_type or ""),
str(inputs.team or ""),
str(inputs.assigned_to or ""),
str(inputs.title or ""),
)
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
`_validate_delegation_chain` runs first (in `_delegate_extra_guards`)
and 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 and getattr(parent, "product_id", None) is None:
return Envelope.invalid_state(
message="parent task has neither a project_id nor a product_id",
remediate=(
"the parent must have a project (single repo) or a product "
"(cell->project map) so subtasks can resolve a repo"
),
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=self._assignee_task_type_remediate(inputs.assigned_to),
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
if str(inputs.task_type) == "documentation":
# 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
# to a developer can never be spawned (dev-dispatch rejects
# role/task_type mismatch) — it becomes a permanent orphan
# that deadlocks submit_up (all subtasks must be terminal).
# The spine-cap is per-type so this slips past the
# code-vs-code dedup; reject it explicitly here.
return Envelope.invalid_state(
message=(
"task_type='documentation' subtasks are not PM-"
"delegatable: the lifecycle creates the documentation "
"phase automatically after the code subtask passes QA."
),
remediate=(
"Delegate ONLY the code subtask (task_type='code'). "
"Once it passes QA the gateway transitions it to "
"awaiting_documentation and spawns a documenter for "
"you — do NOT create a separate documentation subtask "
"or assign docs to a developer. Re-issue delegate(...) "
"with task_type='code' and i_am_idle when done."
),
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.
Rules:
- 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
implement. Planning/design/administrative belong to PMs/board.
The 'research' allowance covers genuine spike work (try a
library, prototype an approach) — NOT coordination/handoff,
which is PM work.
- (#7): the UX/UI cell's developers ARE its designers — for a
DEVELOPER on ``Team.UX_UI`` ``task_type='design'`` is legitimate
cell work (mockups, specs, design assets committed to the repo),
so ux-dev-1/ux-dev-2 also accept ``design``. Backend/frontend
devs still cannot be handed ``design`` — that routing belongs to
the UX cell. The orchestrator already dispatches a developer for a
``design`` task (``_dev_dispatch_role_matches`` returns True), so
this does not create the orphan that blocks ``documentation``.
- Delegating to a QA requires ``task_type='code'`` (their work is
to review PRs of code changes).
- Delegating to a Documenter requires ``task_type='documentation'``.
"""
from roboco.foundation.identity import AGENTS, Role, Team
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."
)
agent = AGENTS.get(assigned_to)
if agent is None:
return None
if agent.role is Role.DEVELOPER:
dev_err = Choreographer._developer_task_type_error(
assigned_to, agent.team is Team.UX_UI, task_type
)
if dev_err is not None:
return dev_err
if agent.role is Role.QA and task_type != "code":
return (
f"task_type={task_type!r} is invalid for assignee {assigned_to!r}: "
f"QA reviews code PRs — task_type must be 'code'."
)
if agent.role is Role.DOCUMENTER and task_type != "documentation":
return (
f"task_type={task_type!r} is invalid for assignee {assigned_to!r}: "
f"Documenters write documentation — task_type must be "
f"'documentation'."
)
return None
@staticmethod
def _developer_task_type_error(
assigned_to: str, is_ux_dev: bool, task_type: str
) -> str | None:
"""Reject a developer's task_type. UX-cell developers additionally
accept 'design' (mockups/specs/design assets are their cell work)."""
allowed = {"code", "documentation", "research"}
owned = "code/documentation/research"
design_clause = "; design belongs to the UX cell."
if is_ux_dev:
allowed.add("design")
owned = "code/documentation/research/design"
design_clause = "."
if task_type in allowed:
return None
return (
f"task_type={task_type!r} is invalid for assignee "
f"{assigned_to!r}: Developers own {owned}. Coordination, "
f"planning, and administrative work belong to PMs{design_clause}"
)
@staticmethod
def _assignee_task_type_remediate(assigned_to: str) -> str:
"""Per-assignee-class remediation for an assignee-vs-task_type reject.
The reject `message` is already case-specific; this gives the PM the
right *next call* for the kind of assignee it just mis-typed instead
of a one-size-fits-all 'pass planning to a Cell PM' hint.
"""
from roboco.foundation.identity import AGENTS, Role, Team
if assigned_to in Choreographer._CELL_PM_SLUGS:
return (
"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."
)
agent = AGENTS.get(assigned_to)
if agent is not None and agent.role is Role.DEVELOPER:
if agent.team is Team.UX_UI:
return (
"UX developers take task_type in "
"{'code','documentation','research','design'}. Use "
"'design' for mockups/specs/design-asset work, 'code' "
"for implementation."
)
return (
"Developers take task_type in "
"{'code','documentation','research'}. Use 'code' for "
"implementation; planning/administrative work stays with you "
"(the PM), and design work is routed to the UX cell."
)
return (
"Match task_type to the assignee's role: 'code' for devs/QA, "
"'documentation' for documenters, 'planning' for Cell PMs."
)
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),
)
def _delegate_completeness_check(
self,
inputs: DelegateInputs,
parent: Any,
briefing: dict[str, Any],
role_str: str,
) -> Envelope | None:
"""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,
...)` against the payload. Returns:
- ``None`` when every TASK_AT_CREATE requirement is satisfied (the
verb continues into the static/lifecycle guards).
- ``Envelope.incomplete_input`` (with `with_introspection` applied)
when any field is missing — the agent gets a structured
field-by-field guide (spec §5.2.1 interrogation pattern).
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
instead of being silently substituted. This method is the
gateway-side defense; the service raise is defense-in-depth for
non-gateway callers.
"""
from types import SimpleNamespace
from roboco.foundation.policy import task_completeness as tc
payload: dict[str, Any] = {
"title": inputs.title,
"description": inputs.description,
"assigned_to": inputs.assigned_to,
"team": inputs.team,
"task_type": inputs.task_type,
"nature": inputs.nature,
"estimated_complexity": inputs.estimated_complexity,
"acceptance_criteria": inputs.acceptance_criteria,
}
# Auto-fill (spec §5.2.1 (a)) — never overwrites explicit values.
# team-from-slug is harmless when the caller already supplied team;
# priority-from-parent records `__priority_inherited=True` for
# post-create journal:note (best-effort observability).
payload = tc.fill_team_from_assignee(payload)
payload = tc.fill_priority_from_parent(payload, parent)
completeness_input = SimpleNamespace(
**{k: v for k, v in payload.items() if not k.startswith("__")}
)
result = tc.check(tc.TASK_AT_CREATE, completeness_input)
if result.passed:
return None
return Envelope.incomplete_input(
missing=result.missing,
field_hints=result.field_hints,
remediate=(
"re-issue delegate(...) with these fields filled: "
f"{', '.join(result.missing)}. Each field's required shape "
"is in `field_hints`."
),
context_briefing=briefing,
).with_introspection(task=parent, role=role_str)
async def _create_subtask_and_envelope(
self,
pm_agent_id: UUID,
parent: Any,
inputs: DelegateInputs,
briefing: dict[str, Any],
role_str: str,
) -> Envelope:
"""Run subtask creation and translate completeness raises into envelopes.
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
runaway. Converting to `Envelope.incomplete_input` here closes that
loop so the agent gets the same interrogation-pattern reply it would
have gotten from the upfront completeness check.
"""
from roboco.foundation.policy.task_completeness import TaskCompletenessError
from roboco.services.base import ValidationError
parent_task_id = parent.id
try:
new_task = await self._create_subtask_from_inputs(
pm_agent_id, parent_task_id, parent, inputs
)
except TaskCompletenessError as exc:
return await self._emit_rejection(
Envelope.incomplete_input(
missing=exc.missing,
field_hints=exc.field_hints,
remediate=(
"re-issue delegate(...) with corrected fields: "
f"{', '.join(exc.missing)}. Each field's required "
"shape is in `field_hints`."
),
context_briefing=briefing,
).with_introspection(task=parent, role=role_str),
agent_id=pm_agent_id,
task_id=parent_task_id,
verb="delegate",
)
except ValidationError as exc:
# A user-input error from task creation (e.g. delegating past
# MAX_TASK_DEPTH — ``_validate_parent_depth`` raises this with a
# remediation message telling the PM to create a sibling instead of
# a further nested subtask). Without this translator it escaped
# uncaught as a 500 ExceptionGroup; the agent never saw the fix.
# ``invalid_state`` carries the message as the remediation so the
# PM gets a clean, actionable rejection it can act on.
return await self._emit_rejection(
Envelope.invalid_state(
message=exc.message,
remediate=exc.message,
context_briefing=briefing,
).with_introspection(task=parent, role=role_str),
agent_id=pm_agent_id,
task_id=parent_task_id,
verb="delegate",
)
await self._wire_ux_frontend_dependency(new_task, parent)
hint = self._sizing_hint(inputs)
if hint is not None:
briefing = {**briefing, "sizing_hint": hint}
# Recompute coverage AFTER creation so the PM sees this child counted and
# exactly which parent criteria are still unclaimed before the next
# delegate / idle. The pre-create briefing built in delegate() is stale.
coverage = await self.task.parent_ac_coverage(parent_task_id)
if coverage:
briefing = {
**briefing,
"parent_ac_coverage": coverage,
"unclaimed_parent_acs": [c["id"] for c in coverage if not c["claimed"]],
}
return Envelope.ok(
status="created",
task_id=str(new_task.id),
next=spec_module._INTENT_VERBS["delegate"].next_hint(new_task),
context_briefing=briefing,
).with_introspection(task=new_task, role=role_str)
@staticmethod
def _team_value(value: Any) -> str:
return value.value if hasattr(value, "value") else str(value)
async def _wire_ux_frontend_dependency(self, new_task: Any, parent: Any) -> None:
"""Cross-cell sequencing: in a product fan-out the implementation cells
(FRONTEND and BACKEND) depend on the UX/UI cell task — UX design defines
the screens and API contracts both cells build against, so it is upstream
of implementation. Wires the dependency in either delegation order. A
dev/code subtask delegated under a cell task that is itself still waiting
on that dependency inherits it, so the developer is held until UX is done
instead of coding ahead of the design. Best-effort: never breaks delegate.
"""
if parent is None or getattr(parent, "product_id", None) is None:
return
from roboco.foundation.identity import Team
nt_team = self._team_value(new_task.team)
try:
# A subtask of a cell task that is waiting on another cell must wait
# too — propagate the parent's unmet cross-cell dependencies down.
await self.task.inherit_unmet_dependencies(new_task.id, parent.id)
if nt_team == Team.FRONTEND.value:
await self._depend_frontend_on_ux(new_task, parent.id)
elif nt_team == Team.BACKEND.value:
await self._depend_backend_on_ux(new_task, parent.id)
elif nt_team == Team.UX_UI.value:
await self._depend_pending_frontends_on_ux(new_task, parent.id)
await self._depend_pending_backends_on_ux(new_task, parent.id)
except Exception as exc:
logger.warning(
"cross-cell UX->implementation sequencing wiring failed",
error=str(exc),
parent_task_id=str(getattr(parent, "id", None)),
)
async def _depend_frontend_on_ux(self, fe_task: Any, parent_id: Any) -> None:
"""Make a new FRONTEND cell task wait on its non-terminal UX/UI sibling."""
from roboco.foundation.identity import Team
from roboco.models.base import TaskStatus
terminal = {TaskStatus.COMPLETED, TaskStatus.CANCELLED}
siblings = await self.task.get_subtasks(parent_id)
ux = next(
(
s
for s in siblings
if self._team_value(s.team) == Team.UX_UI.value
and s.id != fe_task.id
and s.status not in terminal
),
None,
)
if ux is not None:
await self.task.add_dependency(fe_task.id, ux.id)
await self.task.set_sequence(
fe_task.id, (getattr(ux, "sequence", 0) or 0) + 1
)
async def _depend_backend_on_ux(self, be_task: Any, parent_id: Any) -> None:
"""Make a new BACKEND cell task wait on its non-terminal UX/UI sibling."""
from roboco.foundation.identity import Team
from roboco.models.base import TaskStatus
terminal = {TaskStatus.COMPLETED, TaskStatus.CANCELLED}
siblings = await self.task.get_subtasks(parent_id)
ux = next(
(
s
for s in siblings
if self._team_value(s.team) == Team.UX_UI.value
and s.id != be_task.id
and s.status not in terminal
),
None,
)
if ux is not None:
await self.task.add_dependency(be_task.id, ux.id)
await self.task.set_sequence(
be_task.id, (getattr(ux, "sequence", 0) or 0) + 1
)
async def _depend_pending_frontends_on_ux(
self, ux_task: Any, parent_id: Any
) -> None:
"""Retro-wire not-yet-started FRONTEND siblings onto a new UX/UI task."""
from roboco.foundation.identity import Team
from roboco.models.base import TaskStatus
not_started = {TaskStatus.BACKLOG, TaskStatus.PENDING}
ux_sequence = (getattr(ux_task, "sequence", 0) or 0) + 1
siblings = await self.task.get_subtasks(parent_id)
for fe in siblings:
if (
self._team_value(fe.team) == Team.FRONTEND.value
and fe.id != ux_task.id
and fe.status in not_started
):
await self.task.add_dependency(fe.id, ux_task.id)
await self.task.set_sequence(fe.id, ux_sequence)
async def _depend_pending_backends_on_ux(
self, ux_task: Any, parent_id: Any
) -> None:
"""Retro-wire not-yet-started BACKEND siblings onto a new UX/UI task."""
from roboco.foundation.identity import Team
from roboco.models.base import TaskStatus
not_started = {TaskStatus.BACKLOG, TaskStatus.PENDING}
ux_sequence = (getattr(ux_task, "sequence", 0) or 0) + 1
siblings = await self.task.get_subtasks(parent_id)
for be in siblings:
if (
self._team_value(be.team) == Team.BACKEND.value
and be.id != ux_task.id
and be.status in not_started
):
await self.task.add_dependency(be.id, ux_task.id)
await self.task.set_sequence(be.id, ux_sequence)
async def _resolve_subtask_project(
self, parent: Any, inputs: DelegateInputs
) -> UUID:
"""Resolve the project a delegated subtask lands in.
Priority: explicit inputs.project_id -> the parent's ad-hoc cell_projects
map for this cell -> the parent's Product map for this cell -> the
parent's own project. Raises TaskCompletenessError only for a fan-out
parent (product or cell map, no own project) whose map has no mapping for
this cell — i.e. the subtask would have no repo to land in.
"""
if inputs.project_id is not None:
return inputs.project_id
# Ad-hoc per-cell map (a multi-cell MegaTask root-subtask): mirror the
# product.project_for lookup but read the map off the parent task itself.
parent_cell_map = getattr(parent, "cell_projects", None)
if parent_cell_map:
for mapping in parent_cell_map:
if mapping.team == inputs.team:
return UUID(str(mapping.project_id))
parent_product_id = getattr(parent, "product_id", None)
if self.product is not None and parent_product_id is not None:
mapped = await self.product.project_for(parent_product_id, inputs.team)
if mapped is not None:
return UUID(str(mapped))
if parent.project_id is not None:
return UUID(str(parent.project_id))
from roboco.foundation.policy.task_completeness import TaskCompletenessError
raise TaskCompletenessError(
missing=["project_id"],
field_hints={
"project_id": (
f"no project for team {inputs.team!r}: add a "
f"{inputs.team}->project mapping to the parent's cell map or "
"product, or pass an explicit project_id on delegate"
)
},
message=f"cannot resolve a project for the {inputs.team} subtask",
)
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.
By contract, callers (the `delegate` verb body) MUST run
`_delegate_completeness_check` first, so `inputs.acceptance_criteria`
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 the service-layer raise.
"""
from roboco.foundation.policy.task_completeness import TaskCompletenessError
from roboco.models.base import TaskNature
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])
# 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 can attach the
# field hints — never silently substitute.
if not inputs.acceptance_criteria:
raise TaskCompletenessError(
missing=["acceptance_criteria"],
field_hints={
"acceptance_criteria": (
"non-empty list[str]; each item describes a verifiable outcome"
)
},
message=(
"_create_subtask_from_inputs called with empty "
"acceptance_criteria — completeness check must run first"
),
)
if inputs.nature is None:
raise TaskCompletenessError(
missing=["nature"],
field_hints={
"nature": "one of: technical | non_technical",
},
message=(
"_create_subtask_from_inputs called with no nature — "
"completeness check must run first"
),
)
try:
nature_enum = TaskNature(inputs.nature)
except ValueError as exc:
raise TaskCompletenessError(
missing=["nature"],
field_hints={"nature": "one of: technical | non_technical"},
message=f"invalid nature {inputs.nature!r}: {exc}",
) from exc
resolved_project_id = await self._resolve_subtask_project(parent, inputs)
req = TaskCreateRequest(
title=inputs.title,
description=inputs.description,
acceptance_criteria=inputs.acceptance_criteria,
parent_ac_refs=inputs.covers_parent_criteria or [],
team=team_enum,
created_by=pm_agent_id,
project_id=resolved_project_id,
product_id=getattr(parent, "product_id", None),
parent_task_id=parent_task_id,
assigned_to=assignee_id,
task_type=type_enum,
nature=nature_enum,
estimated_complexity=complexity_enum,
# Dev-task collision surface (multi-level sequencing — edge kind 3)
# + explicit dependency override. Forwarded so create_subtask can
# persist them (Phase S2 runs SequencingService over the surfaced
# siblings and wires the collision DAG via add_dependency).
intends_to_touch=inputs.intends_to_touch,
adds_migration=inputs.adds_migration,
touches_shared=inputs.touches_shared,
dependency_ids=list(inputs.depends_on) if inputs.depends_on else [],
)
new_task = await self.task.create_subtask(req)
# Assign a distinct ordinal within the parent's siblings so the merge
# order is deterministic. Within-cell siblings were all left at the
# default sequence 0 — which is why two leaf PRs raced into the same
# cell branch and the second wedged. Each new sibling takes the next
# ordinal (the count of pre-existing siblings).
siblings = await self.task.get_subtasks(parent_task_id)
next_seq = len([s for s in siblings if s.id != new_task.id])
await self.task.set_sequence(new_task.id, next_seq)
# Wire the dev-task collision DAG (multi-level sequencing edge kind 3):
# run the deterministic analyzer over the parent's surfaced siblings
# and add_dependency each collision edge so a later dev task stays
# PENDING until the sibling it collides with completes (PR merged) —
# the cross-dev ordering the assignee-keyed spawn barrier could not
# guarantee (live 2026-06-27 out-of-order break). Incremental +
# idempotent: re-run after every delegate; add_dependency dedupes, and
# dev_task_collision_edges orders by (priority, sequence) so re-runs
# only add edges (never flip an existing pair's order into a cycle).
await self.task.wire_sibling_collision_dag(parent_task_id)
# Multi-level sequencing — edge kinds 2 + 4: the cell-task wave chain
# and the by-osmosis dev-task edge. Dispatch on the parent's team: a
# MAIN_PM parent is a root-subtask, so the new task is a CELL-TASK →
# wire kind 2 (it depends on the previous wave's cell-tasks); a cell
# team parent is a cell-task, so the new task is a DEV TASK → wire kind
# 4 (its first dev task carries the previous wave's merged tail). kind 3
# (wire_sibling_collision_dag, above) runs for both. Idempotent +
# best-effort: missing predecessors / cell-tasks contribute no edge.
from roboco.foundation.identity import Team
parent_team = getattr(parent, "team", None)
if parent_team == Team.MAIN_PM.value:
await self.task.wire_cell_task_wave_chain(new_task.id)
elif parent_team in (
Team.BACKEND.value,
Team.FRONTEND.value,
Team.UX_UI.value,
):
await self.task.wire_by_osmosis_edge(new_task.id)
# 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
# time; the gateway path created subtasks one-by-one and forgot
# this step. Idempotent on re-runs; no-op when no parent session.
if self.messaging is not None:
await self.messaging.propagate_sessions_to_subtask(
parent_task_id=parent_task_id,
subtask_id=new_task.id,
added_by=pm_agent_id,
)
return new_task
@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.
Spec gate runs first and enforces role membership (cell_pm only)
plus the composed ``submit_pm_review`` action's source-status
constraint (IN_PROGRESS only). After the gate accepts, the
verb-specific ``_submit_up_guard`` runs the rest of the
preflight checks the spec doesn't model: ownership, notes
length, journal:decision presence, subtasks-terminal, branch
present. Then ``VerbRunner.run_intent("submit_up", ...)``
dispatches the (submit_pm_review,) atomic chain plus the
(create_pr,) side effect inside a savepoint. After the runner
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, task=t, include_ac_coverage=True
)
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 = str(agent.role) if agent is not None else "cell_pm"
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=pm_agent_id,
task_id=task_id,
verb="submit_up",
)
spec_ctx = spec_module.Context(
actor_id=pm_agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
notes=notes,
)
soup = self._free_text_soup(checks=(("notes", notes, 10),))
decision = spec_module.can_invoke_intent(role, "submit_up", t, spec_ctx)
if env := self._soup_or_decision_env(soup, decision, briefing):
return await self._emit_rejection(
env.with_introspection(task=t, role=role_str),
agent_id=pm_agent_id,
task_id=task_id,
verb="submit_up",
)
# Verb-specific preflight: ownership + notes-length + journal:decision
# + subtasks-terminal + branch-present. None of these are modelled by
# the spec yet — keep them in the verb body.
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_str)
return await self._emit_rejection(
guard,
agent_id=pm_agent_id,
task_id=task_id,
verb="submit_up",
)
outcome = await self._submit_up_run_intent(
t, agent, spec_ctx, briefing, role_str
)
if isinstance(outcome, Envelope):
return await self._emit_rejection(
outcome,
agent_id=pm_agent_id,
task_id=task_id,
verb="submit_up",
)
t = outcome
# 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).
# `_maybe_advance_parent_to_pm_review` already keeps it on the cell PM.
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS["submit_up"].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _submit_up_run_intent(
self,
t: Any,
agent: Any,
spec_ctx: spec_module.Context,
briefing: dict[str, Any],
role_str: str,
) -> Any:
"""Dispatch the submit_up composition through VerbRunner.
Returns the post-composition task on success, or an
``invalid_state`` Envelope when the runner raises or the
underlying ``submit_pm_review`` returns ``None``.
"""
runner = self._verb_runner()
try:
after = await runner.run_intent("submit_up", t, agent, spec_ctx)
except Exception as exc:
return 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)
if after is None:
return Envelope.invalid_state(
message="could not transition to awaiting_pm_review",
remediate="check task state — must be in_progress with PR ready",
context_briefing=briefing,
)
return after
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, notes)
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 shared by submit_up + submit_root.
Both callers run the spec gate (``can_invoke_intent``) first, which
already restricts submit_up→cell_pm and submit_root→main_pm, so this
guard must accept either PM role — a hardcoded cell_pm-only check would
reject the Main PM's submit_root and deadlock root closure (submit_root
and complete then point at each other). The check stays as a
defense-in-depth reject of any non-PM actor that reaches here.
"""
from roboco.config import settings as roboco_settings
agent = await self.task.agent_for(pm_agent_id)
if agent is None or agent.role not in ("cell_pm", "main_pm"):
return Envelope.not_authorized(
message="submit_up / submit_root are reserved for PM roles",
remediate=(
"only a cell PM (submit_up) or main PM (submit_root)"
" may bubble work up"
),
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, notes: str
) -> Envelope | None:
"""Journal + subtask-closure + branch guards for submit_up.
Tracing gates (journal:decision, journal:reflect, notes>=min,
subtasks_terminal) are evaluated by ``_check_submit_up_gates``
which consumes ``VERB_REQUIREMENTS["submit_up"]``. The
``_subtasks_not_terminal_envelope`` call is kept as a fallback
because its remediation enumerates the non-terminal subtask ids
— strictly richer than the foundation hint.
"""
# Write-then-gate: the bubble-up rationale (notes, already validated
# >= min by the ownership guard) becomes the journal:decision.
await self._ensure_pm_decision(pm_agent_id, task_id, notes)
if env := await self._check_submit_up_gates(pm_agent_id, task_id, notes):
return env
if env := await self._subtasks_not_terminal_envelope(
pm_agent_id, task_id, context_phrase="bubbling up"
):
return env
if env := await self._parent_acs_covered_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 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).
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
task below other active rows; the pre-assigned pending check now
wins unconditionally.
"""
# Pre-assigned pending tasks take priority over everything else.
pre_assigned = await self.task.list_pending_for_agent(pm_agent_id)
if pre_assigned:
t = pre_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, task=t),
)
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, task=t),
)
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, task=t),
)
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, task=t),
)
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, task=t),
)
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, task=t),
)
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, reason: str, *, restore: bool = True
) -> Envelope:
"""PM unblocks task; ``reason`` documents why the block is cleared and
is recorded as the journal:decision the gate requires. 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",
)
# A dependency block must not be cleared by hand. It auto-clears via
# _unblock_dependents the moment its last dependency reaches a terminal
# state; forcing it now would let the dependent proceed without the
# upstream's work (e.g. a frontend task built before its UX design lands).
dep_ids = list(t.dependency_ids or [])
unmet = await self.task.unmet_dependency_ids(dep_ids) if dep_ids else []
if unmet:
return await self._emit_rejection(
Envelope.invalid_state(
message=(
f"task {task_id} still depends on {len(unmet)} "
"unfinished task(s); a dependency block clears on its "
"own once the upstream work completes"
),
remediate=(
"don't force this — let the dependency finish; the task "
"auto-unblocks the moment its last dependency reaches "
"completed/cancelled"
),
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",
)
# Write-then-gate: the PM's unblock reason is recorded as the
# journal:decision the gate below requires, so a PM that didn't
# pre-call note(scope='decision') doesn't stall in a tracing_gap
# respawn loop.
await self._ensure_pm_decision(pm_agent_id, task_id, reason)
if env := await self._check_pm_decision_required(
"unblock", pm_agent_id, task_id, t
):
return await self._emit_rejection(
env.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 _own_review_hint(self, pm_agent_id: UUID, exclude_task_id: UUID) -> str:
"""Remediate suffix naming the PM's OWN task ready to complete.
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.
Best-effort: never raises into the rejection path.
"""
try:
owned = await self.task.list_by_assignee(pm_agent_id)
except Exception:
return ""
ready = [
str(o.id)
for o in owned
if str(o.status) == "awaiting_pm_review" and o.id != exclude_task_id
]
if not ready:
return ""
tid = ready[0]
return (
f" You OWN task {tid} which is awaiting_pm_review and ready to "
f"finish — call complete(task_id='{tid}', notes='...') on THAT "
"task, not this one."
)
async def _cell_pm_complete_guard(
self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str
) -> Envelope | None:
"""Return a rejection Envelope if pre-merge guards fail; else None.
Tracing gates (journal:decision, journal:reflect, notes>=min) are
evaluated by ``_check_complete_gates`` which consumes
``VERB_REQUIREMENTS["complete"]``. The
``_subtasks_not_terminal_envelope`` call is kept inline because
its remediation enumerates the non-terminal subtask ids — strictly
richer than the foundation hint.
"""
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."
+ await self._own_review_hint(pm_agent_id, task_id)
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if str(t.status) != "awaiting_pm_review":
# An in_progress cell task must enter the in-path gate first:
# submit_up opens the cell→root PR and moves it to
# awaiting_pr_review, then the cell reviewer pr_passes it to
# awaiting_pm_review where complete merges. Name the verb so the
# cell PM isn't left guessing (the parallel of the main PM's
# submit_root steer).
gate_hint = (
"open the cell→root PR and enter review first:"
" submit_up(task_id, notes='...'). After the cell reviewer"
" pr_passes it, complete merges the cell→root PR."
if str(t.status) == "in_progress"
else "this task is not ready for completion."
)
return Envelope.invalid_state(
message=(
f"task {task_id} is in {t.status}, expected awaiting_pm_review"
),
remediate=(
gate_hint + await self._own_review_hint(pm_agent_id, task_id)
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
# Write-then-gate: the PM's merge rationale (notes) becomes the
# journal:decision the gate requires — no separate note() call needed.
await self._ensure_pm_decision(pm_agent_id, task_id, notes)
if env := await self._check_complete_gates(pm_agent_id, task_id, notes):
return env
if env := (
await self._subtasks_not_terminal_envelope(
pm_agent_id, task_id, context_phrase="completing parent"
)
or await self._parent_acs_covered_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, notes)
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",
)
# 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>/…
target = await resolve_parent_branch(t, self.task)
try:
merge_result = await self.git.pr_merge(
t.pr_number,
target=target,
project_id=cast("UUID", t.project_id),
actor_agent_id=pm_agent_id,
)
except MergeConflictError as exc:
# A sibling landed overlapping work first, so this PR can't merge.
# Resolve it (rebase / close-superseded / escalate) instead of
# letting the failure re-block the task and respawn the PM forever.
return await self._resolve_merge_conflict_on_complete(
pm_agent_id, task_id, t, target, notes, exc
)
return await self._finalize_cell_complete(
pm_agent_id, task_id, t, notes, merge_result.get("merge_commit_sha")
)
async def _finalize_cell_complete(
self,
pm_agent_id: UUID,
task_id: UUID,
t: Any,
notes: str,
merge_commit: str | None,
) -> Envelope:
"""Mark the leaf completed and propagate the completion to its parent."""
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_commit,
)
# `complete()` returns None when its prerequisites fail — most
# notably the PR-merged guard (`work_session.pr_status == "merged"`),
# which is exactly what breaks when the merge recorded against the
# WRONG task's work session (the cross-repo pr_number collision that
# `pr_merge`'s project_id scoping now prevents). Fail closed into a
# clean invalid_state envelope instead of dereferencing None and
# 500-ing, which left the be-pm thrashing escalate<->blocked.
if t is None:
return Envelope.invalid_state(
message=(
"task could not be completed: its PR is not recorded as "
"merged against this task's work session, or it has "
"incomplete subtasks / an invalid completion status"
),
remediate=(
"re-issue pr_merge for this task's PR, then complete; if "
"the PR is genuinely merged on GitHub but the work session "
"still shows open, escalate to the CEO to reconcile the "
"session and complete manually"
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
# 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=spec_module._INTENT_VERBS["complete"].next_hint(t),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role="cell_pm")
async def _resolve_merge_conflict_on_complete(
self,
pm_agent_id: UUID,
task_id: UUID,
t: Any,
target: str,
notes: str,
exc: MergeConflictError,
) -> Envelope:
"""Resolve a leaf PR that couldn't merge because a sibling landed first.
Rebase the branch onto the current base and act on the outcome rather
than failing (which re-blocks the task and respawns the PM forever):
- ``rebased`` — the branch now integrates cleanly; retry the merge.
- ``superseded`` — every change is already in the base via the sibling;
close the dead PR and complete the task without a redundant merge.
- ``conflicts`` / ``unknown`` — a human must resolve; escalate to the
CEO (``awaiting_ceo_approval``) so the task leaves the agent loop.
"""
rebase = await self.git.rebase_pr_for_task(
t.pr_number,
project_id=cast("UUID", t.project_id),
actor_agent_id=pm_agent_id,
)
status = rebase.get("status")
if status == "rebased":
merge_result = await self.git.pr_merge(
t.pr_number,
target=target,
project_id=cast("UUID", t.project_id),
actor_agent_id=pm_agent_id,
)
return await self._finalize_cell_complete(
pm_agent_id, task_id, t, notes, merge_result.get("merge_commit_sha")
)
if status == "superseded":
await self.git.close_pull_request(
t.pr_number,
comment=(
"Closed as superseded: every change on this branch is "
"already present in the base via a sibling PR that merged "
"first. Completing the task without a redundant merge."
),
actor_agent_id=pm_agent_id,
project_id=cast("UUID", t.project_id),
)
return await self._finalize_cell_complete(
pm_agent_id, task_id, t, notes, None
)
return await self._escalate_merge_conflict_to_ceo(
pm_agent_id, task_id, t, rebase, exc
)
async def _escalate_merge_conflict_to_ceo(
self,
pm_agent_id: UUID,
task_id: UUID,
t: Any,
rebase: dict[str, Any],
exc: MergeConflictError,
) -> Envelope:
"""Route an unresolvable PR conflict to the CEO; never loop on it.
Moves the task to ``awaiting_ceo_approval`` (admin override — the leaf
has no in-band edge there) so it leaves agent dispatch, and best-effort
alerts the CEO with the conflicting files.
"""
from roboco.models.base import TaskStatus
files = rebase.get("files") or []
logger.info(
"merge conflict escalated to CEO",
task_id=str(task_id),
conflicting_files=len(files),
rebase_status=rebase.get("status"),
merge_error=str(exc),
)
await self.task.admin_set_status(
task_id,
TaskStatus.AWAITING_CEO_APPROVAL,
actor_id=pm_agent_id,
actor_role="cell_pm",
)
await self._notify_ceo_merge_conflict(task_id, files)
t = await self.task.get(task_id)
detail = f" ({len(files)} conflicting file(s))" if files else ""
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"the PR has merge conflicts that could not be resolved "
f"automatically{detail}; escalated to the CEO. A developer can "
"rebase the branch, or the CEO can close the PR if superseded."
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role="cell_pm")
async def _notify_ceo_merge_conflict(self, task_id: UUID, files: list[str]) -> None:
"""Best-effort CEO alert for a wedged merge conflict; never raises."""
from roboco.services.notification import NotificationService
try:
await NotificationService().send_stuck_agent_notification(
task_id=str(task_id),
agent_slug="cell_pm",
task_status="awaiting_ceo_approval",
to_agent="ceo",
)
except Exception:
logger.warning(
"failed to send CEO merge-conflict notification",
task_id=str(task_id),
conflicting_files=len(files),
)
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)
def _submit_root_preflight(
self, t: Any, role_str: str, briefing: dict[str, Any]
) -> Envelope | None:
"""Reject a submit_root that can never assemble a root→master PR.
Two early refusals collapse here to keep ``submit_root`` within the
return-count budget. A MegaTask umbrella assembles no PR of its own — it
spans many projects (no single master) and each root-subtask opens its
own PR — so it must never enter the in-path review gate; the Main PM
completes the umbrella directly once every root-subtask is terminal. And
an actor whose role is not in the lifecycle spec cannot drive the verb.
Returns the rejection Envelope (caller adds introspection + emits) or
None when neither refusal applies.
"""
if is_batch_umbrella(batch_id=t.batch_id, parent_task_id=t.parent_task_id):
return Envelope.invalid_state(
message="a MegaTask umbrella assembles no PR of its own",
remediate=(
"the umbrella spans many projects — each root-subtask opens"
" its own PR and is reviewed on its own diff. Do not"
" submit_root the umbrella; once every root-subtask is"
" terminal, call complete(task_id, notes='...') to escalate"
" the MegaTask to the CEO."
),
context_briefing=briefing,
)
try:
spec_module.Role(role_str)
except ValueError:
return Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
)
return None
async def _submit_root_unchanged_pr_guard(
self, t: Any, briefing: dict[str, Any]
) -> Envelope | None:
"""Refuse to re-submit a root PR whose diff is unchanged since the last fail.
The hard loop-stopper for the 2026-06-27 ``pr_fail`` re-submit loop. A
prior ``pr_fail`` stamped the assembled PR's head SHA into
``notes_structured.pr_review.head_sha`` (see ``_capture_pr_head_sha`` /
``_record_gate_verdict``). On the next ``submit_root`` this looks up the
PR's CURRENT head SHA and compares. Equal ⇒ no new cell work landed on
the root branch since the fail ⇒ the diff the reviewer would see is
byte-identical to the one just rejected ⇒ refuse, so no model can loop
itself back into ``awaiting_pr_review``. Different ⇒ the branch advanced
⇒ allow. Returns ``None`` (proceed) on every ambiguous case so the gate
FAILS OPEN: no prior ``pr_fail`` verdict, no recorded ``head_sha`` (e.g.
a verdict written before this field existed), no ``pr_number``, no
resolvable project, or a git/closed-PR lookup that returns ``None``.
Only the exact-unchanged case is hard-blocked; the rest fall through to
the reviewer, who can still ``pr_fail`` if the diff is bad.
"""
pr_review = (getattr(t, "notes_structured", None) or {}).get("pr_review") or {}
if pr_review.get("verdict") != "failed":
return None
recorded = pr_review.get("head_sha")
if not recorded:
return None
current = await self._current_root_pr_head_sha(t)
if current is None or current != recorded:
return None
return Envelope.invalid_state(
message=(
"the assembled root PR is unchanged since the last pr_fail"
f" (head {current[:7]}). No new cell work has landed on the"
" root branch, so re-submitting would re-open the exact diff"
" the reviewer just rejected and loop straight back to"
" awaiting_pr_review."
),
remediate=(
"re-delegate the fixes to the owning cell PM(s) via"
" delegate(...) and wait for the cell subtasks to complete"
" and the root branch to be re-assembled. Do NOT call"
" submit_root again until new cell work has advanced the"
" root branch HEAD."
),
context_briefing=briefing,
)
async def _current_root_pr_head_sha(self, t: Any) -> str | None:
"""Best-effort current head SHA of the task's assembled PR (fail-open).
The lookup the unchanged-PR gate compares against. Returns ``None`` on
every ambiguous case (no ``pr_number``, no resolvable project slug, a
git error, or a closed/missing PR) so the gate fails open rather than
wedging the PM — only the exact-unchanged case is hard-blocked.
"""
pr_number = getattr(t, "pr_number", None)
if not pr_number:
return None
try:
# ``_LegacyChoreographer`` doesn't inherit ``ChoreographerHelpers``,
# so cast to the typed view the mixins use to reach the shared
# ``_project_slug_for`` resolver (it delegates to the module-level
# ``resolve_task_project_slug``). Same resolver the pr_fail capture
# path uses, so one stub controls both gate paths.
slug = await cast("ChoreographerHelpers", self)._project_slug_for(t)
if not slug:
return None
sha = await self.git.get_pr_head_sha(slug, int(pr_number))
return sha if isinstance(sha, str) else None
except Exception:
return None
async def submit_root(
self, main_pm_agent_id: UUID, task_id: UUID, notes: str
) -> Envelope:
"""Main PM opens the root→master PR and enters the in-path review gate.
The root analogue of the cell PM's submit_up: the spec gate (main_pm +
in_progress) runs first, then the same preflight guard (ownership, notes
length, journal:decision, subtasks-terminal, branch present). Then
``VerbRunner.run_intent("submit_root", ...)`` opens the root→master PR
(the ``create_root_pr`` pre-side-effect) and transitions in_progress →
awaiting_pr_review. The main reviewer then reviews the assembled
root→master diff; after pr_pass, ``complete`` escalates to the CEO.
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(
main_pm_agent_id, task_id, task=t, include_ac_coverage=True
)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=main_pm_agent_id,
task_id=task_id,
verb="submit_root",
)
agent = await self.task.agent_for(main_pm_agent_id)
role_str = str(agent.role) if agent is not None else "main_pm"
# Hard-reject an umbrella (no PR of its own) or an unknown role before the
# spec gate; the preflight collapses both into one Envelope-or-None.
if env := self._submit_root_preflight(t, role_str, briefing):
return await self._emit_rejection(
env.with_introspection(task=t, role=role_str),
agent_id=main_pm_agent_id,
task_id=task_id,
verb="submit_root",
)
role = spec_module.Role(role_str)
spec_ctx = spec_module.Context(
actor_id=main_pm_agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
notes=notes,
)
soup = self._free_text_soup(checks=(("notes", notes, 10),))
decision = spec_module.can_invoke_intent(role, "submit_root", t, spec_ctx)
if env := self._soup_or_decision_env(soup, decision, briefing):
return await self._emit_rejection(
env.with_introspection(task=t, role=role_str),
agent_id=main_pm_agent_id,
task_id=task_id,
verb="submit_root",
)
# Same preflight as submit_up — ownership + notes-length +
# journal:decision + subtasks-terminal + branch-present. submit_root's
# tracing requirements mirror submit_up's, so the shared guard applies.
guard = await self._submit_up_guard(main_pm_agent_id, task_id, t, notes)
# Hard unchanged-PR gate (the 2026-06-27 pr_fail re-submit loop-stopper).
# A hint (the pr_fail a2a / next-hint steer "do NOT re-submit") is ignored
# by a weak coordinator (minimax-m3 re-submitted PR #139 byte-identical),
# so this refuses the re-submit STRUCTURALLY: if the last pr_fail stamped
# the assembled PR's head SHA and the current PR head SHA is the same, no
# new cell work has landed on the root branch since the fail — re-running
# submit_root would re-open / re-push the exact diff the reviewer just
# rejected, looping straight back to awaiting_pr_review → pr_fail. Force
# the PM to re-delegate the fixes and wait for re-assembly instead.
# Fail-open on ANY ambiguity (no prior fail, no recorded sha, no
# resolvable project, git error, closed/missing PR) — only the
# exact-unchanged case is hard-blocked; everything else proceeds and
# relies on the reviewer to re-fail if the diff is still bad.
if guard is None:
guard = await self._submit_root_unchanged_pr_guard(t, briefing)
if guard is not None:
guard.with_introspection(task=t, role=role_str)
return await self._emit_rejection(
guard,
agent_id=main_pm_agent_id,
task_id=task_id,
verb="submit_root",
)
runner = self._verb_runner()
try:
t = await runner.run_intent("submit_root", t, agent, spec_ctx)
except Exception as exc:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"verb runner failed: {exc}",
remediate=(
"ensure all subtasks are terminal + the branch exists,"
" then retry submit_root"
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=main_pm_agent_id,
task_id=task_id,
verb="submit_root",
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS["submit_root"].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _main_pm_complete_guard(
self, main_pm_agent_id: UUID, root_task_id: UUID, t: Any, notes: str
) -> Envelope | None:
"""Return a rejection Envelope if pre-escalation guards fail; else None.
Tracing gates (journal:decision, journal:reflect, notes>=min) are
evaluated by ``_check_complete_gates`` which consumes
``VERB_REQUIREMENTS["complete"]``. The
``_subtasks_not_terminal_envelope`` call is kept inline because
its remediation enumerates the non-terminal subtask ids — strictly
richer than the foundation hint.
"""
if t.assigned_to != main_pm_agent_id:
return Envelope.not_authorized(
message="not assigned to you",
remediate=(
"wait for assignment or claim."
+ await self._own_review_hint(main_pm_agent_id, root_task_id)
),
context_briefing=await self._briefing_for(
main_pm_agent_id, root_task_id
),
)
# A branch-bearing root must pass the in-path PR-review gate first:
# submit_root opens the root→master PR and moves it in_progress →
# awaiting_pr_review, then the main reviewer pr_passes it to
# awaiting_pm_review. So complete accepts only awaiting_pm_review for a
# branch-bearing root (a Main-PM root-subtask is planning-typed, never
# code, but it still assembles the cells' merged work into a real PR).
# A branchless coordination root (product fan-out, no repo/PR) skips the
# gate, so it may still be walked from in_progress here. The split is
# branch-keyed, not task_type-keyed.
root_is_branchless = not bool(t.branch_name)
allowed_statuses = (
("awaiting_pm_review", "in_progress")
if root_is_branchless
else ("awaiting_pm_review",)
)
if str(t.status) not in allowed_statuses:
gate_hint = (
"this task is not ready for main-PM completion."
if root_is_branchless
else (
"open the root→master PR and enter review first:"
" submit_root(task_id, notes='...'). After the main reviewer"
" pr_passes it, complete escalates to the CEO."
)
)
return Envelope.invalid_state(
message=(
f"task {root_task_id} is in {t.status}, expected"
f" {' or '.join(allowed_statuses)}"
),
remediate=(
gate_hint
+ await self._own_review_hint(main_pm_agent_id, root_task_id)
),
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."
+ await self._own_review_hint(main_pm_agent_id, root_task_id)
),
context_briefing=await self._briefing_for(
main_pm_agent_id, root_task_id
),
)
# Write-then-gate: the Main PM's root-close rationale (notes) becomes
# the journal:decision the gate requires.
await self._ensure_pm_decision(main_pm_agent_id, root_task_id, notes)
if env := await self._check_complete_gates(
main_pm_agent_id, root_task_id, notes
):
return env
if env := (
await self._subtasks_not_terminal_envelope(
main_pm_agent_id, root_task_id, context_phrase="escalating to CEO"
)
or await self._parent_acs_covered_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, notes
)
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",
)
# A branchless coordination root (product fan-out, no repo/PR) skips the
# in-path gate: it never went through submit_root / pr_pass, so walk it
# in_progress → awaiting_pm_review here, exactly as before. A code root
# reaches this point already in awaiting_pm_review — submit_root opened
# the root→master PR and the main reviewer pr_passed it — so no PR
# creation or status walk is needed.
if not bool(t.branch_name):
refreshed = await self.task.get(root_task_id)
if refreshed is not None and str(refreshed.status) == "in_progress":
advanced = await self.task.submit_pm_review(
main_pm_agent_id, root_task_id, notes
)
if advanced is None:
return await self._emit_rejection(
Envelope.invalid_state(
message=(
"could not move coordination root to"
" awaiting_pm_review for CEO escalation"
),
remediate=(
"ensure all subtasks are terminal, then retry complete"
),
context_briefing=await self._briefing_for(
main_pm_agent_id, root_task_id
),
).with_introspection(task=refreshed, role="main_pm"),
agent_id=main_pm_agent_id,
task_id=root_task_id,
verb="main_pm_complete",
)
# 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.
t = await self.task.escalate_to_ceo(
task_id=root_task_id, agent_role="main_pm", notes=notes
)
# Defense-in-depth: escalate_to_ceo returns None when it refuses (e.g. a
# transition guard rejects). Surface that as a clean rejection instead of
# dereferencing None below.
if t is None:
return await self._emit_rejection(
Envelope.invalid_state(
message="escalate_to_ceo did not apply",
remediate=(
"ensure the root is in awaiting_pm_review with all"
" subtasks terminal, then retry complete"
),
context_briefing=await self._briefing_for(
main_pm_agent_id, root_task_id
),
),
agent_id=main_pm_agent_id,
task_id=root_task_id,
verb="main_pm_complete",
)
# 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=spec_module._INTENT_VERBS["complete"].next_hint(t),
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.
Spec gate runs first (role membership + composed ``complete``
action's source-status constraint, AWAITING_PM_REVIEW only).
Both rejections (role not in spec._PM_ROLES, status not awaiting_pm_review)
flow through ``spec.can_invoke_intent`` and surface as the
spec-supplied rejection_kind. After the gate accepts, the
verb body owns dispatch — ``complete`` has two divergent
runtime paths (Cell PM merges leaf into parent branch; Main PM
opens master PR + escalates to CEO) that can't be expressed as
a single VerbRunner composition. Each lower-level method keeps
its own pre-flight guards (``_cell_pm_complete_guard`` /
``_main_pm_complete_guard``) — those model journal:decision
presence, subtasks-terminal, and PR-mergeability checks the
spec doesn't model yet.
"""
t = await self.task.get(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"),
agent_id=agent_id,
task_id=task_id,
verb="complete",
)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else "developer"
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="complete",
)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
)
if soup := await self._guard_free_text(
checks=(("notes", notes, 10),),
task=t,
agent_id=agent_id,
role_str=role_str,
verb="complete",
):
return soup
decision = spec_module.can_invoke_intent(role, "complete", t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=agent_id,
task_id=task_id,
verb="complete",
)
# Spec gate passed — role is CELL_PM or MAIN_PM, status is
# AWAITING_PM_REVIEW. Verb body owns dispatch from here.
if role_str == "cell_pm":
return await self.cell_pm_complete(agent_id, task_id, notes)
# role_str == "main_pm" — spec._PM_ROLES has only these two members.
return await self.main_pm_complete(agent_id, task_id, notes)
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.
Spec gate runs first and enforces role membership (cell_pm or
main_pm only — escalate_up's IntentSpec has ``composes=()``, so
the spec does not enforce a source-status constraint). After the
gate accepts, the verb-specific preflight guards stay:
``journal:decision`` presence (the spec doesn't model journal
side effects) and ``escalation_target`` configuration on the
actor's agent record (also out of the spec's scope). Then the
verb body owns dispatch via ``task.escalate(...)`` because
``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, task=t)
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 = str(me.role) if me is not None else "cell_pm"
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=pm_agent_id,
task_id=task_id,
verb="escalate_up",
)
spec_ctx = spec_module.Context(
actor_id=pm_agent_id,
actor_slug=getattr(me, "slug", None) if me is not None else None,
original_developer_slug=_extract_original_developer(t),
notes=reason,
)
soup = self._free_text_soup(checks=(("reason", reason, 10),))
decision = spec_module.can_invoke_intent(role, "escalate_up", t, spec_ctx)
if env := self._soup_or_decision_env(soup, decision, briefing):
return await self._emit_rejection(
env.with_introspection(task=t, role=role_str),
agent_id=pm_agent_id,
task_id=task_id,
verb="escalate_up",
)
# Write-then-gate: the escalation reason becomes the journal:decision
# the preflight gate requires.
await self._ensure_pm_decision(pm_agent_id, task_id, reason)
preflight = await self._escalate_up_preflight(
pm_agent_id, t, me, briefing, role_str
)
if preflight is not None:
return await self._emit_rejection(
preflight,
agent_id=pm_agent_id,
task_id=task_id,
verb="escalate_up",
)
# Verb body owns dispatch — escalate_up's IntentSpec has
# composes=(), so VerbRunner has no atomic action to run.
target_slug = me.escalation_target if me else None
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=briefing,
),
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=briefing,
).with_introspection(task=t, role=role_str)
async def _escalate_up_preflight(
self,
pm_agent_id: UUID,
t: Any,
me: Any,
briefing: dict[str, Any],
role_str: str,
) -> Envelope | None:
"""Verb-specific preflight gates for escalate_up.
Returns a rejection envelope when the gate fires; ``None`` to
proceed. The spec doesn't model journal side effects or agent
metadata (escalation_target slug), so these gates stay in the
verb body. The journal-decision check is delegated to
``_check_pm_decision_required`` which consumes
``VERB_REQUIREMENTS["escalate_up"]``.
"""
if env := await self._check_pm_decision_required(
"escalate_up", pm_agent_id, t.id, t
):
return env.with_introspection(task=t, role=role_str)
target_slug = me.escalation_target if me else None
if not target_slug:
return Envelope.invalid_state(
message="no escalation target configured for your role",
remediate="check agents_config.py ESCALATION_CHAIN for your slug",
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
return None
# --- 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:
"""Board/Main PM escalates task_id to CEO with reason.
Spec gate runs first and enforces role membership (main_pm,
product_owner, head_marketing) plus the composed
``escalate_to_ceo`` action's source-status constraint
(AWAITING_PM_REVIEW only). After the gate accepts, the
verb-specific preflight guard stays: ``journal:decision``
presence (the spec doesn't model journal side effects). Then
``VerbRunner.run_intent("escalate_to_ceo", ...)`` dispatches the
(escalate_to_ceo,) atomic chain wrapped in a savepoint. After
the runner returns, the task is reassigned to None — the CEO
acts via the UI, not as a spawnable agent (mirrors
main_pm_complete).
"""
t = await self.task.get(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"),
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
me = await self.task.agent_for(agent_id)
role_str = str(me.role) if me is not None else "main_pm"
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(me, "slug", None) if me is not None else None,
original_developer_slug=_extract_original_developer(t),
notes=reason,
)
soup = self._free_text_soup(checks=(("reason", reason, 10),))
decision = spec_module.can_invoke_intent(role, "escalate_to_ceo", t, spec_ctx)
if env := self._soup_or_decision_env(soup, decision, briefing):
return await self._emit_rejection(
env.with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
# Verb-specific preflight: journal:decision presence (out of spec scope).
# Delegates to _check_pm_decision_required which consumes
# VERB_REQUIREMENTS["escalate_to_ceo"].
# Verb-body preflight: journal:decision presence, then the parent-AC
# backstop (a root may not escalate to the CEO with parent ACs that no
# completed subtask covered — inert until coverage is declared).
# Write-then-gate: the escalation reason becomes the journal:decision
# the gate below requires.
await self._ensure_pm_decision(agent_id, task_id, reason)
env = await self._check_pm_decision_required(
"escalate_to_ceo", agent_id, task_id, t
) or await self._parent_acs_covered_envelope(
agent_id, task_id, context_phrase="escalating to CEO"
)
if env:
return await self._emit_rejection(
env.with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="escalate_to_ceo",
)
runner = self._verb_runner()
runner_error: str | None = None
try:
updated = await runner.run_intent("escalate_to_ceo", t, me, spec_ctx)
except Exception as exc:
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,
)
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(
status=str(t.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS["escalate_to_ceo"].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
# board_triage + auditor_triage moved to ``board.py`` as the first
# 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.