/**
 * SleepController — the decision half of agent hard-sleep (Stage B of the
 * Responsible Resource Usage / agent-sleep design, docs/specs/agent-sleep-mode.md).
 *
 * This module owns ONE question: "is it safe for this agent to drop its server to
 * near-zero footprint right now?" It is deliberately split from the MECHANISM that
 * actually stops/respawns the server (the supervisor + lifeline handshake, a later
 * slice). Getting the decision — and every safety guard — correct and OBSERVABLE
 * first, in dry-run, is what makes the mechanism safe to wire: the same dark +
 * dry-run discipline the AgentWorktreeReaper shipped with.
 *
 * Pure `evaluateSleep()` is fake-free and exhaustively unit-testable. The thin
 * `SleepController` class ticks it on a cadence and, in dry-run (the default),
 * only records what it WOULD do to an audit sink — it never stops anything.
 */
import fs from 'node:fs';
import path from 'node:path';
export const DEFAULT_SLEEP_THRESHOLDS = {
    idleGraceMs: 120_000, // 2 min
    deepIdleMs: 900_000, // 15 min
    wakeLeadMs: 120_000, // 2 min
};
/**
 * Decide whether the agent may hard-sleep. Pure; no I/O. The guards are ordered
 * so the returned reason names the FIRST blocking condition — easiest to read in
 * an audit trail.
 */
export function evaluateSleep(input, t) {
    // 1. Active sessions ⇒ never sleep. (A session means real work in flight.)
    if (input.runningSessions > 0) {
        return {
            decision: 'awake',
            reason: `${input.runningSessions} running session(s)`,
            idleForMs: 0,
        };
    }
    // 2. Idle duration = time since the most recent inbound OR activity signal.
    const lastSignal = Math.max(input.lastInboundAt ?? 0, input.lastActivityAt ?? 0);
    const idleForMs = lastSignal > 0 ? input.now - lastSignal : Number.POSITIVE_INFINITY;
    if (idleForMs < t.idleGraceMs) {
        return { decision: 'awake', reason: `recent activity ${secs(idleForMs)} ago`, idleForMs };
    }
    if (idleForMs < t.deepIdleMs) {
        return {
            decision: 'idle-shallow',
            reason: `idle ${secs(idleForMs)} (< deepIdle ${secs(t.deepIdleMs)})`,
            idleForMs,
        };
    }
    // 3. Deep-idle — every safety guard below blocks sleep (KEEP-awake on any).
    if (input.leaseActive && input.holdsLease) {
        return {
            decision: 'keep-awake',
            reason: 'holds the multi-machine serving lease — must hand off before sleeping',
            idleForMs,
        };
    }
    if (input.inflightWork) {
        return { decision: 'keep-awake', reason: 'in-flight work (forward / recovery / queued message)', idleForMs };
    }
    if (input.nextScheduledJobAt !== null) {
        const until = input.nextScheduledJobAt - input.now;
        if (until <= t.wakeLeadMs) {
            return { decision: 'keep-awake', reason: `scheduled job fires in ${secs(Math.max(0, until))} (< wakeLead)`, idleForMs };
        }
    }
    // 4. Deep-idle and every guard clear ⇒ safe to sleep.
    return {
        decision: 'would-sleep',
        reason: `deep-idle ${secs(idleForMs)}; no sessions, no held lease, no in-flight work, no imminent job`,
        idleForMs,
    };
}
function secs(ms) {
    if (!Number.isFinite(ms))
        return '∞';
    return `${Math.round(ms / 1000)}s`;
}
/**
 * Append-only JSONL audit sink at `logs/agent-sleep-events.jsonl`. Only writes on
 * a decision CHANGE (transition), never every tick — the same low-noise pattern as
 * the reaper audit, so a deep-idle agent doesn't spam the log every cadence.
 */
