Rulings: each problem generation is its own visible dock job (the extraction per-chunk pattern), and practice shares extraction's semaphore — one process-wide 3-permit pool for ALL background LLM jobs; total background LLM concurrency bounded at 3. A. SHARED POOL: new src/agent/subagents/bg-llm-semaphore.ts — globalThis __lhLlmSlots, PERMIT_COUNT 3 (the ruling), the fresh-take/handed-off-permit deadlock discipline preserved for BOTH callers. extraction.ts's withExtractSlot is now a thin wrapper (job-row signal → withBgLlmSlot); __lhExtractSlots and __lhPracticeSlots both die. The partitioner now acquires a permit too (it's a background LLM job — it didn't before). B. REGISTRY ROWS: the aggregate setId:generation / setId:grading rows are replaced by setId:partition (phase partitioning, label 'Partitioning — title'), setId:gen:problemId (queued→generating, 'Problem i/N — title'), setId:grade:problemId (queued→grading, 'Grading i/N — title'). Each row carries its OWN abort controller and its OWN usage (one PracticeStepLog per row — the aggregate partitioner/generator/grader folding dies). Row key = feed id = abort key (the dock dup-key law). C. ABORT SEMANTICS: abortPracticeRow(key) routes by shape — gen row: THAT problem only (error 'Aborted', siblings continue, set settles ready with one errored problem); partition row: the whole run; grade row: that grade drops (answer kept, re-submittable). abortPractice(setId) composes from row aborts + the synchronous persisted marks (a set-abort's verdict stands before the settles re-read it — the settle step keeps an existing error status). /api/activity/abort kind practice now takes the ROW key (charset admits ':'). D. FEED + DOCK: ActivityPractice/FeedPractice carry per-row identity (id = row key, problemIndex/problemTotal); dock blobs render per row, labels carry the i/N; the aggregate row is gone. E. Boot sweep unchanged at set grain; the live-row check is now a key-prefix scan (any live row of the set protects it). The updatedAt heartbeat covers the new row scheme as-is. Tests (20 in the pipeline suite): per-problem row shape/uniqueness/ i-N labels (partition row frozen behind a gated partitioner; per-row usage ISOLATION pin — b's log never shows on c's row); abort-one-gen (sibling completes, set ready with one errored problem); partition- row abort; queued grade-row abort (dead-ticket path: answer kept, re-submittable, sibling lands); shared-pool identity + contention (practice queues behind extraction-style holders); registry/dock feed mapping. Test isolation hardened: unique per-store set ids, afterEach registry/lock/pool reset (a timed-out test can no longer poison the rest). architecture.md: jobs-pattern paragraph rewritten (per-problem rows, shared pool, abort routing). 1149/1149, tsc 0, lint 0, build 0.
93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
/**
|
|
* THE SHARED BACKGROUND-LLM SEMAPHORE (owner ruling, 2026-09-29): ONE
|
|
* process-wide pool of 3 permits for ALL background LLM jobs — extraction
|
|
* chunks AND practice partitioner/generators/graders. Total background
|
|
* LLM concurrency is bounded at 3, whoever is calling: no subsystem gets
|
|
* a private lane past the provider-facing stance.
|
|
*
|
|
* Extraction owned this gate alone first (owner, 2026-09-28: at most 3
|
|
* concurrent extractor calls). Practice grew its own twin that evening;
|
|
* the ruling then unified them — this module is the single truth, so
|
|
* extraction.ts and practice-pipeline.ts must never cross-import.
|
|
*
|
|
* FIFO; abort-aware (a queued caller that aborts marks its ticket dead —
|
|
* the permit handoff skips it and hands to the next waiter).
|
|
*
|
|
* FRESH-TAKE / HANDED-OFF-PERMIT discipline (the double-count deadlock
|
|
* lesson, extraction 2026-09-28): fresh takers increment `active`
|
|
* themselves; a HANDED-OFF permit does not (the releaser left `active`
|
|
* untouched for the inheritor) — counting both sides is how a semaphore
|
|
* slowly deadlocks at 3-with-empty-queue.
|
|
*
|
|
* The audit side needs no slot: audits serialize behind their own global
|
|
* lock, so by the time a job releases its permit the audit queue is the
|
|
* only remaining throttle.
|
|
*/
|
|
|
|
/** Total background LLM concurrency, process-wide, across ALL subsystems
|
|
* (owner ruling 2026-09-29: one shared pool — extraction chunks +
|
|
* practice partitioner/generators/graders). */
|
|
const PERMIT_COUNT = 3;
|
|
|
|
export interface LlmSlots {
|
|
active: number;
|
|
queue: { resolve: () => void; dead: boolean }[];
|
|
}
|
|
|
|
interface LhLlmSemaphoreGlobal {
|
|
__lhLlmSlots?: LlmSlots;
|
|
}
|
|
|
|
/** The process's single permit pool (globalThis — Next dev compiles each
|
|
* route entry as its own module instance; a module-level object would
|
|
* split the pool per route. One process, one truth). */
|
|
export function bgLlmSlots(): LlmSlots {
|
|
const g = globalThis as typeof globalThis & LhLlmSemaphoreGlobal;
|
|
return (g.__lhLlmSlots ??= { active: 0, queue: [] });
|
|
}
|
|
|
|
/** Run `critical` under one permit of the shared pool. `signal` (the
|
|
* caller's row/abort controller) kills a queued ticket: it marks itself
|
|
* dead and rejects AbortError — the handoff skips it. */
|
|
export async function withBgLlmSlot<T>(
|
|
signal: AbortSignal | undefined,
|
|
critical: () => Promise<T>,
|
|
): Promise<T> {
|
|
const slots = bgLlmSlots();
|
|
let freshTake = true;
|
|
if (slots.active >= PERMIT_COUNT) {
|
|
freshTake = false;
|
|
await new Promise<void>((resolve, reject) => {
|
|
const ticket: { resolve: () => void; dead: boolean } = { resolve, dead: false };
|
|
slots.queue.push(ticket);
|
|
if (!signal) return;
|
|
const die = () => {
|
|
ticket.dead = true; // the permit handoff must skip us
|
|
const err = new Error("background LLM job aborted while queued for a permit");
|
|
err.name = "AbortError";
|
|
reject(err);
|
|
};
|
|
if (signal.aborted) die();
|
|
else signal.addEventListener("abort", die, { once: true });
|
|
});
|
|
}
|
|
if (freshTake) slots.active++;
|
|
try {
|
|
return await critical();
|
|
} finally {
|
|
// Permit handoff: a waiting ticket inherits the permit directly
|
|
// (active stays); dead tickets are skipped — their job already
|
|
// left through the abort path.
|
|
for (;;) {
|
|
const next = slots.queue.shift();
|
|
if (!next) {
|
|
slots.active--;
|
|
break;
|
|
}
|
|
if (next.dead) continue;
|
|
next.resolve();
|
|
break;
|
|
}
|
|
}
|
|
}
|