+
);
}
diff --git a/src/agent/subagents/practice-pipeline.ts b/src/agent/subagents/practice-pipeline.ts
index fa1feef..88e269d 100644
--- a/src/agent/subagents/practice-pipeline.ts
+++ b/src/agent/subagents/practice-pipeline.ts
@@ -232,10 +232,11 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
// 1. ONCE PER PROCESS (globalThis flag) — an instance could otherwise
// evaluate against a registry whose fresh row it can't observe
// (dev route bundles, restart paths).
- // 2. GRACE WINDOW (120s over updatedAt ?? createdAt) — a live run
- // saves at every phase/problem transition, so its heartbeat is
- // structurally never 120s stale; a true orphan is minutes stale at
- // the next boot.
+ // 2. GRACE WINDOW (120s over updatedAt ?? createdAt) — a live run's
+ // WATCHDOG re-stamps the file every 30s (cross-worker liveness —
+ // the registry is NOT shared between dev route workers), so a
+ // live run is structurally never 120s stale; a true crash stops
+ // the beats and is minutes stale at the next boot.
// 3. Live-row check (globalThis-shared registry) as before.
const sweepFlag = globalThis as { __lhPracticeSweepDone?: boolean };
if (!sweepFlag.__lhPracticeSweepDone) {
@@ -275,6 +276,26 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
): Promise
{
const controller = partitionRow.controller;
+ // PERSISTENT HEARTBEAT (owner live test #2, 2026-09-29 — the sweep
+ // stamped LIVE runs again): dev serves routes from workers that do
+ // NOT share this process's registry, so memory liveness cannot
+ // protect a cross-worker sweep, and a long-running last problem can
+ // exceed the 120s grace between store writes. The watchdog
+ // re-stamps updatedAt on the FILE every 30s while THIS run is live
+ // (4-beat tolerance inside the grace window) — any process's sweep
+ // then sees a fresh heartbeat; a true crash stops the beats and the
+ // orphan goes stale honestly.
+ const beat = setInterval(() => {
+ void withSetLock(set.id, () => {
+ const cur = deps.practiceStore.getSet(set.id);
+ if (!cur) return;
+ if (cur.status !== "partitioning" && cur.status !== "generating") return;
+ deps.practiceStore.saveSet(cur);
+ });
+ }, 30_000);
+ beat.unref?.();
+
+ try {
// Stage 1 — the partitioner (its OWN dock row; acquires a shared
// permit like every background LLM job).
let partitions: Array<{ chains: string[] }>;
@@ -393,6 +414,9 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
cur.error = undefined;
deps.practiceStore.saveSet(cur);
});
+ } finally {
+ clearInterval(beat);
+ }
}
/**
diff --git a/src/agent/subagents/practice.ts b/src/agent/subagents/practice.ts
index 0ef6eb1..45f7227 100644
--- a/src/agent/subagents/practice.ts
+++ b/src/agent/subagents/practice.ts
@@ -188,7 +188,7 @@ INPUTS (in the user message):
THE CUE DISTANCE: 0 Lesson context · 1 Adjacent context · 2 Far context — how far the situation sits from the cues the learner has already met: the lesson's own examples AND every previous problem's situation. Far context is the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking.
THE PRESENTATION: Theory (the subject's own terms — symbols, formal objects, native vocabulary) or Application (a concrete worldly affair in which the proposition's structure appears in the wild). It dresses the situation; it never changes the rung or the cue distance.
Apply Part II step 2: degrade honestly if the target cannot support them — the degradation shows in the problem's own shape, never in meta-commentary.
-- FORMATTING: your statement, solution, and rubric are rendered as full Markdown — use everything it offers. Mathematics is always LaTeX: inline as $…$, display as $$…$$ on its own lines. Code, pseudocode, and formal systems live in fenced code blocks with a language tag; identifiers and symbols inline as \`code\`. Use tables for parallel structure and comparisons, lists for enumerations. Prose-only renderings of mathematical or structured content are a defect: if a sentence carries an equation, a derivation, or a piece of code, format it as what it is.
+- FORMATTING: your statement, solution, and rubric are rendered as full Markdown — use everything it offers. Mathematics is always LaTeX: inline as \\(…\\), display as \\[…\\] on its own lines — a bare $ is literal currency and never starts math. Inside math, use LaTeX commands for symbols, not unicode stand-ins (they fail to render). Code, pseudocode, and formal systems live in fenced code blocks with a language tag; identifiers and symbols inline as \`code\`. Use tables for parallel structure and comparisons, lists for enumerations. Skip headers — at this size they are pointless decoration; structure with paragraphs, bold labels, and lists (a genuinely long derivation may earn one). Prose-only renderings of mathematical or structured content are a defect: if a sentence carries an equation, a derivation, or a piece of code, format it as what it is.
- PREVIOUS PROBLEMS: problems the learner has already answered, with the chains they exercised. Do not rebuild one; keep your situation distant from theirs — cue distance counts previous problems' situations (Part I, "Cue distance").
OUTPUT — your entire deliverable is ONE submit_problem call:
@@ -228,7 +228,7 @@ THE VERDICT (exactly one):
- "partial": some of the target came to mind; the answer is materially incomplete or partly wrong.
- "missed": the targeted proposition(s) did not come to mind — the answer is empty or on the wrong track.
-Feedback renders as full Markdown. Mathematics is always LaTeX — inline as $…$, display as $$…$$ on its own lines. Code in fenced blocks with a language tag, identifiers inline as \`code\`. Lists and tables wherever they carry structure. Name the failed recall and teach the boundary in properly formatted prose, not plain text.
+Feedback renders as full Markdown. Mathematics is always LaTeX — inline as \\(…\\), display as \\[…\\] on its own lines (a bare $ is literal currency). Inside math, LaTeX commands for symbols, not unicode stand-ins. Code in fenced blocks with a language tag, identifiers inline as \`code\`. Lists and tables wherever they carry structure; no headers — feedback is short-form, structure it with paragraphs and bold labels. Name the failed recall and teach the boundary in properly formatted prose, not plain text.
OUTPUT — your entire deliverable is ONE submit_grade call: "verdict" and "feedback" (non-empty; the boundary-teaching text above).`;
diff --git a/tests/practice-agents.test.ts b/tests/practice-agents.test.ts
index cb8cd3a..7d49128 100644
--- a/tests/practice-agents.test.ts
+++ b/tests/practice-agents.test.ts
@@ -294,7 +294,7 @@ describe("formatting doctrine pins", () => {
it("GENERATOR_DOCTRINE carries the FORMATTING bullet verbatim (Markdown + LaTeX + fenced code + tables/lists)", () => {
assert.ok(
GENERATOR_DOCTRINE.includes(
- "- FORMATTING: your statement, solution, and rubric are rendered as full Markdown — use everything it offers. Mathematics is always LaTeX: inline as $…$, display as $$…$$ on its own lines. Code, pseudocode, and formal systems live in fenced code blocks with a language tag; identifiers and symbols inline as `code`. Use tables for parallel structure and comparisons, lists for enumerations. Prose-only renderings of mathematical or structured content are a defect: if a sentence carries an equation, a derivation, or a piece of code, format it as what it is.",
+ "- FORMATTING: your statement, solution, and rubric are rendered as full Markdown — use everything it offers. Mathematics is always LaTeX: inline as \\(…\\), display as \\[…\\] on its own lines — a bare $ is literal currency and never starts math. Inside math, use LaTeX commands for symbols, not unicode stand-ins (they fail to render). Code, pseudocode, and formal systems live in fenced code blocks with a language tag; identifiers and symbols inline as `code`. Use tables for parallel structure and comparisons, lists for enumerations. Skip headers — at this size they are pointless decoration; structure with paragraphs, bold labels, and lists (a genuinely long derivation may earn one). Prose-only renderings of mathematical or structured content are a defect: if a sentence carries an equation, a derivation, or a piece of code, format it as what it is.",
),
"the exact FORMATTING sentence",
);
@@ -312,7 +312,7 @@ describe("formatting doctrine pins", () => {
it("GRADER_INSTRUCTIONS carries the formatting paragraph before the OUTPUT section", () => {
assert.ok(
GRADER_INSTRUCTIONS.includes(
- "Feedback renders as full Markdown. Mathematics is always LaTeX — inline as $…$, display as $$…$$ on its own lines. Code in fenced blocks with a language tag, identifiers inline as `code`. Lists and tables wherever they carry structure. Name the failed recall and teach the boundary in properly formatted prose, not plain text.",
+ "Feedback renders as full Markdown. Mathematics is always LaTeX — inline as \\(…\\), display as \\[…\\] on its own lines (a bare $ is literal currency). Inside math, LaTeX commands for symbols, not unicode stand-ins. Code in fenced blocks with a language tag, identifiers inline as `code`. Lists and tables wherever they carry structure; no headers — feedback is short-form, structure it with paragraphs and bold labels. Name the failed recall and teach the boundary in properly formatted prose, not plain text.",
),
"the exact grader paragraph",
);