import { Worker } from 'node:worker_threads';
import os from 'node:os';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { logger } from '../../logger.js';
import { createQuarantine } from './quarantine.js';
/**
 * Type guard: every element of `items` has the parse-worker shape
 * (`{path: string, content: string}`). Used to narrow the generic input
 * inside `buildDispatchMessage` so a future rename of
 * `ParseWorkerInput.content` would fail to compile inside the narrowed
 * branch instead of silently mismatching at runtime.
 */
function isParseWorkerItemArray(items) {
    if (items.length === 0)
        return false;
    for (const it of items) {
        if (it == null || typeof it !== 'object')
            return false;
        if (typeof it.path !== 'string')
            return false;
        if (typeof it.content !== 'string')
            return false;
    }
    return true;
}
/**
 * Build the sub-batch dispatch payload + transferList.
 *
 * For the parse-worker shape `{path, content: string}[]`, encodes each
 * file's content as a `Uint8Array` via `TextEncoder` so the underlying
 * `ArrayBuffer` can be transferred zero-copy. For any other input
 * shape, the items array is passed through verbatim (no transfer).
 *
 * @internal Exported for the unit test suite
 * (`test/unit/worker-pool-transferlist.test.ts`) so the
 * Uint8Array-per-content allocation contract can be pinned without
 * spinning up a real worker_threads.
 */
export function buildDispatchMessage(items) {
    if (!isParseWorkerItemArray(items)) {
        return { message: { type: 'sub-batch', files: items } };
    }
    // After the type guard, `items` is narrowed to `readonly ParseWorkerItem[]`.
    const encoder = new TextEncoder();
    const files = [];
    const transferList = [];
    for (const item of items) {
        const u8 = encoder.encode(item.content);
        files.push({ path: item.path, content: u8 });
        transferList.push(u8.buffer);
    }
    return {
        message: { type: 'sub-batch', files },
        transferList,
    };
}
export class WorkerPoolDispatchError extends Error {
    /**
     * Snapshot of the pool's session-scoped quarantine at the moment the
     * dispatch error was raised. Surfaced for operator diagnostics: when
     * the circuit breaker trips, this lists the files the pool had
     * already decided were unsafe before the trip. Read-only at the
     * caller boundary; no in-pool consumer rewires it post-construction.
     *
     * Previously named `fallbackExcludePaths` because the (since-
     * removed) sequential-parser fallback in `processParsing` consumed
     * it to filter the fallback file list. After U20's design pivot
     * (worker pool's resilience layers are the sole failure contract;
     * no sequential rescue), the field is informational only. The
     * rename clarifies semantics without changing wire behavior.
     */
    quarantinedPaths;
    constructor(message, quarantinedPaths = []) {
        super(message);
        this.name = 'WorkerPoolDispatchError';
        this.quarantinedPaths = quarantinedPaths;
    }
}
export class WorkerPoolInitializationError extends WorkerPoolDispatchError {
    readinessFailures;
    constructor(message, quarantinedPaths = [], readinessFailures = []) {
        super(message, quarantinedPaths);
        this.name = 'WorkerPoolInitializationError';
        this.readinessFailures = readinessFailures;
    }
}
/**
 * Max files to send to a worker in a single postMessage.
 * Keeps structured-clone memory bounded per sub-batch.
 */
const SUB_BATCH_SIZE = 1500;
const SUB_BATCH_MAX_BYTES = 8 * 1024 * 1024;
const DEFAULT_SUB_BATCH_IDLE_TIMEOUT_MS = 30_000;
const DEFAULT_TIMEOUT_RETRIES = 1;
const DEFAULT_TIMEOUT_BACKOFF_FACTOR = 2;
const DEFAULT_MAX_RESPAWNS_PER_SLOT = 3;
const DEFAULT_MAX_CUMULATIVE_TIMEOUT_FACTOR = 5;
const DEFAULT_CONSECUTIVE_FAILURE_THRESHOLD_FLOOR = 3;
/**
 * Bounded wait for a replacement worker to emit the `{type:'ready'}`
 * handshake from `parse-worker.ts`. Trusting Node's `online` event alone
 * lets a worker that crashes during top-of-script init slip past pool
 * startup — the pool only notices on the first dispatch's idle timeout
 * (default 30s). 5 seconds is a generous budget for parser + grammar
 * imports; if the worker hasn't reported ready by then, it's almost
 * certainly stuck or crashed and the pool should surface the failure
 * fast rather than wait out the dispatch idle timeout.
 */
const WORKER_READY_TIMEOUT_MS = 5_000;
/**
 * Default upper bound on auto-resolved pool size. Past 16 workers the
 * dominant cost shifts from worker-side parsing to main-thread merge /
 * extraction / structured-clone overhead, and the marginal worker adds
 * memory pressure (tree-sitter state + sub-batch buffer) without much
 * throughput gain. Operators on bigger machines override via
 * `GITNEXUS_WORKER_POOL_SIZE` or `--workers <N>`.
 */
const DEFAULT_POOL_SIZE_CAP = 16;
function positiveInteger(value) {
    const parsed = typeof value === 'string' ? Number(value) : value;
    return typeof parsed === 'number' && Number.isFinite(parsed) && parsed > 0
        ? Math.floor(parsed)
        : undefined;
}
function nonNegativeInteger(value) {
    const parsed = typeof value === 'string' ? Number(value) : value;
    return typeof parsed === 'number' && Number.isFinite(parsed) && parsed >= 0
        ? Math.floor(parsed)
        : undefined;
}
export function resolveWorkerPoolOptions(options = {}, poolSize) {
    const subBatchIdleTimeoutMs = positiveInteger(options.subBatchIdleTimeoutMs) ??
        positiveInteger(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS) ??
        DEFAULT_SUB_BATCH_IDLE_TIMEOUT_MS;
    return {
        subBatchSize: positiveInteger(options.subBatchSize) ?? SUB_BATCH_SIZE,
        subBatchMaxBytes: positiveInteger(options.subBatchMaxBytes) ??
            positiveInteger(process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES) ??
            SUB_BATCH_MAX_BYTES,
        subBatchIdleTimeoutMs,
        maxTimeoutRetries: nonNegativeInteger(options.maxTimeoutRetries) ?? DEFAULT_TIMEOUT_RETRIES,
        timeoutBackoffFactor: positiveInteger(options.timeoutBackoffFactor) ?? DEFAULT_TIMEOUT_BACKOFF_FACTOR,
        maxRespawnsPerSlot: nonNegativeInteger(options.maxRespawnsPerSlot) ??
            nonNegativeInteger(process.env.GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT) ??
            DEFAULT_MAX_RESPAWNS_PER_SLOT,
        maxCumulativeTimeoutMs: positiveInteger(options.maxCumulativeTimeoutMs) ??
            positiveInteger(process.env.GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS) ??
            subBatchIdleTimeoutMs * DEFAULT_MAX_CUMULATIVE_TIMEOUT_FACTOR,
        consecutiveFailureThreshold: positiveInteger(options.consecutiveFailureThreshold) ??
            positiveInteger(process.env.GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD) ??
            Math.max(DEFAULT_CONSECUTIVE_FAILURE_THRESHOLD_FLOOR, poolSize ?? 0),
    };
}
/**
 * Resolve the auto-default worker pool size when no explicit `poolSize`
 * arg is passed to `createWorkerPool`. Precedence:
 *
 * 1. `GITNEXUS_WORKER_POOL_SIZE` env var (operator override; set by
 *    `--workers <N>` on the CLI).
 * 2. `os.cpus().length - 1`, clamped to `[1, DEFAULT_POOL_SIZE_CAP]`.
 *
 * The cap exists because past ~16 workers the main-thread merge /
 * extraction work and structured-clone overhead dominate; adding more
 * worker threads costs memory without much throughput gain. Operators
 * who want to push past the cap set the env var explicitly.
 *
 * Exported for unit tests; production code should not call this
 * directly — pass an explicit `poolSize` to `createWorkerPool` or rely
 * on the env / default.
 */
