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).
This commit is contained in:
amosblomqvist
2026-09-25 17:56:12 +03:00
parent 369fe497ae
commit 7cfd8b5bc7
7 changed files with 608 additions and 230 deletions
+20 -8
View File
@@ -1,16 +1,23 @@
/**
* POST /api/activity/abort — the dock's END/ABORT control, uniform across
* process kinds (user-decided contract): body {kind: "job"|"extraction", id}
* where id is the jobId for sub-agent jobs and the chatId for extraction
* runs (the pipeline registry is chat-keyed). Mechanisms live in their
* domains — abortJob (server/jobs.ts, agents stream) and abortExtraction
* (the pipeline module, graph stream); this route is the thin composition.
* process kinds (user-decided contract): body {kind: "job"|"extraction"|"turn", id}
* where id is the jobId for sub-agent jobs, the chatId for extraction runs
* (the pipeline registry is chat-keyed), and the chatId for turns
* (background-turn wave, 2026-09-25: a running turn is a background
* object — any client may stop it here; a view leaving NEVER kills it).
* Mechanisms live in their domains — abortJob (server/jobs.ts, agents
* stream), abortExtraction (the pipeline module, graph stream), and
* abortTurn (server/turn-runs.ts, the running-turn registry); this route
* is the thin composition.
*
* Outcomes: a job's not_found -> 404, not_running -> 409, aborted -> 200.
* A turn mirrors the job semantics: not_running -> 409 (honest "nothing to
* abort" — the caller refetches), aborted -> 200 (partial persists via
* the usual settle path, every subscriber closes cleanly).
* Extraction abort is idempotent by design (aborting with nothing in
* flight is success — the blob is gone either way) -> 200.
*/
import { abortExtraction, abortJob } from "@/src/server";
import { abortExtraction, abortJob, abortTurn } from "@/src/server";
export async function POST(req: Request) {
let body: unknown;
@@ -20,8 +27,8 @@ export async function POST(req: Request) {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { kind, id } = (body ?? {}) as { kind?: string; id?: string };
if ((kind !== "job" && kind !== "extraction") || typeof id !== "string" || id === "") {
return Response.json({ error: 'Body must be {kind: "job"|"extraction", id: string}' }, { status: 400 });
if ((kind !== "job" && kind !== "extraction" && kind !== "turn") || typeof id !== "string" || id === "") {
return Response.json({ error: 'Body must be {kind: "job"|"extraction"|"turn", id: string}' }, { status: 400 });
}
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
return Response.json({ error: "Invalid id" }, { status: 400 });
@@ -35,6 +42,11 @@ export async function POST(req: Request) {
}
return Response.json({ ok: true });
}
if (kind === "turn") {
const outcome = abortTurn(id);
if (!outcome.ok) return Response.json({ ok: false, reason: outcome.reason }, { status: 409 });
return Response.json({ ok: true });
}
// kind === "extraction": true cancellation; idempotent (nothing in
// flight is success — same dock outcome)
abortExtraction(id);
+21
View File
@@ -0,0 +1,21 @@
/**
* THE REATTACH STREAM (background-turn runner wave, owner ruling
* 2026-09-25): one SSE stream per chat, same wire dialect as the POST
* /api/chat stream. A running turn → subscribe to the live turn object:
* FULL replay of the event log, then live chunks until settle ([DONE]).
* No running turn → the cheap terminal ({"type":"turn-settled"} + [DONE]):
* the record is current, the client refetches it.
*
* 404 mirrors the POST handler's unknown-chat. Framework route is a thin
* wrapper over the server barrel (the import law); tests drive the seams
* (turn-runs + ui-stream) directly.
*/
import { getChat, runningTurn, piTurnResponse, settledTurnResponse } from "@/src/server";
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await ctx.params;
if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 });
const handle = runningTurn(id);
if (!handle) return settledTurnResponse();
return piTurnResponse(handle);
}