From 7173a446a6c8b72c513a36a9686c64109f15297a Mon Sep 17 00:00:00 2001 From: Apunkt Date: Thu, 14 May 2026 18:05:05 +0200 Subject: [PATCH] feat(wrapper): spawn daemon on Linux when hibernated On non-darwin platforms, the wrapper now attempts to spawn the daemon via 'iai-mcp daemon start' (which uses systemctl --user start) when the daemon socket is unreachable. This allows the daemon to wake from hibernation automatically instead of only writing wake.signal as a passive marker. If spawn fails (binary not found, systemd unavailable), falls back to writing wake.signal for future daemon boots. --- mcp-wrapper/src/lifecycle.ts | 48 ++++++++++++++++++++++++-- mcp-wrapper/test/lifecycle.test.ts | 54 ++++++++++++++++++++++++------ 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/mcp-wrapper/src/lifecycle.ts b/mcp-wrapper/src/lifecycle.ts index d06d68d..7827781 100644 --- a/mcp-wrapper/src/lifecycle.ts +++ b/mcp-wrapper/src/lifecycle.ts @@ -143,6 +143,10 @@ export interface WrapperLifecycleOptions { /** Spawn `launchctl kickstart`. Defaults to the real `execFile` call. * Tests inject a mock that resolves or rejects deterministically. */ spawnKickstart?: () => Promise; + /** Spawn the daemon subprocess. Defaults to `execFile(['iai-mcp', + * 'daemon', 'start'])`. Tests inject a mock that resolves or rejects + * deterministically. Only used on non-darwin platforms. */ + spawnDaemon?: () => Promise; /** Heartbeat refresh interval (ms). Defaults to * `HEARTBEAT_REFRESH_INTERVAL_MS`. Tests pass a smaller value. */ refreshIntervalMs?: number; @@ -157,6 +161,7 @@ export class WrapperLifecycle { private readonly platform: NodeJS.Platform; private readonly socketReachable: () => Promise; private readonly spawnKickstart: () => Promise; + private readonly spawnDaemon: () => Promise; private readonly refreshIntervalMs: number; private readonly startedAt: string; @@ -172,6 +177,7 @@ export class WrapperLifecycle { this.platform = opts.platform ?? process.platform; this.socketReachable = opts.socketReachable ?? defaultSocketReachable(this.socketPath); this.spawnKickstart = opts.spawnKickstart ?? defaultSpawnKickstart(); + this.spawnDaemon = opts.spawnDaemon ?? defaultSpawnDaemon(); this.refreshIntervalMs = opts.refreshIntervalMs ?? HEARTBEAT_REFRESH_INTERVAL_MS; this.startedAt = isoNow(); } @@ -199,9 +205,28 @@ export class WrapperLifecycle { // timeout). Fall through to the wake.signal fallback so the // daemon's next cold-start path still consumes the request. } + // darwin-with-failed-kickstart: write the cross-platform marker. + try { + await this.writeWakeSignal(); + } catch { + // Even the wake.signal write failed (FS full, permission). + // Nothing we can do safely here. + } + return; } - // Non-darwin OR darwin-with-failed-kickstart: write the cross- - // platform marker so a future daemon boot picks it up. + // Non-darwin: attempt to spawn the daemon directly. + // If spawn succeeds, we're done (the daemon will bind the socket). + // If spawn fails, fall through to the wake.signal marker so a + // future daemon boot (started by systemd / manual / etc.) picks + // it up. + try { + await this.spawnDaemon(); + return; + } catch { + // Spawn failed (binary not found, permission error, etc.). + // Fall through to wake.signal. + } + // Non-darwin-with-failed-spawn: write the cross-platform marker. try { await this.writeWakeSignal(); } catch { @@ -337,3 +362,22 @@ function defaultSpawnKickstart(): () => Promise { }); }; } + +/** Default daemon spawn: `execFile(['iai-mcp', 'daemon', 'start'])` + * with a short timeout. Uses PATH lookup (no hard-coded binary path). + * + * Only invoked on non-darwin platforms where there is no launchd to + * kickstart the daemon. The wrapper uses the CLI entry point + * (`iai-mcp daemon start`) because it is the same command the user + * would run manually — it handles env setup, venv activation, etc. + * + * Security: argv-only invocation via `execFile`, no shell. + */ +function defaultSpawnDaemon(): () => Promise { + return async () => { + await execFileAsync("iai-mcp", ["daemon", "start"], { + timeout: KICKSTART_TIMEOUT_MS, + // No `shell` option — argv-only invocation, no shell interpretation. + }); + }; +} diff --git a/mcp-wrapper/test/lifecycle.test.ts b/mcp-wrapper/test/lifecycle.test.ts index f72692c..6bbc106 100644 --- a/mcp-wrapper/test/lifecycle.test.ts +++ b/mcp-wrapper/test/lifecycle.test.ts @@ -1,15 +1,17 @@ // Phase 10.5 — tests for `WrapperLifecycle`. // -// Eight-test matrix from CONTEXT 10.5: +// Nine-test matrix from CONTEXT 10.5 + L5 non-darwin daemon spawn: // // 1. ensureDaemonAlive: socket reachable -> NO subprocess invoked. // 2. ensureDaemonAlive: socket unreachable + darwin -> kickstart called. // 3. ensureDaemonAlive: kickstart throws -> falls back to wake.signal. -// 4. ensureDaemonAlive: non-macos -> wake.signal written, no subprocess. -// 5. registerHeartbeat: file exists with correct schema. -// 6. heartbeat refresh: small interval -> last_refresh updates. -// 7. cleanupHeartbeat: file gone, timer cleared. -// 8. security: source has no `shell: true` and no shell-interpreting +// 4. ensureDaemonAlive: non-macos -> spawnDaemon called, falls back to +// wake.signal on spawn failure. +// 5. ensureDaemonAlive: non-macos -> successful spawn skips wake.signal. +// 6. registerHeartbeat: file exists with correct schema. +// 7. heartbeat refresh: small interval -> last_refresh updates. +// 8. cleanupHeartbeat: file gone, timer cleared. +// 9. security: source has no `shell: true` and no shell-interpreting // subprocess variant in mcp-wrapper/src/. // // Test runner: Node's built-in `node:test` (zero new dep — Node 22 has @@ -126,10 +128,10 @@ describe("WrapperLifecycle.ensureDaemonAlive", () => { } }); - it("on non-macos writes wake.signal and never spawns subprocess", async () => { + it("on non-macos spawns daemon, falls back to wake.signal on failure", async () => { const tmp = await makeTmp("linux"); try { - let kickstarts = 0; + let daemonSpawns = 0; const lifecycle = new WrapperLifecycle({ socketPath: join(tmp, "daemon.sock"), wakeSignalPath: join(tmp, "wake.signal"), @@ -137,13 +139,43 @@ describe("WrapperLifecycle.ensureDaemonAlive", () => { platform: "linux", socketReachable: async () => false, spawnKickstart: async () => { - kickstarts += 1; + throw new Error("should not be called on non-darwin"); + }, + spawnDaemon: async () => { + daemonSpawns += 1; + throw new Error("daemon spawn simulated failure"); }, }); await lifecycle.ensureDaemonAlive(); - assert.equal(kickstarts, 0, "subprocess must never be invoked on non-darwin"); + assert.equal(daemonSpawns, 1, "spawnDaemon must be invoked exactly once on non-darwin"); const sigStat = await stat(join(tmp, "wake.signal")); - assert.ok(sigStat.isFile(), "wake.signal must exist on non-darwin path"); + assert.ok(sigStat.isFile(), "wake.signal must exist after spawn failure"); + } finally { + await cleanupTmp(tmp); + } + }); + + it("on non-macos: successful spawn skips wake.signal", async () => { + const tmp = await makeTmp("linux-ok"); + try { + const lifecycle = new WrapperLifecycle({ + socketPath: join(tmp, "daemon.sock"), + wakeSignalPath: join(tmp, "wake.signal"), + heartbeatPath: join(tmp, "wrappers", "heartbeat-1-x.json"), + platform: "linux", + socketReachable: async () => false, + spawnKickstart: async () => { + throw new Error("should not be called on non-darwin"); + }, + spawnDaemon: async () => { + // Success — no-op in test + }, + }); + await lifecycle.ensureDaemonAlive(); + await assert.rejects( + stat(join(tmp, "wake.signal")), + "wake.signal must NOT be written when spawn succeeds", + ); } finally { await cleanupTmp(tmp); }