fix(runtime): auto-submit is unconditional; refusals brief the fallback PM

The PR-gate turn cut (#295) already auto-submitted assembled tasks, but
its refusals fell back to a PM spawn silently -- in production the PM
turns the cut was meant to remove kept happening with no visible cause
(live case: an AC-coverage refusal). The flag is gone (the fallback is
the safety net), the umbrella/branchless exclusion uses the canonical
batch predicates, and every refusal reason now rides into the spawned
PM's prompt so the fallback starts informed.
This commit is contained in:
Renn F
2026-07-08 22:16:50 +02:00
parent f48d088c08
commit 5e7c498d00
11 changed files with 209 additions and 91 deletions
+1
View File
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Changed
- **The PR-gate turn cut is now unconditional — the kill-switch is gone.** `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (0.17.0) has been removed from `roboco/config.py`; `_auto_submit_target` no longer checks a flag at all. The turn cut IS the flow now: once every child of an assembled parent is terminal, the orchestrator always runs `submit_up`/`submit_root` system-side as the owning PM before ever considering a PM spawn. The existing gate-rejection fallback (branchless/umbrella parent, a gate refusal, a transport error) is the sole safety net — no config switch sits on top of it. The fallback closure prompt now also carries the exact refusal reason (`_build_pm_closure_prompt`'s new `auto_submit_reason`), so a PM that does get spawned isn't re-running evidence-gathering to rediscover why blind.
- **Leaner agent/orchestrator images (~1.65GB less, cache-stable deploys).** Playwright + its Chromium and system libs are gone from `agent-dev-fe`/`agent-qa-fe` (~770MB each — verified unused repo-wide: panel tests are vitest, the e2e harness is scripted Python; browser-based FE QA is a designed follow-up, and the re-add is two lines scoped to `chromium-headless-shell` in the QA image only). The `agent-grok` image drops a redundant `chown -R` that duplicated the entire 149MB CLI tree into a second layer. The runner-stage `/app` COPY in `agent-base` and `orchestrator` is split `.venv`-first/source-last, so a source-only deploy re-layers ~13MB instead of ~380MB per image, and `agent-base`'s single 813MB apt+node+claude-code RUN is split so a CLI version bump no longer re-downloads the OS/node layer. Hygiene: gitignored `docs/internal/` no longer leaks into the orchestrator image from a working-tree build, and the `uv` helper image is pinned (`0.11`) instead of `:latest`. All four rebuilt images pass runtime probes (claude/git/jq/node/uv/pnpm/grok binaries, `import roboco`, docs/alembic/agents trees present); cache-stability proven by rebuild log (`.venv` layer CACHED across a source-only change).
## [0.19.0] - 2026-07-08
+3 -1
View File
@@ -208,6 +208,8 @@ backlog -> pending -> claimed -> in_progress -> [blocked|paused] -> verifying
**In-path PR-review gate** (`awaiting_pr_review`): each assembled PR is reviewed before the PM merges. The cell PM's `submit_up` opens the cell→root PR and the Main PM's `submit_root` opens the root→master PR; both enter `awaiting_pr_review`, where a reviewer `pr_pass`es it on to `awaiting_pm_review` or `pr_fail`s it back to `needs_revision` — the merge-level reject the PM otherwise lacks. Leaf dev tasks and branchless coordination roots skip the gate.
**PM-turn elimination (auto-submit).** When every child of an assembled, PR-bearing parent goes terminal, the orchestrator's closure dispatcher (`_maybe_spawn_pm_closure``_closure_handled_without_pm``_try_auto_submit`) runs the real `submit_up`/`submit_root` gate system-side as the owning PM instead of spawning the PM for that turn — same verb, same guards (ownership, notes, journal:decision, subtasks-terminal, parent-AC coverage, branch), authorized via the internal API with the PM's own identity headers. This is unconditional — the turn cut IS the flow, no kill-switch. Success lands the task on `awaiting_pr_review` with an audited `task.auto_submitted` row and no PM spawn; ANY refusal (branchless/umbrella parent, a gate rejection — freshness, AC coverage, a subtask-terminal race — or a transport error) falls back to spawning the PM exactly as before — that fallback is the sole safety net — with the refusal reason threaded into the PM's closure prompt so it isn't rediscovering it blind.
**States:**
| State | Description |
|-------|-------------|
@@ -239,7 +241,7 @@ All status transitions are validated through the enforcement layer. Key restrict
| `awaiting_qa``awaiting_documentation` (pass) | QA only |
| `awaiting_qa``needs_revision` (fail) | QA only |
| `awaiting_documentation``awaiting_pm_review` | Documenter or Developer (parallel completion) |
| `in_progress``awaiting_pr_review` (submit_up / submit_root) | PM roles (opens the assembled cell→root / root→master PR) |
| `in_progress``awaiting_pr_review` (submit_up / submit_root) | PM roles (opens the assembled cell→root / root→master PR) — or the orchestrator running the same verb system-side as the owning PM once all children are terminal |
| `awaiting_pr_review``awaiting_pm_review` (pr_pass) | PR reviewer only |
| `awaiting_pr_review``needs_revision` (pr_fail) | PR reviewer only |
| `awaiting_pm_review``completed` | PM roles only |
+4 -4
View File
@@ -4707,8 +4707,8 @@ The AgentOrchestrator is the runtime brain of RoboCo: it owns the per-agent Dock
| AgentOrchestrator._pm_respawn_should_gate | method | roboco/runtime/orchestrator.py:8915 | Per-(slug,task) respawn circuit breaker; tracing_gap rule-following resets (bounded) + durable persist; CEO notify once when tripped. |
| AgentOrchestrator._handle_pm_assigned_task | method | roboco/runtime/orchestrator.py:9084 | Spawn/respawn the PM for an assigned coordination root subject to the respawn gate. |
| AgentOrchestrator._AUTO_SUBMIT_VERB_BY_ROLE | ClassVar[dict] | roboco/runtime/orchestrator.py:10650 | Wave-1 PR-gate turn cut: maps cell_pm -> (cell_pm, submit_up) and main_pm -> (main_pm, submit_root), the flow route+verb that assembles the parent's PR for each coordinator role. |
| AgentOrchestrator._auto_submit_target | method | roboco/runtime/orchestrator.py:10655 | Resolve (role, route, verb, pm_uuid) for an auto-submittable parent; None when `pr_gate_auto_submit_enabled` is off, the parent is branchless coordination (no PR to assemble), the role has no submit verb, or no PM identity resolves. |
| AgentOrchestrator._try_auto_submit | method | roboco/runtime/orchestrator.py:10677 | Wave-1 PR-gate turn cut: run the owning PM's submit_up/submit_root verb system-side via the internal flow API (no PM spawn) when every child of an assembled parent is terminal; True on gate acceptance (fires `task.auto_submitted` audit + `_mark_task_handled`), False on ANY refusal (flag off, branchless parent, unmapped role, gate rejection, or transport error) so the caller falls back to the classic PM closure spawn. |
| AgentOrchestrator._auto_submit_target | method | roboco/runtime/orchestrator.py:10655 | Resolve (role, route, verb, pm_uuid) for an auto-submittable parent; None when the parent is branchless coordination (no PR to assemble), the role has no submit verb, or no PM identity resolves. Unconditional otherwise — no flag (the `pr_gate_auto_submit_enabled` kill-switch was removed post-0.19.0). |
| AgentOrchestrator._try_auto_submit | method | roboco/runtime/orchestrator.py:10677 | PR-gate turn cut: run the owning PM's submit_up/submit_root verb system-side via the internal flow API (no PM spawn) when every child of an assembled parent is terminal; True on gate acceptance (fires `task.auto_submitted` audit + `_mark_task_handled`), False on ANY refusal (branchless parent, unmapped role, gate rejection, or transport error) so the caller falls back to the classic PM closure spawn with the refusal reason threaded into its prompt. |
| AgentOrchestrator._closure_handled_without_pm | method | roboco/runtime/orchestrator.py:10740 | Recover an auto-paused/blocked parent's status first (so the next actor lands on an actionable in_progress parent), then try `_try_auto_submit`; True skips the PM closure spawn entirely. |
| AgentOrchestrator._maybe_spawn_pm_closure | method | roboco/runtime/orchestrator.py:10768 | If this parent task is ready for closure, try the system-side submit turn cut first (`_closure_handled_without_pm`); only spawn its PM when that declines (debounced via _is_recently_paused). |
| AgentOrchestrator._dispatch_dev_work | method | roboco/runtime/orchestrator.py:9733 | Fetch pending/needs_revision/in_progress/claimed code tasks and route each through _dev_dispatch_one. |
@@ -4780,7 +4780,7 @@ stateDiagram-v2
- Rate-limit/overload park-and-probe: `_park_provider_unavailable` (+ grok 75/78 variants) → `_rate_limit_probe_loop` (30s) → `_on_probe_success`/`_on_probe_failure``resolve_wait`
- Gateway-health: `_probe_gateway_health``_gateway_broken_past_grace``_maybe_recover_broken_gateway` (kill+evict)
- Respawn tracker: `_pm_respawn_should_gate``_persist_respawn_record` (durable upsert) + `restore_respawn_tracker` at startup
- PM closure / PR-gate turn cut (wave 1): `_maybe_spawn_pm_closure``_closure_handled_without_pm` (recover paused/blocked status) → `_try_auto_submit` (system-side submit_up/submit_root via `_AUTO_SUBMIT_VERB_BY_ROLE`, gated by `pr_gate_auto_submit_enabled`); only a gate refusal falls through to an actual PM spawn
- PM closure / PR-gate turn cut: `_maybe_spawn_pm_closure``_closure_handled_without_pm` (recover paused/blocked status) → `_try_auto_submit` (system-side submit_up/submit_root via `_AUTO_SUBMIT_VERB_BY_ROLE`, unconditional — no flag); only a gate refusal falls through to an actual PM spawn, with the refusal reason threaded into its prompt
- Default-off loops: `_self_heal_loop`, `_ci_watch_loop`, `_dep_update_loop`, `_release_manager_loop`, `_strategy_engine_loop`, `_external_pr_poll_loop`, `_x_mentions_poll_loop`, `_roadmap_engine_loop`
- Interactive: `start_intake_session` / `_spawn_intake_container` / `_spawn_secretary_container` / `_reap_idle_interactive_sessions`
- Shutdown (`stop`): cancel loops → `stop_agent(release_claim=True)` (skip provider-parked) → `_drain_bg_tasks``_stopped`
@@ -4815,7 +4815,7 @@ stateDiagram-v2
- `ROBOCO_ROADMAP_ENGINE_ENABLED` (+ `_interval_seconds` default 604800 / `_min_items_per_cycle` / `_max_items_per_cycle`, default off) — gates `_roadmap_engine_loop`.
- `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED` (+ `_interval_seconds` default 259200/3d, default off, sub-switch of `x_engine_enabled`) — gates `_x_feature_spotlight_loop`.
- `ROBOCO_FABLE_MODE_ENABLED` (default off) — gates `_fable_hook_groups` (Claude-path hook install) and, via `roboco/agents/factories/_base.py`, the `fable_doctrine_layer` prompt layer; off = byte-for-byte unchanged spawn path.
- `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (`pr_gate_auto_submit_enabled`, default **True**) — wave-1 PR-gate turn cut: when every child of an assembled parent is terminal, `_try_auto_submit` runs the owning PM's submit_up/submit_root gate system-side instead of spawning the PM for that turn; a gate rejection (freshness/integrity) falls back to the classic PM closure spawn.
- No flag — the PR-gate turn cut is unconditional: when every child of an assembled parent is terminal, `_try_auto_submit` always runs the owning PM's submit_up/submit_root gate system-side instead of spawning the PM for that turn; a gate rejection (freshness/integrity/AC-coverage/race) falls back to the classic PM closure spawn (the sole safety net), with the reason threaded into the PM's closure prompt. The `pr_gate_auto_submit_enabled` kill-switch that gated this through 0.19.0 has been removed.
## Gotchas
- Respawn-tracker rows are restored at startup and re-stamped to live values; terminal/missing-task rows are evicted, so a stale row can't gate a fresh task. The upsert is race-free but fire-and-forget persists are ordered by `_respawn_persist_lock` acquisition (= schedule order); a stale persist resolving after a fresh one would otherwise re-burn the strike threshold on restart.
+4 -4
View File
@@ -97,8 +97,8 @@ The AgentOrchestrator is the runtime brain of RoboCo: it owns the per-agent Dock
| AgentOrchestrator._pm_respawn_should_gate | method | roboco/runtime/orchestrator.py:8915 | Per-(slug,task) respawn circuit breaker; tracing_gap rule-following resets (bounded) + durable persist; CEO notify once when tripped. |
| AgentOrchestrator._handle_pm_assigned_task | method | roboco/runtime/orchestrator.py:9084 | Spawn/respawn the PM for an assigned coordination root subject to the respawn gate. |
| AgentOrchestrator._AUTO_SUBMIT_VERB_BY_ROLE | ClassVar[dict] | roboco/runtime/orchestrator.py:10650 | Wave-1 PR-gate turn cut: maps cell_pm -> (cell_pm, submit_up) and main_pm -> (main_pm, submit_root), the flow route+verb that assembles the parent's PR for each coordinator role. |
| AgentOrchestrator._auto_submit_target | method | roboco/runtime/orchestrator.py:10655 | Resolve (role, route, verb, pm_uuid) for an auto-submittable parent; None when `pr_gate_auto_submit_enabled` is off, the parent is branchless coordination (no PR to assemble), the role has no submit verb, or no PM identity resolves. |
| AgentOrchestrator._try_auto_submit | method | roboco/runtime/orchestrator.py:10677 | Wave-1 PR-gate turn cut: run the owning PM's submit_up/submit_root verb system-side via the internal flow API (no PM spawn) when every child of an assembled parent is terminal; True on gate acceptance (fires `task.auto_submitted` audit + `_mark_task_handled`), False on ANY refusal (flag off, branchless parent, unmapped role, gate rejection, or transport error) so the caller falls back to the classic PM closure spawn. |
| AgentOrchestrator._auto_submit_target | method | roboco/runtime/orchestrator.py:10655 | Resolve (role, route, verb, pm_uuid) for an auto-submittable parent; None when the parent is branchless coordination (no PR to assemble), the role has no submit verb, or no PM identity resolves. Unconditional otherwise — no flag (the `pr_gate_auto_submit_enabled` kill-switch was removed post-0.19.0). |
| AgentOrchestrator._try_auto_submit | method | roboco/runtime/orchestrator.py:10677 | PR-gate turn cut: run the owning PM's submit_up/submit_root verb system-side via the internal flow API (no PM spawn) when every child of an assembled parent is terminal; True on gate acceptance (fires `task.auto_submitted` audit + `_mark_task_handled`), False on ANY refusal (branchless parent, unmapped role, gate rejection, or transport error) so the caller falls back to the classic PM closure spawn with the refusal reason threaded into its prompt. |
| AgentOrchestrator._closure_handled_without_pm | method | roboco/runtime/orchestrator.py:10740 | Recover an auto-paused/blocked parent's status first (so the next actor lands on an actionable in_progress parent), then try `_try_auto_submit`; True skips the PM closure spawn entirely. |
| AgentOrchestrator._maybe_spawn_pm_closure | method | roboco/runtime/orchestrator.py:10768 | If this parent task is ready for closure, try the system-side submit turn cut first (`_closure_handled_without_pm`); only spawn its PM when that declines (debounced via _is_recently_paused). |
| AgentOrchestrator._dispatch_dev_work | method | roboco/runtime/orchestrator.py:9733 | Fetch pending/needs_revision/in_progress/claimed code tasks and route each through _dev_dispatch_one. |
@@ -170,7 +170,7 @@ stateDiagram-v2
- Rate-limit/overload park-and-probe: `_park_provider_unavailable` (+ grok 75/78 variants) → `_rate_limit_probe_loop` (30s) → `_on_probe_success`/`_on_probe_failure``resolve_wait`
- Gateway-health: `_probe_gateway_health``_gateway_broken_past_grace``_maybe_recover_broken_gateway` (kill+evict)
- Respawn tracker: `_pm_respawn_should_gate``_persist_respawn_record` (durable upsert) + `restore_respawn_tracker` at startup
- PM closure / PR-gate turn cut (wave 1): `_maybe_spawn_pm_closure``_closure_handled_without_pm` (recover paused/blocked status) → `_try_auto_submit` (system-side submit_up/submit_root via `_AUTO_SUBMIT_VERB_BY_ROLE`, gated by `pr_gate_auto_submit_enabled`); only a gate refusal falls through to an actual PM spawn
- PM closure / PR-gate turn cut: `_maybe_spawn_pm_closure``_closure_handled_without_pm` (recover paused/blocked status) → `_try_auto_submit` (system-side submit_up/submit_root via `_AUTO_SUBMIT_VERB_BY_ROLE`, unconditional — no flag); only a gate refusal falls through to an actual PM spawn, with the refusal reason threaded into its prompt
- Default-off loops: `_self_heal_loop`, `_ci_watch_loop`, `_dep_update_loop`, `_release_manager_loop`, `_strategy_engine_loop`, `_external_pr_poll_loop`, `_x_mentions_poll_loop`, `_roadmap_engine_loop`
- Interactive: `start_intake_session` / `_spawn_intake_container` / `_spawn_secretary_container` / `_reap_idle_interactive_sessions`
- Shutdown (`stop`): cancel loops → `stop_agent(release_claim=True)` (skip provider-parked) → `_drain_bg_tasks``_stopped`
@@ -205,7 +205,7 @@ stateDiagram-v2
- `ROBOCO_ROADMAP_ENGINE_ENABLED` (+ `_interval_seconds` default 604800 / `_min_items_per_cycle` / `_max_items_per_cycle`, default off) — gates `_roadmap_engine_loop`.
- `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED` (+ `_interval_seconds` default 259200/3d, default off, sub-switch of `x_engine_enabled`) — gates `_x_feature_spotlight_loop`.
- `ROBOCO_FABLE_MODE_ENABLED` (default off) — gates `_fable_hook_groups` (Claude-path hook install) and, via `roboco/agents/factories/_base.py`, the `fable_doctrine_layer` prompt layer; off = byte-for-byte unchanged spawn path.
- `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (`pr_gate_auto_submit_enabled`, default **True**) — wave-1 PR-gate turn cut: when every child of an assembled parent is terminal, `_try_auto_submit` runs the owning PM's submit_up/submit_root gate system-side instead of spawning the PM for that turn; a gate rejection (freshness/integrity) falls back to the classic PM closure spawn.
- No flag — the PR-gate turn cut is unconditional: when every child of an assembled parent is terminal, `_try_auto_submit` always runs the owning PM's submit_up/submit_root gate system-side instead of spawning the PM for that turn; a gate rejection (freshness/integrity/AC-coverage/race) falls back to the classic PM closure spawn (the sole safety net), with the reason threaded into the PM's closure prompt. The `pr_gate_auto_submit_enabled` kill-switch that gated this through 0.19.0 has been removed.
## Gotchas
- Respawn-tracker rows are restored at startup and re-stamped to live values; terminal/missing-task rows are evicted, so a stale row can't gate a fresh task. The upsert is race-free but fire-and-forget persists are ordered by `_respawn_persist_lock` acquisition (= schedule order); a stale persist resolving after a fresh one would otherwise re-burn the strike threshold on restart.
+3 -2
View File
@@ -76,14 +76,15 @@ Agents whose provider is `GROK` run xAI's official `grok` CLI. Auth is the host
## Feature flags
Env-gated subsystems. Most are default-off; `ROBOCO_OVERLOAD_BREAK_ENABLED`, `ROBOCO_RESEARCH_ENABLED`, `ROBOCO_PROVISIONING_ENABLED`, and `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` ship default-**on**. Each takes effect on the next backend restart; the panel's Settings → Feature Flags card toggles the panel-exposed ones (`roboco/services/settings.py`'s `FEATURE_FLAGS`) without hand-editing env — a few security/topology flags below are env-only by design and are called out as such.
Env-gated subsystems. Most are default-off; `ROBOCO_OVERLOAD_BREAK_ENABLED`, `ROBOCO_RESEARCH_ENABLED`, and `ROBOCO_PROVISIONING_ENABLED` ship default-**on**. Each takes effect on the next backend restart; the panel's Settings → Feature Flags card toggles the panel-exposed ones (`roboco/services/settings.py`'s `FEATURE_FLAGS`) without hand-editing env — a few security/topology flags below are env-only by design and are called out as such.
The PR-gate turn cut (when every child of an assembled parent is terminal, `_try_auto_submit` runs the real `submit_up` / `submit_root` system-side as the owning PM instead of spawning the PM for that turn) is **unconditional** — no flag, no kill-switch. A gate rejection (freshness/integrity/AC-coverage/a subtask-terminal race) or transport error falls back to the classic PM closure spawn with the rejection reason threaded into the PM's prompt; that fallback is the sole safety net. Each auto-submit leaves a `task.auto_submitted` audit row.
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_CONVENTIONS_ENABLED` | `false` | Architectural Conventions Standard: auto-scaffold `.roboco/conventions.yml`, inject the architecture map, attach baseline constraints, and block `i_am_done` / `pr_pass` on block-level placement and hygiene violations. Off = fully inert. |
| `ROBOCO_TOOLCHAIN_MATCH_ENABLED` | `false` | Provision each agent workspace with the target project's Python (resolved from its `requires-python` / `.python-version`) and block delivery gates when the suite cannot be executed under it. Off = today's behavior. |
| `ROBOCO_OVERLOAD_BREAK_ENABLED` | `true` | Park a provider on a persistent model-API overload (HTTP 529 / 500 / 503) the same way a 429 is parked — queue its spawns and probe until it recovers — instead of crash-retrying into the overload. Off = crash-retry behavior. |
| `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` | `true` | PR-gate turn cut: when every child of an assembled parent is terminal, run the real `submit_up` / `submit_root` system-side as the owning PM (`_try_auto_submit`) instead of spawning the PM for that turn — the submit's substance (freshness rebase, integrity check, PR open) is deterministic gate code. A gate rejection falls back to the classic PM closure spawn; the PM keeps the judgment turns (merge, revision). Each auto-submit leaves a `task.auto_submitted` audit row. Off = every closure spawns the PM to submit. |
| `ROBOCO_SPAWN_PREFLIGHT_ENABLED` | `false` | Refuse to spawn a non-human delivery role absent from `GATEWAY_ENABLED_ROLES` (no manifest → can never claim → would respawn on the same task forever); refuse + alert the overseer once instead. Inert in practice (all delivery roles are gateway-enabled). Armed on the NAS composes. |
| `ROBOCO_NOTIFICATION_SPAWN_COOLDOWN_SECONDS` | `600` | Cross-tick damper for notification-triggered spawns (escalation/approval/audit/a2a — task-less, so the readiness gate and respawn breaker never see them): one spawn per (agent, notification) per window; the notification stays pending so the next window retries. `0` = legacy every-tick respawn. |
| `ROBOCO_SANDBOX_DB_ENABLED` | `false` | Sandboxed per-agent-spawn test DB/Redis/Mongo: throwaway sibling containers provisioned from the engine registry in `roboco/models/sandbox.py` (postgres:16-alpine / redis:8-alpine / mongo:8), per-project opt-in. The valid-service set is `VALID_SANDBOX_SERVICES` (registry-derived). See "Sandboxed Dev DB/Redis/Mongo" below and `docs/rag/architecture/sandbox-db.md`. |
+1 -1
View File
@@ -153,7 +153,7 @@ When every subtask of your cell-scoped parent is terminal (each leaf PR merged i
Re-`submit_up` is refused if the assembled PR is **unchanged** since the last `pr_fail` (no new commits on it) — it stops a re-submit-the-same-PR loop. Fix the issues and commit before re-submitting.
**You may never even see this turn.** When every subtask is terminal, the orchestrator's closure dispatcher first tries `_try_auto_submit`: if `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (default on) and the parent has a branch + project, it runs the real `submit_up` system-side as you, skipping your spawn for that turn — the submit's substance (freshness rebase, integrity check, PR open) is deterministic gate code, not judgment. A gate rejection (freshness/integrity) falls back to spawning you for the classic closure turn instead. Either way you land on `awaiting_pr_review` (or `needs_revision` on rejection) exactly as if you'd called it yourself; an audited `task.auto_submitted` event marks the cut.
**You may never even see this turn.** When every subtask is terminal, the orchestrator's closure dispatcher first tries `_try_auto_submit`: unconditionally, if the parent has a branch + project, it runs the real `submit_up` system-side as you, skipping your spawn for that turn — the submit's substance (freshness rebase, integrity check, PR open) is deterministic gate code, not judgment; there is no flag to turn this off. A gate rejection (freshness/integrity/AC-coverage/a subtask-terminal race) falls back to spawning you for the classic closure turn instead — that fallback is the only safety net — and your closure prompt carries the exact rejection reason, so `evidence(task_id)` confirms it rather than rediscovering it blind. Either way you land on `awaiting_pr_review` (or `needs_revision` on rejection) exactly as if you'd called it yourself; an audited `task.auto_submitted` event marks the cut.
You merge your own cell→root PR — the Main PM does **not** merge your cell branch. The Main PM owns the **root** task: once every cell's parent is terminal, it runs the same gate one level up (`submit_root` → main reviewer → escalate to CEO) and only the CEO merges to `master`. You never open or merge a master PR yourself.
+1 -1
View File
@@ -112,7 +112,7 @@ master ← feature/main_pm/{root} ← feature/{cell}/{root}/{cell-pm} ←
- A cell PM's `complete` merges a leaf PR into its cell branch; after the cell gate, its `complete` merges the cell→root PR into your root branch. You do not merge cell branches.
- Once every cell's parent is terminal, **`submit_root(root_task_id, notes)`** opens the root→master PR and enters the in-path gate (`awaiting_pr_review`). The **main PR reviewer** checks the assembled root diff: `pr_pass``awaiting_pm_review`; `pr_fail``needs_revision` (owned by you, fix + re-`submit_root`). The reviewer's verdict + issues are carried in your task handoff, and re-`submit_root` is refused if the root PR is **unchanged** since the last `pr_fail` — fix and commit before re-submitting.
- **The system may call `submit_root` for you.** When every cell's parent is terminal, the orchestrator's closure dispatcher tries `_try_auto_submit` first: with `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (default on) and a branch + project on the root, it runs `submit_root` system-side as you — skipping your spawn for that turn, since the submit's substance is deterministic gate code, not judgment. A gate rejection (freshness/integrity) falls back to spawning you for the classic closure turn. Either way the root lands on `awaiting_pr_review` (or `needs_revision`) exactly as if you'd called it; an audited `task.auto_submitted` event marks the cut. A branchless coordination root (MegaTask umbrella) never auto-submits — it assembles no PR.
- **The system may call `submit_root` for you.** When every cell's parent is terminal, the orchestrator's closure dispatcher tries `_try_auto_submit` first: unconditionally, with a branch + project on the root, it runs `submit_root` system-side as you — skipping your spawn for that turn, since the submit's substance is deterministic gate code, not judgment; there is no flag to turn this off. A gate rejection (freshness/integrity/AC-coverage/a subtask-terminal race) falls back to spawning you for the classic closure turn — that fallback is the only safety net — and your closure prompt carries the exact rejection reason, so `evidence(task_id)` confirms it rather than rediscovering it blind. Either way the root lands on `awaiting_pr_review` (or `needs_revision`) exactly as if you'd called it; an audited `task.auto_submitted` event marks the cut. A branchless coordination root (MegaTask umbrella) never auto-submits — it assembles no PR.
- After `pr_pass`, `complete(root_task_id, notes)` escalates the root to the CEO (`awaiting_ceo_approval`) — it does **not** merge. A branchless coordination root (product fan-out, no repo) skips the gate and `complete` escalates directly.
- The CEO approves and merges the root→master PR from the panel. Only the CEO ever merges to `master`.
-12
View File
@@ -275,18 +275,6 @@ class Settings(BaseSettings):
"Off => legacy behavior (respawn until the strike breaker trips)."
),
)
pr_gate_auto_submit_enabled: bool = Field(
default=True,
description=(
"When every child of an assembled parent is terminal, run the "
"submit_up/submit_root gate system-side as the owning PM instead "
"of spawning the PM for that turn — the submit's substance "
"(freshness rebase, integrity check, PR open) is deterministic "
"gate code. A gate rejection falls back to the classic PM "
"closure spawn; the PM keeps the judgment turns (merge, "
"revision). Off => every closure spawns the PM to submit."
),
)
gateway_health_enabled: bool = Field(
default=True,
description=(
+74 -26
View File
@@ -11604,15 +11604,16 @@ Start now: evidence(task_id="{task_id}")
) -> tuple[str, str, str, str] | None:
"""(role, route, verb, pm_uuid) when this parent is auto-submittable.
None when the flag is off, the parent is branchless coordination (a
MegaTask umbrella assembles no PR), the role has no submit verb, or
no PM identity can be resolved.
Unconditional no kill-switch: the PM-turn cut IS the flow. None
when the parent is branchless coordination (a MegaTask umbrella
assembles no PR), the role has no submit verb, or no PM identity
can be resolved; the caller falls back to the classic PM spawn.
"""
role = get_agent_role(pm_slug) or ""
pair = self._AUTO_SUBMIT_VERB_BY_ROLE.get(role)
pm_uuid = str(task.get("assigned_to") or AGENT_UUIDS.get(pm_slug) or "")
if (
not settings.pr_gate_auto_submit_enabled
_is_coordination_task(task)
or not task.get("branch_name")
or not task.get("project_id")
or pair is None
@@ -11621,24 +11622,45 @@ Start now: evidence(task_id="{task_id}")
return None
return (role, pair[0], pair[1], pm_uuid)
@staticmethod
def _auto_submit_rejection_reason(body: Any) -> str:
"""Human-readable reason for a gate refusal, for the PM's prompt.
``message`` already folds ``remediate`` in for tracing_gap envelopes
(see Envelope._missing_message); append remediate only when it adds
information ``message`` doesn't already carry.
"""
if not isinstance(body, dict):
return f"unexpected gate response: {body!r}"
error = body.get("error") or "rejected"
message = body.get("message") or "no message"
remediate = body.get("remediate")
reason = f"{error}: {message}"
if remediate and remediate not in message:
reason = f"{reason} ({remediate})"
return reason
async def _try_auto_submit(
self, client: httpx.AsyncClient, task: dict[str, Any], pm_slug: str
) -> bool:
) -> tuple[bool, str | None]:
"""Submit an assembled, all-children-terminal parent to the PR gate
WITHOUT spawning its PM the turn's substance (freshness rebase,
integrity check, PR open) is deterministic gate code, so the real
submit verb is run through the internal API as the owning PM.
Returns True when the gate accepted (the reviewer dispatch takes it
from awaiting_pr_review); False on ANY refusal flag off, a
branchless coordination parent (a MegaTask umbrella assembles no
PR), an unmapped role, a gate rejection (freshness/integrity the
PM turn is then genuinely needed), or a transport error and the
caller falls back to the classic PM closure spawn.
Returns ``(True, None)`` when the gate accepted (the reviewer
dispatch takes it from awaiting_pr_review). Returns ``(False,
reason)`` on ANY refusal branchless / unmapped role (``reason``
is ``None``, nothing to report), a gate rejection
(freshness/integrity/AC-coverage/race the PM turn is then
genuinely needed), or a transport error (``reason`` is a
human-readable string) the caller falls back to the classic PM
closure spawn and threads ``reason`` into its prompt so the
respawned PM isn't rediscovering the refusal blind.
"""
target = self._auto_submit_target(task, pm_slug)
if target is None:
return False
return False, None
role, role_path, verb, pm_uuid = target
task_id = str(task.get("id"))
notes = (
@@ -11655,20 +11677,22 @@ Start now: evidence(task_id="{task_id}")
)
body = resp.json()
except Exception as e:
reason = f"auto-submit transport error: {e}"
logger.warning(
"Auto-submit transport failure; falling back to PM closure spawn",
task_id=task_id,
error=str(e),
)
return False
return False, reason
if not isinstance(body, dict) or body.get("error"):
reason = self._auto_submit_rejection_reason(body)
logger.info(
"Auto-submit rejected by the gate; PM closure spawn proceeds",
task_id=task_id,
error=(body or {}).get("error") if isinstance(body, dict) else body,
message=(body or {}).get("message") if isinstance(body, dict) else None,
)
return False
return False, reason
logger.info(
"Assembled parent auto-submitted to the PR gate (PM turn skipped)",
task_id=task_id,
@@ -11682,7 +11706,7 @@ Start now: evidence(task_id="{task_id}")
details={"verb": verb, "auto": True},
)
self._mark_task_handled(task_id)
return True
return True, None
async def _closure_handled_without_pm(
self,
@@ -11690,7 +11714,7 @@ Start now: evidence(task_id="{task_id}")
task: dict[str, Any],
task_id: str,
pm_id: str,
) -> bool:
) -> tuple[bool, str | None]:
"""Recover the parent's status, then try the submit turn cut.
The parent auto-paused when its PM idled (by design) resume it
@@ -11706,11 +11730,9 @@ Start now: evidence(task_id="{task_id}")
await self._auto_resume_paused_parent(client, task_id)
elif parent_status == "blocked":
await self._auto_recover_blocked_parent(client, task_id)
return parent_status in (
"claimed",
"in_progress",
"paused",
) and await self._try_auto_submit(client, task, pm_id)
if parent_status not in ("claimed", "in_progress", "paused"):
return False, None
return await self._try_auto_submit(client, task, pm_id)
async def _maybe_spawn_pm_closure(
self, client: httpx.AsyncClient, task: dict[str, Any]
@@ -11753,10 +11775,15 @@ Start now: evidence(task_id="{task_id}")
# A parent that is `blocked` at closure (all descendants
# terminal) is an errant/stale block — recover it symmetrically so
# the chain can't wedge forever waiting for a PM to manually unblock.
if await self._closure_handled_without_pm(client, task, task_id, pm_id):
handled, auto_submit_reason = await self._closure_handled_without_pm(
client, task, task_id, pm_id
)
if handled:
return
prompt = self._build_pm_closure_prompt(task, descendants)
prompt = self._build_pm_closure_prompt(
task, descendants, auto_submit_reason=auto_submit_reason
)
await self.spawn_agent(
agent_id=pm_id,
task_id=task_id,
@@ -11822,12 +11849,33 @@ Start now: evidence(task_id="{task_id}")
return []
def _build_pm_closure_prompt(
self, task: dict[str, Any], subtasks: list[dict[str, Any]]
self,
task: dict[str, Any],
subtasks: list[dict[str, Any]],
*,
auto_submit_reason: str | None = None,
) -> str:
"""Prompt for PM closing their own parent task (subtasks terminal)."""
"""Prompt for PM closing their own parent task (subtasks terminal).
``auto_submit_reason`` is set when the orchestrator already tried
``_try_auto_submit`` on this PM's behalf and the gate refused it —
threading the refusal into the prompt so the respawned PM doesn't
re-run evidence-gathering from scratch to rediscover it blind.
"""
task_id = task.get("id", "unknown")
title = task.get("title", "Untitled")
team = task.get("team", "unknown")
auto_submit_note = (
""
if not auto_submit_reason
else (
"\nNOTE: The system already attempted to auto-submit this "
f"closure on your behalf and the gate refused it: "
f"{auto_submit_reason}\nResolve the underlying issue before "
"calling submit_up/submit_root yourself — a stale race "
"(subtask flipped since) may just need a retry.\n"
)
)
subtask_summary = "\n".join(
f" - {st.get('title', 'Untitled')} ({st.get('status', 'unknown')})"
@@ -11865,7 +11913,7 @@ Start now: evidence(task_id="{task_id}")
return f"""You are closing YOUR OWN parent task. All subtasks are
terminal promote the merged work one level up the hierarchy.
{auto_submit_note}
TASK: {task_id}
TITLE: {title}
TEAM: {team}
+5 -4
View File
@@ -135,9 +135,10 @@ def test_pm_merge_chain_to_root_branch(e2e_stack: E2EStack) -> None:
def test_auto_submit_cuts_the_pm_turn(
e2e_stack: E2EStack, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The wave-1 turn cut, end to end: no agent calls submit_up — the
"""The turn cut, end to end: no agent calls submit_up — the
orchestrator's ``_try_auto_submit`` drives the REAL submit verb through
the REAL API as the owning PM, and the gate chain continues unchanged."""
the REAL API as the owning PM, unconditionally, and the gate chain
continues unchanged."""
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
@@ -150,7 +151,6 @@ def test_auto_submit_cuts_the_pm_turn(
# --- the cut: the orchestrator submits system-side ----------------------
monkeypatch.setattr(settings, "api_url", stack.base_url)
monkeypatch.setattr(settings, "pr_gate_auto_submit_enabled", True)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._tick_handled_tasks = set()
orch._bg_tasks = set()
@@ -166,7 +166,8 @@ def test_auto_submit_cuts_the_pm_turn(
async def _go() -> bool:
async with httpx.AsyncClient(timeout=60) as client:
return await orch._try_auto_submit(client, cell_task_dict, "be-pm")
ok, _reason = await orch._try_auto_submit(client, cell_task_dict, "be-pm")
return ok
assert asyncio.run(_go()) is True, "auto-submit should accept a clean parent"
+113 -36
View File
@@ -4,9 +4,12 @@ When every child of an assembled parent is terminal, the orchestrator used
to spawn the PM just to call submit_up/submit_root a whole agent turn
whose substance (freshness rebase, integrity check, PR open) is
deterministic gate code. ``_try_auto_submit`` runs the REAL submit verb
through the internal API as the owning PM; only a gate rejection falls
back to the classic PM closure spawn. The PM's remaining turn is the one
that needs judgment: the final merge (or the revision).
through the internal API as the owning PM, unconditionally this IS the
flow, not an opt-in switch. Only a gate rejection falls back to the classic
PM closure spawn. The PM's remaining turn is the one that needs judgment:
the final merge (or the revision) and when it does fall back, the
rejection reason rides the closure prompt so the PM isn't rediscovering it
blind.
"""
from __future__ import annotations
@@ -15,7 +18,6 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AGENT_UUIDS, AgentOrchestrator
# The commit/notes validator's minimum substantive length.
@@ -47,14 +49,13 @@ def _client(envelope: dict[str, Any]) -> MagicMock:
@pytest.mark.asyncio
async def test_cell_parent_auto_submits_as_owning_pm(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
async def test_cell_parent_auto_submits_as_owning_pm() -> None:
orch = _orch()
client = _client({"status": "awaiting_pr_review", "error": None})
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is True
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
assert ok is True
assert reason is None
(url,), kwargs = client.post.call_args
assert url == f"{orch._api_url}/v1/flow/cell_pm/submit_up"
@@ -65,79 +66,155 @@ async def test_cell_parent_auto_submits_as_owning_pm(
@pytest.mark.asyncio
async def test_main_pm_root_auto_submits_submit_root(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
async def test_main_pm_root_auto_submits_submit_root() -> None:
orch = _orch()
client = _client({"status": "awaiting_pr_review", "error": None})
task = {**_CELL_TASK, "team": "main_pm"}
assert await orch._try_auto_submit(client, task, "main-pm") is True
ok, reason = await orch._try_auto_submit(client, task, "main-pm")
assert ok is True
assert reason is None
(url,), kwargs = client.post.call_args
assert url == f"{orch._api_url}/v1/flow/main_pm/submit_root"
assert kwargs["headers"]["X-Agent-Role"] == "main_pm"
@pytest.mark.asyncio
async def test_branchless_parent_never_auto_submits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def test_branchless_parent_never_auto_submits() -> None:
"""A branchless coordination parent (MegaTask umbrella) assembles no PR."""
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client({"error": None})
task = {**_CELL_TASK, "branch_name": None}
assert await orch._try_auto_submit(client, task, "be-pm") is False
assert await orch._try_auto_submit(client, task, "be-pm") == (False, None)
client.post.assert_not_called()
@pytest.mark.asyncio
async def test_flag_off_is_inert(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", False)
async def test_umbrella_shape_never_auto_submits_even_with_a_stray_branch() -> None:
"""Belt-and-suspenders: _auto_submit_target consults the canonical
is_branchless_coordination predicate (via _is_coordination_task), not
just branch_name/project_id truthiness so a MegaTask umbrella shape
(batch_id set, no parent, no project/product) is excluded structurally,
matching every other git-exemption site in the codebase."""
orch = _orch()
client = _client({"error": None})
task = {
**_CELL_TASK,
"project_id": None,
"batch_id": "44444444-4444-4444-4444-444444444444",
"parent_task_id": None,
}
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is False
assert await orch._try_auto_submit(client, task, "be-pm") == (False, None)
client.post.assert_not_called()
@pytest.mark.asyncio
async def test_gate_rejection_falls_back_to_pm_spawn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def test_gate_rejection_falls_back_to_pm_spawn() -> None:
"""A rejection envelope (e.g. integrity/freshness refusal) means the PM
turn is genuinely needed auto-submit yields to the closure spawn."""
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
turn is genuinely needed auto-submit yields to the closure spawn, and
the reason is threaded back for the PM's prompt. This fallback is the
sole safety net there is no kill-switch on top of it."""
orch = _orch()
client = _client(
{"error": "invalid_state", "message": "assembled branch behind base"}
)
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is False
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
assert ok is False
assert reason == "invalid_state: assembled branch behind base"
client.post.assert_called_once()
@pytest.mark.asyncio
async def test_missing_assignment_falls_back_to_static_identity(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
async def test_ac_coverage_gate_rejection_reason_includes_remediate() -> None:
"""A parent-AC-coverage refusal (tracing_gap) folds remediate into the
reason so the PM's closure prompt names the uncovered criteria."""
orch = _orch()
remediate = (
"1 parent acceptance criteria are not covered by a completed subtask "
"before bubbling up: AC-2. Delegate a subtask to cover it."
)
client = _client(
{
"error": "tracing_gap",
"message": f"missing required tracing: [...]. {remediate}",
"remediate": remediate,
}
)
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
assert ok is False
assert reason is not None
assert "AC-2" in reason
# remediate is already folded into message by Envelope._missing_message;
# it must not be duplicated in the formatted reason.
assert reason.count("AC-2") == 1
@pytest.mark.asyncio
async def test_subtasks_not_terminal_race_falls_back_cleanly() -> None:
"""A subtask flips non-terminal between the closure dispatcher's check
and the submit call (the race) the gate's own re-check refuses and
auto-submit falls back cleanly, same as any other gate rejection."""
orch = _orch()
client = _client(
{
"error": "tracing_gap",
"message": (
"missing required tracing: ['subtasks not all terminal']. "
"all subtasks must be in completed/cancelled before bubbling "
"up. Non-terminal subtasks: ['55555555-5555-5555-5555-"
"555555555555']"
),
}
)
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
assert ok is False
assert reason is not None
assert "subtasks not all terminal" in reason
@pytest.mark.asyncio
async def test_missing_assignment_falls_back_to_static_identity() -> None:
orch = _orch()
client = _client({"status": "awaiting_pr_review", "error": None})
task = {**_CELL_TASK, "assigned_to": None}
assert await orch._try_auto_submit(client, task, "be-pm") is True
ok, reason = await orch._try_auto_submit(client, task, "be-pm")
assert ok is True
assert reason is None
(_, kwargs) = client.post.call_args
assert kwargs["headers"]["X-Agent-ID"] == AGENT_UUIDS["be-pm"]
@pytest.mark.asyncio
async def test_transport_error_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
async def test_transport_error_falls_back() -> None:
orch = _orch()
client = MagicMock()
client.post = AsyncMock(side_effect=RuntimeError("api down"))
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is False
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
assert ok is False
assert reason == "auto-submit transport error: api down"
def test_closure_prompt_threads_auto_submit_reason() -> None:
"""The fallback closure prompt surfaces the exact gate refusal so the
respawned PM doesn't re-run evidence-gathering to rediscover it blind."""
orch = _orch()
prompt = orch._build_pm_closure_prompt(
_CELL_TASK,
[],
auto_submit_reason="tracing_gap: parent AC-2 not covered",
)
assert "already attempted to auto-submit" in prompt
assert "tracing_gap: parent AC-2 not covered" in prompt
def test_closure_prompt_omits_note_when_no_reason() -> None:
orch = _orch()
prompt = orch._build_pm_closure_prompt(_CELL_TASK, [])
assert "already attempted to auto-submit" not in prompt