/**
 * Detects macOS/Linux sleep/wake events via timer drift.
 *
 * When the system sleeps, setInterval timers stop. On wake, the
 * time elapsed between ticks will be much larger than expected.
 * We detect this drift and fire a callback.
 *
 * Ported from Dawn's infrastructure — battle-tested in production.
 *
 * ## CPU-starvation guard (2026-05-28)
 *
 * Timer drift has TWO causes, not one:
 *   1. Real sleep — the OS suspends the process; the wall clock jumps forward.
 *   2. CPU starvation — the machine is so oversubscribed (load >> cores) that the
 *      event loop can't service the `checkIntervalMs` timer on time. The wall
 *      clock advances normally, but the callback fires seconds late.
 *
 * The original detector could not tell these apart, so on a heavily-loaded box
 * (e.g. many concurrent agent sessions) it fired hundreds of false "wake" events
 * — each triggering expensive wake-recovery (tunnel restart, re-registration,
 * failure-counter resets) that piled MORE load on, a self-reinforcing storm.
 *
 * The guard distinguishes the two:
 *   - A **long** drift (>= `longSleepFloorSeconds`) is unambiguously real sleep —
 *     a live machine never starves its own event loop for minutes (a watchdog
 *     would declare it dead first). Always emitted, regardless of load.
 *   - A **short** drift under high system load (`loadavg[0] / cpuCount >
 *     maxLoadRatio`) is treated as CPU starvation and SUPPRESSED — no `wake`.
 *   - A short drift under normal load is a brief real sleep — emitted.
 * On top of classification, a `minWakeIntervalMs` cooldown caps the emit rate so
 * even a misclassified burst can't trigger a recovery storm.
 *
 * ## Recurring-drift guard for the MODERATE-load band (2026-06-15)
 *
 * The load guard (`maxLoadRatio`, default 1.5) and the consecutive-drift burst floor
 * together miss one real cascade: a host oversubscribed only MODERATELY — load just
 * above 1.0/core but below `maxLoadRatio` — stalls its event loop for tens of seconds
 * *intermittently*, ~every couple of minutes. Because on-time ticks fall between those
 * drifts, the consecutive counter resets (so the burst floor never trips), and because
 * the 1-minute `loadavg[0]` sits below 1.5, the load guard never trips either. Each
 * isolated drift then emits a FALSE wake, and the ~2-minute cadence outlasts the
 * `minWakeIntervalMs` cooldown — the 2026-06-15 multi-machine cascade (loadRatio ~1.1/core,
 * `Wake detected after ~33s sleep` roughly every 2 minutes while the host was in use).
 *
 * The recurring-drift guard closes that band: a SHORT drift within `recentDriftWindowMs`
 * of a PRIOR short drift, while `loadRatio > recentDriftLoadFloor`, is recurring
 * starvation and is suppressed — generalizing the burst floor from *consecutive* ticks
 * to *recent* ticks. A genuine isolated sleep (no recent prior drift) and any drift on a
 * lightly-loaded host (ratio <= the floor) are unaffected and still emit; long sleeps are
 * always exempt. Set `recentDriftWindowMs: 0` to disable.
 *
 * Suppressed events are NOT added to `wakeHistory`, so `getCumulativeSleepMsBetween`
 * (the wake-reaper's sleep-credit source) only ever counts genuine sleep.
 */
