From 5cf86ff490bb8e65b459ffd7f933dca84318d66e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 29 Mar 2026 05:37:48 -0500 Subject: [PATCH 1/2] feat(parallel): worker model override for parallel milestone workers Add parallel.worker_model preference so coordinators can assign a cheaper model to parallel workers (e.g. Haiku for execution) instead of inheriting the coordinator's model. The override is applied via GSD_WORKER_MODEL env var during worker bootstrap. - Add worker_model to ParallelConfig type and validation - Inject GSD_WORKER_MODEL env in spawnWorker when configured - Apply override in bootstrapAutoSession for parallel workers - Document in preferences-reference.md --- src/resources/extensions/gsd/auto-start.ts | 19 ++++++++++++ .../gsd/docs/preferences-reference.md | 1 + .../extensions/gsd/parallel-orchestrator.ts | 30 ++++++++++++------- .../extensions/gsd/preferences-validation.ts | 8 +++++ src/resources/extensions/gsd/preferences.ts | 1 + src/resources/extensions/gsd/types.ts | 2 ++ 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 85bdbe370..e3d13d9b5 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -610,6 +610,25 @@ export async function bootstrapAutoSession( }; } + // Apply worker model override from parallel orchestrator (#worker-model). + // GSD_WORKER_MODEL is injected by the coordinator when parallel.worker_model + // is configured, so parallel milestone workers use a cheaper model than the + // coordinator session (e.g. Haiku for execution, Sonnet for planning). + const workerModelOverride = process.env.GSD_WORKER_MODEL; + if (workerModelOverride && process.env.GSD_PARALLEL_WORKER === "1") { + const availableModels = ctx.modelRegistry.getAvailable(); + const { resolveModelId } = await import("./auto-model-selection.js"); + const overrideModel = resolveModelId(workerModelOverride, availableModels, ctx.model?.provider); + if (overrideModel) { + const ok = await pi.setModel(overrideModel, { persist: false }); + if (ok) { + // Update start model so all subsequent units use this as the baseline + s.autoModeStartModel = { provider: overrideModel.provider, id: overrideModel.id }; + ctx.ui.notify(`Worker model override: ${overrideModel.provider}/${overrideModel.id}`, "info"); + } + } + } + // Snapshot installed skills if (resolveSkillDiscoveryMode() !== "off") { snapshotSkills(); diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index 8f110ce37..9d3d29183 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -204,6 +204,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `budget_ceiling`: number — optional per-parallel-run budget ceiling. - `merge_strategy`: `"per-slice"` or `"per-milestone"` — when to merge worktree results back. Default: `"per-milestone"`. - `auto_merge`: `"auto"`, `"confirm"`, or `"manual"` — merge behavior after completion. `"auto"` merges immediately; `"confirm"` asks first; `"manual"` leaves branches for you. Default: `"confirm"`. + - `worker_model`: string — optional model override for parallel milestone workers. When set, workers use this model (e.g. `"claude-haiku-4-5"`) instead of inheriting the coordinator's model. Useful for cost savings on execution-heavy milestones. - `verification_commands`: string[] — shell commands to run as verification after task execution (e.g., `["npm test", "npm run lint"]`). Commands run in order; if any fails, the task is marked as needing fixes. diff --git a/src/resources/extensions/gsd/parallel-orchestrator.ts b/src/resources/extensions/gsd/parallel-orchestrator.ts index 95b87d738..dda0fae5c 100644 --- a/src/resources/extensions/gsd/parallel-orchestrator.ts +++ b/src/resources/extensions/gsd/parallel-orchestrator.ts @@ -548,19 +548,27 @@ export function spawnWorker( let child: ChildProcess; try { + const workerEnv: Record = { + ...process.env, + GSD_MILESTONE_LOCK: milestoneId, + // Pass the real project root so workers don't need to re-derive it. + // Without this, process.cwd() resolves symlinks and the worktree + // path heuristic can match the user-level ~/.gsd instead of the + // project .gsd, causing writes to ~ and corrupting user config. + GSD_PROJECT_ROOT: basePath, + // Prevent workers from spawning their own parallel sessions + GSD_PARALLEL_WORKER: "1", + }; + + // Apply worker model override if configured, so workers use a cheaper + // model (e.g. Haiku) rather than inheriting the coordinator's model. + if (state.config.worker_model) { + workerEnv.GSD_WORKER_MODEL = state.config.worker_model; + } + child = spawn(process.execPath, [binPath, "headless", "--json", "auto"], { cwd: worker.worktreePath, - env: { - ...process.env, - GSD_MILESTONE_LOCK: milestoneId, - // Pass the real project root so workers don't need to re-derive it. - // Without this, process.cwd() resolves symlinks and the worktree - // path heuristic can match the user-level ~/.gsd instead of the - // project .gsd, causing writes to ~ and corrupting user config. - GSD_PROJECT_ROOT: basePath, - // Prevent workers from spawning their own parallel sessions - GSD_PARALLEL_WORKER: "1", - }, + env: workerEnv, stdio: ["ignore", "pipe", "pipe"], detached: false, }); diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index 6b4e0e217..f48113c48 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -492,6 +492,14 @@ export function validatePreferences(preferences: GSDPreferences): { } } + if (p.worker_model !== undefined) { + if (typeof p.worker_model === "string" && p.worker_model.length > 0) { + parallel.worker_model = p.worker_model; + } else { + errors.push("parallel.worker_model must be a non-empty string"); + } + } + if (Object.keys(parallel).length > 0) { validated.parallel = parallel as unknown as import("./types.js").ParallelConfig; } diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 58badbd95..55d0be8a3 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -537,5 +537,6 @@ export function resolveParallelConfig(prefs: GSDPreferences | undefined): import budget_ceiling: prefs?.parallel?.budget_ceiling, merge_strategy: prefs?.parallel?.merge_strategy ?? "per-milestone", auto_merge: prefs?.parallel?.auto_merge ?? "confirm", + worker_model: prefs?.parallel?.worker_model, }; } diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index ffecfc75e..e331c7df0 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -450,6 +450,8 @@ export interface ParallelConfig { budget_ceiling?: number; merge_strategy: MergeStrategy; auto_merge: AutoMergeMode; + /** Optional model override for parallel milestone workers (e.g. "claude-haiku-4-5"). */ + worker_model?: string; } // ─── Reactive Task Execution Types ─────────────────────────────────────── From d862a2a7f028e04fee64f0065c96b313b8fcebfe Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 29 Mar 2026 05:57:16 -0500 Subject: [PATCH 2/2] test(parallel): add worker_model override validation tests Add tests verifying ParallelConfig type, preferences validation, and resolveParallelConfig pass-through for worker_model. --- .../gsd/tests/worker-model-override.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/worker-model-override.test.ts diff --git a/src/resources/extensions/gsd/tests/worker-model-override.test.ts b/src/resources/extensions/gsd/tests/worker-model-override.test.ts new file mode 100644 index 000000000..0b1e49edf --- /dev/null +++ b/src/resources/extensions/gsd/tests/worker-model-override.test.ts @@ -0,0 +1,48 @@ +/** + * Worker model override — tests for parallel.worker_model preference. + * + * Verifies validation, resolveParallelConfig pass-through, and type definitions. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const typesSrc = readFileSync(join(__dirname, "..", "types.ts"), "utf-8"); +const validationSrc = readFileSync(join(__dirname, "..", "preferences-validation.ts"), "utf-8"); +const preferencesSrc = readFileSync(join(__dirname, "..", "preferences.ts"), "utf-8"); + +// ─── Type definition ────────────────────────────────────────────────────── + +test("ParallelConfig includes worker_model optional field", () => { + assert.ok( + typesSrc.includes("worker_model?: string"), + "ParallelConfig should have optional worker_model field", + ); +}); + +// ─── Validation ─────────────────────────────────────────────────────────── + +test("validatePreferences accepts valid worker_model string", () => { + assert.ok( + validationSrc.includes("p.worker_model"), + "validation should check parallel.worker_model", + ); + assert.ok( + validationSrc.includes('parallel.worker_model must be a non-empty string'), + "validation should reject invalid worker_model", + ); +}); + +// ─── resolveParallelConfig ──────────────────────────────────────────────── + +test("resolveParallelConfig passes through worker_model", () => { + assert.ok( + preferencesSrc.includes("worker_model: prefs?.parallel?.worker_model"), + "resolveParallelConfig should pass through worker_model", + ); +});