Files
learnharness/src/server/jobs.ts
T
amosblomqvist 7ab47d4fcb Wave B: the native message record — input events, projection, adapter death
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.

Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
  toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
  subagent-abort, plan-block), constructors, and toLlmMessages — the single
  model-facing seam (convertToLlm): strips ids, flattens markers to user
  text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
  dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
  projection (consecutive assistant+toolResult runs group into one
  assistant view message; toolResults attach backward by toolCallId;
  markers project to data parts; plan-blocks splice after the last
  successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
  continue), parseChatInput validation, and inputAppends (answer targets
  the dangling question call or 400s; verdict = optional note user
  message + native verdict marker).

Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
  persists input appends + claimed steers before the turn; claimSteers
  coalesces the whole queue with the handler's steerDelivered filter as
  the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
  placeholder toolResults (the record ends at the unanswered call),
  emits plan-block markers idempotently (plan-blocks.ts rewrite), and
  persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
  unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
  waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
  convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).

Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
  body after the turn settles — the event was never persisted
  pre-acceptance; the merge logic is gone). The transport consumes the
  stashed input event via prepareSendMessagesRequest and throws loudly on
  a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
  (text, answer, verdict, continue) before invoking the SDK send.

Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
2026-09-26 00:37:46 +03:00

467 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side
* steer delivery for finished sub-agent jobs.
*
* deliverJobResult(steer) — the runner calls this on job completion. The
* steer joins a per-chat serialized queue and a pump drives it:
*
* 1. WAIT FOR IDLE: while a turn is in flight (turn-liveness) OR a
* question tool part is pending (an unanswered client-side tool
* call breaks provider conversion — the steer fires right after
* the learner's continuation), poll. A short grace after the gates
* open avoids stealing the turn a client is about to send.
* 2. FIRE: a server-side teacher turn through the SAME composition as
* a client request (the injected turn runner wraps
* handleChatRequest): write-ahead persist, liveness, extraction —
* one code path. A 409 means a client turn raced in — retry with
* backoff. Idempotent per jobId (steerDelivered): a crashed wake
* or a duplicate deliver is skipped, not duplicated.
* 3. FLIP: only now does the job go done/error — a polling client
* that sees the flip re-syncs once and gets the finished record
* (steer + the teacher's response to it).
*
* Crash recovery (recoverStaleJobs): a process restart can orphan jobs —
* "running" with a result (the wake never fired) or without one (the
* sub-agent loop itself died). The sweep re-delivers finished results
* (idempotently) and re-spawns unfinished loops, both via injected
* seams. It runs lazily off the /api/jobs poll, throttled.
*
* All mutable state lives on globalThis (the turn-liveness lesson: Next dev
* gives each route entry its own module instances — a module-level Map would
* be a different object in each).
*/
import {
buildAbortSteer,
buildSubagentSteer,
isSubagentFailure,
steerDelivered,
type JobRecord,
type SteerMessage,
} from "../core/contracts.ts";
import type { TurnInput } from "../core/input-events.ts";
import { danglingQuestionOf } from "../core/native-messages.ts";
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
import { stores } from "./stores.ts";
import { isTurnRunning } from "./turn-liveness.ts";
/** Fires one server-side teacher turn: (chatId, input) → HTTP-ish status.
* Wave B: the input is the STEER EVENT — the handler appends the marker
* natively under its own turn claim (one composition for every turn). */
export type TurnRunner = (chatId: string, input: TurnInput) => Promise<number>;
/** Re-runs a job's sub-agent loop (crash recovery for orphaned jobs). */
export type JobRespawn = (jobId: string) => void;
interface QueuedSteer {
steer: SteerMessage;
resolve: () => void;
reject: (reason: Error) => void;
}
interface WakeState {
turnRunner?: TurnRunner;
respawn?: JobRespawn;
/** chatId → steers awaiting delivery, in arrival order. */
queues: Map<string, QueuedSteer[]>;
/** Chats whose pump loop is currently active (serialization per chat). */
pumping: Set<string>;
/** Throttle bookkeeping for the stale-job sweep. */
lastSweepAt: number;
}
interface LhWakeGlobal {
__lhWakePipeline?: WakeState;
}
function state(): WakeState {
const g = globalThis as typeof globalThis & LhWakeGlobal;
return (g.__lhWakePipeline ??= { queues: new Map(), pumping: new Set(), lastSweepAt: 0 });
}
/** chat-handler wires this at module init: its adapter around handleChatRequest. */
export function setTurnRunner(run: TurnRunner): void {
state().turnRunner = run;
}
/** chat-handler wires this at module init: its job runner's runJob. */
export function setJobRespawn(respawn: JobRespawn): void {
state().respawn = respawn;
}
const POLL_MS = 400;
/** After the gates open, wait a beat before firing — a client that just
* answered a question is about to send its continuation; don't 409 it.
*
* Evidence for 750: the race window is exactly the learner's answer→POST
* flight — sendAutomaticallyWhen dispatches synchronously on addToolOutput,
* the answer hits the record only at the write-ahead persist, and beginTurn
* flips the turn registry immediately after, all before the grace can fire
* (measured localhost RTT ~10ms; typical LAN/production RTT <300ms). 750ms
* is 2.5× the worst realistic flight. A miss self-heals: the pump rechecks
* the gates after the grace AND the wake pipeline retries 409s with a
* backoff ramp — and since the transport's merge-retry (chat-transport.ts)
* made client 409s non-fatal, even a pathological RTT degrades gracefully.
* (Was 1500 — pure guess; 750 claws back 750ms of post-answer delivery
* latency without weakening the guard.) */
const GRACE_MS = 750;
/** 409 backoff: capped ramp so a busy client can't spin the pump. */
const RETRY_BACKOFF_MS = [500, 1000, 2000, 5000] as const;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** The job a steer belongs to, if it is a job steer (result/abort);
* probe-handoff steers have no job — null. */
function jobIdOf(steer: SteerMessage): string | null {
const details = steer.details as { jobId?: string } | undefined;
return typeof details?.jobId === "string" ? details.jobId : null;
}
/** Finalize a delivered steer's job bookkeeping — job steers only. */
function finalizeSteer(steer: SteerMessage): void {
const jobId = jobIdOf(steer);
if (jobId) finalizeJob(jobId);
}
/** Fail a steer's job on unrecoverable delivery failure — job steers only. */
function failSteer(steer: SteerMessage, err: Error): void {
const jobId = jobIdOf(steer);
if (jobId) failJob(jobId, err);
}
/** Queue ANY steer (result, abort, probe handoff) through the pipeline.
* Resolves when the wake turn has completed; rejects when delivery fails
* unrecoverably. Never rejects for queueing itself. */
export function deliverJobResult(steer: SteerMessage): Promise<void> {
return new Promise<void>((resolve, reject) => {
const s = state();
const chatId = chatIdOf(steer);
const queue = s.queues.get(chatId) ?? [];
queue.push({ steer, resolve, reject });
s.queues.set(chatId, queue);
void pump(chatId);
});
}
/**
* CONTINUATION COALESCING ("THE ASYNC PROTOCOL" §4): the chat-handler calls
* this the instant a client turn wins the turn registry, BEFORE its
* write-ahead persist. Undelivered steers for the chat ride THAT turn —
* appended after the learner's answer, which is conversion-safe (the
* answered quiz carries its output) — instead of waiting for the pump's
* idle window, which a quiz-chaining teacher may never open (the delivery
* starvation bug: every turn ends with a new pending quiz, so the gates
* never stay open long enough to fire a wake).
*
* Claimed steers resolve their deliver() promise and flip the job done
* here: the caller holds the turn registry, so the write-ahead persist is
* imminent and durable. Steers already in the transcript (a wake raced in)
* are finalized, not returned. The pump re-checks steerDelivered right
* before firing, so a claim racing the pump converges to exactly one
* delivery either way.
*
* incoming (the turn's request body — the wake pump fires turns that
* ALREADY CARRY the steer it dequeues only after the turn returns 200, so
* the entry is still queued here): a steer already in the incoming list is
* being delivered by THIS VERY TURN — finalized and resolved, but never
* appended again. Without this the write-ahead persist would hold two
* copies of the same steer id (a live React duplicate-key warning the
* owner hit 2026-09-18; withMessageIds only heals it at onFinish).
*/
export function claimSteers(chatId: string): SteerMessage[] {
const s = state();
const queue = s.queues.get(chatId);
if (!queue || queue.length === 0) return [];
s.queues.delete(chatId);
// The caller holds the turn registry (chat-handler claims after
// beginTurn), so the write-ahead persist is imminent and durable:
// every queued steer is claimed — its delivery is THIS turn's job.
// Steers already in the transcript (a wake raced in) still finalize
// here; the handler's append filter (steerDelivered) keeps exactly
// one copy in the record.
const claimed = queue.map((entry) => entry.steer);
for (const entry of queue) {
finalizeSteer(entry.steer);
entry.resolve();
}
return claimed;
}
/** Result of an abort attempt — the endpoint maps these to HTTP. */
export interface AbortOutcome {
ok: boolean;
reason?: "not_found" | "not_running";
}
/**
* ABORT (2026-09 dock semantics, user-decided): real kill, error with
* reason, the teacher still learns. The dock's END button reaches this
* through POST /api/activity/abort (body {kind: "job", id: jobId}); the
* route is master's thin composition — this is the job-side mechanism.
* Four things happen, in order:
*
* 1. KILL the spend: fire the job's registered AbortController (the
* runner registered it at loop start) — the in-flight model call
* aborts, not just the next loop check. The runner unwinds silently
* (its catch checks abortSignal.aborted; finishJob checks status).
* 2. DROP any queued result steer for this job (the delivering-result
* phase): resolve its entry without firing the wake. If the wake
* turn is ALREADY in flight (the pump shifted the entry), it's too
* late — the result steer lands, and the cancellation steer dedupes
* against it via steerDelivered (same jobId metadata).
* 3. FLIP the record to error "Aborted by learner" immediately — the
* poll reflects the user's action at once (the user chose
* error-with-reason over a distinct cancelled status). finalizeJob
* never overwrites a non-running status, so the flip is terminal no
* matter what the wake does.
* 4. STEER the teacher: a one-line abort note (buildAbortSteer) through
* the same wake pipeline, so the teacher's promise of a result
* doesn't hang silently in the lesson.
*
* The runner writes the usage folded so far in its finally (merge patch),
* so an aborted job's money record survives the kill.
*/
export function abortJob(jobId: string): AbortOutcome {
const job = stores.jobStore.getJob(jobId);
if (!job) return { ok: false, reason: "not_found" };
if (job.status !== "running") return { ok: false, reason: "not_running" };
fireJobAbort(jobId); // no-op when the loop already returned (delivering)
// Drop any queued steers for this job (their deliver() resolves unfired).
const queue = state().queues.get(job.chatId);
if (queue) {
const remaining = queue.filter((e) => jobIdOf(e.steer) !== jobId);
if (remaining.length === 0) state().queues.delete(job.chatId);
else state().queues.set(job.chatId, remaining);
for (const entry of queue) {
if (jobIdOf(entry.steer) === jobId) entry.resolve();
}
}
stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
void deliverJobResult(buildAbortSteer(job)).catch(() => {
/* deliverJobResult marks the job error on unrecoverable failure */
});
return { ok: true };
}
function chatIdOf(steer: SteerMessage): string {
// The probe handoff carries its chat directly in the marker details (the
// job record would say it too, but the details are the cheap path).
if (steer.customType === "probe-handoff") {
return (steer.details as { chatId: string }).chatId;
}
// The chat is resolvable from the job record; the steer's details
// carry jobId, which indexes the store.
const jobId = jobIdOf(steer);
const job = jobId ? stores.jobStore.getJob(jobId) : undefined;
return job?.chatId ?? "";
}
/**
* The per-chat pump: delivers every queued steer in order, one wake turn
* each. Only one loop per chat at a time; later arrivals ride the queue.
*/
async function pump(chatId: string): Promise<void> {
if (!chatId) return;
const s = state();
if (s.pumping.has(chatId)) return;
s.pumping.add(chatId);
try {
for (let attempt = 0; ; ) {
const queue = s.queues.get(chatId);
if (!queue || queue.length === 0) {
s.queues.delete(chatId);
return;
}
const entry = queue[0]!;
const steerId = entry.steer.id;
// Gone chat: nothing to deliver into.
const record = stores.chatStore.getChat(chatId);
if (!record) {
queue.shift();
entry.reject(new Error(`chat ${chatId} not found — cannot deliver steer ${steerId}`));
continue;
}
// Idempotent delivery: the transcript already carries this steer
// (a previous wake completed, or a duplicate deliver raced in).
if (steerDelivered(record.messages, steerId)) {
queue.shift();
finalizeSteer(entry.steer);
entry.resolve();
continue;
}
await waitForIdle(chatId);
// Re-fetch AFTER the gates open — waitForIdle can wait out a quiz
// answer (minutes), and sending the pre-wait snapshot would clobber
// the client's answer + continuation in the write-ahead persist.
const fresh = stores.chatStore.getChat(chatId);
if (!fresh) {
queue.shift();
entry.reject(new Error(`chat ${chatId} not found — cannot deliver steer ${steerId}`));
continue;
}
// The runner is required — chat-handler sets it at init. If some
// module instance missed the wiring, fail loudly rather than hang.
if (!s.turnRunner) {
queue.shift();
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
failSteer(entry.steer, err);
entry.reject(err);
continue;
}
// A client continuation may have COALESCED this steer (claimSteers) while
// the gates were closed — re-check before firing: exactly one delivery.
if (steerDelivered(fresh.messages, steerId)) {
queue.shift();
finalizeSteer(entry.steer);
entry.resolve();
continue;
}
// Fire the server-side teacher turn (the steer EVENT: the handler
// appends the marker natively under its own turn claim). A 409 = a
// client turn raced in between idle and firing — back off and
// retry the same steer.
const status = await s.turnRunner(chatId, { type: "steer", steer: entry.steer }).catch((err: unknown) => {
console.error(`[steer ${steerId}] wake turn crashed:`, err);
return -1;
});
if (status === 409) {
await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!);
continue;
}
attempt = 0;
queue.shift();
if (status === 200) {
finalizeSteer(entry.steer);
entry.resolve();
} else {
const err = new Error(`wake turn failed with status ${status}`);
failSteer(entry.steer, err);
entry.reject(err);
}
}
} finally {
s.pumping.delete(chatId);
// A steer may have arrived between the last check and the finally —
// restart the pump rather than strand it.
if ((state().queues.get(chatId) ?? []).length > 0) void pump(chatId);
}
}
/** Flip the job to its final status now that the lesson has resumed. */
function finalizeJob(jobId: string): void {
const job = stores.jobStore.getJob(jobId);
if (!job || job.status !== "running") return;
const result = job.result;
const failed = result ? isSubagentFailure(job.kind, result as never) : true;
stores.jobStore.updateJob(jobId, {
status: failed ? "error" : "done",
activity: failed ? "failed" : "delivered",
});
}
function failJob(jobId: string, err: Error): void {
// An aborted job stays aborted even when its (abort) steer
// delivery fails — the user's action is the terminal fact, not the
// delivery error. Non-running records are never overwritten here.
const job = stores.jobStore.getJob(jobId);
if (!job || job.status !== "running") return;
stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
}
/**
* Wait until the chat can take a wake turn: no turn in flight AND no
* unanswered question call (the record's dangling question — the answer
* arrives as its tool result, and no turn may run in the window). Poll;
* the learner may take arbitrarily long to answer, and that is correct —
* the steer fires right after their answer turn completes.
*/
async function waitForIdle(chatId: string): Promise<void> {
for (;;) {
const record = stores.chatStore.getChat(chatId);
if (!record) return;
const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null;
if (!blocked) {
await delay(GRACE_MS);
const recheck = stores.chatStore.getChat(chatId);
if (
!isTurnRunning(chatId) &&
recheck &&
danglingQuestionOf(recheck.messages) === null
) {
return;
}
}
await delay(POLL_MS);
}
}
// ─── crash recovery ──────────────────────────────────────────────────────────
const SWEEP_THROTTLE_MS = 30_000;
/**
* Lazy sweep (called off the /api/jobs poll, throttled), LIVENESS-GATED
* (accuracy wave, 2026-09-19): a "running" record without a live loop
* registration (subagents/aborts.ts) is an orphan of a process death —
* re-deliver its stored result (idempotently) or re-spawn the loop, at
* once (the old 15-minute age heuristic waited on ghosts AND re-ran
* hung-but-alive loops beside themselves — the figure-maker incident,
* 2026-09-18). A record WITH a live registration is provably executing
* in THIS process — never touched, however old: a hung first model call
* (zero usage ever billed) stays honestly "running" in the dock until
* END kills it. The abort registration spans loop start to delivery
* completion, so a live registration can never belong to a dead run.
*/
/** Re-run a job's sub-agent loop — exposed for the sweep's tests (the
* /api/jobs poll is its only production caller, disabled under NODE_ENV=test). */
export function recoverStaleJobs(): void {
const s = state();
const now = Date.now();
if (now - s.lastSweepAt < SWEEP_THROTTLE_MS) return;
s.lastSweepAt = now;
try {
for (const chat of stores.chatStore.listChats()) {
for (const job of stores.jobStore.listJobs(chat.id)) {
if (job.status !== "running") continue;
if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it
if (!stores.chatStore.getChat(job.chatId)) continue;
if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now
if (job.result) {
// The wake never fired (or crashed mid-flight): re-deliver.
const steer = buildSubagentSteer(job, job.result as never);
stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" });
void deliverJobResult(steer).catch(() => {
/* deliverJobResult marks the job error on failure */
});
} else {
// The sub-agent loop itself died with the process: re-spawn.
if (!s.respawn) continue;
stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" });
s.respawn(job.id);
}
}
}
} catch (err) {
console.error("[jobs] stale-job recovery sweep failed:", err);
}
}
/** The /api/jobs entry: job list for the client poll (+ lazy recovery). */
export function listJobs(chatId: string): JobRecord[] {
if (process.env.NODE_ENV !== "test") recoverStaleJobs();
return stores.jobStore.listJobs(chatId);
}