/** * daemon-control.ts — find the daemon, stop it, wait for it to come back. * * Shared by `mma stop`, `mma restart`, `mma doctor` and `mma update` so all four * agree on what "the daemon" means. Two copies of that answer is how two * commands start disagreeing about the same process. * * HOW IT IDENTIFIES THE DAEMON, and why in this order: * * 1. The pidfile the daemon wrote at startup (`../pidfile.ts`). * 2. Confirmation from the daemon itself: GET /status reports its own pid. A * pid that matches the record is proof, not inference — this is the step * that makes pid reuse harmless, and it works identically on every * platform. * 3. If /status does not answer but the pid is alive, the daemon exists and is * not serving: draining, or wedged. Still the daemon, still stoppable. * 4. Only if there is no usable record: ask the operating system who owns the * port. POSIX only, because it shells out to `lsof`. * * WHAT IT DELIBERATELY DOES NOT DO. It never matches a command-line pattern. * `pkill -f "mma serve"` is what this module exists to replace: it missed any * daemon started as `node .../index.js serve`, and it matched unrelated * processes — including the shell running the kill — whose command line merely * contained the phrase. * * WHY THE PORT LOOKUP FILTERS ON LISTEN. `lsof -ti tcp:7337` returns every * process holding a socket on that port, which includes connected CLIENTS. A * stop built on that list would kill the user's editor along with the daemon. * `-sTCP:LISTEN` restricts it to the one process that owns the socket. */ import { type spawn as SpawnFn } from 'node:child_process'; /** The subset of GET /status this module reads. */ export interface StatusSnapshot { version?: string; pid?: number; uptimeMs?: number; counters?: { activeTasks?: number; }; } /** Where the daemon's identity came from, so callers can report honestly. */ export type DaemonSource = 'pidfile' | 'port-scan'; export interface ResolvedDaemon { pid: number; port: number; bind: string; /** From /status when it answered, otherwise the pidfile's record, otherwise null. */ version: string | null; source: DaemonSource; /** True when GET /status answered. False means the process is alive but not serving. */ reachable: boolean; /** Tasks the daemon reported in flight. Null when it did not answer. */ activeTasks: number | null; } export interface DaemonControlDeps { /** Home-expanded state directory holding the pidfile. */ stateDir: string; /** Base URL of the daemon, e.g. 'http://127.0.0.1:7337'. */ serverUrl: string; /** Bearer token for /status. /health needs none. */ token: string; fetch?: typeof fetch; /** Test seam for the POSIX port-owner lookup. */ lookupPortOwner?: (port: number) => number | null; /** Test seam for signalling. */ kill?: (pid: number, signal: NodeJS.Signals) => void; /** Test seam for liveness. */ isAlive?: (pid: number) => boolean; /** Test seam for "is this pid really a daemon". null = cannot verify (Windows). */ verifyProcess?: (pid: number) => boolean | null; /** Test seam for the clock. */ now?: () => number; /** Test seam for waiting. */ sleep?: (ms: number) => Promise; platform?: NodeJS.Platform; } /** * Is `pid` actually an mma daemon? * * WHY THIS IS NOT OPTIONAL. Every other identity signal here can go stale. A * pidfile survives a crash, and the operating system reuses pids; `lsof` names * whoever holds the port, which after a crash may be a completely unrelated * program. Without this check `mma stop` would signal that program — the very * failure mode the module was written to remove, reintroduced one layer down. * * The technique is the one boot reconciliation already uses before terminating * a worker (`application/reconcile.ts`): read the command line and look for the * signature. Same discipline, same reason. * * @returns true / false on POSIX; `null` on Windows, where there is no cheap * equivalent — callers treat null as "cannot verify" and fall back to * the pidfile, which only a daemon ever writes. */ /** * Does this command line belong to an mma daemon? * * Both halves are required. "serve" alone matches any server; an identity match alone matches * `mma status` or a text editor holding the source file open. * * The identity half must accept how the daemon is ACTUALLY launched, not just its installed name. * `npm run serve` — and every dev, CI, and smoke-harness invocation — runs * `node packages/server/dist/cli/index.js serve`, whose command line contains neither "mma" nor * "multi-model-agent". Those daemons all verified as somebody else's, which made `stopDaemon` * skip its escalation and (once the first signal was guarded too) skip stopping them at all. */ export declare function matchesDaemonCommand(commandLine: string): boolean; export declare function verifyDaemonProcess(pid: number, platform?: NodeJS.Platform): boolean | null; /** * GET /status, or null when the daemon does not answer. * * A short timeout on purpose: every caller is deciding whether a daemon is * there, and a hung socket must not stall a command that has a fallback. */ export declare function probeStatus(serverUrl: string, token: string, fetcher?: typeof fetch, timeoutMs?: number): Promise; /** * Poll GET /health until it answers or the budget runs out. * * /health is auth-exempt and loopback-only (`http/server.ts`), so this needs no * token — which matters, because a caller that has just restarted the daemon * may be running before any token is loaded. */ export declare function waitForHealth(serverUrl: string, timeoutMs?: number, deps?: { fetch?: typeof fetch; now?: () => number; sleep?: (ms: number) => Promise; }): Promise; /** Find the running daemon, or null when nothing is running. */ export declare function resolveDaemon(deps: DaemonControlDeps): Promise; export interface StopOutcome { /** True when the process is gone. */ stopped: boolean; pid: number; /** The strongest signal it took. Reported so a wedged daemon is visible. */ escalatedTo: 'SIGTERM' | 'SIGTERM(second)' | 'SIGKILL' | 'none'; /** * Set when the pid is alive but `ps` says it is not an mma daemon, so NOTHING was signalled. * * This is separate from `stopped: false` after SIGKILL, and callers must not conflate them: one * means "we tried everything and it survived", the other means "we deliberately did not touch * it". Both leave a live process on the pid, which is why neither may report `stopped: true` — * `restart` and `update` read that flag as permission to bind the port. */ notOurs?: boolean; } /** * Stop a daemon and wait for it to actually exit. * * The escalation follows the daemon's OWN contract rather than inventing one. * `cli/serve.ts` treats a first SIGTERM as "drain in-flight work, bounded by * server.limits.shutdownDrainMs" and a SECOND signal as "the operator is done * waiting, exit now". So: signal, wait `graceMs`, signal again, wait, and only * then SIGKILL. A caller that must not wait for a drain passes a small * `graceMs`; a caller that wants work finished passes a large one. * * Waiting is not optional. Returning while the port is still bound is what made * the old `pkill; mma serve` sequence fail: the replacement bound before the * predecessor released, and lost. */ export declare function stopDaemon(pid: number, deps: DaemonControlDeps & { graceMs?: number; killAfterMs?: number; }): Promise; /** * Start a daemon that outlives this command. * * `detached` plus `unref` is what `nohup … &` was doing by hand, minus the two * ways that failed silently: output went to a file nobody read, and a start * that died immediately looked identical to one that worked. Callers pair this * with {@link waitForHealth}, so "started" means the daemon answered, not that * a process was spawned. * * @returns the child pid, or null when the spawn itself failed. */ export declare function startDaemonDetached(deps: { /** Absolute path to the CLI entry point (dist/cli/index.js in a real install). */ cliPath: string; /** Where the daemon's own output goes. */ logPath: string; /** Extra argv after `serve`, e.g. ['--config', '/path']. */ args?: string[]; execPath?: string; /** Node flags the parent was started with. Defaults to this process's own. */ execArgv?: string[]; spawn?: typeof SpawnFn; openLog?: (path: string) => number; }): number | null; /** * The parent's Node flags, minus the ones a detached daemon must not inherit. * * WHY FORWARD THEM AT ALL. `cliPath` is whatever entry point this process was * loaded from. In a real install that is `dist/cli/index.js` and bare `node` * runs it. Under a TypeScript runner it is a `.ts` file, which bare `node` * cannot execute at all — so a restart spawned a process that died instantly. * Forwarding the flags starts the child the same way the parent was started, * which is correct in both cases rather than only in production. * * WHY NOT ALL OF THEM. `--inspect` would make the daemon fight the parent for * the debugger port, and `--eval` would make it run a script instead of the * CLI. Neither is survivable, and both are easy to be running under by * accident. */ export declare function inheritableExecArgv(argv: readonly string[]): string[]; //# sourceMappingURL=daemon-control.d.ts.map