Skip to content

Commit 4967bcb

Browse files
committed
fix: route session cleanup through gateway writer
1 parent b443704 commit 4967bcb

13 files changed

Lines changed: 363 additions & 118 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Docs: https://docs.openclaw.ai
3838
- 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.
3939
- 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.
4040
- Slack/hooks: preserve bot alert attachment text in message-received hook content when command text is blank. Fixes #76035; refs #76036. Thanks @amsminn.
41-
- Sessions: route Gateway session-store writes 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.
41+
- 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.
4242
- 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.
4343
- 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.
4444
- 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/sessions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ 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
102+
sent through the Gateway so it shares the same session-store writer as runtime
103+
traffic. Use `--store <path>` for explicit offline repair of a store file.
104+
101105
`openclaw sessions cleanup --all-agents --dry-run --json`:
102106

103107
```json

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. `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, `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.
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/commands/sessions-cleanup.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({
1414
capEntryCount: vi.fn(),
1515
updateSessionStore: vi.fn(),
1616
enforceSessionDiskBudget: vi.fn(),
17+
callGateway: vi.fn(),
18+
isGatewayTransportError: vi.fn(),
1719
}));
1820

1921
vi.mock("../config/config.js", () => ({
@@ -37,6 +39,11 @@ vi.mock("../config/sessions.js", () => ({
3739
enforceSessionDiskBudget: mocks.enforceSessionDiskBudget,
3840
}));
3941

42+
vi.mock("../gateway/call.js", () => ({
43+
callGateway: mocks.callGateway,
44+
isGatewayTransportError: mocks.isGatewayTransportError,
45+
}));
46+
4047
import { sessionsCleanupCommand } from "./sessions-cleanup.js";
4148

4249
function makeRuntime(): { runtime: RuntimeEnv; logs: string[] } {
@@ -97,6 +104,8 @@ describe("sessionsCleanupCommand", () => {
97104
);
98105
mocks.capEntryCount.mockImplementation(() => 0);
99106
mocks.updateSessionStore.mockResolvedValue(0);
107+
mocks.callGateway.mockResolvedValue(null);
108+
mocks.isGatewayTransportError.mockReturnValue(true);
100109
mocks.enforceSessionDiskBudget.mockResolvedValue({
101110
totalBytesBefore: 1000,
102111
totalBytesAfter: 700,
@@ -110,6 +119,9 @@ describe("sessionsCleanupCommand", () => {
110119
});
111120

112121
it("emits a single JSON object for non-dry runs and applies maintenance", async () => {
122+
mocks.callGateway.mockRejectedValue(
123+
Object.assign(new Error("closed"), { name: "GatewayTransportError" }),
124+
);
113125
mocks.loadSessionStore
114126
.mockReturnValueOnce({
115127
stale: { sessionId: "stale", updatedAt: 1 },
@@ -190,6 +202,43 @@ describe("sessionsCleanupCommand", () => {
190202
);
191203
});
192204

205+
it("delegates non-store enforcing cleanup through the Gateway writer when reachable", async () => {
206+
mocks.callGateway.mockResolvedValue({
207+
agentId: "main",
208+
storePath: "/resolved/sessions.json",
209+
mode: "enforce",
210+
dryRun: false,
211+
beforeCount: 3,
212+
afterCount: 1,
213+
missing: 0,
214+
pruned: 2,
215+
capped: 0,
216+
diskBudget: null,
217+
wouldMutate: true,
218+
applied: true,
219+
appliedCount: 1,
220+
});
221+
222+
const { runtime, logs } = makeRuntime();
223+
await sessionsCleanupCommand(
224+
{
225+
json: true,
226+
enforce: true,
227+
},
228+
runtime,
229+
);
230+
231+
expect(mocks.callGateway).toHaveBeenCalledWith(
232+
expect.objectContaining({
233+
method: "sessions.cleanup",
234+
params: expect.objectContaining({ enforce: true }),
235+
requiredMethods: ["sessions.cleanup"],
236+
}),
237+
);
238+
expect(mocks.updateSessionStore).not.toHaveBeenCalled();
239+
expect(JSON.parse(logs[0] ?? "{}")).toEqual(expect.objectContaining({ appliedCount: 1 }));
240+
});
241+
193242
it("returns dry-run JSON without mutating the store", async () => {
194243
mocks.loadSessionStore.mockReturnValue({
195244
stale: { sessionId: "stale", updatedAt: 1 },

0 commit comments

Comments
 (0)