@tangle-network/agent-app 0.49.1 → 0.49.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Exact terminal observation for a native Sandbox execution that another
3
+ * request already admitted. This module never sends a prompt, calls
4
+ * `driveTurn`, or cancels a run.
5
+ */
6
+ import type { SandboxInstance } from '@tangle-network/sandbox';
7
+ import type { ChatTurnUsage } from './turn-routes';
8
+ export interface NativeCompletionReceipt {
9
+ state: 'completed' | 'failed';
10
+ text: string;
11
+ parts: Array<Record<string, unknown>>;
12
+ /** An empty object is an unknown usage receipt, never a measured zero. */
13
+ usage: ChatTurnUsage;
14
+ servedModel?: string;
15
+ servedProvider?: string;
16
+ servedSource?: 'request' | 'environment' | 'profile';
17
+ error?: string;
18
+ completedTurnIds: string[];
19
+ }
20
+ export interface NativeCompletionAdmission {
21
+ executionId: string;
22
+ state: 'open' | 'closed';
23
+ admittedTurnIds: readonly string[];
24
+ ownerLeaseUntil: Date | number;
25
+ updatedAt?: Date | number;
26
+ closedAt?: Date | number | null;
27
+ }
28
+ /** Product persistence for the one admission that authorizes this observer. */
29
+ export interface NativeCompletionAdmissionStore {
30
+ read(executionId: string): Promise<NativeCompletionAdmission | null>;
31
+ renew(executionId: string, now: Date): Promise<NativeCompletionAdmission | null>;
32
+ closeExpired(executionId: string, now: Date): Promise<NativeCompletionAdmission | null>;
33
+ }
34
+ /** The official Sandbox reads needed for exact native completion observation. */
35
+ export type NativeCompletionSessionSource = Pick<SandboxInstance, 'findCompletedTurn' | 'session'>;
36
+ export interface NativeCompletionObservationOptions {
37
+ /** Null when the product cannot currently resolve the admitted Sandbox. */
38
+ source: NativeCompletionSessionSource | null;
39
+ admissionStore: NativeCompletionAdmissionStore;
40
+ executionId: string;
41
+ sessionId: string;
42
+ turnId: string;
43
+ registeredAt: number;
44
+ absentDispatchDeadlineMs?: number;
45
+ receiptDeadlineMs?: number;
46
+ now?: number;
47
+ }
48
+ export type NativeCompletionObservation = {
49
+ state: 'running';
50
+ } | {
51
+ state: 'completed';
52
+ receipt: NativeCompletionReceipt;
53
+ } | {
54
+ state: 'failed';
55
+ receipt: NativeCompletionReceipt;
56
+ };
57
+ /** One exact turn receipt before an admission's ordered aggregate. */
58
+ export interface NativeCompletionTurnReceipt {
59
+ turnId: string;
60
+ state: 'completed' | 'failed';
61
+ text: string;
62
+ parts: Array<Record<string, unknown>>;
63
+ usage: ChatTurnUsage;
64
+ servedModel?: string;
65
+ servedProvider?: string;
66
+ servedSource?: 'request' | 'environment' | 'profile';
67
+ error?: string;
68
+ }
69
+ /** Assemble admitted exact receipts in dispatch order. Missing usage stays unknown. */
70
+ export declare function aggregateNativeCompletionReceipts(turns: readonly NativeCompletionTurnReceipt[]): NativeCompletionReceipt;
71
+ /**
72
+ * Observe only the turns admitted under `executionId`. Transport failures throw
73
+ * so the durable owner retries. A missing or partial terminal record remains
74
+ * `running` until the configured deadline; it never starts another run.
75
+ */
76
+ export declare function observeNativeCompletion(options: NativeCompletionObservationOptions): Promise<NativeCompletionObservation>;
@@ -244,6 +244,17 @@ export interface ChatTurnProduceArgs<TContext> {
244
244
  * resolved without a usable id.
245
245
  * - a string — the row id, for `contextGate`, `beforeTurn`, and `produce`. */
246
246
  userMessageId?: string | null;
247
+ /**
248
+ * Transfer completion ownership to a product-owned durable runner. The
249
+ * product MUST first register that runner durably, then await this callback
250
+ * before dispatching native execution. Once it resolves, this route remains
251
+ * a live event projector only: it no longer writes or settles the assistant
252
+ * transcript, lifecycle hooks, lock, or replay-buffer status.
253
+ *
254
+ * Optional for source compatibility with synthetic producers. Every
255
+ * `createChatTurnRoutes` invocation supplies it before `produce` runs.
256
+ */
257
+ handoffCompletion?: () => Promise<void>;
247
258
  }
