practice restructure — per-problem jobs + ONE shared background-LLM pool (owner ruling 2026-09-29)
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.
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
} from "../../core/settings.ts";
|
||||
import type { PiEngine } from "../models.ts";
|
||||
import { runSubagent } from "./pi-run.ts";
|
||||
import { withBgLlmSlot } from "./bg-llm-semaphore.ts";
|
||||
import type {
|
||||
AuditVerdicts,
|
||||
CallUsage,
|
||||
@@ -526,7 +527,6 @@ interface LhExtractionGlobal {
|
||||
* calls process-wide — the provider-facing rate stance (the import
|
||||
* route's old 3-wide pool doctrine, now global). Per-chunk jobs fan
|
||||
* out in parallel but queue here. */
|
||||
__lhExtractSlots?: { active: number; queue: { resolve: () => void; dead: boolean }[] };
|
||||
}
|
||||
|
||||
/** THE RUN REGISTRIES live on globalThis, not module level — the
|
||||
@@ -796,60 +796,19 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
}
|
||||
|
||||
|
||||
/** THE EXTRACT SEMAPHORE (owner, 2026-09-28): at most 3 concurrent
|
||||
* extractor calls, process-wide — the provider-facing stance. Per-chunk
|
||||
* jobs fan out freely; this gate is what they queue behind ("queued").
|
||||
* FIFO; abort-aware (a queued job that aborts marks its ticket dead —
|
||||
* the permit handoff skips it and hands to the next waiter). The audit
|
||||
* side needs no slot: audits already serialize behind their own global
|
||||
* lock, so by the time a job releases its extract slot the audit queue
|
||||
* is the only remaining throttle. */
|
||||
/** THE EXTRACT SLOT (unified 2026-09-29): extraction chunk jobs run
|
||||
* under the SHARED background-LLM semaphore — one process-wide pool
|
||||
* of 3 permits for extraction chunks AND practice's partitioner/
|
||||
* generators/graders (owner ruling; see bg-llm-semaphore.ts). The
|
||||
* job's registry-row signal kills a queued ticket (dead-ticket
|
||||
* handoff). The audit side needs no slot: audits already serialize
|
||||
* behind their own global lock. */
|
||||
async function withExtractSlot<T>(jobKey: string, critical: () => Promise<T>): Promise<T> {
|
||||
const g = globalThis as typeof globalThis & LhExtractionGlobal;
|
||||
const slots = (g.__lhExtractSlots ??= { active: 0, queue: [] });
|
||||
// Fresh takers increment `active` themselves; a HANDED-OFF permit
|
||||
// does not (the releaser left `active` untouched for us) — counting
|
||||
// both sides is how a semaphore slowly deadlocks at 3-with-empty-
|
||||
// queue.
|
||||
let freshTake = true;
|
||||
if (slots.active >= 3) {
|
||||
freshTake = false;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ticket: { resolve: () => void; dead: boolean } = { resolve, dead: false };
|
||||
slots.queue.push(ticket);
|
||||
const signal = runningExtractions.get(jobKey)?.controller.signal;
|
||||
if (!signal) return;
|
||||
const die = () => {
|
||||
ticket.dead = true; // the permit handoff must skip us
|
||||
const err = new Error("extraction aborted while queued for an extract slot");
|
||||
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 slot 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;
|
||||
}
|
||||
}
|
||||
const signal = runningExtractions.get(jobKey)?.controller.signal;
|
||||
return withBgLlmSlot(signal, critical);
|
||||
}
|
||||
|
||||
/** Append the run's usage record to the chat's extractions log — success
|
||||
/** Append the run's usage record to the chat's extractions log — success
|
||||
* or failure: a failed run still spent money, and its partial usage is
|
||||
* exactly what the record should show (stats absent = never merged).
|
||||
* Idempotent per startedAt (the store dedupes). */
|
||||
|
||||
Reference in New Issue
Block a user