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
This commit is contained in:
Jeremy 2026-03-29 05:37:48 -05:00
parent 0a2c9b64c6
commit 5cf86ff490
6 changed files with 50 additions and 11 deletions

View file

@ -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();

View file

@ -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.

View file

@ -548,19 +548,27 @@ export function spawnWorker(
let child: ChildProcess;
try {
const workerEnv: Record<string, string | undefined> = {
...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,
});

View file

@ -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;
}

View file

@ -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,
};
}

View file

@ -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 ───────────────────────────────────────