diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 3d478e8a99ab..2e54bf49d053 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1004,7 +1004,7 @@ describe("update-cli", () => { expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); }); - it("does not restart a stopped managed gateway after post-core plugin errors", async () => { + it("restarts a stopped managed gateway after post-core plugin errors", async () => { const root = createCaseDir("openclaw-update"); const entryPath = path.join(root, "dist", "index.js"); mockPackageInstallStatus(root); @@ -1064,9 +1064,14 @@ describe("update-cli", () => { await updateCommand({ yes: true }); expect(serviceStop).toHaveBeenCalled(); - expect(serviceRestart).not.toHaveBeenCalled(); + expect(serviceRestart).toHaveBeenCalledTimes(1); expect(runDaemonRestart).not.toHaveBeenCalled(); expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect( + requireValue(serviceStop.mock.invocationCallOrder[0], "service stop call order"), + ).toBeLessThan( + requireValue(serviceRestart.mock.invocationCallOrder[0], "service restart call order"), + ); }); it("does not carry gateway service markers into the post-core update process", async () => { @@ -2841,8 +2846,9 @@ describe("update-cli", () => { }); it("stops a running managed gateway before git checkout rebuild", async () => { + const serviceEntrypoint = path.join(process.cwd(), "dist", "index.js"); serviceReadCommand.mockResolvedValue({ - programArguments: ["openclaw", "gateway", "run"], + programArguments: ["node", serviceEntrypoint, "gateway", "run"], environment: { OPENCLAW_SERVICE_MARKER: "openclaw", OPENCLAW_SERVICE_KIND: "gateway", @@ -2875,6 +2881,79 @@ describe("update-cli", () => { ); }); + it("does not stop or restart a managed gateway owned by another git checkout", async () => { + const otherRoot = await createTrackedTempDir("openclaw-update-other-service-root-"); + const otherEntrypoint = path.join(otherRoot, "dist", "index.js"); + await fs.mkdir(path.dirname(otherEntrypoint), { recursive: true }); + await fs.writeFile( + path.join(otherRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.21" }), + "utf-8", + ); + await fs.writeFile(otherEntrypoint, "export {};\n", "utf-8"); + serviceReadCommand.mockResolvedValue({ + programArguments: ["node", otherEntrypoint, "gateway", "run"], + environment: { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + }); + serviceLoaded.mockResolvedValue(true); + serviceReadRuntime.mockResolvedValue({ + status: "running", + pid: 4242, + state: "running", + }); + vi.mocked(runGatewayUpdate).mockResolvedValueOnce(makeOkUpdateResult({ mode: "git" })); + + await updateCommand({ yes: true }); + + expect(serviceStop).not.toHaveBeenCalled(); + expect(prepareRestartScript).not.toHaveBeenCalled(); + expect(serviceRestart).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + expect(runGatewayUpdate).toHaveBeenCalledTimes(1); + }); + + it("restarts a stopped git service before exiting on plugin post-update failure", async () => { + const serviceEntrypoint = path.join(process.cwd(), "dist", "index.js"); + const invalidPostUpdateSnapshot: ConfigFileSnapshot = { + ...baseSnapshot, + valid: false, + issues: [{ path: "plugins", message: "invalid plugin config" }], + config: baseConfig, + runtimeConfig: baseConfig, + }; + vi.mocked(readConfigFileSnapshot) + .mockResolvedValueOnce(baseSnapshot) + .mockResolvedValueOnce(invalidPostUpdateSnapshot); + serviceReadCommand.mockResolvedValue({ + programArguments: ["node", serviceEntrypoint, "gateway", "run"], + environment: { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + }); + serviceLoaded.mockResolvedValue(true); + serviceReadRuntime.mockResolvedValue({ + status: "running", + pid: 4242, + state: "running", + }); + vi.mocked(runGatewayUpdate).mockResolvedValueOnce(makeOkUpdateResult({ mode: "git" })); + + await updateCommand({ yes: true }); + + expect(serviceStop).toHaveBeenCalledTimes(1); + expect(serviceRestart).toHaveBeenCalledTimes(1); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect( + requireValue(serviceStop.mock.invocationCallOrder[0], "service stop call order"), + ).toBeLessThan( + requireValue(serviceRestart.mock.invocationCallOrder[0], "service restart call order"), + ); + }); + it("keeps managed service stop output off stdout during json package updates", async () => { const tempDir = await createTrackedTempDir("openclaw-update-json-stop-service-"); const nodeModules = path.join(tempDir, "node_modules"); diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index c15274d1ab28..e8b4dd4e2220 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -79,7 +79,7 @@ describe("shouldPrepareUpdatedInstallRestart", () => { ).toBe(false); }); - it("keeps non-package updates tied to the loaded service state", () => { + it("keeps non-package updates tied to the matching loaded service state", () => { expect( shouldPrepareUpdatedInstallRestart({ updateMode: "git", @@ -92,6 +92,15 @@ describe("shouldPrepareUpdatedInstallRestart", () => { updateMode: "git", serviceInstalled: true, serviceLoaded: true, + serviceMatchesUpdateRoot: false, + }), + ).toBe(false); + expect( + shouldPrepareUpdatedInstallRestart({ + updateMode: "git", + serviceInstalled: true, + serviceLoaded: true, + serviceMatchesUpdateRoot: true, }), ).toBe(true); }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index e21e53e2c676..b5dc2949e7ff 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -630,6 +630,7 @@ export function shouldPrepareUpdatedInstallRestart(params: { serviceInstalled: boolean; serviceLoaded: boolean; serviceStoppedForUpdate?: boolean; + serviceMatchesUpdateRoot?: boolean; }): boolean { if (isPackageManagerUpdateMode(params.updateMode)) { return params.serviceInstalled; @@ -637,6 +638,9 @@ export function shouldPrepareUpdatedInstallRestart(params: { if (params.updateMode === "git" && params.serviceStoppedForUpdate) { return params.serviceInstalled; } + if (params.updateMode === "git") { + return params.serviceLoaded && params.serviceMatchesUpdateRoot === true; + } return params.serviceLoaded; } @@ -839,6 +843,7 @@ function serviceControlStdoutForMode(jsonMode: boolean): NodeJS.WritableStream { async function maybeStopManagedServiceBeforeMutableUpdate(params: { updateInstallKind: "git" | "package"; + root: string; shouldRestart: boolean; jsonMode: boolean; }): Promise { @@ -912,6 +917,26 @@ async function maybeStopManagedServiceBeforeMutableUpdate(params: { }; } + if ( + params.updateInstallKind === "git" && + !(await gatewayServiceCommandUsesRoot({ root: params.root, command: serviceState.command })) + ) { + if (!params.jsonMode) { + defaultRuntime.log( + theme.muted( + "Managed gateway service points at a different OpenClaw root; leaving it running during this git update.", + ), + ); + } + return { + stopped: false, + inspected: true, + runtimeInspected: true, + running: true, + serviceEnv: serviceState.env, + }; + } + if (!params.jsonMode) { defaultRuntime.log( theme.muted(`Stopping managed gateway service before ${params.updateInstallKind} update...`), @@ -1426,14 +1451,18 @@ async function resolveManagedServicePackageUpdateRoot(params: { async function gatewayServiceCommandUsesRoot(params: { root: string | undefined; env?: NodeJS.ProcessEnv; + command?: GatewayServiceCommandConfig | null; }): Promise { const expectedRoot = normalizeOptionalString(params.root); if (!expectedRoot) { return false; } - const command = await resolveGatewayService() - .readCommand(params.env ?? process.env) - .catch(() => null); + const command = + params.command === undefined + ? await resolveGatewayService() + .readCommand(params.env ?? process.env) + .catch(() => null) + : params.command; const layout = await summarizeGatewayServiceLayout(command); const serviceRoot = layout?.packageRoot; if (!serviceRoot) { @@ -2010,6 +2039,7 @@ async function maybeRestartService(params: { restartScriptPath?: string | null; invocationCwd?: string; nodeRunner?: string; + skipLegacyServiceRestart?: boolean; }): Promise { const verifyRestartedGateway = async ( expectedGatewayVersion: string | undefined, @@ -2216,7 +2246,8 @@ async function maybeRestartService(params: { } } else if ( !refreshedGatewayAlreadyHealthy && - shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode }) + shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode }) && + !params.skipLegacyServiceRestart ) { await createUpdateConfigSnapshot(); restarted = await runDaemonRestart(); @@ -3433,6 +3464,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise try { preManagedServiceStop = await maybeStopManagedServiceBeforeMutableUpdate({ updateInstallKind, + root, shouldRestart, jsonMode: Boolean(opts.json), }); @@ -3697,6 +3729,10 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise result: resultWithPostUpdate, jsonMode: Boolean(opts.json), }); + await maybeRestartServiceAfterFailedMutableUpdate({ + preManagedServiceStop, + jsonMode: Boolean(opts.json), + }); if (opts.json) { defaultRuntime.writeJson(resultWithPostUpdate); } else { @@ -3709,6 +3745,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise let restartScriptPath: string | null = null; let refreshGatewayServiceEnvLocal = false; let gatewayServiceEnv: NodeJS.ProcessEnv | undefined; + let skipLegacyServiceRestart = false; let gatewayPort = resolveUpdatedGatewayRestartPort({ config: postUpdateConfigSnapshot.valid ? postUpdateConfigSnapshot.config : undefined, processEnv: process.env, @@ -3722,12 +3759,26 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise preManagedServiceEnv: preManagedServiceStop?.serviceEnv, }), }); + const serviceMatchesUpdateRoot = + resultWithPostUpdate.mode === "git" + ? await gatewayServiceCommandUsesRoot({ + root: postUpdateRoot, + command: serviceState.command, + }) + : undefined; + skipLegacyServiceRestart = + resultWithPostUpdate.mode === "git" && + serviceState.installed && + serviceState.loaded && + preManagedServiceStop?.stopped !== true && + serviceMatchesUpdateRoot !== true; if ( shouldPrepareUpdatedInstallRestart({ updateMode: resultWithPostUpdate.mode, serviceInstalled: serviceState.installed, serviceLoaded: serviceState.loaded, serviceStoppedForUpdate: preManagedServiceStop?.stopped, + serviceMatchesUpdateRoot, }) ) { gatewayServiceEnv = serviceState.env; @@ -3766,6 +3817,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise restartScriptPath, invocationCwd, nodeRunner: managedServiceNodeRunner, + skipLegacyServiceRestart, }); if (!restartOk) { await markControlPlaneUpdateRestartSentinelFailureBestEffort({