fix: gateway wedges on every startup when legacy runtime-state files conflict with SQLite (#116558)

* 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 4da0eb19c5 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 <noreply@anthropic.com>

* fix(state): keep legacy imports retryable

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Ryan Gu
2026-07-30 21:14:54 -05:00
committed by GitHub
parent 0929a00c37
commit 63c94952b3
3 changed files with 853 additions and 68 deletions
+18 -11
View File
@@ -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,
),
];
+66 -54
View File
@@ -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<LegacyPluginBindingApprovalsImportDatabase>(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<LegacyCurrentConversationBindingsImportDatabase>(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. */
+769 -3
View File
@@ -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<void> {
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<PluginBindingApprovalsDatabase>(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<MixedCommitFailureFixture> {
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<MixedCommitFailureFixture> {
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<string> {
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 () => {