fix: canonicalize legacy session keys
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user