refactor(sessions): route cleanup through controlled writers

This commit is contained in:
Peter Steinberger
2026-05-02 13:40:28 +01:00
parent 2165d1687e
commit b151694e00
15 changed files with 785 additions and 490 deletions

View File

@@ -40,7 +40,7 @@ Docs: https://docs.openclaw.ai
- Models CLI: restore `openclaw models list --provider <id>` catalog and registry fallback rows for unconfigured providers, so provider-specific verification commands no longer report "No models found." Fixes #75517; supersedes #75615. Thanks @lotsoftick and @koshaji.
- Gateway/macOS: write LaunchAgent services with a canonical system PATH and stop preserving old plist PATH entries, so Volta, asdf, fnm, and pnpm shell paths no longer affect gateway child-process Node resolution. Fixes #75233; supersedes #75246. Thanks @nphyde2.
- Slack/hooks: preserve bot alert attachment text in message-received hook content when command text is blank. Fixes #76035; refs #76036. Thanks @amsminn.
- Sessions: route Gateway session-store writes and CLI cleanup maintenance through a dedicated in-process writer and borrow the validated mutable cache during the writer slot, avoiding runtime file locks plus repeated `sessions.json` rereads and JSON clones on hot metadata updates. Refs #68554. Thanks @henkterharmsel.
- Sessions/agents: route Gateway session-store writes, CLI cleanup maintenance, and agent-delete session purges through a dedicated in-process writer and borrow the validated mutable cache during the writer slot, avoiding runtime file locks plus repeated `sessions.json` rereads and JSON clones on hot metadata updates. Refs #68554. Thanks @henkterharmsel.
- Control UI/chat: show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly instead of clearing the composer silently. Fixes #52105. Thanks @MooreQiao.
- Memory/markdown: replace CRLF managed blocks in place and collapse duplicate marker blocks without rewriting unmanaged markdown, so Dreaming and Memory Wiki files self-heal from repeated generated sections. Fixes #75491; supersedes #75495, #75810, and #76008. Thanks @asaenokkostya-coder, @ottodeng, @everettjf, and @lrg913427-dot.
- Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser.

View File

@@ -152,6 +152,7 @@ Notes:
- `main` cannot be deleted.
- Without `--force`, interactive confirmation is required.
- Workspace, agent state, and session transcript directories are moved to Trash, not hard-deleted.
- When the Gateway is reachable, deletion is sent through the Gateway so config and session-store cleanup share the same writer as runtime traffic. If the Gateway cannot be reached, the CLI falls back to the offline local path.
- If another agent's workspace is the same path, inside this workspace, or contains this workspace,
the workspace is retained and `--json` reports `workspaceRetained`,
`workspaceRetainedReason`, and `workspaceSharedWith`.

View File

@@ -98,7 +98,7 @@ openclaw sessions cleanup --json
- `--store <path>`: run against a specific `sessions.json` file.
- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store.
When a Gateway is reachable, enforcing cleanup for configured agent stores is
When a Gateway is reachable, non-dry-run cleanup for configured agent stores is
sent through the Gateway so it shares the same session-store writer as runtime
traffic. Use `--store <path>` for explicit offline repair of a store file.

View File

@@ -85,7 +85,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f
- `maxDiskBytes`: optional sessions-directory budget
- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`)
Normal Gateway writes flow through a per-store session writer that serializes in-process mutations without taking a runtime file lock. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, `openclaw sessions cleanup --enforce` delegates maintenance to the Gateway so cleanup joins the same writer queue; `--store <path>` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately.
Normal Gateway writes flow through a per-store session writer that serializes in-process mutations without taking a runtime file lock. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store <path>` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately.
Maintenance keeps durable external conversation pointers such as group sessions
and thread-scoped chat sessions, but synthetic runtime entries for cron, hooks,

View File

