diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3c77bef..7e56405b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index f6df975be..83b6ef897 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -68,6 +68,10 @@ describe("doctor legacy state migrations", () => { fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), ) as Record; 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; + 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; + 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; + expect(result.migrated).toBe(true); + expect(log.info).toHaveBeenCalled(); + expect(store["main"]).toBeUndefined(); + expect(store["agent:main:main"]?.sessionId).toBe("legacy"); + }); }); diff --git a/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts b/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts index cb2c75ec2..6d351b5ab 100644 --- a/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts +++ b/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts @@ -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", diff --git a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts index a2be5ef7d..09c22616c 100644 --- a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts +++ b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts @@ -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", diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts index a4a3e8fd5..e9d9af2d6 100644 --- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts +++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts @@ -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", diff --git a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts index 8904c8e58..be5f33ee6 100644 --- a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts +++ b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts @@ -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", diff --git a/src/commands/doctor.warns-state-directory-is-missing.test.ts b/src/commands/doctor.warns-state-directory-is-missing.test.ts index e6788f642..24c4595f5 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.test.ts @@ -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", diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index c8c90e993..1fff45b5c 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -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; + agentId: string; + mainKey: string; + scope?: SessionScope; +}): { store: Record; legacyKeys: string[] } { + const canonical: Record = {}; + const meta = new Map(); + 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; + 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 = {}; - 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 = { - ...normalizedLegacy, - ...targetStore, - }; + const merged: Record = { ...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 = {}; @@ -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);