From 26131b35999bac5d09f622106019f6c4a096d0b4 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 2 Oct 2026 18:03:47 +0300 Subject: [PATCH] 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 --- app/api/jobs/route.ts | 13 ++-- components/activity-dock.tsx | 7 +- components/artifacts/demo-card.tsx | 4 +- components/artifacts/dispatch-card.tsx | 6 +- components/artifacts/index.ts | 2 +- components/artifacts/job-card.tsx | 4 +- components/artifacts/visual-card.tsx | 2 +- components/extraction-coverage.tsx | 6 ++ components/graph-view.tsx | 59 ++++++++++------ components/jobs-context.ts | 12 ++-- components/practice-exam.tsx | 94 ++++++++++++++------------ components/practice-list.tsx | 42 ++++++++---- docs/architecture.md | 43 +++++++++++- 13 files changed, 190 insertions(+), 104 deletions(-) diff --git a/app/api/jobs/route.ts b/app/api/jobs/route.ts index 818271e..67ee868 100644 --- a/app/api/jobs/route.ts +++ b/app/api/jobs/route.ts @@ -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"); diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 287524a..5e9273f 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -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 diff --git a/components/artifacts/demo-card.tsx b/components/artifacts/demo-card.tsx index 3ab0d03..b27dfe6 100644 --- a/components/artifacts/demo-card.tsx +++ b/components/artifacts/demo-card.tsx @@ -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; diff --git a/components/artifacts/dispatch-card.tsx b/components/artifacts/dispatch-card.tsx index e570b17..eaa6db7 100644 --- a/components/artifacts/dispatch-card.tsx +++ b/components/artifacts/dispatch-card.tsx @@ -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; diff --git a/components/artifacts/index.ts b/components/artifacts/index.ts index 6db5414..2e22eb3 100644 --- a/components/artifacts/index.ts +++ b/components/artifacts/index.ts @@ -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. */ diff --git a/components/artifacts/job-card.tsx b/components/artifacts/job-card.tsx index 6616eda..1806f60 100644 --- a/components/artifacts/job-card.tsx +++ b/components/artifacts/job-card.tsx @@ -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) { diff --git a/components/artifacts/visual-card.tsx b/components/artifacts/visual-card.tsx index 5c43a02..df4e68d 100644 --- a/components/artifacts/visual-card.tsx +++ b/components/artifacts/visual-card.tsx @@ -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 diff --git a/components/extraction-coverage.tsx b/components/extraction-coverage.tsx index 5a7cd8f..c09c998 100644 --- a/components/extraction-coverage.tsx +++ b/components/extraction-coverage.tsx @@ -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(() => { diff --git a/components/graph-view.tsx b/components/graph-view.tsx index ab04b99..12e3649 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -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(); - 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>(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 | 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 diff --git a/components/jobs-context.ts b/components/jobs-context.ts index bcc46a8..2324d28 100644 --- a/components/jobs-context.ts +++ b/components/jobs-context.ts @@ -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([]); diff --git a/components/practice-exam.tsx b/components/practice-exam.tsx index 3a9e848..88f2b42 100644 --- a/components/practice-exam.tsx +++ b/components/practice-exam.tsx @@ -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; - /** Problems whose grade rows are LIVE (the poll's activity feed). */ + /** Problems whose grade rows are LIVE (the activity feed read). */ liveGradeIds: ReadonlySet; }) { return ( @@ -310,8 +311,7 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) { // per-problem "Grading…" spinners and the status line. const [liveGradeIds, setLiveGradeIds] = useState>(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 | 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) => ({ diff --git a/components/practice-list.tsx b/components/practice-list.tsx index b955ac2..ba949f4 100644 --- a/components/practice-list.tsx +++ b/components/practice-list.tsx @@ -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 | 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; diff --git a/docs/architecture.md b/docs/architecture.md index 81611df..2e8fa42 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 ]` on turn services, `[extraction ]` on the pipeline, `[job ]` 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 |