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.
This commit is contained in:
Apunkt 2026-05-14 18:05:05 +02:00
parent a31bbd7f58
commit 7173a446a6
No known key found for this signature in database
2 changed files with 89 additions and 13 deletions

View file

@ -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<void>;
/** 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<void>;
/** 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<boolean>;
private readonly spawnKickstart: () => Promise<void>;
private readonly spawnDaemon: () => Promise<void>;
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<void> {
});
};
}
/** 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<void> {
return async () => {
await execFileAsync("iai-mcp", ["daemon", "start"], {
timeout: KICKSTART_TIMEOUT_MS,
// No `shell` option — argv-only invocation, no shell interpretation.
});
};
}