/**
 * Pool core for the anthropic-interactive-pool adapter.
 *
 * Manages a fixed-size set of long-lived `claude` REPL sessions in tmux.
 * Each session can serve many prompts before being retired (auto-retire
 * defends against context-window overflow).
 *
 * Lifecycle:
 *   spawning → ready ⇄ busy → retiring → dead
 *
 * Allocation: LRU (least-recently-used ready session wins).
 * Recycling: when a session is retired, the pool spawns a fresh one in its
 * place so the steady-state size is preserved.
 */
import { execFile, execFileSync } from 'node:child_process';
import { promisify } from 'node:util';
import { randomBytes } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { UnexpectedError } from '../../errors.js';
import { ANTHROPIC_INTERACTIVE_POOL_ID } from './errors.js';
const execFileAsync = promisify(execFile);
/** Maximum number of retry attempts after the initial replacement spawn fails. */
const MAX_REPLACEMENT_ATTEMPTS = 5;
/** Maximum backoff between retries (ms). */
const MAX_REPLACEMENT_BACKOFF_MS = 30_000;
export class InteractivePool extends EventEmitter {
    config;
    sessions = new Map();
    waiters = [];
    /** Pending retry timers for replacement spawns. Cleared on shutdown. */
    pendingRetryTimers = new Set();
    /** Has the startup empty-prompt canary already run in this process lifetime? */
    canaryHasRunInCurrentLifetime = false;
    /** Interval handle for the scheduled recurring canary. Cleared on shutdown. */
    scheduledCanaryTimer = null;
    /** Lock to prevent overlapping scheduled-canary runs if the previous one is slow. */
    scheduledCanaryInFlight = false;
    /** Interval handle for the idle-retirement sweep. Cleared on shutdown. */
    idleSweepTimer = null;
    shuttingDown = false;
    constructor(config) {
        super();
        this.config = config;
        // A zero/negative/NaN pool can never serve a call — every allocate
        // would silently wait out its full timeout. Refuse loudly at
        // construction (boot) where it's debuggable, not at call time.
        if (!Number.isFinite(config.poolSize) || config.poolSize < 1) {
            throw new UnexpectedError(`poolSize must be a finite number >= 1, got ${config.poolSize}`, ANTHROPIC_INTERACTIVE_POOL_ID);
        }
    }
    /** The tmux session-name prefix for this pool (see config.sessionPrefix). */
    get sessionPrefix() {
        return this.config.sessionPrefix ?? 'instar-pool';
    }
    async start() {
        // Orphan recovery: a crashed previous process (SIGKILL, OOM) never ran
        // dispose(), leaving its REPL sessions alive and silently drawing
        // subscription quota. Our prefix is agent-scoped (config.sessionPrefix),
        // so anything matching it is OURS from a dead process — kill before
        // spawning fresh, or orphans accumulate across every crash/update.
        await this.killStaleSessions();
        const promises = [];
        for (let i = 0; i < this.config.poolSize; i++) {
            promises.push(this.spawnOne());
        }
        await Promise.all(promises);
        // Schedule recurring canary if enabled (default hourly).
        if (this.config.canaryIntervalMs > 0) {
            this.scheduleRecurringCanary();
        }
        // Idle-retirement sweep: a ready session untouched past maxIdleMinutes
        // is retired WITHOUT immediate replacement (allocate() respawns on
        // demand) — stale context dies and an idle agent holds zero REPLs.
        if (this.config.maxIdleMinutes > 0) {
            this.idleSweepTimer = setInterval(() => this.sweepIdleSessions(), 60_000);
            this.idleSweepTimer.unref?.();
        }
    }
    /** Kill leftover `<prefix>-*` tmux sessions from a previous process. */
    async killStaleSessions() {
        let names = [];
        try {
            const { stdout } = await execFileAsync(this.config.tmuxPath, ['list-sessions', '-F', '#{session_name}'], { timeout: 5000 });
            names = stdout.split('\n').map((s) => s.trim()).filter(Boolean);
        }
        catch {
            // No tmux server running (common: fresh boot) — nothing to clean.
            return;
        }
        const stale = names.filter((n) => n.startsWith(`${this.sessionPrefix}-`) && !this.hasSessionNamed(n));
        for (const name of stale) {
            try {
                await execFileAsync(this.config.tmuxPath, ['kill-session', '-t', `=${name}:`], {
                    timeout: 5000,
                });
                console.log(`[interactive-pool] killed stale pool session from a previous process: ${name}`);
            }
            catch {
                // @silent-fallback-ok — kill-session raced: the stale session is
                // already gone, which is the desired end-state; nothing degraded.
            }
        }
    }
    hasSessionNamed(tmuxName) {
        for (const s of this.sessions.values()) {
            if (s.tmuxName === tmuxName)
                return true;
        }
        return false;
    }
    /** Retire ready sessions idle past maxIdleMinutes (no immediate replace). */
    sweepIdleSessions() {
        if (this.shuttingDown)
            return;
        const cutoff = Date.now() - this.config.maxIdleMinutes * 60_000;
        for (const s of this.sessions.values()) {
            if (s.state === 'ready' && s.lastUsedAt < cutoff) {
                this.retire(s, { replace: false }).catch((err) => {
                    console.error('[interactive-pool] idle retirement failed:', err);
                });
            }
        }
    }
    /**
     * Schedule the recurring empty-prompt canary. Each tick allocates a
     * ready session, runs the canary, releases. Self-healing and
     * degradation surfacing reuse the same paths as the startup canary.
     * Re-entrancy is guarded by `scheduledCanaryInFlight` — if the
     * previous tick is still running when the next interval fires
     * (shouldn't happen at default 1h cadence but possible under
     * misconfiguration), the new tick is skipped.
     */
    scheduleRecurringCanary() {
        if (this.shuttingDown)
            return;
        this.scheduledCanaryTimer = setInterval(async () => {
            if (this.shuttingDown || this.scheduledCanaryInFlight)
                return;
            this.scheduledCanaryInFlight = true;
            try {
                await this.runScheduledCanary();
            }
            catch (err) {
                console.error('[interactive-pool] scheduled canary tick failed:', err);
            }
            finally {
                this.scheduledCanaryInFlight = false;
            }
        }, this.config.canaryIntervalMs);
        // Allow process to exit even if interval is still scheduled.
        this.scheduledCanaryTimer.unref?.();
    }
    /**
     * Run one canary cycle on a borrowed pool session. Handles result
     * surfacing identically to the startup canary path in spawnOne.
     */
    async runScheduledCanary() {
        if (this.shuttingDown)
            return;
        let session;
        try {
            session = await this.allocate();
        }
        catch {
            // Pool fully busy or shutting down — skip this tick; next one will try again.
            return;
        }
        try {
            const { runEmptyPromptCanary } = await import('./canary/emptyPromptCanary.js');
            const result = await runEmptyPromptCanary(this, session, this.config, {
                llmFallback: this.config.llmFallback,
            });
            if (result.status === 'fail') {
                const { DegradationReporter } = await import('../../../monitoring/DegradationReporter.js');
                DegradationReporter.getInstance().report({
                    feature: 'anthropic-interactive-pool.empty-prompt-canary',
                    primary: 'Recurring empty-prompt canary verifies detector still reads Claude Code correctly',
                    fallback: 'Pool continues serving with current signature; consumers may receive corrupted responses',
                    reason: result.message,
                    impact: 'Subscription-path pool may return truncated or corrupted responses — '
                        + 'manual intervention required to determine if Claude Code UI evolution requires code update.',
                });
            }
            else if (result.status === 'self-healed') {
                console.log(`[interactive-pool] scheduled canary self-healed: ${result.message}`);
            }
            // pass: silent (success path is quietly correct per Rule 3.2).
        }
        finally {
            await this.release(session);
        }
    }
    async spawnOne() {
        const id = `aip-${randomBytes(6).toString('hex')}`;
        const tmuxName = `${this.sessionPrefix}-${id}`;
        const session = {
            id,
            tmuxName,
            state: 'spawning',
            messageCount: 0,
            spawnedAt: Date.now(),
            lastUsedAt: Date.now(),
        };
        this.sessions.set(id, session);
        this.emit('session:spawned', session);
        // Build env for the tmux spawn
        const envFlags = [];
        const pushEnv = (key, value) => envFlags.push('-e', `${key}=${value}`);
        pushEnv('CLAUDECODE', '');
        pushEnv('CLAUDE_SESSION_ID', '');
        pushEnv('INSTAR_POOL_SESSION_ID', id);
        if (this.config.credential) {
            if (this.config.credential.startsWith('sk-ant-oat')) {
                pushEnv('CLAUDE_CODE_OAUTH_TOKEN', this.config.credential);
                pushEnv('ANTHROPIC_API_KEY', '');
            }
            else {
                pushEnv('ANTHROPIC_API_KEY', this.config.credential);
                pushEnv('CLAUDE_CODE_OAUTH_TOKEN', '');
            }
        }
        if (this.config.apiBaseUrl) {
            pushEnv('ANTHROPIC_BASE_URL', this.config.apiBaseUrl);
        }
        const args = [
            'new-session',
            '-d',
            '-s',
            tmuxName,
            '-x',
            String(this.config.paneWidth),
            '-y',
            String(this.config.paneHeight),
        ];
        if (this.config.workingDirectory) {
            args.push('-c', this.config.workingDirectory);
        }
        args.push(...envFlags);
        args.push(this.config.claudePath, '--dangerously-skip-permissions');
        if (this.config.model) {
            args.push('--model', this.config.model);
        }
        try {
            execFileSync(this.config.tmuxPath, args, { encoding: 'utf-8' });
            try {
                execFileSync(this.config.tmuxPath, ['set-option', '-t', `=${tmuxName}:`, 'history-limit', '50000'], { encoding: 'utf-8', timeout: 5000 });
            }
            catch {
                /* nice-to-have */
            }
        }
        catch (err) {
            this.sessions.delete(id);
            throw new UnexpectedError(`Failed to spawn pool session: ${err.message}`, ANTHROPIC_INTERACTIVE_POOL_ID, err);
        }
        // Wait for REPL to become ready
        const ready = await this.waitForReady(tmuxName, 30);
        if (!ready) {
            session.state = 'dead';
            this.sessions.delete(id);
            this.emit('session:died', session);
            throw new UnexpectedError(`Pool session ${id} did not reach ready state in 30s`, ANTHROPIC_INTERACTIVE_POOL_ID);
        }
        // Run the empty-prompt canary on the FIRST session that comes up.
        // The signature it derives applies to every subsequent session
        // (signature is process-wide). Skip canary on subsequent sessions
        // since the signature is already valid and the canary cost (a real
        // prompt round-trip per session) would otherwise compound at pool
        // start. Recurring drift detection between restarts is a follow-up.
        // Per Rule 3 of the path constraints.
        if (!this.canaryHasRunInCurrentLifetime) {
            let canaryResult = null;
            try {
                const { runEmptyPromptCanary } = await import('./canary/emptyPromptCanary.js');
                canaryResult = await runEmptyPromptCanary(this, session, this.config, {
                    llmFallback: this.config.llmFallback,
                });
                this.canaryHasRunInCurrentLifetime = true;
            }
            catch (canaryErr) {
                // Canary infrastructure (import / promise chain) crashed — distinct
                // from "canary returned fail status." Log loudly but don't block
                // pool startup; missing canary is protection-in-depth, not a
                // primary failure path.
                console.error('[interactive-pool] canary infrastructure error:', canaryErr);
            }
            if (canaryResult?.status === 'fail') {
                // Graceful degradation (2026-06-07 incident, topic 21816): a canary
                // failure must NOT refuse the whole pool. Throwing here rejected
                // start()'s Promise.all → the entire subscription-path pool failed to
                // start → ALL Anthropic work stranded on the SDK credit pot, and under
                // transient load (a slow/garbled empty-prompt round-trip during CPU
                // starvation) it re-failed every spawn and tripped the LLM circuit in a
                // loop. The empty-prompt detector is protection-in-depth (exactly as the
                // infra-error branch above already states: "missing canary is
                // protection-in-depth, not a primary failure path") — a session that
                // can't verify the empty-prompt SIGNATURE can still serve real prompts.
                // So: report the degradation, keep the canary marked as run (don't retry
                // it on every spawn), and bring the session READY anyway. A genuinely
                // broken session is caught by the per-call retire/replace path, not by
                // refusing service to everyone.
                const { DegradationReporter } = await import('../../../monitoring/DegradationReporter.js');
                DegradationReporter.getInstance().report({
                    feature: 'anthropic-interactive-pool.empty-prompt-canary',
                    primary: 'Empty-prompt detector verified by startup canary',
                    fallback: 'Canary unverified — pool starts anyway; empty-prompt detection is best-effort this lifetime',
                    reason: canaryResult.message,
                    impact: 'Empty-prompt detection runs in best-effort mode until the next successful canary. '
                        + 'The pool still serves real prompts (no SDK-credit fallback, no circuit trip).',
                });
                console.warn(`[interactive-pool] empty-prompt canary failed (non-fatal — pool starts anyway): ${canaryResult.message}`);
            }
            if (canaryResult?.status === 'self-healed') {
                // Self-heal succeeded — log locally, no user-facing alert per
                // Rule 3.2 ("success path is quietly correct").
                console.log(`[interactive-pool] canary self-healed: ${canaryResult.message}`);
            }
        }
        session.state = 'ready';
        this.emit('session:ready', session);
        this.flushWaiter(session);
    }
    async waitForReady(tmuxName, maxSeconds) {
        for (let i = 0; i < maxSeconds; i++) {
            await new Promise((resolve) => setTimeout(resolve, 1000));
            const pane = await this.capturePane(tmuxName, 50);
            if (pane === null)
                continue;
            for (const marker of this.config.idleMarkers) {
                if (pane.includes(marker)) {
                    return true;
                }
            }
        }
        return false;
    }
    async capturePane(tmuxName, lines) {
        // RULE 3: EXEMPT — raw byte capture, no state parsing. The bytes are
        // returned verbatim to callers (promptRunner, canary, sentinels) which
        // are the authorities that interpret them. The state-detection logic
        // lives in those callers (covered by the empty-prompt canary and the
        // poolDecayCanary). This function is a transport-layer signal source.
        try {
            const { stdout } = await execFileAsync(this.config.tmuxPath, ['capture-pane', '-t', `=${tmuxName}:`, '-p', '-S', `-${lines}`], { timeout: 5000, maxBuffer: 4 * 1024 * 1024 });
            return stdout;
        }
        catch {
            return null;
        }
    }
    /**
     * Allocate a ready session. Marks it busy and returns. If no session is
     * ready, waits up to `allocateTimeoutMs`. If no session becomes ready
     * in time, throws.
     */
    async allocate() {
        if (this.shuttingDown) {
            throw new UnexpectedError('Pool is shutting down; cannot allocate', ANTHROPIC_INTERACTIVE_POOL_ID);
        }
        const ready = this.findReadyLru();
        if (ready) {
            this.markBusy(ready);
            return ready;
        }
        const waiting = new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                const idx = this.waiters.findIndex((w) => w.resolve === resolve);
                if (idx >= 0)
                    this.waiters.splice(idx, 1);
                reject(new UnexpectedError(`Pool allocation timed out after ${this.config.allocateTimeoutMs}ms`, ANTHROPIC_INTERACTIVE_POOL_ID));
            }, this.config.allocateTimeoutMs);
            this.waiters.push({ resolve, reject, timer });
        });
        // On-demand growth: idle retirement (and crash recovery) can shrink
        // the pool below poolSize — even to zero. If there's headroom, kick a
        // spawn so the waiter above has something to be flushed onto
        // (otherwise an empty pool waits out the full allocate timeout for
        // nothing). Ordering matters: the waiter is enqueued FIRST so a fast
        // spawn's flushWaiter can never miss it.
        if (this.sessions.size < this.config.poolSize) {
            this.replaceRetired();
        }
        return waiting;
    }
    findReadyLru() {
        let best;
        for (const s of this.sessions.values()) {
            if (s.state !== 'ready')
                continue;
            if (!best || s.lastUsedAt < best.lastUsedAt)
                best = s;
        }
        return best;
    }
    markBusy(s) {
        s.state = 'busy';
        s.lastUsedAt = Date.now();
        this.emit('session:allocated', s);
    }
    flushWaiter(s) {
        if (s.state !== 'ready' || this.waiters.length === 0)
            return;
        const waiter = this.waiters.shift();
        clearTimeout(waiter.timer);
        this.markBusy(s);
        waiter.resolve(s);
    }
    /**
     * Return a session to ready. Increments messageCount and checks retire
     * thresholds; auto-retires if needed.
     */
    async release(s) {
        if (s.state !== 'busy')
            return;
        s.messageCount += 1;
        s.lastUsedAt = Date.now();
        if (s.messageCount >= this.config.maxMessagesPerSession) {
            await this.retire(s);
            return;
        }
        s.state = 'ready';
        this.emit('session:released', s);
        this.flushWaiter(s);
    }
    /**
     * Gracefully retire a session. By default spawns a replacement (the
     * busy-path retirement contract — a caller is likely waiting); idle
     * retirement passes `replace: false` so an idle agent holds zero REPLs
     * and allocate() respawns on demand.
     */
    async retire(s, opts) {
        if (s.state === 'retiring' || s.state === 'dead')
            return;
        s.state = 'retiring';
        try {
            await execFileAsync(this.config.tmuxPath, ['kill-session', '-t', `=${s.tmuxName}:`], { timeout: 5000 });
        }
        catch {
            // already gone
        }
        s.state = 'dead';
        this.emit('session:retired', s);
        this.sessions.delete(s.id);
        if (!this.shuttingDown && (opts?.replace ?? true)) {
            // Replace, with retry-on-failure and observable degradation events.
            // Previous behavior was `.catch(console.error)` — spawn failures were
            // swallowed and the pool decayed silently. Now spawn failures emit
            // `pool:degraded`, schedule an exponential-backoff retry up to
            // MAX_REPLACEMENT_ATTEMPTS, and emit `pool:healed` on recovery or
            // `pool:degraded_persistent` after final exhaustion.
            this.replaceRetired();
        }
    }
    replaceRetired() {
        if (this.shuttingDown)
            return;
        this.spawnOne().catch((err) => {
            this.emit('pool:degraded', { error: err, attempt: 0 });
            this.scheduleRetryReplacement(1);
        });
    }
    scheduleRetryReplacement(attempt) {
        if (this.shuttingDown)
            return;
        if (attempt > MAX_REPLACEMENT_ATTEMPTS) {
            this.emit('pool:degraded_persistent', { totalAttempts: attempt - 1 });
            return;
        }
        const backoffMs = Math.min(MAX_REPLACEMENT_BACKOFF_MS, 1_000 * Math.pow(2, attempt - 1));
        const timer = setTimeout(async () => {
            this.pendingRetryTimers.delete(timer);
            if (this.shuttingDown)
                return;
            try {
                await this.spawnOne();
                this.emit('pool:healed', { afterAttempts: attempt });
            }
            catch (err) {
                this.emit('pool:degraded', { error: err, attempt });
                this.scheduleRetryReplacement(attempt + 1);
            }
        }, backoffMs);
        this.pendingRetryTimers.add(timer);
    }
    /** Force-kill a session without graceful retirement. */
    async hardKill(s) {
        await this.retire(s); // same effect, no graceful drain
    }
    /** Shutdown the pool. */
    async shutdown() {
        this.shuttingDown = true;
        if (this.scheduledCanaryTimer !== null) {
            clearInterval(this.scheduledCanaryTimer);
            this.scheduledCanaryTimer = null;
        }
        if (this.idleSweepTimer !== null) {
            clearInterval(this.idleSweepTimer);
            this.idleSweepTimer = null;
        }
        for (const w of this.waiters) {
            clearTimeout(w.timer);
            w.reject(new UnexpectedError('Pool shutting down', ANTHROPIC_INTERACTIVE_POOL_ID));
        }
        this.waiters.length = 0;
        for (const t of this.pendingRetryTimers)
            clearTimeout(t);
        this.pendingRetryTimers.clear();
        const sessions = Array.from(this.sessions.values());
        await Promise.all(sessions.map((s) => this.retire(s)));
        this.emit('pool:shutdown');
    }
    /** Snapshot of pool state. */
    status() {
        let ready = 0;
        let busy = 0;
        let retiring = 0;
        for (const s of this.sessions.values()) {
            if (s.state === 'ready')
                ready++;
            else if (s.state === 'busy')
                busy++;
            else if (s.state === 'retiring')
                retiring++;
        }
        return {
            total: this.sessions.size,
            ready,
            busy,
            retiring,
            sessions: Array.from(this.sessions.values()).map((s) => ({ ...s })),
        };
    }
    getById(id) {
        return this.sessions.get(id);
    }
}
//# sourceMappingURL=pool.js.map