Skip to content

Commit 12d2153

Browse files
lefarcengithub-actions[bot]claude
authored
fix(desktop): launchd startup reliability hotfix (#544)
* chore(desktop): prepare v0.1.6 * fix(desktop): fix launchd startup reliability — config drift, port race, stale cleanup Root causes fixed: - Plist config drift: after app upgrade, launchd kept using old plist with stale paths/env vars. installService now compares content and re-bootstraps when changed. - White screen on startup: renderer fetched runtimeConfig before cold-start updated ports (web port fallback to OS-assigned). IPC handler now gates on coldStartReady promise. - Stop button ineffective: SIGTERM + KeepAlive = instant respawn. Stop now uses bootout (unregisters from launchd), Start re-bootstraps from plist. - Stale plist cleanup: on startup, compares existing plists against freshly generated ones and cleans up mismatches from old installations/versions. Other fixes: - Packaged mode now passes actual nexuHome for NEXU_HOME validation - Services running without runtime-ports.json are torn down cleanly - Dead Electron PID detected via kill(pid,0) → fresh web port used - Web port fallback uses port:0 (OS-assigned) instead of fragile +1 - EmbeddedWebServer exposes actual bound port - effectivePorts always returned (not just on attach) - Removed dead tryAttachToRunningServices code Tests: 14 new startup scenario smoke tests covering all edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: fix userData path in AGENTS.md full-reset instruction Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8129ff5 commit 12d2153

21 files changed

Lines changed: 2826 additions & 220 deletions

AGENTS.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,21 @@ This repo is desktop-first. Prefer the controller-first path and remove or ignor
6969
- The desktop dev launcher is `apps/desktop/dev.sh`; it is the source of truth for tmux orchestration, sidecar builds, runtime cleanup, and stable repo-local path setup during local development.
7070
- Treat `pnpm start` as the canonical cold-start entrypoint for the full local desktop runtime.
7171
- The active desktop runtime path is controller-first: desktop launches `controller + web + openclaw` and no longer starts local `api`, `gateway`, or `pglite` sidecars.
72-
- Desktop local runtime should not depend on PostgreSQL. In dev mode, all state (config, OpenClaw state, logs) lives under `.tmp/desktop/nexu-home/`, fully isolated from the packaged app's `~/.nexu/`. Launchd plists go to `.tmp/launchd/`, runtime-ports.json also lives there.
73-
- In packaged mode, state lives under `~/.nexu/`, plists under `~/Library/LaunchAgents/`, logs under `~/.nexu/logs/`.
72+
- Desktop local runtime should not depend on PostgreSQL. In dev mode, all state (config, OpenClaw state, logs) lives under `.tmp/desktop/nexu-home/`, fully isolated from the packaged app. Launchd plists go to `.tmp/launchd/`, runtime-ports.json also lives there.
73+
- In packaged mode, data is split across two directories (see table below). Launchd plists go to `~/Library/LaunchAgents/`.
7474
- Local desktop runtime state is repo-scoped under `.tmp/desktop/` in development.
75+
76+
### Packaged app directory layout
77+
78+
| Directory | Purpose | Survives uninstall |
79+
|---|---|---|
80+
| `~/.nexu/` (`NEXU_HOME`) | User config (`config.json`, `cloud-profiles.json`), compiled snapshots, skill ledger, skillhub cache, logs, openclaw-sidecar, `nexu.db` | Yes |
81+
| `~/Library/Application Support/@nexu/desktop/` (Electron `userData`) | OpenClaw runtime state: `runtime/openclaw/state/agents/` (conversations), `runtime/openclaw/state/extensions/` (channel state), `runtime/openclaw/state/skills/`, `runtime/openclaw/state/openclaw.json`, plus Electron internal data (Cache, IndexedDB, etc.) | No (cleaned by uninstall tools) |
82+
83+
The split is intentional: `NEXU_HOME` holds lightweight user preferences that should persist across reinstalls; Electron `userData` holds heavy runtime state tied to the app lifecycle. `OPENCLAW_STATE_DIR` is explicitly set by the desktop launcher to point to the `userData` path — do not rely on the controller's default fallback.
7584
- For startup troubleshooting, use `pnpm logs` to tail dev logs.
76-
- `pnpm reset-state` is a dev-only cleanup shortcut; it stops the stack and removes repo-local desktop runtime state under `.tmp/desktop/`, but it does not delete packaged app state in `~/.nexu/`.
77-
- To fully reset local desktop + controller state, stop the stack, remove `.tmp/desktop/`, then remove `~/.nexu/`.
85+
- `pnpm reset-state` is a dev-only cleanup shortcut; it stops the stack and removes repo-local desktop runtime state under `.tmp/desktop/`, but it does not delete packaged app state.
86+
- To fully reset local desktop + controller state, stop the stack, remove `.tmp/desktop/`, then remove `~/.nexu/` and `~/Library/Application Support/@nexu/desktop/`.
7887
- If `pnpm start` exits immediately because `electron/cli.js` cannot be resolved from `apps/desktop`, validate `pnpm -C apps/desktop exec electron --version` and consult `specs/guides/desktop-runtime-guide.md` before changing the launcher flow.
7988
- Desktop already exposes an agent-friendly runtime observability surface; prefer subscribing/querying before adding temporary UI or ad hoc debug logging.
8089
- For deeper desktop runtime inspection, use the existing event/query path (`onRuntimeEvent(...)`, `runtime:query-events`, `queryRuntimeEvents(...)`) instead of rebuilding one-off diagnostics.

apps/desktop/main/index.ts

Lines changed: 120 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
powerSaveBlocker,
1313
shell,
1414
} from "electron";
15+
import { getOpenclawSkillsDir } from "../shared/desktop-paths";
1516
import type { DesktopChromeMode, DesktopSurface } from "../shared/host";
1617
import { getDesktopRuntimeConfig } from "../shared/runtime-config";
1718
import { getDesktopSentryBuildMetadata } from "../shared/sentry-build-metadata";
@@ -24,8 +25,12 @@ import {
2425
setUpdateManager,
2526
} from "./ipc";
2627
import { RuntimeOrchestrator } from "./runtime/daemon-supervisor";
27-
import { createRuntimeUnitManifests } from "./runtime/manifests";
2828
import {
29+
buildSkillNodePath,
30+
createRuntimeUnitManifests,
31+
} from "./runtime/manifests";
32+
import {
33+
type PortAllocation,
2934
PortAllocationError,
3035
allocateDesktopRuntimePorts,
3136
} from "./runtime/port-allocation";
@@ -36,12 +41,18 @@ import {
3641
} from "./runtime/runtime-logger";
3742
import {
3843
type LaunchdBootstrapResult,
44+
SERVICE_LABELS,
3945
bootstrapWithLaunchd,
4046
getDefaultPlistDir,
47+
getLogDir,
4148
installLaunchdQuitHandler,
4249
isLaunchdBootstrapEnabled,
4350
resolveLaunchdPaths,
4451
} from "./services";
52+
import {
53+
getLegacyNexuHomeStateDir,
54+
migrateOpenclawState,
55+
} from "./services/state-migration";
4556
import { SleepGuard, type SleepGuardLogEntry } from "./sleep-guard";
4657
import { ComponentUpdater } from "./updater/component-updater";
4758
import { StartupHealthCheck } from "./updater/rollback";
@@ -75,19 +86,27 @@ const baseRuntimeConfig = getDesktopRuntimeConfig(process.env, {
7586
resourcesPath: app.isPackaged ? electronRoot : undefined,
7687
useBuildConfig: app.isPackaged,
7788
});
78-
const { allocations: runtimePortAllocations, runtimeConfig } =
79-
await allocateDesktopRuntimePorts(process.env, baseRuntimeConfig).catch(
80-
(error: unknown) => {
81-
if (error instanceof PortAllocationError) {
82-
throw new Error(
83-
`[desktop:ports] ${error.code} purpose=${error.purpose} ` +
84-
`preferredPort=${error.preferredPort ?? "n/a"} ${error.message}`,
85-
);
86-
}
89+
// In launchd mode, skip port probing — the bootstrap has its own port
90+
// recovery via runtime-ports.json and handles leftover processes gracefully.
91+
// Probing here would waste time and the results get overridden by attach anyway.
92+
const useLaunchdMode = isLaunchdBootstrapEnabled();
93+
const { allocations: runtimePortAllocations, runtimeConfig } = useLaunchdMode
94+
? {
95+
allocations: [] as PortAllocation[],
96+
runtimeConfig: baseRuntimeConfig,
97+
}
98+
: await allocateDesktopRuntimePorts(process.env, baseRuntimeConfig).catch(
99+
(error: unknown) => {
100+
if (error instanceof PortAllocationError) {
101+
throw new Error(
102+
`[desktop:ports] ${error.code} purpose=${error.purpose} ` +
103+
`preferredPort=${error.preferredPort ?? "n/a"} ${error.message}`,
104+
);
105+
}
87106

88-
throw error;
89-
},
90-
);
107+
throw error;
108+
},
109+
);
91110
const orchestrator = new RuntimeOrchestrator(
92111
createRuntimeUnitManifests(
93112
electronRoot,
@@ -225,6 +244,14 @@ let diagnosticsReporter: DesktopDiagnosticsReporter | null = null;
225244
let sleepGuard: SleepGuard | null = null;
226245
let launchdResult: LaunchdBootstrapResult | null = null;
227246

247+
// Cold-start gate: IPC handler for `env:get-runtime-config` waits for this
248+
// promise to resolve before returning, ensuring the renderer always gets the
249+
// final config with correct ports (not the pre-cold-start defaults).
250+
let resolveColdStartReady: () => void;
251+
const coldStartReady = new Promise<void>((r) => {
252+
resolveColdStartReady = r;
253+
});
254+
228255
logLaunchTimeline(
229256
`runtime ports ${runtimePortAllocations
230257
.map(
@@ -466,45 +493,107 @@ async function runLaunchdColdStart(): Promise<void> {
466493
const isDev = !app.isPackaged;
467494
const paths = resolveLaunchdPaths(app.isPackaged, electronRoot);
468495

469-
// Derive openclaw paths from nexuHome (must match controller defaults in env.ts)
470496
const nexuHome = runtimeConfig.paths.nexuHome.replace(
471497
/^~/,
472498
process.env.HOME ?? "",
473499
);
474-
const openclawStateDir = resolve(nexuHome, "runtime", "openclaw", "state");
500+
501+
// In packaged mode, keep openclaw state under Electron userData (matches v0.1.5).
502+
// In dev mode, derive from nexuHome for repo-local isolation.
503+
const openclawRuntimeRoot = isDev
504+
? resolve(nexuHome, "runtime", "openclaw")
505+
: resolve(app.getPath("userData"), "runtime", "openclaw");
506+
const openclawStateDir = resolve(openclawRuntimeRoot, "state");
475507
const openclawConfigPath = resolve(openclawStateDir, "openclaw.json");
476508

509+
// Migrate any state created under ~/.nexu during v0.1.6 back to userData path
510+
if (!isDev) {
511+
const legacyStateDir = getLegacyNexuHomeStateDir(
512+
runtimeConfig.paths.nexuHome,
513+
);
514+
if (legacyStateDir !== openclawStateDir) {
515+
migrateOpenclawState({
516+
targetStateDir: openclawStateDir,
517+
sourceStateDir: legacyStateDir,
518+
log: (msg) => logColdStart(`state-migration: ${msg}`),
519+
});
520+
}
521+
}
522+
477523
// In dev mode, serve web app from apps/web/dist
478524
// In packaged mode, serve from resources/web
479525
const webRoot = isDev
480526
? resolve(getWorkspaceRoot(), "apps", "web", "dist")
481527
: resolve(electronRoot, "runtime", "web", "dist");
482528

529+
const repoRoot = getWorkspaceRoot();
530+
const userDataPath = app.getPath("userData");
531+
const openclawSkillsDir = getOpenclawSkillsDir(userDataPath);
532+
const openclawTmpDir = resolve(openclawRuntimeRoot, "tmp");
533+
const openclawBinPath =
534+
process.env.NEXU_OPENCLAW_BIN ?? resolve(paths.openclawCwd, "bin/openclaw");
535+
const openclawPackageRoot = resolve(
536+
paths.openclawCwd,
537+
"node_modules/openclaw",
538+
);
539+
const openclawExtensionsDir = resolve(openclawPackageRoot, "extensions");
540+
const skillhubStaticSkillsDir = app.isPackaged
541+
? resolve(electronRoot, "static/bundled-skills")
542+
: resolve(repoRoot, "apps/desktop/static/bundled-skills");
543+
const platformTemplatesDir = app.isPackaged
544+
? resolve(electronRoot, "static/platform-templates")
545+
: resolve(repoRoot, "apps/controller/static/platform-templates");
546+
const skillNodePath = buildSkillNodePath(electronRoot, app.isPackaged);
547+
483548
launchdResult = await bootstrapWithLaunchd({
484549
isDev,
485550
controllerPort: runtimeConfig.ports.controller,
486551
openclawPort: Number(
487552
new URL(runtimeConfig.urls.openclawBase).port || 18789,
488553
),
489-
nexuHome: isDev ? nexuHome : undefined,
554+
nexuHome,
490555
gatewayToken: isDev ? undefined : runtimeConfig.tokens.gateway,
491556
webPort: runtimeConfig.ports.web,
492557
webRoot,
493558
plistDir: getDefaultPlistDir(isDev),
494559
...paths,
495560
openclawConfigPath,
496561
openclawStateDir,
562+
// Controller-specific env vars
563+
webUrl: runtimeConfig.urls.web,
564+
openclawSkillsDir,
565+
skillhubStaticSkillsDir,
566+
platformTemplatesDir,
567+
openclawBinPath,
568+
openclawExtensionsDir,
569+
skillNodePath,
570+
openclawTmpDir,
497571
});
498572

499-
if (launchdResult.attachedPorts) {
500-
// Attached to existing services — override runtimeConfig with actual ports
501-
const { controllerPort, openclawPort, webPort } =
502-
launchdResult.attachedPorts;
503-
runtimeConfig.ports.controller = controllerPort;
504-
runtimeConfig.ports.web = webPort;
505-
runtimeConfig.urls.controllerBase = `http://127.0.0.1:${controllerPort}`;
506-
runtimeConfig.urls.web = `http://127.0.0.1:${webPort}`;
507-
runtimeConfig.urls.openclawBase = `http://127.0.0.1:${openclawPort}`;
573+
// Wire launchd-managed units into the orchestrator so the control plane
574+
// shows correct status, and Start/Stop buttons work via launchd.
575+
const launchdLogDir = getLogDir(isDev ? nexuHome : undefined);
576+
orchestrator.enableLaunchdMode(
577+
launchdResult.launchd,
578+
{
579+
controller: SERVICE_LABELS.controller(isDev),
580+
openclaw: SERVICE_LABELS.openclaw(isDev),
581+
},
582+
launchdLogDir,
583+
);
584+
585+
// Always sync runtimeConfig with actual effective ports — these may differ
586+
// from the initial config if ports were recovered from a previous session or
587+
// OS-assigned due to conflicts.
588+
const { controllerPort, openclawPort, webPort } =
589+
launchdResult.effectivePorts;
590+
runtimeConfig.ports.controller = controllerPort;
591+
runtimeConfig.ports.web = webPort;
592+
runtimeConfig.urls.controllerBase = `http://127.0.0.1:${controllerPort}`;
593+
runtimeConfig.urls.web = `http://127.0.0.1:${webPort}`;
594+
runtimeConfig.urls.openclawBase = `http://127.0.0.1:${openclawPort}`;
595+
596+
if (launchdResult.isAttach) {
508597
logColdStart(
509598
`attached to running services (controller=${controllerPort} openclaw=${openclawPort} web=${webPort})`,
510599
);
@@ -772,7 +861,7 @@ logLaunchTimeline("electron main module evaluated");
772861
app.whenReady().then(async () => {
773862
logLaunchTimeline("app.whenReady resolved");
774863
installApplicationMenu();
775-
registerIpcHandlers(orchestrator, runtimeConfig);
864+
registerIpcHandlers(orchestrator, runtimeConfig, coldStartReady);
776865
diagnosticsReporter = new DesktopDiagnosticsReporter(orchestrator);
777866
const unsubscribeDiagnostics = diagnosticsReporter.start();
778867
sleepGuard = new SleepGuard({
@@ -797,12 +886,11 @@ app.whenReady().then(async () => {
797886
}
798887

799888
try {
800-
const useLaunchd = isLaunchdBootstrapEnabled();
801889
logColdStart(
802-
`bootstrap mode: ${useLaunchd ? "launchd" : "orchestrator"}`,
890+
`bootstrap mode: ${useLaunchdMode ? "launchd" : "orchestrator"}`,
803891
);
804892

805-
if (useLaunchd) {
893+
if (useLaunchdMode) {
806894
await runLaunchdColdStart();
807895
} else {
808896
await runDesktopColdStart();
@@ -821,6 +909,9 @@ app.whenReady().then(async () => {
821909
logFilePath: getDesktopLogFilePath("cold-start.log"),
822910
windowId: getMainWindowId(),
823911
});
912+
} finally {
913+
// Unblock renderer — it will get the final config (or show error state)
914+
resolveColdStartReady();
824915
}
825916

826917
// Install launchd quit handler regardless of cold-start success/failure

apps/desktop/main/ipc.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ function assertValidChannel(
113113
export function registerIpcHandlers(
114114
orchestrator: RuntimeOrchestrator,
115115
runtimeConfig: DesktopRuntimeConfig,
116+
coldStartReady?: Promise<void>,
116117
): void {
117118
orchestrator.subscribe((runtimeEvent) => {
118119
for (const window of BrowserWindow.getAllWindows()) {
@@ -191,6 +192,9 @@ export function registerIpcHandlers(
191192
}
192193

193194
case "env:get-runtime-config": {
195+
// Wait for cold-start to finish so the renderer gets final ports
196+
// (web port may change due to fallback during bootstrap).
197+
if (coldStartReady) await coldStartReady;
194198
return runtimeConfig;
195199
}
196200

0 commit comments

Comments
 (0)