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.
65 lines
2.9 KiB
TypeScript
65 lines
2.9 KiB
TypeScript
/**
|
|
* POST /api/activity/abort — the dock's END/ABORT control, uniform across
|
|
* process kinds (user-decided contract): body {kind: "job"|"extraction"|"turn", id}
|
|
* where id is the jobId for sub-agent jobs, the chatId for extraction runs
|
|
* (the pipeline registry is chat-keyed), and the chatId for turns
|
|
* (background-turn wave, 2026-09-25: a running turn is a background
|
|
* object — any client may stop it here; a view leaving NEVER kills it).
|
|
* Mechanisms live in their domains — abortJob (server/jobs.ts, agents
|
|
* stream), abortExtraction (the pipeline module, graph stream), and
|
|
* abortTurn (server/turn-runs.ts, the running-turn registry); this route
|
|
* is the thin composition.
|
|
*
|
|
* Outcomes: a job's not_found -> 404, not_running -> 409, aborted -> 200.
|
|
* A turn mirrors the job semantics: not_running -> 409 (honest "nothing to
|
|
* abort" — the caller refetches), aborted -> 200 (partial persists via
|
|
* the usual settle path, every subscriber closes cleanly).
|
|
* Extraction abort is idempotent by design (aborting with nothing in
|
|
* flight is success — the blob is gone either way) -> 200.
|
|
*/
|
|
import { abortExtraction, abortJob, abortTurn, practice } from "@/src/server";
|
|
|
|
export async function POST(req: Request) {
|
|
let body: unknown;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
}
|
|
const { kind, id } = (body ?? {}) as { kind?: string; id?: string };
|
|
if ((kind !== "job" && kind !== "extraction" && kind !== "turn" && kind !== "practice") || typeof id !== "string" || id === "") {
|
|
return Response.json({ error: 'Body must be {kind: "job"|"extraction"|"turn"|"practice", id: string}' }, { status: 400 });
|
|
}
|
|
// Practice row keys carry ":" (setId:gen:problemId) — the charset
|
|
// admits it for every kind (job/turn/extraction ids never use it).
|
|
if (!/^[A-Za-z0-9:_-]+$/.test(id)) {
|
|
return Response.json({ error: "Invalid id" }, { status: 400 });
|
|
}
|
|
|
|
if (kind === "job") {
|
|
const outcome = abortJob(id);
|
|
if (!outcome.ok) {
|
|
const status = outcome.reason === "not_found" ? 404 : 409;
|
|
return Response.json({ ok: false, reason: outcome.reason }, { status });
|
|
}
|
|
return Response.json({ ok: true });
|
|
}
|
|
if (kind === "turn") {
|
|
const outcome = abortTurn(id);
|
|
if (!outcome.ok) return Response.json({ ok: false, reason: outcome.reason }, { status: 409 });
|
|
return Response.json({ ok: true });
|
|
}
|
|
// kind === "extraction": true cancellation; idempotent (nothing in
|
|
// flight is success — same dock outcome)
|
|
if (kind === "practice") {
|
|
// practice restructure (owner, 2026-09-29): id is the ROW key —
|
|
// abort routes by shape (a gen row kills THAT problem only; the
|
|
// partition row ends the run; a grade row drops that grade).
|
|
// Idempotent like extraction (nothing in flight is success).
|
|
practice.abortPracticeRow(id);
|
|
return Response.json({ ok: true });
|
|
}
|
|
abortExtraction(id);
|
|
return Response.json({ ok: true });
|
|
}
|