push architecture slice 7: graph-status + practice surfaces ride the events channel; coverage polls accepted-by-design; stale poll comments + architecture.md observability section rewritten to the push model

This commit is contained in:
amosblomqvist
2026-10-02 18:03:47 +03:00
parent 8795345c82
commit 26131b3599
13 changed files with 190 additions and 104 deletions
+7 -6
View File
@@ -3,12 +3,13 @@ import { isTurnRunning, listJobs } from "@/src/server";
export const dynamic = "force-dynamic";
/**
* Job status polling endpoint (contracts.ts "THE ASYNC PROTOCOL" §4): the
* client's ONE central poller fetches this while any job runs (and slowly
* otherwise) to drive the dispatch cards, the sub-agents panel, and the
* re-sync when a job settles. `turnInFlight` rides along so the client can
* adopt a server-side wake turn (busy state + composer gate) — the same
* machinery as returning to a session mid-turn.
* Job status endpoint (contracts.ts "THE ASYNC PROTOCOL" §4): the chat
* view's jobs fetch lands here — once on mount, per jobs-changed
* announcement (the store emits post-write, debounced client-side), and
* on channel resync (push-architecture S4, 2026-10-02 — the old interval
* poll is retired). It drives the dispatch cards, the sub-agents panel,
* and the re-sync when a job settles. `turnInFlight` rides along for the
* settle logic's idle check.
*/
export async function GET(req: Request) {
const chatId = new URL(req.url).searchParams.get("chatId");
+4 -3
View File
@@ -17,15 +17,16 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
* the observability the learner can't otherwise see; an in-flight teaching
* turn is fully visible where it happens (user decision, 2026-09).
*
* Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined,
* oldest-first + extraction runs), folded client-side with core/prices.ts.
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
* oldest-first + extraction runs), fetched when truth moves (events
* channel, S5) and folded client-side with core/prices.ts.
* Cost is INFERRED (jobCost); the context line is the LAST model call's
* input+output against the model's limit (JobUsage.lastStep — pending
* contract) — never a cumulative sum, which would be the wrong number.
* Absent data renders "—", never zero (the doctrine).
*
* Quiet/ambient by design: no entry animations, stable keys, updates land
* in place — polling never flickers. ALWAYS present (owner, sidebar round):
* in place — refreshes never flicker. ALWAYS present (owner, sidebar round):
* empty means EMPTY — the header sits alone, nothing hides; it populates and
* GROWS when processes run (max-h + internal scroll so a pile never crowds
* the session list). The sidebar's closed state means the learner opted
+2 -2
View File
@@ -7,7 +7,7 @@ import type { ArtifactPartProps, DemoResult } from "@/src/core";
/**
* Demo artifact card (W6): while the part is input-available, a job activity
* card (poll /api/jobs — the job-card idiom); when output-available, the demo
* card (the chat view's jobs feed — the job-card idiom); when output-available, the demo
* runs in a SANDBOXED iframe (sandbox="allow-scripts", no allow-same-origin →
* no access to app storage/cookies/DOM).
*
@@ -70,7 +70,7 @@ export function reduceDemoBridgeState(state: DemoBridgeState, data: unknown): De
// ─── the demo card ──────────────────────────────────────────────────────────
/** The demo tool-call box: the part's output is the dispatch ack — the box
* is the DispatchCard (live status via the central jobs poll). The demo
* is the DispatchCard (live status via the chat view's jobs feed). The demo
* itself renders from the steer message via DemoOutputCard. */
export function DemoCard({ part }: ArtifactPartProps) {
if (part.state !== "output-available") return null;
+3 -3
View File
@@ -11,9 +11,9 @@ import { BrailleSpinner } from "@/components/braille-spinner";
* The dispatch card (contracts.ts "THE ASYNC PROTOCOL" §4): the tool-call
* box for a background sub-agent. The tool part's output is the FIRE-AND-
* FORGET ACK (a string) — never the result. Live status comes from the
* central jobs poll via JobsContext, matched by toolCallId:
* chat-view jobs feed via JobsContext, matched by toolCallId:
*
* job unknown → "dispatched" pulse (the poll hasn't seen it yet)
* job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet)
* running → live activity card (activity line + elapsed)
* done/error → quiet one-liner ("delivered" / "failed") — the artifact
* itself arrives in the steer message right below, so the
@@ -85,7 +85,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
}
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
// Elapsed from createdAt — jobs appear here only via the poll (post-mount),
// Elapsed from createdAt — jobs appear here only via the jobs feed (post-mount),
// so no SSR/hydration mismatch.
const elapsed =
job && job.createdAt ? Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000)) : null;
+1 -1
View File
@@ -14,7 +14,7 @@ import { ToolCallCard } from "@/components/artifacts/tool-call-card";
* edit chat.tsx (see docs/plan.md file-ownership rules).
*
* Typical shape: while the part is input-available render an activity card
* (poll GET /api/jobs?chatId=…; on completion call
* (the chat view's jobs feed reads GET /api/jobs?chatId=…; on completion call
* ChatActionsContext.syncFromServer()); when output-available render the
* artifact itself from part.output.
*/
+2 -2
View File
@@ -8,7 +8,7 @@ import type { ArtifactPartProps, PdfResult, ResearchResult } from "@/src/core";
* The research tool-call box (contracts.ts "THE ASYNC PROTOCOL" §4).
*
* The part's output is the fire-and-forget dispatch ACK — the box itself is
* the DispatchCard (live status via the central jobs poll; quiet one-liner
* the DispatchCard (live status via the chat view's jobs feed; quiet one-liner
* once delivered). The research brief itself no longer renders from the
* tool part at all: it rides the steer message (the user-role delivery
* injected after the wake turn) and renders there via ResearchBriefCard.
@@ -21,7 +21,7 @@ export function JobCard({ part }: ArtifactPartProps) {
/**
* The pdf dispatch box (sources v0) — the same async-protocol idiom as
* JobCard: the ack output drives a DispatchCard (live "PDF reader" status
* via the central jobs poll); the brief itself rides the steer message
* via the chat view's jobs feed); the brief itself rides the steer message
* and renders under the PDF-complete badge in the transcript.
*/
export function PdfJobCard({ part }: ArtifactPartProps) {
+1 -1
View File
@@ -6,7 +6,7 @@ import type { ArtifactPartProps, VisualResult } from "@/src/core";
/**
* Visual artifact card (W6 → ack protocol): the tool part's output is the
* dispatch ack, so the tool-call box is the DispatchCard (live status via
* the central jobs poll). The SVG itself renders from the steer message
* the chat view's jobs feed). The SVG itself renders from the steer message
* (sub-agent result delivery) via VisualOutputCard.
*
* The SVG is model-authored for the learner's own machine (their own teacher
+6
View File
@@ -138,6 +138,9 @@ export function CoverageMenuBlock({ chatId }: { chatId: string }) {
// While a run is in flight the block polls at 2s (the old panel's
// open-only cadence, bounded to the running state) — the numbers
// settle live and the block ends in "fully extracted".
// ACCEPTED BY DESIGN (owner, 2026-10-02): the ring drains per fold,
// and the events channel is per phase — that granularity gap is why
// this bounded, run-scoped poll stays.
useEffect(() => {
if (coverage?.running !== true && !extracting) return;
const timer = setInterval(() => void load(), 2000);
@@ -274,6 +277,9 @@ export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refre
// While a run is in flight: poll at 2s so the ring DRAINS as folds land
// and settles at zero when the run completes (the "resets on
// completion" requirement — coverage re-derives per request).
// ACCEPTED BY DESIGN (owner, 2026-10-02): the ring drains per fold,
// and the events channel is per phase — that granularity gap is why
// this bounded, run-scoped poll stays.
useEffect(() => {
if (coverage?.running !== true) return;
const timer = setInterval(() => {
+37 -22
View File
@@ -70,6 +70,7 @@ import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSna
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import { usePopoverDismiss } from "@/components/popover-dismiss";
import { useActivity } from "@/components/activity-dock";
import { useServerEvent, useServerResync } from "@/components/server-events";
import { MenuShell } from "@/components/menu-shell.tsx";
import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx";
import { ChainTableOverlay } from "@/components/chain-table.tsx";
@@ -1069,7 +1070,8 @@ export function GraphView() {
// The dock's start-site nudge (accuracy wave, 2026-09-19): the import
// POST awaits the WHOLE pipeline server-side, so the run registers long
// before the response — nudge at click and again at +600ms to catch the
// start; the dock's fast active interval carries the run from there.
// start; from its first setPhase the run announces itself on the events
// channel (S5), and the dock rides those announcements thereafter.
const refreshActivity = useActivity().refresh;
const onImportFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
@@ -1088,7 +1090,7 @@ export function GraphView() {
.finally(() => setImporting(false));
};
// Background pipeline status: poll /api/graph/status; whenever a run
// Background pipeline status: read /api/graph/status; whenever a run
// LEAVES the feed (finished / aborted / failed), refetch the graph.
// A run only leaves the registry after its merge + settle + usage
// persist complete, so disappearance ⇒ the graph change is already on
@@ -1102,27 +1104,40 @@ export function GraphView() {
// into one end-of-import reveal (the exact bug this rule exists to
// prevent — a chunk's row leaving IS its merge landing).
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; chatId: string; title: string; phase: string; startedAt: number }[]>([]);
useEffect(() => {
let cancelled = false;
let prevIds = new Set<string>();
const poll = () =>
fetch("/api/graph/status")
.then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>)
.then(({ runs }) => {
if (cancelled) return;
setPipelineRuns(runs);
const ids = new Set(runs.map((r) => r.id));
if ([...prevIds].some((id) => !ids.has(id))) setRefreshTick((t) => t + 1);
prevIds = ids;
})
.catch(() => {}); // status is cosmetic — never surface poll errors
poll();
const timer = setInterval(poll, 2000);
return () => {
cancelled = true;
clearInterval(timer);
};
const rowIdsRef = useRef<Set<string>>(new Set());
const readPipelineStatus = useCallback(() => {
fetch("/api/graph/status")
.then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>)
.then(({ runs }) => {
setPipelineRuns(runs);
const ids = new Set(runs.map((r) => r.id));
if ([...rowIdsRef.current].some((id) => !ids.has(id))) setRefreshTick((t) => t + 1);
rowIdsRef.current = ids;
})
.catch(() => {}); // status is cosmetic — never surface read errors
}, []);
// PUSH (S7, 2026-10-02): the standing 2s poll is RETIRED — the
// extraction registry announces every phase transition and every
// finish (activity-changed), so each read lands exactly when truth
// moved. The per-chunk reveal semantics are PRESERVED event-for-event:
// setPhase + finishExtraction fire per part, so a chunk's row leaving
// is still its own, immediately-visible merge landing. Debounced (the
// S4 law); resync heals a reconnect that ate events.
const statusDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useServerEvent("activity-changed", () => {
if (statusDebounceRef.current) clearTimeout(statusDebounceRef.current);
statusDebounceRef.current = setTimeout(() => {
statusDebounceRef.current = undefined;
readPipelineStatus();
}, 250);
});
useServerResync(() => readPipelineStatus());
useEffect(() => {
readPipelineStatus();
return () => {
if (statusDebounceRef.current) clearTimeout(statusDebounceRef.current);
};
}, [readPipelineStatus]);
// Graph pipeline costs (2026-09 cost metrics wave): the cumulative fold
// of the usage channels (chat + notes extraction runs; the dead namer's
+7 -5
View File
@@ -6,11 +6,13 @@ import type { JobRecord } from "@/src/core";
/**
* Background-job observability channel (contracts.ts "THE ASYNC PROTOCOL" §4).
*
* ONE central poller (in Chat) fetches GET /api/jobs?chatId=… and publishes
* the list here; every job surface consumes it — the transcript dispatch
* cards (the ack box with live status) and the sub-agents sidebar panel.
* Cards used to each run their own poller on input-available parts; under
* the ack-protocol the tool part resolves immediately, so the poller moved
* ONE central fetch (in Chat) reads GET /api/jobs?chatId=… and publishes
* the list here — on mount, per jobs-changed announcement (the store emits
* post-write), and on channel resync (push-architecture S4, 2026-10-02);
* every job surface consumes it — the transcript dispatch cards (the ack
* box with live status) and the sub-agents sidebar panel. Cards used to
* each run their own poller on input-available parts; under the
* ack-protocol the tool part resolves immediately, so the fetch moved
* to the composition root and the cards became pure renderers of this data.
*/
export const JobsContext = createContext<JobRecord[]>([]);
+51 -43
View File
@@ -7,7 +7,7 @@
* thinking out loud is the primary answering mode). ONE ATTEMPT: each
* problem has its OWN submit button; once submitted the box locks and
* the MODEL SOLUTION reveals instantly (rubric collapsed by default).
* The grade (score 0–10 + brief reasoning) lands via the poll and
* The grade (score 0–10 + brief reasoning) lands via the events channel and
* updates live — no reload.
*
* TOP BAR (owner, UI round 2026-09-29): the chat pattern — an h-15
@@ -27,6 +27,7 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useServerEvent, useServerResync } from "@/components/server-events";
import type { Problem, ProblemSet } from "@/src/core";
import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core";
import { restoreMathEscapes } from "@/src/core";
@@ -89,7 +90,7 @@ export function PracticeExamBody({
submittingId: string | null;
/** Per-problem submit failures, keyed by problem id. */
submitErrors: Record<string, string>;
/** Problems whose grade rows are LIVE (the poll's activity feed). */
/** Problems whose grade rows are LIVE (the activity feed read). */
liveGradeIds: ReadonlySet<string>;
}) {
return (
@@ -310,8 +311,7 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) {
// per-problem "Grading…" spinners and the status line.
const [liveGradeIds, setLiveGradeIds] = useState<Set<string>>(new Set());
// True from a successful submit until the feed shows no live grade
// rows for this set — keeps the poll alive across refetches.
const [gradingLive, setGradingLive] = useState(false);
// rows for this set — kept fresh across refetches (S7: the tick).
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
@@ -329,54 +329,61 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) {
const d = (await res.json()) as { set: ProblemSet };
if (mounted.current) setSet(d.set);
} catch {
// transient poll failures keep the last snapshot
// transient fetch failures keep the last snapshot
}
}, [initial.id]);
// The poll: while not ready (generation), or while a grading run is
// live for this set with outstanding ungraded answers. The activity
// feed's practice rows are ROW-KEYED (setId:grade:problemId) — the
// live rows name exactly which grades are pending; a dropped grade
// (no live row) settles the page instead of polling forever.
const pendingGrade = set.problems.some(
(p) => p.answer !== undefined && !p.grade,
);
const needsPoll = set.status !== "ready" || (gradingLive && pendingGrade);
useEffect(() => {
if (!needsPoll) return;
let stopped = false;
const tick = async () => {
await refetch();
try {
const res = await fetch("/api/activity", { cache: "no-store" });
if (!res.ok) return;
const feed = (await res.json()) as {
practice?: Array<{ id: string; kind: string }>;
};
if (!stopped && mounted.current) {
const prefix = `${initial.id}:grade:`;
const pids = (feed.practice ?? [])
.filter((row) => row.id.startsWith(prefix))
.map((row) => row.id.slice(prefix.length));
setLiveGradeIds(new Set(pids));
setGradingLive(pids.length > 0);
}
} catch {
// keep the last liveness read
// PUSH (S7, 2026-10-02): the 2s grading-liveness interval is RETIRED —
// the practice registry announces every grade-row transition
// (activity-changed), and tick() reads exactly that liveness, so it
// fires when truth moved: debounced (the S4 law) + on mount + on
// resync. Ungated by design: a fresh grade registration must be seen
// even from a fully-settled page (the old needsPoll gate would have
// missed it — gradingLive fed the gate, and registration is what
// flips it). The activity feed's practice rows are ROW-KEYED
// (setId:grade:problemId) — the live rows name exactly which grades
// are pending; a dropped grade (no live row) settles the page.
const tick = useCallback(async () => {
await refetch();
try {
const res = await fetch("/api/activity", { cache: "no-store" });
if (!res.ok) return;
const feed = (await res.json()) as {
practice?: Array<{ id: string; kind: string }>;
};
if (mounted.current) {
const prefix = `${initial.id}:grade:`;
const pids = (feed.practice ?? [])
.filter((row) => row.id.startsWith(prefix))
.map((row) => row.id.slice(prefix.length));
setLiveGradeIds(new Set(pids));
}
};
void tick();
const t = setInterval(() => void tick(), 2000);
} catch {
// keep the last liveness read
}
}, [refetch, initial.id]);
const tickRef = useRef(tick);
tickRef.current = tick;
const tickDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
void tickRef.current();
return () => {
stopped = true;
clearInterval(t);
if (tickDebounceRef.current) clearTimeout(tickDebounceRef.current);
};
}, [needsPoll, refetch, initial.id]);
}, []);
useServerEvent("activity-changed", () => {
if (tickDebounceRef.current) clearTimeout(tickDebounceRef.current);
tickDebounceRef.current = setTimeout(() => {
tickDebounceRef.current = undefined;
void tickRef.current();
}, 250);
});
useServerResync(() => void tickRef.current());
// ONE attempt per problem (owner 2026-09-29): the submit posts THAT
// problem's answer alone; the server refuses a second submission
// forever after. On success the box locks (answer persisted) and the
// reveal shows instantly; the grade arrives via the poll.
// reveal shows instantly; the grade arrives via the events channel.
const submit = useCallback(
async (problemId: string) => {
if (submittingId !== null) return;
@@ -408,7 +415,8 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) {
return;
}
setSubmittingId(null);
setGradingLive(true); // the grade lands via the poll
// The grade lands via the events channel: the grade row's register
// announces activity-changed (S7), which triggers tick() above.
await refetch();
} catch {
setSubmitErrors((prev) => ({
+28 -14
View File
@@ -4,12 +4,13 @@
* The problem-set list (practice slice 5): rows of sets — title, date,
* status, problem/graded counts — click through to the exam page; a
* delete affordance in the delete-session dialog's shape; "New problem
* set" opens the creation dialog. Polls /api/practice/sets (2s, the
* graph-view reveal-poll idiom) while ANY set is still generating, so
* statuses live-update; a settled list stops polling.
* set" opens the creation dialog. Refetches /api/practice/sets when a
* practice phase moves (events channel, S7), so statuses live-update;
* a settled list goes quiet.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useServerEvent, useServerResync } from "@/components/server-events";
import { useRouter } from "next/navigation";
import { createPortal } from "react-dom";
import { PlusIcon, TrashIcon } from "lucide-react";
@@ -220,20 +221,33 @@ export function PracticeListPage({
const d = (await res.json()) as { sets: ProblemSet[] };
setSets(d.sets);
} catch {
// a transient failure on the poll keeps the last snapshot
// a transient failure keeps the last snapshot
}
}, []);
// The reveal poll: while any set is partitioning/generating, statuses
// live-update; a settled list goes quiet.
const anyRunning = sets.some(
(s) => s.status === "partitioning" || s.status === "generating",
);
// PUSH (S7, 2026-10-02): the bounded 2s reveal poll is RETIRED — the
// practice registry announces every phase transition (register,
// generating, grading, finish) as activity-changed, so the list
// refetches exactly when a set's status moved. Debounced (the S4
// law); resync heals a reconnect; the mount read catches drift since
// the server-rendered snapshot.
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
const listDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
if (!anyRunning) return;
const t = setInterval(() => void refresh(), 2000);
return () => clearInterval(t);
}, [anyRunning, refresh]);
void refreshRef.current();
return () => {
if (listDebounceRef.current) clearTimeout(listDebounceRef.current);
};
}, []);
useServerEvent("activity-changed", () => {
if (listDebounceRef.current) clearTimeout(listDebounceRef.current);
listDebounceRef.current = setTimeout(() => {
listDebounceRef.current = undefined;
void refreshRef.current();
}, 250);
});
useServerResync(() => void refreshRef.current());
const runDelete = useCallback(async () => {
if (!deleting || deleteBusy) return;
+41 -2
View File
@@ -384,7 +384,7 @@ Practice turns the graph into problem sets the learner reasons through out loud.
**Two-stage generation.** One **partitioner** agent reads the chains plus the three NAMED-LEVEL parameters and submits the true partition; each partition becomes one problem's target. Then **one generator agent per problem**: its prompt carries the problem-engineering framework verbatim (the owner's doctrine — the three distances, the search-to-solution axis, the rung ladder, reverse/discriminate/probe-the-premises, the standing prohibitions, the formatting doctrine — full Markdown, LaTeX math, fenced code, tables/lists; prose-only renderings of structured content are a defect), **YOUR TARGET** (the assigned chains echoed verbatim — the echo law: the submit's chain set must equal the assignment exactly), and **THE FULL SET** (the whole selection: outside propositions are supporting material the generator may draw on with minimal use and maximal focus; the never-announce law covers ALL propositions). The submit is commit-before-pose — statement, solution, and rubric arrive together (the same sycophancy mitigation the quiz/explain mechanic uses); a problem never exists without its pre-written answer. Generators run in parallel (a process-wide 3-permit semaphore shared with grading) with per-problem failure isolation: one bad problem errors alone; the set still reaches `ready`. **Dedup across sets**: each generator also sees the problems of the ≤5 most recent sets sharing a node, so situations don't repeat between sets; within a set, parallel generators can't see each other — accepted for V1 (owner ruling).
**Grading is score-only (owner redesign, 2026-09-29).** ONE ATTEMPT: each problem has its own submit button; once submitted the answer persists and the problem is done — the route refuses re-submission forever after. The submit reveals INSTANTLY (no waiting on the grader): the answer locks read-only, the model solution shows, the rubric sits collapsed. Grading fires one **grader** job per answered problem — the grader sees the problem, the propositions its chains touch, the pre-written solution and rubric, and the learner's answer, never the full set (minimal, by ruling) — and returns an integer **score 0–10 plus one or two sentences of reasoning** (which rubric points were met or missed, which proposition failed to come to mind). The score lands on the problem via the page's 2s poll and updates live; grades never serialize set-wide (submitting B while A grades is fine — the shared pool bounds parallelism). Nothing reaches the graph, no level, no count, anywhere. A dropped grade leaves the answer standing with an honest “grade interrupted” line — one attempt, no re-submission.
**Grading is score-only (owner redesign, 2026-09-29).** ONE ATTEMPT: each problem has its own submit button; once submitted the answer persists and the problem is done — the route refuses re-submission forever after. The submit reveals INSTANTLY (no waiting on the grader): the answer locks read-only, the model solution shows, the rubric sits collapsed. Grading fires one **grader** job per answered problem — the grader sees the problem, the propositions its chains touch, the pre-written solution and rubric, and the learner's answer, never the full set (minimal, by ruling) — and returns an integer **score 0–10 plus one or two sentences of reasoning** (which rubric points were met or missed, which proposition failed to come to mind). The score lands on the problem via the page's events-channel tick (activity-changed → debounced liveness read, S7) and updates live; grades never serialize set-wide (submitting B while A grades is fine — the shared pool bounds parallelism). Nothing reaches the graph, no level, no count, anywhere. A dropped grade leaves the answer standing with an honest “grade interrupted” line — one attempt, no re-submission.
**The jobs pattern — registry, not JobRecords.** Practice runs are NOT Workbench jobs: no `JobStore` records, no steer messages, nothing ever wakes the teacher — a practice run isn't chat-scoped (a set outlives its birth session and has no lesson to re-enter; its result is itself, persisted in its own store and read by its own page). What it shares with the Workbench is the observability surface: a `globalThis` run registry (the module-instance idiom — `__lhPracticeRuns`) with PER-PROBLEM ROWS (owner ruling, 2026-09-29 — matching the extraction per-chunk pattern): `setId:partition` while the partitioner runs ("Partitioning — title"), then one `setId:gen:problemId` per problem ("queued"→"generating", label "Problem i/N — title"), and one `setId:grade:problemId` per graded answer ("queued"→"grading", "Grading i/N — title") — each row its OWN abort controller and its OWN usage accumulation; the row key IS the feed id (the dock dup-key law) and the abort target. ABORT ROUTES BY ROW SHAPE: a gen-row abort kills THAT problem only (siblings continue; the set still settles `ready` with one errored problem), a partition-row abort ends the whole run, a grade-row abort drops that grade (answer kept — one attempt, the problem stays done); set-level abort (delete) composes from row aborts plus the synchronous persisted marks. Model calls — partitioner included — run under the SHARED background-LLM semaphore (`bg-llm-semaphore.ts`): one process-wide pool of 3 permits for ALL background LLM jobs, extraction chunks included (total background LLM concurrency bounded at 3, whoever calls). A boot sweep marks orphaned `partitioning`/`generating` sets as `error "Interrupted (server restarted)"` — once per process, and only when the set's `updatedAt` heartbeat is >120s stale (a live run's 30s FILE watchdog keeps the heartbeat fresh even across dev's per-worker processes — the registry is not shared between them; the guard trio closed a live-stamping bug from the first live test) — the job runner's crash-honesty at set grain. Store writes serialize behind a per-set mutex; the pipeline is the sole writer of `data/practice`.
@@ -462,10 +462,48 @@ The Electron main is a launcher + security guard, never a host for app logic: si
---
## 10. Observability (minimal, by decision)
## 10. Observability and change delivery (the push model, owner ruling 2026-10-02)
Structured-logging frameworks are still deferred; the baseline is one correlation-tagged log line per event at the seams (`[chat <id>]` on turn services, `[extraction <id>]` on the pipeline, `[job <id>]` on runners, `[graph]` on reads). What has grown past log lines is the **activity feed** (`server/observability.ts` → `GET /api/activity` → the activity dock): running jobs, extraction runs, and practice runs (per-set rows: generation/grading phases), per-run usage (money channel), and true abort (`POST /api/activity/abort` — kind `job`/`extraction`/`practice`). When structured logging lands, it goes behind these seams — no call sites change.
### 10.1 The events channel — how the client learns truth moved
Before 2026-10-02 the client discovered server-side change by polling: a lattice of interval polls (sidebar 2.5s, dock adaptive 2.5/10s, jobs 1/4s, graph status 2s, practice 2s, post-turn watches) whose blind spots were measured in the probe→teach handoff freeze (a server-initiated turn in a jobless session was invisible to an open view). The ruling: **push** — exactly one app-scoped SSE channel, `GET /api/events` (`core/events.ts` bus → `server/events-stream.ts` seam), held open by **one EventSource per open window** (`components/server-events.tsx`, mounted outermost in the root layout — the only file where `EventSource` exists). The client owns the connection's lifetime; the server frames `: ping` heartbeats every 25s and never closes first. **Notify→fetch**: payloads never carry data — an event says only *that* truth moved (plus a key like `chatId`); every consumer then fetches fresh from the normal endpoints. Reconnects (EventSource auto-retry) fire a **resync** hook per consumer for one immediate refetch, healing any events the gap ate.
### 10.2 Event vocabulary
| Event | Payload | Emitted at |
|---|---|---|
| `turn-started` | `{chatId}` | `beginTurn` success (after the registry write; the double-begin 409 path is silent) — every turn, client-initiated included |
| `turn-settled` | `{chatId}` | `endTurn` on a live entry — fires after `persistTurn`, so fetches read durable truth |
| `jobs-changed` | `{chatId}` | every job-store write (create/update/delete-that-removed) |
| `activity-changed` | `{}` | extraction `setPhase`/`finishExtraction`; practice `registerRow`/phase-writes/`finishPractice` |
| `chats-changed` | `{}` | list-shape writes only: chat create/rename/delete-that-removed |
### 10.3 Emitter laws
- **Notify-after-write** — the emit always follows the successful write, so a fetch triggered by the event reads committed truth.
- **Present-entry gating** — mutations that changed nothing (missing-id update, delete of an absent row) emit nothing.
- **THE SILENCE LAW** — content writers (`saveMessages`, `appendTurnUsage`, `appendExtractionUsage`, `saveSettings`, `saveTree`) are deliberately silent: `saveMessages` would spam per turn-persist, and list-visible effects already arrive via `turn-started`/`turn-settled` (pinned in tests).
- **Reads never emit** — pure reads are side-effect-free (pinned).
The bus itself is a `globalThis`-backed listener set (the Next per-entry-module discipline, same as the turn registry), snapshot-iterated so unsubscribing mid-emit is safe, with per-listener error isolation.
### 10.4 Consumer laws
- **Refs, not closures** — handlers read `localStreamRef`/`turnInFlightRef`/`statusRef`, never stale render state.
- **The 409-race guard** — on `turn-started`, ignore when `localStreamRef.current` is held (the client's own turn just began; it owns that stream) or already attached; otherwise attach to the running turn (`GET /stream` live response).
- **No mid-attached-stream sync** — while attached, the terminal frame owns settle; a `turn-settled`-triggered `syncFromServer` would swap a stale snapshot and break in-place continuation (the one exception: error status heals by detaching).
- **~250ms trailing debounce** — bursty emitters coalesce into one fetch per window (sidebar/dock/jobs/graph/practice all share the shape).
- **Resync on reconnect** — every consumer refetches once when the channel (re)opens.
### 10.5 What remains bounded or local, and why
- **Extraction-coverage ×2** (session menu block + ring) — bounded, run-scoped 2s reads: the ring drains **per fold**, and the events channel is per **phase**; that granularity gap is accepted by design (owner, 2026-10-02).
- **The 409-retry poll** (`session-core.ts`, 500ms→5s ramp) — a racing client's bounded backoff while a turn it tried to start is already running; not standing.
- **Dock clock ticker** (1s `setNow`) and **dictation audio meter** — local rendering, no network.
- **Mount fast-paths kept** — `initiallyInFlight` (attach on render-time snapshot), the mount-time `needsContinuation` check, and the `lh:chat-renamed`/`lh:turn-state` **local window broadcasts** (instant same-tab patching; the channel is for cross-window and server-initiated truth).
---
## 11. Testing
@@ -494,6 +532,7 @@ Every place the architecture is designed to be cut — the "one file" a future c
| Practice orchestration | `src/agent/subagents/practice-pipeline.ts` | generation/grading flow without touching contracts |
| Graph access for teacher | `src/agent/tools/graph.ts` (`list_sessions` / `list_propositions`) | what the teacher can probe, one gate |
| Steer delivery (wake) | `src/server/jobs.ts` + injected `deliver` | how background results re-enter lessons |
| Push events | `src/core/events.ts` (bus) + `src/server/events-stream.ts` (SSE seam) + `components/server-events.tsx` (the window's EventSource) | change notification, end to end |
| Agent construction (tests) | `handleChatRequest(req, buildAgent?)` | scripted engines, no refactor |
| Sub-agent verification | `sub-agents/headless.ts` (`headlessOverride`) | demo/SVG verification, no Chrome |
| Data location (Electron) | `LH_DATA_DIR` env (`config.ts` PATHS) | where data/ lives, nothing else |