diff --git a/src/gateway/server-methods/config-write-flow.ts b/src/gateway/server-methods/config-write-flow.ts index 1616a6569d1..686d93899b0 100644 --- a/src/gateway/server-methods/config-write-flow.ts +++ b/src/gateway/server-methods/config-write-flow.ts @@ -132,21 +132,40 @@ function queueSharedGatewayAuthGenerationRefresh( }); } -function shouldScheduleDirectConfigRestart(params: { +function isNoopConfigReloadPlan(plan: ReturnType): boolean { + return ( + !plan.restartGateway && + plan.hotReasons.length === 0 && + !plan.reloadHooks && + !plan.restartGmailWatcher && + !plan.restartCron && + !plan.restartHeartbeat && + !plan.restartHealthMonitor && + !plan.reloadPlugins && + !plan.disposeMcpRuntimes && + plan.restartChannels.size === 0 + ); +} + +function resolveConfigRestartRequirement(params: { changedPaths: string[]; nextConfig: OpenClawConfig; -}): boolean { +}): { requiresRestart: boolean; scheduleDirectRestart: boolean } { const reloadSettings = resolveGatewayReloadSettings(params.nextConfig); - if (reloadSettings.mode === "off") { - return true; - } - // Hybrid mode lets hot-reload own non-gateway restarts; only paths the reload - // plan marks as gateway-owned get a direct process restart here. const plan = buildGatewayReloadPlan(params.changedPaths); - if (reloadSettings.mode === "hot" && plan.restartGateway) { - return true; + if (isNoopConfigReloadPlan(plan)) { + return { requiresRestart: false, scheduleDirectRestart: false }; } - return false; + if (reloadSettings.mode === "off") { + return { requiresRestart: true, scheduleDirectRestart: true }; + } + if (reloadSettings.mode === "restart") { + return { requiresRestart: true, scheduleDirectRestart: false }; + } + if (plan.restartGateway) { + return { requiresRestart: true, scheduleDirectRestart: reloadSettings.mode === "hot" }; + } + return { requiresRestart: false, scheduleDirectRestart: false }; } function resolveConfigRestartRequest(params: unknown): { @@ -182,6 +201,7 @@ function buildConfigRestartSentinelPayload(params: { kind: RestartSentinelPayload["kind"]; mode: string; configPath: string; + requiresRestart: boolean; sessionKey: string | undefined; deliveryContext: ReturnType["deliveryContext"]; threadId: ReturnType["threadId"]; @@ -199,6 +219,7 @@ function buildConfigRestartSentinelPayload(params: { stats: { mode: params.mode, root: params.configPath, + requiresRestart: params.requiresRestart, }, }; } @@ -260,20 +281,22 @@ export async function resolveGatewayConfigRestartWriteResult(params: { }> { const { sessionKey, note, restartDelayMs, deliveryContext, threadId } = resolveConfigRestartRequest(params.requestParams); + const restartRequirement = resolveConfigRestartRequirement({ + changedPaths: params.changedPaths, + nextConfig: params.nextConfig, + }); const payload = buildConfigRestartSentinelPayload({ kind: params.kind, mode: params.mode, configPath: params.configPath, + requiresRestart: restartRequirement.requiresRestart, sessionKey, deliveryContext, threadId, note, }); const sentinelPersisted = await tryWriteRestartSentinelPayload(payload); - const restart = shouldScheduleDirectConfigRestart({ - changedPaths: params.changedPaths, - nextConfig: params.nextConfig, - }) + const restart = restartRequirement.scheduleDirectRestart ? scheduleGatewaySigusr1Restart({ delayMs: restartDelayMs, reason: params.mode, diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index 51bc4b22400..ef5c52030a0 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -353,6 +353,33 @@ describe("config shared auth disconnects", () => { await runConfigPatch({ gateway: { port: 19001 } }); expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1); + const payload = restartSentinelMocks.writeRestartSentinel.mock.calls.at(-1)?.[0]; + expect(payload?.stats?.requiresRestart).toBe(true); + }); + + it("marks hot-reloaded config.patch writes as not restart required", async () => { + const prevConfig: OpenClawConfig = { + gateway: { + channelHealthCheckMinutes: 10, + }, + }; + readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig)); + + const { options } = createConfigHandlerHarness({ + method: "config.patch", + params: { + baseHash: "base-hash", + raw: JSON.stringify({ gateway: { channelHealthCheckMinutes: 15 } }), + restartDelayMs: 1_000, + }, + }); + + await configHandlers["config.patch"](options); + await flushConfigHandlerMicrotasks(); + + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + const payload = restartSentinelMocks.writeRestartSentinel.mock.calls.at(-1)?.[0]; + expect(payload?.stats?.requiresRestart).toBe(false); }); it("does not add an agent continuation from generic control-plane sessionKey params", async () => { diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index 464caca3122..0c5dfbe4e4b 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -426,6 +426,8 @@ describe("scheduleRestartSentinelWake", () => { mocks.finalizeUpdateRestartSentinelRunningVersion.mockReset(); mocks.finalizeUpdateRestartSentinelRunningVersion.mockResolvedValue(null); mocks.clearRestartSentinel.mockClear(); + mocks.formatRestartSentinelMessage.mockClear(); + mocks.summarizeRestartSentinel.mockClear(); mocks.injectTimestamp.mockClear(); mocks.timestampOptsFromConfig.mockClear(); mocks.recordInboundSessionAndDispatchReply.mockReset(); @@ -456,6 +458,8 @@ describe("scheduleRestartSentinelWake", () => { }); expect(mocks.ackDelivery).toHaveBeenCalledWith("queue-1"); expect(mocks.failDelivery).not.toHaveBeenCalled(); + expect(mocks.formatRestartSentinelMessage).toHaveBeenCalledWith(expect.anything()); + expect(mocks.summarizeRestartSentinel).toHaveBeenCalledWith(expect.anything()); expect(mockCallArg(mocks.enqueueSystemEvent)).toBe("restart message"); expectNthSystemEventFields(0, { sessionKey: "agent:main:main", diff --git a/src/infra/restart-sentinel.test.ts b/src/infra/restart-sentinel.test.ts index 54f0fdc8a26..90ec1b1b8bb 100644 --- a/src/infra/restart-sentinel.test.ts +++ b/src/infra/restart-sentinel.test.ts @@ -194,6 +194,36 @@ describe("restart sentinel", () => { }); }); + it("keeps old config restart sentinels readable without restart-required stats", async () => { + await withRestartSentinelStateDir(async () => { + const filePath = path.join(process.env.OPENCLAW_STATE_DIR ?? "", "restart-sentinel.json"); + const payload = { + kind: "config-patch" as const, + status: "ok" as const, + ts: Date.now(), + message: "Config updated successfully", + stats: { mode: "config.patch" }, + }; + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify({ version: 1, payload }, null, 2), "utf-8"); + + const read = await readRestartSentinel(); + + expect(read?.payload).toEqual(payload); + if (!read) { + throw new Error("Expected old restart sentinel to be readable"); + } + expect(summarizeRestartSentinel(read.payload)).toBe( + "Gateway restart config-patch ok (config.patch)", + ); + expect(formatRestartSentinelMessage(read.payload)).toBe( + ["Gateway restart config-patch ok (config.patch)", "Config updated successfully"].join( + "\n", + ), + ); + }); + }); + it("formatRestartSentinelMessage uses custom message when present", () => { const payload = { kind: "config-apply" as const, @@ -242,6 +272,48 @@ describe("restart sentinel", () => { expect(result).toContain("Gateway restart"); }); + it("formats config write success notices as restart required when marked", () => { + const payload = { + kind: "config-patch" as const, + status: "ok" as const, + ts: Date.now(), + message: "Run restart-gateway.ps1 to apply config changes.", + doctorHint: "Run openclaw doctor --non-interactive", + stats: { mode: "config.patch", requiresRestart: true }, + }; + + expect(formatRestartSentinelMessage(payload)).toBe( + [ + "Gateway restart required (config.patch)", + "Run restart-gateway.ps1 to apply config changes.", + "Run openclaw doctor --non-interactive", + ].join("\n"), + ); + expect(summarizeRestartSentinel(payload)).toBe("Gateway restart required (config.patch)"); + + expect( + summarizeRestartSentinel({ + kind: "config-apply", + status: "ok", + ts: Date.now(), + stats: { mode: "config.apply", requiresRestart: true }, + }), + ).toBe("Gateway restart required (config.apply)"); + }); + + it("does not mark hot-reloaded config patch notices as restart required", () => { + const payload = { + kind: "config-patch" as const, + status: "ok" as const, + ts: Date.now(), + stats: { mode: "config.patch", requiresRestart: false }, + }; + + expect(summarizeRestartSentinel(payload)).toBe( + "Gateway restart config-patch ok (config.patch)", + ); + }); + it("formats summary, distinct reason, and doctor hint together", () => { const payload = { kind: "config-patch" as const, diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 5974449c453..9c3cf6701ea 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -33,6 +33,7 @@ export type RestartSentinelStep = { export type RestartSentinelStats = { mode?: string; root?: string; + requiresRestart?: boolean; handoffId?: string; before?: Record | null; after?: Record | null; @@ -340,10 +341,22 @@ export function formatRestartSentinelMessage(payload: RestartSentinelPayload): s return lines.join("\n"); } +function isRestartRequiredConfigWriteSentinel(payload: RestartSentinelPayload): boolean { + return ( + (payload.kind === "config-apply" || payload.kind === "config-patch") && + payload.status === "ok" && + payload.stats?.requiresRestart === true + ); +} + export function summarizeRestartSentinel(payload: RestartSentinelPayload): string { if (payload.kind === "config-auto-recovery") { return "Gateway auto-recovery"; } + if (isRestartRequiredConfigWriteSentinel(payload)) { + const mode = payload.stats?.mode ? ` (${payload.stats.mode})` : ""; + return `Gateway restart required${mode}`.trim(); + } const kind = payload.kind; const status = payload.status; const mode = payload.stats?.mode ? ` (${payload.stats.mode})` : "";