diff --git a/corpus/skills/cat-mode/SKILL.md b/corpus/skills/cat-mode/SKILL.md index 97a3caba..c826356b 100644 --- a/corpus/skills/cat-mode/SKILL.md +++ b/corpus/skills/cat-mode/SKILL.md @@ -138,6 +138,7 @@ isolated subagents and report back async rather than blocking on each one. durable artifact.** Separable and parallel is not authorization to fan out; a fan-out default cannot hand a subagent publishing authority the routing table never granted. Route that work through Execution routing. +- **Many PR stacks: one parallel unit per stack, never serial** (Invoker, else a worktree subagent each). - **A fork/subagent told to touch files must run in its own worktree, not the live checkout** — even when told "read-only." Scope wording is not filesystem isolation. diff --git a/corpus/skills/cat-mode/references/execution-routing.md b/corpus/skills/cat-mode/references/execution-routing.md index b5994260..dd105c7c 100644 --- a/corpus/skills/cat-mode/references/execution-routing.md +++ b/corpus/skills/cat-mode/references/execution-routing.md @@ -6,6 +6,7 @@ Catstack owns judgment and local fallback. Invoker owns durable plan submission, ## Decision +0. **More than one independent publishing unit** (several PR stacks to land or repair, several workflows): never serial in the parent chat. Invoker first, one workflow per unit; `subagent_worktree_per_unit` when Invoker is unavailable or the user directs subagents. `route_execution(units=N)` returns it; see [subagents.md](subagents.md). 1. **Invoker unavailable** (no `invoker_prepare_plan_review` / `invoker_submit_plan` tools): stay local — subagents, `loop-generator`, `land-stack`, current chat execution. 2. **Small local work** (one-file fix, short edit, read-only question): stay local even if Invoker is installed. Post-land wait until `MERGED`, merge-queue babysit, and already-named execution Backlog are **not** this bucket — they are `durable_parallel`. 3. **Approved plan or durable/parallel work** and Invoker MCP is available: delegate. If Invoker is missing, use a separate git worktree + PR stack. Do not park that work in the parent chat. diff --git a/corpus/skills/cat-mode/references/subagents.md b/corpus/skills/cat-mode/references/subagents.md index 8180b19c..5ed7b83d 100644 --- a/corpus/skills/cat-mode/references/subagents.md +++ b/corpus/skills/cat-mode/references/subagents.md @@ -36,6 +36,29 @@ definition whatever `produces` claims; and an empty or unrecognized `produces` raises rather than falling through to fan-out, because an output nobody declared is unchecked, not clean. +## Many stacks: parallel per unit, never serial + +Routing picks *who* runs publishing work; it never licenses running several +independent units one after another in the parent thread. When the work is N +independent PR stacks (landing, conflict repair, review fixes), each stack is +its own unit with its own worktree, and the units run in parallel: an Invoker +workflow per stack first, and one worktree-isolated subagent per stack as the +fallback when Invoker is unavailable or the user directs it +(`subagent_worktree_per_unit`). The per-unit subagent still inherits only the +scope the parent names, and its transcript is still grepped for writes. + +The failure shape: asked to land dozens of admin-bypass PRs across two repos, +the parent recommended working the ~20 rebases and review fixes "one at a +time" and started serially in one worktree, until the user asked for a +worktree subagent per stack. The routing table allowed it: `route_execution` +returned `local` for publishing work without Invoker at any unit count. + +Prior art: Amdahl's law — Gene M. Amdahl, "Validity of the single processor +approach to achieving large scale computing capabilities", AFIPS 1967, +https://doi.org/10.1145/1465482.1465560 — the serial fraction bounds the +whole job, so independent units forced through one thread set the finish +time. + ## Defer to the harness's routing skill The precedence above is catstack's fallback, not the owner. When a harness diff --git a/corpus/skills/cat-mode/scripts/route_execution.py b/corpus/skills/cat-mode/scripts/route_execution.py index 4fbf7028..2ad1bc08 100644 --- a/corpus/skills/cat-mode/scripts/route_execution.py +++ b/corpus/skills/cat-mode/scripts/route_execution.py @@ -11,8 +11,8 @@ from typing import Literal WorkKind = Literal["readonly", "small_local", "approved_plan", "durable_parallel"] -Route = Literal["local", "delegate_invoker"] -Delegation = Literal["local", "delegate_invoker", "subagent_fanout"] +Route = Literal["local", "delegate_invoker", "subagent_worktree_per_unit"] +Delegation = Literal["local", "delegate_invoker", "subagent_fanout", "subagent_worktree_per_unit"] DURABLE_ALIASES = frozenset({"post_land_babysit", "named_execution_backlog"}) @@ -36,6 +36,13 @@ "invoker_wait_for_workflow_or_status", ) +SUBAGENT_PER_UNIT_STEPS = ( + "one_worktree_per_unit", + "spawn_one_subagent_per_unit_in_parallel", + "collect_reports_async", + "grep_transcripts_for_writes", +) + SUBAGENT_FANOUT_STEPS = ( "spawn_worktree_isolated_subagents", "collect_reports_async", @@ -72,14 +79,32 @@ def normalize_work_kind(work_kind: str) -> WorkKind: raise ValueError(f"unknown work_kind: {work_kind!r}") -def route_execution(*, tools: set[str] | frozenset[str] | list[str], work_kind: str) -> Route: +def route_execution( + *, + tools: set[str] | frozenset[str] | list[str], + work_kind: str, + units: int = 1, + user_directed_subagents: bool = False, +) -> Route: """Return where execution should run for this request. - 1. Invoker MCP missing → local - 2. Small / read-only work → local even if Invoker exists - 3. Approved plan or durable/parallel → delegate_invoker + `units` counts independent publishing units (PR stacks, workflows). + More than one never runs serially in the parent thread: + Invoker first, else one worktree-isolated subagent per unit. + + 1. units > 1 → delegate_invoker, or subagent_worktree_per_unit when + Invoker is missing or the user directed subagents + 2. Invoker MCP missing → local + 3. Small / read-only work → local even if Invoker exists + 4. Approved plan or durable/parallel → delegate_invoker """ kind = normalize_work_kind(work_kind) + if units < 1: + raise ValueError(f"units must be >= 1, got {units!r}") + if units > 1 and kind != "readonly": + if invoker_mcp_available(tools) and not user_directed_subagents: + return "delegate_invoker" + return "subagent_worktree_per_unit" if not invoker_mcp_available(tools): return "local" if kind in ("readonly", "small_local"): @@ -112,6 +137,8 @@ def route_delegation( tools: set[str] | frozenset[str] | list[str], work_kind: str, produces: set[str] | frozenset[str] | list[str] | tuple[str, ...], + units: int = 1, + user_directed_subagents: bool = False, ) -> Delegation: """Resolve the Subagents default against the execution-routing table. @@ -122,7 +149,9 @@ def route_delegation( """ normalize_work_kind(work_kind) if publishes(work_kind=work_kind, produces=produces): - return route_execution(tools=tools, work_kind=work_kind) + return route_execution( + tools=tools, work_kind=work_kind, units=units, user_directed_subagents=user_directed_subagents, + ) return "subagent_fanout" @@ -131,6 +160,8 @@ def handoff_steps_for(route: Route | Delegation) -> tuple[str, ...]: return ("stay_local",) if route == "subagent_fanout": return SUBAGENT_FANOUT_STEPS + if route == "subagent_worktree_per_unit": + return SUBAGENT_PER_UNIT_STEPS return DELEGATE_HANDOFF_STEPS @@ -142,9 +173,15 @@ def handoff_steps_for(route: Route | Delegation) -> tuple[str, ...]: tools = payload.get("tools", []) work_kind = payload.get("work_kind", "small_local") produces = payload.get("produces") + units = int(payload.get("units", 1)) + directed = bool(payload.get("user_directed_subagents", False)) if produces is None: - route: Route | Delegation = route_execution(tools=tools, work_kind=work_kind) + route: Route | Delegation = route_execution( + tools=tools, work_kind=work_kind, units=units, user_directed_subagents=directed, + ) else: - route = route_delegation(tools=tools, work_kind=work_kind, produces=produces) + route = route_delegation( + tools=tools, work_kind=work_kind, produces=produces, units=units, user_directed_subagents=directed, + ) defer_to = installed_harness_routing_skill(payload.get("home")) print(json.dumps({"route": route, "steps": list(handoff_steps_for(route)), "defer_to": defer_to})) diff --git a/tests/test_execution_routing.py b/tests/test_execution_routing.py index 49b3a80f..8fb7bddb 100644 --- a/tests/test_execution_routing.py +++ b/tests/test_execution_routing.py @@ -40,6 +40,47 @@ def test_partial_tools_still_local(self): ) self.assertEqual(route, "local") + def test_many_publishing_units_never_run_serially(self): + tools = list(self.router.INVOKER_REQUIRED_TOOLS) + for kind in ("durable_parallel", "approved_plan", "post_land_babysit", "small_local"): + for available in (tools, []): + with self.subTest(kind=kind, invoker=bool(available)): + route = self.router.route_execution(tools=available, work_kind=kind, units=13) + self.assertNotEqual(route, "local") + + def test_many_units_prefer_invoker_then_worktree_subagent_per_unit(self): + tools = list(self.router.INVOKER_REQUIRED_TOOLS) + self.assertEqual( + self.router.route_execution(tools=tools, work_kind="post_land_babysit", units=13), + "delegate_invoker", + ) + self.assertEqual( + self.router.route_execution(tools=[], work_kind="post_land_babysit", units=13), + "subagent_worktree_per_unit", + ) + self.assertEqual( + self.router.route_execution( + tools=tools, work_kind="post_land_babysit", units=13, user_directed_subagents=True, + ), + "subagent_worktree_per_unit", + ) + steps = self.router.handoff_steps_for("subagent_worktree_per_unit") + self.assertEqual(steps[0], "one_worktree_per_unit") + self.assertIn("grep_transcripts_for_writes", steps) + + def test_many_units_route_delegation_reaches_per_unit_route(self): + route = self.router.route_delegation( + tools=[], work_kind="durable_parallel", produces=["pull_request"], units=4, + ) + self.assertEqual(route, "subagent_worktree_per_unit") + + def test_units_must_be_positive(self): + with self.assertRaises(ValueError): + self.router.route_execution(tools=[], work_kind="durable_parallel", units=0) + + def test_single_unit_keeps_existing_routes(self): + self.assertEqual(self.router.route_execution(tools=[], work_kind="approved_plan", units=1), "local") + def test_small_local_stays_local_even_with_invoker(self): tools = list(self.router.INVOKER_REQUIRED_TOOLS) for kind in ("small_local", "readonly"):