Files
learnharness/tests/turn-liveness.test.ts
T
Amos a7ca33548b Sessions keep running across navigation: turn liveness + write-ahead persist
The bug: answering a quiz and navigating away mid-turn, then returning,
re-prompted the same question. Root causes: (1) the answer was durable only
at onFinish — every mid-turn view read the stale pre-answer record;
(2) the server had no notion of a turn in flight; (3) remount treated the
stale record as current, with no re-attach path. Worse, the stale dock let
the quiz be answered again, racing two turns into a last-writer-wins
saveMessages clobber.

Fix (four pieces):
- Write-ahead persist in chat-handler: the request body IS the client's
  canonical state — saved to disk BEFORE the stream starts. The answer is
  durable the instant the request arrives; crashes recover via the existing
  mount continuation.
- Turn liveness registry (src/server/turn-liveness.ts): in-memory chatId →
  in-flight map with a TTL leak guard; beginTurn rejects concurrent turns
  (409) so one turn ever owns a chat. endTurn runs in onFinish AFTER
  persistTurn, so pollers that see not-in-flight always read a finished
  record; a turn-start failure clears the registry (no lockout).
- GET /api/chats/[id] reports turnInFlight; the chat page passes
  initiallyInFlight to the client.
- Client re-attach (components/chat.tsx): while in flight, busy shimmer +
  composer gated + mount continuation suppressed; poll /api/chats/[id] until
  the turn finishes, then syncFromServer adopts the completed transcript.
  Stop stays local-stream only (a server turn has no client request to
  abort).

Verified by scripted-model tests: concurrent turn 409 + registry lifecycle,
write-ahead durability mid-turn, failed-turn lockout guard.
2026-09-01 01:57:13 +03:00

25 lines
1019 B
TypeScript

/**
* Turn liveness registry unit tests (src/server/turn-liveness.ts): one turn
* per chat, ever — begin/end round trip, double-begin rejection, restart
* after end. The 30-minute stale-eviction TTL is not tested (it is a leak
* guard, not a behavior).
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
describe("turn liveness registry", () => {
it("begin/end round trip; a second begin is rejected until the turn ends", () => {
const id = "chat_liveness_unit";
assert.equal(isTurnRunning(id), false, "initially idle");
assert.equal(beginTurn(id), true, "first turn accepted");
assert.equal(isTurnRunning(id), true, "in flight");
assert.equal(beginTurn(id), false, "no two turns for one chat");
endTurn(id);
assert.equal(isTurnRunning(id), false, "cleared at finish");
assert.equal(beginTurn(id), true, "a new turn may start after the last ended");
endTurn(id);
});
});