/**
 * ReapGuard — the shared, stateless "is it safe to reap this session?" guard.
 *
 * Spec: docs/specs/unified-session-lifecycle-robustness.md §P2.
 *
 * Extracted from `SessionReaper.evaluate()` so that EVERY autonomous killer —
 * not just the careful new reaper — consults the same positive-evidence KEEP
 * checks before a session is ended. The single ReapAuthority
 * (`SessionManager.terminateSession`) calls this guard; a non-null result means
 * the session must be KEPT and the terminate is a no-op.
 *
 * Scope is the STATELESS guards only (deps-driven, no per-tick observation
 * state): protected, spawn-grace, recovery-in-flight, pending-injection,
 * relay-lease, recent-user-message, open-commitment, active-subagent,
 * structural-long-work, active-process, main-process-uninspectable/active.
 *
 * The STATEFUL checks — transcript-growth (compares against the previous tick)
 * and positive-idle proof (needs a captured pane frame) — stay inside
 * `SessionReaper.evaluate()`, which calls this guard FIRST (preserving the exact
 * keptBy ordering) and then layers its own per-instance checks.
 *
 * Ordering is identical to the original `evaluate()` so a refactor preserves
 * every `keptBy` reason for the same input (asserted by the reaper's tests).
 */
export const DEFAULT_REAP_GUARD_OPTIONS = {
    minAgeMs: 30 * 60_000,
    recentUserWindowMs: 30 * 60_000,
    protectOpenCommitments: true,
    staleCommitmentWindowMs: 8 * 60 * 60_000, // 8h — "silent 8h ⇒ stale" (operator: restarts are cheap, prefer free resources)
};
export class ReapGuard {
    deps;
    opts;
    now;
    constructor(deps, opts) {
        this.deps = deps;
        this.opts = { ...DEFAULT_REAP_GUARD_OPTIONS, ...(opts ?? {}) };
        this.now = deps.now ?? (() => Date.now());
    }
    /**
     * Returns the reason this session must be KEPT (not reaped), or `null` if all
     * stateless guards clear. Cheap-first ordering: in-memory checks before any
     * subprocess fork (process tree / main-process probe) so a guarded session is
     * rejected without paying for a capture.
     *
     * Never throws — a thrown signal source is caught and resolved to KEEP
     * ('guard-error'), because we can never reason about a session we cannot
     * inspect, and the safe answer is always "keep".
     */
    blockedReason(session) {
        try {
            return this.evaluate(session);
        }
        catch {
            return { reason: 'guard-error', confidence: 'low' };
        }
    }
    evaluate(session) {
        const keep = (reason, confidence = 'high') => ({
            reason,
            confidence,
        });
        // ── Cheap, in-memory guards first (no subprocess fork) ──
        // A. Protected set.
        if (this.deps.protectedSessions().includes(session.tmuxSession))
            return keep('protected');
        // M. Spawn grace — a freshly-spawned session that hasn't had time to produce
        //    output yet must not be reaped. Skipped when minAgeMs is 0.
        if (this.opts.minAgeMs > 0 && session.startedAt) {
            const ageMs = this.now() - Date.parse(session.startedAt);
            if (!(ageMs >= this.opts.minAgeMs))
                return keep('spawn-grace');
        }
        // G. Recovery in flight — a kill-to-respawn is mid-flight; never race it.
        if (this.deps.isRecoveryActive(session))
            return keep('recovery-in-flight');
        // H. Pending injection (a message is waiting to be delivered to the session).
        if (this.deps.hasPendingInjection(session.tmuxSession))
            return keep('pending-injection');
        // Relay lease (an agent-to-agent relay holds this session).
        if (this.deps.isRelayLeaseActive(session.id))
            return keep('relay-lease');
        const topicId = this.deps.topicBinding(session.tmuxSession);
        // I. Recent user interaction on the bound topic.
        if (topicId != null && this.deps.recentUserMessage(topicId, this.opts.recentUserWindowMs)) {
            return keep('recent-user-message');
        }
        // J. Open commitment on the bound topic — but only while still recently active.
        //    Guard I above already kept on a message within `recentUserWindowMs`; this
        //    widens the commitment veto to the longer `staleCommitmentWindowMs` horizon.
        //    Past that horizon (no user message — Justin's "no message today" rule) the
        //    commitment is abandoned and must NOT pin the dead session, so we fall
        //    through to the activeness guards below. (2026-06-06 grounding: open-commitment
        //    was the dominant keep-reason — 19/26 sessions, many 26h-idle — that blocked
        //    all idle-session reaping.)
        if (this.opts.protectOpenCommitments && topicId != null && this.deps.activeCommitmentForTopic(topicId)) {
            if (this.deps.recentUserMessage(topicId, this.opts.staleCommitmentWindowMs)) {
                return keep('open-commitment');
            }
            // else: stale commitment — do not veto; continue to activeness guards.
        }
        // K. Active subagent spawned by this session.
        if (this.deps.activeSubagentCount(session.claudeSessionId) > 0)
            return keep('active-subagent');
        // L. Structural long-work (build / autonomous) on the topic/project.
        if (this.deps.buildOrAutonomousActive(topicId))
            return keep('structural-long-work');
        // ── Activeness (positive-evidence) — may fork a subprocess ──
        // C. Process tree: any non-baseline child ⇒ working.
        if (this.deps.hasActiveProcesses(session.tmuxSession))
            return keep('active-process');
        // C(main). Main-process CPU/IO delta. undefined ⇒ cannot inspect ⇒ KEEP (low).
        if (this.deps.mainProcessActive) {
            const mp = this.deps.mainProcessActive(session.tmuxSession);
            if (mp === undefined)
                return keep('process-uninspectable', 'low');
            if (mp === true)
                return keep('main-process-active');
        }
        // All stateless guards clear. (The reaper layers transcript-growth +
        // positive-idle after this; other killers proceed to terminate.)
        return null;
    }
    /**
     * Observe-only work-evidence collection (reap-notify spec R2.1) — the
     * CHOKEPOINT FALLBACK when the killer supplied no evidence of its own.
     *
     * Documented expected-empty for guard-cleared kills: an autonomous kill
     * reaches the chokepoint's body only when these same closures returned
     * nothing (or a named bypass fired), so a re-run here usually proves
     * nothing — real evidence comes from the killer at its decision point.
     *
     * Differences from `blockedReason` (deliberate, per spec):
     *  - Collects ALL work-POSITIVE signals, not first-hit; protected /
     *    spawn-grace / recovery-in-flight are not work evidence and are skipped.
     *  - A closure that throws contributes NOTHING (no keep-true fail-safe:
     *    "cannot inspect ⇒ keep" is correct for blocking a kill, wrong for
     *    asserting work happened).
     *  - With `skipForkChecks` (pressure tier critical), the fork-based
     *    closures (process tree, main-process probe) are not run and the
     *    `unverified-under-pressure` marker is stamped instead — a record
     *    that verification was skipped, never resume-eligible.
     */
    workEvidence(session, opts) {
        const out = [];
        const probe = (fn, name) => {
            try {
                if (fn())
                    out.push(name);
            }
            catch {
                // @silent-fallback-ok — closure error ⇒ no evidence from this closure
                // (never keep-true here); the evidence snapshot must never throw into
                // a killer's decision path (reap-notify R2.1).
            }
        };
        probe(() => this.deps.hasPendingInjection(session.tmuxSession), 'pending-injection');
        probe(() => this.deps.isRelayLeaseActive(session.id), 'relay-lease');
        let topicId = null;
        try {
            topicId = this.deps.topicBinding(session.tmuxSession);
        }
        catch {
            // @silent-fallback-ok — unresolvable binding ⇒ the topic-scoped probes
            // are skipped; the session-scoped probes still run (same never-throw
            // contract as `probe` above).
            topicId = null;
        }
        if (topicId != null) {
            const boundTopicId = topicId;
            probe(() => this.deps.recentUserMessage(boundTopicId, this.opts.recentUserWindowMs), 'recent-user-message');
            // Open commitment — gated by the SAME staleness horizon evaluate()'s KEEP
            // check uses (guard J above). A commitment counts as in-flight-work evidence
            // ONLY while the topic has had a user message within staleCommitmentWindowMs;
            // past that, evaluate() treats the commitment as abandoned and lets the
            // session die — so workEvidence() must NOT re-assert it as work. Without this
            // gate the two methods disagree on the same commitment: an idle session is
            // killed (evaluate: stale ⇒ reap) then immediately revived (workEvidence:
            // open-commitment ⇒ resume-eligible), forever. They MUST agree on what an
            // open commitment means. (2026-06-13: 13 idle sessions across 6 topics were
            // age-killed + revived in a loop, every one tagged solely [open-commitment]
            // on a commitment the KEEP-guard had already judged stale.)
            probe(() => this.opts.protectOpenCommitments &&
                this.deps.activeCommitmentForTopic(boundTopicId) &&
                this.deps.recentUserMessage(boundTopicId, this.opts.staleCommitmentWindowMs), 'open-commitment');
        }
        probe(() => this.deps.activeSubagentCount(session.claudeSessionId) > 0, 'active-subagent');
        probe(() => this.deps.buildOrAutonomousActive(topicId), 'structural-long-work');
        if (opts?.skipForkChecks) {
            out.push('unverified-under-pressure');
            return out;
        }
        probe(() => this.deps.hasActiveProcesses(session.tmuxSession), 'active-process');
        if (this.deps.mainProcessActive) {
            probe(() => this.deps.mainProcessActive(session.tmuxSession) === true, 'main-process-active');
        }
        return out;
    }
}
//# sourceMappingURL=ReapGuard.js.map