fix: canonicalize legacy session keys

This commit is contained in:
Peter Steinberger
2026-01-19 05:17:06 +00:00
parent c578fca687
commit 374da34936
8 changed files with 241 additions and 20 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
## 2026.1.18-5

View File

@@ -68,6 +68,10 @@ describe("doctor legacy state migrations", () => {
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:main"]?.sessionId).toBe("b");
expect(store["agent:main:+1555"]?.sessionId).toBe("a");
expect(store["agent:main:+1666"]?.sessionId).toBe("b");
expect(store["+1555"]).toBeUndefined();
expect(store["+1666"]).toBeUndefined();
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d");
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
@@ -229,4 +233,73 @@ describe("doctor legacy state migrations", () => {
expect(store["agent:main:work"]?.sessionId).toBe("b");
expect(store["agent:main:main"]).toBeUndefined();
});
it("canonicalizes legacy main keys inside the target sessions store", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const targetDir = path.join(root, "agents", "main", "sessions");
writeJson5(path.join(targetDir, "sessions.json"), {
main: { sessionId: "legacy", updatedAt: 10 },
"agent:main:main": { sessionId: "fresh", updatedAt: 20 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["main"]).toBeUndefined();
expect(store["agent:main:main"]?.sessionId).toBe("fresh");
});
it("prefers the newest entry when collapsing main aliases", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = { session: { mainKey: "work" } };
const targetDir = path.join(root, "agents", "main", "sessions");
writeJson5(path.join(targetDir, "sessions.json"), {
"agent:main:main": { sessionId: "legacy", updatedAt: 50 },
"agent:main:work": { sessionId: "canonical", updatedAt: 10 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:work"]?.sessionId).toBe("legacy");
expect(store["agent:main:main"]).toBeUndefined();
});
it("auto-migrates when only target sessions contain legacy keys", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const targetDir = path.join(root, "agents", "main", "sessions");
writeJson5(path.join(targetDir, "sessions.json"), {
main: { sessionId: "legacy", updatedAt: 10 },
});
const log = { info: vi.fn(), warn: vi.fn() };
const result = await autoMigrateLegacyState({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
log,
});
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(result.migrated).toBe(true);
expect(log.info).toHaveBeenCalled();
expect(store["main"]).toBeUndefined();
expect(store["agent:main:main"]?.sessionId).toBe("legacy");
});
});

View File

@@ -295,6 +295,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -303,6 +304,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",

View File

@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",

View File

@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",

View File

@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",

View File

@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",

View File

@@ -6,6 +6,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js";
import type { SessionScope } from "../config/sessions/types.js";
import { saveSessionStore } from "../config/sessions.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
@@ -14,6 +15,7 @@ import {
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "../routing/session-key.js";
import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
import {
ensureDir,
existsDir,
@@ -27,6 +29,7 @@ import {
export type LegacyStateDetection = {
targetAgentId: string;
targetMainKey: string;
targetScope?: SessionScope;
stateDir: string;
oauthDir: string;
sessions: {
@@ -35,6 +38,7 @@ export type LegacyStateDetection = {
targetDir: string;
targetStorePath: string;
hasLegacy: boolean;
legacyKeys: string[];
};
agentDir: {
legacyDir: string;
@@ -72,34 +76,49 @@ function isLegacyGroupKey(key: string): boolean {
return false;
}
function normalizeSessionKeyForAgent(key: string, agentId: string): string {
const raw = key.trim();
function canonicalizeSessionKeyForAgent(params: {
key: string;
agentId: string;
mainKey: string;
scope?: SessionScope;
}): string {
const agentId = normalizeAgentId(params.agentId);
const raw = params.key.trim();
if (!raw) return raw;
if (raw === "global" || raw === "unknown") return raw;
const canonicalMain = canonicalizeMainSessionAlias({
cfg: { session: { scope: params.scope, mainKey: params.mainKey } },
agentId,
sessionKey: raw,
});
if (canonicalMain !== raw) return canonicalMain;
if (raw.startsWith("agent:")) return raw;
if (raw.toLowerCase().startsWith("subagent:")) {
const rest = raw.slice("subagent:".length);
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
return `agent:${agentId}:subagent:${rest}`;
}
if (raw.startsWith("group:")) {
const id = raw.slice("group:".length).trim();
if (!id) return raw;
const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown";
return `agent:${normalizeAgentId(agentId)}:${channel}:group:${id}`;
return `agent:${agentId}:${channel}:group:${id}`;
}
if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) {
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${raw}`;
return `agent:${agentId}:whatsapp:group:${raw}`;
}
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
const remainder = raw.slice("whatsapp:".length).trim();
const cleaned = remainder.replace(/^group:/i, "").trim();
if (cleaned && !isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${cleaned}`;
return `agent:${agentId}:whatsapp:group:${cleaned}`;
}
}
if (isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:${raw}`;
return `agent:${agentId}:${raw}`;
}
return raw;
return `agent:${agentId}:${raw}`;
}
function pickLatestLegacyDirectEntry(
@@ -140,6 +159,91 @@ function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
return normalized;
}
function resolveUpdatedAt(entry: SessionEntryLike): number {
return typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: 0;
}
function mergeSessionEntry(params: {
existing: SessionEntryLike | undefined;
incoming: SessionEntryLike;
preferIncomingOnTie?: boolean;
}): SessionEntryLike {
if (!params.existing) return params.incoming;
const existingUpdated = resolveUpdatedAt(params.existing);
const incomingUpdated = resolveUpdatedAt(params.incoming);
if (incomingUpdated > existingUpdated) return params.incoming;
if (incomingUpdated < existingUpdated) return params.existing;
return params.preferIncomingOnTie ? params.incoming : params.existing;
}
function canonicalizeSessionStore(params: {
store: Record<string, SessionEntryLike>;
agentId: string;
mainKey: string;
scope?: SessionScope;
}): { store: Record<string, SessionEntryLike>; legacyKeys: string[] } {
const canonical: Record<string, SessionEntryLike> = {};
const meta = new Map<string, { isCanonical: boolean; updatedAt: number }>();
const legacyKeys: string[] = [];
for (const [key, entry] of Object.entries(params.store)) {
if (!entry || typeof entry !== "object") continue;
const canonicalKey = canonicalizeSessionKeyForAgent({
key,
agentId: params.agentId,
mainKey: params.mainKey,
scope: params.scope,
});
const isCanonical = canonicalKey === key;
if (!isCanonical) legacyKeys.push(key);
const existing = canonical[canonicalKey];
if (!existing) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, { isCanonical, updatedAt: resolveUpdatedAt(entry) });
continue;
}
const existingMeta = meta.get(canonicalKey);
const incomingUpdated = resolveUpdatedAt(entry);
const existingUpdated = existingMeta?.updatedAt ?? resolveUpdatedAt(existing);
if (incomingUpdated > existingUpdated) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, { isCanonical, updatedAt: incomingUpdated });
continue;
}
if (incomingUpdated < existingUpdated) continue;
if (existingMeta?.isCanonical && !isCanonical) continue;
if (!existingMeta?.isCanonical && isCanonical) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, { isCanonical, updatedAt: incomingUpdated });
continue;
}
}
return { store: canonical, legacyKeys };
}
function listLegacySessionKeys(params: {
store: Record<string, SessionEntryLike>;
agentId: string;
mainKey: string;
scope?: SessionScope;
}): string[] {
const legacy: string[] = [];
for (const key of Object.keys(params.store)) {
const canonical = canonicalizeSessionKeyForAgent({
key,
agentId: params.agentId,
mainKey: params.mainKey,
scope: params.scope,
});
if (canonical !== key) legacy.push(key);
}
return legacy;
}
function emptyDirOrMissing(dir: string): boolean {
if (!existsDir(dir)) return true;
return safeReadDir(dir).length === 0;
@@ -179,6 +283,7 @@ export async function detectLegacyStateMigrations(params: {
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
? rawMainKey.trim()
: DEFAULT_MAIN_KEY;
const targetScope = params.cfg.session?.scope;
const sessionsLegacyDir = path.join(stateDir, "sessions");
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
@@ -189,6 +294,18 @@ export async function detectLegacyStateMigrations(params: {
fileExists(sessionsLegacyStorePath) ||
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
const targetSessionParsed = fileExists(sessionsTargetStorePath)
? readSessionStoreJson5(sessionsTargetStorePath)
: { store: {}, ok: true };
const legacyKeys = targetSessionParsed.ok
? listLegacySessionKeys({
store: targetSessionParsed.store,
agentId: targetAgentId,
mainKey: targetMainKey,
scope: targetScope,
})
: [];
const legacyAgentDir = path.join(stateDir, "agent");
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
const hasLegacyAgentDir = existsDir(legacyAgentDir);
@@ -202,6 +319,9 @@ export async function detectLegacyStateMigrations(params: {
if (hasLegacySessions) {
preview.push(`- Sessions: ${sessionsLegacyDir}${sessionsTargetDir}`);
}
if (legacyKeys.length > 0) {
preview.push(`- Sessions: canonicalize legacy keys in ${sessionsTargetStorePath}`);
}
if (hasLegacyAgentDir) {
preview.push(`- Agent dir: ${legacyAgentDir}${targetAgentDir}`);
}
@@ -212,6 +332,7 @@ export async function detectLegacyStateMigrations(params: {
return {
targetAgentId,
targetMainKey,
targetScope,
stateDir,
oauthDir,
sessions: {
@@ -219,7 +340,8 @@ export async function detectLegacyStateMigrations(params: {
legacyStorePath: sessionsLegacyStorePath,
targetDir: sessionsTargetDir,
targetStorePath: sessionsTargetStorePath,
hasLegacy: hasLegacySessions,
hasLegacy: hasLegacySessions || legacyKeys.length > 0,
legacyKeys,
},
agentDir: {
legacyDir: legacyAgentDir,
@@ -254,17 +376,27 @@ async function migrateLegacySessions(
const legacyStore = legacyParsed.store;
const targetStore = targetParsed.store;
const normalizedLegacy: Record<string, SessionEntryLike> = {};
for (const [key, entry] of Object.entries(legacyStore)) {
const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId);
if (!nextKey) continue;
if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry;
}
const canonicalizedTarget = canonicalizeSessionStore({
store: targetStore,
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
scope: detected.targetScope,
});
const canonicalizedLegacy = canonicalizeSessionStore({
store: legacyStore,
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
scope: detected.targetScope,
});
const merged: Record<string, SessionEntryLike> = {
...normalizedLegacy,
...targetStore,
};
const merged: Record<string, SessionEntryLike> = { ...canonicalizedTarget.store };
for (const [key, entry] of Object.entries(canonicalizedLegacy.store)) {
merged[key] = mergeSessionEntry({
existing: merged[key],
incoming: entry,
preferIncomingOnTie: false,
});
}
const mainKey = buildAgentMainSessionKey({
agentId: detected.targetAgentId,
@@ -285,7 +417,7 @@ async function migrateLegacySessions(
}
if (
legacyParsed.ok &&
(legacyParsed.ok || targetParsed.ok) &&
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
) {
const normalized: Record<string, SessionEntry> = {};
@@ -296,6 +428,11 @@ async function migrateLegacySessions(
}
await saveSessionStore(detected.sessions.targetStorePath, normalized);
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
if (canonicalizedTarget.legacyKeys.length > 0) {
changes.push(
`Canonicalized ${canonicalizedTarget.legacyKeys.length} legacy session key(s)`,
);
}
}
const entries = safeReadDir(detected.sessions.legacyDir);