diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts index 980fa50f2..e6155a2cb 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -1469,4 +1469,34 @@ describe("migrateConfigFile with migration tracking via sidecar (#3263)", () => "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", ])) }) + + test("preserves _migrations in config when sidecar write fails", () => { + // given: Config with _migrations field and a read-only directory that will cause sidecar write to fail + const testConfigPath = tempConfigPath("sidecar-fail") + const rawConfig: Record = { + agents: { + oracle: { model: "anthropic/claude-opus-4-5" }, + }, + _migrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + // Make the directory read-only to cause sidecar write to fail + const workdir = path.dirname(testConfigPath) + fs.chmodSync(workdir, 0o555) + + // when: Migrate config file (sidecar write will fail) + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // then: _migrations should be preserved as fallback since sidecar write failed + expect(needsWrite).toBe(true) + expect(rawConfig._migrations).toEqual(["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"]) + expect((rawConfig.agents as Record>).oracle.model).toBe("anthropic/claude-opus-4-6") + + // Sidecar should not exist because write failed + expect(fs.existsSync(sidecarPath(testConfigPath))).toBe(false) + + // cleanup: restore permissions for cleanup + fs.chmodSync(workdir, 0o755) + }) }) diff --git a/src/shared/migration/config-migration.ts b/src/shared/migration/config-migration.ts index 894bd2dcc..f5457e613 100644 --- a/src/shared/migration/config-migration.ts +++ b/src/shared/migration/config-migration.ts @@ -74,12 +74,13 @@ export function migrateConfigFile( // the first place. The in-memory `rawConfig` never re-exposes // `_migrations` to downstream schema validation. const newMigrationsToRecord = allNewMigrations.filter(mKey => !existingMigrations.has(mKey)) + let sidecarWriteSucceeded = false if (newMigrationsToRecord.length > 0 || hadLegacyInConfigMigrations) { const fullMigrationSet = new Set([ ...existingMigrations, ...newMigrationsToRecord, ]) - writeAppliedMigrations(configPath, fullMigrationSet) + sidecarWriteSucceeded = writeAppliedMigrations(configPath, fullMigrationSet) } if (newMigrationsToRecord.length > 0) { needsWrite = true @@ -88,7 +89,7 @@ export function migrateConfigFile( // Migrating state out of the config body is itself a config write. needsWrite = true } - if ("_migrations" in copy) { + if (sidecarWriteSucceeded && "_migrations" in copy) { delete copy._migrations }