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

File diff suppressed because one or more lines are too long

View File

@@ -22,7 +22,20 @@
* emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
* Reads a wave/plan manifest JSON file and emits the generated Workflow
* script + summary. The manifest shape matches emitWorkflowScript's input:
* { waves: [{ id, plans: [{ id, brief, files_modified: string[] }] }] }.
* { waves: [{ id, plans: [{ id, brief, files_modified: string[], use_worktree?: boolean }] }] }.
* `use_worktree` defaults to true; pass `false` for a plan the inline path
* (execute-phase.md step 2.5) would also keep out of worktree isolation
* (submodule-touching plans — #2772 / #2285 finding 1).
*
* resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>]
* [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>]
* [--budget <n>]
* #2285 — the single composed seam a PRE-wave dispatch-backend selector
* (`execute:wave:pre`) uses: resolves detect-backend + emit-workflow in
* ONE call. Emits { backend: 'inline'|'workflow', reason, script?, summary? }.
* Fail-closed identically to detect-backend/emit-workflow individually —
* any gate miss, or an emit failure on a malformed --waves manifest,
* resolves to 'inline' with no script.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -36,30 +49,26 @@ const core = require("./claude-orchestration.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const configLoader = require("./config-loader.cjs");
const { output } = io;
const { detectWorkflowBackend, emitWorkflowScript } = core;
const { detectWorkflowBackend, emitWorkflowScript, resolveWaveDispatch } = core;
const CAPABLE_HOST = { dispatch: { nested: true, background: true } };
function usage(error) {
error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow> [...]\n' +
error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow|resolve-wave-dispatch> [...]\n' +
' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]');
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]\n' +
' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>]');
}
function argValue(args, flag) {
const i = args.indexOf(flag);
return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
}
/**
* Detect whether the Workflow backend should activate for the current/given
* runtime. Reads `claude_orchestration.*` from the project config; runtime and
* SDK version come from flags (the orchestrator already knows these) or env.
* Resolve the `claude_orchestration.*` config slice from the project config
* (federated keys are merged by loadConfig as a nested object), flattened into
* the dotted-key shape `detectWorkflowBackend`/`resolveWaveDispatch` expect. A
* config read failure degrades to an empty slice — it must not break the core
* loop. Shared by `detect-backend` and `resolve-wave-dispatch`.
*/
function cmdDetectBackend(args, cwd, raw) {
const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown';
const agentSdkVersion = argValue(args, '--agent-sdk-version');
const noNested = args.includes('--no-nested-dispatch');
const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
// Resolve the claude_orchestration.* slice from the project config (federated
// keys are merged by loadConfig as a nested object). A config read failure
// degrades to inline — it must not break the core loop.
function resolveFlatClaudeOrchestrationConfig(cwd) {
let claudeSlice = {};
try {
const loaded = configLoader.loadConfig(cwd);
@@ -71,11 +80,56 @@ function cmdDetectBackend(args, cwd, raw) {
catch {
claudeSlice = {};
}
// Flatten the nested slice into the dotted-key shape detectWorkflowBackend expects.
const flatConfig = {};
for (const k of Object.keys(claudeSlice)) {
flatConfig['claude_orchestration.' + k] = claudeSlice[k];
}
return flatConfig;
}
/**
* Resolve `--runtime`/`--agent-sdk-version`/`--no-nested-dispatch` into the
* `{ runtimeId, hostIntegration, agentSdkVersion }` triple both `detect-backend`
* and `resolve-wave-dispatch` pass to the pure detection seam.
*/
function resolveDetectionArgs(args) {
const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown';
const agentSdkVersion = argValue(args, '--agent-sdk-version');
const noNested = args.includes('--no-nested-dispatch');
const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
return { runtimeId, hostIntegration, agentSdkVersion };
}
/**
* Read and parse a `--waves <path>` manifest file.
*
* #2285 finding 2: a real read/parse failure (`ok:false`) is DISTINCT from a
* manifest that parsed fine but has no top-level `waves` key (`ok:true, waves:
* undefined`) — collapsing both into the same sentinel made the missing-key
* case exit 0 with ZERO output (fail-silent), breaking the "exit 0 => parseable
* JSON verdict" contract callers rely on. Only the `ok:false` (read/parse threw)
* case calls `error(...)` and should short-circuit the caller; `ok:true` with a
* missing/malformed `waves` value must flow through to `emitWorkflowScript`'s
* own validation (matching how `{"waves": null}` already behaves) so the caller
* emits an explicit, non-empty verdict instead of silently doing nothing.
*/
function readWavesManifest(wavesPath, error) {
try {
const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8');
const parsed = JSON.parse(content);
return { ok: true, waves: parsed['waves'] };
}
catch (e) {
error('could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e)));
return { ok: false };
}
}
/**
* Detect whether the Workflow backend should activate for the current/given
* runtime. Reads `claude_orchestration.*` from the project config; runtime and
* SDK version come from flags (the orchestrator already knows these) or env.
*/
function cmdDetectBackend(args, cwd, raw) {
const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args);
const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion });
output(result, raw);
}
@@ -95,22 +149,15 @@ function cmdEmitWorkflow(args, _cwd, raw, error) {
error('emit-workflow requires --run-id <id>');
return;
}
let waves;
try {
const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8');
const parsed = JSON.parse(content);
waves = parsed['waves'];
}
catch (e) {
error('emit-workflow: could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e)));
return;
}
const read = readWavesManifest(wavesPath, (msg) => error('emit-workflow: ' + msg));
if (!read.ok)
return; // read/parse failure — error() already surfaced it loudly above
const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
const result = emitWorkflowScript({
phaseDir,
runId,
waves: waves,
waves: read.waves,
budgetTokens: budget,
});
if (!result.ok) {
@@ -119,6 +166,44 @@ function cmdEmitWorkflow(args, _cwd, raw, error) {
}
output({ script: result.script, summary: result.summary }, raw);
}
/**
* #2285 — the single composed seam a PRE-wave dispatch-backend selector
* (`execute:wave:pre`) uses: resolves `detect-backend` + `emit-workflow` in
* ONE call via `resolveWaveDispatch`. Emits
* `{ backend: 'inline'|'workflow', reason, script?, summary? }`.
*/
function cmdResolveWaveDispatch(args, cwd, raw, error) {
const wavesPath = argValue(args, '--waves');
const runId = argValue(args, '--run-id');
const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
const budgetRaw = argValue(args, '--budget');
if (!wavesPath) {
error('resolve-wave-dispatch requires --waves <path>');
return;
}
if (!runId) {
error('resolve-wave-dispatch requires --run-id <id>');
return;
}
const read = readWavesManifest(wavesPath, (msg) => error('resolve-wave-dispatch: ' + msg));
if (!read.ok)
return; // read/parse failure — error() already surfaced it loudly above
const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args);
const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
const result = resolveWaveDispatch({
runtimeId,
hostIntegration,
config: flatConfig,
agentSdkVersion,
phaseDir,
runId,
waves: read.waves,
budgetTokens: budget,
});
output(result, raw);
}
function routeClaudeOrchestrationCommand(opts) {
const { args, cwd, raw, error } = opts;
// args[0] is the family ('claude-orchestration'); the subcommand is args[1].
@@ -129,6 +214,9 @@ function routeClaudeOrchestrationCommand(opts) {
else if (subcommand === 'emit-workflow') {
cmdEmitWorkflow(args, cwd, raw, error);
}
else if (subcommand === 'resolve-wave-dispatch') {
cmdResolveWaveDispatch(args, cwd, raw, error);
}
else {
usage(error);
}

View File

@@ -16,15 +16,20 @@
* → { ok:true, script, summary } | { ok:false, reason }
* Maps GSD's wave/plan model 1:1 onto Workflow primitives:
* wave → sequential `parallel()` stage barriers,
* plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`,
* plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`
* — UNLESS the plan's `use_worktree` is explicitly `false`, in which case
* `isolation` is omitted entirely for that plan (#2772 / #2285 finding 1:
* a submodule-touching plan must never be forced into worktree isolation
* the inline path (execute-phase.md step 2.5) would keep it out of),
* files_modified overlap → forces plans into separate sequential stages
* (the same overlap rule execute-phase already applies inline),
* resumeFromRunId → wired to the phase run id,
* budgetTokens → a shared token pool.
* The emitted script composes the SAME gsd-executor agent and worktree
* isolation the inline path uses, so it produces the same artifacts/commits
* (criterion 2). It is a generated string consumed by the orchestrator; this
* module never invokes the Workflow tool itself.
* The emitted script composes the SAME gsd-executor agent the inline path
* uses, with per-plan worktree isolation mirroring the inline path's own
* per-plan decision, so it produces the same artifacts/commits (criterion 2).
* It is a generated string consumed by the orchestrator; this module never
* invokes the Workflow tool itself.
*
* Design laws:
* - Gall's Law: ship a small working slice that composes existing primitives
@@ -259,6 +264,17 @@ function partitionStages(plans) {
function quoteString(s) {
return JSON.stringify(s);
}
/**
* Render the `agent()` options object for a single plan — `isolation: "worktree"`
* ONLY when the plan's `use_worktree` is not explicitly `false` (#2772 / #2285
* finding 1). This is the single place that decides worktree isolation for the
* Workflow backend; it must never diverge from the inline path's per-plan gate.
*/
function agentOptions(p) {
return p.use_worktree === false
? '{ agentType: "gsd-executor" }'
: '{ agentType: "gsd-executor", isolation: "worktree" }';
}
/**
* True if `s` is a safe identifier/path token to interpolate into the generated
* script WITHOUT requiring a string-literal context — i.e. it contains no
@@ -318,6 +334,9 @@ function emitWorkflowScript(input) {
if (!isScriptableIdentifier(p.id)) {
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].id must not contain newlines/quotes/backslash/control chars' };
}
if (p.use_worktree !== undefined && typeof p.use_worktree !== 'boolean') {
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].use_worktree must be a boolean if present' };
}
if (seenIds.has(p.id)) {
return { ok: false, reason: 'waves[' + i + '] has duplicate plan id "' + p.id + '"' };
}
@@ -336,8 +355,9 @@ function emitWorkflowScript(input) {
lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)');
lines.push('// phase: ' + phaseDir);
lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.');
lines.push('// Composes the SAME gsd-executor agent + worktree isolation as the inline path,');
lines.push('// so artifacts (SUMMARY.md) and commits are produced identically.');
lines.push('// Composes the SAME gsd-executor agent as the inline path, so artifacts (SUMMARY.md)');
lines.push('// and commits are produced identically. Worktree isolation is per-plan (use_worktree)');
lines.push('// and mirrors execute-phase.md step 2.5\'s submodule gate exactly (#2772 / #2285).');
lines.push('resumeFromRunId(' + quoteString(runId) + ')');
if (budgetTokens !== null) {
lines.push('budget(' + budgetTokens + ')');
@@ -361,13 +381,13 @@ function emitWorkflowScript(input) {
if (stagePlans.length === 1) {
const p = stagePlans[0];
lines.push('parallel(');
lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" })');
lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + ')');
lines.push(')');
}
else {
lines.push('parallel(');
for (const p of stagePlans) {
lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" }),');
lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + '),');
}
// Replace trailing comma on the last agent line with nothing.
const lastIdx = lines.length - 1;
@@ -393,9 +413,64 @@ function emitWorkflowScript(input) {
},
};
}
/**
* #2285 — single composed decision seam for a PRE-wave dispatch-backend selector
* (e.g. the `execute:wave:pre` claude-orchestration contribution). Composes
* `detectWorkflowBackend` (gate ladder) with `emitWorkflowScript` (wave→plan
* mapping) into ONE call so the orchestrator (and its CLI wrapper,
* `claude-orchestration resolve-wave-dispatch`) never has to re-implement the
* two-step "detect, then maybe emit" sequencing.
*
* Fail-closed at every layer, matching the two composed functions:
* - `detectWorkflowBackend` resolving anything other than `'workflow'` →
* `inline` immediately; `emitWorkflowScript` is never invoked (no wasted
* work, no risk of a bad emit masking a correct inline fallback).
* - `detectWorkflowBackend` resolves `'workflow'` but `emitWorkflowScript`
* fails (`ok:false` — e.g. a malformed wave manifest) → `inline`, carrying
* the emit failure reason so the caller can surface it. Never a partial or
* broken script.
*
* This is the designated non-CLI-router, non-test caller of
* `detectWorkflowBackend` and `emitWorkflowScript` — the standalone CLI
* subcommands (`detect-backend`, `emit-workflow`) remain for inspection/
* debugging, but the orchestrator's real per-wave dispatch decision goes
* through this seam.
*
* Never throws on bad input.
*/
function resolveWaveDispatch(input) {
if (input === null || input === undefined || typeof input !== 'object') {
return { backend: 'inline', reason: 'invalid_input' };
}
const detected = detectWorkflowBackend({
runtimeId: input.runtimeId,
hostIntegration: input.hostIntegration,
config: input.config,
agentSdkVersion: input.agentSdkVersion,
});
if (detected.backend !== 'workflow') {
return { backend: 'inline', reason: detected.reason };
}
const emitted = emitWorkflowScript({
phaseDir: input.phaseDir,
waves: input.waves,
runId: input.runId,
budgetTokens: input.budgetTokens,
});
if (!emitted.ok) {
return { backend: 'inline', reason: 'emit_failed: ' + emitted.reason };
}
return {
backend: 'workflow',
reason: detected.reason,
script: emitted.script,
summary: emitted.summary,
};
}
module.exports = {
detectWorkflowBackend,
emitWorkflowScript,
resolveWaveDispatch,
compareSemver,
isValidSemver,
WORKFLOW_TOOL_FLOOR_VERSION,

View File

@@ -115,7 +115,7 @@ fi
```
`isolation="worktree"` is a Claude-Code-specific agent primitive; no other runtime can honor it (Codex maps subagents to `spawn_agent`, others prohibit or omit worktree binding). Failing closed prevents main-checkout edits while the workflow believes agents are isolated.
If the project uses git submodules, worktree isolation is unsafe **only when a plan touches a submodule path** — the executor commit protocol cannot correctly handle submodule commits inside isolated worktrees. The previous behavior unconditionally disabled worktree isolation whenever `.gitmodules` existed, which penalised every plan in a submodule project even when the plan was nowhere near a submodule. Compute submodule paths once and intersect them per-plan with the plan's declared `files_modified` frontmatter.
If the project uses git submodules, worktree isolation is unsafe **only when a plan touches a submodule path** — the executor commit protocol cannot correctly handle submodule commits inside isolated worktrees. Compute submodule paths once and intersect them per-plan with the plan's declared `files_modified` frontmatter.
```bash
# Parse submodule paths from .gitmodules once (empty if no .gitmodules).
@@ -277,12 +277,6 @@ checkpoints between tasks. The user can review, modify, or redirect work at any
3. After all plans: proceed to verification (same as normal mode).
**Benefits of interactive mode:**
- No subagent overhead — dramatically lower token usage
- User catches mistakes early — saves costly verification cycles
- Maintains GSD's planning/tracking structure
- Best for: small phases, bug fixes, verification gaps, learning GSD
**Skip to handle_branching step** (interactive plans execute inline after grouping).
</step>
@@ -553,7 +547,7 @@ increases monotonically across waves. `{status}` is `complete` (success),
```
- Bad: "Executing terrain generation plan"
- Good: "Procedural terrain generator using Perlin noise — creates height maps, biome zones, and collision meshes. Required before vehicle physics can interact with ground."
- Good: "Procedural terrain generator using Perlin noise — creates height maps and biome zones. Required before vehicle physics."
2.5. **Per-plan worktree decision (run for each plan in this wave BEFORE its dispatch):**
@@ -561,6 +555,14 @@ increases monotonically across waves. `{status}` is `complete` (success),
The dispatch branches in step 3 below MUST gate on `USE_WORKTREES_FOR_PLAN` for the current plan, not on the project-level `USE_WORKTREES`.
2.75. **Execute:wave:pre capability dispatch:**
```bash
WAVE_PRE_HOOKS_JSON=$(gsd_run loop render-hooks execute:wave:pre --raw)
```
If a contribution's `activeHooks` entry provides an alternate wave dispatch, follow it instead of step 3's inline loop; otherwise proceed to step 3.
3. **Spawn executor agents:**
**Emit a plan-start heartbeat (literal line, no tool call) immediately before