/**
 * McpProcessReaper — Option B of the MCP-leak fix (Codey's design in
 * `.instar/apprenticeship/codey-task-mcp-leak-reaper.md`).
 *
 * The problem: a session spawns MCP-server children (Playwright, mcp-remote,
 * instar stdio). Killing the session's main pid does NOT cascade to those
 * children — they re-parent (to PID 1 / launchd) or are held by an npm-exec
 * wrapper and survive for days. The fleet accumulated ~80 such leaked procs,
 * up to 5 days old, as a persistent load floor. `OrphanProcessReaper` reaps the
 * session-level CLI proc but not these MCP *descendants*.
 *
 * This is a descendant-aware SWEEP (a sibling reaper, NOT a change to the
 * shared OrphanProcessReaper / ReapGuard path — zero blast radius on the
 * working reaper). For each allow-listed MCP proc it resolves the owning tmux
 * session by walking the ppid chain, and reaps ONLY when the proc is old AND
 * its owning session is a dead/stale *instar* session or fully orphaned. It
 * NEVER touches a proc under a live/tracked session, nor under an external
 * (non-instar) tmux session.
 *
 * Ships DARK + DRY-RUN by default (kills processes — same posture as
 * SessionReaper / AgentWorktreeReaper). Every decision is audited.
 */
import { EventEmitter } from 'node:events';
import { matchMcpSignature } from './mcpProcessSignatures.js';
export const DEFAULT_MCP_PROCESS_REAPER_CONFIG = {
    enabled: false,
    dryRun: true,
    // Conservative: the leaked procs are hours-to-days old; 2h floor means a
    // legitimately busy short-lived MCP child is never a candidate.
    minAgeMs: 2 * 3600 * 1000,
    reapIntervalMs: 30 * 60 * 1000,
    maxReapsPerPass: 25,
    maxAncestorHops: 30,
};
/**
 * Walk the ppid chain from `startPid` until a pid is a tmux pane pid. Returns
 * the owning tmux session name, or null when no tmux ancestor is found within
 * `maxHops` (i.e. the proc is orphaned / re-parented — its session is dead).
 * Pure — cycle-safe via a visited set.
 */
export function resolveOwningSession(startPid, tree, tmuxPaneMap, maxHops) {
    let pid = startPid;
    const seen = new Set();
    for (let hop = 0; hop <= maxHops && pid !== undefined && pid > 1; hop++) {
        if (seen.has(pid))
            break; // cycle guard
        seen.add(pid);
        const session = tmuxPaneMap.get(pid);
        if (session)
            return session;
        pid = tree.get(pid);
    }
    return null;
}
/**
 * Pure per-proc classifier. KEEP unless the proc is old AND its owning session
 * is a dead/stale instar session or fully orphaned. Order matters: the sacred
 * gates (live session, external session) short-circuit before age is consulted.
 */
