Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Steinberger
6f04d1fdcd fix: narrow daemon start not-loaded fallback (#54087) (thanks @hclsys) 2026-03-24 22:33:45 -07:00
HCL
e5273a640d fix: validate config before restart + derive loaded from real state
Address Codex P1 + Greptile P2:
- Move config validation before the restart attempt so invalid config
  is caught in the stop→start path (not just the already-loaded path)
- Derive service.loaded from actual isLoaded() after restart instead
  of hardcoded true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: HCL <chenglunhu@gmail.com>
2026-03-24 22:23:20 -07:00
HCL
02d887c7ba fix(daemon): bootstrap stopped service on gateway start
After `gateway stop` (which runs `launchctl bootout`), `gateway start`
checks `isLoaded` → false → prints "not loaded" hints and exits.
The service is never re-bootstrapped, so `start` cannot recover from
`stop` — only `gateway install` works.

Root cause: src/cli/daemon-cli/lifecycle-core.ts:208-217 — runServiceStart
calls handleServiceNotLoaded which only prints hints, never attempts
service.restart() (which already handles bootstrap via
bootstrapLaunchAgentOrThrow at launchd.ts:598).

Fix: when service is not loaded, attempt service.restart() first (which
handles re-bootstrapping on all platforms). If restart fails (e.g. plist
was deleted, not just booted out), fall back to the existing hints.

The restart path is already proven: restartLaunchAgent (launchd.ts:556)
handles "not loaded" via bootstrapLaunchAgentOrThrow. This fix routes
the start command through the same recovery path.

Closes #53878

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: HCL <chenglunhu@gmail.com>
2026-03-24 22:23:20 -07:00
3 changed files with 78 additions and 12 deletions

View File

@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
- Feishu/MSTeams message tool: keep provider-native `card` payloads optional in merged tool schemas so media-only sends stop failing validation before channel runtime dispatch. (#53715) Thanks @lndyzwdxhs.
- Feishu/startup: keep `requireMention` enforcement strict when bot identity startup probes fail, raise the startup bot-info timeout to 30s, and add cancellable background identity recovery so mention-gated groups recover without noisy fallback. (#43788) Thanks @lefarcen.
- Plugins: enforce terminal hook decision semantics for tool/message guards (#54241) Thanks @joshavant.
- Gateway/daemon: keep `openclaw gateway start` failing on real restart errors for stopped-but-installed services, while still falling back to install hints after uninstall. (#54087) Thanks @hclsys.
## 2026.3.23

View File

@@ -210,4 +210,33 @@ describe("runServiceRestart token drift", () => {
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
it("fails start when restarting a stopped installed service errors", async () => {
service.isLoaded.mockResolvedValue(false);
service.restart.mockRejectedValue(new Error("launchctl kickstart failed: permission denied"));
await expect(runServiceStart(createServiceRunArgs())).rejects.toThrow("__exit__:1");
const payload = readJsonLog<{ ok?: boolean; error?: string }>();
expect(payload.ok).toBe(false);
expect(payload.error).toContain("launchctl kickstart failed: permission denied");
});
it("falls back to not-loaded hints when restart fails after uninstall", async () => {
service.isLoaded.mockResolvedValue(false);
service.restart.mockRejectedValue(new Error("launchctl bootstrap failed"));
service.readCommand.mockResolvedValue(null);
await runServiceStart({
serviceNoun: "Gateway",
service,
renderStartHints: () => ["openclaw gateway install"],
opts: { json: true },
});
const payload = readJsonLog<{ ok?: boolean; result?: string; hints?: string[] }>();
expect(payload.ok).toBe(true);
expect(payload.result).toBe("not-loaded");
expect(payload.hints).toEqual(["openclaw gateway install"]);
});
});

View File

@@ -103,6 +103,14 @@ async function handleServiceNotLoaded(params: {
}
}
async function isServiceInstallMissing(service: GatewayService): Promise<boolean> {
try {
return (await service.readCommand(process.env)) === null;
} catch {
return false;
}
}
async function resolveServiceLoadedOrFail(params: {
serviceNoun: string;
service: GatewayService;
@@ -208,18 +216,8 @@ export async function runServiceStart(params: {
if (loaded === null) {
return;
}
if (!loaded) {
await handleServiceNotLoaded({
serviceNoun: params.serviceNoun,
service: params.service,
loaded,
renderStartHints: params.renderStartHints,
json,
emit,
});
return;
}
// Pre-flight config validation (#35862)
// Pre-flight config validation (#35862) — run for both loaded and not-loaded
// to prevent launching from invalid config in any start path.
{
const configError = await getConfigValidationError();
if (configError) {
@@ -229,6 +227,44 @@ export async function runServiceStart(params: {
return;
}
}
if (!loaded) {
// Service was stopped (e.g. `gateway stop` booted out the LaunchAgent).
// Attempt a restart, which handles re-bootstrapping the service. Without
// this, `start` after `stop` just prints hints and does nothing (#53878).
try {
const restartResult = await params.service.restart({ env: process.env, stdout });
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
const postLoaded = await params.service.isLoaded({ env: process.env }).catch(() => true);
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, postLoaded),
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return;
} catch (err) {
// Only fall back to "not loaded" hints when the service install itself is
// gone. If restart failed while install artifacts still exist, surface
// the real error instead of reporting a misleading success.
if (await isServiceInstallMissing(params.service)) {
await handleServiceNotLoaded({
serviceNoun: params.serviceNoun,
service: params.service,
loaded,
renderStartHints: params.renderStartHints,
json,
emit,
});
return;
}
const hints = params.renderStartHints();
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
return;
}
}
try {
const restartResult = await params.service.restart({ env: process.env, stdout });