Files
learnharness/app/api/activity/abort/route.ts
T
amosblomqvist 88eb4b2ec0 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.
2026-09-29 20:42:33 +03:00

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 });
}