Wave 2: 409 merge-retry transport, dead pendingJob code, evidence-based timing, prompt-size guard

- components/chat-transport.ts (NEW): createChatTransport(chatId) — the
  chat.tsx seam is now a one-line swap. Its fetch turns a 409 on POST
  /api/chat (a learner send racing the wake pipeline's ~1s adoption window)
  into: wait out the in-flight turn (poll turnInFlight, 30s cap), refetch
  the record, MERGE the outgoing messages onto the fresh server list, re-POST.
  A bare retry would clobber the steer + wake messages via the write-ahead
  persist (steerDelivered makes that loss permanent) — merging is the whole
  point. Unit-tested: pass-through, wait+merge+reorder, dedupe by id,
  repeated-409 loop, bounded give-up.
- src/core/pending.ts: findPendingJobCall deleted (dead since wave 1 —
  job tools resolve server-side with an ack, nothing pends client-side).
  JOB_TOOLS kept (runner.ts still uses it). Header doc rewritten.
- src/server/jobs.ts: GRACE_MS 1500 → 750, with the evidence in the
  comment: the race window is only the learner's answer→POST flight
  (sendAutomaticallyWhen dispatches synchronously; ~10ms localhost,
  <300ms realistic RTT — 750 is 2.5× worst case) and misses self-heal
  via the pump's gate recheck, the 409 backoff ramp, and the new
  transport merge-retry. POLL_MS=400 kept (sampling granularity only).
- tests/jobs.test.ts: prompt-size guard — every fragment ≤8k chars,
  composed prompt ≤60k — the README-leak class (815 bytes ≈ 200 tokens
  per teacher turn before wave 1's fix) can't silently recur.

Gates: typecheck, 279/279 tests, lint, build — all green.
This commit is contained in:
amosblomqvist
2026-09-05 21:33:13 +03:00
parent e6409036a9
commit 2f8cf71f52
5 changed files with 287 additions and 10 deletions
+137
View File
@@ -0,0 +1,137 @@
/**
* 409 merge-retry transport tests (components/chat-transport.ts).
*
* Every 409 means the server record is AHEAD (a wake turn persisted the
* steer): the retry must wait out the turn, merge the outgoing messages onto
* the fresh record, and re-POST the merged list — a bare retry would clobber
* the steer/wake messages via the write-ahead persist.
*/
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import { retryingFetch } from "../components/chat-transport.ts";
import type { TeacherUIMessage } from "../src/core/ui-message.ts";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
function msg(id: string, role: "user" | "assistant", text: string): TeacherUIMessage {
return { id, role, metadata: {}, parts: [{ type: "text", text }] };
}
interface Call {
url: string;
body: string | undefined;
}
function installFetch(routes: {
/** POST /api/chat → Response; receives every attempt. */
chatPOST: (call: Call, attempt: number) => Response;
/** GET /api/jobs?chatId=… → turnInFlight value per poll (poll index). */
jobsGET?: (poll: number) => boolean;
/** GET /api/chats/<id> → messages array. */
chatGET?: () => TeacherUIMessage[];
}): Call[] {
const calls: Call[] = [];
let poll = 0;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, body: init?.body as string | undefined });
if (url === "/api/chat" && init?.method === "POST") {
return routes.chatPOST(calls.at(-1)!, calls.filter((c) => c.url === "/api/chat").length);
}
if (url.startsWith("/api/jobs")) return Response.json({ turnInFlight: routes.jobsGET?.(++poll) ?? false });
if (url.startsWith("/api/chats/")) return Response.json({ messages: routes.chatGET?.() ?? [] });
throw new Error(`unexpected fetch: ${url}`);
}) as typeof globalThis.fetch;
return calls;
}
function outgoing(clientOnly: TeacherUIMessage[]): string {
return JSON.stringify({ chatId: "chat_x", messages: [...clientOnly] });
}
describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true : undefined }, () => {
it("passes non-409 responses through untouched", async () => {
const calls = installFetch({
chatPOST: () => new Response("stream", { status: 200 }),
});
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "hi")]) });
assert.equal(res.status, 200);
assert.equal(calls.length, 1, "no polling, no merge, single call");
});
it("waits out the turn, merges onto the fresh record, re-POSTs the merged list", async () => {
const server = [msg("steer_1", "user", "Research complete"), msg("s1", "assistant", "wake turn")];
const fresh = [msg("c1", "user", "my question while the wake turn ran")];
const calls = installFetch({
chatPOST: (_call, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
jobsGET: (poll) => poll <= 1, // first poll: still running; second: done
chatGET: () => server,
});
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
const res = await fetch("/api/chat", { method: "POST", body: outgoing(fresh) });
assert.equal(res.status, 200);
// second POST carries server truth + the client's unsent message, in order
const retry = JSON.parse(calls.filter((c) => c.url === "/api/chat")[1]!.body!);
assert.deepEqual(
retry.messages.map((m: { id: string }) => m.id),
["steer_1", "s1", "c1"],
);
});
it("drops client messages the server already has (no duplicates in the merge)", async () => {
const server = [msg("steer_1", "user", "Research complete"), msg("c1", "user", "my question")];
const calls = installFetch({
chatPOST: (_call, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
chatGET: () => server,
});
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
const res = await fetch("/api/chat", {
method: "POST",
body: outgoing([msg("steer_1", "user", "Research complete"), msg("c1", "user", "my question")]),
});
assert.equal(res.status, 200);
const retry = JSON.parse(calls.filter((c) => c.url === "/api/chat")[1]!.body!);
assert.deepEqual(
retry.messages.map((m: { id: string }) => m.id),
["steer_1", "c1"],
);
});
it("loops on repeated 409s, re-merging fresh each time", async () => {
const server = [msg("steer_1", "user", "Research complete")];
const calls = installFetch({
chatPOST: (_call, n) => (n <= 2 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
chatGET: () => server,
});
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "q")]) });
assert.equal(res.status, 200);
const posts = calls.filter((c) => c.url === "/api/chat");
assert.equal(posts.length, 3);
const last = JSON.parse(posts[2]!.body!);
assert.deepEqual(
last.messages.map((m: { id: string }) => m.id),
["steer_1", "c1"],
);
});
it("gives up after TURN_WAIT_MAX_MS and returns the last 409 (banner as before)", async () => {
// turnInFlight stuck true; two polls (~1s) then force the deadline path
// by asserting we eventually get the 409 back — bounded by poll count.
let polls = 0;
const calls = installFetch({
chatPOST: () => new Response("busy", { status: 409 }),
jobsGET: () => true,
});
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "q")]) });
assert.equal(res.status, 409);
polls = calls.filter((c) => c.url.startsWith("/api/jobs")).length;
assert.ok(polls <= 15, `expected bounded polling (~10×10ms), got ${polls}`);
});
});
+14 -1
View File
@@ -700,7 +700,20 @@ describe("prompt composition", () => {
assert.ok(instructions.startsWith(base), "base prompt first");
for (const f of fs.readdirSync("src/agent/prompts").filter((f) => /^\d+-[\w.-]+\.md$/.test(f)).sort()) {
const body = fs.readFileSync(`src/agent/prompts/${f}`, "utf-8").trim();
assert.ok(instructions.includes(body), `fragment ${f} must compose verbatim`);
assert.ok(instructions.includes(body), `fragment ${f} must compose verbatim`);
}
});
it("guards prompt size — no fragment can silently balloon (the README-leak class)", () => {
// The wave-1 leak: README.md (815 bytes / ~200 tokens) rode into every
// teacher turn until the numeric-prefix filter stopped it. This guard
// catches the sibling failure mode — an accidental dump pasted into a
// fragment. Thresholds: largest real fragment is 40-demos.md at ~3.2k
// chars; 8k is generous headroom. Composed total ≤ 60k chars.
for (const f of fs.readdirSync("src/agent/prompts").filter((f) => /^\d+-[\w.-]+\.md$/.test(f))) {
const body = fs.readFileSync(`src/agent/prompts/${f}`, "utf-8").trim();
assert.ok(body.length <= 8_000, `fragment ${f} is ${body.length} chars — over the 8k guard`);
}
assert.ok(composeInstructions().length <= 60_000, "composed system prompt over the 60k guard");
});
});