mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 14:29:32 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})` : "";
|
||||
|
||||
Reference in New Issue
Block a user