diff --git a/src/agents/clawdbot-gateway-tool.test.ts b/src/agents/clawdbot-gateway-tool.test.ts index 04cbf8599..b69811c09 100644 --- a/src/agents/clawdbot-gateway-tool.test.ts +++ b/src/agents/clawdbot-gateway-tool.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createClawdbotTools } from "./clawdbot-tools.js"; @@ -10,6 +13,11 @@ describe("gateway tool", () => { it("schedules SIGUSR1 restart", async () => { vi.useFakeTimers(); const kill = vi.spyOn(process, "kill").mockImplementation(() => true); + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const stateDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-test-"), + ); + process.env.CLAWDBOT_STATE_DIR = stateDir; try { const tool = createClawdbotTools({ @@ -29,12 +37,27 @@ describe("gateway tool", () => { delayMs: 0, }); + const sentinelPath = path.join(stateDir, "restart-sentinel.json"); + const raw = await fs.readFile(sentinelPath, "utf-8"); + const parsed = JSON.parse(raw) as { + payload?: { kind?: string; doctorHint?: string | null }; + }; + expect(parsed.payload?.kind).toBe("restart"); + expect(parsed.payload?.doctorHint).toBe( + "Run: clawdbot doctor --non-interactive", + ); + expect(kill).not.toHaveBeenCalled(); await vi.runAllTimersAsync(); expect(kill).toHaveBeenCalledWith(process.pid, "SIGUSR1"); } finally { kill.mockRestore(); vi.useRealTimers(); + if (previousStateDir === undefined) { + delete process.env.CLAWDBOT_STATE_DIR; + } else { + process.env.CLAWDBOT_STATE_DIR = previousStateDir; + } } }); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index f2db7f968..e3b83e851 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -2,6 +2,11 @@ import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; +import { + DOCTOR_NONINTERACTIVE_HINT, + type RestartSentinelPayload, + writeRestartSentinel, +} from "../../infra/restart-sentinel.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool } from "./gateway.js"; @@ -61,6 +66,10 @@ export function createGatewayTool(opts?: { "Gateway restart is disabled. Set commands.restart=true to enable.", ); } + const sessionKey = + typeof params.sessionKey === "string" && params.sessionKey.trim() + ? params.sessionKey.trim() + : opts?.agentSessionKey?.trim() || undefined; const delayMs = typeof params.delayMs === "number" && Number.isFinite(params.delayMs) ? Math.floor(params.delayMs) @@ -69,6 +78,27 @@ export function createGatewayTool(opts?: { typeof params.reason === "string" && params.reason.trim() ? params.reason.trim().slice(0, 200) : undefined; + const note = + typeof params.note === "string" && params.note.trim() + ? params.note.trim() + : undefined; + const payload: RestartSentinelPayload = { + kind: "restart", + status: "ok", + ts: Date.now(), + sessionKey, + message: note ?? reason ?? null, + doctorHint: DOCTOR_NONINTERACTIVE_HINT, + stats: { + mode: "gateway.restart", + reason, + }, + }; + try { + await writeRestartSentinel(payload); + } catch { + // ignore: sentinel is best-effort + } console.info( `gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`, ); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 8d6f0d4bb..52a85a6fd 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; +import { doctorCommand } from "../commands/doctor.js"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; import { runGatewayUpdate, @@ -159,6 +160,17 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const restarted = await runDaemonRestart(); if (!opts.json && restarted) { defaultRuntime.log(theme.success("Daemon restarted successfully.")); + defaultRuntime.log(""); + process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1"; + try { + await doctorCommand(defaultRuntime, { nonInteractive: true }); + } catch (err) { + defaultRuntime.log( + theme.warn(`Doctor failed: ${String(err)}`), + ); + } finally { + delete process.env.CLAWDBOT_UPDATE_IN_PROGRESS; + } } } catch (err) { if (!opts.json) { diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 5d6e09348..37d7f6f96 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -8,6 +8,7 @@ import { import { buildConfigSchema } from "../../config/schema.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { + DOCTOR_NONINTERACTIVE_HINT, type RestartSentinelPayload, writeRestartSentinel, } from "../../infra/restart-sentinel.js"; @@ -176,6 +177,7 @@ export const configHandlers: GatewayRequestHandlers = { ts: Date.now(), sessionKey, message: note ?? null, + doctorHint: DOCTOR_NONINTERACTIVE_HINT, stats: { mode: "config.apply", root: CONFIG_PATH_CLAWDBOT, diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index 086eeb7fe..56f4bb985 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -1,6 +1,7 @@ import { resolveClawdbotPackageRoot } from "../../infra/clawdbot-root.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { + DOCTOR_NONINTERACTIVE_HINT, type RestartSentinelPayload, writeRestartSentinel, } from "../../infra/restart-sentinel.js"; @@ -76,6 +77,7 @@ export const updateHandlers: GatewayRequestHandlers = { ts: Date.now(), sessionKey, message: note ?? null, + doctorHint: DOCTOR_NONINTERACTIVE_HINT, stats: { mode: result.mode, root: result.root ?? undefined, diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index a5adfd74c..1b89a33cc 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -28,11 +28,12 @@ export type RestartSentinelStats = { }; export type RestartSentinelPayload = { - kind: "config-apply" | "update"; + kind: "config-apply" | "update" | "restart"; status: "ok" | "error" | "skipped"; ts: number; sessionKey?: string; message?: string | null; + doctorHint?: string | null; stats?: RestartSentinelStats | null; }; @@ -43,6 +44,9 @@ export type RestartSentinel = { const SENTINEL_FILENAME = "restart-sentinel.json"; +export const DOCTOR_NONINTERACTIVE_HINT = + "Run: clawdbot doctor --non-interactive"; + export function resolveRestartSentinelPath( env: NodeJS.ProcessEnv = process.env, ): string {