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:
Marcus Castro
2026-02-13 16:42:24 -03:00
committed by GitHub
parent f6232bc2b4
commit 4225206f0c
8 changed files with 544 additions and 76 deletions

View File

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