Fix: agent workflow hardening (#70)

* fix(gateway): push the branch before QA handoff so reviewers see the latest commits

The commit content tool commits locally without pushing; only open_pr pushed
the branch. On the first submission that was fine, but a fix committed while
addressing needs_revision never reached origin (open_pr is skipped once the PR
exists), so QA — which reviews the remote PR branch — re-reviewed the stale
remote and re-failed the task on every cycle, a loop that never converged.

i_am_done now pushes the task branch (idempotent; a no-op when nothing is
unpushed) as part of the shared submit gate, covering both the normal and
resume-from-verifying paths. A push failure blocks the handoff with a clear
remediation rather than parking the task in awaiting_qa with commits that exist
only in the developer's local workspace.

* fix(orchestrator): don't reap a stale claim while the agent's container is alive

The stale-claim reaper released any claimed/in_progress task whose
last_heartbeat_at exceeded the TTL. The heartbeat only updates on certain
gateway calls, so a developer deep in a long edit/test cycle outran the TTL and
had its claim reaped mid-work — churning the task and risking a double spawn
against the still-running container.

The reaper now skips a task whose assignee still holds a live (ACTIVE) agent
instance, trusting container liveness — the ground truth — over the heartbeat
proxy. The check is defensive on missing fields so a heartbeat-only caller (and
the reaper's existing unit tests) behave exactly as before.

* fix(gateway): refuse to unblock a task while a dependency is unfinished

A PM unblock on a dependency-gated task moved it straight to in_progress,
overriding the dependency — letting a dependent proceed without its upstream's
work (e.g. a frontend task built before its UX design lands). A dependency
block is meant to clear on its own via _unblock_dependents the moment the
upstream reaches a terminal state.

unblock now refuses while any dependency is still non-terminal, returning a
clear remediation that the block resolves automatically. Manual unblock remains
available for genuine, non-dependency blockers.

* fix(gateway): release a dependency-blocked claim to pending instead of looping

A task that reached claimed/in_progress with an unfinished dependency was left
in that state when the claim guard rejected, so the orchestrator's respawn loop
kept reviving its assignee — which could make no progress — burning work for
nothing.

The claim guard now releases such a task back to pending. claimed -> blocked is
not a legal transition, so pending — held by the dispatch dependency filter — is
the lifecycle-correct resting state: the respawn loop ignores pending tasks, and
_unblock_dependents re-dispatches it once the upstream reaches a terminal state.
release_dependency_blocked_claim shares a _force_unclaim_to_pending core with
unclaim_for_reaper so both record a truthful work-session abandon reason.

* feat(security): warn at startup in header-trust mode + document the auth posture

When ROBOCO_AGENT_AUTH_REQUIRED is not enabled the API accepts the X-Agent-Id /
X-Agent-Role headers without a signed token, so any client that can reach it may
act as any role (including 'ceo'). The API now logs a clear warning at startup
in this mode, and the README gains a Security section documenting the auth
posture and how to harden it. Acceptable only on a trusted private network — do
not expose the API to untrusted networks.

* fix(workspace): scope the refresh fetch to current + default branch

ensure_workspace's healthy short-circuit ran an all-refs 'git fetch origin' to
keep every origin/<branch> ref current. On a monorepo with many accumulated
feature/* branches that exceeds the refresh timeout, the fetch silently fails,
and the workspace keeps a stale base — so an agent builds on an out-of-date
branch.

The refresh now fetches only the workspace's current branch and the repo's
default branch (resolved via origin/HEAD), with --no-tags --prune: it transfers
near-nothing and can't time out. Readers need their own branch and the default;
the integration branch is refreshed at branch-creation time.

* fix(git): refresh a dependency-blocked task's branch off the current integration tip

A cross-cell dependent (e.g. a frontend task waiting on the UX design) was
branched off a base captured before its upstream merged into the integration
branch, and the branch was never re-synced — so the agent built on a stale
snapshot with none of the upstream's work.

Two changes close the gap:
- release_dependency_blocked_claim now clears branch_name, so the re-claim
  (after the dependency clears) re-runs branch creation.
- create_branch, when the branch is already on disk with no commits of its own,
  resets it onto the freshly-pulled base — the dependent now builds on the
  current integration tip. A branch carrying real commits is left untouched, so
  no work is discarded; the cell->leaf cascade carries the upstream down to the
  dev branch automatically.

* refactor(gateway): drop the sibling-sequence claim guard

Sibling sequence no longer gates a claim. Cross-cell ordering is
enforced by task dependencies — a cell task that depends on another is
held until its upstream reaches a terminal state, a stronger,
status-aware gate than the sequence-number check. That check was
dormant in practice anyway: every fan-out child carries sequence 0, on
which the guard short-circuited. `sequence` stays a sibling-ordering /
dispatch-priority field (list_pending ordering and the panel).

Removes sibling_sequence_guard and its _earlier_blocking_sibling
helper, the now-unused skip_sequence parameter threaded through the
claim verbs, and the sibling fetch that fed it.

* feat(gateway): sort a cross-cell dependent after its upstream

When the frontend cell task is wired to depend on its UX/UI sibling, set
its sequence to the upstream's sequence + 1 so it sorts after the design
it waits on — list_pending ordering and the panel now show UX ahead of
the implementation it gates, in either delegation order.

Adds TaskService.set_sequence (the sibling-ordering field is a service
write; it carries no claim-gating semantics — dependencies gate claims).

* feat(gateway): make the backend cell depend on UX too

UX/UI design defines the screens and API contracts both implementation
cells build against, so the backend cell — not just the frontend — waits
on the UX/UI cell task in a product fan-out and sorts after it. Wires in
either delegation order: a backend task delegated after UX gets the
dependency directly; a UX task delegated after a still-pending backend
sibling retro-wires it.

Mirrors the existing frontend wiring (_depend_backend_on_ux and
_depend_pending_backends_on_ux). Backend is held by the same dependency
gate, so it costs no extra dispatch churn.

* fix(websocket): forward notification acks instead of logging them incomplete

The bridge handler serves both notification.sent and notification.acked,
but acked events carry `agent_id` (the acking agent) rather than
`recipient_id`, so every acknowledgement tripped the missing-field guard
and logged "Incomplete notification event" instead of reaching the panel.
Accept either field as the recipient.

* feat(api): hint the full UUID when a truncated task id fails validation

Agents copy the 8-character task prefix the system shows them (the commit
prefix, task summaries) and send it as task_id, which fails UUID
validation with an opaque "invalid length" 422 and wastes a call. The
request-validation handler now detects a task_id UUID error and attaches
a `remediate` hint telling the agent to retry with the full 36-character
UUID from its task envelope.

* fix(audit): record the blocked transition when a task is escalated

Escalation sets a task to blocked by writing task.status directly, which
bypassed the validated transition helper and so never emitted a
task.blocked audit row — the lifecycle moved but the Auditor saw nothing.
Extract the audit emit from the central transition helper into
_emit_status_transition_audit and call it from the escalate path,
capturing the prior status and outgoing owner before reassignment so the
row is attributed correctly.

* fix(docs): stop doubling the docs path so design specs index into RAG

The documenter sometimes hands a doc path already rooted at docs/, and
joining it onto DOCS_BASE_PATH (/app/docs) produced /app/docs/docs/...,
so the file was never found and the spec never indexed — the frontend
cell could not retrieve the UX design over RAG. Normalize the path
before joining: trust an absolute path, otherwise strip a single
redundant leading docs/ segment.

* feat(security): let the control panel authenticate in secure mode

With ROBOCO_AGENT_AUTH_REQUIRED=true every request must carry a valid
HMAC token, which locked the human control panel out — it sends role
headers but no token. nginx, the only trusted hop between the browser
and the API, now injects the CEO token on /api and /ws, so the browser
never holds the signing secret. The injected value is just the existing
per-agent token issued for the CEO identity (issue_panel_token), so the
token-verification path is unchanged. An empty value (dev/header-trust
mode) renders to no header.

`make panel-token` prints the value; set it as ROBOCO_PANEL_AGENT_TOKEN
in .env before enabling secure mode. .env.example and the README
Security section document the flow.

* chore(compose): consolidate the two compose files into one

docker-compose.yml and docker-compose.yaml had diverged: .yml — the file
Docker actually uses — carried ROBOCO_PUBLIC_BASE_URL but was missing the
/app/manifests bind-mount, while .yaml had the manifests mount but not
the base URL. Merge the union into docker-compose.yml and delete the
duplicate so there is one source of truth and no "multiple config files"
warning.

This activates the manifests mount in the deployed file: without it the
orchestrator writes per-agent tool manifests to its ephemeral container
fs, they never reach the host for the daemon to bind-mount, and agents
fall back to all-verbs registration. Drop the stale .yaml reference from
the config.py docstring, the labeler, and the CI path filters.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-05 16:35:22 +02:00
committed by GitHub
co-authored by Renn F
parent 97533f769b
commit 06682f33c6
40 changed files with 1300 additions and 653 deletions
+125 -39
View File
@@ -24,7 +24,6 @@ from roboco.services.gateway.choreographer._verb_runner import VerbRunner
from roboco.services.gateway.claim_guards import (
already_active_guard,
paused_tasks_guard,
sibling_sequence_guard,
unmet_dependency_guard,
)
from roboco.services.gateway.envelope import Envelope
@@ -704,7 +703,6 @@ class Choreographer:
*,
agent_id: UUID,
task: Any,
skip_sequence: bool = False,
) -> Envelope | None:
"""Run concurrency-invariant claim guards. Returns rejection or None.
@@ -714,11 +712,7 @@ class Choreographer:
in the verb's spec gate; the former role-typed and
pm_cannot_execute_code guards have been deleted (Task 27, 2026-05-10).
Pre-gateway location: _helpers.py:124-204 + claim.py:121-180.
``skip_sequence`` lets resumption-of-already-claimed-task call sites
skip the sibling-sequence check (the sequence was already validated
on the original claim).
Pre-gateway location: _helpers.py:124-204.
"""
in_progress = await self.task.list_in_progress_for_agent(agent_id)
if guard := already_active_guard(in_progress, task.id):
@@ -730,26 +724,15 @@ class Choreographer:
if dep_ids:
unmet = await self.task.unmet_dependency_ids(dep_ids)
if guard := unmet_dependency_guard(task, unmet):
return guard
if not skip_sequence:
siblings = await self._fetch_siblings(task)
if guard := sibling_sequence_guard(task, siblings):
# 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 _fetch_siblings(self, task: Any) -> list[Any]:
"""Fetch sibling tasks for the sequence-order guard.
Returns ``[]`` when the task has no parent (root task) so the
guard short-circuits. Otherwise returns the parent's subtasks via
``TaskService.get_subtasks``.
"""
parent_id = getattr(task, "parent_task_id", None)
if parent_id is None:
return []
siblings: list[Any] = await self.task.get_subtasks(parent_id)
return siblings
async def _non_terminal_subtask_ids(self, parent_task_id: UUID) -> str:
"""Return a human-readable comma-separated list of non-terminal subtasks.
@@ -833,13 +816,11 @@ class Choreographer:
task_id=task_id,
verb=verb_name,
)
# Concurrency guards still apply (paused / already-active in another
# task). Sibling sequence is skipped on resumption — see
# _run_claim_guards docstring.
# 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,
skip_sequence=True,
):
return await self._emit_rejection(
self._with_briefing(guard, briefing).with_introspection(
@@ -899,7 +880,7 @@ class Choreographer:
"""Run all gates for an ``i_will_work_on`` / ``i_will_plan`` call.
Order: spec.can_invoke_intent -> behavioral claim guards
(already_active / paused / sibling_sequence). Any rejection
(already_active / paused / unmet_dependency). Any rejection
short-circuits with the appropriate envelope.
Per-role claim authority (CLAIM_RULES) is enforced inside
@@ -921,7 +902,7 @@ class Choreographer:
# 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
# - sibling_sequence: an earlier-numbered sibling is still open
# - 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
@@ -1475,7 +1456,10 @@ class Choreographer:
async def _i_am_done_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Run defense-in-depth tracing + field-level gates the spec doesn't model.
Returns the rejection envelope if any gate fails; None on pass.
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.
"""
if rejection := await self._check_tracing_gates(
ctx.agent_id, ctx.task_id, ctx.task
@@ -1485,12 +1469,38 @@ class Choreographer:
ctx.agent_id, ctx.task_id, ctx.task
):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._ensure_branch_pushed(ctx):
return await self._reject_i_am_done(ctx, rejection)
# Wave C5 (2026-05-12) — pre-gateway parity. Persist per-criterion
# 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 _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 sha off the task, dict or model alike."""
@@ -3349,13 +3359,13 @@ class Choreographer:
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 FRONTEND cell task
depends on the UX/UI cell task — UX design is upstream of frontend
implementation, while backend runs in parallel. 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.
"""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
@@ -3368,11 +3378,14 @@ class Choreographer:
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->FE sequencing wiring failed",
"cross-cell UX->implementation sequencing wiring failed",
error=str(exc),
parent_task_id=str(getattr(parent, "id", None)),
)
@@ -3396,6 +3409,32 @@ class Choreographer:
)
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
@@ -3405,6 +3444,7 @@ class Choreographer:
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 (
@@ -3413,6 +3453,26 @@ class Choreographer:
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
@@ -3901,6 +3961,32 @@ class Choreographer:
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",
)
if env := await self._check_pm_decision_required(
"unblock", pm_agent_id, task_id, t
):
@@ -72,7 +72,6 @@ class ChoreographerHelpers:
*,
agent_id: UUID,
task: Any,
skip_sequence: bool = False,
) -> Envelope | None:
raise NotImplementedError
@@ -178,7 +178,6 @@ class DocMixin(_Base):
guard = await self._run_claim_guards(
agent_id=doc_agent_id,
task=t,
skip_sequence=True,
)
if guard:
guard.with_introspection(task=t, role=role_str)
@@ -152,7 +152,6 @@ class QAMixin(_Base):
guard = await self._run_claim_guards(
agent_id=qa_agent_id,
task=t,
skip_sequence=True,
)
if guard:
guard.with_introspection(task=t, role=role_str)