Skip to content

Commit b151694

Browse files
committed
refactor(sessions): route cleanup through controlled writers
1 parent 2165d16 commit b151694

15 files changed

Lines changed: 785 additions & 490 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Docs: https://docs.openclaw.ai
4040
- 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.
4141
- 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.
4242
- Slack/hooks: preserve bot alert attachment text in message-received hook content when command text is blank. Fixes #76035; refs #76036. Thanks @amsminn.
43-
- 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.
43+
- 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.
4444
- 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.
4545
- 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.
4646
- 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.

docs/cli/agents.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ Notes:
152152
- `main` cannot be deleted.
153153
- Without `--force`, interactive confirmation is required.
154154
- Workspace, agent state, and session transcript directories are moved to Trash, not hard-deleted.
155+
- 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.
155156
- If another agent's workspace is the same path, inside this workspace, or contains this workspace,
156157
the workspace is retained and `--json` reports `workspaceRetained`,
157158
`workspaceRetainedReason`, and `workspaceSharedWith`.

docs/cli/sessions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ openclaw sessions cleanup --json
9898
- `--store <path>`: run against a specific `sessions.json` file.
9999
- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store.
100100

101-
When a Gateway is reachable, enforcing cleanup for configured agent stores is
101+
When a Gateway is reachable, non-dry-run cleanup for configured agent stores is
102102
sent through the Gateway so it shares the same session-store writer as runtime
103103
traffic. Use `--store <path>` for explicit offline repair of a store file.
104104

