mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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).
6436 lines
282 KiB
Python
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.
|