Skip to content

Commit 3b1a020

Browse files
committed
fix: repair stale gateway service on start
1 parent 9eb79bc commit 3b1a020

10 files changed

Lines changed: 383 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212
### Fixes
1313

1414
- Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing `main` sessions from staying stuck as running after completed or timed-out turns.
15+
- Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting.
1516
- Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky.
1617
- Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc.
1718
- Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof.

src/cli/daemon-cli/install.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from "./shared.js";
3232
import type { DaemonInstallOptions } from "./types.js";
3333

34-
function mergeInstallInvocationEnv(params: {
34+
export function mergeInstallInvocationEnv(params: {
3535
env: NodeJS.ProcessEnv;
3636
existingServiceEnv?: Record<string, string>;
3737
}): NodeJS.ProcessEnv {

src/cli/daemon-cli/lifecycle-core.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,55 @@ describe("runServiceRestart token drift", () => {
353353
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
354354
});
355355

356+
it("repairs stale loaded services during start before reporting success", async () => {
357+
service.readCommand.mockResolvedValue({
358+
programArguments: ["openclaw", "gateway"],
359+
environment: { OPENCLAW_SERVICE_VERSION: "2026.4.24" },
360+
});
361+
const repairLoadedService = vi.fn(async () => ({
362+
result: "started" as const,
363+
message: "Gateway service definition repaired and started.",
364+
warnings: ["service was installed by OpenClaw 2026.4.24, current CLI is 2026.5.2"],
365+
loaded: true,
366+
}));
367+
368+
await runServiceStart({
369+
serviceNoun: "Gateway",
370+
service,
371+
renderStartHints: () => [],
372+
opts: { json: true },
373+
repairLoadedService,
374+
});
375+
376+
expect(repairLoadedService).toHaveBeenCalledTimes(1);
377+
expect(service.restart).not.toHaveBeenCalled();
378+
const payload = readJsonLog<{
379+
result?: string;
380+
message?: string;
381+
warnings?: string[];
382+
service?: { loaded?: boolean };
383+
}>();
384+
expect(payload.result).toBe("started");
385+
expect(payload.message).toBe("Gateway service definition repaired and started.");
386+
expect(payload.warnings?.[0]).toContain("service was installed by OpenClaw");
387+
expect(payload.service?.loaded).toBe(true);
388+
});
389+
390+
it("fails start with an install hint when a stale loaded service has no repair callback", async () => {
391+
service.readCommand.mockResolvedValue({
392+
programArguments: ["openclaw", "gateway"],
393+
environment: { OPENCLAW_SERVICE_VERSION: "2026.4.24" },
394+
});
395+
396+
await expect(runServiceStart(createServiceRunArgs())).rejects.toThrow("__exit__:1");
397+
398+
const payload = readJsonLog<{ ok?: boolean; error?: string; hints?: string[] }>();
399+
expect(payload.ok).toBe(false);
400+
expect(payload.error).toContain("service needs repair");
401+
expect(payload.hints).toEqual(["openclaw gateway install --force"]);
402+
expect(service.restart).not.toHaveBeenCalled();
403+
});
404+
356405
it("fails start when restarting a stopped installed service errors", async () => {
357406
service.isLoaded.mockResolvedValue(false);
358407
service.restart.mockRejectedValue(new Error("launchctl kickstart failed: permission denied"));

src/cli/daemon-cli/lifecycle-core.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { formatConfigIssueLines } from "../../config/issue-format.js";
55
import { resolveIsNixMode } from "../../config/paths.js";
66
import { checkTokenDrift } from "../../daemon/service-audit.js";
77
import type { GatewayServiceRestartResult } from "../../daemon/service-types.js";
8+
import type { GatewayServiceStartRepairIssue, GatewayServiceState } from "../../daemon/service.js";
89
import { describeGatewayServiceRestart, startGatewayService } from "../../daemon/service.js";
910
import type { GatewayService } from "../../daemon/service.js";
1011
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
@@ -16,6 +17,7 @@ import {
1617
} from "../../infra/restart.js";
1718
import { isWSL } from "../../infra/wsl.js";
1819
import { defaultRuntime } from "../../runtime.js";
20+
import { formatCliCommand } from "../command-format.js";
1921
import { resolveGatewayTokenForDriftCheck } from "./gateway-token-drift.js";
2022
import {
2123
buildDaemonServiceSnapshot,
@@ -48,6 +50,11 @@ type ServiceRecoveryContext = {
4850
fail: (message: string, hints?: string[]) => void;
4951
};
5052

53+
type ServiceStartRepairContext = ServiceRecoveryContext & {
54+
state: GatewayServiceState;
55+
issues: GatewayServiceStartRepairIssue[];
56+
};
57+
5158
async function maybeAugmentSystemdHints(hints: string[]): Promise<string[]> {
5259
if (process.platform !== "linux") {
5360
return hints;
@@ -221,6 +228,7 @@ export async function runServiceStart(params: {
221228
renderStartHints: () => string[];
222229
opts?: DaemonLifecycleOptions;
223230
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
231+
repairLoadedService?: (ctx: ServiceStartRepairContext) => Promise<ServiceRecoveryResult | null>;
224232
}) {
225233
const json = Boolean(params.opts?.json);
226234
const { stdout, emit, fail } = createDaemonActionContext({ action: "start", json });
@@ -298,6 +306,41 @@ export async function runServiceStart(params: {
298306
});
299307
return;
300308
}
309+
if (startResult.outcome === "repair-required") {
310+
try {
311+
const handled = await params.repairLoadedService?.({
312+
json,
313+
stdout,
314+
fail,
315+
state: startResult.state,
316+
issues: startResult.issues,
317+
});
318+
if (handled) {
319+
emit({
320+
ok: true,
321+
result: handled.result,
322+
message: handled.message,
323+
warnings: handled.warnings,
324+
service: buildDaemonServiceSnapshot(params.service, handled.loaded ?? true),
325+
});
326+
if (!json && handled.message) {
327+
defaultRuntime.log(handled.message);
328+
}
329+
return;
330+
}
331+
} catch (err) {
332+
const hints = params.renderStartHints();
333+
fail(`${params.serviceNoun} repair failed: ${String(err)}`, hints);
334+
return;
335+
}
336+
fail(
337+
`${params.serviceNoun} service needs repair before it can start: ${startResult.issues
338+
.map((issue) => issue.message)
339+
.join("; ")}`,
340+
[formatCliCommand("openclaw gateway install --force")],
341+
);
342+
return;
343+
}
301344
emit({
302345
ok: true,
303346
result: "started",

src/cli/daemon-cli/lifecycle.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const probeGateway = vi.fn<
5252
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
5353
const loadConfig = vi.hoisted(() => vi.fn(() => ({})));
5454
const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn());
55+
const repairLoadedGatewayServiceForStart = vi.hoisted(() => vi.fn());
5556

5657
vi.mock("../../config/config.js", () => ({
5758
getRuntimeConfig: () => loadConfig(),
@@ -89,6 +90,10 @@ vi.mock("./launchd-recovery.js", () => ({
8990
recoverInstalledLaunchAgent(args),
9091
}));
9192

93+
vi.mock("./start-repair.js", () => ({
94+
repairLoadedGatewayServiceForStart: (args: unknown) => repairLoadedGatewayServiceForStart(args),
95+
}));
96+
9297
vi.mock("./restart-health.js", () => ({
9398
DEFAULT_RESTART_HEALTH_ATTEMPTS: 120,
9499
DEFAULT_RESTART_HEALTH_DELAY_MS: 500,
@@ -160,6 +165,7 @@ describe("runDaemonRestart health checks", () => {
160165
isRestartEnabled.mockReset();
161166
loadConfig.mockReset();
162167
recoverInstalledLaunchAgent.mockReset();
168+
repairLoadedGatewayServiceForStart.mockReset();
163169

164170
service.readCommand.mockResolvedValue({
165171
programArguments: ["openclaw", "gateway", "--port", "18789"],
@@ -224,6 +230,46 @@ describe("runDaemonRestart health checks", () => {
224230
expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "started" });
225231
});
226232

233+
it("repairs stale loaded service definitions from gateway start", async () => {
234+
repairLoadedGatewayServiceForStart.mockResolvedValue({
235+
result: "started",
236+
message: "Gateway service definition repaired and started.",
237+
loaded: true,
238+
});
239+
runServiceStart.mockImplementation(
240+
async (params: {
241+
repairLoadedService?: (args: {
242+
json: boolean;
243+
stdout: NodeJS.WritableStream;
244+
state: unknown;
245+
issues: unknown[];
246+
}) => Promise<unknown>;
247+
}) => {
248+
await params.repairLoadedService?.({
249+
json: true,
250+
stdout: process.stdout,
251+
state: { command: { environment: { OPENCLAW_SERVICE_VERSION: "2026.4.24" } } },
252+
issues: [{ code: "version-mismatch", message: "old service" }],
253+
});
254+
},
255+
);
256+
257+
await runDaemonStart({ json: true });
258+
259+
expect(repairLoadedGatewayServiceForStart).toHaveBeenCalledWith(
260+
expect.objectContaining({
261+
service,
262+
json: true,
263+
state: expect.objectContaining({
264+
command: expect.objectContaining({
265+
environment: { OPENCLAW_SERVICE_VERSION: "2026.4.24" },
266+
}),
267+
}),
268+
issues: [expect.objectContaining({ code: "version-mismatch" })],
269+
}),
270+
);
271+
});
272+
227273
it("kills stale gateway pids and retries restart", async () => {
228274
const unhealthy: RestartHealthSnapshot = {
229275
healthy: false,

src/cli/daemon-cli/lifecycle.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
waitForGatewayHealthyRestart,
3030
} from "./restart-health.js";
3131
import { parsePortFromArgs, renderGatewayServiceStartHints } from "./shared.js";
32+
import { repairLoadedGatewayServiceForStart } from "./start-repair.js";
3233
import type { DaemonLifecycleOptions } from "./types.js";
3334

3435
const POST_RESTART_HEALTH_ATTEMPTS = DEFAULT_RESTART_HEALTH_ATTEMPTS;
@@ -150,14 +151,23 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
150151
}
151152

152153
export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) {
154+
const service = resolveGatewayService();
153155
return await runServiceStart({
154156
serviceNoun: "Gateway",
155-
service: resolveGatewayService(),
157+
service,
156158
renderStartHints: renderGatewayServiceStartHints,
157159
onNotLoaded:
158160
process.platform === "darwin"
159161
? async () => await recoverInstalledLaunchAgent({ result: "started" })
160162
: undefined,
163+
repairLoadedService: async ({ json, stdout, state, issues }) =>
164+
await repairLoadedGatewayServiceForStart({
165+
service,
166+
json,
167+
stdout,
168+
state,
169+
issues,
170+
}),
161171
opts,
162172
});
163173
}

src/cli/daemon-cli/start-repair.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js";
2+
import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../../commands/daemon-runtime.js";
3+
import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js";
4+
import { readConfigFileSnapshotForWrite } from "../../config/io.js";
5+
import { resolveGatewayPort } from "../../config/paths.js";
6+
import { OPENCLAW_WRAPPER_ENV_KEY, resolveOpenClawWrapperPath } from "../../daemon/program-args.js";
7+
import type { GatewayServiceEnv } from "../../daemon/service-types.js";
8+
import type {
9+
GatewayService,
10+
GatewayServiceStartRepairIssue,
11+
GatewayServiceState,
12+
} from "../../daemon/service.js";
13+
import { formatGatewayServiceStartRepairIssues } from "../../daemon/service.js";
14+
import { defaultRuntime } from "../../runtime.js";
15+
import { mergeInstallInvocationEnv } from "./install.js";
16+
17+
export async function repairLoadedGatewayServiceForStart(params: {
18+
service: GatewayService;
19+
state: GatewayServiceState;
20+
issues: GatewayServiceStartRepairIssue[];
21+
json: boolean;
22+
stdout: NodeJS.WritableStream;
23+
}): Promise<{ result: "started"; message: string; warnings?: string[]; loaded: boolean }> {
24+
const { snapshot: configSnapshot, writeOptions: configWriteOptions } =
25+
await readConfigFileSnapshotForWrite();
26+
const cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config;
27+
const existingEnvironment = params.state.command?.environment;
28+
const installEnv = mergeInstallInvocationEnv({
29+
env: process.env,
30+
existingServiceEnv: existingEnvironment,
31+
});
32+
const wrapperPath = await resolveOpenClawWrapperPath(installEnv[OPENCLAW_WRAPPER_ENV_KEY]);
33+
const port = resolveGatewayPort(cfg);
34+
35+
const tokenResolution = await resolveGatewayInstallToken({
36+
config: cfg,
37+
configSnapshot,
38+
configWriteOptions,
39+
env: installEnv,
40+
autoGenerateWhenMissing: true,
41+
persistGeneratedToken: true,
42+
});
43+
if (tokenResolution.unavailableReason) {
44+
throw new Error(tokenResolution.unavailableReason);
45+
}
46+
47+
const warnings = [
48+
formatGatewayServiceStartRepairIssues(params.issues),
49+
...tokenResolution.warnings,
50+
].filter((warning) => warning.trim().length > 0);
51+
if (!params.json) {
52+
defaultRuntime.log("Gateway service definition needs repair:");
53+
for (const warning of warnings) {
54+
defaultRuntime.log(`- ${warning}`);
55+
}
56+
}
57+
58+
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
59+
env: installEnv,
60+
port,
61+
runtime: DEFAULT_GATEWAY_DAEMON_RUNTIME,
62+
wrapperPath,
63+
existingEnvironment,
64+
config: cfg,
65+
warn: (message) => {
66+
warnings.push(message);
67+
if (!params.json) {
68+
defaultRuntime.log(`- ${message}`);
69+
}
70+
},
71+
});
72+
73+
await params.service.install({
74+
env: installEnv as GatewayServiceEnv,
75+
stdout: params.stdout,
76+
programArguments,
77+
workingDirectory,
78+
environment,
79+
});
80+
81+
let loaded = true;
82+
try {
83+
loaded = await params.service.isLoaded({ env: installEnv });
84+
} catch {
85+
loaded = true;
86+
}
87+
88+
return {
89+
result: "started",
90+
message: "Gateway service definition repaired and started.",
91+
warnings: warnings.length ? warnings : undefined,
92+
loaded,
93+
};
94+
}

src/daemon/service-types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,20 @@ export type GatewayServiceState = {
4848
runtime?: GatewayServiceRuntime;
4949
};
5050

51+
export type GatewayServiceStartRepairIssue = {
52+
code: "missing-program" | "temporary-program" | "version-mismatch";
53+
message: string;
54+
};
55+
5156
export type GatewayServiceStartResult =
5257
| { outcome: "started"; state: GatewayServiceState }
5358
| { outcome: "scheduled"; state: GatewayServiceState }
54-
| { outcome: "missing-install"; state: GatewayServiceState };
59+
| { outcome: "missing-install"; state: GatewayServiceState }
60+
| {
61+
outcome: "repair-required";
62+
state: GatewayServiceState;
63+
issues: GatewayServiceStartRepairIssue[];
64+
};
5565

5666
export type GatewayServiceRenderArgs = {
5767
description?: string;

0 commit comments

Comments
 (0)