mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user