diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 492c60f0b9..4c8790319f 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -8,14 +8,23 @@ const agentCommand = vi.fn(); 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 { resolveAgentIdFromSessionKey, resolveMainSessionKey } = + await import("../config/sessions/main-session.js"); const { resolveStorePath } = await import("../config/sessions/paths.js"); -const { resolveAgentIdFromSessionKey } = await import("../config/sessions/main-session.js"); +const { loadSessionStore, saveSessionStore } = await import("../config/sessions/store.js"); describe("runBootOnce", () => { - beforeEach(() => { + const resolveMainStore = (cfg: { session?: { store?: string } } = {}) => { + const sessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + return { sessionKey, storePath }; + }; + + beforeEach(async () => { vi.clearAllMocks(); + const { storePath } = resolveMainStore(); + await fs.rm(storePath, { force: true }); }); const makeDeps = () => ({ @@ -93,17 +102,14 @@ describe("runBootOnce", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); }); - it("reuses existing session ID when session mapping exists", async () => { + it("uses a fresh boot session ID even when main session mapping already 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"; + const { sessionKey, storePath } = resolveMainStore(cfg); + const existingSessionId = "main-session-abc123"; await saveSessionStore(storePath, { [sessionKey]: { @@ -120,24 +126,21 @@ describe("runBootOnce", () => { 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?.sessionId).not.toBe(existingSessionId); + expect(call?.sessionId).toMatch(/^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/); expect(call?.sessionKey).toBe(sessionKey); await fs.rm(workspaceDir, { recursive: true, force: true }); }); - it("appends boot message to existing session transcript", async () => { + it("restores the original main session mapping after the boot run", 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"; + const { sessionKey, storePath } = resolveMainStore(cfg); + const existingSessionId = "main-session-xyz789"; await saveSessionStore(storePath, { [sessionKey]: { @@ -146,19 +149,46 @@ describe("runBootOnce", () => { }, }); - agentCommand.mockResolvedValue(undefined); + agentCommand.mockImplementation(async (opts: { sessionId?: string }) => { + const current = loadSessionStore(storePath, { skipCache: true }); + current[sessionKey] = { + sessionId: String(opts.sessionId), + updatedAt: Date.now(), + }; + await saveSessionStore(storePath, current); + }); await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ status: "ran", }); - const call = agentCommand.mock.calls[0]?.[0]; + const restored = loadSessionStore(storePath, { skipCache: true }); + expect(restored[sessionKey]?.sessionId).toBe(existingSessionId); - // Verify boot message uses the existing session - expect(call?.sessionId).toBe(existingSessionId); - expect(call?.sessionKey).toBe(sessionKey); + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); - // 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) + it("removes a boot-created main-session mapping when none existed before", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), "health check", "utf-8"); + + const cfg = {}; + const { sessionKey, storePath } = resolveMainStore(cfg); + + agentCommand.mockImplementation(async (opts: { sessionId?: string }) => { + const current = loadSessionStore(storePath, { skipCache: true }); + current[sessionKey] = { + sessionId: String(opts.sessionId), + updatedAt: Date.now(), + }; + await saveSessionStore(storePath, current); + }); + + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + const restored = loadSessionStore(storePath, { skipCache: true }); + expect(restored[sessionKey]).toBeUndefined(); await fs.rm(workspaceDir, { recursive: true, force: true }); }); diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index 7017811a0b..e9486eac32 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -3,12 +3,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions/types.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 { + resolveAgentIdFromSessionKey, + resolveMainSessionKey, +} from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; -import { loadSessionStore } from "../config/sessions/store.js"; +import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { type RuntimeEnv, defaultRuntime } from "../runtime.js"; @@ -19,38 +22,13 @@ 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; -} +type SessionMappingSnapshot = { + storePath: string; + sessionKey: string; + canRestore: boolean; + hadEntry: boolean; + entry?: SessionEntry; +}; const log = createSubsystemLogger("gateway/boot"); const BOOT_FILENAME = "BOOT.md"; @@ -94,6 +72,68 @@ async function loadBootFile( } } +function snapshotMainSessionMapping(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): SessionMappingSnapshot { + const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const storePath = resolveStorePath(params.cfg.session?.store, { agentId }); + try { + const store = loadSessionStore(storePath, { skipCache: true }); + const entry = store[params.sessionKey]; + if (!entry) { + return { + storePath, + sessionKey: params.sessionKey, + canRestore: true, + hadEntry: false, + }; + } + return { + storePath, + sessionKey: params.sessionKey, + canRestore: true, + hadEntry: true, + entry: structuredClone(entry), + }; + } catch (err) { + log.debug("boot: could not snapshot main session mapping", { + sessionKey: params.sessionKey, + error: String(err), + }); + return { + storePath, + sessionKey: params.sessionKey, + canRestore: false, + hadEntry: false, + }; + } +} + +async function restoreMainSessionMapping( + snapshot: SessionMappingSnapshot, +): Promise { + if (!snapshot.canRestore) { + return undefined; + } + try { + await updateSessionStore( + snapshot.storePath, + (store) => { + if (snapshot.hadEntry && snapshot.entry) { + store[snapshot.sessionKey] = snapshot.entry; + return; + } + delete store[snapshot.sessionKey]; + }, + { activeSessionKey: snapshot.sessionKey }, + ); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + export async function runBootOnce(params: { cfg: OpenClawConfig; deps: CliDeps; @@ -119,8 +159,13 @@ export async function runBootOnce(params: { const sessionKey = resolveMainSessionKey(params.cfg); const message = buildBootPrompt(result.content ?? ""); - const sessionId = resolveBootSessionId(params.cfg); + const sessionId = generateBootSessionId(); + const mappingSnapshot = snapshotMainSessionMapping({ + cfg: params.cfg, + sessionKey, + }); + let agentFailure: string | undefined; try { await agentCommand( { @@ -132,10 +177,22 @@ export async function runBootOnce(params: { bootRuntime, params.deps, ); - return { status: "ran" }; } catch (err) { - const messageText = err instanceof Error ? err.message : String(err); - log.error(`boot: agent run failed: ${messageText}`); - return { status: "failed", reason: messageText }; + agentFailure = err instanceof Error ? err.message : String(err); + log.error(`boot: agent run failed: ${agentFailure}`); } + + const mappingRestoreFailure = await restoreMainSessionMapping(mappingSnapshot); + if (mappingRestoreFailure) { + log.error(`boot: failed to restore main session mapping: ${mappingRestoreFailure}`); + } + + if (!agentFailure && !mappingRestoreFailure) { + return { status: "ran" }; + } + const reasonParts = [ + agentFailure ? `agent run failed: ${agentFailure}` : undefined, + mappingRestoreFailure ? `mapping restore failed: ${mappingRestoreFailure}` : undefined, + ].filter((part): part is string => Boolean(part)); + return { status: "failed", reason: reasonParts.join("; ") }; }