Pre-fix, submit_for_qa opened a PR (side effect) and returned OK with
next='call i_am_done' — agents read the verb name, assumed they were
done with QA handoff, never called i_am_done, and PRs ended up
orphaned (PR #12 in the 2026-05-08 trace).
Two changes:
1. Rename submit_for_qa -> open_pr so the verb name matches the
semantic. The PR opens here; the actual QA handoff happens at
i_am_done. Renamed across:
- choreographer/_impl.py (method)
- mcp/flow_server.py (tool registration + _TOOLS dict)
- api/routes/v2/flow_dev.py (route + handler)
- api/schemas/v2/flow.py (OpenPrRequest)
- services/gateway/verb_gates.py (_STATE_VERBS)
- services/gateway/role_config.py (developer flow manifest)
- services/gateway/content_actions.py (commit-success next= hint)
- agent_sdk/server.py (post-tool guidance map)
- runtime/orchestrator.py (developer prompt)
- agents/prompts/{base,roles/developer,_generated/*}.md
- tests/unit/gateway/test_submit_for_qa.py -> test_open_pr.py
- tests/unit/api/routes/v2/test_flow_dev.py
- tests/unit/gateway/test_verb_gates.py
- tests/unit/api/test_correlation_id.py
- tests/unit/mcp_servers/test_flow_server.py
- tests/integration/test_full_lifecycle_real_db.py
2. New regression test (test_open_pr_does_not_create_pr_if_no_commits)
pins the atomic invariant: preconditions (assignee, commits,
no-prior-PR) must be checked BEFORE git.create_pr/push_branch run.
Any future re-ordering breaks the test.
Tests: 3128 passing (3127 + 1 new), 100% coverage, ruff clean.
Note: TaskService.submit_for_qa() (the v1-layer service method) is
INTENTIONALLY not renamed — it's a different layer used by the v1
routes. The rename here is only the gateway verb surface.
Replaces hardcoded role-string-constants in content_actions.py
(_COMMIT_ALLOWED_ROLES, _NOTIFY_ALLOWED_ROLES) with calls into
verb_gates.is_verb_allowed against a synthetic in-progress task probe.
Pre-fix the same role lists lived in both content_actions and
verb_gates; if one drifted the other would mask it. Now there's one
table.
Adds `notify` to verb_gates._ALWAYS_AVAILABLE for cell_pm, main_pm,
product_owner, head_marketing.
Note: i_will_plan / delegate role checks INTENTIONALLY stay as
explicit `role not in (cell_pm, main_pm)` checks, not is_verb_allowed.
Their state checks must surface as `invalid_state` (a different
agent-side error code) — conflating them with role-state combo
checks breaks the rejection-code semantics agents rely on.
Tests: 3127 passing, 100% coverage, ruff clean.
Closes the Task 3 wiring loop: every Envelope construction site in the
QA and Documenter mixins now stamps current_state + valid_next_verbs.
Refactored doc._check_i_documented_inputs to take the loaded task as a
parameter so it can pass through to .with_introspection() without
re-fetching.
Task 3 of the 2026-05-08 gateway introspection plan is now complete
across _impl.py, qa.py, and doc.py.
escalate_up, escalate_to_ceo, submit_up, unblock
Continues Task 3 of the gateway introspection plan. Every developer-
and PM-facing lifecycle verb in _impl.py now stamps current_state +
valid_next_verbs on both the success path and (where the task was
loaded successfully) on rejection paths.
The qa.py / doc.py role mixins still need wiring; that's a follow-up
since their structure mirrors what's already been done here.
Trace-driven priority: the 2026-05-08 audit log showed PMs spamming
`complete` against tasks in the wrong status (claimed/in_progress
rather than awaiting_pm_review). Introspection on the rejection path
now tells the PM the actual current_state and which verbs are valid
right now (typically `delegate` / `escalate_up`), shrinking the
trial-and-error loop.
Continues Task 3 of the gateway introspection plan.
submit_for_qa: ok envelopes (PR-already-open + new-PR) and
not-assigned / no-commits rejections now stamp current_state +
valid_next_verbs.
i_am_done: not-assigned rejection plus tracing-gap and field-gate
rejections all stamp introspection. Success path inherits via the
shared _build_i_am_done_ok helper.
Continues Task 3 of the gateway introspection plan.
Continues Task 3 of the gateway introspection plan: every successful
or rejected envelope from these three verbs now carries current_state +
valid_next_verbs sourced from verb_gates.valid_next_verbs(role, task).
Remaining verbs (submit_for_qa, i_am_done, i_am_blocked, unclaim,
resume, unblock, complete, main_pm_complete, escalate_up,
escalate_to_ceo, submit_up, plus the qa.py / doc.py role-mixins) to be
wired in subsequent commits — the choke-point pattern at each call
site is `.with_introspection(task=t, role=role)` on the constructed
Envelope, so it stays mechanical from here.
Pre-fix, agents had no way to introspect what verbs were valid from a
task's current state — the 2026-05-08 trace showed them spamming
escalate_to_ceo/complete/unblock/resume against a `claimed` task and
racking up rejections. Pre-gateway agents could ground reasoning in
VALID_TRANSITIONS[status] from a doc; the gateway hid that.
Now every Envelope carries:
- current_state: the task's status string (or None for tool-discovery
envelopes that aren't task-bound)
- valid_next_verbs: the verbs the caller can usefully call right now,
sourced from verb_gates.valid_next_verbs(role, task)
Wired into i_will_work_on (highest-traffic verb) for both the OK path
and the wrong-state rejection path. Remaining lifecycle verbs to be
wired in subsequent commits (Task 3.9).
Pre-2026-05-08, role checks lived in three places that could disagree
silently:
1. roboco/services/gateway/role_config.py — verb allow-list per
role (used by spawn manifest)
2. roboco/services/gateway/claim_guards.py — pm_cannot_execute_code,
role_typed_claim
3. Choreographer string constants in _impl.py / qa.py / doc.py /
content_actions.py
verb_gates.valid_next_verbs(role, task) collapses them into one
declarative table mapping (role, task_status) -> tuple of valid verbs,
plus a per-role set of always-available verbs. Will be wired into
Envelope.valid_next_verbs in the next task so agents stop
trial-and-erroring against the gateway, and into the choreographer
guards in Task 4.
Two coupled fixes from the 2026-05-08 smoke-test trace:
1. pm_cannot_execute_code is now scoped to i_will_work_on (the
EXECUTION verb) only. Pre-fix it also fired on i_will_plan, which
deadlocked any code-typed parent: cell_pm couldn't plan, so couldn't
transition parent to in_progress, so couldn't delegate. PMs PLAN
code-typed parents and DELEGATE the work — that's exactly the verb
we were blocking.
2. delegate.task_type is now REQUIRED at both the HTTP boundary
(DelegateRequest) and the choreographer dataclass (DelegateInputs).
The pre-fix default of 'code' silently changed semantics whenever a
caller forgot the field — main-pm's call in the smoke trace omitted
it, schema defaulted to 'code', and the cell PM downstream was
wedged. Also drops the choreographer's task.task_type fallback
(the DB column is NOT NULL anyway).
Plus middleware coverage tests for the parallel ServiceError →
4xx handler hierarchy added in the prior session, restoring 100%
coverage across the touched files.
Tests: 3101 passing, 100% coverage, ruff clean.
Smoke 2026-05-04 captured the cycle the prior 63d0adf fix didn't close:
- Spawn 1: i_will_plan succeeds, task pending → claimed → in_progress.
- Agent goes idle (LLM thinking, container exits, respawned).
- Spawn 2: i_will_plan called again. _i_will_plan_preflight rejects
'task in in_progress, expected pending'. Agent has no recovery path.
- Heartbeat eventually goes stale, reaper drops claim back to pending,
spawn 3 fires, loop repeats indefinitely.
Fix: when a respawned PM/dev re-enters the verb on a task they already
own in claimed/in_progress, return OK with current state and refresh
the heartbeat instead of rejecting. The verb is now genuinely
idempotent for the caller, which matches what 'I will plan' should
mean — record intent + advance state, regardless of how many times
the agent says it. Different-caller contention still rejects.
Refactored i_will_work_on's pending branch into _i_will_work_on_pending
helper to satisfy PLR0911 after the new branch raised return count.
Pin: PM, QA, Board cannot call commit; developer + documenter can.
Smoke 2026-05-03 saw main-pm reach the git layer with a 'commit' call
attempting to author a fix to the very gateway bug we were hitting.
Smoke 2026-05-03 saw main-pm reach the git layer with a 'commit' call,
trying to author 'fix(gateway): allow claimed status in i_will_plan
preflight'. That should never have been possible — main_pm/cell_pm/
board/auditor/qa manifests all exclude commit. Reaching the verb body
means either the MCP manifest filter mis-routed, or the agent hit the
v2 do.py route directly.
Mirror Task 16 notify pattern: server-side role check in the verb body
rejects with not_authorized + 'PMs delegate, do not commit' remediate.
Defense-in-depth — manifest is still the primary gate.
pr_merge falls back to task.assigned_to for workspace resolution, but
that field is None at merge time (submit_qa/pass_qa cleared it during
prior transitions). When project.workspace_path is unset, the resolver
raises ValidationError 'no workspace configured and no agent_id
provided' — surfaces as 500 from cell_pm_complete.
Add actor_agent_id parameter (the PM doing the merge) and use it as
the primary workspace owner. Falls back to task.assigned_to, then
created_by, before raising. cell_pm_complete now threads pm_agent_id
through.
Real-DB integration test: pending pre-assigned task → claim() →
status=CLAIMED AND last_heartbeat_at populated. Without the seed
(reverted in unit testing), the reaper would interpret NULL as
stale and reap the freshly-claimed task on the next dispatch tick.
Smoke run agents picked invalid enums ('development', 'small') because
spawn prompts used 'code|documentation|...' format with literal '...'
that LLMs interpret as 'fill in something creative'. Replace with
explicit '<one of "a" / "b" / ...>' enumerations matching the
TaskType + Complexity enum members, plus an explicit reminder that
the gateway rejects invented values.
Smoke 2026-05-03: reaper fired ~12 times/sec against a freshly-claimed
task because _finalize_claim never seeded last_heartbeat_at. Reaper's
'NULL means stale' rule then reaped the claim within ~250ms of the
claim landing, agent re-claimed via i_will_plan, reaper reaped again.
Set last_heartbeat_at = claimed_at in _finalize_claim so the freshly-
claimed task carries an authentic heartbeat from second zero. Reaper
logic stays untouched.
Pin the smoke-2026-05-03 bugs that motivated 63d0adf:
- pre-assigned-and-pending: claim must still fire (was skipped when
task.assigned_to already matched pm_agent_id)
- start-returns-None: must surface invalid_state (was silently returning
Envelope.ok with fabricated status='in_progress')
Smoke run revealed CEO-pre-assigned root tasks stay stuck in pending
because both verbs skipped claim() when task.assigned_to already
matched the caller. Claim is what transitions pending → claimed; without
it, start() refused the claimed → in_progress transition and silently
returned None. i_will_plan then returned an OK envelope with a
fabricated 'in_progress' status string while the DB stayed pending,
causing the next delegate() call to correctly reject with
PARENT_NOT_CLAIMED. Agent looped on delegate retries, never figuring
out claim was the missing step.
Fix:
- Drive claim() on status == pending (idempotent for same assignee),
not on assigned_to mismatch.
- If start() returns None, surface invalid_state envelope instead of
fabricating success.
Audit followups before pushing the gateway-restoration batch:
- Add notify() row to cell_pm.md, main_pm.md, board.md verb tables.
Manifests + routes + MCP + tests all wired in 3a2498a but agents
had no prompt-level cue.
- Replace # type: ignore[attr-defined] in test_pm_respawn_reset.py
with cast('Any', orch) — matches project's no-suppress standard.
Task 22 added a runtime ValueError when entry_id is None, but the
dataclass still typed it Optional. Contract-vs-runtime split — callers
get no IDE/mypy hint about the required field. Tighten the type to
UUID (required) and remove the misleading "can be None for system
events" docstring note. Lifecycle events already use their synthetic
uuid5 path, so no real caller is broken.
Tiny race: dispatcher decided to spawn for closure between agent's
heartbeat and idle-pause. Spawn would land against an already-paused
parent. Gate spawn on (status != PAUSED OR last_heartbeat older than
cutoff).
State existed in the lifecycle table and the enum but no verb, route,
or service path ever set it. Removing dead state. If we need
problem-task isolation later we'll add it explicitly with a verb.
Two PMs completing different subtasks of the same parent could race
on gh API merge calls. GitHub returns 409 to one but local DB write
ordering wasn't guaranteed. Take row-level lock on parent task before
merge; retry once on 409.
Pre-gateway PMs/Board sent formal notifications (require ack); gateway
had say/dm only. Now PMs and Board can issue ack-required notifications
via NotificationService through the standard envelope path.
I1: _fetch_origin_best_effort runs as root and writes new pack files
+ ref updates that land root-owned, undoing _ensure_agent_owned that
ran before. Subsequent spawns hit Permission denied. Mirror the
fetch_branch_for_inspection pattern: re-run _ensure_agent_owned AFTER
the fetch.
I2: separate workspace_refresh_fetch_timeout_seconds (default 60s)
from workspace_clone_timeout (300s). Refresh transfers small deltas;
300s of blocking on every spawn against a hung remote is operationally
bad. 60s is enough for any sane refresh.
ensure_workspace short-circuited when the clone existed, so a
respawned PM/Doc could be reviewing arbitrarily stale diffs. Add
a best-effort 'git fetch origin' on every entry; checkout
unchanged.
X-Correlation-ID was bound to structlog but lost on the MCP→API hop.
MCP shims now forward it; Envelope carries it back to the agent;
audit_log row records it for forensic joins.
Task 13's has_recent_tracing_gap query used .astext on a generic JSON
column, which raises AttributeError at runtime. The choreographer's
exception handler swallowed it, leaving the strike-count reset
permanently inert in production. Migrate the column to JSONB (which
supports .astext + GIN indexing for future audit queries), update the
ORM, and add a real-DB integration test that would have caught this.
_pm_respawn_should_gate counted PARENT_NOT_CLAIMED rejections as
no-progress and killed PMs after 3 strikes — even when the new prompts
told them to call i_will_plan first. Reset counter when last response
was a tracing_gap (rule-following retry, not stuck).
ROBOCO_GATEWAY_ENABLED defaulted to False, leaving trigger_filter's
spawn cooldown / role-rate logic dormant. Flip to true and add the
gateway_triggers table migration if missing. Without this, respawn
rate has no server-side limit besides _pm_respawn_should_gate.
When a PM's i_will_plan and a child dev's spawn fire in the same tick,
the dev sometimes saw branch_name=None because the PM's transaction
hadn't committed yet. Auto-block fired and the dev sat blocked until
the next 30s tick. Add 3x250ms retries when parent is mid-claim.
I2: resume_for_agent did its own _validate_and_set_status call,
skipping resume()'s structlog 'Task resumed' event and the fire-and-
forget RAG lifecycle-event indexing. Gateway-driven resumes were
invisible to logs and the RAG corpus. Delegate to resume() after
the gateway-specific ownership pre-checks (mirrors pause_for_agent's
pattern).
I1: documented why Choreographer.unclaim does NOT call _touch on
its OK path - assigned_to is cleared, no claimant heartbeat to
refresh. Prevents future readers from 'fixing' the asymmetry.
i_am_idle auto-pauses owned in_progress tasks; lifecycle table allows
paused -> in_progress; no verb implemented it. Adds resume so an agent
respawned for a paused task can continue. Routes through
_validate_and_set_status mirroring the unclaim pattern (Task 9 fix).
unclaim_for_agent did direct attribute assignment, bypassing the
single point of truth for status transitions. Choreographer's pre-check
made the runtime correct today, but a future change to VALID_TRANSITIONS
or ROLE_RESTRICTED_TRANSITIONS would silently miss this path. Route
through _validate_and_set_status; assigned_to is still cleared
explicitly as the unclaim's specific side effect.
Choreographer remediate strings already pointed agents at unclaim;
the verb didn't exist. Now it does — claimed/in_progress -> pending,
clears assigned_to, available to dev/qa/doc/cell_pm/main_pm.
ContentActions.commit was stripping user-supplied prefixes but never
re-adding the canonical one. Dev prompt promised auto-prefix; code
delivered nothing. Now every gateway commit lands with [task-id-short].
Format choice: simple [task_id[:8]] (8-char), matching the dev prompt
("Auto-prefixes [task-id]") and CLAUDE.md's documented commit format.
The legacy templates/git/commit.py uses the richer
[root_short:task_short] for the commit_for_task API path with full
traceability metadata; the gateway commit path is intentionally simpler
and stays aligned with the prompt-level promise.
Previous regex used a greedy [^|]* and required at least one / before
the host, so 'curl roboco-orchestrator:8000/api' (no scheme, no slash)
slipped through. Split into two simpler checks: (a) line starts with
curl/wget/http/https/httpie, AND (b) line contains a forbidden host.
Probe-verified: scheme-ful, scheme-less, and protocol-relative forms
all denied; external URLs still allowed; GitHub-specific deny still
fires first.
Prompts told agents internal API calls were denied; the guard only
denied GitHub. Combined with task 4 (X-Agent-Role enforcement) this
closes the manifest-bypass loophole.