fix(gateway): auto-repair unloaded LaunchAgent on start/restart

When a macOS LaunchAgent silently unloads after sleep or extended idle,
`gateway start` and `gateway restart` would bail with 'not loaded'
hints instead of attempting recovery — even though the plist still
exists on disk and `repairLaunchAgentBootstrap()` can re-register it.

Changes:
- Add optional `repairNotLoaded` method to `GatewayService` interface
- Wire darwin implementation: checks plist exists, calls
  `repairLaunchAgentBootstrap()` (enable + bootstrap + kickstart)
- `runServiceStart()`: when service is not loaded but repair succeeds,
  proceed with normal start flow instead of printing install hints
- `runServiceRestart()`: when `onNotLoaded` returns null (no running
  process to signal) but repair succeeds, proceed with restart flow
- Both paths are best-effort: if repair fails or throws, falls through
  to existing not-loaded behavior (no regression)

Fixes #43602
This commit is contained in:
Chance Robinson 2026-03-11 22:59:39 -04:00
parent bcbfbb831e
commit cdcb9464fb
3 changed files with 198 additions and 2 deletions

View File

@ -190,4 +190,141 @@ describe("runServiceRestart token drift", () => {
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
describe("repairNotLoaded (#43602)", () => {
it("start: repairs unloaded service when repairNotLoaded succeeds", async () => {
service.isLoaded.mockResolvedValue(false);
const repairNotLoaded = vi.fn().mockResolvedValue({ ok: true });
const serviceWithRepair = { ...service, repairNotLoaded };
await runServiceStart({
serviceNoun: "Gateway",
service: serviceWithRepair,
renderStartHints: () => [],
opts: { json: true },
});
expect(repairNotLoaded).toHaveBeenCalledTimes(1);
// After successful repair, start should proceed to restart the service.
expect(service.restart).toHaveBeenCalledTimes(1);
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string };
expect(payload.result).toBe("started");
});
it("start: falls through to hints when repairNotLoaded returns ok:false", async () => {
service.isLoaded.mockResolvedValue(false);
const repairNotLoaded = vi.fn().mockResolvedValue({ ok: false });
const serviceWithRepair = { ...service, repairNotLoaded };
await runServiceStart({
serviceNoun: "Gateway",
service: serviceWithRepair,
renderStartHints: () => ["openclaw gateway install"],
opts: { json: true },
});
expect(repairNotLoaded).toHaveBeenCalledTimes(1);
expect(service.restart).not.toHaveBeenCalled();
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; hints?: string[] };
expect(payload.result).toBe("not-loaded");
expect(payload.hints).toContain("openclaw gateway install");
});
it("start: falls through to hints when repairNotLoaded throws", async () => {
service.isLoaded.mockResolvedValue(false);
const repairNotLoaded = vi.fn().mockRejectedValue(new Error("launchctl failed"));
const serviceWithRepair = { ...service, repairNotLoaded };
await runServiceStart({
serviceNoun: "Gateway",
service: serviceWithRepair,
renderStartHints: () => ["openclaw gateway install"],
opts: { json: true },
});
expect(repairNotLoaded).toHaveBeenCalledTimes(1);
expect(service.restart).not.toHaveBeenCalled();
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string };
expect(payload.result).toBe("not-loaded");
});
it("start: does not call repairNotLoaded when service is already loaded", async () => {
service.isLoaded.mockResolvedValue(true);
const repairNotLoaded = vi.fn().mockResolvedValue({ ok: true });
const serviceWithRepair = { ...service, repairNotLoaded };
await runServiceStart({
serviceNoun: "Gateway",
service: serviceWithRepair,
renderStartHints: () => [],
opts: { json: true },
});
expect(repairNotLoaded).not.toHaveBeenCalled();
});
it("restart: repairs unloaded service when onNotLoaded returns null", async () => {
service.isLoaded.mockResolvedValue(false);
const repairNotLoaded = vi.fn().mockResolvedValue({ ok: true });
const serviceWithRepair = { ...service, repairNotLoaded };
const result = await runServiceRestart({
serviceNoun: "Gateway",
service: serviceWithRepair,
renderStartHints: () => [],
opts: { json: true },
onNotLoaded: async () => null,
});
expect(result).toBe(true);
expect(repairNotLoaded).toHaveBeenCalledTimes(1);
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string };
expect(payload.result).toBe("restarted");
expect(payload.message).toContain("re-registered");
});
it("restart: skips repair when onNotLoaded handles it", async () => {
service.isLoaded.mockResolvedValue(false);
const repairNotLoaded = vi.fn().mockResolvedValue({ ok: true });
const serviceWithRepair = { ...service, repairNotLoaded };
const result = await runServiceRestart({
serviceNoun: "Gateway",
service: serviceWithRepair,
renderStartHints: () => [],
opts: { json: true },
onNotLoaded: async () => ({
result: "restarted" as const,
message: "handled by SIGUSR1",
}),
});
expect(result).toBe(true);
expect(repairNotLoaded).not.toHaveBeenCalled();
});
it("restart: falls through to hints when repair returns ok:false", async () => {
service.isLoaded.mockResolvedValue(false);
const repairNotLoaded = vi.fn().mockResolvedValue({ ok: false });
const serviceWithRepair = { ...service, repairNotLoaded };
const result = await runServiceRestart({
serviceNoun: "Gateway",
service: serviceWithRepair,
renderStartHints: () => ["openclaw gateway install"],
opts: { json: true },
onNotLoaded: async () => null,
});
expect(result).toBe(false);
expect(repairNotLoaded).toHaveBeenCalledTimes(1);
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string };
expect(payload.result).toBe("not-loaded");
});
});
});