docs/reference/session-management-compaction.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f
8585
- `maxDiskBytes`: optional sessions-directory budget
8686
- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`)
8787

88-
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.
88+
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.
8989

9090
Maintenance keeps durable external conversation pointers such as group sessions
9191
and thread-scoped chat sessions, but synthetic runtime entries for cron, hooks,

src/agents/agent-delete-safety.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import type { OpenClawConfig } from "../config/types.openclaw.js";
4+
import { normalizeAgentId } from "../routing/session-key.js";
5+
import { lowercasePreservingWhitespace } from "../shared/string-coerce.js";
6+
import { listAgentEntries, resolveAgentWorkspaceDir } from "./agent-scope.js";
7+
8+
function normalizeWorkspacePathForComparison(input: string): string {
9+
const resolved = path.resolve(input.replaceAll("\0", ""));
10+
let normalized = resolved;
11+
try {
12+
normalized = fs.realpathSync.native(resolved);
13+
} catch {
14+
// Keep lexical path for non-existent directories.
15+
}
16+
if (process.platform === "win32") {
17+
return lowercasePreservingWhitespace(normalized);
18+
}
19+
return normalized;
20+
}
21+
22+
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
23+
const relative = path.relative(rootPath, candidatePath);
24+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
25+
}
26+
27+
function workspacePathsOverlap(left: string, right: string): boolean {
28+
const normalizedLeft = normalizeWorkspacePathForComparison(left);
29+
const normalizedRight = normalizeWorkspacePathForComparison(right);
30+
return (
31+
isPathWithinRoot(normalizedLeft, normalizedRight) ||
32+
isPathWithinRoot(normalizedRight, normalizedLeft)
33+
);
34+
}
35+
36+
export function findOverlappingWorkspaceAgentIds(
37+
cfg: OpenClawConfig,
38+
agentId: string,
39+
workspaceDir: string,
40+
): string[] {
41+
const entries = listAgentEntries(cfg);
42+
const normalizedAgentId = normalizeAgentId(agentId);
43+
const overlappingAgentIds: string[] = [];
44+
for (const entry of entries) {
45+
const otherAgentId = normalizeAgentId(entry.id);
46+
if (otherAgentId === normalizedAgentId) {
47+
continue;
48+
}
49+
const otherWorkspace = resolveAgentWorkspaceDir(cfg, otherAgentId);
50+
if (workspacePathsOverlap(workspaceDir, otherWorkspace)) {
51+
overlappingAgentIds.push(otherAgentId);
52+
}
53+
}
54+
return overlappingAgentIds;
55+
}
Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
2-
import { resolveStorePath, updateSessionStore } from "../config/sessions.js";
31
import type { OpenClawConfig } from "../config/types.openclaw.js";
4-
import { resolveStoredSessionOwnerAgentId } from "../gateway/session-store-key.js";
5-
import { getLogger } from "../logging/logger.js";
6-
import { normalizeAgentId } from "../routing/session-key.js";
72
import type { RuntimeEnv } from "../runtime.js";
83
import {
94
requireValidConfigFileSnapshot as requireValidConfigFileSnapshotBase,
@@ -21,34 +16,3 @@ export async function requireValidConfigFileSnapshot(runtime: RuntimeEnv) {
2116
export async function requireValidConfig(runtime: RuntimeEnv): Promise<OpenClawConfig | null> {
2217
return await requireValidConfigSnapshot(runtime);
2318
}
24-
25-
/** Purge session store entries for a deleted agent (#65524). Best-effort. */
26-
export async function purgeAgentSessionStoreEntries(
27-
cfg: OpenClawConfig,
28-
agentId: string,
29-
): Promise<void> {
30-
try {
31-
const normalizedAgentId = normalizeAgentId(agentId);
32-
const storeConfig = cfg.session?.store;
33-
const storeAgentId =
34-
typeof storeConfig === "string" && storeConfig.includes("{agentId}")
35-
? normalizedAgentId
36-
: normalizeAgentId(resolveDefaultAgentId(cfg));
37-
const storePath = resolveStorePath(cfg.session?.store, { agentId: normalizedAgentId });
38-
await updateSessionStore(storePath, (store) => {
39-
for (const key of Object.keys(store)) {
40-
if (
41-
resolveStoredSessionOwnerAgentId({
42-
cfg,
43-
agentId: storeAgentId,
44-
sessionKey: key,
45-
}) === normalizedAgentId
46-
) {
47-
delete store[key];
48-
}
49-
}
50-
});
51-
} catch (err) {
52-
getLogger().debug("session store purge skipped during agent delete", err);
53-
}
54-
}

src/commands/agents.commands.delete.ts

Lines changed: 64 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,56 @@
1-
import fs from "node:fs";
2-
import path from "node:path";
1+
import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js";
32
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
43
import { replaceConfigFile } from "../config/config.js";
54
import { logConfigUpdated } from "../config/logging.js";
6-
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
7-
import type { OpenClawConfig } from "../config/types.openclaw.js";
5+
import {
6+
purgeAgentSessionStoreEntries,
7+
resolveSessionTranscriptsDirForAgent,
8+
} from "../config/sessions.js";
9+
import { callGateway, isGatewayTransportError } from "../gateway/call.js";
810
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
911
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
1012
import { defaultRuntime } from "../runtime.js";
11-
import { lowercasePreservingWhitespace } from "../shared/string-coerce.js";
13+
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
1214
import { createClackPrompter } from "../wizard/clack-prompter.js";
13-
import {
14-
createQuietRuntime,
15-
purgeAgentSessionStoreEntries,
16-
requireValidConfigFileSnapshot,
17-
} from "./agents.command-shared.js";
15+
import { createQuietRuntime, requireValidConfigFileSnapshot } from "./agents.command-shared.js";
1816
import { findAgentEntryIndex, listAgentEntries, pruneAgentConfig } from "./agents.config.js";
1917
import { moveToTrash } from "./onboard-helpers.js";
2018

21-
function normalizeWorkspacePathForComparison(input: string): string {
22-
const resolved = path.resolve(input.replaceAll("\0", ""));
23-
let normalized = resolved;
24-
try {
25-
normalized = fs.realpathSync.native(resolved);
26-
} catch {
27-
// Keep lexical path for non-existent directories.
28-
}
29-
if (process.platform === "win32") {
30-
return lowercasePreservingWhitespace(normalized);
31-
}
32-
return normalized;
33-
}
34-
35-
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
36-
const relative = path.relative(rootPath, candidatePath);
37-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
38-
}
39-
40-
function workspacePathsOverlap(left: string, right: string): boolean {
41-
const normalizedLeft = normalizeWorkspacePathForComparison(left);
42-
const normalizedRight = normalizeWorkspacePathForComparison(right);
43-
return (
44-
isPathWithinRoot(normalizedLeft, normalizedRight) ||
45-
isPathWithinRoot(normalizedRight, normalizedLeft)
46-
);
47-
}
48-
49-
function findOverlappingWorkspaceAgentIds(
50-
cfg: OpenClawConfig,
51-
agentId: string,
52-
workspaceDir: string,
53-
): string[] {
54-
const entries = listAgentEntries(cfg);
55-
const normalizedAgentId = normalizeAgentId(agentId);
56-
const overlappingAgentIds: string[] = [];
57-
for (const entry of entries) {
58-
const otherAgentId = normalizeAgentId(entry.id);
59-
if (otherAgentId === normalizedAgentId) {
60-
continue;
61-
}
62-
const otherWorkspace = resolveAgentWorkspaceDir(cfg, otherAgentId);
63-
if (workspacePathsOverlap(workspaceDir, otherWorkspace)) {
64-
overlappingAgentIds.push(otherAgentId);
65-
}
66-
}
67-
return overlappingAgentIds;
68-
}
69-
7019
type AgentsDeleteOptions = {
7120
id: string;
7221
force?: boolean;
7322
json?: boolean;
7423
};
7524

25+
type AgentsDeleteGatewayResult = {
26+
ok: true;
27+
agentId: string;
28+
removedBindings: number;
29+
};
30+
31+
async function maybeDeleteAgentThroughGateway(params: {
32+
agentId: string;
33+
deleteFiles: boolean;
34+
}): Promise<AgentsDeleteGatewayResult | null> {
35+
try {
36+
return await callGateway<AgentsDeleteGatewayResult>({
37+
method: "agents.delete",
38+
params: {
39+
agentId: params.agentId,
40+
deleteFiles: params.deleteFiles,
41+
},
42+
mode: GATEWAY_CLIENT_MODES.CLI,
43+
clientName: GATEWAY_CLIENT_NAMES.CLI,
44+
requiredMethods: ["agents.delete"],
45+
});
46+
} catch (error) {
47+
if (isGatewayTransportError(error)) {
48+
return null;
49+
}
50+
throw error;
51+
}
52+
}
53+
7654
export async function agentsDeleteCommand(
7755
opts: AgentsDeleteOptions,
7856
runtime: RuntimeEnv = defaultRuntime,
@@ -127,8 +105,34 @@ export async function agentsDeleteCommand(
127105
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
128106
const agentDir = resolveAgentDir(cfg, agentId);
129107
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
130-
131108
const result = pruneAgentConfig(cfg, agentId);
109+
110+
const gatewayResult = await maybeDeleteAgentThroughGateway({
111+
agentId,
112+
deleteFiles: true,
113+
});
114+
if (gatewayResult) {
115+
const workspaceSharedWith = findOverlappingWorkspaceAgentIds(cfg, agentId, workspaceDir);
116+
const workspaceRetained = workspaceSharedWith.length > 0;
117+
if (opts.json) {
118+
writeRuntimeJson(runtime, {
119+
agentId,
120+
workspace: workspaceDir,
121+
workspaceRetained: workspaceRetained || undefined,
122+
workspaceRetainedReason: workspaceRetained ? "shared" : undefined,
123+
workspaceSharedWith: workspaceRetained ? workspaceSharedWith : undefined,
124+
agentDir,
125+
sessionsDir,
126+
removedBindings: gatewayResult.removedBindings,
127+
removedAllow: result.removedAllow,
128+
transport: "gateway",
129+
});
130+
} else {
131+
runtime.log(`Deleted agent: ${agentId}`);
132+
}
133+
return;
134+
}
135+
132136
await replaceConfigFile({
133137
nextConfig: result.config,
134138
...(baseHash !== undefined ? { baseHash } : {}),

src/commands/agents.delete.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,22 @@ const processMocks = vi.hoisted(() => ({
1515
runCommandWithTimeout: vi.fn(async () => ({ stdout: "", stderr: "", code: 0 })),
1616
}));
1717

18+
const gatewayMocks = vi.hoisted(() => ({
19+
callGateway: vi.fn(),
20+
isGatewayTransportError: vi.fn(),
21+
}));
22+
1823
vi.mock("../config/config.js", async () => ({
1924
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
2025
readConfigFileSnapshot: configMocks.readConfigFileSnapshot,
2126
replaceConfigFile: configMocks.replaceConfigFile,
2227
}));
2328

29+
vi.mock("../gateway/call.js", () => ({
30+
callGateway: gatewayMocks.callGateway,
31+
isGatewayTransportError: gatewayMocks.isGatewayTransportError,
32+
}));
33+
2434
vi.mock("../process/exec.js", () => ({
2535
runCommandWithTimeout: processMocks.runCommandWithTimeout,
2636
}));
@@ -75,11 +85,65 @@ describe("agents delete command", () => {
7585
configMocks.readConfigFileSnapshot.mockReset();
7686
configMocks.replaceConfigFile.mockReset();
7787
processMocks.runCommandWithTimeout.mockClear();
88+
gatewayMocks.callGateway.mockReset();
89+
gatewayMocks.callGateway.mockRejectedValue(
90+
Object.assign(new Error("closed"), { name: "GatewayTransportError" }),
91+
);
92+
gatewayMocks.isGatewayTransportError.mockReset();
93+
gatewayMocks.isGatewayTransportError.mockImplementation(
94+
(error: unknown) => error instanceof Error && error.name === "GatewayTransportError",
95+
);
7896
runtime.log.mockClear();
7997
runtime.error.mockClear();
8098
runtime.exit.mockClear();
8199
});
82100

101+
it("routes deletion through the Gateway when reachable", async () => {
102+
await withStateDirEnv("openclaw-agents-delete-gateway-", async ({ stateDir }) => {
103+
const now = Date.now();
104+
const cfg: OpenClawConfig = {
105+
agents: {
106+
list: [
107+
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
108+
{ id: "ops", workspace: path.join(stateDir, "workspace-ops") },
109+
],
110+
},
111+
} satisfies OpenClawConfig;
112+
const sessions = {
113+
"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 },
114+
"agent:main:main": { sessionId: "sess-main", updatedAt: now + 2 },
115+
};
116+
const storePath = await arrangeAgentsDeleteTest({
117+
stateDir,
118+
cfg,
119+
deletedAgentId: "ops",
120+
sessions,
121+
});
122+
gatewayMocks.callGateway.mockResolvedValue({
123+
ok: true,
124+
agentId: "ops",
125+
removedBindings: 0,
126+
});
127+
128+
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
129+
130+
expect(gatewayMocks.callGateway).toHaveBeenCalledWith(
131+
expect.objectContaining({
132+
method: "agents.delete",
133+
params: { agentId: "ops", deleteFiles: true },
134+
requiredMethods: ["agents.delete"],
135+
}),
136+
);
137+
expect(configMocks.replaceConfigFile).not.toHaveBeenCalled();
138+
expectSessionStore(storePath, sessions);
139+
expect(readJsonLogs()[0]).toMatchObject({
140+
agentId: "ops",
141+
removedBindings: 0,
142+
transport: "gateway",
143+
});
144+
});
145+
});
146+
83147
it("purges deleted agent entries from the session store", async () => {
84148
await withStateDirEnv("openclaw-agents-delete-", async ({ stateDir }) => {
85149
const now = Date.now();

0 commit comments

Comments
 (0)