From fe73878dfc71081288c1cfe4c04a4b362a9f7972 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 15 Feb 2026 23:57:49 +0000 Subject: [PATCH] fix(gateway): preserve session mapping across gateway restarts --- src/gateway/boot.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++ src/gateway/boot.ts | 38 +++++++++++++++- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 9e6ead765b9..492c60f0b9b 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -9,6 +9,9 @@ vi.mock("../commands/agent.js", () => ({ agentCommand })); const { runBootOnce } = await import("./boot.js"); const { resolveMainSessionKey } = await import("../config/sessions/main-session.js"); +const { saveSessionStore } = await import("../config/sessions/store.js"); +const { resolveStorePath } = await import("../config/sessions/paths.js"); +const { resolveAgentIdFromSessionKey } = await import("../config/sessions/main-session.js"); describe("runBootOnce", () => { beforeEach(() => { @@ -69,4 +72,94 @@ describe("runBootOnce", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); }); + + it("generates new session ID when no existing session exists", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + const content = "Say hello when you wake up."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + agentCommand.mockResolvedValue(undefined); + const cfg = {}; + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const call = agentCommand.mock.calls[0]?.[0]; + + // Verify a boot-style session ID was generated (format: boot-YYYY-MM-DD_HH-MM-SS-xxx-xxxxxxxx) + expect(call?.sessionId).toMatch(/^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("reuses existing session ID when session mapping exists", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + const content = "Say hello when you wake up."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + // Create a session store with an existing session + const cfg = {}; + const sessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(undefined, { agentId }); + const existingSessionId = "existing-session-abc123"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now(), + }, + }); + + agentCommand.mockResolvedValue(undefined); + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const call = agentCommand.mock.calls[0]?.[0]; + + // Verify the existing session ID was reused + expect(call?.sessionId).toBe(existingSessionId); + expect(call?.sessionKey).toBe(sessionKey); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("appends boot message to existing session transcript", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + const content = "Check if the system is healthy."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + // Create a session store with an existing session + const cfg = {}; + const sessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(undefined, { agentId }); + const existingSessionId = "test-session-xyz789"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now() - 60_000, // 1 minute ago + }, + }); + + agentCommand.mockResolvedValue(undefined); + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + const call = agentCommand.mock.calls[0]?.[0]; + + // Verify boot message uses the existing session + expect(call?.sessionId).toBe(existingSessionId); + expect(call?.sessionKey).toBe(sessionKey); + + // The agent command should append to the existing session's JSONL file + // (actual file append is handled by agentCommand, we just verify the IDs match) + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); }); diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index bc95c2ab6c5..7017811a0b6 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -6,6 +6,9 @@ import type { OpenClawConfig } from "../config/config.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { agentCommand } from "../commands/agent.js"; import { resolveMainSessionKey } from "../config/sessions/main-session.js"; +import { resolveAgentIdFromSessionKey } from "../config/sessions/main-session.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { loadSessionStore } from "../config/sessions/store.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { type RuntimeEnv, defaultRuntime } from "../runtime.js"; @@ -16,6 +19,39 @@ function generateBootSessionId(): string { return `boot-${ts}-${suffix}`; } +/** + * Resolve the session ID for the boot message. + * If there's an existing session mapped to the main session key, reuse it to avoid orphaning. + * Otherwise, generate a new ephemeral boot session ID. + */ +function resolveBootSessionId(cfg: OpenClawConfig): string { + const sessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + + try { + const sessionStore = loadSessionStore(storePath); + const existingEntry = sessionStore[sessionKey]; + + if (existingEntry?.sessionId) { + log.info("reusing existing session for boot message", { + sessionKey, + sessionId: existingEntry.sessionId, + }); + return existingEntry.sessionId; + } + } catch (err) { + // If we can't load the session store (e.g., first boot), fall through to generate new ID + log.debug("could not load session store for boot; generating new session ID", { + error: String(err), + }); + } + + const newSessionId = generateBootSessionId(); + log.info("generating new boot session", { sessionKey, sessionId: newSessionId }); + return newSessionId; +} + const log = createSubsystemLogger("gateway/boot"); const BOOT_FILENAME = "BOOT.md"; @@ -83,7 +119,7 @@ export async function runBootOnce(params: { const sessionKey = resolveMainSessionKey(params.cfg); const message = buildBootPrompt(result.content ?? ""); - const sessionId = generateBootSessionId(); + const sessionId = resolveBootSessionId(params.cfg); try { await agentCommand(