From 3207a8c4da5c48c7aac206e2b75b26f883053da7 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 4 May 2026 04:40:23 +0700 Subject: [PATCH] fix: scope session slot restart cleanup --- .../session-entry-projection.contract.test.ts | 109 +++++++++++++++++- src/plugins/host-hook-cleanup.ts | 43 ++++++- 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index 13a0ab1e65d..63de77be1a6 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -565,14 +565,119 @@ describe("plugin session extension SessionEntry projection", () => { const stored = loadSessionStore(storePath, { skipCache: true }); const entry = stored["agent:main:main"] as unknown as Record; expect(entry.approvalSnapshot).toBeUndefined(); - expect(entry.pluginExtensionSlotKeys).toEqual({ + expect(entry.pluginExtensionSlotKeys).toBeUndefined(); + expect(entry.pluginExtensions).toEqual({ "restart-promoted-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 only stale promoted SessionEntry slots on mixed plugin restart", async () => { + const previousFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: previousFixture.registry, + config: previousFixture.config, + record: createPluginRecord({ id: "restart-mixed-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + api.registerSessionExtension({ + namespace: "legacy", + description: "legacy promoted workflow", + sessionEntrySlotKey: "legacyApprovalSnapshot", + }); + }, + }); + const nextFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: nextFixture.registry, + config: nextFixture.config, + record: createPluginRecord({ id: "restart-mixed-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + api.registerSessionExtension({ + namespace: "legacy", + description: "legacy workflow", + }); + }, + }); + setActivePluginRegistry(previousFixture.registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-restart-mixed-"), + ); + 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-mixed-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).resolves.toMatchObject({ ok: true }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "restart-mixed-plugin", + namespace: "legacy", + value: { state: "legacy" }, + }), + ).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.legacyApprovalSnapshot).toBeUndefined(); + expect(entry.pluginExtensionSlotKeys).toEqual({ + "restart-mixed-plugin": { workflow: "approvalSnapshot", }, }); expect(entry.pluginExtensions).toEqual({ - "restart-promoted-plugin": { + "restart-mixed-plugin": { workflow: { state: "waiting" }, + legacy: { state: "legacy" }, }, }); }, diff --git a/src/plugins/host-hook-cleanup.ts b/src/plugins/host-hook-cleanup.ts index 3a15f99bf4c..6f5d9ce4774 100644 --- a/src/plugins/host-hook-cleanup.ts +++ b/src/plugins/host-hook-cleanup.ts @@ -70,12 +70,48 @@ function clearPromotedSessionEntrySlots( entry: SessionEntry, pluginId?: string, sessionEntrySlotKeys?: ReadonlySet, + options: { includeStoredSlotKeys?: boolean; pruneSlotOwnership?: boolean } = {}, ): void { - const slotKeys = collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys); + const slotKeys = + options.includeStoredSlotKeys === false && sessionEntrySlotKeys + ? new Set(sessionEntrySlotKeys) + : collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys); const entryRecord = entry as Record; for (const slotKey of slotKeys) { delete entryRecord[slotKey]; } + if (!options.pruneSlotOwnership || !entry.pluginExtensionSlotKeys) { + return; + } + const pruneRecord = (record: Record): void => { + for (const [namespace, slotKey] of Object.entries(record)) { + const normalized = normalizeSessionEntrySlotKey(slotKey); + if (normalized.ok && slotKeys.has(normalized.key)) { + delete record[namespace]; + } + } + }; + if (pluginId) { + const record = entry.pluginExtensionSlotKeys[pluginId]; + if (record) { + pruneRecord(record); + if (Object.keys(record).length === 0) { + delete entry.pluginExtensionSlotKeys[pluginId]; + } + } + } else { + for (const record of Object.values(entry.pluginExtensionSlotKeys)) { + pruneRecord(record); + } + for (const [ownerPluginId, record] of Object.entries(entry.pluginExtensionSlotKeys)) { + if (Object.keys(record).length === 0) { + delete entry.pluginExtensionSlotKeys[ownerPluginId]; + } + } + } + if (Object.keys(entry.pluginExtensionSlotKeys).length === 0) { + delete entry.pluginExtensionSlotKeys; + } } export function clearPluginOwnedSessionState( @@ -225,7 +261,10 @@ async function clearPromotedSessionEntrySlotStores(params: { ) { continue; } - clearPromotedSessionEntrySlots(entry, params.pluginId, params.sessionEntrySlotKeys); + clearPromotedSessionEntrySlots(entry, params.pluginId, params.sessionEntrySlotKeys, { + includeStoredSlotKeys: false, + pruneSlotOwnership: true, + }); entry.updatedAt = now; clearedInStore += 1; }