From f06493f0eaffa23a18229d17eaccb07c1039e28b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 19 Apr 2026 19:24:58 -0400 Subject: [PATCH] fix: preserve deleted main session targets --- src/gateway/session-utils.test.ts | 99 ++++++++++++++++++ src/gateway/session-utils.ts | 112 ++++++++++++++++++--- src/gateway/sessions-resolve-store.test.ts | 35 +++++++ 3 files changed, 234 insertions(+), 12 deletions(-) diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 300758aaaeb..f88dbb042a3 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -331,6 +331,105 @@ describe("gateway session utils", () => { } }); + test("loadSessionEntry preserves a listed deleted main session over the live default main", async () => { + resetConfigRuntimeState(); + try { + await withStateDirEnv("session-utils-load-deleted-main-entry-", async ({ stateDir }) => { + const storeTemplate = path.join( + stateDir, + "agents", + "{agentId}", + "sessions", + "sessions.json", + ); + const liveSessionsDir = path.join(stateDir, "agents", "ops", "sessions"); + const deletedSessionsDir = path.join(stateDir, "agents", "main", "sessions"); + fs.mkdirSync(liveSessionsDir, { recursive: true }); + fs.mkdirSync(deletedSessionsDir, { recursive: true }); + const liveStorePath = path.join(liveSessionsDir, "sessions.json"); + const deletedStorePath = path.join(deletedSessionsDir, "sessions.json"); + fs.writeFileSync( + liveStorePath, + JSON.stringify({ + "agent:ops:main": { sessionId: "sess-live-default", updatedAt: 10 }, + }), + "utf8", + ); + fs.writeFileSync( + deletedStorePath, + JSON.stringify({ + "agent:main:main": { sessionId: "sess-deleted-main", updatedAt: 20 }, + }), + "utf8", + ); + const cfg = { + session: { mainKey: "main", store: storeTemplate }, + agents: { list: [{ id: "ops", default: true }] }, + } as OpenClawConfig; + setRuntimeConfigSnapshot(cfg, cfg); + + const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:main:main" }); + const loaded = loadSessionEntry("agent:main:main"); + + expect(target.canonicalKey).toBe("agent:main:main"); + expect(target.agentId).toBe("main"); + expect(target.storePath).toBe(resolveSyncRealpath(deletedStorePath)); + expect(loaded.canonicalKey).toBe("agent:main:main"); + expect(loaded.storePath).toBe(resolveSyncRealpath(deletedStorePath)); + expect(loaded.entry?.sessionId).toBe("sess-deleted-main"); + }); + } finally { + resetConfigRuntimeState(); + } + }); + + test("loadSessionEntry resolves deleted main aliases when mainKey is customized", async () => { + resetConfigRuntimeState(); + try { + await withStateDirEnv("session-utils-load-deleted-main-alias-", async ({ stateDir }) => { + const storeTemplate = path.join( + stateDir, + "agents", + "{agentId}", + "sessions", + "sessions.json", + ); + const liveSessionsDir = path.join(stateDir, "agents", "ops", "sessions"); + const deletedSessionsDir = path.join(stateDir, "agents", "main", "sessions"); + fs.mkdirSync(liveSessionsDir, { recursive: true }); + fs.mkdirSync(deletedSessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(liveSessionsDir, "sessions.json"), + JSON.stringify({ + "agent:ops:work": { sessionId: "sess-live-default", updatedAt: 10 }, + }), + "utf8", + ); + const deletedStorePath = path.join(deletedSessionsDir, "sessions.json"); + fs.writeFileSync( + deletedStorePath, + JSON.stringify({ + "agent:main:main": { sessionId: "sess-deleted-main", updatedAt: 20 }, + }), + "utf8", + ); + const cfg = { + session: { mainKey: "work", store: storeTemplate }, + agents: { list: [{ id: "ops", default: true }] }, + } as OpenClawConfig; + setRuntimeConfigSnapshot(cfg, cfg); + + const loaded = loadSessionEntry("agent:main:work"); + + expect(loaded.canonicalKey).toBe("agent:main:work"); + expect(loaded.storePath).toBe(resolveSyncRealpath(deletedStorePath)); + expect(loaded.entry?.sessionId).toBe("sess-deleted-main"); + }); + } finally { + resetConfigRuntimeState(); + } + }); + test("loadSessionEntry prefers the freshest duplicate row for a logical key", async () => { resetConfigRuntimeState(); try { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 9b145fa80e0..aacbc9b3a63 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -42,6 +42,7 @@ import { import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { + DEFAULT_AGENT_ID, normalizeAgentId, normalizeMainKey, parseAgentSessionKey, @@ -414,22 +415,23 @@ export function resolveDeletedAgentIdFromSessionKey( export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); - const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey }); - const agentId = resolveSessionStoreAgentId(cfg, canonicalKey); - const { storePath, store } = resolveGatewaySessionStoreLookup({ - cfg, - key: normalizeOptionalString(sessionKey) ?? "", - canonicalKey, - agentId, - }); + const key = normalizeOptionalString(sessionKey) ?? ""; const target = resolveGatewaySessionStoreTarget({ cfg, - key: normalizeOptionalString(sessionKey) ?? "", - store, + key, }); + const storePath = target.storePath; + const store = loadSessionStore(storePath); const freshestMatch = resolveFreshestSessionStoreMatchFromStoreKeys(store, target.storeKeys); - const legacyKey = freshestMatch?.key !== canonicalKey ? freshestMatch?.key : undefined; - return { cfg, storePath, store, entry: freshestMatch?.entry, canonicalKey, legacyKey }; + const legacyKey = freshestMatch?.key !== target.canonicalKey ? freshestMatch?.key : undefined; + return { + cfg, + storePath, + store, + entry: freshestMatch?.entry, + canonicalKey: target.canonicalKey, + legacyKey, + }; } export function resolveFreshestSessionStoreMatchFromStoreKeys( @@ -828,6 +830,83 @@ function resolveGatewaySessionStoreLookup(params: { }; } +function resolveExplicitDeletedLegacyMainStoreTarget(params: { + cfg: OpenClawConfig; + key: string; + scanLegacyKeys?: boolean; +}): { + agentId: string; + storePath: string; + canonicalKey: string; + storeKeys: string[]; +} | null { + const parsed = parseAgentSessionKey(params.key); + const legacyAgentId = normalizeAgentId(parsed?.agentId); + if ( + !parsed || + legacyAgentId !== DEFAULT_AGENT_ID || + listAgentIds(params.cfg).includes(legacyAgentId) + ) { + return null; + } + + // Only preserve agent:main:* when it is backed by a discovered deleted-main store. + // Shared-store legacy aliases should continue remapping to the configured default agent. + const canonicalKey = resolveStoredSessionKeyForAgentStore({ + cfg: params.cfg, + agentId: legacyAgentId, + sessionKey: params.key, + }); + const agentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: legacyAgentId }); + const legacyAgentMainKey = `agent:${legacyAgentId}:main`; + const lookupSeeds = Array.from( + new Set([params.key, canonicalKey, agentMainKey, legacyAgentMainKey]), + ); + let best: + | { + storePath: string; + store: Record; + match: { entry: SessionEntry; key: string }; + } + | undefined; + for (const target of resolveAllAgentSessionStoreTargetsSync(params.cfg)) { + if (target.agentId !== legacyAgentId) { + continue; + } + const store = loadSessionStore(target.storePath); + const match = findFreshestStoreMatch(store, ...lookupSeeds); + if (!match) { + continue; + } + if (!best || (match.entry.updatedAt ?? 0) >= (best.match.entry.updatedAt ?? 0)) { + best = { storePath: target.storePath, store, match }; + } + } + if (!best) { + return null; + } + + const storeKeys = new Set([canonicalKey]); + if (params.key !== canonicalKey) { + storeKeys.add(params.key); + } + storeKeys.add(best.match.key); + if (params.scanLegacyKeys !== false) { + for (const seed of lookupSeeds) { + storeKeys.add(seed); + for (const legacyKey of findStoreKeysIgnoreCase(best.store, seed)) { + storeKeys.add(legacyKey); + } + } + } + return { + agentId: legacyAgentId, + storePath: best.storePath, + canonicalKey, + storeKeys: Array.from(storeKeys), + }; +} + export function resolveGatewaySessionStoreTarget(params: { cfg: OpenClawConfig; key: string; @@ -840,6 +919,15 @@ export function resolveGatewaySessionStoreTarget(params: { storeKeys: string[]; } { const key = normalizeOptionalString(params.key) ?? ""; + const explicitDeletedMainTarget = resolveExplicitDeletedLegacyMainStoreTarget({ + cfg: params.cfg, + key, + scanLegacyKeys: params.scanLegacyKeys, + }); + if (explicitDeletedMainTarget) { + return explicitDeletedMainTarget; + } + const canonicalKey = resolveSessionStoreKey({ cfg: params.cfg, sessionKey: key, diff --git a/src/gateway/sessions-resolve-store.test.ts b/src/gateway/sessions-resolve-store.test.ts index 3b849f4f920..5177c329940 100644 --- a/src/gateway/sessions-resolve-store.test.ts +++ b/src/gateway/sessions-resolve-store.test.ts @@ -109,4 +109,39 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => { }); }); }); + + it("rejects an explicit listed deleted main key instead of remapping to the live default main", async () => { + await withStateDirEnv("openclaw-sessions-resolve-key-deleted-main-", async () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "ops", default: true }] }, + }; + const liveDefaultStorePath = resolveStorePath(cfg.session?.store, { agentId: "ops" }); + await saveSessionStore(liveDefaultStorePath, { + "agent:ops:main": { + sessionId: "sess-live-default", + updatedAt: 10, + }, + }); + const staleMainStorePath = resolveStorePath(cfg.session?.store, { agentId: "main" }); + await saveSessionStore(staleMainStorePath, { + "agent:main:main": { + sessionId: "sess-deleted-main", + updatedAt: 20, + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { key: "agent:main:main" }, + }), + ).resolves.toEqual({ + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: 'Agent "main" no longer exists in configuration', + }, + }); + }); + }); });