From b151694e0048cefaaf5c2f143c37eeb49348fb8f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 13:40:28 +0100 Subject: [PATCH] refactor(sessions): route cleanup through controlled writers --- CHANGELOG.md | 2 +- docs/cli/agents.md | 1 + docs/cli/sessions.md | 2 +- .../session-management-compaction.md | 2 +- src/agents/agent-delete-safety.ts | 55 +++ src/commands/agents.command-shared.ts | 36 -- src/commands/agents.commands.delete.ts | 124 +++--- src/commands/agents.delete.test.ts | 64 +++ src/commands/sessions-cleanup.test.ts | 249 +++++++++--- src/commands/sessions-cleanup.ts | 347 +---------------- src/config/sessions.ts | 1 + src/config/sessions/cleanup-service.ts | 363 ++++++++++++++++++ .../server-methods/agents-mutate.test.ts | 1 + src/gateway/server-methods/agents.ts | 11 +- src/gateway/server-methods/sessions.ts | 17 +- 15 files changed, 785 insertions(+), 490 deletions(-) create mode 100644 src/agents/agent-delete-safety.ts create mode 100644 src/config/sessions/cleanup-service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 363706888d0..f4b5175f9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ Docs: https://docs.openclaw.ai - Models CLI: restore `openclaw models list --provider ` 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. diff --git a/docs/cli/agents.md b/docs/cli/agents.md index 41cb8effa03..5055c95982b 100644 --- a/docs/cli/agents.md +++ b/docs/cli/agents.md @@ -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`. diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 32f8cf23fe5..62dfb146162 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -98,7 +98,7 @@ openclaw sessions cleanup --json - `--store `: 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 ` for explicit offline repair of a store file. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index d6346b7671d..82040d6a8ef 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -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 ` 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 ` 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, diff --git a/src/agents/agent-delete-safety.ts b/src/agents/agent-delete-safety.ts new file mode 100644 index 00000000000..3de2e6d4067 --- /dev/null +++ b/src/agents/agent-delete-safety.ts @@ -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; +} diff --git a/src/commands/agents.command-shared.ts b/src/commands/agents.command-shared.ts index 4597bd9d86a..3508047f3e1 100644 --- a/src/commands/agents.command-shared.ts +++ b/src/commands/agents.command-shared.ts @@ -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 { return await requireValidConfigSnapshot(runtime); } - -/** Purge session store entries for a deleted agent (#65524). Best-effort. */ -export async function purgeAgentSessionStoreEntries( - cfg: OpenClawConfig, - agentId: string, -): Promise { - 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); - } -} diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts index 909168ea806..7f73e7cab13 100644 --- a/src/commands/agents.commands.delete.ts +++ b/src/commands/agents.commands.delete.ts @@ -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 { + try { + return await callGateway({ + 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 } : {}), diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 69543f0dbeb..6827c317e8a 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -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("../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(); diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 8c06c3d1316..9e458dfb86b 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -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; + staleKeys: Set; + cappedKeys: Set; + budgetEvictedKeys: Set; + }) => { + 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[] }) => { + 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) => Promise | void, - opts?: { - onMaintenanceApplied?: (report: { - mode: "warn" | "enforce"; - beforeCount: number; - afterCount: number; - pruned: number; - capped: number; - diskBudget: Record | null; - }) => Promise | 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(), + staleKeys: new Set(), + cappedKeys: new Set(), + budgetEvictedKeys: new Set(), + }, + ], + appliedSummaries: [], }); const { runtime, logs } = makeRuntime(); @@ -258,6 +323,7 @@ describe("sessionsCleanupCommand", () => { const payload = JSON.parse(logs[0] ?? "{}") as Record; 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(), + cappedKeys: new Set(), + budgetEvictedKeys: new Set(), + }, + ], + 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(), + staleKeys: new Set(["stale"]), + cappedKeys: new Set(), + budgetEvictedKeys: new Set(), + }, + ], + 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(), + staleKeys: new Set(["stale"]), + cappedKeys: new Set(), + budgetEvictedKeys: new Set(), + }, + { + 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(), + staleKeys: new Set(["stale"]), + cappedKeys: new Set(), + budgetEvictedKeys: new Set(), + }, + ], + appliedSummaries: [], + }); const { runtime, logs } = makeRuntime(); await sessionsCleanupCommand( diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index 2c2e189c409..a84485a3c52 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -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[number] & { - action: SessionCleanupAction; + action: ReturnType; }; -type SessionCleanupSummary = { - agentId: string; - storePath: string; - mode: "warn" | "enforce"; - dryRun: boolean; - beforeCount: number; - afterCount: number; - missing: number; - pruned: number; - capped: number; - diskBudget: Awaited>; - 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; - staleKeys: Set; - cappedKeys: Set; - budgetEvictedKeys: Set; -}): 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, + 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; + beforeStore: Parameters[0]; missingKeys: Set; staleKeys: Set; cappedKeys: Set; @@ -148,135 +74,10 @@ function buildActionRows(params: { ); } -function pruneMissingTranscriptEntries(params: { - store: Record; - 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(); - const cappedKeys = new Set(); - const missingKeys = new Set(); - 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(); - 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; 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 { @@ -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, }); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 2cc5c170edf..78bff62938d 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -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"; diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts new file mode 100644 index 00000000000..f2e9ccc80e8 --- /dev/null +++ b/src/config/sessions/cleanup-service.ts @@ -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>; + 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; + missingKeys: Set; + staleKeys: Set; + cappedKeys: Set; + budgetEvictedKeys: Set; + }>; + appliedSummaries: SessionCleanupSummary[]; +}; + +export function resolveSessionCleanupAction(params: { + key: string; + missingKeys: Set; + staleKeys: Set; + cappedKeys: Set; + budgetEvictedKeys: Set; +}): 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; + 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(); + const cappedKeys = new Set(); + const missingKeys = new Set(); + 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(); + 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 { + 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 { + 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); + } +} diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 844b319e862..35c83f73579 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -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), diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index d8a47cc5721..cf86cb979e7 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -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), ]); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 89be41b2a09..e5525c2209b 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -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, {