From aca488d5bed6819d363bb84ae838da7361acc096 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 13:15:18 +0100 Subject: [PATCH] fix(gateway): keep watch restarts in-process --- scripts/watch-node.mjs | 3 +++ src/infra/process-respawn.test.ts | 13 ++++++++++++ src/infra/watch-node.test.ts | 33 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 01ef9bba261..572ca6ec3b7 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -44,6 +44,9 @@ export async function runWatchMain(params = {}) { const watchSession = `${deps.now()}-${deps.process.pid}`; childEnv.OPENCLAW_WATCH_MODE = "1"; childEnv.OPENCLAW_WATCH_SESSION = watchSession; + // The watcher owns process restarts; keep SIGUSR1/config reloads in-process + // so inherited launchd/systemd markers do not make the child exit and stall. + childEnv.OPENCLAW_NO_RESPAWN = "1"; if (deps.args.length > 0) { childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" "); } diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index 17ab379d14f..e1e810180db 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -72,6 +72,19 @@ describe("restartGatewayProcessWithFreshPid", () => { expect(spawnMock).not.toHaveBeenCalled(); }); + it("keeps OPENCLAW_NO_RESPAWN ahead of inherited supervisor hints", () => { + clearSupervisorHints(); + setPlatform("darwin"); + process.env.OPENCLAW_NO_RESPAWN = "1"; + process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; + + const result = restartGatewayProcessWithFreshPid(); + + expect(result).toEqual({ mode: "disabled" }); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + it("returns supervised when launchd hints are present on macOS (no kickstart)", () => { clearSupervisorHints(); expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" }); diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 0f176d859ad..bb5f27b19b6 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -76,6 +76,7 @@ describe("watch-node script", () => { PATH: "/usr/bin", OPENCLAW_WATCH_MODE: "1", OPENCLAW_WATCH_SESSION: "1700000000000-4242", + OPENCLAW_NO_RESPAWN: "1", OPENCLAW_WATCH_COMMAND: "gateway --force", }), }), @@ -146,6 +147,38 @@ describe("watch-node script", () => { expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); }); + it("forces no-respawn for watch children even when supervisor hints are inherited", async () => { + const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness(); + + const runPromise = runWatchMain({ + args: ["gateway", "--force"], + createWatcher, + env: { + LAUNCH_JOB_LABEL: "ai.openclaw.gateway", + PATH: "/usr/bin", + }, + process: fakeProcess, + spawn, + }); + + expect(spawn).toHaveBeenCalledWith( + "/usr/local/bin/node", + ["scripts/run-node.mjs", "gateway", "--force"], + expect.objectContaining({ + env: expect.objectContaining({ + LAUNCH_JOB_LABEL: "ai.openclaw.gateway", + OPENCLAW_NO_RESPAWN: "1", + }), + }), + ); + + fakeProcess.emit("SIGINT"); + const exitCode = await runPromise; + expect(exitCode).toBe(130); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(watcher.close).toHaveBeenCalledTimes(1); + }); + it("ignores test-only changes and restarts on non-test source changes", async () => { const childA = Object.assign(new EventEmitter(), { kill: vi.fn(function () {