sessions: dedupe shared stores and harden archive matching

This commit is contained in:
Shakker
2026-02-23 22:09:34 +00:00
parent 75e6696d9d
commit cbcb1ad8c7
7 changed files with 200 additions and 13 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSessionStoreTargets } from "./session-store-targets.js";
const resolveStorePathMock = vi.hoisted(() => vi.fn());
@@ -15,6 +15,10 @@ vi.mock("../agents/agent-scope.js", () => ({
}));
describe("resolveSessionStoreTargets", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves the default agent store when no selector is provided", () => {
resolveDefaultAgentIdMock.mockReturnValue("main");
resolveStorePathMock.mockReturnValue("/tmp/main-sessions.json");
@@ -44,6 +48,21 @@ describe("resolveSessionStoreTargets", () => {
]);
});
it("dedupes shared store paths for --all-agents", () => {
listAgentIdsMock.mockReturnValue(["main", "work"]);
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
const targets = resolveSessionStoreTargets(
{
session: { store: "/tmp/shared-sessions.json" },
},
{ allAgents: true },
);
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/shared-sessions.json" }]);
expect(resolveStorePathMock).toHaveBeenCalledTimes(2);
});
it("rejects unknown agent ids", () => {
listAgentIdsMock.mockReturnValue(["main", "work"]);
expect(() => resolveSessionStoreTargets({}, { agent: "ghost" })).toThrow(/Unknown agent id/);

View File

@@ -14,6 +14,16 @@ export type SessionStoreTarget = {
storePath: string;
};
function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] {
const deduped = new Map<string, SessionStoreTarget>();
for (const target of targets) {
if (!deduped.has(target.storePath)) {
deduped.set(target.storePath, target);
}
}
return [...deduped.values()];
}
export function resolveSessionStoreTargets(
cfg: OpenClawConfig,
opts: SessionStoreSelectionOptions,
@@ -38,10 +48,11 @@ export function resolveSessionStoreTargets(
}
if (allAgents) {
return listAgentIds(cfg).map((agentId) => ({
const targets = listAgentIds(cfg).map((agentId) => ({
agentId,
storePath: resolveStorePath(cfg.session?.store, { agentId }),
}));
return dedupeTargetsByStorePath(targets);
}
if (hasAgent) {

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
const loadConfigMock = vi.hoisted(() =>
@@ -59,6 +59,16 @@ function createRuntime(): { runtime: RuntimeEnv; logs: string[] } {
}
describe("sessionsCommand default store agent selection", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveStorePathMock.mockImplementation(
(_store: string | undefined, opts?: { agentId?: string }) => {
return `/tmp/sessions-${opts?.agentId ?? "missing"}.json`;
},
);
loadSessionStoreMock.mockImplementation(() => ({}));
});
it("includes agentId on sessions rows for --all-agents JSON output", async () => {
resolveStorePathMock.mockClear();
loadSessionStoreMock.mockReset();
@@ -82,6 +92,34 @@ describe("sessionsCommand default store agent selection", () => {
expect(payload.sessions?.map((session) => session.agentId)).toContain("voice");
});
it("avoids duplicate rows when --all-agents resolves to a shared store path", async () => {
resolveStorePathMock.mockReset();
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
loadSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
"agent:main:room": { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" },
"agent:voice:room": { sessionId: "s2", updatedAt: Date.now() - 30_000, model: "pi:opus" },
});
const { runtime, logs } = createRuntime();
await sessionsCommand({ allAgents: true, json: true }, runtime);
const payload = JSON.parse(logs[0] ?? "{}") as {
count?: number;
stores?: Array<{ agentId: string; path: string }>;
allAgents?: boolean;
sessions?: Array<{ key: string; agentId?: string }>;
};
expect(payload.count).toBe(2);
expect(payload.allAgents).toBe(true);
expect(payload.stores).toEqual([{ agentId: "main", path: "/tmp/shared-sessions.json" }]);
expect(payload.sessions?.map((session) => session.agentId).toSorted()).toEqual([
"main",
"voice",
]);
expect(loadSessionStoreMock).toHaveBeenCalledTimes(1);
});
it("uses configured default agent id when resolving implicit session store path", async () => {
resolveStorePathMock.mockClear();
const { runtime, logs } = createRuntime();

View File

@@ -4,6 +4,7 @@ import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveFreshSessionTotalTokens } from "../config/sessions.js";
import { classifySessionKey } from "../gateway/session-utils.js";
import { info } from "../globals.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import { resolveSessionStoreTargets } from "./session-store-targets.js";
@@ -87,6 +88,7 @@ export async function sessionsCommand(
opts: { json?: boolean; store?: string; active?: string; agent?: string; allAgents?: boolean },
runtime: RuntimeEnv,
) {
const aggregateAgents = opts.allAgents === true;
const cfg = loadConfig();
const displayDefaults = resolveSessionDisplayDefaults(cfg);
const configContextTokens =
@@ -122,7 +124,7 @@ export async function sessionsCommand(
const store = loadSessionStore(target.storePath);
return toSessionDisplayRows(store).map((row) => ({
...row,
agentId: target.agentId,
agentId: parseAgentSessionKey(row.key)?.agentId ?? target.agentId,
kind: classifySessionKey(row.key, store[row.key]),
}));
})
@@ -139,17 +141,18 @@ export async function sessionsCommand(
if (opts.json) {
const multi = targets.length > 1;
const aggregate = aggregateAgents || multi;
runtime.log(
JSON.stringify(
{
path: multi ? null : (targets[0]?.storePath ?? null),
stores: multi
path: aggregate ? null : (targets[0]?.storePath ?? null),
stores: aggregate
? targets.map((target) => ({
agentId: target.agentId,
path: target.storePath,
}))
: undefined,
allAgents: multi ? true : undefined,
allAgents: aggregateAgents ? true : undefined,
count: rows.length,
activeMinutes: activeMinutes ?? null,
sessions: rows.map((r) => {
@@ -172,7 +175,7 @@ export async function sessionsCommand(
return;
}
if (targets.length === 1) {
if (targets.length === 1 && !aggregateAgents) {
runtime.log(info(`Session store: ${targets[0]?.storePath}`));
} else {
runtime.log(
@@ -189,7 +192,7 @@ export async function sessionsCommand(
}
const rich = isRich();
const showAgentColumn = targets.length > 1;
const showAgentColumn = aggregateAgents || targets.length > 1;
const header = [
...(showAgentColumn ? ["Agent".padEnd(AGENT_PAD)] : []),
"Kind".padEnd(KIND_PAD),

View File

@@ -12,11 +12,13 @@ describe("session artifact helpers", () => {
expect(isSessionArchiveArtifactName("abc.jsonl.reset.2026-01-01T00-00-00.000Z")).toBe(true);
expect(isSessionArchiveArtifactName("abc.jsonl.bak.2026-01-01T00-00-00.000Z")).toBe(true);
expect(isSessionArchiveArtifactName("sessions.json.bak.1737420882")).toBe(true);
expect(isSessionArchiveArtifactName("keep.deleted.keep.jsonl")).toBe(false);
expect(isSessionArchiveArtifactName("abc.jsonl")).toBe(false);
});
it("classifies primary transcript files", () => {
expect(isPrimarySessionTranscriptFileName("abc.jsonl")).toBe(true);
expect(isPrimarySessionTranscriptFileName("keep.deleted.keep.jsonl")).toBe(true);
expect(isPrimarySessionTranscriptFileName("abc.jsonl.deleted.2026-01-01T00-00-00.000Z")).toBe(
false,
);
@@ -31,5 +33,6 @@ describe("session artifact helpers", () => {
const file = `abc.jsonl.deleted.${stamp}`;
expect(parseSessionArchiveTimestamp(file, "deleted")).toBe(now);
expect(parseSessionArchiveTimestamp(file, "reset")).toBeNull();
expect(parseSessionArchiveTimestamp("keep.deleted.keep.jsonl", "deleted")).toBeNull();
});
});

View File

@@ -1,11 +1,26 @@
export type SessionArchiveReason = "bak" | "reset" | "deleted";
const ARCHIVE_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z$/;
const LEGACY_STORE_BACKUP_RE = /^sessions\.json\.bak\.\d+$/;
function hasArchiveSuffix(fileName: string, reason: SessionArchiveReason): boolean {
const marker = `.${reason}.`;
const index = fileName.lastIndexOf(marker);
if (index < 0) {
return false;
}
const raw = fileName.slice(index + marker.length);
return ARCHIVE_TIMESTAMP_RE.test(raw);
}
export function isSessionArchiveArtifactName(fileName: string): boolean {
if (LEGACY_STORE_BACKUP_RE.test(fileName)) {
return true;
}
return (
fileName.includes(".deleted.") ||
fileName.includes(".reset.") ||
fileName.includes(".bak.") ||
fileName.startsWith("sessions.json.bak.")
hasArchiveSuffix(fileName, "deleted") ||
hasArchiveSuffix(fileName, "reset") ||
hasArchiveSuffix(fileName, "bak")
);
}
@@ -44,6 +59,9 @@ export function parseSessionArchiveTimestamp(
if (!raw) {
return null;
}
if (!ARCHIVE_TIMESTAMP_RE.test(raw)) {
return null;
}
const timestamp = Date.parse(restoreSessionArchiveTimestamp(raw));
return Number.isNaN(timestamp) ? null : timestamp;
}

View File

@@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { formatSessionArchiveTimestamp } from "./artifacts.js";
import { enforceSessionDiskBudget } from "./disk-budget.js";
import type { SessionEntry } from "./types.js";
const createdDirs: string[] = [];
async function createCaseDir(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
createdDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(createdDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
createdDirs.length = 0;
});
describe("enforceSessionDiskBudget", () => {
it("does not treat referenced transcripts with marker-like session IDs as archived artifacts", async () => {
const dir = await createCaseDir("openclaw-disk-budget-");
const storePath = path.join(dir, "sessions.json");
const sessionId = "keep.deleted.keep";
const activeKey = "agent:main:main";
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
const store: Record<string, SessionEntry> = {
[activeKey]: {
sessionId,
updatedAt: Date.now(),
},
};
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
await fs.writeFile(transcriptPath, "x".repeat(256), "utf-8");
const result = await enforceSessionDiskBudget({
store,
storePath,
activeSessionKey: activeKey,
maintenance: {
maxDiskBytes: 150,
highWaterBytes: 100,
},
warnOnly: false,
});
await expect(fs.stat(transcriptPath)).resolves.toBeDefined();
expect(result).toEqual(
expect.objectContaining({
removedFiles: 0,
}),
);
});
it("removes true archived transcript artifacts while preserving referenced primary transcripts", async () => {
const dir = await createCaseDir("openclaw-disk-budget-");
const storePath = path.join(dir, "sessions.json");
const sessionId = "keep";
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
const archivePath = path.join(
dir,
`old-session.jsonl.deleted.${formatSessionArchiveTimestamp(Date.now() - 24 * 60 * 60 * 1000)}`,
);
const store: Record<string, SessionEntry> = {
"agent:main:main": {
sessionId,
updatedAt: Date.now(),
},
};
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
await fs.writeFile(transcriptPath, "k".repeat(80), "utf-8");
await fs.writeFile(archivePath, "a".repeat(260), "utf-8");
const result = await enforceSessionDiskBudget({
store,
storePath,
maintenance: {
maxDiskBytes: 300,
highWaterBytes: 220,
},
warnOnly: false,
});
await expect(fs.stat(transcriptPath)).resolves.toBeDefined();
await expect(fs.stat(archivePath)).rejects.toThrow();
expect(result).toEqual(
expect.objectContaining({
removedFiles: 1,
removedEntries: 0,
}),
);
});
});