Fix config patch restart-required notices

Merges the Clownfish-repaired contributor branch for #83041. Clownfish merge preflight cleared security/comments/review and accepted pnpm check:changed; the remaining cron shard failure is present on current main.
This commit is contained in:
Helck
2026-06-22 19:45:26 +08:00
committed by GitHub
parent 90cf265f29
commit 96e27c6ea8
5 changed files with 153 additions and 14 deletions

View File

@@ -132,21 +132,40 @@ function queueSharedGatewayAuthGenerationRefresh(
});
}
function shouldScheduleDirectConfigRestart(params: {
function isNoopConfigReloadPlan(plan: ReturnType<typeof buildGatewayReloadPlan>): 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<typeof extractDeliveryInfo>["deliveryContext"];
threadId: ReturnType<typeof extractDeliveryInfo>["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,

View File

@@ -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 () => {

View File

@@ -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",

View File

@@ -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,

View File

@@ -33,6 +33,7 @@ export type RestartSentinelStep = {
export type RestartSentinelStats = {
mode?: string;
root?: string;
requiresRestart?: boolean;
handoffId?: string;
before?: Record<string, unknown> | null;
after?: Record<string, unknown> | 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})` : "";