248
259
  /** One event as it crosses the route: the producer's own vocabulary, or an
249
260
  * injected keepalive. Same shape the engine forwards verbatim.
@@ -62,6 +62,7 @@ function createBufferedTurnTap(opts) {
62
62
  let lastFlush = Date.now();
63
63
  let started = false;
64
64
  let settled = false;
65
+ let detached = false;
65
66
  let renewalTimer;
66
67
  let renewal = Promise.resolve();
67
68
  let lastRenewedAt = 0;
@@ -74,7 +75,7 @@ function createBufferedTurnTap(opts) {
74
75
  renewalTimer = void 0;
75
76
  }
76
77
  function scheduleRenewal() {
77
- if (settled || !opts.scopeId) return;
78
+ if (settled || detached || !opts.scopeId) return;
78
79
  clearRenewalTimer();
79
80
  const elapsedMs = Math.max(0, Date.now() - lastRenewedAt);
80
81
  const delayMs = Math.max(1, runningTurnRenewIntervalMs - elapsedMs);
@@ -87,7 +88,7 @@ function createBufferedTurnTap(opts) {
87
88
  }
88
89
  }
89
90
  async function renewLease() {
90
- if (settled || !opts.scopeId) return;
91
+ if (settled || detached || !opts.scopeId) return;
91
92
  clearRenewalTimer();
92
93
  lastRenewedAt = Date.now();
93
94
  renewal = renewal.then(() => opts.store.setStatus(opts.turnId, "running", opts.scopeId)).catch(() => {
@@ -106,6 +107,7 @@ function createBufferedTurnTap(opts) {
106
107
  async function ensureStarted() {
107
108
  if (started) return;
108
109
  started = true;
110
+ if (detached) return;
109
111
  await opts.store.setStatus(opts.turnId, "running", opts.scopeId);
110
112
  lastRenewedAt = Date.now();
111
113
  scheduleRenewal();
@@ -113,7 +115,7 @@ function createBufferedTurnTap(opts) {
113
115
  return {
114
116
  async onEvent(raw) {
115
117
  await ensureStarted();
116
- if (opts.scopeId && Date.now() - lastRenewedAt >= runningTurnRenewIntervalMs) {
118
+ if (!detached && opts.scopeId && Date.now() - lastRenewedAt >= runningTurnRenewIntervalMs) {
117
119
  await renewLease();
118
120
  }
119
121
  const ev = raw && typeof raw === "object" ? { ...raw, _t: Date.now() - startedAt } : raw;
@@ -125,10 +127,14 @@ function createBufferedTurnTap(opts) {
125
127
  clientGone = true;
126
128
  }
127
129
  }
128
- if (Date.now() - lastFlush >= flushIntervalMs) await flush();
130
+ if (detached || Date.now() - lastFlush >= flushIntervalMs) await flush();
129
131
  },
130
132
  async done(status = "complete") {
131
133
  await ensureStarted();
134
+ if (detached) {
135
+ await flush();
136
+ return;
137
+ }
132
138
  settled = true;
133
139
  clearRenewalTimer();
134
140
  await renewal;
@@ -141,6 +147,12 @@ function createBufferedTurnTap(opts) {
141
147
  }
142
148
  await flush();
143
149
  await opts.store.setStatus(opts.turnId, "complete", opts.scopeId);
150
+ },
151
+ async detach() {
152
+ detached = true;
153
+ clearRenewalTimer();
154
+ await renewal;
155
+ await flush();
144
156
  }
145
157
  };
146
158
  }
@@ -244,7 +256,17 @@ function createD1TurnEventStore(db, options = {}) {
244
256
  const chunk = events.slice(start, start + D1_APPEND_CHUNK);
245
257
  const placeholders = chunk.map(() => "(?, ?, ?)").join(", ");
246
258
  const values = chunk.flatMap((e) => [turnId, e.seq, e.event]);
247
- await db.prepare(`INSERT OR IGNORE INTO turn_events (turnId, seq, event) VALUES ${placeholders}`).bind(...values).run();
259
+ await db.prepare(
260
+ `WITH pending(turnId, seq, event) AS (VALUES ${placeholders})
261
+ INSERT OR IGNORE INTO turn_events (turnId, seq, event)
262
+ SELECT pending.turnId, pending.seq, pending.event
263
+ FROM pending
264
+ WHERE NOT EXISTS (
265
+ SELECT 1 FROM turn_status
266
+ WHERE turn_status.turnId = pending.turnId
267
+ AND turn_status.status IN ('complete', 'error')
268
+ )`
269
+ ).bind(...values).run();
248
270
  }
249
271
  },
250
272
  async read(turnId, fromSeq) {
@@ -298,6 +320,7 @@ function createMemoryTurnEventStore(options = {}) {
298
320
  );
299
321
  return {
300
322
  async append(turnId, rows) {
323
+ if (status.get(turnId) === "complete" || status.get(turnId) === "error") return;
301
324
  const list = events.get(turnId) ?? [];
302
325
  list.push(...rows);
303
326
  events.set(turnId, list);
@@ -369,4 +392,4 @@ export {
369
392
  createD1TurnEventStore,
370
393
  createMemoryTurnEventStore
371
394
  };
372
- //# sourceMappingURL=chunk-N4RR4WAI.js.map
395
+ //# sourceMappingURL=chunk-HXTJXODG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/stream/turn-buffer.ts"],"sourcesContent":["/**\n * Resumable chat turns — the router-path answer to \"streams resume on\n * disconnect\" (issue #27). A turn's loop events are teed into a store as they\n * stream; the turn keeps running under `ctx.waitUntil` when the client drops;\n * a reconnecting client replays the buffered tail by sequence number and\n * keeps following until the turn completes.\n *\n * POST /chat/stream → pumpBufferedTurn(...) + live NDJSON\n * GET /chat/stream/:turnId → replayTurnEvents({ fromSeq }) → NDJSON\n *\n * Storage is a structural seam ({@link TurnEventStore}); a D1 implementation\n * ships here because that's what Cloudflare products have (KV is unsuitable:\n * eventually consistent cross-isolate). Per-token deltas would mean hundreds\n * of rows per turn, so consecutive text/reasoning deltas are coalesced within\n * a flush window before they are persisted — replay yields slightly chunkier\n * deltas with identical concatenation.\n */\n\nexport type TurnStatus = 'running' | 'complete' | 'error'\n\n/** A running row is a renewable lease, not permanent truth. If the process\n * driving a turn dies before writing a terminal status, reconnect discovery\n * must eventually stop returning that abandoned row. */\nexport const DEFAULT_RUNNING_TURN_LEASE_MS = 5 * 60_000\n\n/** Keep a healthy turn's lease comfortably ahead of expiry without turning\n * per-token streaming into status-write traffic. */\nexport const DEFAULT_RUNNING_TURN_RENEW_INTERVAL_MS = 30_000\n\n/** Represent a buffered turn event with a sequence number and serialized event data */\nexport interface BufferedTurnEvent {\n seq: number\n /** The serialized event line (JSON string, no trailing newline). */\n event: string\n}\n\n/** Manage and query turn events and their lifecycle statuses within a scoped event store */\nexport interface TurnEventStore {\n /**\n * Append rows while the turn is live. Implementations must ignore rows that\n * arrive after `setStatus` publishes `complete` or `error`; the terminal\n * status is the replay fence for a detached live projection.\n */\n append(turnId: string, events: BufferedTurnEvent[]): Promise<void>\n read(turnId: string, fromSeq: number): Promise<BufferedTurnEvent[]>\n /** Record turn lifecycle. `scopeId` (a thread/session id) is optional and lets\n * {@link TurnEventStore.listRunning} rediscover this turn after a client reload\n * loses the turnId; stores that don't track scope ignore it. */\n setStatus(turnId: string, status: TurnStatus, scopeId?: string): Promise<void>\n getStatus(turnId: string): Promise<TurnStatus | null>\n /** Unexpired running turnIds for a scope, newest first — so a reloaded client\n * (clientRunId lost) can find and resume the in-flight turn without reviving\n * a row abandoned by a dead process. Optional: a store records it only if\n * `setStatus` was given a `scopeId`. */\n listRunning?(scopeId: string): Promise<string[]>\n /** Explicitly remove one turn's events and status. Optional so existing\n * custom stores remain source-compatible; the built-in D1 and memory stores\n * provide it. */\n deleteTurn?(turnId: string): Promise<void>\n /** Remove terminal turns whose status was updated before `before` (Unix\n * milliseconds or a Date). Running turns are never eligible. */\n pruneTerminalTurns?(before: number | Date): Promise<number>\n}\n\n/** Configure running-turn lease evaluation. The clock is injectable so store\n * contract tests do not sleep. Keep the default in production unless a\n * deployment also tunes the buffer's renewal interval. */\nexport interface TurnEventStoreOptions {\n runningTurnLeaseMs?: number\n now?: () => number\n}\n\n// ── coalescing ────────────────────────────────────────────────────────────\n\ntype AnyRecord = Record<string, unknown>\n\nfunction deltaTypeOf(ev: unknown): 'text' | 'reasoning' | null {\n const e = ev as AnyRecord | null\n if (!e || typeof e !== 'object') return null\n const inner = (e.kind === 'event' ? (e.event as AnyRecord | undefined) : e) as AnyRecord | undefined\n if (!inner || typeof inner !== 'object') return null\n if ((inner.type === 'text' || inner.type === 'reasoning') && typeof inner.text === 'string') {\n return inner.type\n }\n return null\n}\n\n/** Merge consecutive text/reasoning deltas of the same type into one event.\n * Concatenation-preserving: replaying the coalesced stream produces the same\n * accumulated text as the original. */\nexport function coalesceDeltas(events: unknown[]): unknown[] {\n const out: unknown[] = []\n for (const ev of events) {\n const type = deltaTypeOf(ev)\n const prev = out[out.length - 1]\n if (type && prev && deltaTypeOf(prev) === type) {\n const read = (x: unknown): AnyRecord =>\n ((x as AnyRecord).kind === 'event' ? (x as AnyRecord).event : x) as AnyRecord\n const merged = JSON.parse(JSON.stringify(prev)) as AnyRecord\n read(merged).text = String(read(prev).text) + String(read(ev).text)\n out[out.length - 1] = merged\n continue\n }\n out.push(ev)\n }\n return out\n}\n\nfunction asPartUpdate(ev: unknown): { partId: unknown; delta: unknown } | null {\n const e = ev as AnyRecord | null\n if (!e || typeof e !== 'object' || e.type !== 'message.part.updated') return null\n const data = e.data as AnyRecord | undefined\n if (!data || typeof data !== 'object') return null\n const part = data.part as AnyRecord | undefined\n const partId = part?.id ?? data.partId ?? part?.partId ?? null\n return { partId, delta: data.delta }\n}\n\n/**\n * Coalesce consecutive `message.part.updated` deltas for the SAME part into one\n * event. agent-runtime products stream `ChatStreamEvent` NDJSON\n * (`{type:'message.part.updated', data:{part, delta}}`); pumped through the\n * buffer with the default tool-loop coalescer, every per-token delta persists as\n * its own row because that coalescer never recognizes the shape. Pass this as\n * {@link PumpBufferedTurnOptions.coalesce} instead.\n *\n * Concatenation-preserving for BOTH consumer styles: the merged event keeps the\n * LATEST event's `data.part` (already the cumulative accumulation) and sets\n * `data.delta` to the concatenation of the merged deltas, so a client that\n * appends `delta` and one that reads the cumulative `part` both reconstruct the\n * identical final text.\n */\nexport function coalesceChatStreamEvents(events: unknown[]): unknown[] {\n const out: unknown[] = []\n for (const ev of events) {\n const cur = asPartUpdate(ev)\n const prevEv = out[out.length - 1]\n const prev = prevEv ? asPartUpdate(prevEv) : null\n if (cur && prev && cur.partId != null && cur.partId === prev.partId) {\n // Base the merged row on the latest event (its `part` is the most complete\n // accumulation); carry forward the summed delta.\n const merged = JSON.parse(JSON.stringify(ev)) as AnyRecord\n ;(merged.data as AnyRecord).delta = String(prev.delta ?? '') + String(cur.delta ?? '')\n out[out.length - 1] = merged\n continue\n }\n out.push(ev)\n }\n return out\n}\n\n// ── buffering core (the tap) ────────────────────────────────────────────────\n\n/** Define options for buffering and flushing turn events with optional live client delivery and event coalescing */\nexport interface BufferedTurnOptions {\n store: TurnEventStore\n turnId: string\n /** Deliver one serialized line to the live client. Throwing here (client\n * disconnected) does NOT stop buffering — events keep persisting. */\n write?: (line: string) => Promise<void> | void\n /** Flush buffered events to the store at most this often. Default 400ms. */\n flushIntervalMs?: number\n /** Per-flush coalescer. Default {@link coalesceDeltas} (tool-loop text/reasoning\n * deltas). agent-runtime products streaming `ChatStreamEvent` pass\n * {@link coalesceChatStreamEvents} so per-token deltas don't each persist as a\n * row. Must be concatenation-preserving. */\n coalesce?: (events: unknown[]) => unknown[]\n /** Optional scope (thread/session id) recorded with the turn status, so\n * {@link TurnEventStore.listRunning} can find this turn after a reload. */\n scopeId?: string\n /** How often to renew the running-turn lease while a producer is alive.\n * The timer is backed up by event-driven renewal when a runtime freezes\n * unreferenced timers during remote I/O. Default\n * {@link DEFAULT_RUNNING_TURN_RENEW_INTERVAL_MS}. */\n runningTurnRenewIntervalMs?: number\n}\n\n/** A push-driven buffer for a turn whose producer the caller does NOT own. */\nexport interface BufferedTurnTap {\n /** Buffer one event: persist (coalesced, on the flush window) + best-effort\n * live-deliver. Wire to a push source's per-event hook (e.g. agent-runtime\n * `handleChatTurn`'s `hooks.onEvent`). Marks the turn 'running' on first call. */\n onEvent(raw: unknown): Promise<void>\n /** Settle the turn: final flush + set status. Call after the producer resolves\n * ('complete') or rejects ('error'). 'error' flushes what was produced first. */\n done(status?: Extract<TurnStatus, 'complete' | 'error'>): Promise<void>\n /**\n * Give terminal-status ownership to an external durable runner. Stops this\n * tap's running-lease renewal and flushes its current projection, but keeps\n * `onEvent` available as an append-only live-observation path. Events\n * received after the handoff flush before this method's caller continues,\n * so a durable owner cannot publish terminal status ahead of an observed\n * event. Call it again when observation ends to flush any final event.\n *\n * A caller MUST have registered that durable owner before detaching: this\n * method intentionally writes no replacement status.\n */\n detach(): Promise<void>\n}\n\n/**\n * The buffering core. Sequence-numbers every event, delivers it to `write`\n * (best-effort — a disconnected client never stops buffering), and flushes to\n * the store in coalesced batches. Drives both transports:\n *\n * • {@link pumpBufferedTurn} — when you OWN an `AsyncIterable` producer.\n * • this tap (`onEvent`/`done`) — when the producer owns iteration and only\n * hands you a push callback (agent-runtime `handleChatTurn`'s `hooks.onEvent`\n * + the finished body). Durability stays here in the shell; the engine needs\n * no `TurnEventStore` seam.\n */\nexport function createBufferedTurnTap(opts: BufferedTurnOptions): BufferedTurnTap {\n const flushIntervalMs = opts.flushIntervalMs ?? 400\n const coalesce = opts.coalesce ?? coalesceDeltas\n const startedAt = Date.now()\n let seq = 0\n let clientGone = false\n let pending: unknown[] = []\n let lastFlush = Date.now()\n let started = false\n let settled = false\n let detached = false\n let renewalTimer: ReturnType<typeof setTimeout> | undefined\n let renewal: Promise<void> = Promise.resolve()\n let lastRenewedAt = 0\n const runningTurnRenewIntervalMs = Math.max(\n 1,\n opts.runningTurnRenewIntervalMs ?? DEFAULT_RUNNING_TURN_RENEW_INTERVAL_MS,\n )\n\n function clearRenewalTimer(): void {\n if (renewalTimer !== undefined) clearTimeout(renewalTimer)\n renewalTimer = undefined\n }\n\n function scheduleRenewal(): void {\n if (settled || detached || !opts.scopeId) return\n clearRenewalTimer()\n const elapsedMs = Math.max(0, Date.now() - lastRenewedAt)\n const delayMs = Math.max(1, runningTurnRenewIntervalMs - elapsedMs)\n renewalTimer = setTimeout(() => {\n renewalTimer = undefined\n void renewLease()\n }, delayMs)\n // A deliberately abandoned tap in a Node test must not keep the process\n // alive until the production renewal interval elapses.\n if (typeof renewalTimer === 'object' && 'unref' in renewalTimer) {\n renewalTimer.unref()\n }\n }\n\n async function renewLease(): Promise<void> {\n if (settled || detached || !opts.scopeId) return\n clearRenewalTimer()\n lastRenewedAt = Date.now()\n renewal = renewal\n .then(() => opts.store.setStatus(opts.turnId, 'running', opts.scopeId))\n .catch(() => {})\n await renewal\n scheduleRenewal()\n }\n\n async function flush(): Promise<void> {\n if (pending.length === 0) return\n const batch = coalesce(pending)\n pending = []\n const rows = batch.map((ev) => ({ seq: ++seq, event: JSON.stringify(ev) }))\n await opts.store.append(opts.turnId, rows)\n lastFlush = Date.now()\n }\n\n async function ensureStarted(): Promise<void> {\n if (started) return\n started = true\n // An external durable owner may have detached before the first observed\n // event. It owns status from that point, so this tap must never resurrect\n // its running lease.\n if (detached) return\n await opts.store.setStatus(opts.turnId, 'running', opts.scopeId)\n lastRenewedAt = Date.now()\n scheduleRenewal()\n }\n\n return {\n async onEvent(raw) {\n await ensureStarted()\n // Cloudflare may freeze an unreferenced timer while a producer is\n // awaiting remote I/O. A real event (including the route heartbeat)\n // therefore also renews any lease whose interval has elapsed.\n if (\n !detached &&\n opts.scopeId &&\n Date.now() - lastRenewedAt >= runningTurnRenewIntervalMs\n ) {\n await renewLease()\n }\n // Stamp ms-since-turn-start so any stored turn is replayable AND traceable\n // (see ../trace) from the same buffered rows.\n const ev = raw && typeof raw === 'object' ? { ...(raw as Record<string, unknown>), _t: Date.now() - startedAt } : raw\n pending.push(ev)\n if (!clientGone && opts.write) {\n try {\n // Live delivery carries a provisional ordering hint, not the persisted\n // seq (coalescing changes seq assignment); clients resume with the\n // seqs from replay, or 0 for \"everything\".\n await opts.write(JSON.stringify(ev))\n } catch {\n clientGone = true\n }\n }\n if (detached || Date.now() - lastFlush >= flushIntervalMs) await flush()\n },\n async done(status = 'complete') {\n await ensureStarted()\n if (detached) {\n await flush()\n return\n }\n settled = true\n clearRenewalTimer()\n await renewal\n if (status === 'error') {\n await flush().catch(() => {})\n await opts.store.setStatus(opts.turnId, 'error', opts.scopeId).catch(() => {})\n return\n }\n await flush()\n await opts.store.setStatus(opts.turnId, 'complete', opts.scopeId)\n },\n async detach() {\n detached = true\n clearRenewalTimer()\n // A renewal already queued before detach may be writing `running`. Wait\n // for it before returning so the durable owner can safely publish a\n // terminal status after the awaited handoff.\n await renewal\n await flush()\n },\n }\n}\n\n// ── pump (producer side) ──────────────────────────────────────────────────\n\n/** Define options to pump data from an asynchronous iterable source with buffered turn control */\nexport interface PumpBufferedTurnOptions extends BufferedTurnOptions {\n source: AsyncIterable<unknown>\n}\n\n/**\n * Drive a turn to completion regardless of the live client, when you OWN the\n * producer as an `AsyncIterable`. A thin driver over {@link createBufferedTurnTap}.\n * Returns a promise that resolves when the turn finishes — hand it to\n * `ctx.waitUntil` so a disconnect can't kill the turn. Never rejects on\n * client-write failure; a source error marks the turn 'error' (after flushing\n * what was produced) and rethrows.\n */\nexport async function pumpBufferedTurn(opts: PumpBufferedTurnOptions): Promise<void> {\n const tap = createBufferedTurnTap(opts)\n try {\n for await (const raw of opts.source) await tap.onEvent(raw)\n await tap.done('complete')\n } catch (err) {\n await tap.done('error')\n throw err\n }\n}\n\n// ── replay (consumer side) ────────────────────────────────────────────────\n\n/** Define options for replaying turn events with control over sequence, polling, and timeout */\nexport interface ReplayTurnEventsOptions {\n store: TurnEventStore\n turnId: string\n /** Replay strictly after this sequence number (0 = from the beginning). */\n fromSeq?: number\n /** Poll cadence while the turn is still running. Default 500ms. */\n pollMs?: number\n /** Give up following a 'running' turn after this long. Default 120s. */\n timeoutMs?: number\n}\n\n/**\n * Yield buffered events after `fromSeq`, then keep polling while the turn is\n * still 'running' until it completes, errors, or times out. Terminates with a\n * final `{seq: -1, event: '{\"type\":\"turn_status\",...}'}` marker so clients\n * know why the replay ended.\n */\nexport async function* replayTurnEvents(opts: ReplayTurnEventsOptions): AsyncGenerator<BufferedTurnEvent> {\n const pollMs = opts.pollMs ?? 500\n const timeoutMs = opts.timeoutMs ?? 120_000\n let cursor = opts.fromSeq ?? 0\n const deadline = Date.now() + timeoutMs\n\n for (;;) {\n const batch = await opts.store.read(opts.turnId, cursor)\n for (const row of batch) {\n cursor = Math.max(cursor, row.seq)\n yield row\n }\n const status = await opts.store.getStatus(opts.turnId)\n if (status !== 'running') {\n // Writers flush before publishing terminal status; include rows committed after the first read.\n yield* await opts.store.read(opts.turnId, cursor)\n yield { seq: -1, event: JSON.stringify({ type: 'turn_status', status: status ?? 'unknown' }) }\n return\n }\n if (Date.now() >= deadline) {\n yield { seq: -1, event: JSON.stringify({ type: 'turn_status', status: 'timeout' }) }\n return\n }\n await new Promise((r) => setTimeout(r, pollMs))\n }\n}\n\n/**\n * Serialize a replayed row for the wire, stamping the buffer ordinal ONTO the\n * line so a reconnecting client can continue from `?fromSeq=<lastSeq>`.\n *\n * The seq lives on the {@link BufferedTurnEvent} row wrapper, not inside the\n * serialized event — `flush()` builds `{seq: ++seq, event: JSON.stringify(ev)}`.\n * A route that enqueues `row.event` alone therefore emits lines with no seq at\n * all, and every client cursor silently pins to 0: each reconnect refetches the\n * whole turn and re-applies every delta onto already-rendered state. This\n * restores the contract `web-react/chat-stream` already documents (\"replayed\n * lines carry an extra `seq` — transparently ignored\").\n *\n * The `{seq: -1}` `turn_status` sentinel is passed through unstamped: it is a\n * terminator, not a cursor position, and stamping it would move a client's\n * cursor to -1.\n *\n * Fail-soft by construction — a line that is not a JSON object passes through\n * verbatim. A stamping bug must degrade to today's behaviour, never break a\n * replay.\n */\nexport function stampReplaySeq(row: BufferedTurnEvent): string {\n if (row.seq <= 0) return row.event\n const line = row.event\n // Cheap splice instead of parse+stringify: these rows are already canonical\n // JSON objects from `JSON.stringify`, and replay is a hot per-event path.\n if (line.charCodeAt(0) !== 0x7b /* { */) return line\n const rest = line.slice(1)\n return rest.trimStart().startsWith('}')\n ? `{\"seq\":${row.seq}${rest}`\n : `{\"seq\":${row.seq},${rest}`\n}\n\n// ── D1 store ──────────────────────────────────────────────────────────────\n\n/** A bound D1 statement used by the turn store. */\nexport interface D1BoundForTurns {\n run(): Promise<unknown>\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>\n first<T = Record<string, unknown>>(): Promise<T | null>\n}\n\n/** Minimal structural D1 contract (Cloudflare `D1Database` satisfies it). */\nexport interface D1LikeForTurns {\n prepare(sql: string): {\n bind(...values: unknown[]): D1BoundForTurns\n }\n /** D1's only atomic primitive. Retention refuses a driver without it rather\n * than deleting events and status in separate, reorderable requests. */\n batch?(statements: D1BoundForTurns[]): Promise<unknown[]>\n}\n\nfunction normalizeTurnCutoff(before: number | Date): { milliseconds: number; iso: string } {\n const milliseconds = before instanceof Date ? before.getTime() : before\n if (!Number.isFinite(milliseconds)) {\n throw new RangeError('turn retention cutoff must be a finite Unix-millisecond timestamp or a valid Date')\n }\n const date = new Date(milliseconds)\n if (!Number.isFinite(date.getTime())) {\n throw new RangeError('turn retention cutoff is outside the supported Date range')\n }\n return { milliseconds, iso: date.toISOString() }\n}\n\nasync function runAtomicTurnBatch(\n db: D1LikeForTurns,\n statements: D1BoundForTurns[],\n): Promise<unknown[]> {\n if (typeof db.batch !== 'function') {\n throw new Error('turn retention requires D1 batch() for atomic deletion')\n }\n return db.batch(statements)\n}\n\nfunction changesFromD1Result(result: unknown): number {\n if (!result || typeof result !== 'object') return 0\n const meta = (result as { meta?: unknown }).meta\n if (meta && typeof meta === 'object') {\n const changes = (meta as { changes?: unknown }).changes\n if (typeof changes === 'number' && Number.isFinite(changes)) return changes\n }\n const changes = (result as { changes?: unknown }).changes\n return typeof changes === 'number' && Number.isFinite(changes) ? changes : 0\n}\n\n/** Schema for the D1 store — append to the product's migrations. */\nexport const TURN_EVENTS_MIGRATION_SQL = `\nCREATE TABLE IF NOT EXISTS turn_events (\n turnId TEXT NOT NULL,\n seq INTEGER NOT NULL,\n event TEXT NOT NULL,\n PRIMARY KEY (turnId, seq)\n);\nCREATE TABLE IF NOT EXISTS turn_status (\n turnId TEXT PRIMARY KEY,\n status TEXT NOT NULL,\n scopeId TEXT,\n updatedAt TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_turn_status_scope ON turn_status (scopeId, status);\nCREATE INDEX IF NOT EXISTS idx_turn_status_retention ON turn_status (status, updatedAt);\n`\n\n/** For deployments whose `turn_status` table predates `scopeId`/`listRunning` —\n * run once to add the column (the CREATE above already includes it for new\n * deployments). SQLite ignores a duplicate-add error if already applied. */\nexport const TURN_STATUS_SCOPE_MIGRATION_SQL = `ALTER TABLE turn_status ADD COLUMN scopeId TEXT;`\n\n/** For deployments that already have the turn tables, add the index used by\n * terminal-turn retention without rerunning the table migration. */\nexport const TURN_STATUS_RETENTION_MIGRATION_SQL = 'CREATE INDEX IF NOT EXISTS idx_turn_status_retention ON turn_status (status, updatedAt);'\n\n/** Resolve a TurnEventStore that appends and reads turn events using a D1-like database interface */\n/** 100 D1 variables per statement, three per row. */\nconst D1_APPEND_CHUNK = 33\n\nexport function createD1TurnEventStore(\n db: D1LikeForTurns,\n options: TurnEventStoreOptions = {},\n): TurnEventStore {\n const now = options.now ?? Date.now\n const runningTurnLeaseMs = Math.max(\n 1,\n options.runningTurnLeaseMs ?? DEFAULT_RUNNING_TURN_LEASE_MS,\n )\n return {\n async append(turnId, events) {\n if (!events.length) return\n // One multi-row insert per flush window keeps write volume bounded, but\n // D1 caps a statement at 100 bound variables, so a burst of more than 33\n // events (a subagent finishing, a long tool result) failed the whole\n // flush with `D1_ERROR: too many SQL variables` and killed the turn.\n // Rows are appended in order across chunks; a partial failure leaves a\n // prefix, which the ordered `seq` column already tolerates on replay.\n for (let start = 0; start < events.length; start += D1_APPEND_CHUNK) {\n const chunk = events.slice(start, start + D1_APPEND_CHUNK)\n const placeholders = chunk.map(() => '(?, ?, ?)').join(', ')\n const values = chunk.flatMap((e) => [turnId, e.seq, e.event])\n await db\n .prepare(\n `WITH pending(turnId, seq, event) AS (VALUES ${placeholders})\n INSERT OR IGNORE INTO turn_events (turnId, seq, event)\n SELECT pending.turnId, pending.seq, pending.event\n FROM pending\n WHERE NOT EXISTS (\n SELECT 1 FROM turn_status\n WHERE turn_status.turnId = pending.turnId\n AND turn_status.status IN ('complete', 'error')\n )`,\n )\n .bind(...values)\n .run()\n }\n },\n async read(turnId, fromSeq) {\n const { results } = await db\n .prepare('SELECT seq, event FROM turn_events WHERE turnId = ? AND seq > ? ORDER BY seq ASC')\n .bind(turnId, fromSeq)\n .all<{ seq: number; event: string }>()\n return results\n },\n async setStatus(turnId, status, scopeId) {\n // COALESCE preserves a scopeId set on the initial 'running' write when a\n // later 'complete'/'error' write passes none.\n await db\n .prepare(\n 'INSERT INTO turn_status (turnId, status, scopeId, updatedAt) VALUES (?, ?, ?, ?) ON CONFLICT(turnId) DO UPDATE SET status = excluded.status, scopeId = COALESCE(excluded.scopeId, turn_status.scopeId), updatedAt = excluded.updatedAt',\n )\n .bind(turnId, status, scopeId ?? null, new Date(now()).toISOString())\n .run()\n },\n async getStatus(turnId) {\n const row = await db.prepare('SELECT status FROM turn_status WHERE turnId = ?').bind(turnId).first<{ status: TurnStatus }>()\n return row?.status ?? null\n },\n async listRunning(scopeId) {\n const { results } = await db\n .prepare(\n \"SELECT turnId FROM turn_status WHERE scopeId = ? AND status = 'running' AND updatedAt >= ? ORDER BY updatedAt DESC, rowid DESC\",\n )\n .bind(scopeId, new Date(now() - runningTurnLeaseMs).toISOString())\n .all<{ turnId: string }>()\n return results.map((r) => r.turnId)\n },\n async deleteTurn(turnId) {\n await runAtomicTurnBatch(db, [\n db.prepare('DELETE FROM turn_events WHERE turnId = ?').bind(turnId),\n db.prepare('DELETE FROM turn_status WHERE turnId = ?').bind(turnId),\n ])\n },\n async pruneTerminalTurns(before) {\n const cutoff = normalizeTurnCutoff(before)\n const terminalBefore = \"status IN ('complete', 'error') AND updatedAt < ?\"\n const results = await runAtomicTurnBatch(db, [\n db\n .prepare(\n `DELETE FROM turn_events WHERE turnId IN (SELECT turnId FROM turn_status WHERE ${terminalBefore})`,\n )\n .bind(cutoff.iso),\n db.prepare(`DELETE FROM turn_status WHERE ${terminalBefore}`).bind(cutoff.iso),\n ])\n return changesFromD1Result(results[1])\n },\n }\n}\n\n/** In-memory store for tests and keyless local dev. */\nexport function createMemoryTurnEventStore(\n options: TurnEventStoreOptions = {},\n): TurnEventStore {\n const events = new Map<string, BufferedTurnEvent[]>()\n const status = new Map<string, TurnStatus>()\n const scopes = new Map<string, string>()\n const order: string[] = []\n const updatedAt = new Map<string, number>()\n const now = options.now ?? Date.now\n const runningTurnLeaseMs = Math.max(\n 1,\n options.runningTurnLeaseMs ?? DEFAULT_RUNNING_TURN_LEASE_MS,\n )\n return {\n async append(turnId, rows) {\n if (status.get(turnId) === 'complete' || status.get(turnId) === 'error') return\n const list = events.get(turnId) ?? []\n list.push(...rows)\n events.set(turnId, list)\n },\n async read(turnId, fromSeq) {\n return (events.get(turnId) ?? []).filter((e) => e.seq > fromSeq)\n },\n async setStatus(turnId, s, scopeId) {\n status.set(turnId, s)\n if (scopeId) scopes.set(turnId, scopeId)\n if (!order.includes(turnId)) order.push(turnId)\n updatedAt.set(turnId, now())\n },\n async getStatus(turnId) {\n return status.get(turnId) ?? null\n },\n async listRunning(scopeId) {\n const cutoff = now() - runningTurnLeaseMs\n return order\n .filter(\n (turnId) =>\n status.get(turnId) === 'running' &&\n scopes.get(turnId) === scopeId &&\n (updatedAt.get(turnId) ?? Number.NEGATIVE_INFINITY) >= cutoff,\n )\n .sort((left, right) => {\n const updatedDelta = (updatedAt.get(right) ?? 0) - (updatedAt.get(left) ?? 0)\n return updatedDelta || order.indexOf(right) - order.indexOf(left)\n })\n },\n async deleteTurn(turnId) {\n events.delete(turnId)\n status.delete(turnId)\n scopes.delete(turnId)\n updatedAt.delete(turnId)\n const index = order.indexOf(turnId)\n if (index >= 0) order.splice(index, 1)\n },\n async pruneTerminalTurns(before) {\n const cutoff = normalizeTurnCutoff(before).milliseconds\n const removable = order.filter((turnId) => {\n const s = status.get(turnId)\n return (s === 'complete' || s === 'error') && (updatedAt.get(turnId) ?? Number.POSITIVE_INFINITY) < cutoff\n })\n for (const turnId of removable) {\n events.delete(turnId)\n status.delete(turnId)\n scopes.delete(turnId)\n updatedAt.delete(turnId)\n }\n if (removable.length) {\n const removed = new Set(removable)\n for (let i = order.length - 1; i >= 0; i--) {\n if (removed.has(order[i]!)) order.splice(i, 1)\n }\n }\n return removable.length\n },\n }\n}\n"],"mappings":"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","names":["changes"]}
@@ -24,7 +24,7 @@ import {
24
24
  createBufferedTurnTap,
25
25
  replayTurnEvents,
26
26
  stampReplaySeq
27
- } from "./chunk-N4RR4WAI.js";
27
+ } from "./chunk-HXTJXODG.js";
28
28
 