@@ -0,0 +1,55 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { lowercasePreservingWhitespace } from "../shared/string-coerce.js";
import { listAgentEntries, resolveAgentWorkspaceDir } from "./agent-scope.js";
function normalizeWorkspacePathForComparison(input: string): string {
const resolved = path.resolve(input.replaceAll("\0", ""));
let normalized = resolved;
try {
normalized = fs.realpathSync.native(resolved);
} catch {
// Keep lexical path for non-existent directories.
}
if (process.platform === "win32") {
return lowercasePreservingWhitespace(normalized);
}
return normalized;
}
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
const relative = path.relative(rootPath, candidatePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function workspacePathsOverlap(left: string, right: string): boolean {
const normalizedLeft = normalizeWorkspacePathForComparison(left);
const normalizedRight = normalizeWorkspacePathForComparison(right);
return (
isPathWithinRoot(normalizedLeft, normalizedRight) ||
isPathWithinRoot(normalizedRight, normalizedLeft)
);
}
export function findOverlappingWorkspaceAgentIds(
cfg: OpenClawConfig,
agentId: string,
workspaceDir: string,
): string[] {
const entries = listAgentEntries(cfg);
const normalizedAgentId = normalizeAgentId(agentId);
const overlappingAgentIds: string[] = [];
for (const entry of entries) {
const otherAgentId = normalizeAgentId(entry.id);
if (otherAgentId === normalizedAgentId) {
continue;
}
const otherWorkspace = resolveAgentWorkspaceDir(cfg, otherAgentId);
if (workspacePathsOverlap(workspaceDir, otherWorkspace)) {
overlappingAgentIds.push(otherAgentId);
}
}
return overlappingAgentIds;
}

View File

@@ -1,9 +1,4 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveStorePath, updateSessionStore } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveStoredSessionOwnerAgentId } from "../gateway/session-store-key.js";
import { getLogger } from "../logging/logger.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import {
requireValidConfigFileSnapshot as requireValidConfigFileSnapshotBase,
@@ -21,34 +16,3 @@ export async function requireValidConfigFileSnapshot(runtime: RuntimeEnv) {
export async function requireValidConfig(runtime: RuntimeEnv): Promise<OpenClawConfig | null> {
return await requireValidConfigSnapshot(runtime);
}
/** Purge session store entries for a deleted agent (#65524). Best-effort. */
export async function purgeAgentSessionStoreEntries(
cfg: OpenClawConfig,
agentId: string,
): Promise<void> {
try {
const normalizedAgentId = normalizeAgentId(agentId);
const storeConfig = cfg.session?.store;
const storeAgentId =
typeof storeConfig === "string" && storeConfig.includes("{agentId}")
? normalizedAgentId
: normalizeAgentId(resolveDefaultAgentId(cfg));
const storePath = resolveStorePath(cfg.session?.store, { agentId: normalizedAgentId });
await updateSessionStore(storePath, (store) => {
for (const key of Object.keys(store)) {
if (
resolveStoredSessionOwnerAgentId({
cfg,
agentId: storeAgentId,
sessionKey: key,
}) === normalizedAgentId
) {
delete store[key];
}
}
});
} catch (err) {
getLogger().debug("session store purge skipped during agent delete", err);
}
}

View File

@@ -1,78 +1,56 @@
import fs from "node:fs";
import path from "node:path";
import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { replaceConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
purgeAgentSessionStoreEntries,
resolveSessionTranscriptsDirForAgent,
} from "../config/sessions.js";
import { callGateway, isGatewayTransportError } from "../gateway/call.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { lowercasePreservingWhitespace } from "../shared/string-coerce.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import {
createQuietRuntime,
purgeAgentSessionStoreEntries,
requireValidConfigFileSnapshot,
} from "./agents.command-shared.js";
import { createQuietRuntime, requireValidConfigFileSnapshot } from "./agents.command-shared.js";
import { findAgentEntryIndex, listAgentEntries, pruneAgentConfig } from "./agents.config.js";
import { moveToTrash } from "./onboard-helpers.js";
function normalizeWorkspacePathForComparison(input: string): string {
const resolved = path.resolve(input.replaceAll("\0", ""));
let normalized = resolved;
try {
normalized = fs.realpathSync.native(resolved);
} catch {
// Keep lexical path for non-existent directories.
}
if (process.platform === "win32") {
return lowercasePreservingWhitespace(normalized);
}
return normalized;
}
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
const relative = path.relative(rootPath, candidatePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function workspacePathsOverlap(left: string, right: string): boolean {
const normalizedLeft = normalizeWorkspacePathForComparison(left);
const normalizedRight = normalizeWorkspacePathForComparison(right);
return (
isPathWithinRoot(normalizedLeft, normalizedRight) ||
isPathWithinRoot(normalizedRight, normalizedLeft)
);
}
function findOverlappingWorkspaceAgentIds(
cfg: OpenClawConfig,
agentId: string,
workspaceDir: string,
): string[] {
const entries = listAgentEntries(cfg);
const normalizedAgentId = normalizeAgentId(agentId);
const overlappingAgentIds: string[] = [];
for (const entry of entries) {
const otherAgentId = normalizeAgentId(entry.id);
if (otherAgentId === normalizedAgentId) {
continue;
}
const otherWorkspace = resolveAgentWorkspaceDir(cfg, otherAgentId);
if (workspacePathsOverlap(workspaceDir, otherWorkspace)) {
overlappingAgentIds.push(otherAgentId);
}
}
return overlappingAgentIds;
}
type AgentsDeleteOptions = {
id: string;
force?: boolean;
json?: boolean;
};
type AgentsDeleteGatewayResult = {
ok: true;
agentId: string;
removedBindings: number;
};
async function maybeDeleteAgentThroughGateway(params: {
agentId: string;
deleteFiles: boolean;
}): Promise<AgentsDeleteGatewayResult | null> {
try {
return await callGateway<AgentsDeleteGatewayResult>({
method: "agents.delete",
params: {
agentId: params.agentId,
deleteFiles: params.deleteFiles,
},
mode: GATEWAY_CLIENT_MODES.CLI,
clientName: GATEWAY_CLIENT_NAMES.CLI,
requiredMethods: ["agents.delete"],
});
} catch (error) {
if (isGatewayTransportError(error)) {
return null;
}
throw error;
}
}
export async function agentsDeleteCommand(
opts: AgentsDeleteOptions,
runtime: RuntimeEnv = defaultRuntime,
@@ -127,8 +105,34 @@ export async function agentsDeleteCommand(
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const agentDir = resolveAgentDir(cfg, agentId);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
const result = pruneAgentConfig(cfg, agentId);
const gatewayResult = await maybeDeleteAgentThroughGateway({
agentId,
deleteFiles: true,
});
if (gatewayResult) {
const workspaceSharedWith = findOverlappingWorkspaceAgentIds(cfg, agentId, workspaceDir);
const workspaceRetained = workspaceSharedWith.length > 0;
if (opts.json) {
writeRuntimeJson(runtime, {
agentId,
workspace: workspaceDir,
workspaceRetained: workspaceRetained || undefined,
workspaceRetainedReason: workspaceRetained ? "shared" : undefined,
workspaceSharedWith: workspaceRetained ? workspaceSharedWith : undefined,
agentDir,
sessionsDir,
removedBindings: gatewayResult.removedBindings,
removedAllow: result.removedAllow,
transport: "gateway",
});
} else {
runtime.log(`Deleted agent: ${agentId}`);
}
return;
}
await replaceConfigFile({
nextConfig: result.config,
...(baseHash !== undefined ? { baseHash } : {}),

View File

@@ -15,12 +15,22 @@ const processMocks = vi.hoisted(() => ({
runCommandWithTimeout: vi.fn(async () => ({ stdout: "", stderr: "", code: 0 })),
}));
const gatewayMocks = vi.hoisted(() => ({
callGateway: vi.fn(),
isGatewayTransportError: vi.fn(),
}));
vi.mock("../config/config.js", async () => ({
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
readConfigFileSnapshot: configMocks.readConfigFileSnapshot,
replaceConfigFile: configMocks.replaceConfigFile,
}));
vi.mock("../gateway/call.js", () => ({
callGateway: gatewayMocks.callGateway,
isGatewayTransportError: gatewayMocks.isGatewayTransportError,
}));
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: processMocks.runCommandWithTimeout,
}));
@@ -75,11 +85,65 @@ describe("agents delete command", () => {
configMocks.readConfigFileSnapshot.mockReset();
configMocks.replaceConfigFile.mockReset();
processMocks.runCommandWithTimeout.mockClear();
gatewayMocks.callGateway.mockReset();
gatewayMocks.callGateway.mockRejectedValue(
Object.assign(new Error("closed"), { name: "GatewayTransportError" }),
);
gatewayMocks.isGatewayTransportError.mockReset();
gatewayMocks.isGatewayTransportError.mockImplementation(
(error: unknown) => error instanceof Error && error.name === "GatewayTransportError",
);
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
});
it("routes deletion through the Gateway when reachable", async () => {
await withStateDirEnv("openclaw-agents-delete-gateway-", async ({ stateDir }) => {
const now = Date.now();
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
{ id: "ops", workspace: path.join(stateDir, "workspace-ops") },
],
},
} satisfies OpenClawConfig;
const sessions = {
"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 },
"agent:main:main": { sessionId: "sess-main", updatedAt: now + 2 },
};
const storePath = await arrangeAgentsDeleteTest({
stateDir,
cfg,
deletedAgentId: "ops",
sessions,
});
gatewayMocks.callGateway.mockResolvedValue({
ok: true,
agentId: "ops",
removedBindings: 0,
});
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
expect(gatewayMocks.callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "agents.delete",
params: { agentId: "ops", deleteFiles: true },
requiredMethods: ["agents.delete"],
}),
);
expect(configMocks.replaceConfigFile).not.toHaveBeenCalled();
expectSessionStore(storePath, sessions);
expect(readJsonLogs()[0]).toMatchObject({
agentId: "ops",
removedBindings: 0,
transport: "gateway",
});
});
});
it("purges deleted agent entries from the session store", async () => {
await withStateDirEnv("openclaw-agents-delete-", async ({ stateDir }) => {
const now = Date.now();

View File

@@ -14,6 +14,9 @@ const mocks = vi.hoisted(() => ({
capEntryCount: vi.fn(),
updateSessionStore: vi.fn(),
enforceSessionDiskBudget: vi.fn(),
resolveSessionCleanupAction: vi.fn(),
runSessionsCleanup: vi.fn(),
serializeSessionCleanupResult: vi.fn(),
callGateway: vi.fn(),
isGatewayTransportError: vi.fn(),
}));
@@ -37,6 +40,9 @@ vi.mock("../config/sessions.js", () => ({
capEntryCount: mocks.capEntryCount,
updateSessionStore: mocks.updateSessionStore,
enforceSessionDiskBudget: mocks.enforceSessionDiskBudget,
resolveSessionCleanupAction: mocks.resolveSessionCleanupAction,
runSessionsCleanup: mocks.runSessionsCleanup,
serializeSessionCleanupResult: mocks.serializeSessionCleanupResult,
}));
vi.mock("../gateway/call.js", () => ({
@@ -106,6 +112,47 @@ describe("sessionsCleanupCommand", () => {
mocks.updateSessionStore.mockResolvedValue(0);
mocks.callGateway.mockResolvedValue(null);
mocks.isGatewayTransportError.mockReturnValue(true);
mocks.resolveSessionCleanupAction.mockImplementation(
(params: {
key: string;
missingKeys: Set<string>;
staleKeys: Set<string>;
cappedKeys: Set<string>;
budgetEvictedKeys: Set<string>;
}) => {
if (params.missingKeys.has(params.key)) {
return "prune-missing";
}
if (params.staleKeys.has(params.key)) {
return "prune-stale";
}
if (params.cappedKeys.has(params.key)) {
return "cap-overflow";
}
if (params.budgetEvictedKeys.has(params.key)) {
return "evict-budget";
}
return "keep";
},
);
mocks.serializeSessionCleanupResult.mockImplementation(
(params: { mode: string; dryRun: boolean; summaries: Record<string, unknown>[] }) => {
if (params.summaries.length === 1) {
return params.summaries[0] ?? {};
}
return {
allAgents: true,
mode: params.mode,
dryRun: params.dryRun,
stores: params.summaries,
};
},
);
mocks.runSessionsCleanup.mockResolvedValue({
mode: "warn",
previewResults: [],
appliedSummaries: [],
});
mocks.enforceSessionDiskBudget.mockResolvedValue({
totalBytesBefore: 1000,
totalBytesAfter: 700,
@@ -122,34 +169,18 @@ describe("sessionsCleanupCommand", () => {
mocks.callGateway.mockRejectedValue(
Object.assign(new Error("closed"), { name: "GatewayTransportError" }),
);
mocks.loadSessionStore
.mockReturnValueOnce({
stale: { sessionId: "stale", updatedAt: 1 },
fresh: { sessionId: "fresh", updatedAt: 2 },
})
.mockReturnValueOnce({
fresh: { sessionId: "fresh", updatedAt: 2 },
});
mocks.updateSessionStore.mockImplementation(
async (
_storePath: string,
mutator: (store: Record<string, SessionEntry>) => Promise<void> | void,
opts?: {
onMaintenanceApplied?: (report: {
mode: "warn" | "enforce";
beforeCount: number;
afterCount: number;
pruned: number;
capped: number;
diskBudget: Record<string, unknown> | null;
}) => Promise<void> | void;
},
) => {
await mutator({});
await opts?.onMaintenanceApplied?.({
mocks.runSessionsCleanup.mockResolvedValue({
mode: "enforce",
previewResults: [],
appliedSummaries: [
{
agentId: "main",
storePath: "/resolved/sessions.json",
mode: "enforce",
dryRun: false,
beforeCount: 3,
afterCount: 1,
missing: 0,
pruned: 0,
capped: 2,
diskBudget: {
@@ -162,10 +193,12 @@ describe("sessionsCleanupCommand", () => {
highWaterBytes: 800,
overBudget: true,
},
});
return 0;
},
);
wouldMutate: true,
applied: true,
appliedCount: 1,
},
],
});
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand(
@@ -191,13 +224,14 @@ describe("sessionsCleanupCommand", () => {
removedEntries: 0,
}),
);
expect(mocks.updateSessionStore).toHaveBeenCalledWith(
"/resolved/sessions.json",
expect.any(Function),
expect(mocks.runSessionsCleanup).toHaveBeenCalledWith(
expect.objectContaining({
activeSessionKey: "agent:main:main",
maintenanceOverride: { mode: "enforce" },
onMaintenanceApplied: expect.any(Function),
cfg: { session: { store: "/cfg/sessions.json" } },
opts: expect.objectContaining({
enforce: true,
activeKey: "agent:main:main",
}),
targets: [{ agentId: "main", storePath: "/resolved/sessions.json" }],
}),
);
});
@@ -240,9 +274,40 @@ describe("sessionsCleanupCommand", () => {
});
it("returns dry-run JSON without mutating the store", async () => {
mocks.loadSessionStore.mockReturnValue({
stale: { sessionId: "stale", updatedAt: 1 },
fresh: { sessionId: "fresh", updatedAt: 2 },
mocks.runSessionsCleanup.mockResolvedValue({
mode: "warn",
previewResults: [
{
summary: {
agentId: "main",
storePath: "/resolved/sessions.json",
mode: "warn",
dryRun: true,
beforeCount: 2,
afterCount: 1,
missing: 0,
pruned: 1,
capped: 0,
diskBudget: {
totalBytesBefore: 1000,
totalBytesAfter: 700,
removedFiles: 1,
removedEntries: 1,
freedBytes: 300,
maxBytes: 900,
highWaterBytes: 700,
overBudget: true,
},
wouldMutate: true,
},
beforeStore: {},
missingKeys: new Set<string>(),
staleKeys: new Set<string>(),
cappedKeys: new Set<string>(),
budgetEvictedKeys: new Set<string>(),
},
],
appliedSummaries: [],
});
const { runtime, logs } = makeRuntime();
@@ -258,6 +323,7 @@ describe("sessionsCleanupCommand", () => {
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
expect(payload.dryRun).toBe(true);
expect(payload.applied).toBeUndefined();
expect(mocks.runSessionsCleanup).toHaveBeenCalled();
expect(mocks.updateSessionStore).not.toHaveBeenCalled();
expect(payload.diskBudget).toEqual(
expect.objectContaining({
@@ -269,8 +335,31 @@ describe("sessionsCleanupCommand", () => {
it("counts missing transcript entries when --fix-missing is enabled in dry-run", async () => {
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
mocks.loadSessionStore.mockReturnValue({
missing: { sessionId: "missing-transcript", updatedAt: 1 },
mocks.runSessionsCleanup.mockResolvedValue({
mode: "warn",
previewResults: [
{
summary: {
agentId: "main",
storePath: "/resolved/sessions.json",
mode: "warn",
dryRun: true,
beforeCount: 1,
afterCount: 0,
missing: 1,
pruned: 0,
capped: 0,
diskBudget: null,
wouldMutate: true,
},
beforeStore: {},
missingKeys: new Set(["missing"]),
staleKeys: new Set<string>(),
cappedKeys: new Set<string>(),
budgetEvictedKeys: new Set<string>(),
},
],
appliedSummaries: [],
});
const { runtime, logs } = makeRuntime();
@@ -292,9 +381,34 @@ describe("sessionsCleanupCommand", () => {
it("renders a dry-run action table with keep/prune actions", async () => {
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
mocks.loadSessionStore.mockReturnValue({
stale: { sessionId: "stale", updatedAt: 1, model: "pi:opus" },
fresh: { sessionId: "fresh", updatedAt: 2, model: "pi:opus" },
mocks.runSessionsCleanup.mockResolvedValue({
mode: "warn",
previewResults: [
{
summary: {
agentId: "main",
storePath: "/resolved/sessions.json",
mode: "warn",
dryRun: true,
beforeCount: 2,
afterCount: 1,
missing: 0,
pruned: 1,
capped: 0,
diskBudget: null,
wouldMutate: true,
},
beforeStore: {
stale: { sessionId: "stale", updatedAt: 1, model: "pi:opus" },
fresh: { sessionId: "fresh", updatedAt: 2, model: "pi:opus" },
},
missingKeys: new Set<string>(),
staleKeys: new Set(["stale"]),
cappedKeys: new Set<string>(),
budgetEvictedKeys: new Set<string>(),
},
],
appliedSummaries: [],
});
const { runtime, logs } = makeRuntime();
@@ -317,9 +431,52 @@ describe("sessionsCleanupCommand", () => {
{ agentId: "work", storePath: "/resolved/work-sessions.json" },
]);
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
mocks.loadSessionStore
.mockReturnValueOnce({ stale: { sessionId: "stale-main", updatedAt: 1 } })
.mockReturnValueOnce({ stale: { sessionId: "stale-work", updatedAt: 1 } });
mocks.runSessionsCleanup.mockResolvedValue({
mode: "warn",
previewResults: [
{
summary: {
agentId: "main",
storePath: "/resolved/main-sessions.json",
mode: "warn",
dryRun: true,
beforeCount: 1,
afterCount: 0,
missing: 0,
pruned: 1,
capped: 0,
diskBudget: null,
wouldMutate: true,
},
beforeStore: {},
missingKeys: new Set<string>(),
staleKeys: new Set(["stale"]),
cappedKeys: new Set<string>(),
budgetEvictedKeys: new Set<string>(),
},
{
summary: {
agentId: "work",
storePath: "/resolved/work-sessions.json",
mode: "warn",
dryRun: true,
beforeCount: 1,
afterCount: 0,
missing: 0,
pruned: 1,
capped: 0,
diskBudget: null,
wouldMutate: true,
},
beforeStore: {},
missingKeys: new Set<string>(),
staleKeys: new Set(["stale"]),
cappedKeys: new Set<string>(),
budgetEvictedKeys: new Set<string>(),
},
],
appliedSummaries: [],
});
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand(

View File

@@ -1,31 +1,19 @@
import fs from "node:fs";
import { getRuntimeConfig } from "../config/config.js";
import {
capEntryCount,
enforceSessionDiskBudget,
resolveSessionFilePath,
resolveSessionFilePathOptions,
loadSessionStore,
pruneStaleEntries,
resolveMaintenanceConfig,
updateSessionStore,
type SessionEntry,
type SessionMaintenanceApplyReport,
resolveSessionCleanupAction,
runSessionsCleanup,
serializeSessionCleanupResult,
type SessionCleanupSummary,
type SessionsCleanupOptions,
type SessionsCleanupResult,
} from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway, isGatewayTransportError } from "../gateway/call.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
resolveSessionStoreTargets,
resolveSessionStoreTargetsOrExit,
type SessionStoreTarget,
} from "./session-store-targets.js";
import {
resolveSessionDisplayDefaults,
resolveSessionDisplayModel,
} from "./sessions-display-model.js";
import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js";
import { resolveSessionDisplayModel } from "./sessions-display-model.js";
import {
formatSessionAgeCell,
formatSessionFlagsCell,
@@ -37,78 +25,16 @@ import {
toSessionDisplayRows,
} from "./sessions-table.js";
export type SessionsCleanupOptions = {
store?: string;
agent?: string;
allAgents?: boolean;
dryRun?: boolean;
enforce?: boolean;
activeKey?: string;
json?: boolean;
fixMissing?: boolean;
};
type SessionCleanupAction =
| "keep"
| "prune-missing"
| "prune-stale"
| "cap-overflow"
| "evict-budget";
const ACTION_PAD = 12;
type SessionCleanupActionRow = ReturnType<typeof toSessionDisplayRows>[number] & {
action: SessionCleanupAction;
action: ReturnType<typeof resolveSessionCleanupAction>;
};
type SessionCleanupSummary = {
agentId: string;
storePath: string;
mode: "warn" | "enforce";
dryRun: boolean;
beforeCount: number;
afterCount: number;
missing: number;
pruned: number;
capped: number;
diskBudget: Awaited<ReturnType<typeof enforceSessionDiskBudget>>;
wouldMutate: boolean;
applied?: true;
appliedCount?: number;
};
export type SessionsCleanupResult =
| SessionCleanupSummary
| {
allAgents: true;
mode: "warn" | "enforce";
dryRun: boolean;
stores: SessionCleanupSummary[];
};
function resolveSessionCleanupAction(params: {
key: string;
missingKeys: Set<string>;
staleKeys: Set<string>;
cappedKeys: Set<string>;
budgetEvictedKeys: Set<string>;
}): SessionCleanupAction {
if (params.missingKeys.has(params.key)) {
return "prune-missing";
}
if (params.staleKeys.has(params.key)) {
return "prune-stale";
}
if (params.cappedKeys.has(params.key)) {
return "cap-overflow";
}
if (params.budgetEvictedKeys.has(params.key)) {
return "evict-budget";
}
return "keep";
}
function formatCleanupActionCell(action: SessionCleanupAction, rich: boolean): string {
function formatCleanupActionCell(
action: ReturnType<typeof resolveSessionCleanupAction>,
rich: boolean,
): string {
const label = action.padEnd(ACTION_PAD);
if (!rich) {
return label;
@@ -129,7 +55,7 @@ function formatCleanupActionCell(action: SessionCleanupAction, rich: boolean): s
}
function buildActionRows(params: {
beforeStore: Record<string, SessionEntry>;
beforeStore: Parameters<typeof toSessionDisplayRows>[0];
missingKeys: Set<string>;
staleKeys: Set<string>;
cappedKeys: Set<string>;
@@ -148,135 +74,10 @@ function buildActionRows(params: {
);
}
function pruneMissingTranscriptEntries(params: {
store: Record<string, SessionEntry>;
storePath: string;
onPruned?: (key: string) => void;
}): number {
const sessionPathOpts = resolveSessionFilePathOptions({
storePath: params.storePath,
});
let removed = 0;
for (const [key, entry] of Object.entries(params.store)) {
if (!entry?.sessionId) {
continue;
}
const transcriptPath = resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts);
if (!fs.existsSync(transcriptPath)) {
delete params.store[key];
removed += 1;
params.onPruned?.(key);
}
}
return removed;
}
async function previewStoreCleanup(params: {
target: SessionStoreTarget;
mode: "warn" | "enforce";
dryRun: boolean;
activeKey?: string;
fixMissing?: boolean;
}) {
const maintenance = resolveMaintenanceConfig();
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
const previewStore = structuredClone(beforeStore);
const staleKeys = new Set<string>();
const cappedKeys = new Set<string>();
const missingKeys = new Set<string>();
const missing =
params.fixMissing === true
? pruneMissingTranscriptEntries({
store: previewStore,
storePath: params.target.storePath,
onPruned: (key) => {
missingKeys.add(key);
},
})
: 0;
const pruned = pruneStaleEntries(previewStore, maintenance.pruneAfterMs, {
log: false,
onPruned: ({ key }) => {
staleKeys.add(key);
},
});
const capped = capEntryCount(previewStore, maintenance.maxEntries, {
log: false,
onCapped: ({ key }) => {
cappedKeys.add(key);
},
});
const beforeBudgetStore = structuredClone(previewStore);
const diskBudget = await enforceSessionDiskBudget({
store: previewStore,
storePath: params.target.storePath,
activeSessionKey: params.activeKey,
maintenance,
warnOnly: false,
dryRun: true,
});
const budgetEvictedKeys = new Set<string>();
for (const key of Object.keys(beforeBudgetStore)) {
if (!Object.hasOwn(previewStore, key)) {
budgetEvictedKeys.add(key);
}
}
const beforeCount = Object.keys(beforeStore).length;
const afterPreviewCount = Object.keys(previewStore).length;
const wouldMutate =
missing > 0 ||
pruned > 0 ||
capped > 0 ||
(diskBudget?.removedEntries ?? 0) > 0 ||
(diskBudget?.removedFiles ?? 0) > 0;
const summary: SessionCleanupSummary = {
agentId: params.target.agentId,
storePath: params.target.storePath,
mode: params.mode,
dryRun: params.dryRun,
beforeCount,
afterCount: afterPreviewCount,
missing,
pruned,
capped,
diskBudget,
wouldMutate,
};
return {
summary,
actionRows: buildActionRows({
beforeStore,
staleKeys,
cappedKeys,
budgetEvictedKeys,
missingKeys,
}),
};
}
function serializeSessionCleanupResult(params: {
mode: "warn" | "enforce";
dryRun: boolean;
summaries: SessionCleanupSummary[];
}): SessionsCleanupResult {
if (params.summaries.length === 1) {
return params.summaries[0] ?? ({} as SessionCleanupSummary);
}
return {
allAgents: true,
mode: params.mode,
dryRun: params.dryRun,
stores: params.summaries,
};
}
function renderStoreDryRunPlan(params: {
cfg: OpenClawConfig;
summary: SessionCleanupSummary;
actionRows: SessionCleanupActionRow[];
displayDefaults: ReturnType<typeof resolveSessionDisplayDefaults>;
runtime: RuntimeEnv;
showAgentHeader: boolean;
}) {
@@ -343,122 +144,6 @@ function renderAppliedSummaries(params: {
}
}
export async function runSessionsCleanup(params: {
cfg: OpenClawConfig;
opts: SessionsCleanupOptions;
targets?: SessionStoreTarget[];
}): Promise<{
mode: "warn" | "enforce";
previewResults: Array<{
summary: SessionCleanupSummary;
actionRows: SessionCleanupActionRow[];
}>;
appliedSummaries: SessionCleanupSummary[];
}> {
const { cfg, opts } = params;
const mode = opts.enforce ? "enforce" : resolveMaintenanceConfig().mode;
const targets =
params.targets ??
resolveSessionStoreTargets(cfg, {
store: opts.store,
agent: opts.agent,
allAgents: opts.allAgents,
});
const previewResults: Array<{
summary: SessionCleanupSummary;
actionRows: SessionCleanupActionRow[];
}> = [];
for (const target of targets) {
const result = await previewStoreCleanup({
target,
mode,
dryRun: Boolean(opts.dryRun),
activeKey: opts.activeKey,
fixMissing: Boolean(opts.fixMissing),
});
previewResults.push(result);
}
const appliedSummaries: SessionCleanupSummary[] = [];
if (!opts.dryRun) {
for (const target of targets) {
const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = {
current: null,
};
const missingApplied = await updateSessionStore(
target.storePath,
async (store) => {
if (!opts.fixMissing) {
return 0;
}
return pruneMissingTranscriptEntries({
store,
storePath: target.storePath,
});
},
{
activeSessionKey: opts.activeKey,
maintenanceOverride: {
mode,
},
onMaintenanceApplied: (report) => {
appliedReportRef.current = report;
},
},
);
const afterStore = loadSessionStore(target.storePath, { skipCache: true });
const preview = previewResults.find(
(result) => result.summary.storePath === target.storePath,
);
const appliedReport = appliedReportRef.current;
const summary: SessionCleanupSummary =
appliedReport === null
? {
...(preview?.summary ?? {
agentId: target.agentId,
storePath: target.storePath,
mode,
dryRun: false,
beforeCount: 0,
afterCount: 0,
missing: 0,
pruned: 0,
capped: 0,
diskBudget: null,
wouldMutate: false,
}),
dryRun: false,
applied: true,
appliedCount: Object.keys(afterStore).length,
}
: {
agentId: target.agentId,
storePath: target.storePath,
mode: appliedReport.mode,
dryRun: false,
beforeCount: appliedReport.beforeCount,
afterCount: appliedReport.afterCount,
missing: missingApplied,
pruned: appliedReport.pruned,
capped: appliedReport.capped,
diskBudget: appliedReport.diskBudget,
wouldMutate:
missingApplied > 0 ||
appliedReport.pruned > 0 ||
appliedReport.capped > 0 ||
(appliedReport.diskBudget?.removedEntries ?? 0) > 0 ||
(appliedReport.diskBudget?.removedFiles ?? 0) > 0,
applied: true,
appliedCount: Object.keys(afterStore).length,
};
appliedSummaries.push(summary);
}
}
return { mode, previewResults, appliedSummaries };
}
async function maybeRunGatewayCleanup(
opts: SessionsCleanupOptions,
): Promise<SessionsCleanupResult | null> {
@@ -502,7 +187,6 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
}
const cfg = getRuntimeConfig();
const displayDefaults = resolveSessionDisplayDefaults(cfg);
const targets = resolveSessionStoreTargetsOrExit({
cfg,
opts: {
@@ -542,8 +226,7 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
renderStoreDryRunPlan({
cfg,
summary: result.summary,
actionRows: result.actionRows,
displayDefaults,
actionRows: buildActionRows(result),
runtime,
showAgentHeader: previewResults.length > 1,
});

View File

@@ -15,3 +15,4 @@ export * from "./sessions/session-file.js";
export * from "./sessions/delivery-info.js";
export * from "./sessions/disk-budget.js";
export * from "./sessions/targets.js";
export * from "./sessions/cleanup-service.js";

View File

@@ -0,0 +1,363 @@
import fs from "node:fs";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { resolveStoredSessionOwnerAgentId } from "../../gateway/session-store-key.js";
import { getLogger } from "../../logging/logger.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import { enforceSessionDiskBudget } from "./disk-budget.js";
import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveStorePath,
} from "./paths.js";
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
import {
capEntryCount,
pruneStaleEntries,
type ResolvedSessionMaintenanceConfig,
} from "./store-maintenance.js";
import {
loadSessionStore,
updateSessionStore,
type SessionMaintenanceApplyReport,
} from "./store.js";
import {
resolveSessionStoreTargets,
type SessionStoreTarget,
type SessionStoreSelectionOptions,
} from "./targets.js";
import type { SessionEntry } from "./types.js";
export type SessionsCleanupOptions = SessionStoreSelectionOptions & {
dryRun?: boolean;
enforce?: boolean;
activeKey?: string;
json?: boolean;
fixMissing?: boolean;
};
export type SessionCleanupAction =
| "keep"
| "prune-missing"
| "prune-stale"
| "cap-overflow"
| "evict-budget";
export type SessionCleanupSummary = {
agentId: string;
storePath: string;
mode: ResolvedSessionMaintenanceConfig["mode"];
dryRun: boolean;
beforeCount: number;
afterCount: number;
missing: number;
pruned: number;
capped: number;
diskBudget: Awaited<ReturnType<typeof enforceSessionDiskBudget>>;
wouldMutate: boolean;
applied?: true;
appliedCount?: number;
};
export type SessionsCleanupResult =
| SessionCleanupSummary
| {
allAgents: true;
mode: ResolvedSessionMaintenanceConfig["mode"];
dryRun: boolean;
stores: SessionCleanupSummary[];
};
export type SessionsCleanupRunResult = {
mode: ResolvedSessionMaintenanceConfig["mode"];
previewResults: Array<{
summary: SessionCleanupSummary;
beforeStore: Record<string, SessionEntry>;
missingKeys: Set<string>;
staleKeys: Set<string>;
cappedKeys: Set<string>;
budgetEvictedKeys: Set<string>;
}>;
appliedSummaries: SessionCleanupSummary[];
};
export function resolveSessionCleanupAction(params: {
key: string;
missingKeys: Set<string>;
staleKeys: Set<string>;
cappedKeys: Set<string>;
budgetEvictedKeys: Set<string>;
}): SessionCleanupAction {
if (params.missingKeys.has(params.key)) {
return "prune-missing";
}
if (params.staleKeys.has(params.key)) {
return "prune-stale";
}
if (params.cappedKeys.has(params.key)) {
return "cap-overflow";
}
if (params.budgetEvictedKeys.has(params.key)) {
return "evict-budget";
}
return "keep";
}
export function serializeSessionCleanupResult(params: {
mode: ResolvedSessionMaintenanceConfig["mode"];
dryRun: boolean;
summaries: SessionCleanupSummary[];
}): SessionsCleanupResult {
if (params.summaries.length === 1) {
return params.summaries[0] ?? ({} as SessionCleanupSummary);
}
return {
allAgents: true,
mode: params.mode,
dryRun: params.dryRun,
stores: params.summaries,
};
}
function pruneMissingTranscriptEntries(params: {
store: Record<string, SessionEntry>;
storePath: string;
onPruned?: (key: string) => void;
}): number {
const sessionPathOpts = resolveSessionFilePathOptions({
storePath: params.storePath,
});
let removed = 0;
for (const [key, entry] of Object.entries(params.store)) {
if (!entry?.sessionId) {
continue;
}
const transcriptPath = resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts);
if (!fs.existsSync(transcriptPath)) {
delete params.store[key];
removed += 1;
params.onPruned?.(key);
}
}
return removed;
}
async function previewStoreCleanup(params: {
target: SessionStoreTarget;
mode: ResolvedSessionMaintenanceConfig["mode"];
dryRun: boolean;
activeKey?: string;
fixMissing?: boolean;
}) {
const maintenance = resolveMaintenanceConfig();
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
const previewStore = structuredClone(beforeStore);
const staleKeys = new Set<string>();
const cappedKeys = new Set<string>();
const missingKeys = new Set<string>();
const missing =
params.fixMissing === true
? pruneMissingTranscriptEntries({
store: previewStore,
storePath: params.target.storePath,
onPruned: (key) => {
missingKeys.add(key);
},
})
: 0;
const pruned = pruneStaleEntries(previewStore, maintenance.pruneAfterMs, {
log: false,
onPruned: ({ key }) => {
staleKeys.add(key);
},
});
const capped = capEntryCount(previewStore, maintenance.maxEntries, {
log: false,
onCapped: ({ key }) => {
cappedKeys.add(key);
},
});
const beforeBudgetStore = structuredClone(previewStore);
const diskBudget = await enforceSessionDiskBudget({
store: previewStore,
storePath: params.target.storePath,
activeSessionKey: params.activeKey,
maintenance,
warnOnly: false,
dryRun: true,
});
const budgetEvictedKeys = new Set<string>();
for (const key of Object.keys(beforeBudgetStore)) {
if (!Object.hasOwn(previewStore, key)) {
budgetEvictedKeys.add(key);
}
}
const beforeCount = Object.keys(beforeStore).length;
const afterPreviewCount = Object.keys(previewStore).length;
const wouldMutate =
missing > 0 ||
pruned > 0 ||
capped > 0 ||
(diskBudget?.removedEntries ?? 0) > 0 ||
(diskBudget?.removedFiles ?? 0) > 0;
const summary: SessionCleanupSummary = {
agentId: params.target.agentId,
storePath: params.target.storePath,
mode: params.mode,
dryRun: params.dryRun,
beforeCount,
afterCount: afterPreviewCount,
missing,
pruned,
capped,
diskBudget,
wouldMutate,
};
return {
summary,
beforeStore,
missingKeys,
staleKeys,
cappedKeys,
budgetEvictedKeys,
};
}
export async function runSessionsCleanup(params: {
cfg: OpenClawConfig;
opts: SessionsCleanupOptions;
targets?: SessionStoreTarget[];
}): Promise<SessionsCleanupRunResult> {
const { cfg, opts } = params;
const mode = opts.enforce ? "enforce" : resolveMaintenanceConfig().mode;
const targets =
params.targets ??
resolveSessionStoreTargets(cfg, {
store: opts.store,
agent: opts.agent,
allAgents: opts.allAgents,
});
const previewResults: SessionsCleanupRunResult["previewResults"] = [];
for (const target of targets) {
const result = await previewStoreCleanup({
target,
mode,
dryRun: Boolean(opts.dryRun),
activeKey: opts.activeKey,
fixMissing: Boolean(opts.fixMissing),
});
previewResults.push(result);
}
const appliedSummaries: SessionCleanupSummary[] = [];
if (!opts.dryRun) {
for (const target of targets) {
const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = {
current: null,
};
const missingApplied = await updateSessionStore(
target.storePath,
async (store) => {
if (!opts.fixMissing) {
return 0;
}
return pruneMissingTranscriptEntries({
store,
storePath: target.storePath,
});
},
{
activeSessionKey: opts.activeKey,
maintenanceOverride: {
mode,
},
onMaintenanceApplied: (report) => {
appliedReportRef.current = report;
},
},
);
const afterStore = loadSessionStore(target.storePath, { skipCache: true });
const preview = previewResults.find(
(result) => result.summary.storePath === target.storePath,
);
const appliedReport = appliedReportRef.current;
const summary: SessionCleanupSummary =
appliedReport === null
? {
...(preview?.summary ?? {
agentId: target.agentId,
storePath: target.storePath,
mode,
dryRun: false,
beforeCount: 0,
afterCount: 0,
missing: 0,
pruned: 0,
capped: 0,
diskBudget: null,
wouldMutate: false,
}),
dryRun: false,
applied: true,
appliedCount: Object.keys(afterStore).length,
}
: {
agentId: target.agentId,
storePath: target.storePath,
mode: appliedReport.mode,
dryRun: false,
beforeCount: appliedReport.beforeCount,
afterCount: appliedReport.afterCount,
missing: missingApplied,
pruned: appliedReport.pruned,
capped: appliedReport.capped,
diskBudget: appliedReport.diskBudget,
wouldMutate:
missingApplied > 0 ||
appliedReport.pruned > 0 ||
appliedReport.capped > 0 ||
(appliedReport.diskBudget?.removedEntries ?? 0) > 0 ||
(appliedReport.diskBudget?.removedFiles ?? 0) > 0,
applied: true,
appliedCount: Object.keys(afterStore).length,
};
appliedSummaries.push(summary);
}
}
return { mode, previewResults, appliedSummaries };
}
/** Purge session store entries for a deleted agent (#65524). Best-effort. */
export async function purgeAgentSessionStoreEntries(
cfg: OpenClawConfig,
agentId: string,
): Promise<void> {
try {
const normalizedAgentId = normalizeAgentId(agentId);
const storeConfig = cfg.session?.store;
const storeAgentId =
typeof storeConfig === "string" && storeConfig.includes("{agentId}")
? normalizedAgentId
: normalizeAgentId(resolveDefaultAgentId(cfg));
const storePath = resolveStorePath(cfg.session?.store, { agentId: normalizedAgentId });
await updateSessionStore(storePath, (store) => {
for (const key of Object.keys(store)) {
if (
resolveStoredSessionOwnerAgentId({
cfg,
agentId: storeAgentId,
sessionKey: key,
}) === normalizedAgentId
) {
delete store[key];
}
}
});
} catch (err) {
getLogger().debug("session store purge skipped during agent delete", err);
}
}

View File

@@ -63,6 +63,7 @@ vi.mock("../../commands/agents.config.js", () => ({
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: () => ["main"],
listAgentEntries: mocks.listAgentEntries,
resolveAgentDir: mocks.resolveAgentDir,
resolveAgentConfig: (cfg: unknown, agentId: string) =>
getAgentList(cfg).find((entry) => entry.id === agentId),

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { findOverlappingWorkspaceAgentIds } from "../../agents/agent-delete-safety.js";
import {
listAgentIds,
resolveAgentDir,
@@ -19,7 +20,6 @@ import {
ensureAgentWorkspace,
isWorkspaceSetupCompleted,
} from "../../agents/workspace.js";
import { purgeAgentSessionStoreEntries } from "../../commands/agents.command-shared.js";
import {
applyAgentConfig,
findAgentEntryIndex,
@@ -27,7 +27,10 @@ import {
pruneAgentConfig,
} from "../../commands/agents.config.js";
import { replaceConfigFile } from "../../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
import {
purgeAgentSessionStoreEntries,
resolveSessionTranscriptsDirForAgent,
} from "../../config/sessions.js";
import type { IdentityConfig } from "../../config/types.base.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { sameFileIdentity } from "../../infra/file-identity.js";
@@ -657,8 +660,10 @@ export const agentsHandlers: GatewayRequestHandlers = {
await purgeAgentSessionStoreEntries(cfg, agentId);
if (deleteFiles) {
const workspaceSharedWith = findOverlappingWorkspaceAgentIds(cfg, agentId, workspaceDir);
const deleteWorkspace = workspaceSharedWith.length === 0;
await Promise.all([
moveToTrashBestEffort(workspaceDir),
...(deleteWorkspace ? [moveToTrashBestEffort(workspaceDir)] : []),
moveToTrashBestEffort(agentDir),
moveToTrashBestEffort(sessionsDir),
]);

View File

@@ -12,9 +12,10 @@ import {
import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js";
import { normalizeReasoningLevel, normalizeThinkLevel } from "../../auto-reply/thinking.js";
import { runSessionsCleanup } from "../../commands/sessions-cleanup.js";
import {
loadSessionStore,
runSessionsCleanup,
serializeSessionCleanupResult,
resolveMainSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
@@ -680,15 +681,11 @@ export const sessionsHandlers: GatewayRequestHandlers = {
fixMissing: params.fixMissing,
},
});
const result =
appliedSummaries.length === 1
? (appliedSummaries[0] ?? {})
: {
allAgents: true,
mode,
dryRun: false,
stores: appliedSummaries,
};
const result = serializeSessionCleanupResult({
mode,
dryRun: false,
summaries: appliedSummaries,
});
respond(true, result, undefined);
for (const summary of appliedSummaries) {
emitSessionsChanged(context, {