mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 11:15:00 +00:00
fix(gateway): normalize session key casing to prevent ghost sessions (#12846)
* fix(gateway): normalize session key casing to prevent ghost sessions on Linux On case-sensitive filesystems (Linux), mixed-case session keys like agent:ops:MySession and agent:ops:mysession resolve to different store entries, creating ghost duplicates that never converge. Core changes in session-utils.ts: - resolveSessionStoreKey: lowercase all session key components - canonicalizeSpawnedByForAgent: accept cfg, resolve main-alias references via canonicalizeMainSessionAlias after lowercasing - loadSessionEntry: return legacyKey only when it differs from canonicalKey - resolveGatewaySessionStoreTarget: scan store for case-insensitive matches; add optional scanLegacyKeys param to skip disk reads for read-only callers - Export findStoreKeysIgnoreCase for use by write-path consumers - Compare global/unknown sentinels case-insensitively in all canonicalization functions sessions-resolve.ts: - Make resolveSessionKeyFromResolveParams async for inline migration - Check canonical key first (fast path), then fall back to legacy scan - Delete ALL legacy case-variant keys in a single updateSessionStore pass Fixes #12603 * fix(gateway): propagate canonical keys and clean up all case variants on write paths - agent.ts: use canonicalizeSpawnedByForAgent (with cfg) instead of raw toLowerCase; use findStoreKeysIgnoreCase to delete all legacy variants on store write; pass canonicalKey to addChatRun, registerAgentRunContext, resolveSendPolicy, and agentCommand - sessions.ts: replace single-key migration with full case-variant cleanup via findStoreKeysIgnoreCase in patch/reset/delete/compact handlers; add case-insensitive fallback in preview (store already loaded); make sessions.resolve handler async; pass scanLegacyKeys: false in preview - server-node-events.ts: use findStoreKeysIgnoreCase to clean all legacy variants on voice.transcript and agent.request write paths; pass canonicalKey to addChatRun and agentCommand * test(gateway): add session key case-normalization tests Cover the case-insensitive session key canonicalization logic: - resolveSessionStoreKey normalizes mixed-case bare and prefixed keys - resolveSessionStoreKey resolves mixed-case main aliases (MAIN, Main) - resolveGatewaySessionStoreTarget includes legacy mixed-case store keys - resolveGatewaySessionStoreTarget collects all case-variant duplicates - resolveGatewaySessionStoreTarget finds legacy main alias keys with customized mainKey configuration All 5 tests fail before the production changes, pass after. * fix: clean legacy session alias cleanup gaps (openclaw#12846) thanks @mcaxtr --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
listSessionsFromStore,
|
||||
loadCombinedSessionStoreForGateway,
|
||||
loadSessionEntry,
|
||||
pruneLegacyStoreKeys,
|
||||
readSessionPreviewItemsFromTranscript,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionModelRef,
|
||||
@@ -42,6 +43,31 @@ import {
|
||||
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
||||
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
|
||||
|
||||
function migrateAndPruneSessionStoreKey(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
key: string;
|
||||
store: Record<string, SessionEntry>;
|
||||
}) {
|
||||
const target = resolveGatewaySessionStoreTarget({
|
||||
cfg: params.cfg,
|
||||
key: params.key,
|
||||
store: params.store,
|
||||
});
|
||||
const primaryKey = target.canonicalKey;
|
||||
if (!params.store[primaryKey]) {
|
||||
const existingKey = target.storeKeys.find((candidate) => Boolean(params.store[candidate]));
|
||||
if (existingKey) {
|
||||
params.store[primaryKey] = params.store[existingKey];
|
||||
}
|
||||
}
|
||||
pruneLegacyStoreKeys({
|
||||
store: params.store,
|
||||
canonicalKey: primaryKey,
|
||||
candidates: target.storeKeys,
|
||||
});
|
||||
return { target, primaryKey, entry: params.store[primaryKey] };
|
||||
}
|
||||
|
||||
export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
"sessions.list": ({ params, respond }) => {
|
||||
if (!validateSessionsListParams(params)) {
|
||||
@@ -104,12 +130,16 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const store = storeCache.get(target.storePath) ?? loadSessionStore(target.storePath);
|
||||
storeCache.set(target.storePath, store);
|
||||
const entry =
|
||||
target.storeKeys.map((candidate) => store[candidate]).find(Boolean) ??
|
||||
store[target.canonicalKey];
|
||||
const storeTarget = resolveGatewaySessionStoreTarget({ cfg, key, scanLegacyKeys: false });
|
||||
const store =
|
||||
storeCache.get(storeTarget.storePath) ?? loadSessionStore(storeTarget.storePath);
|
||||
storeCache.set(storeTarget.storePath, store);
|
||||
const target = resolveGatewaySessionStoreTarget({
|
||||
cfg,
|
||||
key,
|
||||
store,
|
||||
});
|
||||
const entry = target.storeKeys.map((candidate) => store[candidate]).find(Boolean);
|
||||
if (!entry?.sessionId) {
|
||||
previews.push({ key, status: "missing", items: [] });
|
||||
continue;
|
||||
@@ -134,7 +164,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
|
||||
respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined);
|
||||
},
|
||||
"sessions.resolve": ({ params, respond }) => {
|
||||
"sessions.resolve": async ({ params, respond }) => {
|
||||
if (!validateSessionsResolveParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
@@ -149,7 +179,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const p = params;
|
||||
const cfg = loadConfig();
|
||||
|
||||
const resolved = resolveSessionKeyFromResolveParams({ cfg, p });
|
||||
const resolved = await resolveSessionKeyFromResolveParams({ cfg, p });
|
||||
if (!resolved.ok) {
|
||||
respond(false, undefined, resolved.error);
|
||||
return;
|
||||
@@ -179,12 +209,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
const applied = await updateSessionStore(storePath, async (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||
return await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
@@ -235,12 +260,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
const next = await updateSessionStore(storePath, (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||
const entry = store[primaryKey];
|
||||
const now = Date.now();
|
||||
const nextEntry: SessionEntry = {
|
||||
@@ -331,12 +351,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
}
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||
if (store[primaryKey]) {
|
||||
delete store[primaryKey];
|
||||
}
|
||||
@@ -392,13 +407,8 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const storePath = target.storePath;
|
||||
// Lock + read in a short critical section; transcript work happens outside.
|
||||
const compactTarget = await updateSessionStore(storePath, (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
return { entry: store[primaryKey], primaryKey };
|
||||
const { entry, primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||
return { entry, primaryKey };
|
||||
});
|
||||
const entry = compactTarget.entry;
|
||||
const sessionId = entry?.sessionId;
|
||||
|
||||
Reference in New Issue
Block a user