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:
parent
a31bbd7f58
commit
7173a446a6
2 changed files with 89 additions and 13 deletions
|
|
@ -143,6 +143,10 @@ export interface WrapperLifecycleOptions {
|
||||||
/** Spawn `launchctl kickstart`. Defaults to the real `execFile` call.
|
/** Spawn `launchctl kickstart`. Defaults to the real `execFile` call.
|
||||||
* Tests inject a mock that resolves or rejects deterministically. */
|
* Tests inject a mock that resolves or rejects deterministically. */
|
||||||
spawnKickstart?: () => Promise<void>;
|
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). Defaults to
|
||||||
* `HEARTBEAT_REFRESH_INTERVAL_MS`. Tests pass a smaller value. */
|
* `HEARTBEAT_REFRESH_INTERVAL_MS`. Tests pass a smaller value. */
|
||||||
refreshIntervalMs?: number;
|
refreshIntervalMs?: number;
|
||||||
|
|
@ -157,6 +161,7 @@ export class WrapperLifecycle {
|
||||||
private readonly platform: NodeJS.Platform;
|
private readonly platform: NodeJS.Platform;
|
||||||
private readonly socketReachable: () => Promise<boolean>;
|
private readonly socketReachable: () => Promise<boolean>;
|
||||||
private readonly spawnKickstart: () => Promise<void>;
|
private readonly spawnKickstart: () => Promise<void>;
|
||||||
|
private readonly spawnDaemon: () => Promise<void>;
|
||||||
private readonly refreshIntervalMs: number;
|
private readonly refreshIntervalMs: number;
|
||||||
|
|
||||||
private readonly startedAt: string;
|
private readonly startedAt: string;
|
||||||
|
|
@ -172,6 +177,7 @@ export class WrapperLifecycle {
|
||||||
this.platform = opts.platform ?? process.platform;
|
this.platform = opts.platform ?? process.platform;
|
||||||
this.socketReachable = opts.socketReachable ?? defaultSocketReachable(this.socketPath);
|
this.socketReachable = opts.socketReachable ?? defaultSocketReachable(this.socketPath);
|
||||||
this.spawnKickstart = opts.spawnKickstart ?? defaultSpawnKickstart();
|
this.spawnKickstart = opts.spawnKickstart ?? defaultSpawnKickstart();
|
||||||
|
this.spawnDaemon = opts.spawnDaemon ?? defaultSpawnDaemon();
|
||||||
this.refreshIntervalMs = opts.refreshIntervalMs ?? HEARTBEAT_REFRESH_INTERVAL_MS;
|
this.refreshIntervalMs = opts.refreshIntervalMs ?? HEARTBEAT_REFRESH_INTERVAL_MS;
|
||||||
this.startedAt = isoNow();
|
this.startedAt = isoNow();
|
||||||
}
|
}
|
||||||
|
|
@ -199,9 +205,28 @@ export class WrapperLifecycle {
|
||||||
// timeout). Fall through to the wake.signal fallback so the
|
// timeout). Fall through to the wake.signal fallback so the
|
||||||
// daemon's next cold-start path still consumes the request.
|
// 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-
|
// Non-darwin: attempt to spawn the daemon directly.
|
||||||
// platform marker so a future daemon boot picks it up.
|
// 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 {
|
try {
|
||||||
await this.writeWakeSignal();
|
await this.writeWakeSignal();
|
||||||
} catch {
|
} 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.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
// Phase 10.5 — tests for `WrapperLifecycle`.
|
// 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.
|
// 1. ensureDaemonAlive: socket reachable -> NO subprocess invoked.
|
||||||
// 2. ensureDaemonAlive: socket unreachable + darwin -> kickstart called.
|
// 2. ensureDaemonAlive: socket unreachable + darwin -> kickstart called.
|
||||||
// 3. ensureDaemonAlive: kickstart throws -> falls back to wake.signal.
|
// 3. ensureDaemonAlive: kickstart throws -> falls back to wake.signal.
|
||||||
// 4. ensureDaemonAlive: non-macos -> wake.signal written, no subprocess.
|
// 4. ensureDaemonAlive: non-macos -> spawnDaemon called, falls back to
|
||||||
// 5. registerHeartbeat: file exists with correct schema.
|
// wake.signal on spawn failure.
|
||||||
// 6. heartbeat refresh: small interval -> last_refresh updates.
|
// 5. ensureDaemonAlive: non-macos -> successful spawn skips wake.signal.
|
||||||
// 7. cleanupHeartbeat: file gone, timer cleared.
|
// 6. registerHeartbeat: file exists with correct schema.
|
||||||
// 8. security: source has no `shell: true` and no shell-interpreting
|
// 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/.
|
// subprocess variant in mcp-wrapper/src/.
|
||||||
//
|
//
|
||||||
// Test runner: Node's built-in `node:test` (zero new dep — Node 22 has
|
// 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");
|
const tmp = await makeTmp("linux");
|
||||||
try {
|
try {
|
||||||
let kickstarts = 0;
|
let daemonSpawns = 0;
|
||||||
const lifecycle = new WrapperLifecycle({
|
const lifecycle = new WrapperLifecycle({
|
||||||
socketPath: join(tmp, "daemon.sock"),
|
socketPath: join(tmp, "daemon.sock"),
|
||||||
wakeSignalPath: join(tmp, "wake.signal"),
|
wakeSignalPath: join(tmp, "wake.signal"),
|
||||||
|
|
@ -137,13 +139,43 @@ describe("WrapperLifecycle.ensureDaemonAlive", () => {
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
socketReachable: async () => false,
|
socketReachable: async () => false,
|
||||||
spawnKickstart: async () => {
|
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();
|
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"));
|
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 {
|
} finally {
|
||||||
await cleanupTmp(tmp);
|
await cleanupTmp(tmp);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue