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:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user