fix(#2285): wire claude-orchestration Workflow backend into execute-phase (#2314)

The claude-orchestration capability (#1143) shipped registered 'active'
but fully inert: detectWorkflowBackend/emitWorkflowScript had no caller
outside their own CLI router, and execute-phase.md declared an
execute:wave:pre hook point that the workflow body never rendered — so
claude_orchestration.enabled:true had zero effect on real runs.

Approach B (maintainer-chosen):
- execute-phase.md now renders the execute:wave:pre hook
  (gsd_run loop render-hooks execute:wave:pre) at a new step 2.75,
  immediately before each wave's Agent() dispatch — fixing the latent
  dead-hook gap for any pre-wave capability.
- Move the claude-orchestration contribution execute:wave:post ->
  execute:wave:pre (a pre-wave backend selector belongs before dispatch,
  not after); rename fragments/execute-wave-post.md -> execute-wave-pre.md
  with prose instructing the orchestrator to call resolve-wave-dispatch
  before step 3. Unrelated wave:post contributions (ui.safety-gate, drift,
  external-job, mempalace) untouched.
- New .cts seam resolveWaveDispatch(input) composes detectWorkflowBackend
  + emitWorkflowScript into one {backend:'inline'|'workflow', ...} result;
  exposed as gsd-tools claude-orchestration resolve-wave-dispatch. This is
  a real non-CLI-router, non-test caller of both functions.

Fail-closed: any gate miss (disabled, non-Claude runtime, Workflow tool
absent, SDK below floor, execution_backend:inline, malformed input) or an
emit failure resolves to inline with a byte-identical result shape — no
regression to the default-off execute-phase path.

Regression tests (tests/fix-2285-*) cover happy-path activation + SDK-floor
BVA, the fail-closed gate-miss table with detectWorkflowBackend parity, a
fast-check composition property, capability.json contribution assertions,
and a source-contract guard that execute:wave:pre is now actually rendered.
Dependent registry-shape assertions updated in-scope.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-15 19:18:28 -04:00
committed by GitHub
parent 75bedf16fd
commit ff9cb6069f
35 changed files with 1486 additions and 203 deletions

View File

@@ -25,7 +25,8 @@
"router": "routeClaudeOrchestrationCommand",
"subcommands": [
"detect-backend",
"emit-workflow"
"emit-workflow",
"resolve-wave-dispatch"
]
}
],
@@ -55,10 +56,10 @@
"steps": [],
"contributions": [
{
"point": "execute:wave:post",
"point": "execute:wave:pre",
"into": "executor",
"fragment": {
"path": "fragments/execute-wave-post.md"
"path": "fragments/execute-wave-pre.md"
},
"produces": [],
"consumes": [

View File

@@ -1,64 +0,0 @@
# Claude orchestration — Workflow execution backend (BETA)
> Injected at `execute:wave:post` `into: executor` only when
> `claude_orchestration.enabled` is true. Default-off; `onError: skip`.
## When this contribution is active
The Claude orchestration capability is **default-off and BETA**. It activates only
when ALL of the following hold:
1. `claude_orchestration.enabled` is `true` in `.planning/config.json`, AND
2. the active runtime is **Claude Code** (the Workflow tool is Claude / Agent
SDK-specific), AND
3. `claude_orchestration.execution_backend` resolves to `workflow` — either
explicitly, or via `auto` — **and** the Agent SDK version is
`>= claude_orchestration.min_agent_sdk_version` (default `0.3.149`). The SDK
floor applies in both `auto` and `workflow` modes (fail-closed: a pre-release
or older SDK never activates the preview backend).
Detection is fail-closed: any miss degrades to **inline, manual, one-agent-per-
message dispatch** — exactly today's behaviour. On a non-Claude runtime this
contribution is a no-op.
## What the executor does when the Workflow backend is active
Instead of the orchestrator fanning out one `Agent(subagent_type=gsd-executor,
isolation=worktree, run_in_background=true)` per message (which on Claude Code
cannot nest further subagents — #853 — and so degrades to sequential inline
execution), execute-phase **emits a generated Workflow script** and lets the main
loop orchestrate it:
- **waves → one or more sequential `parallel()` barriers** — each wave is a
barrier group; when plans within a wave share `files_modified`, they are split
into separate sequential stages within that wave's barrier (the next wave
still waits for the previous wave to complete).
- **plans → `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })`**
— the SAME executor agent and worktree isolation the inline path uses, so the
produced `SUMMARY.md` and commits are identical.
- **`files_modified` overlap → separate sequential stages** — two plans that
touch the same file are placed in different stages within the wave (the same
overlap rule execute-phase already applies inline).
- **`resumeFromRunId`** — wired to the phase run id, so an interrupted phase
resumes without re-running completed plans.
- **`budget(tokens)`** — a shared token pool across the whole phase when the
orchestrator passes a `budgetTokens` value to `emitWorkflowScript` (it is a
function parameter, not a config key; the orchestrator decides the budget).
The emitter is a pure function exposed through the capability command surface:
`gsd-tools claude-orchestration emit-workflow --waves <manifest.json> --run-id <id>
[--phase-dir <dir>] [--budget <n>]` (or `require('gsd-core/bin/lib/claude-orchestration.cjs').emitWorkflowScript`
directly). It maps the phase's wave/plan manifest to the Workflow script string
and never invokes the Workflow tool itself; the orchestrator runs the emitted
script. Detection is resolved by the orchestrator calling the pure
`detectWorkflowBackend` with the LIVE host descriptor (the CLI
`gsd-tools claude-orchestration detect-backend` is a simulation harness that
assumes a capable host unless `--no-nested-dispatch` is passed — it does not probe
the real runtime; the orchestrator supplies the real descriptor).
## Fallback contract
If detection resolves to `inline` (tool absent, SDK too old, runtime not Claude,
or the capability disabled), execute-phase MUST proceed with the standard inline
wave dispatch. The executor MUST NOT assume parallelism, a shared budget, or
resume-from-run-id semantics in that mode.

View File

@@ -0,0 +1,167 @@
# Claude orchestration — Workflow execution backend (BETA)
> Injected at `execute:wave:pre` `into: executor` only when
> `claude_orchestration.enabled` is true. Default-off; `onError: skip`.
## When this contribution is active
The Claude orchestration capability is **default-off and BETA**. It activates only
when ALL of the following hold:
1. `claude_orchestration.enabled` is `true` in `.planning/config.json`, AND
2. the active runtime is **Claude Code** (the Workflow tool is Claude / Agent
SDK-specific), AND
3. `claude_orchestration.execution_backend` resolves to `workflow` — either
explicitly, or via `auto` — **and** the Agent SDK version is
`>= claude_orchestration.min_agent_sdk_version` (default `0.3.149`). The SDK
floor applies in both `auto` and `workflow` modes (fail-closed: a pre-release
or older SDK never activates the preview backend).
Detection is fail-closed: any miss degrades to **inline, manual, one-agent-per-
message dispatch** — exactly today's behaviour. On a non-Claude runtime this
contribution is a no-op.
## Why `execute:wave:pre` (not `execute:wave:post`)
This is a **dispatch-backend selector** — it decides HOW a wave's executor agents
are spawned. That decision has to be made BEFORE the wave's `Agent()` calls in
`execute-phase.md` step 3, not after the wave has already finished (#2285). The
capability previously registered at `execute:wave:post`, which fires only after
worktree merge/post-merge tests/tracking updates — by then the wave was already
dispatched inline, so the contribution was structurally unable to change how
dispatch happened. This fragment is injected at the point that actually precedes
dispatch.
## What the orchestrator does when the Workflow backend is active
Before spawning executor agents for the current wave (execute-phase.md step 3),
resolve the dispatch backend through the single composed CLI seam:
```bash
gsd-tools claude-orchestration resolve-wave-dispatch \
--waves "$WAVE_MANIFEST_PATH" --run-id "$PHASE_RUN_ID" \
--runtime "$RUNTIME" \
${AGENT_SDK_VERSION:+--agent-sdk-version "$AGENT_SDK_VERSION"} \
--phase-dir "$PHASE_DIR" --raw
```
This composes `detectWorkflowBackend` (the gate ladder above) with
`emitWorkflowScript` (the wave→plan mapping below) in ONE call — the pure
function backing it is `resolveWaveDispatch` in
`gsd-core/bin/lib/claude-orchestration.cjs`. Response shape:
`{ backend: 'inline'|'workflow', reason, script?, summary? }`.
### Manifest construction (`$WAVE_MANIFEST_PATH`, `$PHASE_RUN_ID`, `$PHASE_DIR`, `$AGENT_SDK_VERSION`)
These are NOT pre-existing execute-phase.md variables — the orchestrator builds
them at this step, from data it already has in-context from `discover_and_group_plans`
(the `PLAN_INDEX` JSON) and step 2.5 (the per-plan `USE_WORKTREES_FOR_PLAN` decision):
1. **`$PHASE_DIR`** — reuse `{phase_dir}` from the `INIT` bundle (already loaded
in the `initialize` step). No new value needed.
2. **`$PHASE_RUN_ID`** — a stable identifier for THIS phase-execution attempt, so
`resumeFromRunId` can resume an interrupted run without re-dispatching plans
the Workflow tool already completed. Construct it deterministically —
`execute-{phase_number}-{phase_slug}` — from `INIT`'s `phase_number`/`phase_slug`
(both are already validated identifiers used elsewhere in this workflow, so
they satisfy `emitWorkflowScript`'s `isScriptableIdentifier` check). Do NOT
mint a new random id per wave — the SAME `$PHASE_RUN_ID` is reused for every
wave in the phase so the Workflow tool can correctly track cross-wave resume
state.
3. **`$WAVE_MANIFEST_PATH`** — a fresh temp file for THIS wave's manifest (one
wave = one `waves` array with a single entry, matching the wave-by-wave
dispatch loop; do not batch multiple waves into one manifest — waves are
dispatched in wave order, not all at once):
```bash
WAVE_MANIFEST_PATH=$(mktemp "${TMPDIR:-/tmp}/gsd-wave-dispatch-XXXXXX") && mv "$WAVE_MANIFEST_PATH" "$WAVE_MANIFEST_PATH.json" && WAVE_MANIFEST_PATH="$WAVE_MANIFEST_PATH.json"
```
Then **use the Write tool** (not a bash/jq pipeline — the orchestrator already
has every field parsed in-context) to write the manifest JSON to
`$WAVE_MANIFEST_PATH`:
```json
{
"waves": [
{
"id": "wave-{N}",
"plans": [
{
"id": "{plan_id}",
"brief": "{the SAME <objective>...<success_criteria> prompt block step 3 builds for this plan's inline Agent() call}",
"files_modified": ["{from PLAN_INDEX.plans[].files_modified for this plan}"],
"use_worktree": {true unless step 2.5 set USE_WORKTREES_FOR_PLAN=false for this plan}
}
]
}
]
}
```
- **`id`** — the plan id from `PLAN_INDEX`, e.g. `"01-01"`.
- **`brief`** — MUST carry the same task content as step 3's inline `Agent()`
prompt (the `<objective>`/`<execution_context>`/`<files_to_read>`/
`<success_criteria>` block, with `{plan_number}`/`{phase_number}`/
`{phase_name}` substituted) — a short summary here would NOT reproduce
step 3's behavior and would violate the "identical artifacts" contract.
- **`files_modified`** — copy verbatim from the plan's `PLAN_INDEX` entry.
- **`use_worktree`** — `true` for every plan UNLESS step 2.5's per-plan
worktree gate (`execute-phase/steps/per-plan-worktree-gate.md`) set
`USE_WORKTREES_FOR_PLAN=false` for that plan (submodule-touching plan, or
project-level `USE_WORKTREES=false`) — in which case pass `false` here so
`emitWorkflowScript` omits `isolation: "worktree"` for that plan (#2772 /
#2285 finding 1). **Never** hardcode `true` — that would force worktree
isolation on a plan the inline path explicitly keeps out of worktrees.
4. **`$AGENT_SDK_VERSION`** — see below; OMIT when unknown (fails closed).
**Agent SDK version:** the orchestrator has no scriptable (bash-computable) way
to introspect the live Agent SDK version. When it can determine the version
(e.g. from a host-exposed value it can read directly), pass
`--agent-sdk-version`. When it cannot, OMIT the flag — `resolveWaveDispatch`'s
gate 5 (`agent_sdk_version_unknown`) then fails closed to `inline` by design;
this is not a bug, it is the same fail-closed posture documented above applied
to a real absence of information.
**If `backend == "workflow"`:** run the emitted `script` via the Workflow tool
for THIS wave instead of the per-message `Agent()` loop in step 3. The script
composes the SAME `gsd-executor` agent type the inline path uses, with
worktree isolation applied PER PLAN from the manifest's `use_worktree` field
(see `emitWorkflowScript`):
- **waves → one or more sequential `parallel()` barriers** — each wave is a
barrier group; when plans within a wave share `files_modified`, they are split
into separate sequential stages within that wave's barrier.
- **plans → `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })`**
when `use_worktree` is not `false`, or `agent(brief, { agentType: 'gsd-executor' })`
(no isolation) when it is — so the produced `SUMMARY.md` and commits are
identical to inline dispatch, INCLUDING the inline path's submodule safety
gate (#2772 / #2285 finding 1).
- **`files_modified` overlap → separate sequential stages** — the same overlap
rule execute-phase already applies inline (step 1 of the wave loop).
- **`resumeFromRunId`** — wired to the phase run id, so an interrupted phase
resumes without re-running completed plans.
The orchestrator still runs steps 4–5.8 (wait for completion, worktree cleanup,
post-merge gate, tracking update) exactly as it does for inline dispatch — the
Workflow backend only replaces HOW agents are spawned for this wave, not what
happens after they return.
**If `backend == "inline"`** (any gate miss, or `resolve-wave-dispatch` itself
unavailable/erroring): proceed to step 3's standard per-message `Agent()`
dispatch — the default, byte-identical-to-today path. `onError: skip` on this
contribution means a `resolve-wave-dispatch` command failure is treated exactly
like an `inline` result, never as a fatal wave error.
## Fallback contract
Detection is fail-closed end-to-end: capability disabled, non-Claude runtime,
`execution_backend:"inline"`, missing/incapable host descriptor, unknown or
below-floor Agent SDK version, or an `emitWorkflowScript` failure on a malformed
wave manifest — ANY of these degrades to `backend:"inline"` and execute-phase's
standard inline dispatch (step 3) runs unmodified. The Workflow backend never
partially activates; the executor MUST NOT assume parallelism, a shared budget,
or resume-from-run-id semantics when `backend == "inline"`.