From b08c269a6df001f5a298ea49a3f9f4ca00c54da5 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 4 May 2026 02:26:59 +0700 Subject: [PATCH] fix: preserve promoted slots on restart --- .../session-entry-projection.contract.test.ts | 89 +++++++++++++++++++ src/plugins/host-hook-cleanup.ts | 25 +++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index 9d248daaf5a..13a0ab1e65d 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -587,6 +587,95 @@ describe("plugin session extension SessionEntry projection", () => { } }); + it("preserves promoted SessionEntry slots on plugin restart when the slot is still declared", async () => { + const previousFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: previousFixture.registry, + config: previousFixture.config, + record: createPluginRecord({ id: "restart-preserved-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + const nextFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: nextFixture.registry, + config: nextFixture.config, + record: createPluginRecord({ id: "restart-preserved-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + setActivePluginRegistry(previousFixture.registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-restart-preserve-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "restart-preserved-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + cleanupReplacedPluginHostRegistry({ + cfg: tempConfig as never, + previousRegistry: previousFixture.registry.registry, + nextRegistry: nextFixture.registry.registry, + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + const entry = stored["agent:main:main"] as unknown as Record; + expect(entry.approvalSnapshot).toEqual({ state: "waiting" }); + expect(entry.pluginExtensionSlotKeys).toEqual({ + "restart-preserved-plugin": { + workflow: "approvalSnapshot", + }, + }); + expect(entry.pluginExtensions).toEqual({ + "restart-preserved-plugin": { + workflow: { state: "waiting" }, + }, + }); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + it("clears persisted promoted slots when registry metadata is unavailable", async () => { setActivePluginRegistry(createEmptyPluginRegistry()); const stateDir = await fs.mkdtemp( diff --git a/src/plugins/host-hook-cleanup.ts b/src/plugins/host-hook-cleanup.ts index 638973f706f..3a15f99bf4c 100644 --- a/src/plugins/host-hook-cleanup.ts +++ b/src/plugins/host-hook-cleanup.ts @@ -265,6 +265,7 @@ export async function runPluginHostCleanup(params: { runId?: string; preserveSchedulerJobIds?: ReadonlySet; shouldCleanup?: () => boolean; + restartPromotedSessionEntrySlotKeys?: ReadonlySet; }): Promise { const failures: PluginHostCleanupFailure[] = []; const shouldCleanup = params.shouldCleanup ?? (() => true); @@ -276,6 +277,8 @@ export async function runPluginHostCleanup(params: { registry ?? getActivePluginRegistry(), params.pluginId, ); + const restartPromotedSessionEntrySlotKeys = + params.restartPromotedSessionEntrySlotKeys ?? sessionEntrySlotKeys; let persistentCleanupCount = 0; if (shouldCleanup()) { try { @@ -285,7 +288,7 @@ export async function runPluginHostCleanup(params: { cfg: params.cfg ?? getRuntimeConfig(), pluginId: params.pluginId, sessionKey: params.sessionKey, - sessionEntrySlotKeys, + sessionEntrySlotKeys: restartPromotedSessionEntrySlotKeys, }) : await clearPluginOwnedSessionStores({ cfg: params.cfg ?? getRuntimeConfig(), @@ -442,6 +445,19 @@ function collectSchedulerJobIds( ); } +function collectRestartPromotedSessionEntrySlotKeys( + previousRegistry: PluginRegistry, + nextRegistry: PluginRegistry | null | undefined, + pluginId: string, +): Set { + const staleSlotKeys = collectSessionEntrySlotKeys(previousRegistry, pluginId); + const preservedSlotKeys = collectSessionEntrySlotKeys(nextRegistry, pluginId); + for (const slotKey of preservedSlotKeys) { + staleSlotKeys.delete(slotKey); + } + return staleSlotKeys; +} + export async function cleanupReplacedPluginHostRegistry(params: { cfg: OpenClawConfig; previousRegistry?: PluginRegistry | null; @@ -476,6 +492,13 @@ export async function cleanupReplacedPluginHostRegistry(params: { ? collectSchedulerJobIds(params.nextRegistry, pluginId) : undefined, shouldCleanup, + restartPromotedSessionEntrySlotKeys: restarted + ? collectRestartPromotedSessionEntrySlotKeys( + previousRegistry, + params.nextRegistry, + pluginId, + ) + : undefined, }); cleanupCount += result.cleanupCount; failures.push(...result.failures);