View File

@ -194,7 +194,7 @@ export async function runServiceStart(params: {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createActionIO({ action: "start", json });
const loaded = await resolveServiceLoadedOrFail({
let loaded = await resolveServiceLoadedOrFail({
serviceNoun: params.serviceNoun,
service: params.service,
fail,
@ -202,6 +202,25 @@ export async function runServiceStart(params: {
if (loaded === null) {
return;
}
if (!loaded && params.service.repairNotLoaded) {
// The service was previously installed but is no longer loaded (e.g.
// macOS LaunchAgent silently unloaded after sleep/idle). Attempt to
// re-register the existing service definition before falling through
// to the "not loaded" install hints. See #43602.
try {
const repair = await params.service.repairNotLoaded({ env: process.env });
if (repair.ok) {
loaded = true;
if (!json) {
defaultRuntime.log(
`${params.serviceNoun} was not loaded — re-registered from existing service definition.`,
);
}
}
} catch {
// Best-effort repair; fall through to normal not-loaded handling.
}
}
if (!loaded) {
await handleServiceNotLoaded({
serviceNoun: params.serviceNoun,
@ -356,7 +375,7 @@ export async function runServiceRestart(params: {
return true;
};
const loaded = await resolveServiceLoadedOrFail({
let loaded = await resolveServiceLoadedOrFail({
serviceNoun: params.serviceNoun,
service: params.service,
fail,
@ -384,6 +403,26 @@ export async function runServiceRestart(params: {
fail(`${params.serviceNoun} restart failed: ${String(err)}`);
return false;
}
if (!handledNotLoaded && params.service.repairNotLoaded) {
// No running process to signal, but the service definition may still
// exist on disk (e.g. macOS LaunchAgent unloaded after sleep/idle).
// Re-register it so `restart` can proceed normally. See #43602.
try {
const repair = await params.service.repairNotLoaded({ env: process.env });
if (repair.ok) {
loaded = true;
handledNotLoaded = {
result: "restarted",
message: `${params.serviceNoun} was not loaded — re-registered from existing service definition.`,
};
if (!json) {
defaultRuntime.log(handledNotLoaded.message);
}
}
} catch {
// Best-effort repair; fall through to normal not-loaded handling.
}
}
if (!handledNotLoaded) {
await handleServiceNotLoaded({
serviceNoun: params.serviceNoun,

View File

@ -1,8 +1,10 @@
import {
installLaunchAgent,
isLaunchAgentLoaded,
launchAgentPlistExists,
readLaunchAgentProgramArguments,
readLaunchAgentRuntime,
repairLaunchAgentBootstrap,
restartLaunchAgent,
stopLaunchAgent,
uninstallLaunchAgent,
@ -64,6 +66,16 @@ export type GatewayService = {
isLoaded: (args: GatewayServiceEnvArgs) => Promise<boolean>;
readCommand: (env: GatewayServiceEnv) => Promise<GatewayServiceCommandConfig | null>;
readRuntime: (env: GatewayServiceEnv) => Promise<GatewayServiceRuntime>;
/**
* Attempt to re-register and start a service that was previously installed
* but is no longer loaded (e.g. after macOS sleep/idle unloads the
* LaunchAgent). Returns `{ ok: true }` when the service was successfully
* re-bootstrapped, `{ ok: false }` when the service definition does not
* exist on disk (caller should fall through to install hints).
*
* Optional platforms that do not experience silent unloads can omit this.
*/
repairNotLoaded?: (args: GatewayServiceEnvArgs) => Promise<{ ok: boolean; detail?: string }>;
};
export function describeGatewayServiceRestart(
@ -105,6 +117,14 @@ const GATEWAY_SERVICE_REGISTRY: Record<SupportedGatewayServicePlatform, GatewayS
isLoaded: isLaunchAgentLoaded,
readCommand: readLaunchAgentProgramArguments,
readRuntime: readLaunchAgentRuntime,
repairNotLoaded: async (args) => {
const env = args.env ?? (process.env as Record<string, string | undefined>);
const plistExists = await launchAgentPlistExists(env);
if (!plistExists) {
return { ok: false, detail: "plist not found on disk" };
}
return await repairLaunchAgentBootstrap({ env });
},
},
linux: {
label: "systemd",