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"; export const dynamic = "force-dynamic";
/** /**
* Job status polling endpoint (contracts.ts "THE ASYNC PROTOCOL" §4): the * Job status endpoint (contracts.ts "THE ASYNC PROTOCOL" §4): the chat
* client's ONE central poller fetches this while any job runs (and slowly * view's jobs fetch lands here — once on mount, per jobs-changed
* otherwise) to drive the dispatch cards, the sub-agents panel, and the * announcement (the store emits post-write, debounced client-side), and
* re-sync when a job settles. `turnInFlight` rides along so the client can * on channel resync (push-architecture S4, 2026-10-02 — the old interval
* adopt a server-side wake turn (busy state + composer gate) — the same * poll is retired). It drives the dispatch cards, the sub-agents panel,
* machinery as returning to a session mid-turn. * and the re-sync when a job settles. `turnInFlight` rides along for the
* settle logic's idle check.
*/ */
export async function GET(req: Request) { export async function GET(req: Request) {
const chatId = new URL(req.url).searchParams.get("chatId"); 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 * the observability the learner can't otherwise see; an in-flight teaching
* turn is fully visible where it happens (user decision, 2026-09). * turn is fully visible where it happens (user decision, 2026-09).
* *
* Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined, * Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
* oldest-first + extraction runs), folded client-side with core/prices.ts. * 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 * Cost is INFERRED (jobCost); the context line is the LAST model call's
* input+output against the model's limit (JobUsage.lastStep — pending * input+output against the model's limit (JobUsage.lastStep — pending
* contract) — never a cumulative sum, which would be the wrong number. * contract) — never a cumulative sum, which would be the wrong number.
* Absent data renders "—", never zero (the doctrine). * Absent data renders "—", never zero (the doctrine).
* *
* Quiet/ambient by design: no entry animations, stable keys, updates land * 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 * 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 * 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 * 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 * 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 → * runs in a SANDBOXED iframe (sandbox="allow-scripts", no allow-same-origin →
* no access to app storage/cookies/DOM). * no access to app storage/cookies/DOM).
* *
@@ -70,7 +70,7 @@ export function reduceDemoBridgeState(state: DemoBridgeState, data: unknown): De
// ─── the demo card ────────────────────────────────────────────────────────── // ─── the demo card ──────────────────────────────────────────────────────────
/** The demo tool-call box: the part's output is the dispatch ack — the box /** 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. */ * itself renders from the steer message via DemoOutputCard. */
export function DemoCard({ part }: ArtifactPartProps) { export function DemoCard({ part }: ArtifactPartProps) {
if (part.state !== "output-available") return null; 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 * 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- * 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 * 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) * running → live activity card (activity line + elapsed)
* done/error → quiet one-liner ("delivered" / "failed") — the artifact * done/error → quiet one-liner ("delivered" / "failed") — the artifact
* itself arrives in the steer message right below, so the * 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 }) { 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. // so no SSR/hydration mismatch.
const elapsed = const elapsed =
job && job.createdAt ? Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000)) : null; 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). * edit chat.tsx (see docs/plan.md file-ownership rules).
* *
* Typical shape: while the part is input-available render an activity card * 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 * ChatActionsContext.syncFromServer()); when output-available render the
* artifact itself from part.output. * 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 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 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 * once delivered). The research brief itself no longer renders from the
* tool part at all: it rides the steer message (the user-role delivery * tool part at all: it rides the steer message (the user-role delivery
* injected after the wake turn) and renders there via ResearchBriefCard. * 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 * The pdf dispatch box (sources v0) — the same async-protocol idiom as
* JobCard: the ack output drives a DispatchCard (live "PDF reader" status * 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. * and renders under the PDF-complete badge in the transcript.
*/ */
export function PdfJobCard({ part }: ArtifactPartProps) { 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 * 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 * 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. * (sub-agent result delivery) via VisualOutputCard.
* *
* The SVG is model-authored for the learner's own machine (their own teacher * 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 // 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 // open-only cadence, bounded to the running state) — the numbers
// settle live and the block ends in "fully extracted". // 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(() => { useEffect(() => {
if (coverage?.running !== true && !extracting) return; if (coverage?.running !== true && !extracting) return;
const timer = setInterval(() => void load(), 2000); 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 // 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 // and settles at zero when the run completes (the "resets on
// completion" requirement — coverage re-derives per request). // 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(() => { useEffect(() => {
if (coverage?.running !== true) return; if (coverage?.running !== true) return;
const timer = setInterval(() => { 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 { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import { usePopoverDismiss } from "@/components/popover-dismiss"; import { usePopoverDismiss } from "@/components/popover-dismiss";
import { useActivity } from "@/components/activity-dock"; import { useActivity } from "@/components/activity-dock";
import { useServerEvent, useServerResync } from "@/components/server-events";
import { MenuShell } from "@/components/menu-shell.tsx"; import { MenuShell } from "@/components/menu-shell.tsx";
import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx"; import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx";
import { ChainTableOverlay } from "@/components/chain-table.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 // 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 // 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 // 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 refreshActivity = useActivity().refresh;
const onImportFiles = (files: FileList | null) => { const onImportFiles = (files: FileList | null) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@@ -1088,7 +1090,7 @@ export function GraphView() {
.finally(() => setImporting(false)); .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. // LEAVES the feed (finished / aborted / failed), refetch the graph.
// A run only leaves the registry after its merge + settle + usage // A run only leaves the registry after its merge + settle + usage
// persist complete, so disappearance ⇒ the graph change is already on // 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 // into one end-of-import reveal (the exact bug this rule exists to
// prevent — a chunk's row leaving IS its merge landing). // prevent — a chunk's row leaving IS its merge landing).
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; chatId: string; title: string; phase: string; startedAt: number }[]>([]); const [pipelineRuns, setPipelineRuns] = useState<{ id: string; chatId: string; title: string; phase: string; startedAt: number }[]>([]);
useEffect(() => { const rowIdsRef = useRef<Set<string>>(new Set());
let cancelled = false; const readPipelineStatus = useCallback(() => {
let prevIds = new Set<string>(); fetch("/api/graph/status")
const poll = () => .then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>)
fetch("/api/graph/status") .then(({ runs }) => {
.then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>) setPipelineRuns(runs);
.then(({ runs }) => { const ids = new Set(runs.map((r) => r.id));
if (cancelled) return; if ([...rowIdsRef.current].some((id) => !ids.has(id))) setRefreshTick((t) => t + 1);
setPipelineRuns(runs); rowIdsRef.current = ids;
const ids = new Set(runs.map((r) => r.id)); })
if ([...prevIds].some((id) => !ids.has(id))) setRefreshTick((t) => t + 1); .catch(() => {}); // status is cosmetic — never surface read errors
prevIds = ids;
})
.catch(() => {}); // status is cosmetic — never surface poll errors
poll();
const timer = setInterval(poll, 2000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, []); }, []);
// 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 // Graph pipeline costs (2026-09 cost metrics wave): the cumulative fold
// of the usage channels (chat + notes extraction runs; the dead namer's // 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). * Background-job observability channel (contracts.ts "THE ASYNC PROTOCOL" §4).
* *
* ONE central poller (in Chat) fetches GET /api/jobs?chatId=… and publishes * ONE central fetch (in Chat) reads GET /api/jobs?chatId=… and publishes
* the list here; every job surface consumes it — the transcript dispatch * the list here — on mount, per jobs-changed announcement (the store emits
* cards (the ack box with live status) and the sub-agents sidebar panel. * post-write), and on channel resync (push-architecture S4, 2026-10-02);
* Cards used to each run their own poller on input-available parts; under * every job surface consumes it — the transcript dispatch cards (the ack
* the ack-protocol the tool part resolves immediately, so the poller moved * 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. * to the composition root and the cards became pure renderers of this data.
*/ */
export const JobsContext = createContext<JobRecord[]>([]); export const JobsContext = createContext<JobRecord[]>([]);
+51 -43
View File
@@ -7,7 +7,7 @@
* thinking out loud is the primary answering mode). ONE ATTEMPT: each * thinking out loud is the primary answering mode). ONE ATTEMPT: each
* problem has its OWN submit button; once submitted the box locks and * problem has its OWN submit button; once submitted the box locks and
* the MODEL SOLUTION reveals instantly (rubric collapsed by default). * 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. * updates live — no reload.
* *
* TOP BAR (owner, UI round 2026-09-29): the chat pattern — an h-15 * 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 { useCallback, useEffect, useRef, useState } from "react";
import { useServerEvent, useServerResync } from "@/components/server-events";
import type { Problem, ProblemSet } from "@/src/core"; import type { Problem, ProblemSet } from "@/src/core";
import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core"; import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core";
import { restoreMathEscapes } from "@/src/core"; import { restoreMathEscapes } from "@/src/core";
@@ -89,7 +90,7 @@ export function PracticeExamBody({
submittingId: string | null; submittingId: string | null;
/** Per-problem submit failures, keyed by problem id. */ /** Per-problem submit failures, keyed by problem id. */
submitErrors: Record<string, string>; 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>; liveGradeIds: ReadonlySet<string>;
}) { }) {
return ( return (
@@ -310,8 +311,7 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) {
// per-problem "Grading…" spinners and the status line. // per-problem "Grading…" spinners and the status line.
const [liveGradeIds, setLiveGradeIds] = useState<Set<string>>(new Set()); const [liveGradeIds, setLiveGradeIds] = useState<Set<string>>(new Set());
// True from a successful submit until the feed shows no live grade // True from a successful submit until the feed shows no live grade
// rows for this set — keeps the poll alive across refetches. // rows for this set — kept fresh across refetches (S7: the tick).
const [gradingLive, setGradingLive] = useState(false);
const mounted = useRef(true); const mounted = useRef(true);
useEffect(() => { useEffect(() => {
mounted.current = true; mounted.current = true;
@@ -329,54 +329,61 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) {
const d = (await res.json()) as { set: ProblemSet }; const d = (await res.json()) as { set: ProblemSet };
if (mounted.current) setSet(d.set); if (mounted.current) setSet(d.set);
} catch { } catch {
// transient poll failures keep the last snapshot // transient fetch failures keep the last snapshot
} }
}, [initial.id]); }, [initial.id]);
// The poll: while not ready (generation), or while a grading run is // PUSH (S7, 2026-10-02): the 2s grading-liveness interval is RETIRED —
// live for this set with outstanding ungraded answers. The activity // the practice registry announces every grade-row transition
// feed's practice rows are ROW-KEYED (setId:grade:problemId) — the // (activity-changed), and tick() reads exactly that liveness, so it
// live rows name exactly which grades are pending; a dropped grade // fires when truth moved: debounced (the S4 law) + on mount + on
// (no live row) settles the page instead of polling forever. // resync. Ungated by design: a fresh grade registration must be seen
const pendingGrade = set.problems.some( // even from a fully-settled page (the old needsPoll gate would have
(p) => p.answer !== undefined && !p.grade, // missed it — gradingLive fed the gate, and registration is what
); // flips it). The activity feed's practice rows are ROW-KEYED
const needsPoll = set.status !== "ready" || (gradingLive && pendingGrade); // (setId:grade:problemId) — the live rows name exactly which grades
useEffect(() => { // are pending; a dropped grade (no live row) settles the page.
if (!needsPoll) return; const tick = useCallback(async () => {
let stopped = false; await refetch();
const tick = async () => { try {
await refetch(); const res = await fetch("/api/activity", { cache: "no-store" });
try { if (!res.ok) return;
const res = await fetch("/api/activity", { cache: "no-store" }); const feed = (await res.json()) as {
if (!res.ok) return; practice?: Array<{ id: string; kind: string }>;
const feed = (await res.json()) as { };
practice?: Array<{ id: string; kind: string }>; if (mounted.current) {
}; const prefix = `${initial.id}:grade:`;
if (!stopped && mounted.current) { const pids = (feed.practice ?? [])
const prefix = `${initial.id}:grade:`; .filter((row) => row.id.startsWith(prefix))
const pids = (feed.practice ?? []) .map((row) => row.id.slice(prefix.length));
.filter((row) => row.id.startsWith(prefix)) setLiveGradeIds(new Set(pids));
.map((row) => row.id.slice(prefix.length));
setLiveGradeIds(new Set(pids));
setGradingLive(pids.length > 0);
}
} catch {
// keep the last liveness read
} }
}; } catch {
void tick(); // keep the last liveness read
const t = setInterval(() => void tick(), 2000); }
}, [refetch, initial.id]);
const tickRef = useRef(tick);
tickRef.current = tick;
const tickDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
void tickRef.current();
return () => { return () => {
stopped = true; if (tickDebounceRef.current) clearTimeout(tickDebounceRef.current);
clearInterval(t);
}; };
}, [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 // ONE attempt per problem (owner 2026-09-29): the submit posts THAT
// problem's answer alone; the server refuses a second submission // problem's answer alone; the server refuses a second submission
// forever after. On success the box locks (answer persisted) and the // 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( const submit = useCallback(
async (problemId: string) => { async (problemId: string) => {
if (submittingId !== null) return; if (submittingId !== null) return;
@@ -408,7 +415,8 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) {
return; return;
} }
setSubmittingId(null); 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(); await refetch();
} catch { } catch {
setSubmitErrors((prev) => ({ setSubmitErrors((prev) => ({
+28 -14
View File
@@ -4,12 +4,13 @@
* The problem-set list (practice slice 5): rows of sets — title, date, * The problem-set list (practice slice 5): rows of sets — title, date,
* status, problem/graded counts — click through to the exam page; a * status, problem/graded counts — click through to the exam page; a
* delete affordance in the delete-session dialog's shape; "New problem * delete affordance in the delete-session dialog's shape; "New problem
* set" opens the creation dialog. Polls /api/practice/sets (2s, the * set" opens the creation dialog. Refetches /api/practice/sets when a
* graph-view reveal-poll idiom) while ANY set is still generating, so * practice phase moves (events channel, S7), so statuses live-update;
* statuses live-update; a settled list stops polling. * 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 { useRouter } from "next/navigation";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { PlusIcon, TrashIcon } from "lucide-react"; import { PlusIcon, TrashIcon } from "lucide-react";
@@ -220,20 +221,33 @@ export function PracticeListPage({
const d = (await res.json()) as { sets: ProblemSet[] }; const d = (await res.json()) as { sets: ProblemSet[] };
setSets(d.sets); setSets(d.sets);
} catch { } 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 // PUSH (S7, 2026-10-02): the bounded 2s reveal poll is RETIRED — the
// live-update; a settled list goes quiet. // practice registry announces every phase transition (register,
const anyRunning = sets.some( // generating, grading, finish) as activity-changed, so the list
(s) => s.status === "partitioning" || s.status === "generating", // 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(() => { useEffect(() => {
if (!anyRunning) return; void refreshRef.current();
const t = setInterval(() => void refresh(), 2000); return () => {
return () => clearInterval(t); if (listDebounceRef.current) clearTimeout(listDebounceRef.current);
}, [anyRunning, refresh]); };
}, []);
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 () => { const runDelete = useCallback(async () => {
if (!deleting || deleteBusy) return; 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). **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`. **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. 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 ## 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 | | 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 | | 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 | | 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 | | Agent construction (tests) | `handleChatRequest(req, buildAgent?)` | scripted engines, no refactor |
| Sub-agent verification | `sub-agents/headless.ts` (`headlessOverride`) | demo/SVG verification, no Chrome | | 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 | | Data location (Electron) | `LH_DATA_DIR` env (`config.ts` PATHS) | where data/ lives, nothing else |