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>
8.6 KiB
Claude orchestration — Workflow execution backend (BETA)
Injected at
execute:wave:preinto: executoronly whenclaude_orchestration.enabledis 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:
claude_orchestration.enabledistruein.planning/config.json, AND- the active runtime is Claude Code (the Workflow tool is Claude / Agent SDK-specific), AND
claude_orchestration.execution_backendresolves toworkflow— either explicitly, or viaauto— and the Agent SDK version is>= claude_orchestration.min_agent_sdk_version(default0.3.149). The SDK floor applies in bothautoandworkflowmodes (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:
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):
-
$PHASE_DIR— reuse{phase_dir}from theINITbundle (already loaded in theinitializestep). No new value needed. -
$PHASE_RUN_ID— a stable identifier for THIS phase-execution attempt, soresumeFromRunIdcan resume an interrupted run without re-dispatching plans the Workflow tool already completed. Construct it deterministically —execute-{phase_number}-{phase_slug}— fromINIT'sphase_number/phase_slug(both are already validated identifiers used elsewhere in this workflow, so they satisfyemitWorkflowScript'sisScriptableIdentifiercheck). Do NOT mint a new random id per wave — the SAME$PHASE_RUN_IDis reused for every wave in the phase so the Workflow tool can correctly track cross-wave resume state. -
$WAVE_MANIFEST_PATH— a fresh temp file for THIS wave's manifest (one wave = onewavesarray 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):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:{ "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 fromPLAN_INDEX, e.g."01-01".brief— MUST carry the same task content as step 3's inlineAgent()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'sPLAN_INDEXentry.use_worktree—truefor every plan UNLESS step 2.5's per-plan worktree gate (execute-phase/steps/per-plan-worktree-gate.md) setUSE_WORKTREES_FOR_PLAN=falsefor that plan (submodule-touching plan, or project-levelUSE_WORKTREES=false) — in which case passfalsehere soemitWorkflowScriptomitsisolation: "worktree"for that plan (#2772 / #2285 finding 1). Never hardcodetrue— that would force worktree isolation on a plan the inline path explicitly keeps out of worktrees.
-
$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 sharefiles_modified, they are split into separate sequential stages within that wave's barrier. - plans →
agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })whenuse_worktreeis notfalse, oragent(brief, { agentType: 'gsd-executor' })(no isolation) when it is — so the producedSUMMARY.mdand commits are identical to inline dispatch, INCLUDING the inline path's submodule safety gate (#2772 / #2285 finding 1). files_modifiedoverlap → 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".