Skip to content

Commit 2e31aea

Browse files
yfgeTakhoffman
andauthored
fix(gateway): invalidate bootstrap cache on session rollover (openclaw#38535)
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: yfge <1186273+yfge@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
1 parent e802840 commit 2e31aea

7 files changed

Lines changed: 63 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ Docs: https://docs.openclaw.ai
225225
- ACP/console silent reply suppression: filter ACP `NO_REPLY` lead fragments and silent-only finals before `openclaw agent` logging/delivery so console-backed ACP sessions no longer leak `NO`/`NO_REPLY` placeholders. (#38436) Thanks @ql-wade.
226226
- Feishu/reply delivery reliability: disable block streaming in Feishu reply options so plain-text auto-render replies are no longer silently dropped before final delivery. (#38258) Thanks @xinhuagu.
227227
- Agents/reply MEDIA delivery: normalize local assistant `MEDIA:` paths before block/final delivery, keep media dedupe aligned with message-tool sends, and contain malformed media normalization failures so generated files send reliably instead of falling back to empty responses. (#38572) Thanks @obviyus.
228+
- Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing `sessionKey` rolls to a new `sessionId` across auto-reply, command, and isolated cron session resolvers, so `AGENTS.md`/`MEMORY.md`/`USER.md` updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm.
228229

229230
## 2026.3.2
230231

src/agents/bootstrap-cache.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ export function clearBootstrapSnapshot(sessionKey: string): void {
2020
cache.delete(sessionKey);
2121
}
2222

23+
export function clearBootstrapSnapshotOnSessionRollover(params: {
24+
sessionKey?: string;
25+
previousSessionId?: string;
26+
}): void {
27+
if (!params.sessionKey || !params.previousSessionId) {
28+
return;
29+
}
30+
31+
clearBootstrapSnapshot(params.sessionKey);
32+
}
33+
2334
export function clearAllBootstrapSnapshots(): void {
2435
cache.clear();
2536
}

src/auto-reply/reply/session.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
5+
import * as bootstrapCache from "../../agents/bootstrap-cache.js";
56
import { buildModelAliasIndex } from "../../agents/model-selection.js";
67
import type { OpenClawConfig } from "../../config/config.js";
78
import type { SessionEntry } from "../../config/sessions.js";
@@ -850,11 +851,18 @@ describe("initSessionState RawBody", () => {
850851
});
851852

852853
describe("initSessionState reset policy", () => {
854+
let clearBootstrapSnapshotOnSessionRolloverSpy: ReturnType<typeof vi.spyOn>;
855+
853856
beforeEach(() => {
854857
vi.useFakeTimers();
858+
clearBootstrapSnapshotOnSessionRolloverSpy = vi.spyOn(
859+
bootstrapCache,
860+
"clearBootstrapSnapshotOnSessionRollover",
861+
);
855862
});
856863

857864
afterEach(() => {
865+
clearBootstrapSnapshotOnSessionRolloverSpy.mockRestore();
858866
vi.useRealTimers();
859867
});
860868

@@ -881,6 +889,10 @@ describe("initSessionState reset policy", () => {
881889

882890
expect(result.isNewSession).toBe(true);
883891
expect(result.sessionId).not.toBe(existingSessionId);
892+
expect(clearBootstrapSnapshotOnSessionRolloverSpy).toHaveBeenCalledWith({
893+
sessionKey,
894+
previousSessionId: existingSessionId,
895+
});
884896
});
885897

886898
it("treats sessions as stale before the daily reset when updated before yesterday's boundary", async () => {
@@ -1057,6 +1069,10 @@ describe("initSessionState reset policy", () => {
10571069

10581070
expect(result.isNewSession).toBe(false);
10591071
expect(result.sessionId).toBe(existingSessionId);
1072+
expect(clearBootstrapSnapshotOnSessionRolloverSpy).toHaveBeenCalledWith({
1073+
sessionKey,
1074+
previousSessionId: undefined,
1075+
});
10601076
});
10611077
});
10621078

src/auto-reply/reply/session.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
parseTelegramChatIdFromTarget,
66
} from "../../acp/conversation-id.js";
77
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
8+
import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js";
89
import { normalizeChatType } from "../../channels/chat-type.js";
910
import type { OpenClawConfig } from "../../config/config.js";
1011
import {
@@ -358,6 +359,10 @@ export async function initSessionState(params: {
358359
// and for scheduled/daily resets where the session has become stale (!freshEntry).
359360
// Without this, daily-reset transcripts are left as orphaned files on disk (#35481).
360361
const previousSessionEntry = (resetTriggered || !freshEntry) && entry ? { ...entry } : undefined;
362+
clearBootstrapSnapshotOnSessionRollover({
363+
sessionKey,
364+
previousSessionId: previousSessionEntry?.sessionId,
365+
});
361366

362367
if (!isNewSession && freshEntry) {
363368
sessionId = entry.sessionId;

src/commands/agent/session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import crypto from "node:crypto";
22
import { listAgentIds } from "../../agents/agent-scope.js";
3+
import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js";
34
import type { MsgContext } from "../../auto-reply/templating.js";
45
import {
56
normalizeThinkLevel,
@@ -144,6 +145,11 @@ export function resolveSession(opts: {
144145
opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID();
145146
const isNewSession = !fresh && !opts.sessionId;
146147

148+
clearBootstrapSnapshotOnSessionRollover({
149+
sessionKey,
150+
previousSessionId: isNewSession ? sessionEntry?.sessionId : undefined,
151+
});
152+
147153
const persistedThinking =
148154
fresh && sessionEntry?.thinkingLevel
149155
? normalizeThinkLevel(sessionEntry.thinkingLevel)

src/cron/isolated-agent/session.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../../config/config.js";
33

44
vi.mock("../../config/sessions.js", () => ({
@@ -8,6 +8,16 @@ vi.mock("../../config/sessions.js", () => ({
88
resolveSessionResetPolicy: vi.fn().mockReturnValue({ mode: "idle", idleMinutes: 60 }),
99
}));
1010

11+
vi.mock("../../agents/bootstrap-cache.js", () => ({
12+
clearBootstrapSnapshot: vi.fn(),
13+
clearBootstrapSnapshotOnSessionRollover: vi.fn(({ sessionKey, previousSessionId }) => {
14+
if (sessionKey && previousSessionId) {
15+
clearBootstrapSnapshot(sessionKey);
16+
}
17+
}),
18+
}));
19+
20+
import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js";
1121
import { loadSessionStore, evaluateSessionFreshness } from "../../config/sessions.js";
1222
import { resolveCronSession } from "./session.js";
1323

@@ -40,6 +50,10 @@ function resolveWithStoredEntry(params?: {
4050
}
4151

4252
describe("resolveCronSession", () => {
53+
beforeEach(() => {
54+
vi.mocked(clearBootstrapSnapshot).mockReset();
55+
});
56+
4357
it("preserves modelOverride and providerOverride from existing session entry", () => {
4458
const result = resolveWithStoredEntry({
4559
sessionKey: "agent:main:cron:test-job",
@@ -100,6 +114,7 @@ describe("resolveCronSession", () => {
100114
expect(result.sessionEntry.sessionId).toBe("existing-session-id-123");
101115
expect(result.isNewSession).toBe(false);
102116
expect(result.systemSent).toBe(true);
117+
expect(clearBootstrapSnapshot).not.toHaveBeenCalled();
103118
});
104119

105120
it("creates new sessionId when session is stale", () => {
@@ -121,6 +136,7 @@ describe("resolveCronSession", () => {
121136
expect(result.sessionEntry.modelOverride).toBe("gpt-4.1-mini");
122137
expect(result.sessionEntry.providerOverride).toBe("openai");
123138
expect(result.sessionEntry.sendPolicy).toBe("allow");
139+
expect(clearBootstrapSnapshot).toHaveBeenCalledWith("webhook:stable-key");
124140
});
125141

126142
it("creates new sessionId when forceNew is true", () => {
@@ -141,6 +157,7 @@ describe("resolveCronSession", () => {
141157
expect(result.systemSent).toBe(false);
142158
expect(result.sessionEntry.modelOverride).toBe("sonnet-4");
143159
expect(result.sessionEntry.providerOverride).toBe("anthropic");
160+
expect(clearBootstrapSnapshot).toHaveBeenCalledWith("webhook:stable-key");
144161
});
145162

146163
it("clears delivery routing metadata and deliveryContext when forceNew is true", () => {

src/cron/isolated-agent/session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import crypto from "node:crypto";
2+
import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js";
23
import type { OpenClawConfig } from "../../config/config.js";
34
import {
45
evaluateSessionFreshness,
@@ -58,6 +59,11 @@ export function resolveCronSession(params: {
5859
systemSent = false;
5960
}
6061

62+
clearBootstrapSnapshotOnSessionRollover({
63+
sessionKey: params.sessionKey,
64+
previousSessionId: isNewSession ? entry?.sessionId : undefined,
65+
});
66+
6167
const sessionEntry: SessionEntry = {
6268
// Preserve existing per-session overrides even when rolling to a new sessionId.
6369
...entry,

0 commit comments

Comments
 (0)