fix: prevent model config bleed between concurrent GSD instances (#650) (#652)

Two fixes for the model configuration bleeding between simultaneous
GSD instances that share the same global settings.json.

## Root Cause

1. `setDefaultModelAndProvider()` always persisted to `~/.gsd/agent/settings.json`
   (global), so when either instance's interactive mode changed models (via
   Ctrl+P or /model), it overwrote the other instance's saved default.

2. When auto-mode dispatched a new unit (after context wipe), if no
   per-unit-type model preference was configured, the session picked up
   the default from the now-contaminated global settings file.

## Fix 1: Project-scoped model persistence (settings-manager.ts)

`setDefaultModelAndProvider()`, `setDefaultModel()`, and `setDefaultProvider()`
now persist to project-level settings (`.pi/settings.json`) when a project
settings file exists, falling back to global only when no project context
is available. This prevents concurrent instances from overwriting each
other's model choice.

Added `hasProjectSettingsFile()` helper to detect project context.

## Fix 2: Auto-mode model capture (auto.ts)

Captures the session's model at auto-mode start (`autoModeStartModel`).
At each unit dispatch, if no model preference is configured for the unit
type, the captured model is re-applied with `persist: false`. This
ensures each auto-mode session maintains its own model regardless of
what other instances write to the shared settings file.

## Tests

3 new tests covering:
- Project settings file isolates model from global
- Two projects have independent model configs
- autoModeStartModel concept prevents model drift

All 448 existing tests pass.

Fixes #650
This commit is contained in:
Tom Boucher 2026-03-16 12:59:12 -04:00 committed by GitHub
parent 53edf284fa
commit cdf42fe001
3 changed files with 174 additions and 11 deletions

View file