29
29
  // src/chat-routes/draft-persistence.ts
30
30
  var CONTENT_EVENT_TYPES = /* @__PURE__ */ new Set([
@@ -361,6 +361,27 @@ function createChatTurnRoutes(options) {
361
361
  });
362
362
  const turnStreamId = crypto.randomUUID();
363
363
  const prompt = fileParts.length === 0 ? content : content ? [{ type: "text", text: content }, ...fileParts] : [...fileParts];
364
+ let draft;
365
+ let tap;
366
+ let completionHandoff = false;
367
+ let completionHandoffPromise;
368
+ const handoffCompletion = () => {
369
+ if (!completionHandoffPromise) {
370
+ completionHandoffPromise = (async () => {
371
+ completionHandoff = true;
372
+ try {
373
+ await tap?.detach();
374
+ } catch (err) {
375
+ log("[chat-routes] turn buffer detach failed", {
376
+ turnId: turnStreamId,
377
+ error: err instanceof Error ? err.message : String(err)
378
+ });
379
+ }
380
+ await draft?.close();
381
+ })();
382
+ }
383
+ return completionHandoffPromise;
384
+ };
364
385
  let produceArgs = {
365
386
  request,
366
387
  body: payload,
@@ -370,13 +391,14 @@ function createChatTurnRoutes(options) {
370
391
  executionId,
371
392
  turnStreamId,
372
393
  priorMessages: chatTurn.priorMessages,
394
+ handoffCompletion,
373
395
  ...ctx?.executionLimits ? { executionLimits: ctx.executionLimits } : {}
374
396
  };
375
397
  let lockAcquired = false;
376
398
  let lockHandle;
377
399
  let lockReleased = false;
378
400
  const releaseLock = async () => {
379
- if (!lockAcquired || lockReleased) return;
401
+ if (completionHandoff || !lockAcquired || lockReleased) return;
380
402
  lockReleased = true;
381
403
  try {
382
404
  await options.turnLock.release(lockHandle);
@@ -394,7 +416,6 @@ function createChatTurnRoutes(options) {
394
416
  lockHandle = acquired.handle;
395
417
  }
396
418
  let producer;
397
- let draft;
398
419
  let assistantMessageId;
399
420
  const assistantRowId = () => assistantMessageId ?? draft?.rowId() ?? null;
400
421
  let runFailed = false;
@@ -469,6 +490,7 @@ function createChatTurnRoutes(options) {
469
490
  }
470
491
  if (!gate.proceed) {
471
492
  await releaseLock();
493
+ if (completionHandoff) return gate.response;
472
494
  gatedTurn = true;
473
495
  turnStartedAtMs = Date.now();
474
496
  if (options.lifecycle?.onTurnStart) {
@@ -495,7 +517,7 @@ function createChatTurnRoutes(options) {
495
517
  const patch = await options.beforeTurn(produceArgs);
496
518
  if (patch) produceArgs = { ...produceArgs, ...patch };
497
519
  }
498
- const tap = createBufferedTurnTap({
520
+ tap = createBufferedTurnTap({
499
521
  store: options.turnStore,
500
522
  turnId: turnStreamId,
501
523
  scopeId: payload.threadId,
@@ -503,6 +525,7 @@ function createChatTurnRoutes(options) {
503
525
  });
504
526
  const turnMarker = { type: "turn", turnId: turnStreamId };
505
527
  await tap.onEvent(turnMarker);
528
+ if (completionHandoff) await tap.detach();
506
529
  if (draftTuning) {
507
530
  draft = createAssistantDraftWriter({
508
531
  ...draftTuning,
@@ -521,6 +544,7 @@ function createChatTurnRoutes(options) {
521
544
  ...options.transformFinalText ? { transformText: options.transformFinalText } : {},
522
545
  log
523
546
  });
547
+ if (completionHandoff) await draft.close();
524
548
  }
525
549
  turnStartedAtMs = Date.now();
526
550
  turnStarted = true;
@@ -570,11 +594,12 @@ function createChatTurnRoutes(options) {
570
594
  lastFailureData = event.data;
571
595
  }
572
596
  await tap.onEvent(event);
573
- draft?.notify(event);
597
+ if (!completionHandoff) draft?.notify(event);
574
598
  if (options.onEvent) await options.onEvent(event, context);
575
599
  },
576
600
  ...options.transformFinalText ? { transformFinalText: options.transformFinalText } : {},
577
601
  persistAssistantMessage: async ({ finalText }) => {
602
+ if (completionHandoff) return;
578
603
  if (ctx?.cancelOnDisconnect && request.signal.aborted) {
579
604
  await draft?.discard();
580
605
  assistantMessageId = null;
@@ -628,7 +653,8 @@ function createChatTurnRoutes(options) {
628
653
  // (not a throw) still lands here — so surface `runFailed` so the
629
654
  // product skips billing an errored turn instead of marking it
630
655
  // complete with empty text.
631
- onTurnComplete: ({ identity: turnIdentity, finalText }) => {
656
+ onTurnComplete: async ({ identity: turnIdentity, finalText }) => {
657
+ if (completionHandoff) return;
632
658
  const failoverInfo = producer?.modelFailover?.();
633
659
  const attribution = producer?.modelAttribution?.();
634
660
  return options.onTurnComplete({
@@ -667,6 +693,10 @@ function createChatTurnRoutes(options) {
667
693
  });
668
694
  }
669
695
  const failed = runFailed || drainError !== void 0;
696
+ if (completionHandoff) {
697
+ await tap.detach();
698
+ return;
699
+ }
670
700
  if (ctx?.cancelOnDisconnect && request.signal.aborted) {
671
701
  await draft?.discard();
672
702
  } else {
@@ -703,8 +733,10 @@ function createChatTurnRoutes(options) {
703
733
  }
704
734
  });
705
735
  } catch (err) {
706
- if (turnStarted) await fireTerminalLifecycle(true, err);
707
- await releaseLock();
736
+ if (!completionHandoff) {
737
+ if (turnStarted) await fireTerminalLifecycle(true, err);
738
+ await releaseLock();
739
+ }
708
740
  throw err;
709
741
  }
710
742
  }
@@ -1046,4 +1078,4 @@ export {
1046
1078
  createChatTurnRoutes,
1047
1079
  streamChatRouteAsSandboxEvents
1048
1080
  };
1049
- //# sourceMappingURL=chunk-MFLUDFD3.js.map
1081
+ //# sourceMappingURL=chunk-V4YGTOCS.js.map