/** * The dispatcher's side of admission: how many slots there are, what to do with * the candidates that got one, and how to describe the ones that did not. * * The gates themselves are `admission.ts` at the top level — pure, storeless, * and shared with everything that needs to ask "would this be admitted". This * module is deliberately the other half: it holds no rule, only the concurrency * primitive every pass launches through (`WorkerPool`) and the two summaries the * tick logs and the digest read. Hence the name — it is the *pass*, not the * policy, and the two must not be one file or the policy becomes untestable * without a pool. */ import type { Admission, AdmissionHold } from "../admission.ts"; import type { AdmissionHoldReason, DispatchSummary } from "../types.ts"; export const HOLD_SAMPLE_SIZE = 5; export const DEGRADED_HOLDS: ReadonlySet = new Set([ "parent-lookup-error", "open-pr-lookup-error", "issue-state-lookup-error", "critical-base-verify-error", ]); /** Groups transient decisions into the bounded record exposed by status. */ export function summarizeDispatch( ready: number, routed: number, claimed: number, admitted: number, holds: readonly AdmissionHold[], completedAt = Date.now(), settled = 0, parked = 0, ): DispatchSummary { const groups = new Map(); for (const hold of holds) { const group = groups.get(hold.reason) ?? { count: 0, issues: [], details: [] }; group.count += 1; if (group.issues.length < HOLD_SAMPLE_SIZE) { // `issues` and `details` share the sample: the detail is only kept when // the issue it explains is, so the arrays stay index-aligned. group.issues.push(hold.issue); if (hold.detail !== undefined) group.details.push(hold.detail); } groups.set(hold.reason, group); } return { completedAt, ready, routed, claimed, admitted, degraded: holds.some((hold) => DEGRADED_HOLDS.has(hold.reason)), holds: [...groups] .sort(([a], [b]) => a.localeCompare(b)) .map(([reason, group]) => ({ reason, count: group.count, issues: group.issues, ...(group.details.length === 0 ? {} : { details: group.details }), })), settled, // Omitted at zero so a pass with nothing parked keeps the pre-#507 record // shape byte for byte — old persisted rows lack the key and readers use // `?? 0` either way. ...(parked === 0 ? {} : { parked }), }; } /** * The record of a held pass (#497): the loop ran — settling and reconciling * above the pause gate — but routing never happened and nothing was admitted. * `ready`/`routed`/`claimed` stay 0 because they were never computed; the * `paused` flag is what keeps a reader from reading them as an empty queue. */ export function summarizeHeldPass(settled: number, completedAt = Date.now()): DispatchSummary { return { completedAt, ready: 0, routed: 0, claimed: 0, admitted: 0, degraded: false, holds: [], settled, paused: true, }; } export interface WorkerPool { launch(work: Promise): void; activeCount(): number; drain(): Promise; } /** * Keeps background workers alive without making the five-minute tick await them. * * `onSettled` fires as each worker leaves the pool, whichever way it ended * (#878): that is the instant a slot frees, and without it queued work waited * for the next scheduled pass — up to five minutes of idle capacity that reads, * from outside, exactly like a stalled queue. It only *prompts* a pass; every * hold, drain, lane, budget and routing gate is re-evaluated by that pass as * usual. */ export function createWorkerPool(onSettled?: () => void): WorkerPool { const active = new Set>(); return { launch(work) { active.add(work); const settled = (): void => { active.delete(work); onSettled?.(); }; void work.then(settled, settled); }, activeCount: () => active.size, async drain() { await Promise.allSettled(active); }, }; } /** `--once` awaits workers; the resident daemon registers them for shutdown. */ export async function dispatchAdmissions( admitted: readonly Admission[], run: (admission: Admission) => Promise, pool?: WorkerPool, ): Promise { if (pool !== undefined) { for (const admission of admitted) pool.launch(run(admission)); return; } await Promise.allSettled(admitted.map(run)); }