export function resolveAutoPoolSize() {
    const envOverride = nonNegativeInteger(process.env.GITNEXUS_WORKER_POOL_SIZE);
    if (envOverride !== undefined)
        return envOverride;
    // Prefer os.availableParallelism (Node 18.14+) so cgroup CPU limits
    // (containers, taskset-restricted runtimes, CI runners with explicit
    // CPU quotas) are honored — os.cpus().length returns the host count,
    // which over-sizes the pool on constrained shapes and can reintroduce
    // the very "main-thread saturated by oversubscription" symptom the
    // pool cap exists to prevent. Falls back to os.cpus().length on
    // older Node versions. Mirrors `capabilities.ts:85`
    // (`defaultEmbeddingThreads`).
    const cores = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
    return Math.min(DEFAULT_POOL_SIZE_CAP, Math.max(1, cores - 1));
}
/**
 * Wait for a freshly-spawned replacement worker to emit the
 * `{type:'ready'}` handshake from `parse-worker.ts` before treating its
 * slot as dispatch-ready. Trusting Node's `online` event alone (which
 * fires when the worker thread starts, BEFORE the worker script's
 * top-of-script body runs) let a worker that crashes during init
 * (parser/grammar import failure, missing native binding) slip past
 * pool startup. The pool then only noticed the dead replacement on the
 * first dispatch's idle timeout (default 30s) — a long stall masking
 * an actual crash. This handshake bounds the wait at
 * {@link WORKER_READY_TIMEOUT_MS} and surfaces init failures as
 * `error` / `exit` / `messageerror` events directly. `messageerror` is
 * wired the same way: a V8 deserialization failure during startup is
 * treated as worker death and rejects the readiness promise.
 */
function waitForWorkerReady(worker) {
    return new Promise((resolve, reject) => {
        const cleanup = () => {
            clearTimeout(timer);
            worker.removeListener('message', onMessage);
            worker.removeListener('error', onError);
            worker.removeListener('exit', onExit);
            worker.removeListener('messageerror', onMessageError);
        };
        const onMessage = (msg) => {
            // Native postMessage delivers POJO directly via Node's structured
            // clone. The ready handshake is `{type:'ready'}`; any other early
            // message during the startup window is ignored — the eventual
            // timeout / exit / error handlers catch a genuinely-broken worker.
            if (typeof msg === 'object' && msg !== null && msg.type === 'ready') {
                cleanup();
                resolve();
            }
        };
        const onError = (err) => {
            cleanup();
            reject(err);
        };
        const onExit = (code) => {
            cleanup();
            reject(new Error(`Replacement worker exited with code ${code} before reporting ready`));
        };
        const onMessageError = (err) => {
            cleanup();
            reject(new Error(`Replacement worker emitted messageerror before reporting ready: ${err.message}`));
        };
        // `timer` is declared after `cleanup` so the cleanup closure can reference
        // it. The const is reached before any handler attaches below, so no TDZ
        // access can fire from the listeners.
        const timer = setTimeout(() => {
            cleanup();
            reject(new Error(`Replacement worker did not report ready within ${WORKER_READY_TIMEOUT_MS}ms — likely crashed during top-of-script init`));
        }, WORKER_READY_TIMEOUT_MS);
        worker.on('message', onMessage);
        worker.once('error', onError);
        worker.once('exit', onExit);
        worker.once('messageerror', onMessageError);
    });
}
function estimateItemBytes(item) {
    if (typeof item !== 'object' || item === null)
        return 0;
    const content = item.content;
    return typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : 0;
}
function itemPath(item) {
    if (typeof item !== 'object' || item === null)
        return undefined;
    const path = item.path;
    return typeof path === 'string' ? path : undefined;
}
/**
 * Best-guess path of the file in flight when a worker dies mid-job — used as
 * the fallback when the authoritative `starting-file` message hasn't been
 * observed yet (very early job-startup crash, or older worker build that
 * doesn't emit the signal).
 *
 * `lastProgress` is the number of files the worker has acknowledged via
 * `progress` messages, so `items[lastProgress]` is the next file it was
 * about to process — the most likely culprit when the worker crashes
 * (OOM, native addon SIGSEGV) or reports an error.
 *
 * Returns `[]` when no path is determinable so the caller retries the whole
 * job.
 */
