/**
 * SpawnRequestManager — handles on-demand session spawning for message delivery.
 *
 * Per Phase 5 of INTER-AGENT-MESSAGING-SPEC v3.1:
 * - Evaluates spawn requests against resource constraints
 * - Spawns sessions with full context about why they were created
 * - Delivers pending messages to newly spawned sessions
 * - Handles denials with retry and escalation
 * - Enforces cooldown, session limits, memory pressure checks
 *
 * §4.2 additions (Threadline Cooldown & Queue Drain spec v7):
 * - Failure-suppressive cooldown reservation: `lastSpawnByAgent.set` BEFORE
 *   async spawn, never rolled back on failure. Prevents a peer who triggers
 *   fast-failing spawn errors from beating the cooldown.
 * - Classified failure attribution: Phase 1 classifier treats only
 *   locally-generated typed errors as agent-attributable. Everything else is
 *   ambiguous and does NOT bump penalty.
 * - Penalty state in separate fields: `penaltyUntil` (timestamp), and
 *   `consecutiveSpawnFailures` (counter). Reset on success. After 3
 *   attributable failures, `penaltyUntil = now + 2 * cooldownMs`.
 * - Single cooldown-remaining read path: `cooldownRemainingMs(agent)`.
 *   No consumer computes `now - lastSpawn` directly — closes the alias bug.
 * - State stored as `#private` ECMAScript fields so external consumers can't
 *   bypass the helpers. tsconfig target is ES2022; private fields are native.
 */
var _a;
import { createHash } from 'node:crypto';
/**
 * §4.3: hash version prefix. Stored on every queued entry's `envelopeHash`
 * so future algorithm upgrades can ship without invalidating queued entries
 * (forward-compat, matches subresource-integrity pattern).
 */
const ENVELOPE_HASH_PREFIX = 'sha256-v1:';
/**
 * Stable canonical-JSON serialization with sorted keys. Permuting input
 * object keys yields the same hash. Only handles plain objects, arrays,
 * primitives — no class instances, dates, or undefined values (which
 * JSON.stringify drops anyway).
 */
function canonicalJson(value) {
    if (value === null || typeof value !== 'object')
        return JSON.stringify(value);
    if (Array.isArray(value))
        return '[' + value.map(canonicalJson).join(',') + ']';
    const obj = value;
    const keys = Object.keys(obj).sort();
    return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}';
}
/**
 * §4.3: compute the versioned envelope hash for a queue entry's payload.
 * Hashes a canonical JSON of `{ context, threadId }` so two requests with
 * the same payload but differently-ordered keys produce the same hash.
 */
export function computeEnvelopeHash(input) {
    const payload = canonicalJson({ context: input.context ?? '', threadId: input.threadId ?? '' });
    const sha = createHash('sha256').update(payload, 'utf8').digest('hex');
    return ENVELOPE_HASH_PREFIX + sha;
}
/** Error class callers throw from inside `spawnSession` to tag attributable failures. */
export class SpawnFailureError extends Error {
    cause;
    constructor(message, cause) {
        super(message);
        this.cause = cause;
        this.name = 'SpawnFailureError';
    }
}
const AGENT_ATTRIBUTABLE_CAUSES = new Set([
    'envelope-validation',
    'admission-cap',
    'safety-refusal-on-payload',
]);
// ── Constants ───────────────────────────────────────────────────
const DEFAULT_COOLDOWN_MS = 30_000; // 30 seconds (reduced from 5 min to allow multi-message agents)
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_MAX_RETRY_WINDOW_MS = 30 * 60_000;
/** §4.2: penalty kicks in after this many consecutive agent-attributable failures. */
const PENALTY_FAILURE_THRESHOLD = 3;
/** §4.2: penalty duration is this multiple of the configured cooldown. */
const PENALTY_COOLDOWN_MULTIPLIER = 2;
/**
 * §4.2 infra-failure soft limiter.
 *
 * Tracks infrastructure-attributable failures (provider-5xx, gate-llm-timeout,
 * etc.) over a sliding window. If a peer exceeds the threshold within the
 * window, their queue admission is **degraded** to `degradedMaxQueuedPerAgent`
 * for the degradation duration. No penalty, no blame — just gentle backpressure
 * on peers that reliably trigger infra paths.
 */