export function sleepAuditSink(stateDir) {
    const file = path.join(stateDir, 'logs', 'agent-sleep-events.jsonl');
    return (entry) => {
        try {
            fs.mkdirSync(path.dirname(file), { recursive: true });
            fs.appendFileSync(file, JSON.stringify(entry) + '\n');
        }
        catch {
            /* @silent-fallback-ok — audit is best-effort observability, never load-bearing */
        }
    };
}
/**
 * Live-mode `requestSleep` impl (Stage B mechanism): writes
 * `state/sleep-requested.json` for the ServerSupervisor to honor (it stops the
 * server). Only ever called by the SleepController in live mode (enabled &&
 * !dryRun), so a dark / dry-run agent never writes it. TTL-stamped so a stale
 * request can't sleep a server long after it was queued.
 */
export function sleepRequestWriter(stateDir, ttlMs = 300_000) {
    return (verdict) => {
        try {
            const dir = path.join(stateDir, 'state');
            fs.mkdirSync(dir, { recursive: true });
            const now = Date.now();
            fs.writeFileSync(path.join(dir, 'sleep-requested.json'), JSON.stringify({
                requestedBy: 'SleepController',
                reason: verdict.reason,
                requestedAt: new Date(now).toISOString(),
                expiresAt: new Date(now + ttlMs).toISOString(),
            }));
        }
        catch {
            /* @silent-fallback-ok — supervisor re-checks each tick; a missed write just delays sleep */
        }
    };
}
/**
 * Ticks the sleep decision on a cadence. Records every TRANSITION to the audit
 * sink; in live mode (dryRun=false) calls `requestSleep` on a fresh would-sleep.
 * In dry-run it is pure observability — the foundation slice ships this way.
 */
export class SleepController {
    deps;
    opts;
    thresholds;
    lastDecision = null;
    lastVerdict = null;
    sleepRequested = false;
    timer = null;
    constructor(deps, opts) {
        this.deps = deps;
        this.opts = opts;
        this.thresholds = { ...DEFAULT_SLEEP_THRESHOLDS, ...(opts.thresholds ?? {}) };
    }
    /** Begin ticking on the configured cadence. No-op when not enabled (the audit
     *  still works in dry-run-but-enabled; a fully disabled controller never ticks). */
    start() {
        if (this.timer || !this.opts.enabled)
            return;
        const intervalMs = this.opts.tickIntervalMs ?? 60_000;
        this.timer = setInterval(() => {
            try {
                this.tick();
            }
            catch { /* @silent-fallback-ok — observability tick, never load-bearing */ }
        }, intervalMs);
        if (typeof this.timer.unref === 'function')
            this.timer.unref();
    }
    stop() {
        if (this.timer) {
            clearInterval(this.timer);
            this.timer = null;
        }
    }
    /** Evaluate once. Returns the verdict (also used by tests + a status route). */
    tick() {
        const verdict = evaluateSleep(this.deps.sample(), this.thresholds);
        this.lastVerdict = verdict;
        if (verdict.decision !== this.lastDecision) {
            this.lastDecision = verdict.decision;
            this.deps.audit?.({
                ts: new Date().toISOString(),
                decision: verdict.decision,
                reason: verdict.reason,
                idleForMs: verdict.idleForMs,
                dryRun: this.opts.dryRun,
            });
        }
        // Live mechanism (off in the foundation slice): request sleep once per
        // would-sleep episode; reset the latch as soon as we leave would-sleep.
        if (verdict.decision === 'would-sleep') {
            if (!this.opts.dryRun && this.opts.enabled && !this.sleepRequested) {
                this.sleepRequested = true;
                this.deps.requestSleep?.(verdict);
            }
        }
        else {
            this.sleepRequested = false;
        }
        return verdict;
    }
    /** Current latched state — for a status route / tests. */
    get state() {
        return { lastDecision: this.lastDecision, sleepRequested: this.sleepRequested };
    }
    /** Read-only status for GET /sleep. Ticks once so the verdict is fresh. */
    snapshot() {
        const verdict = this.tick();
        return {
            enabled: this.opts.enabled,
            dryRun: this.opts.dryRun,
            thresholds: this.thresholds,
            verdict,
            sleepRequested: this.sleepRequested,
        };
    }
}
//# sourceMappingURL=SleepController.js.map