@ -473,6 +473,16 @@ export class SettingsManager {
this.errors.push({ scope, error: normalizedError });
}
/**
* Check if project-level settings are active (loaded from a file).
* Used to scope model persistence to the project when possible,
* preventing model config bleed between concurrent instances (#650).
*/
private hasProjectSettings(): boolean {
// Project settings are active if we loaded them and they weren't empty/errored
return !this.projectSettingsLoadError && Object.keys(this.projectSettings).length > 0;
}
private clearModifiedScope(scope: SettingsScope): void {
if (scope === "global") {
this.modifiedFields.clear();
@ -595,23 +605,43 @@ export class SettingsManager {
}
setDefaultProvider(provider: string): void {
this.globalSettings.defaultProvider = provider;
this.markModified("defaultProvider");
this.save();
if (this.hasProjectSettings()) {
this.projectSettings.defaultProvider = provider;
this.markProjectModified("defaultProvider");
this.saveProjectSettings(this.projectSettings);
} else {
this.globalSettings.defaultProvider = provider;
this.markModified("defaultProvider");
this.save();
}
}
setDefaultModel(modelId: string): void {
this.globalSettings.defaultModel = modelId;
this.markModified("defaultModel");
this.save();
if (this.hasProjectSettings()) {
this.projectSettings.defaultModel = modelId;
this.markProjectModified("defaultModel");
this.saveProjectSettings(this.projectSettings);
} else {
this.globalSettings.defaultModel = modelId;
this.markModified("defaultModel");
this.save();
}
}
setDefaultModelAndProvider(provider: string, modelId: string): void {
this.globalSettings.defaultProvider = provider;
this.globalSettings.defaultModel = modelId;
this.markModified("defaultProvider");
this.markModified("defaultModel");
this.save();
if (this.hasProjectSettings()) {
this.projectSettings.defaultProvider = provider;
this.projectSettings.defaultModel = modelId;
this.markProjectModified("defaultProvider");
this.markProjectModified("defaultModel");
this.saveProjectSettings(this.projectSettings);
} else {
this.globalSettings.defaultProvider = provider;
this.globalSettings.defaultModel = modelId;
this.markModified("defaultProvider");
this.markModified("defaultModel");
this.save();
}
}
getSteeringMode(): "all" | "one-at-a-time" {

View file

@ -251,6 +251,15 @@ let currentUnit: { type: string; id: string; startedAt: number } | null = null;
/** Track dynamic routing decision for the current unit (for metrics) */
let currentUnitRouting: { tier: string; modelDowngraded: boolean } | null = null;
/**
* Model captured at auto-mode start. Used to prevent model bleed between
* concurrent GSD instances sharing the same global settings.json (#650).
* When preferences don't specify a model for a unit type, this ensures
* the session's original model is re-applied instead of reading from
* the shared global settings (which another instance may have overwritten).
*/
let autoModeStartModel: { provider: string; id: string } | null = null;
/** Track current milestone to detect transitions */
let currentMilestoneId: string | null = null;
let lastBudgetAlertLevel: BudgetAlertLevel = 0;
@ -562,6 +571,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
lastBudgetAlertLevel = 0;
unitLifetimeDispatches.clear();
currentUnit = null;
autoModeStartModel = null;
currentMilestoneId = null;
originalBasePath = "";
completedUnits = [];
@ -965,6 +975,14 @@ export async function startAuto(
// Initialize routing history for adaptive learning
initRoutingHistory(base);
// Capture the session's current model at auto-mode start (#650).
// This prevents model bleed when multiple GSD instances share the
// same global settings.json — each instance remembers its own model.
const currentModel = ctx.model;
if (currentModel) {
autoModeStartModel = { provider: currentModel.provider, id: currentModel.id };
}
// Snapshot installed skills so we can detect new ones after research
if (resolveSkillDiscoveryMode() !== "off") {
snapshotSkills();
@ -2488,6 +2506,22 @@ async function dispatchNextUnit(
}
// modelSet=false is already handled by the "all fallbacks exhausted" warning above
} else if (autoModeStartModel) {
// No model preference for this unit type — re-apply the model captured
// at auto-mode start to prevent bleed from the shared global settings.json
// when multiple GSD instances run concurrently (#650).
const availableModels = ctx.modelRegistry.getAvailable();
const startModel = availableModels.find(
m => m.provider === autoModeStartModel!.provider && m.id === autoModeStartModel!.id,
);
if (startModel) {
const ok = await pi.setModel(startModel, { persist: false });
if (!ok) {
// Fallback: try matching just by ID across providers
const byId = availableModels.find(m => m.id === autoModeStartModel!.id);
if (byId) await pi.setModel(byId, { persist: false });
}
}
}
// Start progress-aware supervision: a soft warning, an idle watchdog, and

View file

@ -0,0 +1,99 @@
/**
* Tests for model config isolation between concurrent instances (#650).
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
// ─── Test helpers ─────────────────────────────────────────────────────────────
function makeTmpDir(suffix: string): string {
const dir = join(tmpdir(), `gsd-test-650-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
// ─── Settings Manager Model Scoping ───────────────────────────────────────────
describe("model config isolation (#650)", () => {
let tmpGlobal: string;
let tmpProjectA: string;
let tmpProjectB: string;
beforeEach(() => {
tmpGlobal = makeTmpDir("global");
tmpProjectA = makeTmpDir("project-a");
tmpProjectB = makeTmpDir("project-b");
// Create .pi directories for project settings
mkdirSync(join(tmpProjectA, ".pi"), { recursive: true });
mkdirSync(join(tmpProjectB, ".pi"), { recursive: true });
});
afterEach(() => {
try { rmSync(tmpGlobal, { recursive: true, force: true }); } catch {}
try { rmSync(tmpProjectA, { recursive: true, force: true }); } catch {}
try { rmSync(tmpProjectB, { recursive: true, force: true }); } catch {}
});
it("project settings file isolates model from global", async () => {
// Write project settings for project A
const projectSettingsPath = join(tmpProjectA, ".pi", "settings.json");
writeFileSync(projectSettingsPath, JSON.stringify({
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-6",
}));
// Write global settings with a different model
const globalSettingsPath = join(tmpGlobal, "settings.json");
writeFileSync(globalSettingsPath, JSON.stringify({
defaultProvider: "openai",
defaultModel: "gpt-5.4",
}));
// Verify project settings exist and have independent data
const projectData = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
const globalData = JSON.parse(readFileSync(globalSettingsPath, "utf-8"));
assert.equal(projectData.defaultModel, "claude-opus-4-6");
assert.equal(globalData.defaultModel, "gpt-5.4");
assert.notEqual(projectData.defaultModel, globalData.defaultModel,
"Project and global should have different models");
});
it("two projects have independent model configs", () => {
const settingsA = join(tmpProjectA, ".pi", "settings.json");
const settingsB = join(tmpProjectB, ".pi", "settings.json");
writeFileSync(settingsA, JSON.stringify({
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-6",
}));
writeFileSync(settingsB, JSON.stringify({
defaultProvider: "openai-codex",
defaultModel: "gpt-5.4",
}));
const dataA = JSON.parse(readFileSync(settingsA, "utf-8"));
const dataB = JSON.parse(readFileSync(settingsB, "utf-8"));
assert.equal(dataA.defaultModel, "claude-opus-4-6");
assert.equal(dataB.defaultModel, "gpt-5.4");
assert.notEqual(dataA.defaultProvider, dataB.defaultProvider);
});
it("autoModeStartModel concept prevents model drift", () => {
// Simulate the auto-mode start model capture pattern
const autoModeStartModel = { provider: "anthropic", id: "claude-opus-4-6" };
// Simulate another instance writing to global settings
const globalSettings = { defaultProvider: "openai-codex", defaultModel: "gpt-5.4" };
// The captured model should be used, not the global settings
assert.notEqual(autoModeStartModel.id, globalSettings.defaultModel);
assert.equal(autoModeStartModel.id, "claude-opus-4-6",
"Captured model should be preserved regardless of global settings changes");
});
});