From f17f2f918c71a274d7af884b8590d03ccb3cdb33 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 20:42:33 +0000 Subject: [PATCH] fix(gateway): order bootstrap cache clear after embedded run wait Landed from #38873 by @MumuTW. Co-authored-by: MumuTW --- CHANGELOG.md | 1 + src/gateway/server-methods/sessions.ts | 3 ++- ...sessions.gateway-server-sessions-a.test.ts | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a67e187d3..20437930ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -272,6 +272,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI WS reconnect retry accounting: avoid double retry scheduling when reconnect failures emit both `error` and `close`, so retry budgets track actual reconnect attempts instead of exhausting early. (#39133) Thanks @scoootscooob. - Daemon/Windows schtasks runtime detection: use locale-invariant `Last Run Result` running codes (`0x41301`/`267009`) as the primary running signal so `openclaw node status` no longer misreports active tasks as stopped on non-English Windows locales. (#39076) Thanks @ademczuk. - Usage/token count formatting: round near-million token counts to millions (`1.0m`) instead of `1000k`, with explicit boundary coverage for `999_499` and `999_500`. (#39129) Thanks @CurryMessi. +- Gateway/session bootstrap cache invalidation ordering: clear bootstrap snapshots only after active embedded-run shutdown wait completes, preventing dying runs from repopulating stale cache between `/new`/`sessions.reset` turns. (#38873) Thanks @MumuTW. ## 2026.3.2 diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 8200031ae7c..bd8f6b57ac2 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -207,14 +207,15 @@ async function ensureSessionRuntimeCleanup(params: { queueKeys.add(params.sessionId); } clearSessionQueues([...queueKeys]); - clearBootstrapSnapshot(params.target.canonicalKey); stopSubagentsForRequester({ cfg: params.cfg, requesterSessionKey: params.target.canonicalKey }); if (!params.sessionId) { + clearBootstrapSnapshot(params.target.canonicalKey); await closeTrackedBrowserTabs(); return undefined; } abortEmbeddedPiRun(params.sessionId); const ended = await waitForEmbeddedPiRunEnd(params.sessionId, 15_000); + clearBootstrapSnapshot(params.target.canonicalKey); if (ended) { await closeTrackedBrowserTabs(); return undefined; diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 3780174cee0..3837247c9bc 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -23,6 +23,10 @@ const sessionCleanupMocks = vi.hoisted(() => ({ stopSubagentsForRequester: vi.fn(() => ({ stopped: 0 })), })); +const bootstrapCacheMocks = vi.hoisted(() => ({ + clearBootstrapSnapshot: vi.fn(), +})); + const sessionHookMocks = vi.hoisted(() => ({ triggerInternalHook: vi.fn(async () => {}), })); @@ -68,6 +72,14 @@ vi.mock("../auto-reply/reply/abort.js", async () => { }; }); +vi.mock("../agents/bootstrap-cache.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + clearBootstrapSnapshot: bootstrapCacheMocks.clearBootstrapSnapshot, + }; +}); + vi.mock("../hooks/internal-hooks.js", async () => { const actual = await vi.importActual( "../hooks/internal-hooks.js", @@ -204,6 +216,7 @@ describe("gateway server sessions", () => { beforeEach(() => { sessionCleanupMocks.clearSessionQueues.mockClear(); sessionCleanupMocks.stopSubagentsForRequester.mockClear(); + bootstrapCacheMocks.clearBootstrapSnapshot.mockReset(); sessionHookMocks.triggerInternalHook.mockClear(); subagentLifecycleHookMocks.runSubagentEnded.mockClear(); subagentLifecycleHookState.hasSubagentEndedHook = true; @@ -926,6 +939,10 @@ describe("gateway server sessions", () => { test("sessions.reset aborts active runs and clears queues", async () => { await seedActiveMainSession(); + const waitCallCountAtSnapshotClear: number[] = []; + bootstrapCacheMocks.clearBootstrapSnapshot.mockImplementation(() => { + waitCallCountAtSnapshotClear.push(embeddedRunMock.waitCalls.length); + }); embeddedRunMock.activeIds.add("sess-main"); embeddedRunMock.waitResults.set("sess-main", true); @@ -947,6 +964,7 @@ describe("gateway server sessions", () => { ["main", "agent:main:main", "sess-main"], "sess-main", ); + expect(waitCallCountAtSnapshotClear).toEqual([1]); expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledTimes(1); expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith({ sessionKeys: expect.arrayContaining(["main", "agent:main:main", "sess-main"]), @@ -1163,6 +1181,10 @@ describe("gateway server sessions", () => { test("sessions.reset returns unavailable when active run does not stop", async () => { const { dir, storePath } = await seedActiveMainSession(); + const waitCallCountAtSnapshotClear: number[] = []; + bootstrapCacheMocks.clearBootstrapSnapshot.mockImplementation(() => { + waitCallCountAtSnapshotClear.push(embeddedRunMock.waitCalls.length); + }); embeddedRunMock.activeIds.add("sess-main"); embeddedRunMock.waitResults.set("sess-main", false); @@ -1180,6 +1202,7 @@ describe("gateway server sessions", () => { ["main", "agent:main:main", "sess-main"], "sess-main", ); + expect(waitCallCountAtSnapshotClear).toEqual([1]); expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).not.toHaveBeenCalled(); const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<