function inFlightExcludePath(job, lastProgress) {
    if (lastProgress >= job.items.length)
        return [];
    const path = itemPath(job.items[lastProgress]);
    return path ? [path] : [];
}
function createJobs(items, maxItems, maxBytes, timeoutMs) {
    const jobs = [];
    let startIndex = 0;
    let batch = [];
    let batchBytes = 0;
    const flush = () => {
        if (batch.length === 0)
            return;
        jobs.push({
            startIndex,
            items: batch,
            estimatedBytes: batchBytes,
            attempt: 0,
            splitDepth: 0,
            timeoutMs,
            cumulativeTimeoutMs: timeoutMs,
        });
        startIndex += batch.length;
        batch = [];
        batchBytes = 0;
    };
    for (const item of items) {
        const itemBytes = estimateItemBytes(item);
        const wouldExceedItems = batch.length >= maxItems;
        const wouldExceedBytes = batch.length > 0 && batchBytes + itemBytes > maxBytes;
        if (wouldExceedItems || wouldExceedBytes)
            flush();
        batch.push(item);
        batchBytes += itemBytes;
    }
    flush();
    return jobs;
}
/**
 * Create a pool of worker threads.
 *
 * Resilience model (PR #1693 / 1694):
 * - Layer 1 (auto-respawn): a worker `error`/`exit` triggers a replacement on
 *   the same slot, bounded by {@link WorkerPoolOptions.maxRespawnsPerSlot}.
 *   The slot is dropped from the rotation when its budget is exhausted.
 * - Layer 2 (circuit breaker): `consecutiveFailureThreshold` consecutive
 *   worker deaths (no successful job between) — OR all slots exhausting their
 *   respawn budget — trip the breaker. Every subsequent dispatch rejects
 *   with `WorkerPoolDispatchError` and the caller must build a new pool.
 * - Layer 3 (quarantine): a path identified as the in-flight file at the
 *   time of a worker death is added to `quarantined` and filtered out of
 *   future dispatches. Snapshot via {@link WorkerPool.getQuarantinedPaths}.
 * - Layer 4 (authoritative in-flight): the worker emits a `starting-file`
 *   message before each parse attempt; the pool prefers this for crash
 *   attribution and falls back to {@link inFlightExcludePath} only when no
 *   signal has been observed yet.
 * - Layer 5 (cumulative timeout budget): each job tracks the total wall
 *   time spent across all attempts/splits/retries. When the budget is
 *   exhausted, the pool surfaces the in-flight path via `WorkerPoolDispatchError`
 *   instead of letting timeouts compound indefinitely.
 */
