mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(gateway): claim-scoped context briefing — heavy sections only on context-acquisition verbs
* feat(gateway): cap unbounded LLM-facing payloads — embedded diffs, notification bodies, handoff journal content, north star
* feat(mcp): role-scope the optimal server's tool groups; index management becomes dev/test-only
* feat(mcp): cap per-result content on kb/error/learning search, mentor sources, rag citations
* refactor(gateway): extract heavy-briefing sections + clip helper to keep xenon ranks
* feat(orchestrator): cross-tick cooldown for notification-triggered spawns
* feat(usage,orchestrator): scope spawn-waste to anthropic sessions; cap agent Bash output via settings env
* docs: claim-scoped briefing, payload caps, optimal role-scoping, notification-spawn cooldown
* test(mcp): type the mixed-item cap fixture explicitly
* fix(orchestrator): lazy-init the notification-spawn cooldown store
* fix(lifecycle): admin-override claim reconciliation + PM request_changes verb (S6 postmortem B3+B4)
B3 — admin_set_status now reconciles claim ownership when leaving BLOCKED:
review/queue targets clear claimed_by/claimed_at/active_claimant_id and
consume the pre-block snapshot (a stale escalation claim was stranding the
next claimant: give_me_work handed the task out while note() bounced
not_authorized — the live b8fe0494 wedge). The pending/in_progress restore
path also syncs active_claimant_id, and a REST PATCH unassign releases the
claim with it.
B4 — new PM verb request_changes: awaiting_pm_review -> needs_revision with
concrete issues. The PM previously had no reject at merge review (only
complete/escalate), so an AC/scope violation looped i_am_blocked->escalate
4x live. Full vertical: lifecycle transition + ActionSpec + IntentSpec,
TaskService.request_changes (routes like a QA fail — original dev for a
leaf, revision PM for assembled; issues appended to dev_notes), verb-runner
compose, choreographer verb (spec gate + non-empty issues + soup check +
a2a delivery of the reject reason), HTTP routes on both PM flows, MCP tool,
journal:decision tracing, PM prompts, regenerated lifecycle artifacts.
* fix(panel): stop scorecard fetches for fallback-roster placeholder ids
useAgents() serves the static AGENT_ROSTER (ids "1".."22") while agent
definitions load; the Scorecards tab fetched a member scorecard per row
immediately, firing 22 guaranteed-422 requests per refetch cycle. Through
the browser's per-origin connection limit those queued every metrics-page
query behind them (~10s of skeletons on every tab). Gate the fetch on a
real member id (agent UUID or the "ceo" alias).
* Upgraded uv.lock
* fix(sequencing): declared deps become real edges + full loop-breaker coverage + assembled-branch freshness (S6 postmortem B1/B2/B6 + breaker)
B1a — code delegations REQUIRE a collision surface: new TASK_AT_DELEGATE
completeness spec (conditional FieldRequirement, when=('task_type','code'))
enforced at the gateway delegate gate. A no-surface code sibling is
'parallel to everything' by analyzer design, which is how two devs ran the
CEO's explicitly-ordered work out of order (f3e1afc5: seq#1 started before
seq#0, zero dependency edges). PM prompts updated; REST/manual creation
(TASK_AT_CREATE) unchanged.
B1b — the CEO's declared 'Depends on' lists become real edges: DraftSurface
gains declared_depends_on; SequencingService.analyze unions declared edges
(validated: self/out-of-range rejected) with the derived collision rules,
cycle-checked by the existing toposort. confirm_live_batch/preview_batch
map each draft's depends_on through (string indices coerced); intake tool
doc + prompter role prompt instruct verbatim copying. The live S6 root got
1 of its 3 declared in-batch edges and started alongside still-running R3.
Breaker coverage — the progress-aware respawn circuit breaker
(_pm_respawn_should_gate: strike counting, status-advance reset,
tracing-gap budget, DB durability, one-shot CEO notification) was consulted
by only 3 spawn paths; the doc/QA/dev/PR-review/PR-gate/revision/board
paths spawned unguarded at fixed cadence (the 26-respawn fe-doc loop,
~$7.20). Now consulted at every task-keyed spawn site (14 total).
B2 — assembled-branch freshness: submit_up/submit_root auto-sync the
assembled branch when it has fallen behind its base (children are terminal
at submit time, so the rebase is safe; master is never written). A rebase
conflict is a hard reject naming the files instead of a blind re-review —
kills the needs_revision↔awaiting_pr_review ping-pong of re-submitting a
stale head. Leaf i_am_done already had the behind-base gate; claim-time
fetch-fresh cut already existed.
B6 — documenter revision-pass loop: the awaiting_documentation bail
rejections (i_am_blocked/unclaim) now name the actual exit (i_documented
re-affirm) and the documenter prompt gets an explicit revision-pass rule.
* fix(orchestration): assembly-integrity gate + dispatcher heartbeat (incidents #11, #1)
Assembly integrity — submit_up/submit_root refuse when a completed child's
commits are not patch-present in the assembled branch (git cherry —
rebase-safe; branch pruned after merge or any git error fails open). Live
incident #11: a completed revert subtask's merge was lost from the cell
branch and the review gate re-flagged the exact violation the revert fixed,
spawning another revision cycle.
Dispatcher heartbeat — a dispatcher.alive audit row every 5 minutes from
the dispatch loop. The 2026-07-01 outage was 4h25m of fleet-wide silence
with no way to distinguish 'loop dead' from 'no work'; the loop's stdout
died with the container while audit_log survives. CHANGELOG for tonight's
full sweep included.
* style: ruff format for the orchestration sweep
* refactor(gateway): fold the assembled-submit guards + trim complexity under the xenon gate
_assembled_submit_guards combines the #11 integrity check and B2 freshen for
submit_up/submit_root; lifecycle's invalid-source remediate and git's
per-child cherry probe extracted into helpers. Test harnesses built via
__new__ stub the respawn tracker (the breaker now runs on their paths).
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
323 lines
14 KiB
Python
323 lines
14 KiB
Python
"""Composed-actions runner with atomicity invariant.
|
|
|
|
Used by every choreographer verb body that has a non-trivial
|
|
composition. The runner:
|
|
|
|
1. Wraps the composed atomic actions in `session.begin_nested()`
|
|
(a SAVEPOINT). A mid-sequence failure rolls the DB back to the
|
|
pre-verb state.
|
|
2. Runs side effects (git push, PR creation, etc.) AFTER the
|
|
savepoint commits, never before. Each side effect is itself
|
|
idempotent + retryable per the open_pr atomicity pattern.
|
|
|
|
Preconditions are NOT this runner's concern — `spec.can_invoke_intent`
|
|
runs before the verb body, so by the time the runner is called the
|
|
Decision is `allow`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from roboco.foundation.policy import lifecycle as spec
|
|
|
|
_AtomicHandler = Callable[[Any, Any, Any, spec.Context], Awaitable[Any]]
|
|
_SideEffectHandler = Callable[[Any, Any, Any], Awaitable[Any]]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VerbRunner:
|
|
"""Lightweight composition runner; one instance per choreographer."""
|
|
|
|
task_service: Any
|
|
git_service: Any
|
|
|
|
async def run_intent(
|
|
self,
|
|
intent_name: str,
|
|
task: Any,
|
|
agent: Any,
|
|
context: spec.Context,
|
|
) -> Any:
|
|
"""Run pre-side-effects, then composed atomic actions, then side effects.
|
|
|
|
Most verbs only need composes→side_effects. A few (submit_up) need a
|
|
git op to run BEFORE the DB transition it gates — those declare
|
|
``pre_side_effects`` which run first, outside the savepoint.
|
|
|
|
Returns the final task object (post-composition). Raises whatever
|
|
the underlying TaskService methods raise; the savepoint context
|
|
rolls the DB back on raise.
|
|
"""
|
|
# Fail loud + clean on a missing task/agent. The atomic handlers below
|
|
# dereference task.id / agent.id, so a None here would otherwise crash
|
|
# with a cryptic "'NoneType' object has no attribute 'id'" (observed when
|
|
# a task was forced into an unexpected state out-of-band) instead of an
|
|
# actionable error the agent can recover from.
|
|
if task is None or agent is None:
|
|
missing = "task" if task is None else "agent"
|
|
raise ValueError(
|
|
f"INVALID_STATE: cannot run '{intent_name}' — its {missing} "
|
|
"could not be resolved. Re-fetch with evidence(task_id) and "
|
|
"re-issue your claim verb."
|
|
)
|
|
intent = spec._INTENT_VERBS[intent_name]
|
|
for side_effect_name in intent.pre_side_effects:
|
|
await self._dispatch_side_effect(side_effect_name, task, agent)
|
|
async with self.task_service.session.begin_nested():
|
|
for position, action_name in enumerate(intent.composes):
|
|
# A composed atomic action (claim/set_plan/start) returns None when
|
|
# its source-status check fails — which happens mid-sequence when a
|
|
# CONCURRENT agent transitions the row between the verb's precondition
|
|
# gate and this execution (e.g. i_will_plan's gate saw `needs_revision`
|
|
# but a racing i_am_blocked moved it to `blocked`, so claim() found no
|
|
# valid transition and returned None). The NEXT action would then
|
|
# dereference None.id and crash with the cryptic "'NoneType' object has
|
|
# no attribute 'id'" — the entry guard above only covers the INITIAL
|
|
# task. Fail loud + clean before the next dispatch so the choreographer
|
|
# surfaces an actionable rejection and the agent re-fetches, not
|
|
# respawn-loops.
|
|
#
|
|
# Only an INTERMEDIATE None is fatal here. A None from the LAST
|
|
# composed action is the verb's own result: it flows out as the
|
|
# runner's return value so the caller's existing `if task is None`
|
|
# handler can surface the verb-specific message (e.g. "start
|
|
# failed for task ...", or the board verb's decline envelope) —
|
|
# preserving that contract instead of masking it as a crash.
|
|
if position > 0 and task is None:
|
|
raise ValueError(
|
|
f"INVALID_STATE: a composed action before '{action_name}' in "
|
|
f"'{intent_name}' returned no task — its source status was "
|
|
"invalid, most likely because a concurrent transition changed "
|
|
"the task between the precondition gate and execution. "
|
|
"Re-fetch with evidence(task_id) and re-issue your verb."
|
|
)
|
|
task = await self._dispatch_atomic(action_name, task, agent, context)
|
|
# A TRAILING None (the last composed action returned None because its
|
|
# source-status check failed under a concurrent transition) is the
|
|
# verb's own result and flows out as the runner's return value. The
|
|
# side_effects loop must NOT run on that None — a side effect
|
|
# dereferences task.branch_name / task.pr_number and crashes
|
|
# (_do_push_branch(None) -> None.branch_name AttributeError), turning
|
|
# the clean INVALID_STATE the entry/intermediate guards give into a
|
|
# 500/respawn loop. Skip side effects so the caller's `if task is None`
|
|
# handler surfaces the verb-specific message.
|
|
if task is not None:
|
|
for side_effect_name in intent.side_effects:
|
|
await self._dispatch_side_effect(side_effect_name, task, agent)
|
|
return task
|
|
|
|
async def _dispatch_atomic(
|
|
self, action_name: str, task: Any, agent: Any, context: spec.Context
|
|
) -> Any:
|
|
"""Dispatch one atomic action by name. Returns the post-action task."""
|
|
handler = self._atomic_handlers().get(action_name)
|
|
if handler is None:
|
|
raise ValueError(f"unknown atomic action '{action_name}'")
|
|
return await handler(self, task, agent, context)
|
|
|
|
async def _dispatch_side_effect(
|
|
self, side_effect_name: str, task: Any, agent: Any
|
|
) -> Any:
|
|
"""Dispatch one side effect by name. Idempotent operations only."""
|
|
handler = self._side_effect_handlers().get(side_effect_name)
|
|
if handler is None:
|
|
raise ValueError(f"unknown side effect '{side_effect_name}'")
|
|
return await handler(self, task, agent)
|
|
|
|
# -- Atomic handlers ---------------------------------------------------
|
|
|
|
async def _do_claim(self, task: Any, agent: Any, _ctx: spec.Context) -> Any:
|
|
return await self.task_service.claim(task.id, agent.id)
|
|
|
|
async def _do_set_plan(self, task: Any, _agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.set_plan(task.id, ctx.plan or "")
|
|
|
|
async def _do_start(self, task: Any, agent: Any, _ctx: spec.Context) -> Any:
|
|
return await self.task_service.start(task.id, agent.id)
|
|
|
|
async def _do_submit_verification(
|
|
self, task: Any, agent: Any, ctx: spec.Context
|
|
) -> Any:
|
|
return await self.task_service.submit_verification(
|
|
agent.id, task.id, ctx.notes or ""
|
|
)
|
|
|
|
async def _do_submit_qa(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.submit_qa(agent.id, task.id, ctx.notes or "")
|
|
|
|
async def _do_qa_pass(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.qa_pass(agent.id, task.id, ctx.notes or "")
|
|
|
|
async def _do_qa_fail(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.qa_fail(
|
|
agent.id, task.id, ctx.notes or "", list(ctx.issues)
|
|
)
|
|
|
|
async def _do_docs_complete(self, task: Any, _agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.docs_complete(task.id, doc_notes=ctx.notes or "")
|
|
|
|
async def _do_complete(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.cell_pm_complete(
|
|
agent.id, task.id, ctx.notes or ""
|
|
)
|
|
|
|
async def _do_submit_pm_review(
|
|
self, task: Any, agent: Any, ctx: spec.Context
|
|
) -> Any:
|
|
return await self.task_service.submit_pm_review(
|
|
agent.id, task.id, ctx.notes or ""
|
|
)
|
|
|
|
async def _do_submit_for_review(
|
|
self, task: Any, agent: Any, ctx: spec.Context
|
|
) -> Any:
|
|
return await self.task_service.submit_for_review(
|
|
agent.id, task.id, ctx.notes or ""
|
|
)
|
|
|
|
async def _do_pr_pass(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.pr_pass(agent.id, task.id, ctx.notes or "")
|
|
|
|
async def _do_pr_fail(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.pr_fail(
|
|
agent.id, task.id, ctx.notes or "", list(ctx.issues)
|
|
)
|
|
|
|
async def _do_request_changes(
|
|
self, task: Any, agent: Any, ctx: spec.Context
|
|
) -> Any:
|
|
# Forward the actor's real role — cell_pm and main_pm both own this
|
|
# verb and the audit row must attribute the reject to the reviewer.
|
|
agent_role = str(agent.role) if agent is not None else "cell_pm"
|
|
return await self.task_service.request_changes(
|
|
agent.id,
|
|
task.id,
|
|
ctx.notes or "",
|
|
list(ctx.issues),
|
|
agent_role=agent_role,
|
|
)
|
|
|
|
async def _do_escalate_to_ceo(
|
|
self, task: Any, agent: Any, ctx: spec.Context
|
|
) -> Any:
|
|
# Use the actor's real role — escalate_to_ceo is allow-listed for
|
|
# main_pm, product_owner, head_marketing in the spec, and the task
|
|
# service stamps the escalator's role into the audit trail. Forward
|
|
# the actor's UUID so the awaiting_ceo_approval audit row attributes
|
|
# the escalation to the specific PM/Board agent (every sibling
|
|
# transition forwards the actor; this branch was the only one that
|
|
# lost it, leaving a role-only record ambiguous across same-role PMs).
|
|
agent_role = str(agent.role) if agent is not None else "main_pm"
|
|
return await self.task_service.escalate_to_ceo(
|
|
task_id=task.id,
|
|
agent_role=agent_role,
|
|
notes=ctx.notes or "",
|
|
actor_agent_id=agent.id,
|
|
)
|
|
|
|
async def _do_block(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.escalate(agent.id, task.id, ctx.notes or "")
|
|
|
|
async def _do_unblock(self, task: Any, agent: Any, _ctx: spec.Context) -> Any:
|
|
return await self.task_service.unblock_with_restore(
|
|
agent.id, task.id, restore=True
|
|
)
|
|
|
|
async def _do_resume(self, task: Any, agent: Any, _ctx: spec.Context) -> Any:
|
|
return await self.task_service.resume_for_agent(task.id, agent.id)
|
|
|
|
async def _do_create_subtask(
|
|
self, _task: Any, _agent: Any, _ctx: spec.Context
|
|
) -> Any:
|
|
raise NotImplementedError(
|
|
"create_subtask requires DelegateInputs; verb body owns dispatch"
|
|
)
|
|
|
|
async def _do_pr_review_done(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
|
|
return await self.task_service.complete_review(
|
|
agent.id, task.id, ctx.notes or ""
|
|
)
|
|
|
|
@classmethod
|
|
def _atomic_handlers(cls) -> dict[str, _AtomicHandler]:
|
|
return {
|
|
"claim": cls._do_claim,
|
|
"set_plan": cls._do_set_plan,
|
|
"start": cls._do_start,
|
|
"submit_verification": cls._do_submit_verification,
|
|
"submit_qa": cls._do_submit_qa,
|
|
"qa_pass": cls._do_qa_pass,
|
|
"qa_fail": cls._do_qa_fail,
|
|
"docs_complete": cls._do_docs_complete,
|
|
"complete": cls._do_complete,
|
|
"submit_pm_review": cls._do_submit_pm_review,
|
|
"submit_for_review": cls._do_submit_for_review,
|
|
"pr_pass": cls._do_pr_pass,
|
|
"pr_fail": cls._do_pr_fail,
|
|
"request_changes": cls._do_request_changes,
|
|
"escalate_to_ceo": cls._do_escalate_to_ceo,
|
|
"block": cls._do_block,
|
|
"unblock": cls._do_unblock,
|
|
"resume": cls._do_resume,
|
|
"create_subtask": cls._do_create_subtask,
|
|
"pr_review_done": cls._do_pr_review_done,
|
|
}
|
|
|
|
# -- Side-effect handlers ---------------------------------------------
|
|
|
|
async def _do_push_branch(self, task: Any, agent: Any) -> Any:
|
|
# Forward the actor so the workspace resolves from the actor's clone
|
|
# (actor_agent_id wins over the assigned_to/created_by fallback) —
|
|
# matches _do_pr_merge. Without it, a verb on a task whose
|
|
# assigned_to was cleared before the side effect falls through to the
|
|
# wrong workspace and pushes from / opens a PR against it.
|
|
return await self.git_service.push_branch(
|
|
task.branch_name, actor_agent_id=agent.id
|
|
)
|
|
|
|
async def _do_create_pr(self, task: Any, agent: Any) -> Any:
|
|
from roboco.services.gateway.merge_chain import resolve_parent_branch
|
|
|
|
parent = await resolve_parent_branch(task, self.task_service)
|
|
return await self.git_service.create_pr(
|
|
task.branch_name,
|
|
parent=parent,
|
|
is_root_pr=False,
|
|
actor_agent_id=agent.id,
|
|
)
|
|
|
|
async def _do_create_root_pr(self, task: Any, agent: Any) -> Any:
|
|
# Root→master PR for the in-path gate's root level (submit_root). The
|
|
# base is always master and is_root_pr marks it for the CEO-merge path.
|
|
# The PM opening the master PR is the assigned_to-may-be-None case
|
|
# create_pr's actor_agent_id exists for.
|
|
return await self.git_service.create_pr(
|
|
task.branch_name,
|
|
parent="master",
|
|
is_root_pr=True,
|
|
actor_agent_id=agent.id,
|
|
)
|
|
|
|
async def _do_pr_merge(self, task: Any, agent: Any) -> Any:
|
|
from roboco.services.gateway.merge_chain import resolve_parent_branch
|
|
|
|
target = await resolve_parent_branch(task, self.task_service)
|
|
return await self.git_service.pr_merge(
|
|
task.pr_number,
|
|
target=target,
|
|
project_id=task.project_id,
|
|
actor_agent_id=agent.id,
|
|
)
|
|
|
|
@classmethod
|
|
def _side_effect_handlers(cls) -> dict[str, _SideEffectHandler]:
|
|
return {
|
|
"push_branch": cls._do_push_branch,
|
|
"create_pr": cls._do_create_pr,
|
|
"create_root_pr": cls._do_create_root_pr,
|
|
"pr_merge": cls._do_pr_merge,
|
|
}
|