fix(doctor): partition retired session stores (#124846)

Recover owner-qualified rows from the retired top-level session store into each configured agent database without guessing ambiguous ownership. Relocate stale transcript paths, tolerate historical Unicode IDs when an explicit transcript exists, and avoid double-counting physical SQLite databases in migration totals.


Amp-Thread-ID: https://ampcode.com/threads/T-01a00a6a-b64e-74a5-8b15-2d3b966a468d

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Josh Lehman <josh@martian.engineering>
This commit is contained in:
Peter Steinberger
2026-08-16 16:33:28 -07:00
committed by GitHub
parent 22ab8e3c2c
commit fecb377da6
4 changed files with 164 additions and 13 deletions
@@ -14,11 +14,13 @@ import type { FileEntry } from "../agents/sessions/session-manager-types.js";
import type { TranscriptEvent } from "../config/sessions/session-accessor.js";
import type { SqliteTranscriptStorageRow } from "../config/sessions/session-accessor.sqlite-read.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { SessionStoreTarget } from "../config/sessions/targets.js";
import type { SessionStoreTarget as ResolvedSessionStoreTarget } from "../config/sessions/targets.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.js";
type SessionStoreTarget = ResolvedSessionStoreTarget & { sqlitePath?: string };
type ReadOnlySqliteSessionSummary = {
entry: SessionEntry;
sessionKey: string;
@@ -472,6 +474,9 @@ export function readOnlySqliteDbStats(target: SessionStoreTarget): ReadOnlySqlit
}
export function resolveTargetSqlitePath(target: SessionStoreTarget): string {
if (target.sqlitePath) {
return resolveOpenClawAgentSqlitePath({ agentId: target.agentId, path: target.sqlitePath });
}
const sqliteTarget = resolveSqliteTargetFromSessionStorePath(target.storePath, {
agentId: target.agentId,
});
+8 -1
View File
@@ -177,6 +177,13 @@ export function createDoctorSessionSqliteTotals(
> = {},
): DoctorSessionSqliteReport["totals"] {
const { archivedLegacyStoreFiles, reclaimedBytes } = values;
const sqliteEntries = new Map<string, number>();
for (const target of targets) {
sqliteEntries.set(
target.sqlitePath,
Math.max(sqliteEntries.get(target.sqlitePath) ?? 0, target.sqliteEntries),
);
}
return {
...(archivedLegacyStoreFiles === undefined ? {} : { archivedLegacyStoreFiles }),
archivedTranscriptFiles: values.archivedTranscriptFiles ?? 0,
@@ -186,7 +193,7 @@ export function createDoctorSessionSqliteTotals(
issues: sumDoctorSessionSqliteTargets(targets, (target) => target.issues.length),
legacyEntries: values.legacyEntries ?? 0,
...(reclaimedBytes === undefined ? {} : { reclaimedBytes }),
sqliteEntries: sumDoctorSessionSqliteTargets(targets, (target) => target.sqliteEntries),
sqliteEntries: [...sqliteEntries.values()].reduce((total, count) => total + count, 0),
targets: targets.length,
unreferencedJsonlFiles: values.unreferencedJsonlFiles ?? 0,
validatedEntries: values.validatedEntries ?? 0,
@@ -2638,6 +2638,96 @@ describe("runDoctorSessionSqlite", () => {
}
});
it("partitions the retired top-level store without guessing unscoped ownership", async () => {
const stateDir = autoCleanupTempDirs.make("openclaw-doctor-retired-sessions-");
const sessionDir = path.join(stateDir, "sessions");
const storePath = path.join(sessionDir, "sessions.json");
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:main": {
sessionFile: "/retired/home/.openclaw/sessions/main-session.jsonl",
sessionId: "main-会議",
updatedAt: 20,
},
"agent:ops:main": {
sessionFile: "ops-session.jsonl",
sessionId: "ops-session",
updatedAt: 30,
},
"voice:ambiguous": { sessionId: "ambiguous-session", updatedAt: 40 },
}),
{ mode: 0o600 },
);
fs.writeFileSync(
path.join(sessionDir, "main-session.jsonl"),
'{"type":"session","sessionId":"main-会議"}\n',
{ mode: 0o600 },
);
fs.writeFileSync(
path.join(sessionDir, "ops-session.jsonl"),
'{"type":"session","sessionId":"ops-session"}\n',
{ mode: 0o600 },
);
const cfg = {
agents: { ownership: "explicit" as const, entries: { main: {}, ops: {} } },
};
const report = await runDoctorSessionSqlite({
allAgents: true,
cfg,
env,
mode: "import",
});
expect(report.targets.map((target) => target.agentId)).toEqual(["main", "ops"]);
expect(report.totals).toMatchObject({
archivedLegacyStoreFiles: 0,
importedEntries: 2,
importedTranscriptEvents: 2,
legacyEntries: 2,
sqliteEntries: 2,
});
for (const [agentId, sessionId] of [
["main", "main-会議"],
["ops", "ops-session"],
] as const) {
const agentStorePath = path.join(stateDir, "agents", agentId, "sessions", "sessions.json");
expect(
loadExactSessionEntry({
agentId,
sessionKey: `agent:${agentId}:main`,
storePath: agentStorePath,
})?.entry.sessionId,
).toBe(sessionId);
expect(
loadExactSessionEntry({
agentId,
sessionKey: "voice:ambiguous",
storePath: agentStorePath,
}),
).toBeUndefined();
}
expect(fs.existsSync(storePath)).toBe(true);
expect(fs.existsSync(path.join(sessionDir, "main-session.jsonl"))).toBe(true);
expect(fs.existsSync(path.join(sessionDir, "ops-session.jsonl"))).toBe(true);
const owned = await runDoctorSessionSqlite({
allAgents: true,
cfg: {
...cfg,
agents: { ...cfg.agents, defaults: { sessionStore: { agentId: "main" } } },
},
env,
mode: "import",
});
expect(owned.totals.archivedLegacyStoreFiles).toBe(1);
expect(owned.totals.importedEntries).toBe(3);
expect(fs.existsSync(storePath)).toBe(false);
});
it("imports shared custom stores into per-agent SQLite targets", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-session-sqlite-"));
try {
+60 -11
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { tryResolveDefaultAgentId } from "../agents/agent-scope.js";
import { getRuntimeConfig } from "../config/config.js";
import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js";
import { resolveStateDir } from "../config/paths.js";
import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js";
import { resolveSessionFilePathCore } from "../config/sessions/paths.js";
@@ -14,7 +15,7 @@ import {
resolveAllAgentSessionStoreCandidateTargetsSync,
resolveAllAgentSessionStoreTargetsSync,
resolveSessionStoreTargets,
type SessionStoreTarget,
type SessionStoreTarget as ResolvedSessionStoreTarget,
} from "../config/sessions/targets.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -23,7 +24,11 @@ import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js";
import { isPathInside } from "../infra/path-guards.js";
import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js";
import { normalizeLegacySessionEntryDelivery as normalizeSessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js";
import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import {
LEGACY_IMPLICIT_AGENT_ID,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { closeOpenClawAgentDatabaseByPath } from "../state/openclaw-agent-db.js";
import { compactDoctorSessionSqliteTarget } from "./doctor-session-sqlite-compact.js";
import {
@@ -85,6 +90,8 @@ export type {
DoctorSessionSqliteTargetReport,
} from "./doctor-session-sqlite-types.js";
type SessionStoreTarget = ResolvedSessionStoreTarget & { sqlitePath?: string };
type LegacySessionRecord = {
entry: SessionEntry;
sessionKey: string;
@@ -256,10 +263,27 @@ function resolveDoctorSessionSqliteTargets(params: {
);
}
if (params.allAgents) {
return filterLegacySessionStoreTargets(
const targets = filterLegacySessionStoreTargets(
resolveAllAgentSessionStoreTargetsSync(params.cfg, { env: params.env }),
params.mode,
);
if (params.mode !== "dry-run" && params.mode !== "import" && params.mode !== "validate") {
return targets;
}
const legacyStorePath = path.join(resolveStateDir(params.env), "sessions", "sessions.json");
if (!fs.existsSync(legacyStorePath)) {
return targets;
}
const legacyTargets = resolveSessionStoreTargets(
params.cfg,
{ allAgents: true },
{ env: params.env },
).map((target) => ({
agentId: target.agentId,
sqlitePath: resolveTargetSqlitePath(target),
storePath: legacyStorePath,
}));
return [...legacyTargets, ...targets];
}
return resolveSessionStoreTargets(params.cfg, {}, { env: params.env }).filter((target) =>
fs.existsSync(target.storePath),
@@ -505,6 +529,16 @@ function isLegacySessionRecordOwnedByTarget(
target: SessionStoreTarget,
sessionKey: string,
): boolean {
if (target.sqlitePath) {
const parsed = parseAgentSessionKey(sessionKey);
const ownerAgentId =
parsed?.agentId ??
cfg.agents?.defaults?.sessionStore?.agentId?.trim() ??
tryResolveLegacyCompatibilityAgentId(cfg);
return ownerAgentId
? normalizeAgentId(ownerAgentId) === normalizeAgentId(target.agentId)
: false;
}
const ownerAgentId = resolveStoredSessionOwnerAgentId({
cfg,
agentId: target.agentId,
@@ -529,14 +563,29 @@ function resolveLegacyTranscriptPath(
if (parseSqliteSessionFileMarker(legacySessionFile)) {
return undefined;
}
const defaultPath = resolveSessionFilePathCore(entry.sessionId, entry, {
agentId: target.agentId,
sessionsDir: path.dirname(target.storePath),
});
const sessionsDir = path.dirname(target.storePath);
const relocatedPath = legacySessionFile?.trim()
? path.join(sessionsDir, path.basename(legacySessionFile))
: undefined;
let defaultPath: string;
try {
defaultPath = resolveSessionFilePathCore(entry.sessionId, entry, {
agentId: target.agentId,
sessionsDir,
});
} catch (error) {
if (!relocatedPath) {
throw error;
}
defaultPath = relocatedPath;
}
if (fs.existsSync(defaultPath)) {
return defaultPath;
}
return legacySessionFile?.trim() ? defaultPath : undefined;
if (relocatedPath && fs.existsSync(relocatedPath)) {
return relocatedPath;
}
return relocatedPath ? defaultPath : undefined;
}
function countLegacyTranscript(
@@ -591,7 +640,7 @@ async function importLegacySessionRecord(
entry: record.entry,
preserveExactStoredKey: true,
sessionKey: record.sessionKey,
storePath: target.storePath,
storePath: target.sqlitePath ?? target.storePath,
});
report.importedEntries += 1;
report.importedTranscriptEvents += imported.transcriptEvents;
@@ -608,7 +657,7 @@ async function importLegacySessionRecord(
entry: record.entry,
preserveExactStoredKey: true,
sessionKey: record.sessionKey,
storePath: target.storePath,
storePath: target.sqlitePath ?? target.storePath,
...(record.transcriptPath && shouldImportTranscript
? {
readTranscriptEvents: createTranscriptEventPrefixReader(
@@ -637,7 +686,7 @@ async function importLegacySessionRecord(
entry: record.entry,
preserveExactStoredKey: true,
sessionKey: record.sessionKey,
storePath: target.storePath,
storePath: target.sqlitePath ?? target.storePath,
...(record.transcriptPath && result.status === "ok" && shouldImportTranscript
? {
readTranscriptEvents: createTranscriptEventReader(