/**
 * StaleSessionBackstop — the unkillability backstop (UNIFIED-SESSION-LIFECYCLE §P5).
 *
 * Rules 1–2 of the robustness bar are deliberately conservative ("can't tell"
 * never means "dead"). That creates a dual risk the round-1 review surfaced:
 *   (a) a session that FAKES work (a tight CPU loop, or a transcript that appends
 *       a heartbeat byte every few minutes) is KEPT by every killer forever;
 *   (b) a session stuck `indeterminate` forever leaks a slot.
 * Both are resolved by a single staleness escalation — NEVER an auto-kill. After
 * M minutes of no-forward-progress, or N consecutive `indeterminate` probes, ONE
 * deduped Attention-queue item is raised for an operator decision
 * (investigate / force-kill?). Dedupe is per EPISODE (a recovered session that
 * later goes stale again raises a fresh item).
 *
 * Forward progress is NOT raw byte growth — a wedged session that appends a
 * heartbeat byte would defeat a naive "any growth" gate forever (exactly the
 * absence-of-signal inference rule 2 forbids). "Progress" = ANY of:
 *   (i)   a MEANINGFUL transcript advance: delta ≥ progressFloorBytes AND the new
 *         tail differs from the prior tail (guards the heartbeat/loop case),
 *   (ii)  main-process CPU above an idle floor,
 *   (iii) a change in the positive-idle / prompt state.
 *
 * A server-unreachable `indeterminate` raises ONE GLOBAL "tmux control-plane
 * unreachable" item (not one per session) — anti-flood. Long-`indeterminate`
 * sessions are flagged so the spawn-path can exclude them from the ABSOLUTE
 * session cap, so a fleet of unverifiable panes can never lock a human out of
 * spawning (the death-spiral cannot relocate here).
 *
 * Signal-only: this backstop never ends a session. It only observes and asks.
 */