export function classifyMcpProcess(proc, owningSession, liveSessions, instarSessions, minAgeMs) {
    const base = {
        pid: proc.pid,
        signatureId: proc.signatureId,
        ageMs: proc.elapsedMs,
        owningSession,
    };
    const keep = (reason) => ({ ...base, verdict: 'keep', reason });
    const reap = (reason) => ({ ...base, verdict: 'reap-eligible', reason });
    if (owningSession !== null) {
        // SACRED: a live/tracked session owns it — keep regardless of age. A long-
        // running autonomous session legitimately owns old MCP servers.
        if (liveSessions.has(owningSession))
            return keep('session-live');
        // An external (non-instar) tmux session — never touch the user's processes.
        if (!instarSessions.has(owningSession))
            return keep('external-session');
        // A stale/dead *instar* session still lingering in tmux: reapable when old.
        if (proc.elapsedMs < minAgeMs)
            return keep('stale-instar-too-young');
        return reap(`stale-instar-session:${owningSession}`);
    }
    // No tmux ancestor — the owning session is dead and this proc re-parented.
    // This is the dominant leak shape. Reapable only when old.
    if (proc.elapsedMs < minAgeMs)
        return keep('orphan-too-young');
    return reap('orphaned-no-session');
}
export class McpProcessReaper extends EventEmitter {
    cfg;
    deps;
    now;
    timer;
    running = false;
    lastPassAt = 0;
    reapedLastPass = 0;
    constructor(deps, cfg) {
        super();
        this.deps = deps;
        this.cfg = { ...DEFAULT_MCP_PROCESS_REAPER_CONFIG, ...(cfg ?? {}) };
        this.now = deps.now ?? (() => Date.now());
    }
    start() {
        if (this.timer || !this.cfg.enabled)
            return;
        this.timer = setInterval(() => { void this.reap(); }, this.cfg.reapIntervalMs);
        if (typeof this.timer.unref === 'function')
            this.timer.unref();
    }
    stop() {
        if (this.timer) {
            clearInterval(this.timer);
            this.timer = undefined;
        }
    }
    get killsEnabled() {
        return this.cfg.enabled && !this.cfg.dryRun;
    }
    /** Resolve + classify one proc against the current process/tmux/session view. */
    evaluate(proc, tree, tmuxPaneMap, liveSessions, instarSessions) {
        const owningSession = resolveOwningSession(proc.pid, tree, tmuxPaneMap, this.cfg.maxAncestorHops);
        return classifyMcpProcess(proc, owningSession, liveSessions, instarSessions, this.cfg.minAgeMs);
    }
    /** One reap pass. Returns the per-proc evaluations + what was reaped. */
    async reap() {
        if (this.running)
            return { ts: this.now(), evaluations: [], reaped: [], dryRun: !this.killsEnabled };
        this.running = true;
        const reaped = [];
        const evaluations = [];
        try {
            let procs;
            let tree;
            let tmuxPaneMap;
            let liveSessions;
            let instarSessions;
            try {
                procs = this.deps.listMcpProcesses();
                tree = this.deps.getProcessTree();
                tmuxPaneMap = this.deps.getTmuxPaneMap();
                liveSessions = this.deps.getLiveSessions();
                instarSessions = this.deps.getInstarSessions();
            }
            catch (err) {
                this.emit('error', err);
                return { ts: this.now(), evaluations: [], reaped: [], dryRun: !this.killsEnabled };
            }
            for (const proc of procs) {
                let evaln;
                try {
                    evaln = this.evaluate(proc, tree, tmuxPaneMap, liveSessions, instarSessions);
                }
                catch {
                    // A signal threw — cannot reason about it, so KEEP. Never reap on a
                    // failed evaluation.
                    evaln = {
                        pid: proc.pid, signatureId: proc.signatureId, ageMs: proc.elapsedMs,
                        owningSession: null, verdict: 'keep', reason: 'eval-error',
                    };
                }
                evaluations.push(evaln);
                if (evaln.verdict !== 'reap-eligible') {
                    this.writeAudit({ ts: this.now(), type: 'kept', pid: proc.pid, signatureId: proc.signatureId, ageMs: proc.elapsedMs, owningSession: evaln.owningSession, reason: evaln.reason, dryRun: !this.killsEnabled });
                    continue;
                }
                if (reaped.length >= this.cfg.maxReapsPerPass)
                    continue; // blast-radius cap
                if (!this.killsEnabled) {
                    // Dry-run: classify + audit what we WOULD kill, kill nothing.
                    this.writeAudit({ ts: this.now(), type: 'would-reap', pid: proc.pid, signatureId: proc.signatureId, ageMs: proc.elapsedMs, owningSession: evaln.owningSession, reason: evaln.reason, dryRun: true });
                    continue;
                }
                try {
                    this.deps.killProcess(proc.pid);
                    reaped.push(proc.pid);
                    this.writeAudit({ ts: this.now(), type: 'reaped', pid: proc.pid, signatureId: proc.signatureId, ageMs: proc.elapsedMs, owningSession: evaln.owningSession, reason: evaln.reason, dryRun: false });
                    this.emit('reaped', proc);
                }
                catch (err) {
                    this.emit('error', err);
                }
            }
            this.lastPassAt = this.now();
            this.reapedLastPass = reaped.length;
            this.emit('pass', { evaluations, reaped });
        }
        finally {
            this.running = false;
        }
        return { ts: this.now(), evaluations, reaped, dryRun: !this.killsEnabled };
    }
    writeAudit(entry) {
        try {
            this.deps.audit?.(entry);
        }
        catch { /* audit must never break the loop */ }
    }
    /** Observability snapshot for GET /processes/mcp-reaper (no side effects). */
    snapshot() {
        let processes = [];
        try {
            const procs = this.deps.listMcpProcesses();
            const tree = this.deps.getProcessTree();
            const tmuxPaneMap = this.deps.getTmuxPaneMap();
            const liveSessions = this.deps.getLiveSessions();
            const instarSessions = this.deps.getInstarSessions();
            processes = procs.map((proc) => {
                try {
                    return this.evaluate(proc, tree, tmuxPaneMap, liveSessions, instarSessions);
                }
                catch {
                    return { pid: proc.pid, signatureId: proc.signatureId, ageMs: proc.elapsedMs, owningSession: null, verdict: 'keep', reason: 'eval-error' };
                }
            });
        }
        catch { /* listing failed — report empty, never crash the route */ }
        return {
            enabled: this.cfg.enabled,
            dryRun: this.cfg.dryRun,
            minAgeMs: this.cfg.minAgeMs,
            lastPassAt: this.lastPassAt,
            reapedLastPass: this.reapedLastPass,
            processes,
            reapEligible: processes.filter((p) => p.verdict === 'reap-eligible').length,
        };
    }
}
/** Re-export for production wiring that builds McpProcessInfo from a ps line. */
export { matchMcpSignature };
//# sourceMappingURL=McpProcessReaper.js.map