fix(migration): track applied migrations in sidecar so user reverts stick
Users who auto-migrated from `openai/gpt-5.3-codex` to `openai/gpt-5.4`
and then reverted their config back to `gpt-5.3-codex` by hand had the
migration re-apply on every startup in an infinite loop. Discord bug
report pointed at the exact symptom: "i deleted the migrations and they
kept coming back".
The old migration tracking lived on the config body itself as a
`_migrations` string array. The skip-already-applied check relied on
the user not touching that field. But users hit by the unwanted
migration naturally reached for the JSON file to roll their model back,
and the natural human reaction to an incomprehensible internal field
next to their config is to delete it. That wiped the migration memory
and let the same migration re-apply at the next startup.
This PR introduces a sidecar state file that lives next to the config
as `<configPath>.migrations.json` and tracks applied migrations
outside the user's hand-editable config body. The migration pipeline:
1. Reads applied migrations from BOTH the sidecar AND the legacy
in-config `_migrations` field, unioning them. This keeps old
configs that still carry `_migrations` working without forcing
a reset.
2. Writes the updated migration set to the sidecar, never to the
config body.
3. Strips the legacy `_migrations` field out of the config body on
the first write after the sidecar takes over. Users stop seeing
the mystery internal field in their own config from that point
forward.
If the user also deletes the sidecar (explicit fresh-start gesture)
the migrations run again - that is intentional.
Tests (TDD, all new tests written before implementation):
- src/shared/migration/migrations-sidecar.test.ts - 11 unit tests
covering read/write/round-trip, malformed-payload resilience,
parent-directory creation, sorted output for stable diffs, and
non-string entry filtering.
- src/shared/migration.test.ts - 6 new integration tests under the
"migrateConfigFile with migration tracking via sidecar" block
covering: no-op path, sidecar-only write, sidecar skip after user
revert, legacy _migrations mirroring + strip, sidecar + legacy
union with dedupe, and partial-history append. Existing
"preserves existing _migrations and appends new ones" test was
rewritten to assert the new sidecar-based contract.
- Also fixes a latent test-hygiene bug: the shared
/tmp/nonexistent-path-for-test.json config path used by many
migrateConfigFile tests did not clean up its companion sidecar
between tests, letting state from one test bleed into the next.
Added afterEach that unlinks the sidecar.
Verified:
- bun test src/shared/migration/ -> 11 new sidecar tests pass
- bun test src/shared/migration.test.ts -> 82 pass, 0 fail
- bun run typecheck -> clean
- bun run script/run-ci-tests.ts -> 4458 pass, 0 fail (full suite)
This commit is contained in:
+169
-44
@@ -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<string, unknown> = {
|
||||
@@ -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<string, unknown> = {
|
||||
@@ -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<string, Record<string, unknown>>).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<string, unknown> = {
|
||||
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<string, Record<string, unknown>>).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<string, unknown> = {
|
||||
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<string, Record<string, unknown>>).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<string, unknown> = {
|
||||
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<string, Record<string, unknown>>).sisyphus.model).toBe("openai/gpt-5.4-codex")
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).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<string, unknown> = {
|
||||
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<string, Record<string, unknown>>).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<string, unknown> = {
|
||||
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<string, Record<string, unknown>>).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<string, unknown> = {
|
||||
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<string, Record<string, unknown>>).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<string, unknown> = {
|
||||
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<string, Record<string, unknown>>).codex.model).toBe("openai/gpt-5.3-codex")
|
||||
// claude migrates
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).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",
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user