Files
learnharness/src/server/turn-runs.ts
T
amosblomqvist 7cfd8b5bc7 Background turn runner: every turn is a server-side process, clients are views
Owner ruling 2026-09-25. runPiTurn executes a turn decoupled from any HTTP
response: an unbounded memory-only event log (finite by construction; the
30-min liveness TTL bounds the pathological case) plus a subscriber set.
The POST /api/chat stream and the new GET /api/chats/[id]/stream are both
subscribers — identical SSE dialect; late subscribers get FULL replay
(starting at the start chunk) then live until finish + [DONE]. A view
cancelling (reload, navigate) now detaches ONLY — never aborts the turn.
The remote kill is the abort channel: POST /api/activity/abort gains
kind: "turn" (id = chatId; not_running 409 like jobs); handle.abort sets
sawAbort + agent.abort(), every subscriber closes cleanly, the partial
persists through the usual settle (onFinish runs once, then endTurn,
then unregisterTurn LAST so a view that finds nothing finds the record
current). No running turn on reattach: the cheap terminal — one
{"type":"turn-settled"} event + [DONE] (settledTurnResponse).

The live smoke caught what the unit suite had masked: the agent_end
close() had been left behind in the restructure — ReferenceError every
turn, steered into the error path; fixed and pinned (clean turns must
never emit an error chunk; finishReason stop present).
2026-09-25 17:56:12 +03:00

82 lines
3.5 KiB
TypeScript

/**
* The running-turn registry (background-turn runner wave, owner ruling
* 2026-09-25): chatId → the live PiTurnHandle. THE reattach surface —
* GET /api/chats/[id]/stream resolves here, and the remote abort
* (/api/activity/abort, kind "turn") kills through here.
*
* Same globalThis discipline as turn-liveness.ts (read its header): Next
* dev compiles each route as its own module instance, so a module-level
* Map would be a DIFFERENT registry in the POST /api/chat entry and the
* GET .../stream entry. globalThis spans module instances within the
* process.
*
* Registration lifecycle: chat-handler registers the handle the moment
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
* — after persist, usage, extraction, and endTurn — so anything that sees
* "no running turn" also sees an already-persisted record. The per-chat
* 409 guard means a chatId can hold at most one handle, and beginTurn's
* 30-minute TTL bounds the pathological never-settling case the same way
* it does for the liveness map: if onFinish never fires, the stale entry
* is overwritten by the next turn's registration (Map.set) and garbage
* collected — no unbounded leak beyond the same pathology turn-liveness
* already accepts.
*
* App-quit mid-turn (the process dying with an un-persisted turn) is the
* SAME residual as before the wave: the registry is memory-only, the
* write-ahead record + liveness TTL recovery cover the next mount.
*/
import type { PiTurnHandle } from "./pi/ui-stream.ts";
interface LhTurnRunsGlobal {
__lhTurnRuns?: Map<string, PiTurnHandle>;
}
function runs(): Map<string, PiTurnHandle> {
const g = globalThis as typeof globalThis & LhTurnRunsGlobal;
return (g.__lhTurnRuns ??= new Map<string, PiTurnHandle>());
}
/** Register the chat's running turn (chat-handler, right after the turn
* starts). One per chat by construction — the 409 guard. */
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
runs().set(chatId, handle);
}
/** Unregister at settle — chat-handler's onFinish, LAST (after endTurn). */
export function unregisterTurn(chatId: string): void {
runs().delete(chatId);
}
/** The live turn handle for a chat, or undefined — the subscribe endpoint's
* and the abort channel's resolution point. */
export function runningTurn(chatId: string): PiTurnHandle | undefined {
return runs().get(chatId);
}
/** The remote kill: any client (original sender, reattached viewer, future
* surfaces) may stop a running turn. not_running mirrors abortJob's
* honest 409 — there was nothing to abort, the caller refetches. */
export function abortTurn(chatId: string): { ok: true } | { ok: false; reason: "not_running" } {
const handle = runs().get(chatId);
if (!handle) return { ok: false, reason: "not_running" };
handle.abort();
return { ok: true };
}
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
* turn (raced settle, or the client checked turnInFlight a beat too
* late): a same-dialect SSE response whose single chunk tells the client
* "nothing to attach to — the record is current, refetch it". Chosen as
* an EVENT (not a status code) so the reattach path consumes ONE wire
* protocol end to end. */
export function settledTurnResponse(): Response {
return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}