diff --git a/CHANGELOG.md b/CHANGELOG.md index 015f032c983..ba7f945fad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. - Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus. - Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn. diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 932707d11b7..8a017c14ce6 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -9,7 +9,7 @@ const agentCommand = vi.fn(); vi.mock("../commands/agent.js", () => ({ agentCommand })); const { runBootOnce } = await import("./boot.js"); -const { resolveAgentIdFromSessionKey, resolveMainSessionKey } = +const { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveMainSessionKey } = await import("../config/sessions/main-session.js"); const { resolveStorePath } = await import("../config/sessions/paths.js"); const { loadSessionStore, saveSessionStore } = await import("../config/sessions/store.js"); @@ -99,6 +99,24 @@ describe("runBootOnce", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); }); + it("uses per-agent session key when agentId is provided", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), "Check status.", "utf-8"); + + agentCommand.mockResolvedValue(undefined); + const cfg = {}; + const agentId = "ops"; + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir, agentId })).resolves.toEqual({ + status: "ran", + }); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const perAgentCall = agentCommand.mock.calls[0]?.[0]; + expect(perAgentCall?.sessionKey).toBe(resolveAgentMainSessionKey({ cfg, agentId })); + + 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."; diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index ff36af7eebf..edf1f2b5310 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -7,6 +7,7 @@ import { agentCommand } from "../commands/agent.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentIdFromSessionKey, + resolveAgentMainSessionKey, resolveMainSessionKey, } from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; @@ -138,6 +139,7 @@ export async function runBootOnce(params: { cfg: OpenClawConfig; deps: CliDeps; workspaceDir: string; + agentId?: string; }): Promise { const bootRuntime: RuntimeEnv = { log: () => {}, @@ -157,7 +159,9 @@ export async function runBootOnce(params: { return { status: "skipped", reason: result.status }; } - const sessionKey = resolveMainSessionKey(params.cfg); + const sessionKey = params.agentId + ? resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.agentId }) + : resolveMainSessionKey(params.cfg); const message = buildBootPrompt(result.content ?? ""); const sessionId = generateBootSessionId(); const mappingSnapshot = snapshotMainSessionMapping({ diff --git a/src/hooks/bundled/boot-md/HOOK.md b/src/hooks/bundled/boot-md/HOOK.md index 183325c6b1d..b31c97727d4 100644 --- a/src/hooks/bundled/boot-md/HOOK.md +++ b/src/hooks/bundled/boot-md/HOOK.md @@ -16,4 +16,5 @@ metadata: # Boot Checklist Hook -Runs `BOOT.md` every time the gateway starts, if the file exists in the workspace. +Runs `BOOT.md` at gateway startup for each configured agent scope, if the file exists in that +agent's resolved workspace. diff --git a/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts b/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts new file mode 100644 index 00000000000..cfbc0bb420b --- /dev/null +++ b/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CliDeps } from "../../../cli/deps.js"; +import type { OpenClawConfig } from "../../../config/config.js"; + +const runBootOnce = vi.fn(); + +vi.mock("../../../gateway/boot.js", () => ({ runBootOnce })); +vi.mock("../../../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +const { default: runBootChecklist } = await import("./handler.js"); +const { clearInternalHooks, createInternalHookEvent, registerInternalHook, triggerInternalHook } = + await import("../../internal-hooks.js"); + +describe("boot-md startup hook integration", () => { + beforeEach(() => { + runBootOnce.mockReset(); + clearInternalHooks(); + }); + + afterEach(() => { + clearInternalHooks(); + }); + + it("dispatches gateway:startup through internal hooks and runs BOOT for each configured agent scope", async () => { + const cfg = { + hooks: { internal: { enabled: true } }, + agents: { + list: [ + { id: "main", default: true, workspace: "/ws/main" }, + { id: "ops", workspace: "/ws/ops" }, + ], + }, + } as OpenClawConfig; + const deps = {} as CliDeps; + runBootOnce.mockResolvedValue({ status: "ran" }); + + registerInternalHook("gateway:startup", runBootChecklist); + const event = createInternalHookEvent("gateway", "startup", "gateway:startup", { cfg, deps }); + await triggerInternalHook(event); + + expect(runBootOnce).toHaveBeenCalledTimes(2); + expect(runBootOnce).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ cfg, deps, workspaceDir: "/ws/main", agentId: "main" }), + ); + expect(runBootOnce).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cfg, deps, workspaceDir: "/ws/ops", agentId: "ops" }), + ); + }); +}); diff --git a/src/hooks/bundled/boot-md/handler.test.ts b/src/hooks/bundled/boot-md/handler.test.ts new file mode 100644 index 00000000000..ee19f7cc1e9 --- /dev/null +++ b/src/hooks/bundled/boot-md/handler.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { InternalHookEvent } from "../../internal-hooks.js"; + +const runBootOnce = vi.fn(); +const listAgentIds = vi.fn(); +const resolveAgentWorkspaceDir = vi.fn(); +const logWarn = vi.fn(); +const logDebug = vi.fn(); + +vi.mock("../../../gateway/boot.js", () => ({ runBootOnce })); +vi.mock("../../../agents/agent-scope.js", () => ({ + listAgentIds, + resolveAgentWorkspaceDir, +})); +vi.mock("../../../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ + warn: logWarn, + debug: logDebug, + }), +})); + +const { default: runBootChecklist } = await import("./handler.js"); + +function makeEvent(overrides?: Partial): InternalHookEvent { + return { + type: "gateway", + action: "startup", + sessionKey: "test", + context: {}, + timestamp: new Date(), + messages: [], + ...overrides, + }; +} + +describe("boot-md handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + logWarn.mockReset(); + logDebug.mockReset(); + }); + + it("skips non-gateway events", async () => { + await runBootChecklist(makeEvent({ type: "command", action: "new" })); + expect(runBootOnce).not.toHaveBeenCalled(); + }); + + it("skips non-startup actions", async () => { + await runBootChecklist(makeEvent({ action: "shutdown" })); + expect(runBootOnce).not.toHaveBeenCalled(); + }); + + it("skips when cfg is missing from context", async () => { + await runBootChecklist(makeEvent({ context: { workspaceDir: "/tmp" } })); + expect(runBootOnce).not.toHaveBeenCalled(); + }); + + it("runs boot for each agent", async () => { + const cfg = { agents: { list: [{ id: "main" }, { id: "ops" }] } }; + listAgentIds.mockReturnValue(["main", "ops"]); + resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) => `/ws/${id}`); + runBootOnce.mockResolvedValue({ status: "ran" }); + + await runBootChecklist(makeEvent({ context: { cfg } })); + + expect(listAgentIds).toHaveBeenCalledWith(cfg); + expect(runBootOnce).toHaveBeenCalledTimes(2); + expect(runBootOnce).toHaveBeenCalledWith( + expect.objectContaining({ cfg, workspaceDir: "/ws/main", agentId: "main" }), + ); + expect(runBootOnce).toHaveBeenCalledWith( + expect.objectContaining({ cfg, workspaceDir: "/ws/ops", agentId: "ops" }), + ); + }); + + it("runs boot for single default agent when no agents configured", async () => { + const cfg = {}; + listAgentIds.mockReturnValue(["main"]); + resolveAgentWorkspaceDir.mockReturnValue("/ws/main"); + runBootOnce.mockResolvedValue({ status: "skipped", reason: "missing" }); + + await runBootChecklist(makeEvent({ context: { cfg } })); + + expect(runBootOnce).toHaveBeenCalledTimes(1); + expect(runBootOnce).toHaveBeenCalledWith( + expect.objectContaining({ cfg, workspaceDir: "/ws/main", agentId: "main" }), + ); + }); + + it("logs warning details when a per-agent boot run fails", async () => { + const cfg = { agents: { list: [{ id: "main" }, { id: "ops" }] } }; + listAgentIds.mockReturnValue(["main", "ops"]); + resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) => `/ws/${id}`); + runBootOnce + .mockResolvedValueOnce({ status: "ran" }) + .mockResolvedValueOnce({ status: "failed", reason: "agent failed" }); + + await runBootChecklist(makeEvent({ context: { cfg } })); + + expect(logWarn).toHaveBeenCalledTimes(1); + expect(logWarn).toHaveBeenCalledWith("boot-md failed for agent startup run", { + agentId: "ops", + workspaceDir: "/ws/ops", + reason: "agent failed", + }); + }); + + it("logs debug details when a per-agent boot run is skipped", async () => { + const cfg = { agents: { list: [{ id: "main" }] } }; + listAgentIds.mockReturnValue(["main"]); + resolveAgentWorkspaceDir.mockReturnValue("/ws/main"); + runBootOnce.mockResolvedValue({ status: "skipped", reason: "missing" }); + + await runBootChecklist(makeEvent({ context: { cfg } })); + + expect(logDebug).toHaveBeenCalledWith("boot-md skipped for agent startup run", { + agentId: "main", + workspaceDir: "/ws/main", + reason: "missing", + }); + }); +}); diff --git a/src/hooks/bundled/boot-md/handler.ts b/src/hooks/bundled/boot-md/handler.ts index 6d41a144b4c..b5fcb065ac6 100644 --- a/src/hooks/bundled/boot-md/handler.ts +++ b/src/hooks/bundled/boot-md/handler.ts @@ -1,27 +1,44 @@ -import type { CliDeps } from "../../../cli/deps.js"; +import { listAgentIds, resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import { createDefaultDeps } from "../../../cli/deps.js"; -import type { OpenClawConfig } from "../../../config/config.js"; import { runBootOnce } from "../../../gateway/boot.js"; +import { createSubsystemLogger } from "../../../logging/subsystem.js"; import type { HookHandler } from "../../hooks.js"; +import { isGatewayStartupEvent } from "../../internal-hooks.js"; -type BootHookContext = { - cfg?: OpenClawConfig; - workspaceDir?: string; - deps?: CliDeps; -}; +const log = createSubsystemLogger("hooks/boot-md"); const runBootChecklist: HookHandler = async (event) => { - if (event.type !== "gateway" || event.action !== "startup") { + if (!isGatewayStartupEvent(event)) { return; } - const context = (event.context ?? {}) as BootHookContext; - if (!context.cfg || !context.workspaceDir) { + if (!event.context.cfg) { return; } - const deps = context.deps ?? createDefaultDeps(); - await runBootOnce({ cfg: context.cfg, deps, workspaceDir: context.workspaceDir }); + const cfg = event.context.cfg; + const deps = event.context.deps ?? createDefaultDeps(); + const agentIds = listAgentIds(cfg); + + for (const agentId of agentIds) { + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const result = await runBootOnce({ cfg, deps, workspaceDir, agentId }); + if (result.status === "failed") { + log.warn("boot-md failed for agent startup run", { + agentId, + workspaceDir, + reason: result.reason, + }); + continue; + } + if (result.status === "skipped") { + log.debug("boot-md skipped for agent startup run", { + agentId, + workspaceDir, + reason: result.reason, + }); + } + } }; export default runBootChecklist; diff --git a/src/hooks/internal-hooks.test.ts b/src/hooks/internal-hooks.test.ts index 9a2b7998a58..110e72cde6e 100644 --- a/src/hooks/internal-hooks.test.ts +++ b/src/hooks/internal-hooks.test.ts @@ -4,12 +4,14 @@ import { createInternalHookEvent, getRegisteredEventKeys, isAgentBootstrapEvent, + isGatewayStartupEvent, isMessageReceivedEvent, isMessageSentEvent, registerInternalHook, triggerInternalHook, unregisterInternalHook, type AgentBootstrapHookContext, + type GatewayStartupHookContext, type MessageReceivedHookContext, type MessageSentHookContext, } from "./internal-hooks.js"; @@ -185,6 +187,21 @@ describe("hooks", () => { }); }); + describe("isGatewayStartupEvent", () => { + it("returns true for gateway:startup events with expected context", () => { + const context: GatewayStartupHookContext = { + cfg: {}, + }; + const event = createInternalHookEvent("gateway", "startup", "gateway:startup", context); + expect(isGatewayStartupEvent(event)).toBe(true); + }); + + it("returns false for non-startup gateway events", () => { + const event = createInternalHookEvent("gateway", "shutdown", "gateway:shutdown", {}); + expect(isGatewayStartupEvent(event)).toBe(false); + }); + }); + describe("isMessageReceivedEvent", () => { it("returns true for message:received events with expected context", () => { const context: MessageReceivedHookContext = { diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index 428c5ddf412..1e69057e4a8 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -6,6 +6,7 @@ */ import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; +import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; export type InternalHookEventType = "command" | "session" | "agent" | "gateway" | "message"; @@ -25,6 +26,18 @@ export type AgentBootstrapHookEvent = InternalHookEvent & { context: AgentBootstrapHookContext; }; +export type GatewayStartupHookContext = { + cfg?: OpenClawConfig; + deps?: CliDeps; + workspaceDir?: string; +}; + +export type GatewayStartupHookEvent = InternalHookEvent & { + type: "gateway"; + action: "startup"; + context: GatewayStartupHookContext; +}; + // ============================================================================ // Message Hook Events // ============================================================================ @@ -234,6 +247,14 @@ export function isAgentBootstrapEvent(event: InternalHookEvent): event is AgentB return Array.isArray(context.bootstrapFiles); } +export function isGatewayStartupEvent(event: InternalHookEvent): event is GatewayStartupHookEvent { + if (event.type !== "gateway" || event.action !== "startup") { + return false; + } + const context = event.context as GatewayStartupHookContext | null; + return Boolean(context && typeof context === "object"); +} + export function isMessageReceivedEvent( event: InternalHookEvent, ): event is MessageReceivedHookEvent {