fix: the handoff freeze + the free-form mermaid pass

Two user-reported problems from the muscle-protein-synthesis session,
both diagnosed from the live data (chat_mtxeml9e_6118e59f).

The freeze: after probe_done, the open session never showed the
teacher's wake turn — only navigating away and back loaded it. Not an
agent-switch problem: the server-side handoff worked perfectly
(write-ahead persist, liveness registry, the sidebar's busy dot saw the
wake the whole time). The gap was client attach: the only mid-session
wake detector the open page has is the jobs poll, and probe-phase
sessions have zero job traffic, so that poll is OFF; the post-turn
adoption poll read turnInFlight=false at ~0ms (the wake fires at
~0.75-1.2s, after the pump's grace), adopted, and stopped. Fix: the
post-turn adoption effect now keeps a short WATCH WINDOW after adopting
(~4s, 8x500ms) — if turnInFlight flips true, it hands off to the
existing wake-adoption poll (busy shimmer, adopt at finish).

The mermaid failures: four failed plan_init calls, all caused by ONE
hidden rule — underscores are forbidden in node ids (mps_translation,
net_balance, per_meal). The id regex rejected '_' BEFORE the slug check,
so the specific, actionable message never fired; the generic 'can't read
the edge — expected a[title] --> b[title]' lied (the syntax was exactly
that), and the teacher spent three attempts hunting punctuation it
suspected (colons, commas, multi-word labels) — visible in its own
reasoning trail. Per the user's principle — enforce only the slugs,
free everything else: underscores are first-class slug chars; the id
CANDIDATE class is broad so every bad id reaches the specific slug
message; chained edges (a --> b --> c) expand to pairs; ==>, -.-> are
edge synonyms; any direction header accepted (we layout ourselves);
id["title"] quote pairs are stripped. The tool description, zod
describe, and the teacher's planning prompt now state the one strict
rule instead of the old subset liturgy.

Verified: tsc, 305/305 (the exact muscle-protein mermaid is now a
passing test), lint, build; real-browser puppeteer smoke on live GLM —
the teacher's wake turn appeared in the open page ~50s after the probe
concluded with ZERO navigation, verdict gate + first teach check +
plan window all live in-page (the single console 404 is the app's
missing favicon, environmental noise).
This commit is contained in:
amosblomqvist
2026-09-11 23:54:06 +03:00
parent b2c89b8c91
commit 377d4b6548
5 changed files with 168 additions and 72 deletions
+50 -3
View File
@@ -238,17 +238,64 @@ describe("parseMermaidPlan (the constrained mermaid subset)", () => {
]);
});
it("rejects: non-slug ids, self-loops, chained edges, subgraphs, missing header, empty", () => {
it("rejects: non-slug ids (with the SPECIFIC message), self-loops, subgraphs, missing header, empty", () => {
// The muscle-protein post-mortem (2026-09-11): underscore ids died at
// the regex layer with a generic message, and the teacher spent four
// plan_init calls hunting punctuation the parser didn't care about.
// Now EVERY bad id reaches the slug check — the message names the id.
assert.ok(!parse("graph TD\n Foo Bar[x]\n").ok, "id with a space");
assert.match(parse("graph TD\n Foo Bar[x]\n").problems[0]!, /line 2.*slug/);
assert.match(parse("graph TD\n mps.translation[x]\n").problems[0]!, /node id `mps.translation` must be a lowercase slug/);
assert.match(parse("graph TD\n MPS[x]\n").problems[0]!, /node id `MPS` must be a lowercase slug/);
assert.match(
parse("graph TD\n a[Alpha] --> mps.translation[Beta]\n").problems[0]!,
/node id `mps.translation` must be a lowercase slug/,
"bad id inside an edge also gets the specific message",
);
assert.ok(!parse("graph TD\n a --> a\n").ok, "self-loop");
assert.ok(!parse("graph TD\n a --> b --> c\n").ok, "chained edge");
assert.match(parse("graph TD\n a --> b --> c\n").problems[0]!, /one edge per line/);
assert.ok(!parse("graph TD\n subgraph cluster\n").ok, "subgraph");
assert.ok(!parse("digraph G\n a\n").ok, "wrong header");
assert.ok(!parse("%% only a comment\n").ok, "empty plan");
});
it("free-form pass (2026-09-11): underscores, chains, arrow synonyms, any direction, quoted titles", () => {
// Underscores are first-class slug chars — kept verbatim, referenced
// verbatim by quiz nodeId.
const us = parse("graph TD\n mps_translation[Ribosomes build protein] --> net_balance[Net balance]\n");
assert.ok(us.ok, "underscore ids");
assert.deepEqual(us.plan!.nodes, [
{ id: "mps_translation", title: "Ribosomes build protein" },
{ id: "net_balance", title: "Net balance" },
]);
// The exact shape the muscle-protein teacher wrote (attempt 1):
// quoted titles with colons, commas, equals signs — must just work.
const quoted = parse(
'graph TD\n mps_translation["MPS = ribosomes building protein"] --> per_meal["Per-meal dose: ~20-40 g saturates, excess oxidized"]\n',
);
assert.ok(quoted.ok, "quoted punctuation-rich titles");
assert.deepEqual(quoted.plan!.nodes, [
{ id: "mps_translation", title: "MPS = ribosomes building protein" },
{ id: "per_meal", title: "Per-meal dose: ~20-40 g saturates, excess oxidized" },
]);
// Chains expand to consecutive pairs.
const chain = parse("graph TD\n a[Alpha] --> b[Bee] --> c[See]\n");
assert.ok(chain.ok, "chained edges");
assert.deepEqual(chain.plan!.edges, [
{ from: "a", to: "b" },
{ from: "b", to: "c" },
]);
// Arrow synonyms ==>, -.-> mean the same as -->.
for (const arrow of ["==>", "-.->"]) {
const res = parse(`graph TD\n a ${arrow} b\n`);
assert.ok(res.ok, arrow);
assert.deepEqual(res.plan!.edges, [{ from: "a", to: "b" }]);
}
// Any direction header (we layout ourselves).
for (const header of ["graph LR", "flowchart RL", "graph BT"]) {
assert.ok(parse(`${header}\n a --> b\n`).ok, header);
}
});
it("the first label wins when a node line repeats (relabeling mid-block is ignored)", () => {
const res = parse("graph TD\n a[First label]\n a[Second label]\n");
assert.deepEqual(res.plan!.nodes, [{ id: "a", title: "First label" }]);