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:
@@ -7,7 +7,7 @@ import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
|
||||
import { recordToPiMessages } from "./pi/record-adapter.ts";
|
||||
import { createPiTurnResponse } from "./pi/ui-stream.ts";
|
||||
import { piTurnResponse, runPiTurn } from "./pi/ui-stream.ts";
|
||||
import { effectiveSettings, stores } from "./stores.ts";
|
||||
import { searchAvailable, searchProvider } from "./search.ts";
|
||||
import { providerKeyPresent } from "./keys.ts";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../core/settings-resolution.ts";
|
||||
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
||||
import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
|
||||
import type { CallUsage } from "../core/contracts.ts";
|
||||
|
||||
@@ -310,10 +311,14 @@ export async function handleChatRequest(
|
||||
// the factory's subscription (wired above via onUsage).
|
||||
try {
|
||||
assertValidUiMessages(outgoing);
|
||||
return createPiTurnResponse({
|
||||
// THE TURN AS A BACKGROUND OBJECT (owner ruling, 2026-09-25):
|
||||
// runPiTurn executes decoupled from this response — the POST stream
|
||||
// is just the FIRST subscriber (a view). A client disconnect can no
|
||||
// longer abort the turn; the remote kill is the abort channel, and
|
||||
// late views reattach via GET /api/chats/[id]/stream.
|
||||
const handle = runPiTurn({
|
||||
agent: constructed,
|
||||
uiMessages: outgoing,
|
||||
signal: req.signal,
|
||||
// Stream-level failures are provider/transport internals — masked,
|
||||
// like the SDK defaults. Tool-input validation messages never
|
||||
// route through here: they ride the error-result path and reach the
|
||||
@@ -352,8 +357,22 @@ export async function handleChatRequest(
|
||||
// LAST: a poller that sees not-in-flight must read an already-persisted
|
||||
// record — endTurn only after persistTurn has done its job.
|
||||
endTurn(chatId);
|
||||
// And only then does the turn stop being a running object
|
||||
// (background-turn wave, 2026-09-25): a reattach that finds
|
||||
// nothing must find the persisted record current — the same
|
||||
// ordering rule as endTurn.
|
||||
unregisterTurn(chatId);
|
||||
},
|
||||
});
|
||||
// The turn is now a live background object: register it so any view
|
||||
// can reattach (GET /api/chats/[id]/stream) or remotely abort it
|
||||
// (POST /api/activity/abort, kind "turn"). Registration after the
|
||||
// runPiTurn call is race-free: the agent loop only ticks in later
|
||||
// microtasks, so settle (and thus unregister) cannot beat us here.
|
||||
registerTurn(chatId, handle);
|
||||
// The POST response: just this caller's view onto the running turn —
|
||||
// identical wire dialect to the reattach stream.
|
||||
return piTurnResponse(handle);
|
||||
} catch (err) {
|
||||
// The stream never started, so no onFinish will fire — clear the registry
|
||||
// or a malformed request would lock the chat until the TTL eviction.
|
||||
|
||||
Reference in New Issue
Block a user