Merge pull request #3117 from jeremymcs/feat/worker-model-override

feat(parallel): worker model override for parallel milestone workers
This commit is contained in:
Jeremy McSpadden 2026-04-07 07:22:00 -05:00 committed by GitHub
commit 91eec25d21
7 changed files with 98 additions and 11 deletions

View file

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

View file

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

View file

@ -550,19 +550,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

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

View file

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

View file

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

View file

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