From 63c94952b3e4c8471c9a97127bb6590d35d27bb4 Mon Sep 17 00:00:00 2001 From: Ryan Gu <133598845+ryangu00@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:14:54 -0500 Subject: [PATCH] fix: gateway wedges on every startup when legacy runtime-state files conflict with SQLite (#116558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(state): converge deterministic legacy runtime-state conflicts instead of wedging startup Four legacy runtime-state sources still use the pre-4da0eb19c570 pattern when their JSON conflicts with canonical shared SQLite state: they push a startup warning and leave the file in place. The conflict is deterministic — every restart re-reads the same file against the same rows — so the warning feeds startupMigrationWarnings and the readiness gate refuses to report the gateway ready on every boot, and "openclaw doctor --fix" runs the identical code path and cannot clear it. That is the same non-converging failure class #112395 reports for the state-dir source and 4da0eb19c570 already removed for update-check state. Mirror the merged update-check precedent at all four sites — voice wake triggers, voice wake routing, plugin binding approvals, and current-conversation bindings: on deterministic conflict, keep SQLite canonical, emit a non-blocking notice, and archive the legacy file (the archive preserves the conflicting payload for operator inspection; nothing is deleted). Conflicting legacy entries are never imported over existing rows — unchanged from before. Read failures and migration failures keep their blocking warnings; the fail-closed boundary for non-deterministic problems does not move. The notices flow through the existing collectNotices plumbing that update-check already uses; the three affected steps now opt in. Tests: per site, a conflict case asserts no warnings, the exact notice, SQLite values preserved, and the legacy file archived; malformed-file cases assert the blocking warning remains. All new cases fail against the previous behavior. Refs #112395; complementary to #114678 (state-dir source, same failure class). Co-Authored-By: Claude Fable 5 * fix(state): keep legacy imports retryable --------- Co-authored-by: Claude Fable 5 Co-authored-by: Vincent Koc --- src/infra/state-migrations.doctor.ts | 29 +- src/infra/state-migrations.runtime-state.ts | 120 +-- src/infra/state-migrations.test.ts | 772 +++++++++++++++++++- 3 files changed, 853 insertions(+), 68 deletions(-) diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 298eb8e65f6b..ef11d05a8f71 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -1076,23 +1076,30 @@ function buildLegacyStateMigrationSteps( ), sharedStep(() => migrateLegacyTaskStateSidecars({ stateDir })), sharedStep(() => migrateLegacyDeliveryQueues({ stateDir })), - sharedStep(() => migrateLegacyVoiceWakeSettings({ detected: detected.voiceWake, stateDir })), + sharedStep( + () => migrateLegacyVoiceWakeSettings({ detected: detected.voiceWake, stateDir }), + true, + ), sharedStep( () => migrateLegacyUpdateCheckState({ detected: detected.updateCheck, stateDir }), true, ), sharedStep(() => migrateLegacyConfigHealth({ detected: detected.configHealth, stateDir })), - sharedStep(() => - migrateLegacyPluginBindingApprovals({ - detected: detected.pluginBindingApprovals, - stateDir, - }), + sharedStep( + () => + migrateLegacyPluginBindingApprovals({ + detected: detected.pluginBindingApprovals, + stateDir, + }), + true, ), - sharedStep(() => - migrateLegacyCurrentConversationBindings({ - detected: detected.currentConversationBindings, - stateDir, - }), + sharedStep( + () => + migrateLegacyCurrentConversationBindings({ + detected: detected.currentConversationBindings, + stateDir, + }), + true, ), ]; diff --git a/src/infra/state-migrations.runtime-state.ts b/src/infra/state-migrations.runtime-state.ts index a6b2f0c45dbb..444e97291fa3 100644 --- a/src/infra/state-migrations.runtime-state.ts +++ b/src/infra/state-migrations.runtime-state.ts @@ -14,7 +14,7 @@ import { normalizeConversationRef } from "./outbound/session-binding-normalizati import type { SessionBindingRecord } from "./outbound/session-binding.types.js"; import { fileExists } from "./state-migrations.fs.js"; import { archiveLegacyImportSource } from "./state-migrations.storage.js"; -import type { LegacyStateDetection } from "./state-migrations.types.js"; +import type { LegacyStateDetection, MigrationMessages } from "./state-migrations.types.js"; import { normalizeVoiceWakeRoutingConfig } from "./voicewake-routing.js"; type LegacyVoiceWakeImportDatabase = Pick< @@ -137,9 +137,10 @@ function legacyVoiceWakeRoutingMatches( export function migrateLegacyVoiceWakeSettings(params: { detected: LegacyStateDetection["voiceWake"]; stateDir: string; -}): { changes: string[]; warnings: string[] } { +}): MigrationMessages { const changes: string[] = []; const warnings: string[] = []; + const notices: string[] = []; const env = { ...process.env, OPENCLAW_STATE_DIR: params.stateDir }; if (fileExists(params.detected.triggersPath)) { let triggers: string[]; @@ -170,12 +171,12 @@ export function migrateLegacyVoiceWakeSettings(params: { ).rows; if (existing.length > 0) { if (!legacyVoiceWakeTriggersMatch(existing, triggers)) { - warnings.push( - `Left legacy voice wake triggers in place because shared SQLite state already has different triggers: ${params.detected.triggersPath}`, + // SQLite is canonical; retaining divergent JSON would block every startup. + notices.push( + `Kept shared SQLite voice wake triggers because legacy file differs: ${params.detected.triggersPath}`, ); - } else { - shouldArchive = true; } + shouldArchive = true; return; } const updatedAtMs = Date.now(); @@ -255,9 +256,11 @@ export function migrateLegacyVoiceWakeSettings(params: { if (legacyVoiceWakeRoutingMatches(existing, routeRows, routingConfig)) { shouldArchive = true; } else { - warnings.push( - `Left legacy voice wake routing in place because shared SQLite routing already exists with different routes: ${params.detected.routingPath}`, + // SQLite is canonical; retaining divergent JSON would block every startup. + notices.push( + `Kept shared SQLite voice wake routing because legacy file differs: ${params.detected.routingPath}`, ); + shouldArchive = true; } return; } @@ -317,7 +320,7 @@ export function migrateLegacyVoiceWakeSettings(params: { } } - return { changes, warnings }; + return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings }; } type LegacyConfigHealthFile = { @@ -646,9 +649,10 @@ function pluginBindingApprovalComparable(entry: LegacyPluginBindingApprovalEntry export function migrateLegacyPluginBindingApprovals(params: { detected: LegacyStateDetection["pluginBindingApprovals"]; stateDir: string; -}): { changes: string[]; warnings: string[] } { +}): MigrationMessages { const changes: string[] = []; const warnings: string[] = []; + const notices: string[] = []; // Detection requires the source to belong to this state root; fileExists // re-checks for races before the import mutates the same trust scope. if (!params.detected.hasLegacy || !fileExists(params.detected.sourcePath)) { @@ -666,10 +670,13 @@ export function migrateLegacyPluginBindingApprovals(params: { return { changes, warnings }; } - let importedCount = 0; - let shouldArchive = approvals.length === 0; + let outcome = { + conflictCount: 0, + importedCount: 0, + shouldArchive: false, + }; try { - runOpenClawStateWriteTransaction( + outcome = runOpenClawStateWriteTransaction( ({ db }) => { const stateDb = getNodeSqliteKysely(db); const existing = executeSqliteQuerySync( @@ -723,26 +730,30 @@ export function migrateLegacyPluginBindingApprovals(params: { .insertInto("plugin_binding_approvals") .values(approvalsToInsert.map(pluginBindingApprovalRow)), ); - importedCount = approvalsToInsert.length; - } - shouldArchive = conflictCount === 0; - if (conflictCount > 0) { - warnings.push( - `Left legacy plugin binding approvals in place because ${conflictCount} ${conflictCount === 1 ? "approval conflicts" : "approvals conflict"} with shared SQLite state: ${params.detected.sourcePath}`, - ); } + // Publish archive/count state only after COMMIT succeeds; otherwise retry needs the source. + return { + conflictCount, + importedCount: approvalsToInsert.length, + shouldArchive: true, + }; }, { env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } }, ); } catch (err) { warnings.push(`Failed migrating legacy plugin binding approvals: ${String(err)}`); } - if (importedCount > 0) { - changes.push( - `Migrated ${importedCount} plugin binding ${importedCount === 1 ? "approval" : "approvals"} → shared SQLite state`, + if (outcome.conflictCount > 0) { + notices.push( + `Kept shared SQLite plugin binding approvals because ${outcome.conflictCount} ${outcome.conflictCount === 1 ? "legacy approval conflicts" : "legacy approvals conflict"}: ${params.detected.sourcePath}`, ); } - if (shouldArchive) { + if (outcome.importedCount > 0) { + changes.push( + `Migrated ${outcome.importedCount} plugin binding ${outcome.importedCount === 1 ? "approval" : "approvals"} → shared SQLite state`, + ); + } + if (outcome.shouldArchive) { archiveLegacyImportSource({ sourcePath: params.detected.sourcePath, label: "plugin binding approvals", @@ -750,7 +761,7 @@ export function migrateLegacyPluginBindingApprovals(params: { warnings, }); } - return { changes, warnings }; + return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings }; } const CURRENT_BINDING_CONVERSATION_KIND = "current"; @@ -872,9 +883,10 @@ function currentConversationBindingRow(record: SessionBindingRecord): { export function migrateLegacyCurrentConversationBindings(params: { detected: LegacyStateDetection["currentConversationBindings"]; stateDir: string; -}): { changes: string[]; warnings: string[] } { +}): MigrationMessages { const changes: string[] = []; const warnings: string[] = []; + const notices: string[] = []; if (!fileExists(params.detected.sourcePath)) { return { changes, warnings }; } @@ -890,10 +902,13 @@ export function migrateLegacyCurrentConversationBindings(params: { return { changes, warnings }; } - let importedCount = 0; - let shouldArchive = records.length === 0; + let outcome = { + conflictCount: 0, + importedCount: 0, + shouldArchive: false, + }; try { - runOpenClawStateWriteTransaction( + outcome = runOpenClawStateWriteTransaction( ({ db }) => { const stateDb = getNodeSqliteKysely(db); const existing = executeSqliteQuerySync( @@ -916,40 +931,37 @@ export function migrateLegacyCurrentConversationBindings(params: { conflictCount += 1; } } - if (recordsToInsert.length === 0) { - shouldArchive = conflictCount === 0; - if (conflictCount > 0) { - warnings.push( - `Left legacy current-conversation bindings in place because ${conflictCount} ${conflictCount === 1 ? "binding conflicts" : "bindings conflict"} with shared SQLite state: ${params.detected.sourcePath}`, - ); - } - return; - } - executeSqliteQuerySync( - db, - stateDb - .insertInto("current_conversation_bindings") - .values(recordsToInsert.map(currentConversationBindingRow)), - ); - importedCount = recordsToInsert.length; - shouldArchive = conflictCount === 0; - if (conflictCount > 0) { - warnings.push( - `Left legacy current-conversation bindings in place because ${conflictCount} ${conflictCount === 1 ? "binding conflicts" : "bindings conflict"} with shared SQLite state: ${params.detected.sourcePath}`, + if (recordsToInsert.length > 0) { + executeSqliteQuerySync( + db, + stateDb + .insertInto("current_conversation_bindings") + .values(recordsToInsert.map(currentConversationBindingRow)), ); } + // Publish archive/count state only after COMMIT succeeds; otherwise retry needs the source. + return { + conflictCount, + importedCount: recordsToInsert.length, + shouldArchive: true, + }; }, { env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } }, ); } catch (err) { warnings.push(`Failed migrating legacy current-conversation bindings: ${String(err)}`); } - if (importedCount > 0) { - changes.push( - `Migrated ${importedCount} current-conversation ${importedCount === 1 ? "binding" : "bindings"} → shared SQLite state`, + if (outcome.conflictCount > 0) { + notices.push( + `Kept shared SQLite current-conversation bindings because ${outcome.conflictCount} ${outcome.conflictCount === 1 ? "legacy binding conflicts" : "legacy bindings conflict"}: ${params.detected.sourcePath}`, ); } - if (shouldArchive) { + if (outcome.importedCount > 0) { + changes.push( + `Migrated ${outcome.importedCount} current-conversation ${outcome.importedCount === 1 ? "binding" : "bindings"} → shared SQLite state`, + ); + } + if (outcome.shouldArchive) { archiveLegacyImportSource({ sourcePath: params.detected.sourcePath, label: "current-conversation bindings", @@ -957,6 +969,6 @@ export function migrateLegacyCurrentConversationBindings(params: { warnings, }); } - return { changes, warnings }; + return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings }; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 188db445bd6e..de486353af1b 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -38,6 +38,10 @@ import { runLegacyStateMigrations, } from "./state-migrations.js"; import * as sessionStore from "./state-migrations.legacy-session-store.js"; +import { + migrateLegacyCurrentConversationBindings, + migrateLegacyPluginBindingApprovals, +} from "./state-migrations.runtime-state.js"; import { loadVoiceWakeRoutingConfig, setVoiceWakeRoutingConfig } from "./voicewake-routing.js"; import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "./voicewake.js"; @@ -189,6 +193,32 @@ async function expectMissingPath(targetPath: string): Promise { expect(statError?.path).toBe(targetPath); expect(statError?.syscall).toBe("stat"); } + +function failArchiveRenameOnce(sourcePath: string) { + const actualRenameSync = fsSync.renameSync.bind(fsSync); + let failed = false; + return vi.spyOn(fsSync, "renameSync").mockImplementation((from, to) => { + if (!failed && String(from) === sourcePath) { + failed = true; + throw new Error("forced archive failure"); + } + actualRenameSync(from, to); + }); +} + +function failNextStateDbCommit(env: NodeJS.ProcessEnv) { + const { db } = openOpenClawStateDatabase({ env }); + const actualExec = db.exec.bind(db); + let failed = false; + return vi.spyOn(db, "exec").mockImplementation((sql) => { + if (!failed && sql.trim() === "COMMIT") { + failed = true; + throw new Error("forced commit failure"); + } + actualExec(sql); + }); +} + const createTempDir = () => tempDirs.make("openclaw-state-migrations-test-"); function readUpdateCheckState(env: NodeJS.ProcessEnv): @@ -304,6 +334,22 @@ function readPluginBindingApprovalRows(env: NodeJS.ProcessEnv): Array<{ ).rows; } +function insertPluginBindingApprovalRow( + env: NodeJS.ProcessEnv, + row: { + plugin_root: string; + channel: string; + account_id: string; + plugin_id: string; + plugin_name: string | null; + approved_at: number; + }, +): void { + const { db } = openOpenClawStateDatabase({ env }); + const stateDb = getNodeSqliteKysely(db); + executeSqliteQuerySync(db, stateDb.insertInto("plugin_binding_approvals").values(row)); +} + function insertCurrentConversationBindingRow( env: NodeJS.ProcessEnv, params: { @@ -370,6 +416,139 @@ function createEnv(stateDir: string): NodeJS.ProcessEnv { }; } +type MixedCommitFailureFixture = { + env: NodeJS.ProcessEnv; + expectedWarning: string; + migrate: () => { notices?: string[]; warnings: string[] }; + readRowCount: () => number; + sourceFragment: string; + sourcePath: string; +}; + +async function createMixedPluginBindingCommitFailureFixture(): Promise { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const sourcePath = path.join(stateDir, "plugin-binding-approvals.json"); + insertPluginBindingApprovalRow(env, { + plugin_root: "/plugins/conflict", + channel: "discord", + account_id: "default", + plugin_id: "sqlite-plugin", + plugin_name: "SQLite Plugin", + approved_at: 1, + }); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + version: 1, + approvals: [ + { + pluginRoot: "/plugins/conflict", + pluginId: "legacy-plugin", + pluginName: "Legacy Plugin", + channel: "discord", + accountId: "default", + approvedAt: 2, + }, + { + pluginRoot: "/plugins/import", + pluginId: "imported-plugin", + pluginName: "Imported Plugin", + channel: "telegram", + accountId: "default", + approvedAt: 3, + }, + ], + }), + "utf8", + ); + return { + env, + expectedWarning: + "Failed migrating legacy plugin binding approvals: Error: forced commit failure", + migrate: () => + migrateLegacyPluginBindingApprovals({ + detected: { sourcePath, hasLegacy: true }, + stateDir, + }), + readRowCount: () => readPluginBindingApprovalRows(env).length, + sourceFragment: "Imported Plugin", + sourcePath, + }; +} + +async function createMixedCurrentConversationCommitFailureFixture(): Promise { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const bindingsDir = path.join(stateDir, "bindings"); + const sourcePath = path.join(bindingsDir, "current-conversations.json"); + const conflictingKey = "workspace\u241fdefault\u241f\u241fuser:U123"; + insertCurrentConversationBindingRow(env, { + bindingKey: conflictingKey, + bindingId: `generic:${conflictingKey}`, + targetSessionKey: "agent:codex:acp:existing", + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + recordJson: JSON.stringify({ + bindingId: `generic:${conflictingKey}`, + targetSessionKey: "agent:codex:acp:existing", + targetKind: "session", + conversation: { + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }, + status: "active", + boundAt: 1, + }), + }); + await fs.mkdir(bindingsDir, { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + version: 1, + bindings: [ + { + targetSessionKey: "agent:codex:acp:legacy-conflict", + conversation: { + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }, + boundAt: 2, + }, + { + targetSessionKey: "agent:codex:acp:legacy-missing", + conversation: { + channel: "workspace", + accountId: "default", + conversationId: "user:U456", + }, + boundAt: 3, + }, + ], + }), + "utf8", + ); + return { + env, + expectedWarning: + "Failed migrating legacy current-conversation bindings: Error: forced commit failure", + migrate: () => + migrateLegacyCurrentConversationBindings({ + detected: { sourcePath, hasLegacy: true }, + stateDir, + }), + readRowCount: () => readCurrentConversationBindingRows(env).length, + sourceFragment: "legacy-missing", + sourcePath, + }; +} + async function createLegacyAuditLedger(stateDir: string): Promise { const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); await fs.mkdir(path.dirname(databasePath), { recursive: true }); @@ -1933,6 +2112,204 @@ describe("state migrations", () => { await expect(fs.readFile(`${routingPath}.migrated`, "utf8")).resolves.toContain("robot wake"); }); + it("archives divergent legacy voice wake triggers and keeps shared SQLite canonical", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const cfg = createConfig(); + const triggersPath = path.join(stateDir, "settings", "voicewake.json"); + await setVoiceWakeTriggers(["sqlite wake"], stateDir); + await fs.mkdir(path.dirname(triggersPath), { recursive: true }); + await fs.writeFile(triggersPath, JSON.stringify({ triggers: ["legacy wake"] }), "utf8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: createEnv(stateDir), + homedir: () => root, + }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + expect(result.notices).toEqual([ + `Kept shared SQLite voice wake triggers because legacy file differs: ${triggersPath}`, + ]); + await expect(loadVoiceWakeConfig(stateDir)).resolves.toMatchObject({ + triggers: ["sqlite wake"], + }); + await expectMissingPath(triggersPath); + await expect(fs.readFile(`${triggersPath}.migrated`, "utf8")).resolves.toContain("legacy wake"); + }); + + it("keeps a failed voice wake triggers archive blocking and converges on retry", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const cfg = createConfig(); + const triggersPath = path.join(stateDir, "settings", "voicewake.json"); + await setVoiceWakeTriggers(["sqlite wake"], stateDir); + await fs.mkdir(path.dirname(triggersPath), { recursive: true }); + await fs.writeFile(triggersPath, JSON.stringify({ triggers: ["legacy wake"] }), "utf8"); + + const rename = failArchiveRenameOnce(triggersPath); + const detected = await detectLegacyStateMigrations({ + cfg, + env: createEnv(stateDir), + homedir: () => root, + }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + rename.mockRestore(); + + expect(result.warnings).toStrictEqual([ + `Failed archiving voice wake triggers legacy source ${triggersPath}: Error: forced archive failure`, + ]); + expect(result.notices).toEqual([ + `Kept shared SQLite voice wake triggers because legacy file differs: ${triggersPath}`, + ]); + await expect(fs.readFile(triggersPath, "utf8")).resolves.toContain("legacy wake"); + await expectMissingPath(`${triggersPath}.migrated`); + + const retryDetected = await detectLegacyStateMigrations({ + cfg, + env: createEnv(stateDir), + homedir: () => root, + }); + const retry = await runLegacyStateMigrations({ detected: retryDetected, config: cfg }); + expect(retry.warnings).toStrictEqual([]); + await expectMissingPath(triggersPath); + }); + + it("leaves malformed legacy voice wake triggers in place with a warning", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const cfg = createConfig(); + const triggersPath = path.join(stateDir, "settings", "voicewake.json"); + await fs.mkdir(path.dirname(triggersPath), { recursive: true }); + await fs.writeFile(triggersPath, "{ malformed", "utf8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: createEnv(stateDir), + homedir: () => root, + }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("Failed reading legacy voice wake triggers"); + expect(result.notices).toBeUndefined(); + await expect(fs.access(triggersPath)).resolves.toBeUndefined(); + await expectMissingPath(`${triggersPath}.migrated`); + }); + + it("archives divergent legacy voice wake routing and keeps shared SQLite canonical", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const cfg = createConfig(); + const routingPath = path.join(stateDir, "settings", "voicewake-routing.json"); + await setVoiceWakeRoutingConfig( + { + defaultTarget: { mode: "current" }, + routes: [{ trigger: "sqlite wake", target: { agentId: "main" } }], + }, + stateDir, + ); + await fs.mkdir(path.dirname(routingPath), { recursive: true }); + await fs.writeFile( + routingPath, + JSON.stringify({ + defaultTarget: { mode: "current" }, + routes: [{ trigger: "legacy wake", target: { agentId: "main" } }], + }), + "utf8", + ); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: createEnv(stateDir), + homedir: () => root, + }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + expect(result.notices).toEqual([ + `Kept shared SQLite voice wake routing because legacy file differs: ${routingPath}`, + ]); + await expect(loadVoiceWakeRoutingConfig(stateDir)).resolves.toMatchObject({ + routes: [{ trigger: "sqlite wake", target: { agentId: "main" } }], + }); + await expectMissingPath(routingPath); + await expect(fs.readFile(`${routingPath}.migrated`, "utf8")).resolves.toContain("legacy wake"); + }); + + it("keeps a failed voice wake routing archive blocking and converges on retry", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const cfg = createConfig(); + const routingPath = path.join(stateDir, "settings", "voicewake-routing.json"); + await setVoiceWakeRoutingConfig( + { + defaultTarget: { mode: "current" }, + routes: [{ trigger: "sqlite wake", target: { agentId: "main" } }], + }, + stateDir, + ); + await fs.mkdir(path.dirname(routingPath), { recursive: true }); + await fs.writeFile( + routingPath, + JSON.stringify({ + defaultTarget: { mode: "current" }, + routes: [{ trigger: "legacy wake", target: { agentId: "main" } }], + }), + "utf8", + ); + + const rename = failArchiveRenameOnce(routingPath); + const detected = await detectLegacyStateMigrations({ + cfg, + env: createEnv(stateDir), + homedir: () => root, + }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + rename.mockRestore(); + + expect(result.warnings).toStrictEqual([ + `Failed archiving voice wake routing legacy source ${routingPath}: Error: forced archive failure`, + ]); + expect(result.notices).toEqual([ + `Kept shared SQLite voice wake routing because legacy file differs: ${routingPath}`, + ]); + await expect(fs.readFile(routingPath, "utf8")).resolves.toContain("legacy wake"); + await expectMissingPath(`${routingPath}.migrated`); + + const retryDetected = await detectLegacyStateMigrations({ + cfg, + env: createEnv(stateDir), + homedir: () => root, + }); + const retry = await runLegacyStateMigrations({ detected: retryDetected, config: cfg }); + expect(retry.warnings).toStrictEqual([]); + await expectMissingPath(routingPath); + }); + + it("leaves malformed legacy voice wake routing in place with a warning", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const cfg = createConfig(); + const routingPath = path.join(stateDir, "settings", "voicewake-routing.json"); + await fs.mkdir(path.dirname(routingPath), { recursive: true }); + await fs.writeFile(routingPath, "{ malformed", "utf8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: createEnv(stateDir), + homedir: () => root, + }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("Failed reading legacy voice wake routing"); + expect(result.notices).toBeUndefined(); + await expect(fs.access(routingPath)).resolves.toBeUndefined(); + await expectMissingPath(`${routingPath}.migrated`); + }); + it("auto-migrates standalone legacy JSON settings", async () => { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw"); @@ -3045,6 +3422,204 @@ describe("state migrations", () => { ); }); + it("archives conflicting plugin binding approvals without overwriting shared SQLite", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const sourcePath = path.join(stateDir, "plugin-binding-approvals.json"); + insertPluginBindingApprovalRow(env, { + plugin_root: "/plugins/conflict", + channel: "discord", + account_id: "default", + plugin_id: "sqlite-plugin", + plugin_name: "SQLite Plugin", + approved_at: 1, + }); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + version: 1, + approvals: [ + { + pluginRoot: "/plugins/conflict", + pluginId: "legacy-plugin", + pluginName: "Legacy Plugin", + channel: "discord", + accountId: "default", + approvedAt: 2, + }, + { + pluginRoot: "/plugins/import", + pluginId: "imported-plugin", + pluginName: "Imported Plugin", + channel: "telegram", + accountId: "default", + approvedAt: 3, + }, + ], + }), + "utf8", + ); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + expect(result.notices).toEqual([ + `Kept shared SQLite plugin binding approvals because 1 legacy approval conflicts: ${sourcePath}`, + ]); + expect(result.changes).toContain("Migrated 1 plugin binding approval → shared SQLite state"); + expect(readPluginBindingApprovalRows(env)).toEqual([ + { + plugin_root: "/plugins/conflict", + channel: "discord", + account_id: "default", + plugin_id: "sqlite-plugin", + plugin_name: "SQLite Plugin", + approved_at: 1, + }, + { + plugin_root: "/plugins/import", + channel: "telegram", + account_id: "default", + plugin_id: "imported-plugin", + plugin_name: "Imported Plugin", + approved_at: 3, + }, + ]); + await expectMissingPath(sourcePath); + await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain("Legacy Plugin"); + }); + + it("archives a legacy plugin binding approvals file when every approval conflicts", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const sourcePath = path.join(stateDir, "plugin-binding-approvals.json"); + insertPluginBindingApprovalRow(env, { + plugin_root: "/plugins/conflict", + channel: "discord", + account_id: "default", + plugin_id: "sqlite-plugin", + plugin_name: "SQLite Plugin", + approved_at: 1, + }); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + version: 1, + approvals: [ + { + pluginRoot: "/plugins/conflict", + pluginId: "legacy-plugin", + pluginName: "Legacy Plugin", + channel: "discord", + accountId: "default", + approvedAt: 2, + }, + ], + }), + "utf8", + ); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + expect(result.notices).toEqual([ + `Kept shared SQLite plugin binding approvals because 1 legacy approval conflicts: ${sourcePath}`, + ]); + expect(result.changes.filter((change) => change.startsWith("Migrated"))).toStrictEqual([]); + expect(readPluginBindingApprovalRows(env)).toEqual([ + { + plugin_root: "/plugins/conflict", + channel: "discord", + account_id: "default", + plugin_id: "sqlite-plugin", + plugin_name: "SQLite Plugin", + approved_at: 1, + }, + ]); + await expectMissingPath(sourcePath); + await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain("legacy-plugin"); + }); + + it("keeps a failed plugin binding approvals archive blocking and converges on retry", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const sourcePath = path.join(stateDir, "plugin-binding-approvals.json"); + insertPluginBindingApprovalRow(env, { + plugin_root: "/plugins/conflict", + channel: "discord", + account_id: "default", + plugin_id: "sqlite-plugin", + plugin_name: "SQLite Plugin", + approved_at: 1, + }); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + version: 1, + approvals: [ + { + pluginRoot: "/plugins/conflict", + pluginId: "legacy-plugin", + pluginName: "Legacy Plugin", + channel: "discord", + accountId: "default", + approvedAt: 2, + }, + ], + }), + "utf8", + ); + + const rename = failArchiveRenameOnce(sourcePath); + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + rename.mockRestore(); + + expect(result.warnings).toStrictEqual([ + `Failed archiving plugin binding approvals legacy source ${sourcePath}: Error: forced archive failure`, + ]); + expect(result.notices).toEqual([ + `Kept shared SQLite plugin binding approvals because 1 legacy approval conflicts: ${sourcePath}`, + ]); + await expect(fs.readFile(sourcePath, "utf8")).resolves.toContain("legacy-plugin"); + await expectMissingPath(`${sourcePath}.migrated`); + + const retryDetected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const retry = await runLegacyStateMigrations({ detected: retryDetected, config: cfg }); + expect(retry.warnings).toStrictEqual([]); + await expectMissingPath(sourcePath); + }); + + it("leaves malformed plugin binding approvals in place with a warning", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const sourcePath = path.join(stateDir, "plugin-binding-approvals.json"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(sourcePath, "{ malformed", "utf8"); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("Failed reading legacy plugin binding approvals"); + expect(result.notices).toBeUndefined(); + await expect(fs.access(sourcePath)).resolves.toBeUndefined(); + await expectMissingPath(`${sourcePath}.migrated`); + }); + it("never imports home-state plugin approvals into a custom state dir", async () => { // Regression: direct doctor repair follows the same trust boundary as // automatic startup migration and cannot archive another state's policy. @@ -3202,9 +3777,10 @@ describe("state migrations", () => { expect(result.changes).toContain( "Migrated 1 current-conversation binding → shared SQLite state", ); - expect(result.warnings).toContain( - `Left legacy current-conversation bindings in place because 1 binding conflicts with shared SQLite state: ${sourcePath}`, - ); + expect(result.warnings).toStrictEqual([]); + expect(result.notices).toEqual([ + `Kept shared SQLite current-conversation bindings because 1 legacy binding conflicts: ${sourcePath}`, + ]); expect(readCurrentConversationBindingRows(env)).toMatchObject([ { binding_key: conflictingKey, @@ -3215,7 +3791,197 @@ describe("state migrations", () => { target_session_key: "agent:codex:acp:legacy-missing", }, ]); + await expectMissingPath(sourcePath); + await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain( + "legacy-conflict", + ); + }); + + it.each([ + { + name: "plugin binding approvals", + setup: createMixedPluginBindingCommitFailureFixture, + }, + { + name: "current-conversation bindings", + setup: createMixedCurrentConversationCommitFailureFixture, + }, + ])("keeps mixed $name retryable when SQLite commit fails", async ({ setup }) => { + const fixture = await setup(); + const commit = failNextStateDbCommit(fixture.env); + const result = fixture.migrate(); + commit.mockRestore(); + + expect(result.warnings).toEqual([fixture.expectedWarning]); + expect(result.notices).toBeUndefined(); + expect(fixture.readRowCount()).toBe(1); + await expect(fs.readFile(fixture.sourcePath, "utf8")).resolves.toContain( + fixture.sourceFragment, + ); + await expectMissingPath(`${fixture.sourcePath}.migrated`); + + const retry = fixture.migrate(); + expect(retry.warnings).toStrictEqual([]); + expect(fixture.readRowCount()).toBe(2); + await expectMissingPath(fixture.sourcePath); + }); + + it("archives a legacy current-conversation file when every binding conflicts", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const sourcePath = path.join(stateDir, "bindings", "current-conversations.json"); + const bindingKey = "workspace\u241fdefault\u241f\u241fuser:U123"; + insertCurrentConversationBindingRow(env, { + bindingKey, + bindingId: `generic:${bindingKey}`, + targetSessionKey: "agent:codex:acp:existing", + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + recordJson: JSON.stringify({ + bindingId: `generic:${bindingKey}`, + targetSessionKey: "agent:codex:acp:existing", + targetKind: "session", + conversation: { + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }, + status: "active", + boundAt: 1, + }), + }); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + version: 1, + bindings: [ + { + bindingId: `generic:${bindingKey}`, + targetSessionKey: "agent:codex:acp:legacy-conflict", + targetKind: "session", + conversation: { + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }, + status: "active", + boundAt: 2, + }, + ], + }), + "utf8", + ); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + expect(result.notices).toEqual([ + `Kept shared SQLite current-conversation bindings because 1 legacy binding conflicts: ${sourcePath}`, + ]); + expect(readCurrentConversationBindingRows(env)).toMatchObject([ + { + binding_key: bindingKey, + target_session_key: "agent:codex:acp:existing", + }, + ]); + await expectMissingPath(sourcePath); + await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain( + "legacy-conflict", + ); + }); + + it("keeps a failed current-conversation bindings archive blocking and converges on retry", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const sourcePath = path.join(stateDir, "bindings", "current-conversations.json"); + const bindingKey = "workspace\u241fdefault\u241f\u241fuser:U123"; + insertCurrentConversationBindingRow(env, { + bindingKey, + bindingId: `generic:${bindingKey}`, + targetSessionKey: "agent:codex:acp:existing", + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + recordJson: JSON.stringify({ + bindingId: `generic:${bindingKey}`, + targetSessionKey: "agent:codex:acp:existing", + targetKind: "session", + conversation: { + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }, + status: "active", + boundAt: 1, + }), + }); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + version: 1, + bindings: [ + { + bindingId: `generic:${bindingKey}`, + targetSessionKey: "agent:codex:acp:legacy-conflict", + targetKind: "session", + conversation: { + channel: "workspace", + accountId: "default", + conversationId: "user:U123", + }, + status: "active", + boundAt: 2, + }, + ], + }), + "utf8", + ); + + const rename = failArchiveRenameOnce(sourcePath); + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + rename.mockRestore(); + + expect(result.warnings).toStrictEqual([ + `Failed archiving current-conversation bindings legacy source ${sourcePath}: Error: forced archive failure`, + ]); + expect(result.notices).toEqual([ + `Kept shared SQLite current-conversation bindings because 1 legacy binding conflicts: ${sourcePath}`, + ]); await expect(fs.readFile(sourcePath, "utf8")).resolves.toContain("legacy-conflict"); + await expectMissingPath(`${sourcePath}.migrated`); + + const retryDetected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const retry = await runLegacyStateMigrations({ detected: retryDetected, config: cfg }); + expect(retry.warnings).toStrictEqual([]); + await expectMissingPath(sourcePath); + }); + + it("leaves malformed current-conversation bindings in place with a warning", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const sourcePath = path.join(stateDir, "bindings", "current-conversations.json"); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile(sourcePath, "{ malformed", "utf8"); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("Failed reading legacy current-conversation bindings"); + expect(result.notices).toBeUndefined(); + await expect(fs.access(sourcePath)).resolves.toBeUndefined(); + await expectMissingPath(`${sourcePath}.migrated`); }); it("keeps legacy delivery queue files when shared SQLite already has a conflicting row", async () => {