export const createWorkerPool = (workerUrl, poolSize, options) => {
    // Validate worker script exists before spawning to prevent uncaught
    // MODULE_NOT_FOUND crashes in worker threads (e.g. when running from src/ via vitest)
    const workerPath = fileURLToPath(workerUrl);
    if (!fs.existsSync(workerPath)) {
        throw new Error(`Worker script not found: ${workerPath}`);
    }
    const size = poolSize ?? resolveAutoPoolSize();
    const poolOptions = resolveWorkerPoolOptions(options, size);
    const spawnWorker = options?.workerFactory ?? ((url) => new Worker(url));
    const workers = new Array(size);
    const retiredWorkers = new Set();
    const respawnCount = new Array(size).fill(0);
    const activeSlots = new Set();
    // Layer 3 (quarantine): tracked via the dedicated `quarantine.ts`
    // module so the resilience layer is addressable as a unit (named
    // interface, isolated tests) rather than an inline Set tangled into
    // 1100+ LOC of pool plumbing. Public worker-pool API is unchanged —
    // `getQuarantinedPaths()` still returns the same defensive copy.
    const quarantine = createQuarantine();
    const initialReadinessFailures = [];
    // Per-slot consecutive-failure counter (F6): replaces the prior pool-wide
    // scalar so a chronically-failing slot trips the breaker on its own
    // failure streak instead of being masked by another slot's successes.
    // Reset to 0 on that slot's next successful job.
    const consecutiveFailuresPerSlot = new Array(size).fill(0);
    // Per-slot generation counter (U12). Incremented on every successful
    // worker replacement (see replaceWorker below). Handlers in the
    // dispatch loop capture the slot's generation at attach time and
    // short-circuit when they fire on a stale generation. Defensive layer
    // on top of the existing `settled` flag + listener removal — protects
    // against any future refactor that loosens cleanup() ordering or
    // re-attaches handlers without resetting the per-job state. Exposed
    // via getStats so operators (and tests) can verify a slot was
    // actually replaced and not just the same worker recycled.
    const slotGenerations = new Array(size).fill(0);
    let poolBroken = false;
    let poolFailure;
    const terminateTrackedWorkers = async (liveWorkers) => {
        const retired = Array.from(retiredWorkers);
        await Promise.all([
            ...liveWorkers.map((worker) => worker?.terminate().catch(() => undefined)),
            ...retired.map((record) => record.terminate()),
        ]);
        retiredWorkers.clear();
    };
    for (let i = 0; i < size; i++) {
        workers[i] = spawnWorker(workerUrl);
        activeSlots.add(i);
    }
    // Symmetrize the readiness gate across initial and replacement spawn
    // paths. `replaceWorker` already awaits `waitForWorkerReady` per
    // replacement so an init-crashing worker is dropped before dispatch
    // sees it. The initial-spawn loop above didn't — a worker whose
    // top-of-script init crashes (failed tree-sitter native binding,
    // missing dependency) would only be noticed at the first dispatch's
    // 30s idle timeout, vs the 5s WORKER_READY_TIMEOUT_MS bound that
    // replacements enjoy.
    //
    // The promise below settles every initial slot in parallel and drops
    // unready slots from `activeSlots` before any dispatch can fire.
    // `dispatch` awaits it via `initialReadyGate` on first invocation.
    // Wrapped in a single `Promise.allSettled` so a slow worker doesn't
    // block ready workers from being usable — first dispatch waits for
    // all slots' verdicts (good or bad).
    const initialReadyGate = Promise.allSettled(workers.map(async (w, i) => {
        if (!w)
            return;
        try {
            await waitForWorkerReady(w);
        }
        catch (err) {
            initialReadinessFailures.push(err instanceof Error ? err.message : String(err));
            logger.warn({
                workerIndex: i,
                err: err instanceof Error ? err.message : String(err),
            }, `Worker ${i} did not report ready on initial spawn; dropping slot.`);
            await w.terminate().catch(() => undefined);
            workers[i] = undefined;
            activeSlots.delete(i);
        }
    })).then(() => undefined);
    const dispatch = async (items, onProgress) => {
        // Await the initial-spawn readiness gate (F13). On first dispatch
        // this blocks for up to WORKER_READY_TIMEOUT_MS while every initial
        // worker's `{type:'ready'}` handshake is checked; on subsequent
        // dispatches the promise is already settled and resolves
        // synchronously. Slots whose initial worker crashed in top-of-
        // script init have been dropped from `activeSlots` by the gate
        // before this point — they don't surface here as "no active
        // workers" until *all* initial slots fail.
        await initialReadyGate;
        if (poolBroken) {
            const reason = poolFailure ? `: ${poolFailure.message}` : '';
            throw new WorkerPoolDispatchError(`Worker pool circuit breaker tripped${reason}. ` +
                `Subsequent dispatches require a fresh pool instance.`, []);
        }
        if (items.length === 0)
            return [];
        if (activeSlots.size === 0) {
            const detail = initialReadinessFailures.length > 0
                ? ` after initial ready handshake: ${initialReadinessFailures.join('; ')}`
                : '';
            throw new WorkerPoolInitializationError(`Worker pool has no active workers${detail}`, [], initialReadinessFailures);
        }
        // Layer 3: filter out quarantined paths so a known-bad file never reaches
        // a worker again this pool lifetime. The caller queries
        // `getQuarantinedPaths` after dispatch to route filtered items.
        const dispatchableItems = [];
        for (const item of items) {
            const path = itemPath(item);
            if (path !== undefined && quarantine.has(path))
                continue;
            dispatchableItems.push(item);
        }
        if (dispatchableItems.length === 0)
            return [];
        const jobs = createJobs(dispatchableItems, poolOptions.subBatchSize, poolOptions.subBatchMaxBytes, poolOptions.subBatchIdleTimeoutMs);
        return new Promise((resolve, reject) => {
            const results = [];
            const inFlightProgress = new Array(size).fill(0);
            // Tracks which slots are currently mid-job so the "wake idle slots"
            // pass after a requeue doesn't double-dispatch to a busy slot.
            const busySlots = new Set();
            // Per-conceptual-job (identified by startIndex) death count for the
            // unattributable-crash path (F5). On the 2nd time a job dies with
            // no exclusion attribution, requeueRemainder quarantines items[0]
            // as a best-guess culprit to break the death loop.
            const unattributedJobDeaths = new Map();
            let completedFiles = 0;
            let activeWorkers = 0;
            let stopped = false;
            let maxReported = 0;
            const wakeIdleSlots = () => {
                if (stopped || jobs.length === 0)
                    return;
                for (const slot of activeSlots) {
                    if (busySlots.has(slot))
                        continue;
                    if (jobs.length === 0)
                        break;
                    runWorker(slot);
                }
            };
            const reportProgress = () => {
                if (!onProgress)
                    return;
                const inFlight = inFlightProgress.reduce((sum, value) => sum + value, 0);
                const next = Math.min(dispatchableItems.length, Math.max(maxReported, completedFiles + inFlight));
                if (next === maxReported)
                    return;
                maxReported = next;
                onProgress(next);
            };
            const retireWorkerAfterTimeout = (worker, workerIndex, reason) => {
                let cleaned = false;
                let terminateStarted = false;
                function cleanupRetired() {
                    if (cleaned)
                        return;
                    cleaned = true;
                    worker.removeListener('message', onRetiredMessage);
                    worker.removeListener('error', onRetiredError);
                    worker.removeListener('exit', onRetiredExit);
                    worker.removeListener('messageerror', onRetiredMessageError);
                    retiredWorkers.delete(record);
                }
                async function terminateRetired() {
                    if (terminateStarted)
                        return;
                    terminateStarted = true;
                    cleanupRetired();
                    await worker.terminate().catch(() => undefined);
                }
                function terminateWhenBackInJs() {
                    void terminateRetired();
                }
                function onRetiredMessage(raw) {
                    if (raw === null || typeof raw !== 'object')
                        return;
                    const type = raw.type;
                    if (type === 'sub-batch-done' || type === 'result' || type === 'error') {
                        terminateWhenBackInJs();
                    }
                }
                const onRetiredError = () => cleanupRetired();
                const onRetiredExit = () => cleanupRetired();
                const onRetiredMessageError = () => terminateWhenBackInJs();
                const record = {
                    worker,
                    workerIndex,
                    reason,
                    cleanup: cleanupRetired,
                    terminate: terminateRetired,
                };
                retiredWorkers.add(record);
                worker.on('message', onRetiredMessage);
                worker.once('error', onRetiredError);
                worker.once('exit', onRetiredExit);
                worker.once('messageerror', onRetiredMessageError);
                worker.unref?.();
                logger.warn({ workerIndex, reason }, `Worker ${workerIndex} timed out; retiring without immediate terminate to avoid aborting native parser state.`);
            };
            const removeWorkerFromSlot = async (workerIndex, mode, reason) => {
                const existing = workers[workerIndex];
                workers[workerIndex] = undefined;
                if (!existing)
                    return;
                if (mode === 'retire') {
                    retireWorkerAfterTimeout(existing, workerIndex, reason);
                    return;
                }
                await existing.terminate().catch(() => undefined);
            };
            const replaceWorker = async (workerIndex, mode = 'terminate', reason = 'replacing worker') => {
                await removeWorkerFromSlot(workerIndex, mode, reason);
                if (stopped)
                    return false;
                const replacement = spawnWorker(workerUrl);
                try {
                    await waitForWorkerReady(replacement);
                }
                catch (err) {
                    await replacement.terminate().catch(() => undefined);
                    logger.warn({ workerIndex, error: err instanceof Error ? err.message : String(err) }, `Worker ${workerIndex} replacement failed to come online; dropping slot.`);
                    return false;
                }
                if (stopped) {
                    await replacement.terminate().catch(() => undefined);
                    return false;
                }
                workers[workerIndex] = replacement;
                // U12: bump the slot generation atomically with the worker swap so
                // any late event from the OLD worker that somehow slipped past
                // cleanup() carries a stale generation and short-circuits in the
                // handler guard below. Increment AFTER `workers[workerIndex]` is
                // updated so observers (getStats) see the new pair consistently.
                slotGenerations[workerIndex]++;
                return true;
            };
            // Terminal failure path: trip the pool circuit breaker and reject the
            // outer dispatch promise with the cumulative exclude paths. This is the
            // ONLY place that sets `poolBroken = true` — recoverable single-worker
            // failures stay local to `handleWorkerDeath`.
            //
            // Reject the caller's promise BEFORE awaiting `worker.terminate()` so a
            // stuck terminate (OOM-killed thread, hung native addon) can't block
            // the caller indefinitely. Worker cleanup runs in the background; the
            // next `dispatch` call sees `poolBroken=true` and rejects up front.
            const tripBreaker = (err) => {
                poolBroken = true;
                poolFailure = err;
                if (stopped)
                    return;
                stopped = true;
                reject(err);
                const liveWorkers = workers.slice();
                for (let i = 0; i < workers.length; i++)
                    workers[i] = undefined;
                activeSlots.clear();
                void terminateTrackedWorkers(liveWorkers);
            };
            const maybeDone = () => {
                if (stopped)
                    return;
                if (jobs.length === 0 && activeWorkers === 0) {
                    stopped = true;
                    results.sort((a, b) => a.startIndex - b.startIndex);
                    if (onProgress && maxReported < dispatchableItems.length)
                        onProgress(dispatchableItems.length);
                    resolve(results.map((result) => result.data));
                }
            };
            // Re-queue the non-quarantined remainder of a dead worker's job so a
            // healthy worker can finish the work. Earlier items in the dead job
            // were never flushed back to the main thread, so they must be
            // re-processed. The new job carries the existing job's startIndex so
            // result ordering is preserved. `cumulativeTimeoutMs` is carried
            // forward unchanged — the death itself consumed no timeout budget,
            // so charging another timeoutMs here would double-bill the next
            // `requeueAfterTimeout` call's accumulation.
            //
            // Unattributed-death tracking (F5): when called with `excluded=[]`
            // the worker died without identifying a culprit (no `starting-file`
            // observed, `lastProgress=0`, `items[lastProgress]` heuristic empty).
            // The first time, re-queue the job intact and hope another worker
            // succeeds. On the second such death of the SAME conceptual job
            // (same `startIndex`), quarantine `items[0]` as a best-guess
            // culprit so the next attempt isn't condemned to the same death.
            // This bounds the unattributable-crash death loop and ensures the
            // pool's final `quarantinedPaths` snapshot carries SOME signal
            // for downstream diagnostics instead of silently re-hitting the
            // bad file.
            const requeueRemainder = (job, excluded) => {
                let effectiveExcluded = excluded;
                if (excluded.length === 0) {
                    const deaths = (unattributedJobDeaths.get(job.startIndex) ?? 0) + 1;
                    unattributedJobDeaths.set(job.startIndex, deaths);
                    if (deaths < 2) {
                        jobs.unshift(job);
                        return;
                    }
                    const firstPath = itemPath(job.items[0]);
                    if (firstPath !== undefined) {
                        quarantine.add(firstPath);
                        logger.warn({ startIndex: job.startIndex, firstPath, deaths }, `Conceptual job ${job.startIndex} died ${deaths} times unattributably; ` +
                            `quarantining items[0] (${firstPath}) as best-guess culprit.`);
                        effectiveExcluded = [firstPath];
                    }
                    else {
                        // No identifiable file on items[0] either — drop the job to
                        // break the loop. The breaker counter still increments via
                        // handleWorkerDeath, so consecutive unattributable deaths
                        // eventually trip it even without quarantine signal.
                        logger.warn({ startIndex: job.startIndex, deaths }, `Conceptual job ${job.startIndex} died ${deaths} times unattributably with ` +
                            `no identifiable file; dropping job to break the death loop.`);
                        return;
                    }
                }
                const excludeSet = new Set(effectiveExcluded);
                const filtered = job.items.filter((item) => {
                    const p = itemPath(item);
                    return p === undefined || !excludeSet.has(p);
                });
                if (filtered.length === 0)
                    return;
                jobs.unshift({
                    startIndex: job.startIndex,
                    items: filtered,
                    estimatedBytes: filtered.reduce((sum, item) => sum + estimateItemBytes(item), 0),
                    attempt: job.attempt,
                    splitDepth: job.splitDepth,
                    timeoutMs: job.timeoutMs,
                    cumulativeTimeoutMs: job.cumulativeTimeoutMs,
                });
            };
            // Recoverable worker death — quarantine the in-flight path, attempt
            // to respawn the slot, re-queue the rest of the job, and continue.
            // Trips the circuit breaker only when consecutiveFailures crosses the
            // threshold OR all slots have exhausted their respawn budget.
            const handleWorkerDeath = async (workerIndex, reason, excludePaths, removalMode = 'terminate') => {
                if (stopped)
                    return;
                consecutiveFailuresPerSlot[workerIndex]++;
                for (const p of excludePaths) {
                    if (p)
                        quarantine.add(p);
                }
                if (consecutiveFailuresPerSlot[workerIndex] >= poolOptions.consecutiveFailureThreshold) {
                    tripBreaker(new WorkerPoolDispatchError(`${reason}. Pool circuit breaker tripped: slot ${workerIndex} hit ` +
                        `${consecutiveFailuresPerSlot[workerIndex]} consecutive failures ` +
                        `(threshold: ${poolOptions.consecutiveFailureThreshold}).`, quarantine.snapshot()));
                    return;
                }
                respawnCount[workerIndex]++;
                if (respawnCount[workerIndex] > poolOptions.maxRespawnsPerSlot) {
                    logger.warn({
                        workerIndex,
                        respawnCount: respawnCount[workerIndex],
                        maxRespawns: poolOptions.maxRespawnsPerSlot,
                        reason,
                    }, `Worker ${workerIndex} exceeded respawn budget; dropping slot.`);
                    await removeWorkerFromSlot(workerIndex, removalMode, reason);
                    activeSlots.delete(workerIndex);
                    if (activeSlots.size === 0) {
                        tripBreaker(new WorkerPoolDispatchError(`${reason}. All ${size} worker slot(s) exhausted their respawn budget.`, quarantine.snapshot()));
                        return;
                    }
                    return;
                }
                logger.warn({
                    workerIndex,
                    respawnCount: respawnCount[workerIndex],
                    reason,
                    excludePaths,
                }, `Worker ${workerIndex} died; respawning slot (attempt ${respawnCount[workerIndex]}/${poolOptions.maxRespawnsPerSlot}).`);
                const respawned = await replaceWorker(workerIndex, removalMode, reason);
                if (!respawned) {
                    activeSlots.delete(workerIndex);
                    if (activeSlots.size === 0) {
                        tripBreaker(new WorkerPoolDispatchError(`${reason}. Replacement worker startup failed and no slots remain.`, quarantine.snapshot()));
                    }
                    return;
                }
            };
            const requeueAfterTimeout = (workerIndex, job, lastProgress, inFlightPath) => {
                const nextTimeout = Math.ceil(job.timeoutMs * poolOptions.timeoutBackoffFactor);
                const nextCumulative = job.cumulativeTimeoutMs + nextTimeout;
                // Layer 5: respect the per-job cumulative timeout budget. Once
                // exhausted, surface the in-flight file via WorkerPoolDispatchError
                // instead of letting exponential backoff stall further.
                if (nextCumulative > poolOptions.maxCumulativeTimeoutMs) {
                    const firstPath = itemPath(job.items[0]);
                    const exhausted = inFlightPath !== undefined
                        ? [inFlightPath]
                        : firstPath !== undefined
                            ? [firstPath]
                            : [];
                    logger.warn({
                        workerIndex,
                        cumulativeMs: job.cumulativeTimeoutMs,
                        nextCumulativeMs: nextCumulative,
                        maxCumulativeMs: poolOptions.maxCumulativeTimeoutMs,
                        exhausted,
                    }, `Worker ${workerIndex} parse job exhausted cumulative timeout budget. Surfacing in-flight file(s).`);
                    return {
                        kind: 'give-up',
                        reason: `Worker ${workerIndex} parse job exhausted cumulative timeout budget ` +
                            `(${(nextCumulative / 1000).toFixed(0)}s > ${(poolOptions.maxCumulativeTimeoutMs / 1000).toFixed(0)}s cap)`,
                        excludePaths: exhausted,
                    };
                }
                if (job.items.length > 1) {
                    const midpoint = Math.ceil(job.items.length / 2);
                    const firstItems = job.items.slice(0, midpoint);
                    const secondItems = job.items.slice(midpoint);
                    const first = {
                        startIndex: job.startIndex,
                        items: firstItems,
                        estimatedBytes: firstItems.reduce((sum, item) => sum + estimateItemBytes(item), 0),
                        attempt: job.attempt,
                        splitDepth: job.splitDepth + 1,
                        timeoutMs: nextTimeout,
                        cumulativeTimeoutMs: nextCumulative,
                    };
                    const second = {
                        startIndex: job.startIndex + midpoint,
                        items: secondItems,
                        estimatedBytes: secondItems.reduce((sum, item) => sum + estimateItemBytes(item), 0),
                        attempt: job.attempt,
                        splitDepth: job.splitDepth + 1,
                        timeoutMs: nextTimeout,
                        cumulativeTimeoutMs: nextCumulative,
                    };
                    logger.warn({
                        workerIndex,
                        timeoutSec: job.timeoutMs / 1000,
                        items: job.items.length,
                        estimatedBytes: job.estimatedBytes,
                        lastProgress,
                        firstSplitItems: first.items.length,
                        secondSplitItems: second.items.length,
                        nextTimeoutSec: nextTimeout / 1000,
                    }, `Worker ${workerIndex} parse job idle timeout. Splitting into ${first.items.length}/${second.items.length} item jobs.`);
                    // Preserve intuitive retry order; final result order is still enforced by startIndex sort.
                    jobs.unshift(first, second);
                    return { kind: 'retry' };
                }
                const nextAttempt = job.attempt + 1;
                if (nextAttempt <= poolOptions.maxTimeoutRetries) {
                    logger.warn({
                        workerIndex,
                        timeoutSec: job.timeoutMs / 1000,
                        attempt: nextAttempt,
                        maxAttempts: poolOptions.maxTimeoutRetries + 1,
                        nextTimeoutSec: nextTimeout / 1000,
                    }, `Worker ${workerIndex} parse job idle timeout (single item). Retrying with ${nextTimeout / 1000}s timeout.`);
                    jobs.unshift({
                        ...job,
                        attempt: nextAttempt,
                        timeoutMs: nextTimeout,
                        cumulativeTimeoutMs: nextCumulative,
                    });
                    return { kind: 'retry' };
                }
                const stalledPath = inFlightPath ?? itemPath(job.items[0]);
                const excludes = stalledPath ? [stalledPath] : [];
                logger.warn({
                    workerIndex,
                    timeoutSec: job.timeoutMs / 1000,
                    stalledPath,
                    cumulativeMs: job.cumulativeTimeoutMs,
                }, `Worker ${workerIndex} parse job idle timeout exhausted retries; quarantining file and respawning slot.`);
                return {
                    kind: 'give-up',
                    reason: `Worker ${workerIndex} parse job idle timeout after ${job.timeoutMs / 1000}s ` +
                        `(single item${stalledPath ? `: ${stalledPath}` : ''}, ` +
                        `${job.estimatedBytes} bytes, last progress: ${lastProgress})`,
                    excludePaths: excludes,
                };
            };
            const runWorker = (workerIndex) => {
                if (stopped)
                    return;
                if (!activeSlots.has(workerIndex))
                    return;
                // Drop quarantined items that may have been re-queued before a death
                // added them to quarantine — keeps the worker from ever seeing a
                // known-bad file. Loops until we find a job with dispatchable items
                // or exhaust the queue (avoids recursion depth growth when many
                // queued jobs are fully quarantined back-to-back).
                let job;
                while ((job = jobs.shift()) !== undefined) {
                    if (quarantine.size === 0)
                        break;
                    const dispatchable = job.items.filter((item) => {
                        const p = itemPath(item);
                        return p === undefined || !quarantine.has(p);
                    });
                    if (dispatchable.length === 0)
                        continue;
                    if (dispatchable.length !== job.items.length) {
                        job.items = dispatchable;
                        job.estimatedBytes = dispatchable.reduce((sum, item) => sum + estimateItemBytes(item), 0);
                    }
                    break;
                }
                if (!job) {
                    maybeDone();
                    return;
                }
                activeWorkers++;
                busySlots.add(workerIndex);
                inFlightProgress[workerIndex] = 0;
                const worker = workers[workerIndex];
                if (!worker) {
                    // Slot's worker is undefined — typically mid-respawn (replaceWorker
                    // clears `workers[i]` before awaiting `waitForWorkerOnline`). The
                    // respawn IIFE / handleWorkerDeath that started the respawn owns
                    // calling runWorker when the new worker is online; we just
                    // unshift the job and bail.
                    //
                    // Do NOT call wakeIdleSlots from here: it would iterate
                    // `activeSlots` and re-enter `runWorker` for this same slot
                    // (now non-busy), find `workers[i]` still undefined, and
                    // recurse until the call stack overflows.
                    activeWorkers--;
                    busySlots.delete(workerIndex);
                    jobs.unshift(job);
                    maybeDone();
                    return;
                }
                let settled = false;
                let waitingForFlush = false;
                let idleTimer = null;
                let lastProgress = 0;
                // Authoritative in-flight file from the worker's `starting-file`
                // message. Cleared on `progress` so a between-files crash falls
                // back to the `items[lastProgress]` heuristic, which then points
                // at the next file (the one about to start) — the right guess.
                let inFlightPath;
                const resolveExcludePaths = () => {
                    if (inFlightPath !== undefined)
                        return [inFlightPath];
                    return inFlightExcludePath(job, lastProgress);
                };
                const cleanup = () => {
                    if (idleTimer)
                        clearTimeout(idleTimer);
                    worker.removeListener('message', handler);
                    worker.removeListener('error', errorHandler);
                    worker.removeListener('exit', exitHandler);
                    worker.removeListener('messageerror', messageErrorHandler);
                };
                const finishJob = () => {
                    activeWorkers--;
                    busySlots.delete(workerIndex);
                    inFlightProgress[workerIndex] = 0;
                    runWorker(workerIndex);
                    maybeDone();
                };
                // Recover-and-resume flow shared by all in-pool worker death sites
                // (`error`, `exit`, msg-channel error). Bridges the per-job teardown
                // into the pool-level handleWorkerDeath recovery + breaker logic.
                const recoverAndResume = async (reason, excludePaths) => {
                    activeWorkers--;
                    busySlots.delete(workerIndex);
                    inFlightProgress[workerIndex] = 0;
                    requeueRemainder(job, excludePaths);
                    await handleWorkerDeath(workerIndex, reason, excludePaths);
                    if (stopped)
                        return;
                    // Slot may have been dropped or respawned. Kick the current slot
                    // if still active, then wake any other idle live slots so the
                    // requeued remainder can be picked up immediately (without this,
                    // dropped-slot scenarios can deadlock when no other slot is
                    // currently busy and the next finishJob never fires).
                    if (activeSlots.has(workerIndex)) {
                        runWorker(workerIndex);
                    }
                    wakeIdleSlots();
                    maybeDone();
                };
                const resetIdleTimer = () => {
                    if (idleTimer)
                        clearTimeout(idleTimer);
                    idleTimer = setTimeout(() => {
                        if (!settled) {
                            settled = true;
                            cleanup();
                            inFlightProgress[workerIndex] = 0;
                            const stalledPath = inFlightPath;
                            const decision = requeueAfterTimeout(workerIndex, job, lastProgress, stalledPath);
                            if (decision.kind === 'give-up') {
                                // Give-up path: re-queue the non-quarantined remainder,
                                // then await handleWorkerDeath so we know when the slot
                                // is respawned (or dropped) and can dispatch the next
                                // job deterministically.
                                void (async () => {
                                    activeWorkers--;
                                    busySlots.delete(workerIndex);
                                    requeueRemainder(job, decision.excludePaths);
                                    await handleWorkerDeath(workerIndex, decision.reason, decision.excludePaths, 'retire');
                                    if (stopped)
                                        return;
                                    if (activeSlots.has(workerIndex))
                                        runWorker(workerIndex);
                                    wakeIdleSlots();
                                    maybeDone();
                                })();
                                return;
                            }
                            // Timeout-retry path: enforce the per-slot respawn budget
                            // BEFORE spawning a fresh worker. The previous version
                            // called `replaceWorker` unconditionally, letting a
                            // chronically-timing-out slot respawn forever.
                            //
                            // Also increment `consecutiveFailuresPerSlot` here so the
                            // per-slot circuit breaker sees pure-timeout death loops
                            // (not just crashes). Without it, a slot that consistently
                            // times out will consume its full respawn budget without
                            // the breaker ever firing — chronic timeouts are
                            // structurally the same kind of failure as crashes from
                            // the breaker's perspective.
                            void (async () => {
                                try {
                                    respawnCount[workerIndex]++;
                                    consecutiveFailuresPerSlot[workerIndex]++;
                                    // Complete the per-slot breaker contract on the
                                    // timeout-retry path. Without this check, chronic
                                    // pure-timeout deaths accumulated `consecutive-
                                    // FailuresPerSlot` increments that never tripped the
                                    // breaker — only the `respawnCount > maxRespawnsPerSlot`
                                    // slot-drop path was active. Now timeouts trip the
                                    // breaker on the same threshold as crashes, which is
                                    // what the increment was meant to enable.
                                    if (consecutiveFailuresPerSlot[workerIndex] >=
                                        poolOptions.consecutiveFailureThreshold) {
                                        logger.warn({
                                            workerIndex,
                                            consecutiveFailures: consecutiveFailuresPerSlot[workerIndex],
                                            threshold: poolOptions.consecutiveFailureThreshold,
                                        }, `Worker ${workerIndex} hit consecutive-failure threshold on idle-timeout retry; tripping circuit breaker.`);
                                        await removeWorkerFromSlot(workerIndex, 'retire', 'idle-timeout retry consecutive-failure threshold');
                                        activeSlots.delete(workerIndex);
                                        tripBreaker(new WorkerPoolDispatchError(`Worker pool tripped circuit breaker: slot ${workerIndex} hit ` +
                                            `${consecutiveFailuresPerSlot[workerIndex]} consecutive failures ` +
                                            `(threshold: ${poolOptions.consecutiveFailureThreshold}).`, quarantine.snapshot()));
                                        return;
                                    }
                                    if (respawnCount[workerIndex] > poolOptions.maxRespawnsPerSlot) {
                                        logger.warn({
                                            workerIndex,
                                            respawnCount: respawnCount[workerIndex],
                                            maxRespawns: poolOptions.maxRespawnsPerSlot,
                                        }, `Worker ${workerIndex} exceeded respawn budget during idle-timeout retry; dropping slot.`);
                                        await removeWorkerFromSlot(workerIndex, 'retire', 'idle-timeout retry respawn budget exhausted');
                                        activeSlots.delete(workerIndex);
                                    }
                                    else {
                                        const respawned = await replaceWorker(workerIndex, 'retire', 'idle-timeout retry');
                                        if (!respawned) {
                                            activeSlots.delete(workerIndex);
                                        }
                                    }
                                }
                                finally {
                                    activeWorkers--;
                                    busySlots.delete(workerIndex);
                                }
                                if (stopped)
                                    return;
                                if (activeSlots.size === 0) {
                                    tripBreaker(new WorkerPoolDispatchError(`Worker pool exhausted all slots during idle-timeout retry.`, quarantine.snapshot()));
                                    return;
                                }
                                reportProgress();
                                if (activeSlots.has(workerIndex))
                                    runWorker(workerIndex);
                                wakeIdleSlots();
                                maybeDone();
                            })();
                        }
                    }, job.timeoutMs);
                };
                // U12: capture the slot's generation at handler-attach time so any
                // late event from a previous worker on this slot (which would carry
                // an older generation) short-circuits below. Defensive — cleanup()
                // already removes listeners synchronously when a death is observed,
                // so under the current control flow no listener should fire on a
                // stale generation. The guard catches future-refactor mistakes.
                const slotGen = slotGenerations[workerIndex];
                const handler = (raw) => {
                    if (slotGenerations[workerIndex] !== slotGen)
                        return;
                    if (settled || stopped)
                        return;
                    // Native postMessage delivers POJO directly via Node's
                    // structured clone. V8 deserialization failures (malformed
                    // frame, non-cloneable value) surface as a `messageerror`
                    // event handled below — they never reach this handler. The
                    // only thing we need to guard for here is a worker that
                    // sends a message without a `type` discriminant (a bug in
                    // the worker, not a wire-format issue): without the guard
                    // `null.type` would throw a TypeError out of the
                    // EventEmitter listener → uncaughtException on the main
                    // thread.
                    const msg = raw;
                    if (msg === null || typeof msg !== 'object' || typeof msg.type !== 'string') {
                        settled = true;
                        cleanup();
                        void recoverAndResume(`Worker ${workerIndex} sent a malformed message (no type discriminant)`, resolveExcludePaths());
                        return;
                    }
                    if (msg.type === 'starting-file') {
                        inFlightPath = msg.path;
                        resetIdleTimer();
                    }
                    else if (msg.type === 'progress') {
                        const bounded = Math.min(job.items.length, Math.max(0, msg.filesProcessed));
                        inFlightProgress[workerIndex] = bounded;
                        lastProgress = bounded;
                        inFlightPath = undefined;
                        resetIdleTimer();
                        reportProgress();
                    }
                    else if (msg.type === 'warning') {
                        resetIdleTimer();
                        logger.warn(msg.message);
                    }
                    else if (msg.type === 'sub-batch-done') {
                        waitingForFlush = true;
                        resetIdleTimer();
                        worker.postMessage({ type: 'flush' });
                    }
                    else if (msg.type === 'error') {
                        settled = true;
                        cleanup();
                        void recoverAndResume(`Worker ${workerIndex} error: ${msg.error}`, resolveExcludePaths());
                    }
                    else if (msg.type === 'result') {
                        if (!waitingForFlush) {
                            settled = true;
                            cleanup();
                            tripBreaker(new WorkerPoolDispatchError(`Worker ${workerIndex} protocol error: result before flush`, quarantine.snapshot()));
                            return;
                        }
                        settled = true;
                        cleanup();
                        results.push({ startIndex: job.startIndex, data: msg.data });
                        completedFiles += job.items.length;
                        // Layer 2 (F6): a successful job resets THIS slot's
                        // consecutive-failure counter so the breaker only trips
                        // when a specific slot is chronically failing — another
                        // slot's successes can't mask a single bad slot.
                        consecutiveFailuresPerSlot[workerIndex] = 0;
                        reportProgress();
                        finishJob();
                    }
                    else if (msg.type === 'ready') {
                        // No-op: the ready handshake is consumed by `waitForWorkerReady`
                        // before dispatch handlers are attached. A stray `ready` here
                        // (e.g., a future worker build re-emitting after an internal
                        // recovery) is benign — ignore so the exhaustiveness check
                        // below keeps catching genuinely-unknown variants.
                    }
                    else {
                        // F7: exhaustiveness check — drift-catcher when a future
                        // WorkerOutgoingMessage variant is added without a handler.
                        const _exhaustive = msg;
                        void _exhaustive;
                    }
                };
                const errorHandler = (err) => {
                    if (slotGenerations[workerIndex] !== slotGen)
                        return;
                    if (!settled) {
                        settled = true;
                        cleanup();
                        void recoverAndResume(`Worker ${workerIndex} error: ${err.message}`, resolveExcludePaths());
                    }
                };
                const exitHandler = (code) => {
                    if (slotGenerations[workerIndex] !== slotGen)
                        return;
                    if (!settled) {
                        settled = true;
                        cleanup();
                        const excludes = resolveExcludePaths();
                        const inFlightSuffix = excludes.length > 0 ? ` (in-flight: ${excludes[0]})` : '';
                        void recoverAndResume(`Worker ${workerIndex} exited with code ${code}. ` +
                            `Likely OOM or native addon failure${inFlightSuffix}.`, excludes);
                    }
                };
                // `messageerror` fires when V8 fails to deserialize a postMessage
                // payload (e.g., the worker tries to send a non-cloneable value
                // back, or structured-clone hits an unsupported shape). The worker
                // stays ALIVE but the message is lost — without this handler the
                // pool would sit on the dropped message until the idle timeout
                // expires. Treat it as worker death so the resilience layers fire:
                // requeue the remainder via `recoverAndResume`, attribute the
                // in-flight file from the `starting-file` signal (if observed),
                // and let the per-slot respawn budget and circuit breaker decide
                // whether to keep this slot in rotation.
                const messageErrorHandler = (err) => {
                    if (slotGenerations[workerIndex] !== slotGen)
                        return;
                    if (!settled) {
                        settled = true;
                        cleanup();
                        void recoverAndResume(`Worker ${workerIndex} messageerror (postMessage deserialization failure): ${err.message}`, resolveExcludePaths());
                    }
                };
                worker.on('message', handler);
                worker.once('error', errorHandler);
                worker.once('exit', exitHandler);
                worker.once('messageerror', messageErrorHandler);
                resetIdleTimer();
                if (stopped) {
                    cleanup();
                    return;
                }
                const { message, transferList } = buildDispatchMessage(job.items);
                if (transferList) {
                    worker.postMessage(message, transferList);
                }
                else {
                    worker.postMessage(message);
                }
            };
            for (const slotIndex of activeSlots)
                runWorker(slotIndex);
        });
    };
    let terminated = false;
    const terminate = async () => {
        terminated = true;
        // `.catch(() => undefined)` per-worker matches every other terminate
        // site in this file. Without it, a hung/OOM-killed worker's terminate
        // rejection escapes `Promise.all` and replaces the original pipeline
        // exception when this is called from `runChunkedParseAndResolve`'s
        // finally block — masking the real failure and leaving `workers[]`
        // populated with dead references because the lines below never run.
        await terminateTrackedWorkers(workers);
        workers.length = 0;
        activeSlots.clear();
    };
    return {
        dispatch,
        terminate,
        size,
        getQuarantinedPaths: () => quarantine.snapshot(),
        getStats: () => ({
            size,
            activeSlots: activeSlots.size,
            droppedSlots: size - activeSlots.size,
            quarantined: quarantine.size,
            poolBroken,
            terminated,
            slotGenerations: slotGenerations.slice(),
        }),
    };
};