export const DEFAULT_STALE_BACKSTOP_OPTIONS = {
    enabled: true,
    tickIntervalSec: 120,
    unverifiableEscalateMinutes: 30,
    conversationalEscalateMinutes: 180,
    indeterminateEscalateCount: 15,
    progressFloorBytes: 512,
    cpuFloorSeconds: 1,
};
export class StaleSessionBackstop {
    deps;
    opts;
    now;
    timer = null;
    obs = new Map();
    globalUnreachable = false;
    globalUnreachableSeq = 0;
    constructor(deps, opts) {
        this.deps = deps;
        this.opts = { ...DEFAULT_STALE_BACKSTOP_OPTIONS, ...(opts ?? {}) };
        this.now = deps.now ?? (() => Date.now());
    }
    start() {
        if (this.timer || !this.opts.enabled)
            return;
        this.timer = setInterval(() => { void this.tick(); }, this.opts.tickIntervalSec * 1000);
        if (typeof this.timer.unref === 'function')
            this.timer.unref();
    }
    stop() {
        if (this.timer) {
            clearInterval(this.timer);
            this.timer = null;
        }
    }
    obsFor(id) {
        let o = this.obs.get(id);
        if (!o) {
            o = { lastSnapshot: null, lastProgressAt: this.now(), indeterminateStreak: 0, episodeActive: false, episodeSeq: 0 };
            this.obs.set(id, o);
        }
        return o;
    }
    /** Run one observation pass. Public so the lifecycle (and tests) can drive it. */
    async tick() {
        if (!this.opts.enabled)
            return;
        const running = this.deps.listRunningSessions();
        const batch = await this.deps.probeLiveness(running.map((s) => s.tmuxSession));
        // Control-plane unreachable: ONE global item, never per-session (anti-flood).
        if (!batch.reachable) {
            if (!this.globalUnreachable) {
                this.globalUnreachable = true;
                this.globalUnreachableSeq++;
                await this.deps.raiseAttention({
                    id: `stale-tmux-unreachable-${this.globalUnreachableSeq}`,
                    title: 'tmux control plane unreachable',
                    summary: 'The tmux server is not answering, so no session can be verified alive or dead. '
                        + 'Sessions are being KEPT (never auto-killed on doubt). Investigate the tmux server / host.',
                    category: 'degradation',
                    priority: 'HIGH',
                });
            }
            return; // do not advance per-session episodes while blind
        }
        this.globalUnreachable = false;
        const liveIds = new Set(running.map((s) => s.id));
        for (const session of running) {
            const v = batch.liveness.get(session.tmuxSession) ?? 'indeterminate';
            const o = this.obsFor(session.id);
            if (v === 'dead') {
                // A dead session is the reapers' business; clear our state for it.
                this.clear(session.id);
                continue;
            }
            if (v === 'indeterminate') {
                o.indeterminateStreak++;
                const isLong = o.indeterminateStreak >= this.opts.indeterminateEscalateCount;
                this.deps.setLongIndeterminate?.(session.id, isLong);
                if (isLong && !o.episodeActive && !this.deps.isProtectedSession?.(session)) {
                    o.episodeActive = true;
                    o.episodeSeq++;
                    await this.escalateSession(session, o, `unverifiable: ${o.indeterminateStreak} consecutive indeterminate probes`);
                }
                continue;
            }
            // v === 'alive' — verifiable again.
            o.indeterminateStreak = 0;
            this.deps.setLongIndeterminate?.(session.id, false);
            const cur = this.deps.snapshot(session);
            const prev = o.lastSnapshot;
            o.lastSnapshot = cur;
            if (!prev) {
                // First sighting — establish the baseline, treat as progress.
                o.lastProgressAt = this.now();
                continue;
            }
            if (this.hasForwardProgress(prev, cur)) {
                o.lastProgressAt = this.now();
                o.episodeActive = false; // recovered — a future stall is a new episode
                continue;
            }
            const stalledMs = this.now() - o.lastProgressAt;
            // Conversational/autonomous sessions get a far more forgiving window than
            // run-to-completion jobs: they legitimately idle between turns and while
            // waiting on long tool calls, so a tight window cries wolf on healthy
            // long-runners. (Never below the job window.) Protected sessions — ones the
            // operator deliberately keeps alive — are never escalated as stale.
            const staleThresholdMin = session.jobSlug
                ? this.opts.unverifiableEscalateMinutes
                : Math.max(this.opts.conversationalEscalateMinutes, this.opts.unverifiableEscalateMinutes);
            if (stalledMs >= staleThresholdMin * 60_000
                && !o.episodeActive
                && !this.deps.isProtectedSession?.(session)) {
                o.episodeActive = true;
                o.episodeSeq++;
                await this.escalateSession(session, o, `no forward progress for ${Math.round(stalledMs / 60_000)} min (transcript static + no CPU + no prompt change) — may be faking work`);
            }
        }
        // Drop obs for sessions that are gone.
        for (const id of [...this.obs.keys()]) {
            if (!liveIds.has(id))
                this.clear(id);
        }
    }
    hasForwardProgress(prev, cur) {
        // (i) Meaningful transcript advance — guards the heartbeat/loop case.
        if (prev.transcriptResolved && cur.transcriptResolved &&
            cur.transcriptSize - prev.transcriptSize >= this.opts.progressFloorBytes &&
            cur.transcriptTailHash != null && cur.transcriptTailHash !== prev.transcriptTailHash) {
            return true;
        }
        // (ii) Real work by the main process.
        //      For JOB sessions (which have no legitimate-idle state — a job runs to
        //      completion) require actual CPU USED in the interval: the cpu-seconds
        //      delta past a small floor. A wedged-but-alive job (process up, 0% CPU)
        //      reads as no-progress here, where the old existence-based check
        //      false-positived it as "active" (the 12h-undetected codex-job wedge).
        //      Conversational sessions keep the existence-based check, which correctly
        //      exempts a legitimately-idle session that happens to have a background
        //      process. `undefined` (uninspectable) is never progress.
        if (cur.isJobSession) {
            if (cur.descendantCpuSeconds - prev.descendantCpuSeconds > this.opts.cpuFloorSeconds)
                return true;
        }
        else if (cur.mainProcessActive === true) {
            return true;
        }
        // (iii) Positive-idle / prompt state changed.
        if (cur.idleStateToken !== prev.idleStateToken)
            return true;
        return false;
    }
    async escalateSession(session, o, detail) {
        // Resolve a friendly topic name so the heads-up reads "the 'EXO 3.0' session",
        // never "topic-19077". Fall back to the session name only if it isn't the
        // useless topic-<n> form.
        const resolved = this.deps.resolveTopicName?.(session) ?? null;
        const display = (resolved && !/^topic-\d+$/.test(resolved))
            ? resolved
            : (!/^topic-\d+$/.test(session.name) ? session.name : (resolved ?? session.name));
        // Route into the calm Agent-Health lane at NORMAL priority. This is a routine
        // self-health observation, not a user-critical alert — so it bundles into the
        // ONE "🩺 Agent Health" topic and never spawns topic-after-topic. The store
        // `id` still carries the episode seq (each episode is recorded), while
        // `healthKey` is stable per session so the lane suppresses duplicate re-posts.
        await this.deps.raiseAttention({
            id: `stale-${session.id}-${o.episodeSeq}`,
            healthKey: `stale-${session.id}`,
            lane: 'agent-health',
            title: `Heads-up on the "${display}" session`,
            summary: `It hasn't shown visible progress in a while (${detail}), so it might be stuck — but it's still `
                + `running and nothing's been killed. Reply "check ${display}" and I'll look, or ignore this if you know it's fine.`,
            category: 'degradation',
            priority: 'NORMAL',
        });
    }
    clear(id) {
        if (this.obs.delete(id))
            this.deps.setLongIndeterminate?.(id, false);
    }
}
//# sourceMappingURL=StaleSessionBackstop.js.map