diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts index d63e9d2f1..980fa50f2 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -321,6 +321,18 @@ describe("migrateHookNames", () => { describe("migrateConfigFile", () => { const testConfigPath = "/tmp/nonexistent-path-for-test.json" + // Tests in this block share a single config path and do not write a real + // config file, but migrateConfigFile now persists migration tracking to a + // sidecar next to the config (#3263). Clear the sidecar between tests so + // state from an earlier test does not bleed into the next one. + afterEach(() => { + try { + fs.unlinkSync(`${testConfigPath}.migrations.json`) + } catch { + // ignore — sidecar may not exist + } + }) + test("migrates experimental.hashline_edit to top-level hashline_edit", () => { // given: Config with legacy experimental.hashline_edit const rawConfig: Record = { @@ -790,8 +802,8 @@ describe("migrateConfigFile _migrations tracking", () => { fs.rmSync(tmpDir, { recursive: true }) }) - test("preserves existing _migrations and appends new ones", () => { - // given: Config with existing migration history and a new migratable model + test("migrates legacy in-config _migrations into the sidecar and appends new migrations (#3263)", () => { + // given: Config with an existing legacy in-config _migrations history and a new migratable model const tmpDir = fs.mkdtempSync("/tmp/migration-test-") const configPath = `${tmpDir}/oh-my-opencode.json` const rawConfig: Record = { @@ -804,12 +816,17 @@ describe("migrateConfigFile _migrations tracking", () => { // when: Migrate config file const result = migrateConfigFile(configPath, rawConfig) - // then: New migration appended, old one preserved + // then: The config body has _migrations stripped. The full history + // (legacy + new) is written to the sidecar file exactly once. expect(result).toBe(true) - expect(rawConfig._migrations).toEqual([ + expect(rawConfig._migrations).toBeUndefined() + expect((rawConfig.agents as Record>).prometheus.model).toBe("anthropic/claude-opus-4-6") + + const sidecar = JSON.parse(fs.readFileSync(`${configPath}.migrations.json`, "utf-8")) + expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([ "model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex", "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", - ]) + ])) // cleanup fs.rmSync(tmpDir, { recursive: true }) @@ -1263,7 +1280,7 @@ describe("migrateModelVersions with applied migrations", () => { }) }) -describe("migrateConfigFile with _migrations tracking", () => { +describe("migrateConfigFile with migration tracking via sidecar (#3263)", () => { const cleanupPaths: string[] = [] afterEach(() => { @@ -1276,72 +1293,180 @@ describe("migrateConfigFile with _migrations tracking", () => { cleanupPaths.length = 0 }) - test("records new migrations in _migrations field", () => { - // given: Config with old model, no _migrations field - const testConfigPath = "/tmp/test-config-migrations-1.json" + function tempConfigPath(label: string): string { + const workdir = fs.mkdtempSync(`/tmp/omo-migration-${label}-`) + cleanupPaths.push(workdir) + return path.join(workdir, "oh-my-openagent.json") + } + + function sidecarPath(configPath: string): string { + return `${configPath}.migrations.json` + } + + test("does not emit migration history when no migration applies", () => { + // given: Config with a model that does not appear in MODEL_VERSION_MAP + const testConfigPath = tempConfigPath("no-op") const rawConfig: Record = { agents: { sisyphus: { model: "openai/gpt-5.4-codex" }, }, } fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) - cleanupPaths.push(testConfigPath) - // when: Migrate config file const needsWrite = migrateConfigFile(testConfigPath, rawConfig) - // then: gpt-5.4-codex should not create migration history expect(needsWrite).toBe(false) expect(rawConfig._migrations).toBeUndefined() expect((rawConfig.agents as Record>).sisyphus.model).toBe("openai/gpt-5.4-codex") + expect(fs.existsSync(sidecarPath(testConfigPath))).toBe(false) }) - test("skips re-applying already-recorded migrations", () => { - // given: Config with old model but migration already in _migrations - const testConfigPath = "/tmp/test-config-migrations-2.json" + test("writes applied migrations to sidecar instead of leaving them on the config", () => { + // given: Config that needs a real model migration and has no prior history + const testConfigPath = tempConfigPath("sidecar-write") const rawConfig: Record = { agents: { - sisyphus: { model: "openai/gpt-5.4-codex" }, - }, - _migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"], - } - fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) - cleanupPaths.push(testConfigPath) - - // when: Migrate config file - const needsWrite = migrateConfigFile(testConfigPath, rawConfig) - - // then: Should not migrate (user reverted) - expect(needsWrite).toBe(false) - expect((rawConfig.agents as Record>).sisyphus.model).toBe("openai/gpt-5.4-codex") - expect(rawConfig._migrations).toEqual(["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"]) - }) - - test("preserves existing _migrations and appends new ones", () => { - // given: Config with multiple old models, partial migration history - const testConfigPath = "/tmp/test-config-migrations-3.json" - const rawConfig: Record = { - agents: { - sisyphus: { model: "openai/gpt-5.4-codex" }, oracle: { model: "anthropic/claude-opus-4-5" }, }, - _migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"], } fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) - cleanupPaths.push(testConfigPath) - // when: Migrate config file const needsWrite = migrateConfigFile(testConfigPath, rawConfig) - // then: Should skip sisyphus, migrate oracle, append to _migrations expect(needsWrite).toBe(true) - expect((rawConfig.agents as Record>).sisyphus.model).toBe("openai/gpt-5.4-codex") expect((rawConfig.agents as Record>).oracle.model).toBe("anthropic/claude-opus-4-6") - expect(rawConfig._migrations).toEqual([ - "model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex", + expect(rawConfig._migrations).toBeUndefined() + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(sidecar.appliedMigrations).toEqual([ "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", ]) }) + test("skips re-applying a migration that is recorded in the sidecar even if the user edited _migrations away", () => { + // This is the core #3263 regression: a user auto-migrated from + // gpt-5.3-codex to gpt-5.4, reverted to gpt-5.3-codex by hand, and + // deleted _migrations in the process. Without the sidecar their + // revert was clobbered on every startup. + const testConfigPath = tempConfigPath("sidecar-revert") + fs.writeFileSync( + sidecarPath(testConfigPath), + JSON.stringify({ + appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + }), + ) + const rawConfig: Record = { + agents: { + oracle: { model: "openai/gpt-5.3-codex" }, + }, + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + expect(needsWrite).toBe(false) + expect((rawConfig.agents as Record>).oracle.model).toBe("openai/gpt-5.3-codex") + expect(rawConfig._migrations).toBeUndefined() + }) + + test("mirrors legacy in-config _migrations into the sidecar and then strips the field", () => { + // BC path: configs written by older OMO versions still carry the + // legacy _migrations field in the JSON body. On the next startup we + // must copy that history into the new sidecar and remove the field + // from the config so the migration tracking lives in exactly one + // place from then on. + const testConfigPath = tempConfigPath("bc-mirror") + const rawConfig: Record = { + agents: { + oracle: { model: "openai/gpt-5.3-codex" }, + }, + _migrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // needsWrite is true because we rewrote the config to drop _migrations + expect(needsWrite).toBe(true) + expect(rawConfig._migrations).toBeUndefined() + expect((rawConfig.agents as Record>).oracle.model).toBe("openai/gpt-5.3-codex") + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(sidecar.appliedMigrations).toEqual([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + ]) + }) + + test("unions sidecar and legacy _migrations entries, deduplicating", () => { + // Defensive case: a config written by two different OMO versions + // could end up with an entry in _migrations that is also in the + // sidecar. The merged set should be deduplicated and the config + // should not be re-migrated. + const testConfigPath = tempConfigPath("sidecar-union") + fs.writeFileSync( + sidecarPath(testConfigPath), + JSON.stringify({ + appliedMigrations: [ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ], + }), + ) + const rawConfig: Record = { + agents: { + oracle: { model: "anthropic/claude-opus-4-5" }, + }, + _migrations: ["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"], + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // needsWrite because the legacy _migrations field was stripped + expect(needsWrite).toBe(true) + expect(rawConfig._migrations).toBeUndefined() + // The reverted opus-4-5 value must be preserved + expect((rawConfig.agents as Record>).oracle.model).toBe("anthropic/claude-opus-4-5") + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(sidecar.appliedMigrations).toEqual([ + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + ]) + }) + + test("appends new migrations to the sidecar when partial history exists", () => { + // Scenario: sidecar already has one migration, a second model still + // needs to be migrated. The new migration should be recorded and the + // already-applied one preserved. + const testConfigPath = tempConfigPath("sidecar-append") + fs.writeFileSync( + sidecarPath(testConfigPath), + JSON.stringify({ + appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + }), + ) + const rawConfig: Record = { + agents: { + codex: { model: "openai/gpt-5.3-codex" }, + claude: { model: "anthropic/claude-opus-4-5" }, + }, + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + expect(needsWrite).toBe(true) + // codex was reverted, must stay + expect((rawConfig.agents as Record>).codex.model).toBe("openai/gpt-5.3-codex") + // claude migrates + expect((rawConfig.agents as Record>).claude.model).toBe("anthropic/claude-opus-4-6") + expect(rawConfig._migrations).toBeUndefined() + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ])) + }) }) diff --git a/src/shared/migration/config-migration.ts b/src/shared/migration/config-migration.ts index 58a4b4b33..894bd2dcc 100644 --- a/src/shared/migration/config-migration.ts +++ b/src/shared/migration/config-migration.ts @@ -4,6 +4,7 @@ import { writeFileAtomically } from "../write-file-atomically" import { AGENT_NAME_MAP, migrateAgentNames } from "./agent-names" import { migrateHookNames } from "./hook-names" import { migrateModelVersions } from "./model-versions" +import { readAppliedMigrations, writeAppliedMigrations } from "./migrations-sidecar" export function migrateConfigFile( configPath: string, @@ -12,10 +13,22 @@ export function migrateConfigFile( const copy = structuredClone(rawConfig) let needsWrite = false - // Load previously applied migrations - const existingMigrations = Array.isArray(copy._migrations) + // Load previously applied migrations from BOTH the legacy in-config + // `_migrations` field AND the external sidecar file. The sidecar is the + // new source of truth because users were editing the config file to + // revert auto-migrated values and accidentally dropping the `_migrations` + // field in the process, which produced an infinite migration loop on + // every startup (#3263). Reading from both sources keeps old configs + // that still carry `_migrations` working without a forced reset. + const sidecarMigrations = readAppliedMigrations(configPath) + const inConfigMigrations = Array.isArray(copy._migrations) ? new Set(copy._migrations as string[]) : new Set() + const existingMigrations = new Set([ + ...sidecarMigrations, + ...inConfigMigrations, + ]) + const hadLegacyInConfigMigrations = inConfigMigrations.size > 0 const allNewMigrations: string[] = [] if (copy.agents && typeof copy.agents === "object") { @@ -54,13 +67,30 @@ export function migrateConfigFile( allNewMigrations.push(...newMigrations) } - // Record newly applied migrations - if (allNewMigrations.length > 0) { - const updatedMigrations = Array.from(existingMigrations) - updatedMigrations.push(...allNewMigrations) - copy._migrations = updatedMigrations + // Record newly applied migrations. We persist the full set (existing + + // new) to the external sidecar file and strip the legacy `_migrations` + // field from the config body on its way out, so users stop having to + // think about a field that never should have been in their config in + // the first place. The in-memory `rawConfig` never re-exposes + // `_migrations` to downstream schema validation. + const newMigrationsToRecord = allNewMigrations.filter(mKey => !existingMigrations.has(mKey)) + if (newMigrationsToRecord.length > 0 || hadLegacyInConfigMigrations) { + const fullMigrationSet = new Set([ + ...existingMigrations, + ...newMigrationsToRecord, + ]) + writeAppliedMigrations(configPath, fullMigrationSet) + } + if (newMigrationsToRecord.length > 0) { needsWrite = true } + if (hadLegacyInConfigMigrations) { + // Migrating state out of the config body is itself a config write. + needsWrite = true + } + if ("_migrations" in copy) { + delete copy._migrations + } if (copy.omo_agent) { copy.sisyphus_agent = copy.omo_agent diff --git a/src/shared/migration/migrations-sidecar.test.ts b/src/shared/migration/migrations-sidecar.test.ts new file mode 100644 index 000000000..5809bde94 --- /dev/null +++ b/src/shared/migration/migrations-sidecar.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { getSidecarPath, readAppliedMigrations, writeAppliedMigrations } from "./migrations-sidecar" + +describe("migrations sidecar", () => { + let workdir: string + + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "omo-migrations-sidecar-")) + }) + + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }) + }) + + describe("getSidecarPath", () => { + test("appends .migrations.json to the config path", () => { + expect(getSidecarPath("/home/user/.config/opencode/oh-my-openagent.json")).toBe( + "/home/user/.config/opencode/oh-my-openagent.json.migrations.json", + ) + }) + + test("works for jsonc configs too", () => { + expect(getSidecarPath("/home/user/oh-my-openagent.jsonc")).toBe( + "/home/user/oh-my-openagent.jsonc.migrations.json", + ) + }) + }) + + describe("readAppliedMigrations", () => { + test("returns an empty set when no sidecar exists", () => { + const configPath = join(workdir, "oh-my-openagent.json") + expect(readAppliedMigrations(configPath).size).toBe(0) + }) + + test("returns the applied migrations listed in a well-formed sidecar", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync( + getSidecarPath(configPath), + JSON.stringify({ + appliedMigrations: [ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ], + }), + ) + + const applied = readAppliedMigrations(configPath) + + expect(applied.size).toBe(2) + expect(applied.has("model-version:openai/gpt-5.3-codex->openai/gpt-5.4")).toBe(true) + expect(applied.has("model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6")).toBe(true) + }) + + test("returns an empty set on malformed JSON instead of throwing", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync(getSidecarPath(configPath), "{ this is not json") + + expect(readAppliedMigrations(configPath).size).toBe(0) + }) + + test("returns an empty set when the sidecar payload has the wrong shape", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync(getSidecarPath(configPath), JSON.stringify({ appliedMigrations: "not-an-array" })) + + expect(readAppliedMigrations(configPath).size).toBe(0) + }) + + test("ignores non-string entries inside appliedMigrations", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync( + getSidecarPath(configPath), + JSON.stringify({ + appliedMigrations: ["model-version:a->b", 42, null, "model-version:c->d"], + }), + ) + + const applied = readAppliedMigrations(configPath) + + expect(applied.size).toBe(2) + expect(applied.has("model-version:a->b")).toBe(true) + expect(applied.has("model-version:c->d")).toBe(true) + }) + }) + + describe("writeAppliedMigrations", () => { + test("creates the sidecar with the given migration keys", () => { + const configPath = join(workdir, "oh-my-openagent.json") + const migrations = new Set([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + ]) + + const ok = writeAppliedMigrations(configPath, migrations) + + expect(ok).toBe(true) + expect(existsSync(getSidecarPath(configPath))).toBe(true) + + const body = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8")) + expect(body.appliedMigrations).toEqual(["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"]) + }) + + test("writes entries in sorted order for stable diffs", () => { + const configPath = join(workdir, "oh-my-openagent.json") + const migrations = new Set([ + "model-version:z->y", + "model-version:a->b", + "model-version:m->n", + ]) + + writeAppliedMigrations(configPath, migrations) + + const body = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8")) + expect(body.appliedMigrations).toEqual([ + "model-version:a->b", + "model-version:m->n", + "model-version:z->y", + ]) + }) + + test("creates parent directories if they do not exist yet", () => { + const nested = join(workdir, "nested", "dir", "that", "does", "not", "exist") + const configPath = join(nested, "oh-my-openagent.json") + // Parent chain intentionally not created. + + const ok = writeAppliedMigrations(configPath, new Set(["model-version:a->b"])) + + expect(ok).toBe(true) + expect(existsSync(getSidecarPath(configPath))).toBe(true) + }) + + test("round-trips via readAppliedMigrations", () => { + const configPath = join(workdir, "oh-my-openagent.jsonc") + const original = new Set([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ]) + + writeAppliedMigrations(configPath, original) + const roundTripped = readAppliedMigrations(configPath) + + expect(roundTripped).toEqual(original) + }) + }) +}) diff --git a/src/shared/migration/migrations-sidecar.ts b/src/shared/migration/migrations-sidecar.ts new file mode 100644 index 000000000..cd0088922 --- /dev/null +++ b/src/shared/migration/migrations-sidecar.ts @@ -0,0 +1,92 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { log } from "../logger" +import { writeFileAtomically } from "../write-file-atomically" + +/** + * Sidecar state file that tracks applied config migrations outside the user's + * config file. + * + * Why this exists (#3263): users who revert an auto-migrated value (e.g. + * `gpt-5.4` → `gpt-5.3-codex`) and then delete the `_migrations` field from + * their config would fall into an infinite migration loop — every startup + * re-applied the migration because there was no memory of the previous + * application. The sidecar remembers applied migrations even when the user + * scrubs the config, and only "resets" when the user explicitly deletes both + * the config and the sidecar. + * + * The sidecar lives next to the config file as + * `.migrations.json`. One sidecar per config file. The file + * format is a flat JSON object: + * + * { + * "appliedMigrations": [ + * "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + * "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6" + * ] + * } + */ + +export interface MigrationsSidecar { + appliedMigrations: string[] +} + +export function getSidecarPath(configPath: string): string { + return `${configPath}.migrations.json` +} + +/** + * Read the set of applied migration keys from the sidecar next to + * `configPath`. Returns an empty set on any read or parse failure so the + * caller can still trust the return value and safely fall back to the + * config's `_migrations` field. + */ +export function readAppliedMigrations(configPath: string): Set { + const sidecarPath = getSidecarPath(configPath) + try { + if (!fs.existsSync(sidecarPath)) { + return new Set() + } + const content = fs.readFileSync(sidecarPath, "utf-8") + const parsed = JSON.parse(content) as unknown + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + Array.isArray((parsed as MigrationsSidecar).appliedMigrations) + ) { + return new Set((parsed as MigrationsSidecar).appliedMigrations.filter((m): m is string => typeof m === "string")) + } + return new Set() + } catch (err) { + log(`[migration] Failed to read migrations sidecar at ${sidecarPath}`, err) + return new Set() + } +} + +/** + * Persist the given set of applied migration keys to the sidecar next to + * `configPath`. The sidecar is written atomically. Returns true on success, + * false if the write failed (the caller can still proceed — the next + * startup will re-run the migration, which is idempotent by design). + */ +export function writeAppliedMigrations(configPath: string, migrations: Set): boolean { + const sidecarPath = getSidecarPath(configPath) + const body: MigrationsSidecar = { + appliedMigrations: Array.from(migrations).sort(), + } + try { + // Ensure the parent directory exists in case the config file was created + // out-of-band. We intentionally do NOT create the sidecar when the migration + // set is empty — there is nothing to remember. + const parentDir = path.dirname(sidecarPath) + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }) + } + writeFileAtomically(sidecarPath, JSON.stringify(body, null, 2) + "\n") + return true + } catch (err) { + log(`[migration] Failed to write migrations sidecar at ${sidecarPath}`, err) + return false + } +}