From 2e31aead39ddd666dfced4e0fad854450aa46fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=90=E7=88=B7=26=26=E8=80=81=E6=8B=90=E7=98=A6?= Date: Sat, 7 Mar 2026 13:46:02 +0800 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + src/agents/bootstrap-cache.ts | 11 +++++++++++ src/auto-reply/reply/session.test.ts | 16 ++++++++++++++++ src/auto-reply/reply/session.ts | 5 +++++ src/commands/agent/session.ts | 6 ++++++ src/cron/isolated-agent/session.test.ts | 19 ++++++++++++++++++- src/cron/isolated-agent/session.ts | 6 ++++++ 7 files changed, 63 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d15297a447b..da341abad8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,7 @@ Docs: https://docs.openclaw.ai - 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. - 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. - 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. +- 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. ## 2026.3.2 diff --git a/src/agents/bootstrap-cache.ts b/src/agents/bootstrap-cache.ts index 03c4a923464..98ca267994f 100644 --- a/src/agents/bootstrap-cache.ts +++ b/src/agents/bootstrap-cache.ts @@ -20,6 +20,17 @@ export function clearBootstrapSnapshot(sessionKey: string): void { cache.delete(sessionKey); } +export function clearBootstrapSnapshotOnSessionRollover(params: { + sessionKey?: string; + previousSessionId?: string; +}): void { + if (!params.sessionKey || !params.previousSessionId) { + return; + } + + clearBootstrapSnapshot(params.sessionKey); +} + export function clearAllBootstrapSnapshots(): void { cache.clear(); } diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 58d6b893267..db0870b704a 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as bootstrapCache from "../../agents/bootstrap-cache.js"; import { buildModelAliasIndex } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -850,11 +851,18 @@ describe("initSessionState RawBody", () => { }); describe("initSessionState reset policy", () => { + let clearBootstrapSnapshotOnSessionRolloverSpy: ReturnType; + beforeEach(() => { vi.useFakeTimers(); + clearBootstrapSnapshotOnSessionRolloverSpy = vi.spyOn( + bootstrapCache, + "clearBootstrapSnapshotOnSessionRollover", + ); }); afterEach(() => { + clearBootstrapSnapshotOnSessionRolloverSpy.mockRestore(); vi.useRealTimers(); }); @@ -881,6 +889,10 @@ describe("initSessionState reset policy", () => { expect(result.isNewSession).toBe(true); expect(result.sessionId).not.toBe(existingSessionId); + expect(clearBootstrapSnapshotOnSessionRolloverSpy).toHaveBeenCalledWith({ + sessionKey, + previousSessionId: existingSessionId, + }); }); it("treats sessions as stale before the daily reset when updated before yesterday's boundary", async () => { @@ -1057,6 +1069,10 @@ describe("initSessionState reset policy", () => { expect(result.isNewSession).toBe(false); expect(result.sessionId).toBe(existingSessionId); + expect(clearBootstrapSnapshotOnSessionRolloverSpy).toHaveBeenCalledWith({ + sessionKey, + previousSessionId: undefined, + }); }); }); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a0e730334e2..6db6b1708cb 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -5,6 +5,7 @@ import { parseTelegramChatIdFromTarget, } from "../../acp/conversation-id.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -358,6 +359,10 @@ export async function initSessionState(params: { // and for scheduled/daily resets where the session has become stale (!freshEntry). // Without this, daily-reset transcripts are left as orphaned files on disk (#35481). const previousSessionEntry = (resetTriggered || !freshEntry) && entry ? { ...entry } : undefined; + clearBootstrapSnapshotOnSessionRollover({ + sessionKey, + previousSessionId: previousSessionEntry?.sessionId, + }); if (!isNewSession && freshEntry) { sessionId = entry.sessionId; diff --git a/src/commands/agent/session.ts b/src/commands/agent/session.ts index 62600448af4..f3ef076d654 100644 --- a/src/commands/agent/session.ts +++ b/src/commands/agent/session.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { listAgentIds } from "../../agents/agent-scope.js"; +import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import type { MsgContext } from "../../auto-reply/templating.js"; import { normalizeThinkLevel, @@ -144,6 +145,11 @@ export function resolveSession(opts: { opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID(); const isNewSession = !fresh && !opts.sessionId; + clearBootstrapSnapshotOnSessionRollover({ + sessionKey, + previousSessionId: isNewSession ? sessionEntry?.sessionId : undefined, + }); + const persistedThinking = fresh && sessionEntry?.thinkingLevel ? normalizeThinkLevel(sessionEntry.thinkingLevel) diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index 08f273e8c41..fc75ed100f6 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; vi.mock("../../config/sessions.js", () => ({ @@ -8,6 +8,16 @@ vi.mock("../../config/sessions.js", () => ({ resolveSessionResetPolicy: vi.fn().mockReturnValue({ mode: "idle", idleMinutes: 60 }), })); +vi.mock("../../agents/bootstrap-cache.js", () => ({ + clearBootstrapSnapshot: vi.fn(), + clearBootstrapSnapshotOnSessionRollover: vi.fn(({ sessionKey, previousSessionId }) => { + if (sessionKey && previousSessionId) { + clearBootstrapSnapshot(sessionKey); + } + }), +})); + +import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js"; import { loadSessionStore, evaluateSessionFreshness } from "../../config/sessions.js"; import { resolveCronSession } from "./session.js"; @@ -40,6 +50,10 @@ function resolveWithStoredEntry(params?: { } describe("resolveCronSession", () => { + beforeEach(() => { + vi.mocked(clearBootstrapSnapshot).mockReset(); + }); + it("preserves modelOverride and providerOverride from existing session entry", () => { const result = resolveWithStoredEntry({ sessionKey: "agent:main:cron:test-job", @@ -100,6 +114,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.sessionId).toBe("existing-session-id-123"); expect(result.isNewSession).toBe(false); expect(result.systemSent).toBe(true); + expect(clearBootstrapSnapshot).not.toHaveBeenCalled(); }); it("creates new sessionId when session is stale", () => { @@ -121,6 +136,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.modelOverride).toBe("gpt-4.1-mini"); expect(result.sessionEntry.providerOverride).toBe("openai"); expect(result.sessionEntry.sendPolicy).toBe("allow"); + expect(clearBootstrapSnapshot).toHaveBeenCalledWith("webhook:stable-key"); }); it("creates new sessionId when forceNew is true", () => { @@ -141,6 +157,7 @@ describe("resolveCronSession", () => { expect(result.systemSent).toBe(false); expect(result.sessionEntry.modelOverride).toBe("sonnet-4"); expect(result.sessionEntry.providerOverride).toBe("anthropic"); + expect(clearBootstrapSnapshot).toHaveBeenCalledWith("webhook:stable-key"); }); it("clears delivery routing metadata and deliveryContext when forceNew is true", () => { diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index b1c9fe3710d..c7bde5cea2d 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import type { OpenClawConfig } from "../../config/config.js"; import { evaluateSessionFreshness, @@ -58,6 +59,11 @@ export function resolveCronSession(params: { systemSent = false; } + clearBootstrapSnapshotOnSessionRollover({ + sessionKey: params.sessionKey, + previousSessionId: isNewSession ? entry?.sessionId : undefined, + }); + const sessionEntry: SessionEntry = { // Preserve existing per-session overrides even when rolling to a new sessionId. ...entry,