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.
Owner report: created a set, 'Interrupted (server restarted)' showed
on list + set views, generation proceeded and overwrote it. The
sweep's 'generating + no registry row' test had a false-positive
mode: it ran at EVERY pipeline instantiation, and an instance could
evaluate against a registry whose fresh row it couldn't observe.
Three guards, per ruling:
1. HEARTBEAT: ProblemSet.updatedAt? (contract + doc) — bumped on
every store write; createSet stamps it = createdAt; saveSet
re-stamps (validator tolerates absence for legacy rows).
2. ONCE PER PROCESS: the sweep guards on globalThis
.__lhPracticeSweepDone — no instance re-evaluates after the
process's first sweep.
3. GRACE WINDOW: only sets whose heartbeat (updatedAt ?? createdAt)
is older than 120s are stamped. A live run saves at every phase/
problem transition, so it is structurally unreachable; a true
orphan is minutes stale at next boot. One [practice <id>] log
line per stamped set (the extraction/jobs console.error idiom).
Tests: stale orphan stamped; FRESH generating set untouched (grace);
once-guard (second factory instantiation never re-sweeps); live-row
protection re-armed via resetSweep() test seam; store pins for
updatedAt birth-stamp + re-stamp + legacy tolerance. Architecture.md
sweep sentence updated (once-per-process + grace semantics).
1142/1142, tsc 0, lint 0, build 0.
Owner ruling: 'problem sets always take in nodes — graph objects' —
practice is graph-domain, not chat-domain. No session-created sets.
API: POST /api/practice/sets takes {nodeIds, params, title?} ONLY —
the chatId branch, UnknownChatError, and the any-provenance filter are
deleted; the selector is nodeIds (readNodes filtered to the selection,
unknown ids drop, empty live intersection → 400 empty_selection);
title optional from the UI (the picker's cluster label), dated default
stays. chatStore leaves the pipeline deps entirely (labels resolve
store-side in the clusters service).
GET /api/graph/clusters (new): the birth-session groups the graph view
renders — graphSnapshot's clusters verbatim (same ordering, same
MIN_CLUSTER_SIZE presentation filter), mapped to {key, kind
chat|notes, label (null when the naming record is gone — honest,
never guessed), nodeIds, nodeCount}; corrupt/empty graph → empty
list. GraphClusterGroup contract in core/contracts/graph.ts (client-
safe — the dialog imports it).
practice-dialog: session picker → cluster picker (rows show label +
proposition count, notes clusters marked); Create posts the cluster's
nodeIds + label as title + params. 400 empty-selection copy reworded
for the vanished-nodes edge ('Nothing practicable is left in that
cluster — the graph changed.'); 503 deep link unchanged. Preselected
mode gone — the dialog is the only creation entry for now (the graph-
view cluster entry comes later with free-form selection).
Sidebar: session right-click 'Practice this session's propositions'
removed (item + state + dialog wiring); the /practice nav row stays.
Tests: pipeline — chatId/any-provenance tests die, nodeIds selection
pins added (unknown-id filtering, dated-default title, title hint
rides, empty/vanished → EmptySelectionError); render — cluster picker
pins (label+count rows, notes kind, unlabeled cluster muted, empty
graph line), preselected pins removed; 16 render + 13 pipeline.
Docs: architecture.md §7 API+UI paragraph rewritten (graph-domain
ruling, /api/graph/clusters, cluster picker as the only entry, no
404-unknown-chat); README creation-flow line updated.
1130/1130, tsc 0, lint 0, build 0.