/** §4.3: default payload byte-size cap. */
const DEFAULT_MAX_ENVELOPE_BYTES = 256 * 1024; // 256 KiB
/** §4.3: default global queue cap. */
const DEFAULT_MAX_GLOBAL_QUEUED = 1000;
const INFRA_FAILURE_WINDOW_MS = 10 * 60_000; // 10 min
const INFRA_FAILURE_THRESHOLD = 5; // failures within window to trigger
const INFRA_DEGRADATION_DURATION_MS = 30 * 60_000; // 30 min degraded admission
const DEGRADED_MAX_QUEUED_PER_AGENT_DEFAULT = 1;
/**
 * §4.2 drain-loop constants (DRR scheduling).
 *
 * The drain loop is a single shared `setInterval` that picks ready agents
 * (cooldown cleared AND queued messages present) and calls
 * `onDrainReady(agent)` so the consumer can run an actual spawn cycle.
 *
 * Fairness uses Deficit Round Robin with quantum=1, cost=1, at most one
 * drain per agent per tick. Agents not served this tick carry deficit for
 * next tick — prevents starvation when |ready| > MAX_DRAINS_PER_TICK.
 */
const DRAIN_TICK_FLOOR_MS = 1_000;
const DRAIN_TICK_CEILING_MS = 5_000;
const DRAIN_MAX_PER_TICK_DEFAULT = 8;
const DRR_QUANTUM = 1;
const DRR_COST = 1;
const DRR_AGE_BOOST_MULTIPLIER = 1.5;
const SPAWN_PROMPT_TEMPLATE = `You were spawned by an inter-agent message request.

Requester: {requester_agent}/{requester_session} on {requester_machine}
Reason: {reason}
{context_line}
You have {pending_count} pending message(s) to process.
After addressing these messages, you may continue with other work
or end your session if no further action is needed.

Use the threadline_send MCP tool to respond to messages. Include the threadId to maintain conversation context.
Use threadline_send with the target agentId to send new messages.`;
// ── Implementation ──────────────────────────────────────────────
export class SpawnRequestManager {
    #config;
    #nowFn;
    /** Track last spawn per agent for cooldown. Written BEFORE async spawn (§4.2 reservation). */
    #lastSpawnByAgent = new Map();
    /** §4.2: forbidden-until timestamp per agent regardless of cooldown elapsed. */
    #penaltyUntil = new Map();
    /** §4.2: consecutive agent-attributable failures per agent. Reset on success. */
    #consecutiveSpawnFailures = new Map();
    /** Track pending spawn retries (legacy retry path — still used by handleDenial). */
    #pendingRetries = new Map();
    /** Queue messages that arrive during cooldown, keyed by agent */
    /**
     * Per-agent queue of pending messages.
     *
     * §4.3 entry shape:
     * - `context` / `threadId`: the payload (existing).
     * - `receivedAt`: enqueue timestamp (existing, used for TTL pruning).
     * - `envelopeHash`: SHA-256 of canonical JSON of payload, prefixed
     *   `sha256-v1:`. Computed at enqueue. Lets the drain loop verify
     *   integrity and lets future code dedupe identical re-sends.
     * - `drainAttempts`: count of times the drain loop has attempted to
     *   process this entry. Bumped before each drain; reset on success.
     *   Used by DRR's age-boost.
     */
    #pendingMessages = new Map();
    /** Max queued messages per agent before oldest are dropped */
    static MAX_QUEUED_PER_AGENT = 10;
    /** Max age for queued messages (10 minutes) */
    static QUEUE_MAX_AGE_MS = 10 * 60_000;
    /** §4.2: DRR deficit counter per agent. Survives across ticks. */
    #drrDeficit = new Map();
    /** §4.2: drain-attempt counter per agent. Reset on successful drain. */
    #drainAttempts = new Map();
    /** §4.2: shared drain-tick timer. null when not started. */
    #drainTimer = null;
    /** §4.2: re-entrancy guard to prevent overlapping tick executions. */
    #tickInflight = false;
    /** §4.2: rolling timestamps of infra-attributable failures per agent. */
    #infraFailureWindow = new Map();
    /**
     * §4.3: per-agent truncation marker. Set when a queue write evicts an
     * older entry due to per-agent or degraded admission cap. Cleared when
     * the queue is fully drained for that agent. Lets downstream code report
     * to the operator (or to the spawned session) that some messages were
     * dropped.
     */
    #truncated = new Set();
    constructor(config) {
        this.#config = config;
        this.#nowFn = config.nowFn ?? (() => Date.now());
    }
    // ── §4.2 helpers ────────────────────────────────────────────
    /**
     * Single read path for "how long until this agent may spawn again" (§4.2).
     *
     * Returns the MAX of (remaining cooldown, remaining penalty, 0). No external
     * consumer should compute `now - lastSpawn` — this closes the alias bug
     * where subtracting a fresh timestamp against a stale one could produce
     * a negative elapsed and grant an unintended spawn.
     */
    cooldownRemainingMs(agent) {
        const now = this.#nowFn();
        const cooldownMs = this.#config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
        const lastSpawn = this.#lastSpawnByAgent.get(agent) ?? 0;
        const cooldownRem = Math.max(cooldownMs - (now - lastSpawn), 0);
        const penaltyRem = Math.max((this.#penaltyUntil.get(agent) ?? 0) - now, 0);
        return Math.max(cooldownRem, penaltyRem);
    }
    /**
     * Classify a thrown error from `spawnSession` into a SpawnFailureCause.
     * Phase 1 (per spec): only locally-generated `SpawnFailureError` with an
     * attributable cause counts. Everything else is `ambiguous`. No regex on
     * third-party error strings — that's brittle across library upgrades.
     */
    #classifyFailure(err) {
        if (err instanceof SpawnFailureError)
            return err.cause;
        return 'ambiguous';
    }
    /**
     * Apply a classified failure to penalty state. Only agent-attributable
     * causes increment `consecutiveSpawnFailures`; hitting the threshold stamps
     * `penaltyUntil`. Infrastructure + ambiguous causes do NOT bump the counter.
     *
     * §4.2 infra soft limiter: infrastructure-attributable causes ARE recorded
     * in `#infraFailureWindow` so peers that reliably trigger infra paths can
     * be gently throttled via `isInfraDegraded(agent)`.
     */
    #applyFailureAttribution(agent, cause) {
        if (AGENT_ATTRIBUTABLE_CAUSES.has(cause)) {
            const prior = this.#consecutiveSpawnFailures.get(agent) ?? 0;
            const next = prior + 1;
            this.#consecutiveSpawnFailures.set(agent, next);
            // §4.5: emit on the trip-edge (prior < threshold && next >= threshold).
            if (prior < PENALTY_FAILURE_THRESHOLD && next >= PENALTY_FAILURE_THRESHOLD) {
                const cooldownMs = this.#config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
                const penaltyMs = PENALTY_COOLDOWN_MULTIPLIER * cooldownMs;
                const at = this.#nowFn();
                this.#penaltyUntil.set(agent, at + penaltyMs);
                try {
                    this.#config.onDegradation?.({
                        kind: 'spawn-penalty-tripped',
                        agent,
                        consecutiveFailures: next,
                        penaltyMs,
                        at,
                    });
                }
                catch { /* observability sink errors must never affect spawn flow */ }
            }
            else if (next >= PENALTY_FAILURE_THRESHOLD) {
                // Already in penalty — refresh the timer on subsequent attributable failures.
                const cooldownMs = this.#config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
                this.#penaltyUntil.set(agent, this.#nowFn() + PENALTY_COOLDOWN_MULTIPLIER * cooldownMs);
            }
            return;
        }
        // Non-attributable causes (infra + ambiguous) feed the soft limiter
        // window. Ambiguous is included because, from the limiter's perspective,
        // unknown is indistinguishable from infra — the signal is "this peer
        // reliably triggers things going wrong, but we can't blame them".
        this.#recordInfraFailure(agent);
    }
    /** Record an infra-attributable failure timestamp; prune stale entries. */
    #recordInfraFailure(agent) {
        const now = this.#nowFn();
        const cutoff = now - INFRA_FAILURE_WINDOW_MS;
        const window = this.#infraFailureWindow.get(agent) ?? [];
        const wasDegradedBefore = window.length >= INFRA_FAILURE_THRESHOLD &&
            window.filter(t => t > cutoff).length >= INFRA_FAILURE_THRESHOLD;
        const fresh = window.filter(t => t > cutoff);
        fresh.push(now);
        this.#infraFailureWindow.set(agent, fresh);
        // §4.5: emit on the trip-edge (was-not-degraded → now-degraded).
        if (!wasDegradedBefore && fresh.length >= INFRA_FAILURE_THRESHOLD) {
            try {
                this.#config.onDegradation?.({
                    kind: 'spawn-infra-degraded',
                    agent,
                    failureCount: fresh.length,
                    degradationMs: INFRA_DEGRADATION_DURATION_MS,
                    at: now,
                });
            }
            catch { /* observability sink errors must never affect spawn flow */ }
        }
    }
    /**
     * Returns true if the agent has exceeded `INFRA_FAILURE_THRESHOLD`
     * infra-attributable failures within `INFRA_FAILURE_WINDOW_MS` AND the
     * resulting degradation window (`INFRA_DEGRADATION_DURATION_MS` since the
     * threshold-tripping failure) has not yet elapsed.
     *
     * No penalty implied — degraded admission ONLY caps queue depth via
     * `effectiveMaxQueuedPerAgent(agent)`.
     */
    isInfraDegraded(agent) {
        const window = this.#infraFailureWindow.get(agent);
        if (!window || window.length < INFRA_FAILURE_THRESHOLD)
            return false;
        const now = this.#nowFn();
        const cutoff = now - INFRA_FAILURE_WINDOW_MS;
        const fresh = window.filter(t => t > cutoff);
        if (fresh.length < INFRA_FAILURE_THRESHOLD)
            return false;
        // Threshold-tripping failure = the Nth-most-recent failure (where N = threshold).
        // Degradation lasts INFRA_DEGRADATION_DURATION_MS from that timestamp.
        const tripIdx = fresh.length - INFRA_FAILURE_THRESHOLD;
        const trippedAt = fresh[tripIdx];
        return now - trippedAt < INFRA_DEGRADATION_DURATION_MS;
    }
    /** Effective per-agent queue cap, accounting for soft-limiter degradation. */
    effectiveMaxQueuedPerAgent(agent) {
        if (this.isInfraDegraded(agent)) {
            return this.#config.degradedMaxQueuedPerAgent ?? DEGRADED_MAX_QUEUED_PER_AGENT_DEFAULT;
        }
        return _a.MAX_QUEUED_PER_AGENT;
    }
    /** Clear penalty counters on successful spawn. */
    #clearFailureAttribution(agent) {
        this.#consecutiveSpawnFailures.delete(agent);
        this.#penaltyUntil.delete(agent);
    }
    // ── Public API ──────────────────────────────────────────────
    /**
     * Evaluate and potentially approve a spawn request.
     * Returns the result with approval status and session info if spawned.
     */
    async evaluate(request) {
        const agent = request.requester.agent;
        // §4.3: payload byte-size cap. Refuse oversized envelopes at admission so
        // bulk content can't hog drain-tick budget. Measured on the UTF-8 byte
        // length of `context` (the only payload field on this surface today).
        const maxBytes = this.#config.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
        if (request.context !== undefined) {
            const bytes = Buffer.byteLength(request.context, 'utf8');
            if (bytes > maxBytes) {
                return {
                    approved: false,
                    reason: `envelope-too-large: ${bytes} bytes exceeds cap of ${maxBytes} bytes`,
                };
            }
        }
        // §4.2: single-source cooldown check (covers cooldown AND penalty).
        const remainingMs = this.cooldownRemainingMs(agent);
        if (remainingMs > 0) {
            if (request.context) {
                this.#queueMessage(agent, request.context, request.pendingMessages?.[0]);
            }
            return {
                approved: false,
                reason: `Cooldown: ${Math.ceil(remainingMs / 1000)}s remaining before next spawn for ${agent}`,
                retryAfterMs: remainingMs,
            };
        }
        // Check session limits — prefer the live accessor over the constructor
        // snapshot so config reloads (e.g. operator raising sessions.maxSessions)
        // take effect without a manager rebuild. Falls back to the constructor
        // value for back-compat.
        const activeSessions = this.#config.getActiveSessions();
        const liveMaxSessions = this.#config.getMaxSessions
            ? this.#config.getMaxSessions()
            : this.#config.maxSessions;
        if (activeSessions.length >= liveMaxSessions) {
            if (request.priority !== 'critical' && request.priority !== 'high') {
                return {
                    approved: false,
                    reason: `Session limit reached (${activeSessions.length}/${liveMaxSessions}). Priority ${request.priority} insufficient to override.`,
                    retryAfterMs: 60_000,
                };
            }
        }
        // Check memory pressure
        if (this.#config.isMemoryPressureHigh?.()) {
            return {
                approved: false,
                reason: 'Memory pressure too high for new session',
                retryAfterMs: 120_000,
            };
        }
        // Subscription-quota gate (S1) — only wired when the subscription-path
        // reroute is active, so a peer-driven spawn can't exhaust the operator's
        // 5h window. Queue the message (like cooldown) rather than dropping it:
        // quota pressure is transient and the content must survive it.
        if (this.#config.shouldSpawnSession) {
            const quota = this.#config.shouldSpawnSession(request.priority);
            if (!quota.allowed) {
                if (request.context) {
                    this.#queueMessage(agent, request.context, request.pendingMessages?.[0]);
                }
                return {
                    approved: false,
                    reason: `Subscription quota gate: ${quota.reason}`,
                    retryAfterMs: 120_000,
                };
            }
        }
        // §4.2: failure-suppressive reservation. Stamp `lastSpawnByAgent` BEFORE
        // the async spawn, and do NOT roll back on failure. A peer that triggers
        // fast-failing spawns still pays the cooldown.
        this.#lastSpawnByAgent.set(agent, this.#nowFn());
        try {
            const queuedMessages = this.#drainQueue(agent);
            const prompt = this.#buildSpawnPrompt(request, queuedMessages);
            const spawned = await this.#config.spawnSession(prompt, {
                model: request.suggestedModel,
                maxDurationMinutes: request.suggestedMaxDuration,
                // §4.5: forward provenance tag (defaults to 'spawn-request' on inline path).
                triggeredBy: request.triggeredBy ?? 'spawn-request',
                // Threadline A2A continuity: forward the conversation-id intent so the
                // claude-code spawn sets (--session-id) or resumes (--resume) the
                // transcript. Both undefined on every non-Threadline spawn path.
                sessionId: request.sessionId,
                resumeSessionId: request.resumeSessionId,
                // Warm-session A2A: route to the interactive keep-alive path when set.
                // Undefined on every non-warm spawn → headless behavior unchanged.
                interactive: request.interactive,
            });
            // Accept both the legacy bare-id return and the {sessionId, tmuxSession}
            // object form. The tmuxSession (when provided) is forwarded so callers
            // (spawnNewThread) persist the REAL tmux name as the resume entry's
            // sessionName — load-bearing for Threadline continuity.
            const sessionId = typeof spawned === 'string' ? spawned : spawned.sessionId;
            const tmuxSession = typeof spawned === 'string' ? undefined : spawned.tmuxSession;
            // Success — clear penalty state and pending retries.
            this.#clearFailureAttribution(agent);
            const retryKey = this.#getRetryKey(request);
            this.#pendingRetries.delete(retryKey);
            return {
                approved: true,
                sessionId,
                tmuxSession,
                reason: `Session spawned for: ${request.reason}`,
            };
        }
        catch (err) {
            const cause = this.#classifyFailure(err);
            this.#applyFailureAttribution(agent, cause);
            return {
                approved: false,
                reason: `Spawn failed (${cause}): ${err instanceof Error ? err.message : 'unknown error'}`,
                retryAfterMs: 30_000,
            };
        }
    }
    /**
     * Handle a denied spawn request — track retries and escalate if needed.
     */
    handleDenial(request, result) {
        const retryKey = this.#getRetryKey(request);
        const maxRetries = this.#config.maxRetries ?? DEFAULT_MAX_RETRIES;
        const maxWindow = this.#config.maxRetryWindowMs ?? DEFAULT_MAX_RETRY_WINDOW_MS;
        const pending = this.#pendingRetries.get(retryKey) ?? {
            request,
            attempts: 0,
            firstAttemptAt: this.#nowFn(),
        };
        pending.attempts++;
        this.#pendingRetries.set(retryKey, pending);
        const elapsed = this.#nowFn() - pending.firstAttemptAt;
        if (pending.attempts >= maxRetries || elapsed >= maxWindow) {
            this.#pendingRetries.delete(retryKey);
            const hasCritical = request.priority === 'critical' ||
                request.pendingMessages?.length;
            if (hasCritical && this.#config.onEscalate) {
                this.#config.onEscalate(request, `Spawn request denied ${pending.attempts} times over ${Math.round(elapsed / 60_000)}min. ` +
                    `Reason: ${result.reason}. Pending messages: ${request.pendingMessages?.length ?? 0}`);
            }
        }
    }
    /** Build the prompt for a spawned session */
    #buildSpawnPrompt(request, queuedMessages) {
        const queuedSection = queuedMessages && queuedMessages.length > 0
            ? `\n\nAdditional messages received while you were being set up (${queuedMessages.length} queued):\n${queuedMessages.map((m, i) => `--- Queued message ${i + 1} ---\n${m.context}`).join('\n')}\n`
            : '';
        const totalPending = (request.pendingMessages?.length ?? 0) + (queuedMessages?.length ?? 0);
        return SPAWN_PROMPT_TEMPLATE
            .replace('{requester_agent}', request.requester.agent)
            .replace('{requester_session}', request.requester.session)
            .replace('{requester_machine}', request.requester.machine)
            .replace('{reason}', request.reason)
            .replace('{context_line}', request.context ? `Context: ${request.context}\n` : '')
            .replace('{pending_count}', String(totalPending))
            + queuedSection;
    }
    /**
     * Queue a message for an agent during cooldown.
     *
     * §4.3 admission policy:
     * 1. Stale-entry pruning by age (existing).
     * 2. Per-agent cap (or degraded cap if peer is in soft-limiter degradation).
     *    If exceeded, drop oldest AND set the per-agent truncation marker.
     * 3. Global cap across all agents. If reached, refuse the new entry
     *    silently (returns false). The caller's `evaluate` already returned
     *    a denial earlier, so this is just a defensive bound.
     *
     * Returns true if the message was queued; false if rejected by the
     * global cap. (Per-agent truncation still queues the new entry.)
     */
    #queueMessage(agent, context, threadId) {
        // §4.3 global cap: refuse new enqueues when total queued is at the
        // global limit. Computed before any local mutation.
        const maxGlobal = this.#config.maxGlobalQueued ?? DEFAULT_MAX_GLOBAL_QUEUED;
        let totalQueued = 0;
        for (const q of this.#pendingMessages.values())
            totalQueued += q.length;
        if (totalQueued >= maxGlobal)
            return false;
        let queue = this.#pendingMessages.get(agent);
        if (!queue) {
            queue = [];
            this.#pendingMessages.set(agent, queue);
        }
        const now = this.#nowFn();
        const maxAge = _a.QUEUE_MAX_AGE_MS;
        while (queue.length > 0 && now - queue[0].receivedAt > maxAge) {
            queue.shift();
        }
        // §4.2 infra soft limiter + §4.3 truncation marker.
        // Per-agent cap (degraded if soft-limited). Drop oldest on overflow.
        const cap = this.effectiveMaxQueuedPerAgent(agent);
        let truncatedAny = false;
        while (queue.length >= cap) {
            queue.shift();
            truncatedAny = true;
        }
        if (truncatedAny)
            this.#truncated.add(agent);
        queue.push({
            context,
            threadId,
            receivedAt: now,
            envelopeHash: computeEnvelopeHash({ context, threadId }),
            drainAttempts: 0,
        });
        return true;
    }
    /** §4.3: returns true if this agent's queue was truncated since last drain. */
    isTruncated(agent) {
        return this.#truncated.has(agent);
    }
    /** Drain all queued messages for an agent */
    #drainQueue(agent) {
        const queue = this.#pendingMessages.get(agent);
        if (!queue || queue.length === 0) {
            this.#truncated.delete(agent); // empty queue can't claim "truncated"
            return [];
        }
        const now = this.#nowFn();
        const maxAge = _a.QUEUE_MAX_AGE_MS;
        const valid = queue.filter(m => now - m.receivedAt < maxAge);
        this.#pendingMessages.delete(agent);
        this.#truncated.delete(agent); // §4.3: drain clears the marker
        return valid;
    }
    /** Get count of queued messages for an agent (for monitoring) */
    getQueuedCount(agent) {
        return this.#pendingMessages.get(agent)?.length ?? 0;
    }
    /** Generate a unique key for retry tracking */
    #getRetryKey(request) {
        return `${request.requester.agent}:${request.target.agent}:${request.reason.slice(0, 50)}`;
    }
    /** Get current spawn state for monitoring */
    getStatus() {
        const cooldowns = [];
        for (const agent of this.#lastSpawnByAgent.keys()) {
            const remaining = this.cooldownRemainingMs(agent);
            if (remaining > 0) {
                cooldowns.push({ agent, remainingMs: remaining });
            }
        }
        const penalties = [];
        const now = this.#nowFn();
        for (const [agent, until] of this.#penaltyUntil) {
            if (until > now) {
                penalties.push({
                    agent,
                    untilMs: until - now,
                    consecutiveFailures: this.#consecutiveSpawnFailures.get(agent) ?? 0,
                });
            }
        }
        const queuedMessages = [];
        for (const [agent, queue] of this.#pendingMessages) {
            if (queue.length > 0) {
                queuedMessages.push({ agent, count: queue.length });
            }
        }
        return {
            cooldowns,
            pendingRetries: this.#pendingRetries.size,
            queuedMessages,
            penalties,
        };
    }
    // ── §4.2 Drain loop ─────────────────────────────────────────
    /**
     * Compute the drain-tick interval from the configured cooldown (§4.2).
     *
     * Formula: `max(min(cooldownMs / 4, 5000), 1000)`. Floor at 1 s prevents
     * a tiny cooldown from producing a hot loop; ceiling at 5 s preserves
     * responsiveness under long cooldowns.
     */
    getDrainTickMs() {
        const cooldownMs = this.#config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
        const tick = Math.min(Math.floor(cooldownMs / 4), DRAIN_TICK_CEILING_MS);
        return Math.max(tick, DRAIN_TICK_FLOOR_MS);
    }
    /**
     * Start the shared drain tick. Idempotent — safe to call twice; a second
     * call is a no-op. The tick runs on a real `setInterval` so consumers
     * can use vitest fake timers for test determinism.
     */
    start() {
        if (this.#drainTimer !== null)
            return;
        const intervalMs = this.getDrainTickMs();
        this.#drainTimer = setInterval(() => {
            // Fire-and-forget tick; any rejection is swallowed (logged inside tick).
            void this.runTick();
        }, intervalMs);
        // Node-only: `unref` so the timer doesn't keep the event loop alive.
        // Defensive type check because some runtimes don't expose unref.
        const maybeUnref = this.#drainTimer.unref;
        if (typeof maybeUnref === 'function') {
            maybeUnref.call(this.#drainTimer);
        }
    }
    /**
     * Stop the drain tick and clear deficit/attempt state. Idempotent.
     * Use this OR `reset()` between tests — `reset()` also clears cooldown.
     */
    dispose() {
        if (this.#drainTimer !== null) {
            clearInterval(this.#drainTimer);
            this.#drainTimer = null;
        }
        this.#drrDeficit.clear();
        this.#drainAttempts.clear();
        this.#tickInflight = false;
    }
    /**
     * Run a single drain tick. Exposed for tests + for consumers who want
     * to drive ticks manually (e.g., drive-by-event rather than by timer).
     *
     * Behavior:
     * 1. O(1) early return if no agents have queued messages.
     * 2. Collect ready agents: those with `cooldownRemainingMs <= tickGraceMs`
     *    AND queued messages present.
     * 3. Add DRR quantum to each ready agent's deficit (with age boost for
     *    agents whose oldest queued message has been drain-attempted > 1 time).
     * 4. Select up to `maxDrainsPerTick` agents ordered by descending deficit;
     *    decrement deficit by cost for each selected agent.
     * 5. Fire `onDrainReady` for selected agents concurrently via allSettled.
     *
     * Returns the count of drain callbacks invoked this tick (useful for tests).
     */
    async runTick() {
        if (this.#tickInflight)
            return 0;
        if (this.#pendingMessages.size === 0)
            return 0;
        const onDrainReady = this.#config.onDrainReady;
        if (!onDrainReady)
            return 0;
        this.#tickInflight = true;
        try {
            const tickGraceMs = this.getDrainTickMs();
            const readyAgents = [];
            for (const [agent, queue] of this.#pendingMessages) {
                if (queue.length === 0)
                    continue;
                if (this.cooldownRemainingMs(agent) <= tickGraceMs) {
                    readyAgents.push(agent);
                }
            }
            if (readyAgents.length === 0)
                return 0;
            // DRR: add quantum (with age boost) to each ready agent's deficit.
            for (const agent of readyAgents) {
                const attempts = this.#drainAttempts.get(agent) ?? 0;
                const quantum = attempts > 1 ? DRR_QUANTUM * DRR_AGE_BOOST_MULTIPLIER : DRR_QUANTUM;
                this.#drrDeficit.set(agent, (this.#drrDeficit.get(agent) ?? 0) + quantum);
            }
            // Select drainees: highest deficit first, stable by insertion order.
            // At most one drain per agent per tick, capped at maxDrainsPerTick.
            const max = this.#config.maxDrainsPerTick ?? DRAIN_MAX_PER_TICK_DEFAULT;
            const selected = [...readyAgents]
                .sort((a, b) => (this.#drrDeficit.get(b) ?? 0) - (this.#drrDeficit.get(a) ?? 0))
                .slice(0, max)
                .filter(a => (this.#drrDeficit.get(a) ?? 0) >= DRR_COST);
            for (const agent of selected) {
                this.#drrDeficit.set(agent, (this.#drrDeficit.get(agent) ?? 0) - DRR_COST);
                this.#drainAttempts.set(agent, (this.#drainAttempts.get(agent) ?? 0) + 1);
            }
            // Garbage-collect deficit for agents no longer ready AND at zero.
            for (const agent of this.#drrDeficit.keys()) {
                if (!readyAgents.includes(agent) && (this.#drrDeficit.get(agent) ?? 0) <= 0) {
                    this.#drrDeficit.delete(agent);
                    this.#drainAttempts.delete(agent);
                }
            }
            // Fire callbacks concurrently; one callback failure does not abort the batch.
            const results = await Promise.allSettled(selected.map(agent => onDrainReady(agent).then(() => {
                // Successful drain → reset attempt counter so next tick doesn't apply age-boost.
                this.#drainAttempts.delete(agent);
            })));
            // Log but don't throw on individual callback failures.
            for (let i = 0; i < results.length; i++) {
                const r = results[i];
                if (r.status === 'rejected') {
                    console.warn(`[SpawnRequestManager] drain callback failed for agent ${selected[i]}: ${String(r.reason)}`);
                }
            }
            return selected.length;
        }
        finally {
            this.#tickInflight = false;
        }
    }
    /** Test seam: snapshot of DRR deficit state. */
    getDrrDeficitSnapshotForTests() {
        return new Map(this.#drrDeficit);
    }
    /**
     * §4.4 commit 3: runtime-tunable subset of the config. Exposed for the
     * GET endpoint that lets operators inspect current values without exposing
     * sensitive callbacks (`spawnSession`, `getActiveSessions`, etc.).
     */
    getRuntimeConfig() {
        return {
            cooldownMs: this.#config.cooldownMs ?? DEFAULT_COOLDOWN_MS,
            maxDrainsPerTick: this.#config.maxDrainsPerTick ?? DRAIN_MAX_PER_TICK_DEFAULT,
            maxEnvelopeBytes: this.#config.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
            maxGlobalQueued: this.#config.maxGlobalQueued ?? DEFAULT_MAX_GLOBAL_QUEUED,
            degradedMaxQueuedPerAgent: this.#config.degradedMaxQueuedPerAgent ?? DEGRADED_MAX_QUEUED_PER_AGENT_DEFAULT,
            drainTickMs: this.getDrainTickMs(),
        };
    }
    /**
     * §4.4 commit 3: update the runtime-tunable subset of the config in place.
     *
     * Changing `cooldownMs` updates the gate logic immediately, but the drain
     * tick interval is fixed at `start()`. To pick up a new tick interval,
     * callers should `dispose()` then `start()`. Returns true if the timer
     * needs to be restarted to pick up tick-interval changes.
     *
     * Validation: each field is rejected if not a positive finite number (or
     * if it would result in nonsensical state). The whole patch is atomic —
     * any invalid field rejects the entire update.
     */
    updateConfig(patch) {
        const validators = [
            ['cooldownMs', v => v >= 0 && Number.isFinite(v)],
            ['maxDrainsPerTick', v => v >= 1 && Number.isFinite(v) && Number.isInteger(v)],
            ['maxEnvelopeBytes', v => v >= 1 && Number.isFinite(v) && Number.isInteger(v)],
            ['maxGlobalQueued', v => v >= 0 && Number.isFinite(v) && Number.isInteger(v)],
            ['degradedMaxQueuedPerAgent', v => v >= 0 && Number.isFinite(v) && Number.isInteger(v)],
        ];
        for (const [k, ok] of validators) {
            const v = patch[k];
            if (v !== undefined && !ok(v)) {
                return { applied: false, reason: `Invalid value for ${String(k)}: ${v}` };
            }
        }
        const oldTickMs = this.getDrainTickMs();
        // Mutate in place. `#config` is readonly as a binding; the object's
        // fields aren't individually readonly, so this is safe.
        if (patch.cooldownMs !== undefined)
            this.#config.cooldownMs = patch.cooldownMs;
        if (patch.maxDrainsPerTick !== undefined)
            this.#config.maxDrainsPerTick = patch.maxDrainsPerTick;
        if (patch.maxEnvelopeBytes !== undefined)
            this.#config.maxEnvelopeBytes = patch.maxEnvelopeBytes;
        if (patch.maxGlobalQueued !== undefined)
            this.#config.maxGlobalQueued = patch.maxGlobalQueued;
        if (patch.degradedMaxQueuedPerAgent !== undefined) {
            this.#config.degradedMaxQueuedPerAgent = patch.degradedMaxQueuedPerAgent;
        }
        const newTickMs = this.getDrainTickMs();
        return { applied: true, tickIntervalChanged: oldTickMs !== newTickMs };
    }
    /** Clear all state (for testing) */
    reset() {
        this.#lastSpawnByAgent.clear();
        this.#pendingRetries.clear();
        this.#pendingMessages.clear();
        this.#penaltyUntil.clear();
        this.#consecutiveSpawnFailures.clear();
        this.#drrDeficit.clear();
        this.#drainAttempts.clear();
        this.#infraFailureWindow.clear();
        this.#truncated.clear();
    }
}
_a = SpawnRequestManager;
//# sourceMappingURL=SpawnRequestManager.js.map