From 8bdfa58cbb057223f860a808ad966836c88843d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 09:07:39 +0100 Subject: [PATCH] fix(migrations): avoid partial Hermes config apply after conflict --- extensions/migrate-hermes/apply.ts | 70 ++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/extensions/migrate-hermes/apply.ts b/extensions/migrate-hermes/apply.ts index 1af8852b4fd..96b6e50de9e 100644 --- a/extensions/migrate-hermes/apply.ts +++ b/extensions/migrate-hermes/apply.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration"; +import { markMigrationItemSkipped, summarizeMigrationItems } from "openclaw/plugin-sdk/migration"; import { archiveMigrationItem, copyMigrationFileItem, @@ -18,6 +18,33 @@ import { buildHermesPlan } from "./plan.js"; import { applySecretItem } from "./secrets.js"; import { resolveTargets } from "./targets.js"; +const HERMES_REASON_BLOCKED_BY_APPLY_CONFLICT = "blocked by earlier apply conflict"; + +function withCachedConfigRuntime( + runtime: MigrationProviderContext["runtime"] | undefined, + fallbackConfig: MigrationProviderContext["config"], +): MigrationProviderContext["runtime"] | undefined { + if (!runtime?.config.writeConfigFile) { + return runtime; + } + let cachedConfig: MigrationProviderContext["config"] | undefined; + const loadConfig = () => { + cachedConfig ??= structuredClone(runtime.config.loadConfig?.() ?? fallbackConfig); + return cachedConfig; + }; + return { + ...runtime, + config: { + ...runtime.config, + loadConfig, + writeConfigFile: async (next, options) => { + cachedConfig = structuredClone(next); + await runtime.config.writeConfigFile(next, options); + }, + }, + }; +} + export async function applyHermesPlan(params: { ctx: MigrationProviderContext; plan?: MigrationPlan; @@ -27,35 +54,42 @@ export async function applyHermesPlan(params: { const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "hermes"); const targets = resolveTargets(params.ctx); const items: MigrationItem[] = []; + const runtime = withCachedConfigRuntime(params.ctx.runtime ?? params.runtime, params.ctx.config); + const applyCtx = { ...params.ctx, runtime }; + let blockedByApplyConflict = false; for (const item of plan.items) { if (item.status !== "planned") { items.push(item); continue; } + if (blockedByApplyConflict) { + items.push(markMigrationItemSkipped(item, HERMES_REASON_BLOCKED_BY_APPLY_CONFLICT)); + continue; + } + let appliedItem: MigrationItem; if (item.id === "config:default-model") { - items.push( - await applyModelItem( - { ...params.ctx, runtime: params.ctx.runtime ?? params.runtime }, - item, - ), - ); + appliedItem = await applyModelItem(applyCtx, item); } else if (item.kind === "config") { - items.push( - await applyConfigItem( - { ...params.ctx, runtime: params.ctx.runtime ?? params.runtime }, - item, - ), - ); + appliedItem = await applyConfigItem(applyCtx, item); } else if (item.kind === "manual") { - items.push(applyManualItem(item)); + appliedItem = applyManualItem(item); } else if (item.action === "archive") { - items.push(await archiveMigrationItem(item, reportDir)); + appliedItem = await archiveMigrationItem(item, reportDir); } else if (item.kind === "secret") { - items.push(await applySecretItem(params.ctx, item, targets)); + appliedItem = await applySecretItem(params.ctx, item, targets); } else if (item.action === "append") { - items.push(await appendItem(item)); + appliedItem = await appendItem(item); } else { - items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite })); + appliedItem = await copyMigrationFileItem(item, reportDir, { + overwrite: params.ctx.overwrite, + }); + } + items.push(appliedItem); + if ( + item.kind === "config" && + (appliedItem.status === "conflict" || appliedItem.status === "error") + ) { + blockedByApplyConflict = true; } } const result: MigrationApplyResult = {