diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index a3d0232d5..fcc1be0ab 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -633,6 +633,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 2ae6b8b6e..cc8c4b3b0 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -211,6 +211,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 fe610bab4..689de6ce2 100644 --- a/src/resources/extensions/gsd/parallel-orchestrator.ts +++ b/src/resources/extensions/gsd/parallel-orchestrator.ts @@ -550,19 +550,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 33b4fe3f0..bee6b810f 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -530,6 +530,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 be027f2bf..2ca2f687d 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -565,5 +565,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/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", + ); +}); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index 83fd1f99d..b5a0c0b17 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -453,6 +453,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 ───────────────────────────────────────