import { EventEmitter } from 'node:events';
import os from 'node:os';
export class SleepWakeDetector extends EventEmitter {
    interval = null;
    lastTick;
    lastEmittedWakeAtMs = null;
    checkIntervalMs;
    driftThresholdMs;
    maxLoadRatio;
    longSleepFloorSeconds;
    minWakeIntervalMs;
    driftBurstSuppressFloor;
    recentDriftWindowMs;
    recentDriftLoadFloor;
    /** Wall-clock (ms) of the most recent SHORT drift, emitted or suppressed. Drives the
     *  recurring-drift guard: a fresh short drift within `recentDriftWindowMs` of this,
     *  under elevated load, is recurring starvation. Null until the first short drift. */
    lastShortDriftAtMs = null;
    /** Count of BACK-TO-BACK drift ticks (reset by any on-time tick). A real sleep is
     *  ONE isolated drift; sustained CPU starvation produces consecutive drifts. The
     *  Nth+ consecutive drift is a storm, not a sleep, and is suppressed regardless of
     *  the (lagging, fluctuating) 1-min load ratio — the gap maxLoadRatio alone missed
     *  (2026-06-07: tunnel-restart storm from 10-42s drifts firing whenever loadRatio
     *  momentarily dipped below maxLoadRatio). */
    consecutiveDrifts = 0;
    loadAvgProvider;
    cpuCountProvider;
    now;
    wakeHistory = [];
    suppressionHistory = [];
    constructor(config = {}) {
        super();
        this.checkIntervalMs = config.checkIntervalMs ?? 2000;
        this.driftThresholdMs = config.driftThresholdMs ?? 10000;
        this.maxLoadRatio = config.maxLoadRatio ?? 1.5;
        this.longSleepFloorSeconds = config.longSleepFloorSeconds ?? 300;
        this.minWakeIntervalMs = config.minWakeIntervalMs ?? 60000;
        this.driftBurstSuppressFloor = config.driftBurstSuppressFloor ?? 2;
        this.recentDriftWindowMs = config.recentDriftWindowMs ?? 300000;
        this.recentDriftLoadFloor = config.recentDriftLoadFloor ?? 1.0;
        this.loadAvgProvider = config.loadAvgProvider ?? (() => os.loadavg());
        this.cpuCountProvider = config.cpuCountProvider ?? (() => os.cpus().length);
        this.now = config.nowProvider ?? (() => Date.now());
        this.lastTick = this.now();
    }
    start() {
        if (this.interval)
            return;
        this.lastTick = this.now();
        this.interval = setInterval(() => {
            const now = this.now();
            const elapsed = now - this.lastTick;
            this.lastTick = now;
            if (elapsed <= this.driftThresholdMs) {
                this.consecutiveDrifts = 0;
                return;
            } // on-time tick → not starving
            const sleepDuration = Math.round((elapsed - this.checkIntervalMs) / 1000);
            const isLongSleep = sleepDuration >= this.longSleepFloorSeconds;
            const loadRatio = this.currentLoadRatio();
            this.consecutiveDrifts += 1;
            // Record every SHORT drift's time (emitted or suppressed) so the recurring-drift
            // guard below can measure recurrence; capture the prior value first for the check.
            const prevShortDriftAtMs = this.lastShortDriftAtMs;
            if (!isLongSleep)
                this.lastShortDriftAtMs = now;
            // Consecutive-drift burst = sustained CPU starvation, not sleep. A genuine sleep
            // is ONE isolated drift (the next on-time tick resets the counter); the 2nd+
            // back-to-back SHORT drift is a storm. Suppress it regardless of the (lagging,
            // fluctuating) load ratio — this catches the drifts maxLoadRatio missed when the
            // 1-min average momentarily dipped below the threshold (2026-06-07 tunnel-restart
            // storm). A genuine LONG sleep (>= longSleepFloorSeconds) is exempt — it always
            // emits (real-sleep recovery is essential), and the FIRST short drift still falls
            // through to the checks below, so an isolated real wake is unaffected.
            if (!isLongSleep && this.driftBurstSuppressFloor > 0 && this.consecutiveDrifts >= this.driftBurstSuppressFloor) {
                this.recordSuppression('cpu-starvation', sleepDuration, loadRatio, now);
                console.warn(`[SleepWakeDetector] Drift ~${sleepDuration}s — consecutive drift #${this.consecutiveDrifts} ` +
                    `(>= ${this.driftBurstSuppressFloor}) = starvation burst, suppressing wake`);
                return;
            }
            // Short drift under heavy CPU load = event-loop starvation, not sleep.
            if (!isLongSleep && loadRatio > this.maxLoadRatio) {
                this.recordSuppression('cpu-starvation', sleepDuration, loadRatio, now);
                console.warn(`[SleepWakeDetector] Drift ~${sleepDuration}s under load ratio ${loadRatio.toFixed(2)} ` +
                    `(> ${this.maxLoadRatio}) — treating as CPU starvation, suppressing wake`);
                return;
            }
            // Recurring short drift under MODERATE load (the band the two guards above miss):
            // a short drift within recentDriftWindowMs of a PRIOR short drift, while load is
            // above recentDriftLoadFloor (oversubscribed) but below maxLoadRatio, is recurring
            // CPU starvation — the 2026-06-15 cascade where loadRatio sat ~1.1/core (under the
            // 1.5 hard guard) yet the loop stalled every ~2min, and on-time ticks between the
            // drifts reset the consecutive counter so the burst floor never tripped. A genuine
            // isolated sleep (no recent prior drift) and any drift on a lightly-loaded host
            // (ratio <= floor) still emit; long sleeps are exempt (handled above).
            if (!isLongSleep &&
                this.recentDriftWindowMs > 0 &&
                loadRatio > this.recentDriftLoadFloor &&
                prevShortDriftAtMs !== null &&
                now - prevShortDriftAtMs < this.recentDriftWindowMs) {
                this.recordSuppression('cpu-starvation', sleepDuration, loadRatio, now);
                console.warn(`[SleepWakeDetector] Recurring short drift ~${sleepDuration}s within ` +
                    `${this.recentDriftWindowMs}ms of a prior short drift at load ratio ` +
                    `${loadRatio.toFixed(2)} (> ${this.recentDriftLoadFloor}) — recurring CPU ` +
                    `starvation, suppressing wake`);
                return;
            }
            // Rate-limit emitted wakes so even a misclassified burst can't storm
            // recovery. Long sleeps bypass the cooldown — recovery there is essential.
            if (!isLongSleep &&
                this.lastEmittedWakeAtMs !== null &&
                now - this.lastEmittedWakeAtMs < this.minWakeIntervalMs) {
                this.recordSuppression('cooldown', sleepDuration, loadRatio, now);
                console.warn(`[SleepWakeDetector] Wake within cooldown ` +
                    `(${now - this.lastEmittedWakeAtMs}ms < ${this.minWakeIntervalMs}ms) — suppressing duplicate recovery`);
                return;
            }
            console.log(`[SleepWakeDetector] Wake detected after ~${sleepDuration}s sleep`);
            const event = { sleepDurationSeconds: sleepDuration, timestamp: new Date(now).toISOString() };
            this.wakeHistory.push(event);
            if (this.wakeHistory.length > 100)
                this.wakeHistory.shift();
            this.lastEmittedWakeAtMs = now;
            this.emit('wake', event);
        }, this.checkIntervalMs);
        this.interval.unref(); // Don't prevent process exit in CLI contexts
    }
    stop() {
        if (this.interval) {
            clearInterval(this.interval);
            this.interval = null;
        }
    }
    /** Current `loadavg[0] / cpuCount` ratio. Returns 0 when load is unavailable
     *  (e.g. Windows reports [0,0,0]), which disables the starvation guard there. */
    currentLoadRatio() {
        let cpuCount = 1;
        try {
            cpuCount = Math.max(1, this.cpuCountProvider());
        }
        catch {
            cpuCount = 1;
        }
        let load1 = 0;
        try {
            load1 = this.loadAvgProvider()[0] ?? 0;
        }
        catch {
            load1 = 0;
        }
        if (!Number.isFinite(load1) || load1 <= 0)
            return 0;
        return load1 / cpuCount;
    }
    recordSuppression(reason, driftSeconds, loadRatio, nowMs) {
        this.suppressionHistory.push({
            reason,
            driftSeconds,
            loadRatio: Math.round(loadRatio * 100) / 100,
            timestamp: new Date(nowMs).toISOString(),
        });
        if (this.suppressionHistory.length > 100)
            this.suppressionHistory.shift();
    }
    /**
     * Cumulative wall-time-asleep during the half-open window [startMs, endMs).
     * Used by the wake-reaper (UNIFIED-SESSION-LIFECYCLE §P0 #9 / SE-8) to subtract
     * sleep that overlapped a job run rather than relying on the single last
     * `sleepDurationSeconds` event — a run that started before multiple sleeps was
     * previously credited only the last sleep's duration and reaped early.
     *
     * Each wake event's sleep window is approximated as
     *   [wakeTimestamp − sleepDurationSeconds, wakeTimestamp].
     * Returns the sum of overlap with the query window in milliseconds. Returns 0
     * when the history is empty or no event overlaps.
     *
     * Only EMITTED wakes are in `wakeHistory`; suppressed CPU-starvation drifts are
     * deliberately excluded so starvation is never credited as real sleep.
     */
    getCumulativeSleepMsBetween(startMs, endMs) {
        if (endMs <= startMs)
            return 0;
        let total = 0;
        for (const e of this.wakeHistory) {
            const wakeMs = new Date(e.timestamp).getTime();
            const sleepStartMs = wakeMs - e.sleepDurationSeconds * 1000;
            const overlapStart = Math.max(startMs, sleepStartMs);
            const overlapEnd = Math.min(endMs, wakeMs);
            if (overlapEnd > overlapStart)
                total += overlapEnd - overlapStart;
        }
        return total;
    }
    /** Get wake event stats for telemetry reporting. */
    getStats(sinceMs) {
        const since = sinceMs ?? 0;
        const relevant = this.wakeHistory.filter(e => new Date(e.timestamp).getTime() >= since);
        const suppressed = this.suppressionHistory.filter(e => new Date(e.timestamp).getTime() >= since);
        const suppressedByReason = {
            'cpu-starvation': 0,
            cooldown: 0,
        };
        for (const e of suppressed)
            suppressedByReason[e.reason]++;
        return {
            wakeCount: relevant.length,
            totalSleepSeconds: relevant.reduce((sum, e) => sum + e.sleepDurationSeconds, 0),
            longestSleepSeconds: relevant.length > 0 ? Math.max(...relevant.map(e => e.sleepDurationSeconds)) : 0,
            suppressedCount: suppressed.length,
            suppressedByReason,
            lastSuppressedAt: suppressed.length > 0 ? suppressed[suppressed.length - 1].timestamp : null,
        };
    }
}
//# sourceMappingURL=SleepWakeDetector.js.map