fix: preserve deleted main session targets

This commit is contained in:
Gustavo Madeira Santana
2026-04-19 19:24:58 -04:00
parent d41c9860d7
commit f06493f0ea
3 changed files with 234 additions and 12 deletions

View File

@@ -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 {

View File

@@ -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<string, SessionEntry>;
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<string>([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,

View File

@@ -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',
},
});
});
});
});