libfx 0.0.13 → 0.0.14-dev.1534.g6afa7c33dac5

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.
package/durable.js ADDED
@@ -0,0 +1,2591 @@
1
+ // Durable agent sessions: the code that runs an agent in a script runs it in
2
+ // production. Every model step and tool intent is stored in the session's
3
+ // log before anything depends on it, one worker at a time runs a session
4
+ // under a lease, and a turn that runs out of time, crashes, or lands on
5
+ // another server continues where it stopped.
6
+ //
7
+ // The agent loop itself is a harness the core drives through a small
8
+ // contract (see `createDurableAgentFactory`): fx is one, in fx-harness.js.
9
+ //
10
+ // A session is one ordered log plus one stream of what the user sees. The
11
+ // log holds three kinds of entry:
12
+ //
13
+ // - inputs (`prompt`, `steer`, `cancel`), which any server may add;
14
+ // - a chain of lease claims, lease releases and the harness's records, each
15
+ // naming the chained entry before it, so taking the lease fences the old
16
+ // holder's next write;
17
+ // - checkpoints, each covering the chain up to a record.
18
+ //
19
+ // `prompt()` only queues a message. The worker the queue delivers it to adds
20
+ // the input to the log, takes the lease, and runs every turn the log holds
21
+ // until none is left, or its time runs out: then it asks the queue to
22
+ // deliver the same message again, and the next worker continues the turn.
23
+
24
+ // A write the session's chain refused because another worker continued it.
25
+ // Harnesses recognize it by its code.
26
+ class FencedError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "FencedError";
30
+ this.code = "FX_FENCED";
31
+ }
32
+ }
33
+
34
+ const durabilityTag = Symbol.for("libfx.durability");
35
+ const encoder = new TextEncoder();
36
+ const decoder = new TextDecoder();
37
+
38
+ // The core's rule for turn and input ids, which messages become.
39
+ const idPattern = /^[A-Za-z0-9._-]{1,128}$/;
40
+ const validId = (id) => typeof id === "string" && idPattern.test(id);
41
+ const idRule = "1 to 128 letters, digits, '.', '_' or '-'";
42
+ const newId = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
43
+
44
+ // How long a worker leaves for a model step it decides not to start.
45
+ const defaultReserveMs = 30_000;
46
+ // A lease with nothing to show whether its holder lives expires this long
47
+ // after it was taken or last renewed, and its holder renews it every third of
48
+ // that while it runs. So a worker that dies frees its session this soon
49
+ // after its last renewal, not at its function's deadline.
50
+ const defaultLeaseMs = 15_000;
51
+
52
+ // Whether the queue shows whether a worker lives, delivering the message its
53
+ // delivery held again once it is gone, so the worker holds its lease to the
54
+ // deadline that bounds it and renews nothing.
55
+ const queueHeld = (backend, deadline) => !backend.livenessKnown && backend.queueLeases === true && deadline !== null;
56
+ // Whether a worker holds its lease to its deadline and renews nothing: when
57
+ // whether it lives is known, or the queue shows it.
58
+ const heldToDeadline = (backend, deadline) => backend.livenessKnown || queueHeld(backend, deadline);
59
+
60
+ // When a lease taken or renewed now runs out: never past the deadline its
61
+ // worker stops at, and when it is held to that deadline, only there.
62
+ function leaseEnd(backend, deadline) {
63
+ if (heldToDeadline(backend, deadline)) return deadline;
64
+ const renewed = Date.now() + backend.leaseMs;
65
+ return deadline === null ? renewed : Math.min(renewed, deadline);
66
+ }
67
+ // The newest consumed message ids a checkpoint keeps, so a message the queue
68
+ // delivers again after its turn ended is still recognized.
69
+ const recentIdsKept = 256;
70
+ // The longest a message waits for a live holder before it is delivered again.
71
+ const maxBackstopSeconds = 300;
72
+
73
+ const toBase64 = (bytes) => {
74
+ let text = "";
75
+ for (let index = 0; index < bytes.length; index += 0x8000) {
76
+ text += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
77
+ }
78
+ return btoa(text);
79
+ };
80
+ const fromBase64 = (text) => Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
81
+
82
+ /**
83
+ * The durability that keeps sessions in this process only. Agents given the
84
+ * same `memory()` share its sessions, as servers share a database. With
85
+ * `maxDurationMs`, each delivery runs as if its function stopped after that
86
+ * long, stopping `reserveMs` before, as on Vercel.
87
+ */
88
+ export function memory({ maxDurationMs, reserveMs } = {}) {
89
+ checkDurations("memory", { maxDurationMs, reserveMs });
90
+ let backend = null;
91
+ return { [durabilityTag]: true, name: "memory", create: () => (backend ??= createMemoryBackend({ maxDurationMs, reserveMs })) };
92
+ }
93
+
94
+ function checkDurations(name, { maxDurationMs, reserveMs }) {
95
+ for (const [key, value] of Object.entries({ maxDurationMs, reserveMs })) {
96
+ if (value !== undefined && !(Number.isSafeInteger(value) && value >= 0)) throw new TypeError(`${name}() ${key} must be a non-negative integer`);
97
+ }
98
+ }
99
+
100
+ // A durability is `memory()`, or a World one from `libfx/durable-world` (and
101
+ // `libfx/durable-local` and `libfx/durable-vercel`, built on it): `{ world() }`
102
+ // and how its workers hold sessions.
103
+ function isDurability(value) {
104
+ return Boolean(value && typeof value === "object" && value[durabilityTag] === true &&
105
+ (typeof value.create === "function" || typeof value.world === "function"));
106
+ }
107
+
108
+ async function createBackend(durability) {
109
+ if (typeof durability.create === "function") return durability.create();
110
+ checkDurations(durability.name, durability);
111
+ return createWorldBackend(await durability.world(), durability);
112
+ }
113
+
114
+ // The workers holding a session in this process, across every agent in it,
115
+ // so a lease this process took is judged by whether its worker still runs.
116
+ const liveHolders = (globalThis[Symbol.for("libfx.liveHolders")] ??= new Set());
117
+
118
+ // Times a turn's engine may stop under it before the turn is cancelled, or
119
+ // ends with an error when it never started; and further times it may stop
120
+ // while that cancel runs before the session can run no more turns.
121
+ const maxHarnessStops = 3;
122
+ const maxCancelStops = 2;
123
+ // Times in a row the deadline may cut off the same step of a turn, with no
124
+ // record between, before the next delivery stops running it again.
125
+ const maxCutoffs = 3;
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // The session log, folded. Pure: the same entries always fold the same way.
129
+
130
+ // How many times in a row the deadline cut off an open turn's step. A
131
+ // harness that marks its records with the step a turn is at counts the
132
+ // cut-offs while that step stays the same, since continuing a cut-off step
133
+ // can write a record with no progress in it. For one that marks no steps,
134
+ // any record is progress.
135
+ const noCutoffs = { cutoffs: 0, cutStep: null, step: null, named: false };
136
+ function afterRecord(cut, marks) {
137
+ let next = cut;
138
+ for (const mark of marks ?? []) {
139
+ if (typeof mark.start === "string") next = noCutoffs;
140
+ else if (typeof mark.step === "string") next = { ...next, step: mark.step, named: true };
141
+ }
142
+ return next.named ? next : { ...next, cutoffs: 0 };
143
+ }
144
+ function afterCutoff(cut) {
145
+ const same = !cut.named || cut.step === cut.cutStep;
146
+ return { ...cut, cutoffs: same ? cut.cutoffs + 1 : 1, cutStep: cut.step };
147
+ }
148
+
149
+ /**
150
+ * Folds a session's log into what a worker acts on. `entries` are
151
+ * `{ cursor, entry }` in log order from the latest checkpoint's `through`;
152
+ * cursors are increasing integer strings. `now` and `alive(lease)` decide
153
+ * whether a lease stands; `alive` returns true, false, or null for unknown.
154
+ */
155
+ export function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
156
+ let checkpoint = null;
157
+ for (const item of entries) if (item.entry?.k === "checkpoint") checkpoint = item;
158
+ const base = checkpoint?.entry ?? null;
159
+ let head = base ? base.through ?? null : null;
160
+ let lease = base?.lease ?? null;
161
+ // The highest epoch any lease in the chain took. A release clears the
162
+ // lease but not this: the next claim takes a higher epoch, so its UI lines
163
+ // outrank every earlier line.
164
+ let epoch = base?.lease?.epoch ?? 0;
165
+ const consumed = new Set(base?.recent ?? []);
166
+ const recent = [...(base?.recent ?? [])];
167
+ // Which of those were steers, so a prompt and a steer cannot share an id
168
+ // after a checkpoint drops their inputs. A checkpoint from before this was
169
+ // kept does not say.
170
+ const baseSteers = new Set(base?.recentSteers ?? []);
171
+ const kindsKnown = base === null || Array.isArray(base?.recentSteers);
172
+ // Inputs the open turn accepted and has not placed, which a withdrawal can
173
+ // hand back. They count as taken until then.
174
+ const held = new Set(base?.held ?? []);
175
+ for (const id of held) consumed.add(id);
176
+ const seenInputs = new Set();
177
+ const inputs = [];
178
+ const records = [];
179
+ // A checkpoint taken where a turn yielded leaves that turn open.
180
+ // `from` is where the input that opened it was stored: steers stored after
181
+ // it join it, before it started too.
182
+ let openTurn = typeof base?.openTurn?.id === "string" ? { id: base.openTurn.id, at: Number(base.openTurn.at) || 0, from: Number(base.openTurn.from ?? base.openTurn.at) || 0, context: base.openTurn.context ?? null, settings: base.openTurn.settings ?? null } : null;
183
+ let lastTurnId = base?.lastTurnId ?? null;
184
+ // The agent the session belongs to: the one its first stored input, or the
185
+ // checkpoint it was restored from, names. A session stored before agents
186
+ // had names belongs to the default agent.
187
+ let agent = typeof base?.agent === "string" ? base.agent : base && base.through != null ? defaultAgentName : null;
188
+ let yielded = base?.yielded === true;
189
+ // The context each prompt's or steer's caller gave, and the settings its
190
+ // session object set, which its turn runs with: an untaken steer becomes a
191
+ // turn.
192
+ const contexts = new Map();
193
+ const settingsOf = new Map();
194
+ for (const input of base?.pending ?? []) {
195
+ if (input.context !== undefined) contexts.set(input.messageId, input.context);
196
+ if (input.settings !== undefined) settingsOf.set(input.messageId, input.settings);
197
+ }
198
+ // How many times each turn's engine stopped under it.
199
+ const stops = new Map();
200
+ const failed = new Map();
201
+ // Deadline cut-offs in a row of the open turn's step.
202
+ let cut = noCutoffs;
203
+ let maxCursor = 0;
204
+ const consume = (id) => {
205
+ if (consumed.has(id)) return;
206
+ consumed.add(id);
207
+ recent.push(id);
208
+ };
209
+ // A steer its turn accepted and ended without placing: the model never saw
210
+ // it, so it waits for a turn again.
211
+ const handBack = (id) => {
212
+ if (!consumed.delete(id)) return;
213
+ const index = recent.lastIndexOf(id);
214
+ if (index >= 0) recent.splice(index, 1);
215
+ };
216
+ for (const input of base?.pending ?? []) {
217
+ if (seenInputs.has(input.key)) continue;
218
+ seenInputs.add(input.key);
219
+ inputs.push(input);
220
+ }
221
+ for (const { cursor, entry } of entries) {
222
+ const position = Number(cursor);
223
+ if (position > maxCursor) maxCursor = position;
224
+ switch (entry?.k) {
225
+ case "input":
226
+ agent ??= typeof entry.agent === "string" ? entry.agent : defaultAgentName;
227
+ if (seenInputs.has(entry.key)) break;
228
+ seenInputs.add(entry.key);
229
+ inputs.push({ ...entry, cursor: position });
230
+ if (entry.context !== undefined) contexts.set(entry.messageId, entry.context);
231
+ if (entry.settings !== undefined) settingsOf.set(entry.messageId, entry.settings);
232
+ break;
233
+ case "lease":
234
+ case "release":
235
+ case "record": {
236
+ // A chained entry stands only when it continues the chain; the
237
+ // first writer to continue a cursor wins and the rest are fenced.
238
+ if ((entry.a ?? null) !== head) break;
239
+ head = cursor;
240
+ if (entry.k === "lease") {
241
+ lease = { holder: entry.holder, epoch: entry.epoch, expiresAt: entry.expiresAt ?? null, pid: entry.pid, host: entry.host, delivery: entry.delivery, cursor };
242
+ if (Number.isSafeInteger(entry.epoch) && entry.epoch > epoch) epoch = entry.epoch;
243
+ } else if (entry.k === "release") {
244
+ if (lease?.holder === entry.holder) lease = null;
245
+ if (typeof entry.cutoff === "string" && entry.cutoff === openTurn?.id) cut = afterCutoff(cut);
246
+ } else {
247
+ cut = afterRecord(cut, entry.marks);
248
+ records.push({ cursor, data: entry.data });
249
+ for (const mark of entry.marks ?? []) {
250
+ if (typeof mark.start === "string") {
251
+ const opener = inputs.find((input) => input.messageId === mark.start);
252
+ openTurn = { id: mark.start, at: position, from: opener?.cursor ?? position, context: contexts.get(mark.start) ?? null, settings: settingsOf.get(mark.start) ?? null };
253
+ consume(mark.start);
254
+ yielded = false;
255
+ } else if (mark.end === true) {
256
+ if (openTurn) lastTurnId = openTurn.id;
257
+ openTurn = null;
258
+ yielded = false;
259
+ // A turn's unplaced inputs end with it, handed back first.
260
+ held.clear();
261
+ } else if (mark.yield === true) {
262
+ yielded = true;
263
+ } else if (typeof mark.accepted === "string") {
264
+ consume(mark.accepted);
265
+ held.add(mark.accepted);
266
+ } else if (typeof mark.placed === "string") {
267
+ held.delete(mark.placed);
268
+ } else if (typeof mark.withdrawn === "string") {
269
+ held.delete(mark.withdrawn);
270
+ handBack(mark.withdrawn);
271
+ }
272
+ }
273
+ }
274
+ break;
275
+ }
276
+ case "failed":
277
+ // A message whose turn could not start is answered and done.
278
+ failed.set(entry.messageId, entry.error ?? { name: "Error", message: "the turn failed" });
279
+ consume(entry.messageId);
280
+ break;
281
+ case "ended":
282
+ // A turn that ended before writing a record of its own, such as one
283
+ // the harness refused or its cancel stopped first. Its outcome is on
284
+ // the UI stream.
285
+ consume(entry.messageId);
286
+ break;
287
+ case "stopped":
288
+ stops.set(entry.messageId, (stops.get(entry.messageId) ?? 0) + 1);
289
+ break;
290
+ default:
291
+ break;
292
+ }
293
+ }
294
+ // What the inputs ask for, given the turns the records started. A
295
+ // prompt's own cancel names its turn, in whichever order the two land.
296
+ const cancelTargets = new Set();
297
+ for (const input of inputs) {
298
+ if (input.type === "cancel" && typeof input.target === "string") cancelTargets.add(input.target);
299
+ }
300
+ // An open turn whose harness could not start has ended for its viewers.
301
+ const openFailed = openTurn !== null && failed.has(openTurn.id);
302
+ // The turns waiting to run, each with the steers that join it when it
303
+ // starts, whether it waited or ran when they came, as the fx CLI's steering
304
+ // does: a text steer stored after the open turn's opener joins that turn,
305
+ // and otherwise the turn waiting before it. A steer that cannot join a
306
+ // turn waits as its own, and the turn it came after ends at its next safe
307
+ // point, at once if it has not started; the steers after it join its turn.
308
+ // A steer with no turn to join starts one.
309
+ const pending = [];
310
+ const steers = [];
311
+ const openOwner = openTurn !== null && !openFailed ? { joiners: steers, ends: false } : null;
312
+ let cancelOpen = false;
313
+ let owner = null;
314
+ for (const input of inputs) {
315
+ if (openOwner !== null && owner === null && input.cursor > openTurn.from) owner = openOwner;
316
+ if (consumed.has(input.messageId)) continue;
317
+ const forOpenTurn = openTurn !== null && input.cursor > openTurn.at;
318
+ if (input.type === "prompt") {
319
+ const entry = { ...input, ...(cancelTargets.has(input.messageId) ? { cancelled: true } : {}), joiners: [], ends: false };
320
+ pending.push(entry);
321
+ // While a turn runs, steers still join it: a prompt is the next turn.
322
+ if (owner === null || owner !== openOwner) owner = entry;
323
+ } else if (input.type === "steer") {
324
+ if (owner !== null && joinable(input.input)) owner.joiners.push(input);
325
+ else {
326
+ if (owner !== null) owner.ends = true;
327
+ owner = { ...input, type: "prompt", joiners: [], ends: false };
328
+ pending.push(owner);
329
+ }
330
+ } else if (input.type === "cancel") {
331
+ if (typeof input.target === "string") {
332
+ if (openTurn?.id === input.target) cancelOpen = true;
333
+ } else if (forOpenTurn) cancelOpen = true;
334
+ }
335
+ }
336
+ // A cancel is spent once it can no longer apply: a prompt's own cancel
337
+ // once its turn ended, and a session cancel once the turn open when it
338
+ // landed ended. Its id is kept like a turn's, so a late delivery of the
339
+ // same message is not taken as a new cancel.
340
+ for (const input of inputs) {
341
+ if (input.type !== "cancel" || consumed.has(input.messageId)) continue;
342
+ const live = typeof input.target === "string"
343
+ ? !consumed.has(input.target) || openTurn?.id === input.target
344
+ : openTurn !== null && input.cursor > openTurn.at;
345
+ if (!live) consume(input.messageId);
346
+ }
347
+ // A cancel the failed open turn is due waits for the next prompt's
348
+ // engine. One whose engine stopped each time it continued it is
349
+ // cancelled; one whose engine stopped even then can never go on, and the
350
+ // session runs no more turns.
351
+ const openStops = openTurn === null ? 0 : stops.get(openTurn.id) ?? 0;
352
+ const halted = openStops >= maxHarnessStops + maxCancelStops;
353
+ const stuck = openStops >= maxHarnessStops && !halted;
354
+ const kinds = new Map(inputs.map((input) => [input.messageId, input.type]));
355
+ const kindOf = (id) => kinds.get(id) ?? (baseSteers.has(id) ? "steer" : kindsKnown ? "prompt" : null);
356
+ return {
357
+ // Inputs no turn has taken, as written: what a checkpoint keeps, with
358
+ // the ones the open turn holds unplaced, which a withdrawal hands back.
359
+ unconsumed: inputs.filter((input) => !consumed.has(input.messageId)),
360
+ held: inputs.filter((input) => held.has(input.messageId)),
361
+ heldIds: [...held],
362
+ // A steer that cannot join the open turn waits for it to end.
363
+ endOpen: openOwner?.ends === true,
364
+ head,
365
+ // The lease others must respect, and the chain's latest whether or not
366
+ // it still stands: a worker checks the latter is its own.
367
+ lease: leaseStands(lease, now, alive) ? lease : null,
368
+ lastLease: lease,
369
+ lastEpoch: epoch,
370
+ checkpoint: base ? { through: base.through ?? null, data: base.data } : null,
371
+ records,
372
+ openTurn,
373
+ cut: openTurn === null ? noCutoffs : cut,
374
+ cutoffs: openTurn === null ? 0 : cut.cutoffs,
375
+ openFailed,
376
+ lastTurnId,
377
+ agent,
378
+ yielded,
379
+ pending,
380
+ steers,
381
+ cancelOpen,
382
+ recent: recent.slice(-recentIdsKept),
383
+ recentSteers: recent.slice(-recentIdsKept).filter((id) => kindOf(id) === "steer"),
384
+ // What kind of input a taken id named, or null when no record says.
385
+ consumedKind: (id) => (consumed.has(id) ? kindOf(id) : null),
386
+ consumed,
387
+ failed,
388
+ stops,
389
+ inputKeys: seenInputs,
390
+ maxCursor,
391
+ stuck,
392
+ halted,
393
+ // A halted session's open turn is work until it ends with an error.
394
+ hasWork: (openTurn !== null && !openFailed) || pending.length > 0,
395
+ };
396
+ }
397
+
398
+ function leaseStands(lease, now, alive) {
399
+ if (!lease) return false;
400
+ const living = alive(lease);
401
+ if (living === false) return false;
402
+ if (lease.expiresAt !== null && lease.expiresAt !== undefined && now >= lease.expiresAt) return false;
403
+ return true;
404
+ }
405
+
406
+ // Which queue message a delivery carries, by the queue's own id, and which
407
+ // delivery of it this is, when the World reports both.
408
+ function deliveryOf(meta) {
409
+ const messageId = meta?.messageId;
410
+ const attempt = meta?.attempt;
411
+ return typeof messageId === "string" && messageId.length > 0 && Number.isSafeInteger(attempt) && attempt > 0
412
+ ? { messageId, attempt }
413
+ : null;
414
+ }
415
+
416
+ // Whether `delivery`, in another process, carries the message whose delivery
417
+ // took `lease`. A queue hands a message on only once the delivery holding it
418
+ // let it go unacknowledged: its handler failed, or its process died or lost
419
+ // the message. On Vercel, a function whose process exits mid-delivery runs
420
+ // the same delivery again, attempt and all, in a new process. Either way the
421
+ // holder no longer runs the session, or runs it replaced without knowing,
422
+ // and its next write is fenced.
423
+ function redelivers(delivery, lease) {
424
+ const held = lease?.delivery;
425
+ return Boolean(delivery && held && held.messageId === delivery.messageId &&
426
+ Number.isSafeInteger(held.attempt) && delivery.attempt >= held.attempt);
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Backends. Each gives sessions a log, a stream, and a queue.
431
+
432
+ function createMemoryBackend({ maxDurationMs, reserveMs } = {}) {
433
+ const sessions = new Map();
434
+ const holders = liveHolders;
435
+ // The agents sharing the backend, by name: an agent's copies take the
436
+ // deliveries for its name in turn.
437
+ const handlers = new Map();
438
+ const timers = new Set();
439
+ const sessionFor = (id) => {
440
+ let session = sessions.get(id);
441
+ if (!session) {
442
+ session = { entries: [], head: null, ui: [], uiWaiters: new Set(), watchers: new Set() };
443
+ sessions.set(id, session);
444
+ }
445
+ return session;
446
+ };
447
+ const later = (fn, ms) => {
448
+ const timer = setTimeout(() => { timers.delete(timer); fn(); }, ms);
449
+ timers.add(timer);
450
+ };
451
+ // A message keeps its id across its deliveries, as a queue's does.
452
+ const deliver = (message, attempt, messageId = newId("qmsg")) => {
453
+ if (handlers.size === 0) return later(() => deliver(message, attempt, messageId), 10);
454
+ const group = handlers.get(agentOf(message));
455
+ // No agent of that name here yet: tried again, as a failed delivery is.
456
+ if (!group) {
457
+ if (attempt < 48) later(() => deliver(message, attempt + 1, messageId), 1000);
458
+ return;
459
+ }
460
+ const handler = group.members[group.turn++ % group.members.length];
461
+ Promise.resolve()
462
+ .then(() => handler(message, { messageId, attempt }))
463
+ .then((result) => {
464
+ if (Number.isFinite(result?.timeoutSeconds)) later(() => deliver(message, attempt + 1, messageId), result.timeoutSeconds * 1000);
465
+ }, () => {
466
+ if (attempt < 48) later(() => deliver(message, attempt + 1, messageId), 1000);
467
+ });
468
+ };
469
+ return {
470
+ name: "memory",
471
+ livenessKnown: true,
472
+ runInRequest: false,
473
+ queueDurable: false,
474
+ pollMs: null,
475
+ reserveMs: reserveMs ?? defaultReserveMs,
476
+ maxDurationMs: maxDurationMs ?? null,
477
+ newSessionId: () => newId("ses"),
478
+ validSessionId: validId,
479
+ deadline: () => null,
480
+ holderInfo: () => ({}),
481
+ alive: (lease) => holders.has(lease.holder),
482
+ holding(holder, held) {
483
+ if (held) holders.add(holder);
484
+ else holders.delete(holder);
485
+ },
486
+ async session(id) {
487
+ const session = sessionFor(id);
488
+ return {
489
+ async read() {
490
+ let from = 0;
491
+ for (let index = session.entries.length - 1; index >= 0; index -= 1) {
492
+ const entry = session.entries[index].entry;
493
+ if (entry.k === "checkpoint") {
494
+ from = entry.through === null ? 0 : Number(entry.through);
495
+ break;
496
+ }
497
+ }
498
+ return session.entries.slice(from);
499
+ },
500
+ async readSince(cursor) {
501
+ return session.entries.slice(Number(cursor));
502
+ },
503
+ async append(entry) {
504
+ const chained = entry.k === "lease" || entry.k === "release" || entry.k === "record";
505
+ if (chained && (entry.a ?? null) !== session.head) {
506
+ // A retry of an entry that landed returns its cursor.
507
+ const landed = session.entries.find((item) => item.entry.key !== undefined && item.entry.key === entry.key && item.entry.k === entry.k);
508
+ if (landed) return landed.cursor;
509
+ throw new FencedError(`another worker continued session ${id}`);
510
+ }
511
+ const cursor = String(session.entries.length + 1);
512
+ session.entries.push({ cursor, entry: structuredClone(entry) });
513
+ if (chained) session.head = cursor;
514
+ for (const watcher of session.watchers) watcher();
515
+ return cursor;
516
+ },
517
+ watch(fn) {
518
+ session.watchers.add(fn);
519
+ return () => session.watchers.delete(fn);
520
+ },
521
+ ui: {
522
+ async write(lines) {
523
+ session.ui.push(...lines);
524
+ for (const waiter of session.uiWaiters) waiter();
525
+ },
526
+ async length() {
527
+ return session.ui.length;
528
+ },
529
+ read(from) {
530
+ let index = from;
531
+ let wake = null;
532
+ const notify = () => wake?.();
533
+ return new ReadableStream({
534
+ start() { session.uiWaiters.add(notify); },
535
+ async pull(controller) {
536
+ while (index >= session.ui.length) await new Promise((resolve) => { wake = resolve; });
537
+ wake = null;
538
+ controller.enqueue(encoder.encode(`${session.ui[index++]}\n`));
539
+ },
540
+ cancel() { session.uiWaiters.delete(notify); notify(); },
541
+ });
542
+ },
543
+ },
544
+ };
545
+ },
546
+ queue: {
547
+ async send(message, { delaySeconds = 0 } = {}) {
548
+ later(() => deliver(message, 1), delaySeconds * 1000);
549
+ },
550
+ },
551
+ listen(fn, name = defaultAgentName) {
552
+ const group = joinGroup(handlers, name, fn);
553
+ return () => {
554
+ leaveGroup(handlers, name, group, fn);
555
+ if (handlers.size > 0) return;
556
+ // Nobody is left to deliver to.
557
+ for (const timer of timers) clearTimeout(timer);
558
+ timers.clear();
559
+ };
560
+ },
561
+ };
562
+ }
563
+
564
+ // World runs are sessions; `fx.log` steps are its entries.
565
+ const logStep = "fx.log";
566
+ // A World names streams for its whole project, so each session's stream is
567
+ // named after its run, as Workflow names a run's own streams: `strm_` and
568
+ // the run's id, then `_user_` and the base64url of the namespace
569
+ // "libfx-ui".
570
+ const uiStreamOf = (runId) => `${runId.replace(/^wrun_/, "strm_")}_user_bGliZngtdWk`;
571
+ const queuePrefix = "__libfx_wkf_workflow_";
572
+ // The name of an agent created without one, and of whatever a message or a
573
+ // session from before names belonged to.
574
+ const defaultAgentName = "default";
575
+ const agentNameRule = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
576
+ // The agent a queued message is for.
577
+ const agentOf = (message) => (typeof message?.agent === "string" ? message.agent : defaultAgentName);
578
+ const queueName = `${queuePrefix}session`;
579
+ const eventIdPattern = /^[a-z]+_(\d{26})$/;
580
+
581
+ function ulid(now = Date.now()) {
582
+ const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
583
+ let time = "";
584
+ for (let rest = now, index = 0; index < 10; index += 1, rest = Math.floor(rest / 32)) time = alphabet[rest % 32] + time;
585
+ let random = "";
586
+ for (const byte of crypto.getRandomValues(new Uint8Array(16))) random += alphabet[byte % 32];
587
+ return time + random;
588
+ }
589
+
590
+ // The entries that continue a session's chain of claims and records.
591
+ function chainedKind(k) {
592
+ return k === "lease" || k === "release" || k === "record";
593
+ }
594
+
595
+ // A run's in-band count right after its creation, as `@workflow/world`
596
+ // defines it (`IN_BAND_SEQ_AT_RUN_CREATION`): `run_created` counts as the
597
+ // first in-band write.
598
+ const inBandAtRunCreation = 1;
599
+
600
+ // The World's in-band writer fence refused a write: another writer moved the
601
+ // run's in-band count past the one the write expected.
602
+ function inBandSuperseded(error) {
603
+ return error?.name === "InBandSupersededError" || error?.code === "in-band-superseded" || error?.status === 412;
604
+ }
605
+
606
+ function slotOf(event) {
607
+ const match = eventIdPattern.exec(String(event?.eventId ?? ""));
608
+ if (!match) throw new Error(`the World returned event id ${JSON.stringify(event?.eventId ?? null)}, which holds no slot`);
609
+ return Number(match[1]);
610
+ }
611
+
612
+ function logEntryOf(event) {
613
+ if (event?.eventType !== "step_created" || event.eventData?.stepName !== logStep) return null;
614
+ const input = event.eventData.input;
615
+ if (!(input instanceof Uint8Array)) return null;
616
+ try { return JSON.parse(decoder.decode(input)); } catch { return null; }
617
+ }
618
+
619
+ const missingRun = (error) => error?.status === 404 || /not found|does not exist|ENOENT/i.test(String(error?.message ?? ""));
620
+ // Tries for a failed UI stream read that is not a missing stream.
621
+ const streamReadRetries = 5;
622
+ // How often a viewer looks for a session stream that does not exist yet,
623
+ // and for how long before it backs off.
624
+ const missingStreamPollMs = 50;
625
+ const missingStreamFastMs = 2000;
626
+
627
+ // Events a session-log read lists per page: the first page, then the rest.
628
+ const firstPageEvents = 10;
629
+ const laterPageEvents = 100;
630
+
631
+ // The agents listening on each World in this process. A World holds one
632
+ // handler for libfx's queue, so the agents given one World take its
633
+ // deliveries in turn, as servers sharing a queue do.
634
+ const listenersOf = new WeakMap();
635
+
636
+ // Adds `member` to the group for `name` in `groups`, a Map of name to
637
+ // `{ members, turn }`, and returns the group; leaveGroup takes it out again.
638
+ function joinGroup(groups, name, member) {
639
+ let group = groups.get(name);
640
+ if (!group) groups.set(name, group = { members: [], turn: 0 });
641
+ group.members.push(member);
642
+ return group;
643
+ }
644
+
645
+ function leaveGroup(groups, name, group, member) {
646
+ const index = group.members.indexOf(member);
647
+ if (index >= 0) group.members.splice(index, 1);
648
+ if (group.members.length === 0 && groups.get(name) === group) groups.delete(name);
649
+ }
650
+
651
+ function noAgentNamed(name) {
652
+ return Object.assign(new Error(`no libfx agent named ${JSON.stringify(name)} runs in this process; the queue delivers this again`), { code: "FX_NO_AGENT", agent: name });
653
+ }
654
+
655
+ // The durable agents in this process, by name, from when each is created
656
+ // until it closes, each as `{ open, durability }`: a route delivers each
657
+ // message to the agent it names, and a function that only receives queue
658
+ // messages has its agents here before any of them is used. Shared by every
659
+ // copy of this module in the process.
660
+ const agentsByName = (globalThis[Symbol.for("libfx.durableAgents")] ??= new Map());
661
+
662
+ // The World backend of an agent named `name` in this process whose
663
+ // durability `matches`, the agent's copies in turn, or null.
664
+ async function agentNamed(name, matches) {
665
+ const group = agentsByName.get(name);
666
+ if (!group) return null;
667
+ const { members } = group;
668
+ const first = group.turn++;
669
+ for (let index = 0; index < members.length; index += 1) {
670
+ const member = members[(first + index) % members.length];
671
+ if (!matches(await member.durability())) continue;
672
+ const created = await member.open();
673
+ if (typeof created.deliverHere === "function") return created;
674
+ }
675
+ return null;
676
+ }
677
+
678
+ // Agents share sessions through the durability they were created with, so
679
+ // one on another durability can hold other sessions.
680
+ const agentSharing = (name, durability) => agentNamed(name, (other) => other === durability);
681
+
682
+ // A route takes its deployment's deliveries, which every route durability
683
+ // of the same kind in the process shares, such as each `vercel()` an app
684
+ // creates: the agent a message names on this durability, or on another of
685
+ // that kind.
686
+ const agentOnRoute = async (name, durability) => (await agentSharing(name, durability)) ??
687
+ agentNamed(name, (other) => other !== durability && other.name === durability.name && typeof other.world === "function" && other.create === undefined);
688
+
689
+ /**
690
+ * The request handler a deployment mounts for queue deliveries, for every
691
+ * durable agent in the process: each delivery goes to the agent its message
692
+ * names. One route and one queue trigger serve any number of agents.
693
+ */
694
+ export function wakeHandler() {
695
+ return async (request) => {
696
+ for (const group of agentsByName.values()) {
697
+ for (const member of group.members) {
698
+ const created = await member.open();
699
+ if (created.viaRoute === true) return created.queueHandler(request);
700
+ }
701
+ }
702
+ return new Response("no libfx agent in this process takes queue deliveries from a route", { status: 503 });
703
+ };
704
+ }
705
+
706
+ // libfx starts and closes a World it `owned`, having created it; a World the
707
+ // app passed in stays the app's.
708
+ function createWorldBackend(world, durability) {
709
+ const { name, reserveMs, pollMs, leaseMs, alive, holderInfo, livenessKnown = false, queueLeases = false, runInRequest = queueLeases, waitUntil, queueDurable = false, maxDurationMs, owned = true } = durability;
710
+ const spec = world.specVersion === undefined ? {} : { specVersion: world.specVersion };
711
+ const created = new Set();
712
+ // Runs being created now: writes that go out together create a run once.
713
+ const creating = new Map();
714
+ // Workers in this process hear about each other's entries at once; others
715
+ // poll.
716
+ const watchers = new Map();
717
+ const changed = (runId) => { for (const watcher of watchers.get(runId) ?? []) watcher(); };
718
+ let handler = null;
719
+ const route = typeof world.registerHandler !== "function";
720
+ const listEvents = (runId, cursor, limit) => world.events.list({
721
+ runId,
722
+ pagination: { sortOrder: "desc", limit, ...(cursor ? { cursor } : {}) },
723
+ resolveData: "all",
724
+ });
725
+ // `meta` says which queue message this is and which delivery of it.
726
+ const deliverTo = async (handle, message, meta) => {
727
+ const input = message?.input;
728
+ if (!input || typeof input !== "object") return undefined;
729
+ if (!handle) {
730
+ // A World libfx created closes with its agent. The app's own World
731
+ // outlives it, so its queue keeps the message for the next agent.
732
+ if (owned) return undefined;
733
+ throw new Error("no libfx agent is listening; the queue delivers this again");
734
+ }
735
+ const result = await handle(input, meta);
736
+ // The queue delivers this same message again, which takes back a lease
737
+ // its worker could not let go.
738
+ if (result?.redeliver === true) throw new Error("libfx asks the queue to deliver this message again");
739
+ return Number.isFinite(result?.timeoutSeconds) ? { timeoutSeconds: result.timeoutSeconds } : undefined;
740
+ };
741
+ // The route a deployment mounts delivers to the agent in this process the
742
+ // message names, whichever agent's route it is.
743
+ const queueHandler = world.createQueueHandler(queuePrefix, async (message, meta) => {
744
+ const named = agentOf(message?.input);
745
+ const agent = await agentOnRoute(named, durability);
746
+ if (!agent) throw noAgentNamed(named);
747
+ return agent.deliverHere(message, meta);
748
+ });
749
+ const backend = {
750
+ name,
751
+ livenessKnown,
752
+ queueLeases,
753
+ // A prompt runs in the request that took it, held by its backstop's
754
+ // message, which the queue keeps until after the request's deadline.
755
+ runInRequest: queueLeases && runInRequest,
756
+ // Keeps the request alive for a turn it runs after its response.
757
+ waitUntil: waitUntil ?? null,
758
+ queueDurable,
759
+ pollMs: pollMs ?? 1000,
760
+ reserveMs: reserveMs ?? defaultReserveMs,
761
+ leaseMs: leaseMs ?? defaultLeaseMs,
762
+ maxDurationMs: maxDurationMs ?? null,
763
+ world,
764
+ newSessionId: () => `wrun_${typeof world.createRunId === "function" ? world.createRunId({}) : ulid()}`,
765
+ validSessionId: (id) => typeof id === "string" && /^wrun_[0-9A-Za-z]{10,64}$/.test(id),
766
+ deadline: () => null,
767
+ holderInfo: holderInfo ?? (() => ({})),
768
+ // Whether a lease's holder still runs, as `delivery` can tell: a worker
769
+ // of this process runs while it holds it, and the message its delivery
770
+ // held, delivered to this process, means it no longer does.
771
+ alive(lease, delivery = null) {
772
+ if (liveHolders.has(lease.holder)) return true;
773
+ const mine = holderInfo?.() ?? {};
774
+ if (mine.pid !== undefined && lease.pid === mine.pid && lease.host === mine.host) return false;
775
+ if (redelivers(delivery, lease)) return false;
776
+ return alive ? alive(lease) : null;
777
+ },
778
+ holding(holder, held) {
779
+ if (held) liveHolders.add(holder);
780
+ else liveHolders.delete(holder);
781
+ },
782
+ async start() {
783
+ if (owned) await world.start?.();
784
+ if (typeof world.getRuntimeDeadline === "function") {
785
+ backend.deadline = () => backend.runtimeDeadline ?? null;
786
+ }
787
+ },
788
+ async refreshDeadline() {
789
+ if (typeof world.getRuntimeDeadline !== "function") return;
790
+ try {
791
+ const deadline = await world.getRuntimeDeadline();
792
+ backend.runtimeDeadline = deadline instanceof Date ? deadline.getTime() : null;
793
+ } catch {
794
+ backend.runtimeDeadline = null;
795
+ }
796
+ },
797
+ async session(runId) {
798
+ // The newest slot this worker has seen.
799
+ let known = 0;
800
+ // Where the World has the in-band writer fence, chained entries are
801
+ // written in-band, each expecting the World's count of in-band writes
802
+ // at the entry it continues, so the World refuses one that continues a
803
+ // superseded head and nothing of it lands. `inBandAt` maps a chained
804
+ // entry's cursor (`null` for an empty chain) to the count right after
805
+ // it: from the snapshot a read returns, or from the write itself. The
806
+ // check after a write stays, since a World may count per process.
807
+ const inBandAt = new Map();
808
+ let inBand = world.capabilities?.inBandFence === true;
809
+ const noteInBand = (cursor, count) => {
810
+ inBandAt.delete(cursor);
811
+ inBandAt.set(cursor, count);
812
+ if (inBandAt.size > 16) inBandAt.delete(inBandAt.keys().next().value);
813
+ };
814
+ // A snapshot is read before its listing, so it gives the count at the
815
+ // chain's head only when it covers the head's slot. It never replaces
816
+ // a count this worker's own write reported.
817
+ const noteSnapshot = (snapshot, cursor) => {
818
+ if (!inBand || cursor === undefined || inBandAt.has(cursor) || !Number.isSafeInteger(snapshot?.seqInBand)) return;
819
+ if (cursor !== "null" && !(Number(cursor) <= snapshot.seq)) return;
820
+ noteInBand(cursor, snapshot.seqInBand);
821
+ };
822
+ const ensureRun = () => {
823
+ if (created.has(runId)) return Promise.resolve();
824
+ let pending = creating.get(runId);
825
+ if (!pending) {
826
+ pending = createRun().finally(() => creating.delete(runId));
827
+ creating.set(runId, pending);
828
+ }
829
+ return pending;
830
+ };
831
+ const createRun = async () => {
832
+ try {
833
+ const deploymentId = typeof world.getDeploymentId === "function" ? await world.getDeploymentId() : "libfx";
834
+ await world.events.create(runId, {
835
+ eventType: "run_created",
836
+ ...spec,
837
+ eventData: { deploymentId, workflowName: "libfx/session", input: encoder.encode("{}") },
838
+ });
839
+ } catch (error) {
840
+ // Another worker created it first.
841
+ if (!/exist|conflict|409|already/i.test(String(error?.message ?? "")) && error?.status !== 409) throw error;
842
+ }
843
+ try {
844
+ await world.events.create(runId, { eventType: "run_started", ...spec });
845
+ } catch (error) {
846
+ if (!/exist|conflict|409|already|started|status/i.test(String(error?.message ?? "")) && error?.status !== 409) throw error;
847
+ }
848
+ created.add(runId);
849
+ };
850
+ // Events newest first, down to and excluding slot `stopBelow`.
851
+ const newestFirst = async (stopBelow, stopAtCheckpoint) => {
852
+ const events = [];
853
+ let through = null;
854
+ let snapshot;
855
+ // The newest chained entry read: the chain's head, unless the read
856
+ // stopped before reaching any.
857
+ let newestChained;
858
+ // Ends the read. `head` is where the chain's head is when the read
859
+ // found no chained entry, or undefined when the read does not show.
860
+ const done = (head) => {
861
+ noteSnapshot(snapshot, newestChained ?? head);
862
+ return { events, through };
863
+ };
864
+ // A session's newest checkpoint is usually among its last few
865
+ // entries, so a read starts with a small page and fetches more only
866
+ // while it has not reached where it stops; every page carries its
867
+ // entries' payloads, a checkpoint holding the whole conversation.
868
+ for (let cursor, limit = firstPageEvents; ; limit = laterPageEvents) {
869
+ let page;
870
+ try {
871
+ page = await listEvents(runId, cursor, limit);
872
+ } catch (error) {
873
+ if (!missingRun(error)) throw error;
874
+ // The run's creation starts its count.
875
+ if (stopBelow === 0) noteSnapshot({ seq: 0, seqInBand: inBandAtRunCreation }, "null");
876
+ return { events, through };
877
+ }
878
+ snapshot ??= page.snapshot;
879
+ for (const event of page.data) {
880
+ const slot = slotOf(event);
881
+ if (slot > known) known = slot;
882
+ if (slot <= stopBelow) return done(undefined);
883
+ // The checkpoint stands for the entries through its `through`,
884
+ // the last of them the chain's head.
885
+ if (through !== null && slot <= through) return done(through === 0 ? "null" : String(through));
886
+ const entry = logEntryOf(event);
887
+ if (!entry) continue;
888
+ created.add(runId);
889
+ if (newestChained === undefined && chainedKind(entry.k)) newestChained = String(slot);
890
+ events.push({ cursor: String(slot), entry });
891
+ if (stopAtCheckpoint && through === null && entry.k === "checkpoint") {
892
+ through = entry.through === null || entry.through === undefined ? 0 : Number(entry.through);
893
+ }
894
+ }
895
+ if (!page.hasMore) return done(stopBelow > 0 ? undefined : through ? String(through) : "null");
896
+ cursor = page.cursor;
897
+ }
898
+ };
899
+ return {
900
+ async read() {
901
+ const { events } = await newestFirst(0, true);
902
+ return events.reverse();
903
+ },
904
+ async readSince(cursor) {
905
+ const { events } = await newestFirst(Number(cursor), false);
906
+ return events.reverse();
907
+ },
908
+ async append(entry) {
909
+ await ensureRun();
910
+ const chained = chainedKind(entry.k);
911
+ // A chained entry asks the World to report every event since the
912
+ // one it continues, so a competing continuation is seen.
913
+ const after = chained ? (entry.a === null || entry.a === undefined ? 0 : Number(entry.a)) : known;
914
+ const continues = entry.a === null || entry.a === undefined ? "null" : String(entry.a);
915
+ let expected = chained && inBand ? inBandAt.get(continues) : undefined;
916
+ const event = {
917
+ eventType: "step_created",
918
+ correlationId: `step_${ulid()}`,
919
+ eventData: { stepName: logStep, input: encoder.encode(JSON.stringify(entry)) },
920
+ };
921
+ let result;
922
+ for (let recounted = false; ;) {
923
+ try {
924
+ result = await world.events.create(runId, event, expected === undefined ? { eventCount: after } : { eventCount: after, inBand: true, expectedSeqInBand: expected });
925
+ break;
926
+ } catch (error) {
927
+ if (expected === undefined) throw error;
928
+ if (inBandSuperseded(error)) {
929
+ // The next read maps the head afresh.
930
+ inBandAt.delete(continues);
931
+ // A World counts a write after it publishes it, so a read
932
+ // between the two gave this head the count before its own
933
+ // write. When nothing else continued the head, the write goes
934
+ // again once at the count the World reports.
935
+ if (!recounted && Number.isSafeInteger(error?.seqInBand)) {
936
+ const { events: since } = await newestFirst(after, false);
937
+ const rivals = since.filter(({ entry: other }) => chainedKind(other.k) && (other.a ?? null) === (entry.a ?? null));
938
+ // Our own earlier write of this entry landed first.
939
+ const ours = rivals.find(({ entry: other }) => other.key !== undefined && other.key === entry.key && other.k === entry.k);
940
+ if (ours) return ours.cursor;
941
+ if (rivals.length === 0) {
942
+ recounted = true;
943
+ expected = error.seqInBand;
944
+ continue;
945
+ }
946
+ }
947
+ throw new FencedError(`another worker continued session ${runId}`);
948
+ }
949
+ // A World may refuse in-band writes on a run it stamped before
950
+ // it had the fence; the write goes out-of-band, as before.
951
+ if (!(error?.status >= 400 && error?.status < 500) || error?.status === 409) throw error;
952
+ inBand = false;
953
+ expected = undefined;
954
+ }
955
+ }
956
+ const slot = slotOf(result.event);
957
+ if (slot > known) known = slot;
958
+ if (expected !== undefined && result.allocated !== undefined) noteInBand(String(slot), expected + result.allocated);
959
+ changed(runId);
960
+ if (!chained || slot === after + 1) return String(slot);
961
+ const reported = Array.isArray(result.events) ? result.events : (await newestFirst(after, false)).events.map((item) => ({ eventId: `evnt_${String(item.cursor).padStart(26, "0")}`, ...item }));
962
+ for (const event of reported) {
963
+ const other = event.entry ?? logEntryOf(event);
964
+ const otherSlot = event.entry ? Number(event.cursor) : slotOf(event);
965
+ if (!other || otherSlot <= after || otherSlot >= slot) continue;
966
+ const otherChained = chainedKind(other.k);
967
+ if (!otherChained || (other.a ?? null) !== (entry.a ?? null)) continue;
968
+ // Our own earlier write of this entry landed first.
969
+ if (other.key !== undefined && other.key === entry.key && other.k === entry.k) return String(otherSlot);
970
+ throw new FencedError(`another worker continued session ${runId}`);
971
+ }
972
+ return String(slot);
973
+ },
974
+ watch(fn) {
975
+ let set = watchers.get(runId);
976
+ if (!set) watchers.set(runId, set = new Set());
977
+ set.add(fn);
978
+ return () => set.delete(fn);
979
+ },
980
+ ui: {
981
+ async write(lines) {
982
+ await ensureRun();
983
+ const chunks = lines.map((line) => `${line}\n`);
984
+ if (typeof world.streams.writeMulti === "function" && chunks.length > 1) await world.streams.writeMulti(runId, uiStreamOf(runId), chunks);
985
+ else for (const chunk of chunks) await world.streams.write(runId, uiStreamOf(runId), chunk);
986
+ },
987
+ async length() {
988
+ try {
989
+ const info = await world.streams.getInfo(runId, uiStreamOf(runId));
990
+ return Math.max(0, Number(info?.tailIndex ?? -1) + 1);
991
+ } catch {
992
+ return 0;
993
+ }
994
+ },
995
+ read(from) {
996
+ let reader = null;
997
+ let cancelled = false;
998
+ const opened = Date.now();
999
+ return new ReadableStream({
1000
+ async pull(controller) {
1001
+ for (let delay = missingStreamPollMs, failures = 0; !reader;) {
1002
+ if (cancelled) return;
1003
+ try {
1004
+ reader = (await world.streams.get(runId, uiStreamOf(runId), from)).getReader();
1005
+ } catch (error) {
1006
+ // A stream nobody wrote yet appears with the turn's first
1007
+ // event; any other failure gets a few tries, then ends
1008
+ // the read, so its viewer hears of it.
1009
+ const missing = missingRun(error);
1010
+ if (!missing && ++failures > streamReadRetries) {
1011
+ controller.error(error);
1012
+ return;
1013
+ }
1014
+ await new Promise((resolve) => setTimeout(resolve, delay));
1015
+ // A new session's stream appears within moments of its
1016
+ // first prompt, so it is looked for often at first.
1017
+ delay = missing && Date.now() - opened < missingStreamFastMs ? missingStreamPollMs : Math.min(delay * 2, 1000);
1018
+ }
1019
+ }
1020
+ const { value, done } = await reader.read();
1021
+ if (done) controller.close();
1022
+ else controller.enqueue(typeof value === "string" ? encoder.encode(value) : value);
1023
+ },
1024
+ // A live World read can take minutes to cancel, so nothing
1025
+ // waits for it: a viewer that has its turn's end is done.
1026
+ cancel() {
1027
+ cancelled = true;
1028
+ void reader?.cancel().catch(() => {});
1029
+ },
1030
+ });
1031
+ },
1032
+ },
1033
+ };
1034
+ },
1035
+ queue: {
1036
+ // Returns the queue's id for the message, when the World gives one.
1037
+ async send(message, { delaySeconds = 0, idempotencyKey } = {}) {
1038
+ const sent = await world.queue(queueName, { runId: message.sessionId, requestId: message.messageId, input: message }, {
1039
+ ...(idempotencyKey ? { idempotencyKey } : {}),
1040
+ ...(delaySeconds > 0 ? { delaySeconds } : {}),
1041
+ });
1042
+ return typeof sent?.messageId === "string" && sent.messageId.length > 0 ? { messageId: sent.messageId } : {};
1043
+ },
1044
+ },
1045
+ // The route a deployment mounts for queue deliveries, until the World
1046
+ // can deliver to an in-process handler itself.
1047
+ queueHandler,
1048
+ durability,
1049
+ // Whether the queue delivers to queueHandler, mounted by the deployment.
1050
+ viaRoute: route,
1051
+ // Delivers a routed message to this backend's agent.
1052
+ deliverHere: (message, meta) => deliverTo(handler, message, meta),
1053
+ listen(fn, name = defaultAgentName) {
1054
+ handler = fn;
1055
+ if (route) return () => { if (handler === fn) handler = null; };
1056
+ // The agents on this World, by name: an agent's copies take the
1057
+ // deliveries for its name in turn. A name none of them has goes to an
1058
+ // agent of that name on the same durability elsewhere in the process,
1059
+ // as each agent has a World of its own, and one the process has none
1060
+ // of is left for the queue to deliver again.
1061
+ let listeners = listenersOf.get(world);
1062
+ if (!listeners) {
1063
+ listeners = new Map();
1064
+ listenersOf.set(world, listeners);
1065
+ world.registerHandler(queuePrefix, world.createQueueHandler(queuePrefix, async (message, meta) => {
1066
+ const named = agentOf(message?.input);
1067
+ const group = listeners.get(named);
1068
+ if (group) return deliverTo(group.members[group.turn++ % group.members.length], message, meta);
1069
+ const agent = await agentSharing(named, durability);
1070
+ if (agent) return agent.deliverHere(message, meta);
1071
+ if (listeners.size === 0) return deliverTo(null, message, meta);
1072
+ throw noAgentNamed(named);
1073
+ }));
1074
+ }
1075
+ const group = joinGroup(listeners, name, fn);
1076
+ return () => {
1077
+ if (handler === fn) handler = null;
1078
+ leaveGroup(listeners, name, group, fn);
1079
+ };
1080
+ },
1081
+ // Closing is best effort: a runtime whose HTTP agent cannot close, as
1082
+ // under Bun, still lets the agent go.
1083
+ async close() {
1084
+ if (!owned) return;
1085
+ try {
1086
+ await world.close?.();
1087
+ } catch {}
1088
+ },
1089
+ };
1090
+ return backend;
1091
+ }
1092
+
1093
+
1094
+ // ---------------------------------------------------------------------------
1095
+ // The worker: runs a session's turns while it holds the lease.
1096
+
1097
+ // Lines a worker writes to the session's UI stream, in order. A line goes
1098
+ // out at once when no write is in flight; lines pushed during one go
1099
+ // together in the next. Each carries the worker's lease epoch, so a reader
1100
+ // can tell a replaced worker's late lines from its successor's.
1101
+ function uiWriter(log, onError, epoch) {
1102
+ let queued = [];
1103
+ let writing = null;
1104
+ const send = () => {
1105
+ const lines = queued;
1106
+ queued = [];
1107
+ writing = log.ui.write(lines).catch(onError).then(() => {
1108
+ writing = null;
1109
+ if (queued.length > 0) send();
1110
+ });
1111
+ };
1112
+ return {
1113
+ push(event) {
1114
+ queued.push(JSON.stringify(epoch === undefined ? event : { ...event, epoch }));
1115
+ if (!writing) send();
1116
+ },
1117
+ // Waits until every line pushed so far is written.
1118
+ async settle() {
1119
+ while (writing) await writing;
1120
+ },
1121
+ };
1122
+ }
1123
+
1124
+ // What a turn runs with: its caller's context and its session object's
1125
+ // settings, from the input that started it or from the open turn.
1126
+ const runOf = (input) => ({ context: input?.context ?? null, settings: input?.settings ?? null });
1127
+
1128
+ const errorSummary = (error) => ({ name: error?.name ?? "Error", message: String(error?.message ?? error), ...(error?.code ? { code: error.code } : {}) });
1129
+ // The harness contract: a fenced write rejects with this code.
1130
+ const isFenced = (error) => error?.code === "FX_FENCED";
1131
+
1132
+ // What a queue message adds to its session's log: a restored agent's first
1133
+ // checkpoint and the message's input, a prompt with its caller's context and
1134
+ // its session object's settings.
1135
+ // Nothing the log already holds. Pure.
1136
+ function messageEntries(state, message) {
1137
+ const entries = [];
1138
+ const named = typeof message.agent === "string" ? { agent: message.agent } : {};
1139
+ if (message.seed && state.checkpoint === null && state.records.length === 0) {
1140
+ entries.push({ k: "checkpoint", through: null, data: message.seed, ...named });
1141
+ }
1142
+ const key = `${message.type}:${message.messageId}`;
1143
+ const known = state.inputKeys.has(key) || state.consumed.has(message.messageId) || idInUse(state, message);
1144
+ if ((message.type === "prompt" || message.type === "steer" || message.type === "cancel") && !known) {
1145
+ entries.push({
1146
+ k: "input",
1147
+ key,
1148
+ type: message.type,
1149
+ messageId: message.messageId,
1150
+ ...named,
1151
+ ...(message.input === undefined ? {} : { input: message.input }),
1152
+ ...(typeof message.target === "string" ? { target: message.target } : {}),
1153
+ ...((message.type === "prompt" || message.type === "steer") && message.context !== undefined ? { context: message.context } : {}),
1154
+ ...((message.type === "prompt" || message.type === "steer") && message.settings !== undefined ? { settings: message.settings } : {}),
1155
+ });
1156
+ }
1157
+ return entries;
1158
+ }
1159
+
1160
+ // Whether a steer's input can join a turn: text. One with images or
1161
+ // resources runs as its own turn, after the running one hands off to it.
1162
+ const joinable = (input) => typeof input === "string" || (Array.isArray(input) && input.every((block) => block?.type === "text"));
1163
+
1164
+ // Whether `message` reuses the id of the session's other kind of input: one
1165
+ // id names one input, prompt or steer.
1166
+ function idInUse(state, message) {
1167
+ const other = message.type === "prompt" ? "steer" : message.type === "steer" ? "prompt" : null;
1168
+ if (other === null) return false;
1169
+ return state.inputKeys.has(`${other}:${message.messageId}`) || state.consumedKind?.(message.messageId) === other;
1170
+ }
1171
+
1172
+ const messageIdInUse = (message) => Object.assign(
1173
+ new TypeError(`messageId ${JSON.stringify(message.messageId)} already names a ${message.type === "prompt" ? "steer" : "prompt"} in this session`),
1174
+ { code: "FX_MESSAGE_ID_IN_USE" },
1175
+ );
1176
+
1177
+ // Writes what a queue message adds to its session, in order, and returns how
1178
+ // many entries it wrote. The sender and the consumer can both call it. A
1179
+ // message that reuses another input's id writes nothing and throws.
1180
+ async function recordMessage(log, state, message) {
1181
+ if (idInUse(state, message)) throw messageIdInUse(message);
1182
+ const entries = messageEntries(state, message);
1183
+ for (const entry of entries) await log.append(entry);
1184
+ return entries.length;
1185
+ }
1186
+
1187
+ /**
1188
+ * The session as a worker can start it before its claim lands: the log it
1189
+ * read, with this message's input in it, when that leaves no open turn and
1190
+ * a prompt to run. The turn's first model request then needs nothing the
1191
+ * claim writes. Null when the worker must claim first: an open turn
1192
+ * continues only from the log as its claim finds it, and a restored agent's
1193
+ * first checkpoint lands before anything reads it. `fold` folds entries the
1194
+ * way the worker does. Pure.
1195
+ */
1196
+ export function earlyStart(entries, state, message, fold) {
1197
+ const writes = messageEntries(state, message);
1198
+ if (writes.some((entry) => entry.k !== "input")) return null;
1199
+ // The input lands after everything the worker read.
1200
+ const next = writes.length === 0 ? state : fold([...entries, ...writes.map((entry) => ({ cursor: String(state.maxCursor), entry }))]);
1201
+ if (next.openTurn !== null || next.halted || next.pending.length === 0) return null;
1202
+ return { writes, state: next };
1203
+ }
1204
+
1205
+ // The log as a worker writes it under a claim still in flight: each write
1206
+ // waits for the claim, and none is made once it failed. `ready` resolves
1207
+ // true when the claim landed and false when it failed. A UI line written
1208
+ // under a failed claim is dropped: its turn never ran for anyone.
1209
+ function gatedLog(log, ready, claimError) {
1210
+ return {
1211
+ ...log,
1212
+ async append(entry) {
1213
+ if (!(await ready)) throw claimError();
1214
+ return log.append(entry);
1215
+ },
1216
+ ui: {
1217
+ ...log.ui,
1218
+ async write(lines) {
1219
+ if (await ready) await log.ui.write(lines);
1220
+ },
1221
+ },
1222
+ };
1223
+ }
1224
+
1225
+ // How long before its deadline a worker stops itself, at most: it cancels
1226
+ // the model or tool call still running, so it never runs on after its lease
1227
+ // could pass to another worker.
1228
+ const hardStopMs = 2000;
1229
+
1230
+ const durableInternals = Symbol.for("libfx.durableInternals");
1231
+
1232
+ // The shortest wait before a message that found its session running in this
1233
+ // process is delivered again, as a backstop for the running worker.
1234
+ const runningBackstopSeconds = 2;
1235
+
1236
+ // When a delivery that starts now must stop: its function's deadline, or
1237
+ // `maxDurationMs` after now; null when nothing stops it.
1238
+ const deliveryDeadline = (backend) => backend.deadline() ?? (backend.maxDurationMs === null ? null : Date.now() + backend.maxDurationMs);
1239
+
1240
+ // How long past the request's own deadline the queue keeps a prompt that
1241
+ // request runs itself, before it delivers it as a backstop.
1242
+ const inlineBackstopMarginSeconds = 5;
1243
+
1244
+ class SessionWorker {
1245
+ constructor(agent, backend, sessionId) {
1246
+ this.agent = agent;
1247
+ this.backend = backend;
1248
+ this.sessionId = sessionId;
1249
+ // Set while this process runs the session; each run holds its own lease.
1250
+ this.running = false;
1251
+ // Deliveries in progress for the session in this process.
1252
+ this.deliveries = 0;
1253
+ this.holder = null;
1254
+ }
1255
+
1256
+ // The log folded as `delivery` judges whether a lease's holder lives.
1257
+ fold(entries, delivery = null) {
1258
+ return foldSessionLog(entries, { now: Date.now(), alive: (lease) => this.backend.alive(lease, delivery) });
1259
+ }
1260
+
1261
+ // One queue delivery, `delivery` its message's id and attempt when the
1262
+ // queue reports them. Its input reaches the log at once, even while this
1263
+ // process runs the session, so a steer or cancel reaches the running turn.
1264
+ // Then it runs the session when no live worker holds it. Returns
1265
+ // `{ timeoutSeconds }` to have the same message delivered again later.
1266
+ async consume(message, delivery = null) {
1267
+ const log = await this.backend.session(this.sessionId);
1268
+ await this.backend.refreshDeadline?.();
1269
+ // When this delivery's function stops, if it does.
1270
+ const deadline = this.backend.deadline() ?? (this.backend.maxDurationMs === null ? null : Date.now() + this.backend.maxDurationMs);
1271
+ let entries = await log.read();
1272
+ let state = this.fold(entries, delivery);
1273
+ // A session runs only on the agent it belongs to, which a new one is the
1274
+ // agent that sent its first message. A message that reached another
1275
+ // agent, as one sent to another agent's session does, goes on to it.
1276
+ const owner = state.agent ?? agentOf(message);
1277
+ if (owner !== this.agent.name) {
1278
+ await this.backend.queue.send({ ...message, agent: owner }, {
1279
+ idempotencyKey: `${message.sessionId}:${message.type}:${message.messageId}:to:${owner}`,
1280
+ });
1281
+ this.agent.emit("session.forwarded", { sessionId: this.sessionId, agent: owner });
1282
+ return undefined;
1283
+ }
1284
+ for (let claims = 0; claims < 8; claims += 1) {
1285
+ // A session with no open turn starts its next prompt at once: the
1286
+ // first model request goes out while the input and the lease are
1287
+ // written, and nothing else is written until both land.
1288
+ const early = this.running || state.lease ? null : earlyStart(entries, state, message, (items) => this.fold(items, delivery));
1289
+ let outcome;
1290
+ if (early) {
1291
+ outcome = await this.claim(log, state, deadline, early, delivery);
1292
+ } else {
1293
+ let wrote;
1294
+ try {
1295
+ wrote = await recordMessage(log, state, message);
1296
+ } catch (error) {
1297
+ if (error?.code !== "FX_MESSAGE_ID_IN_USE") throw error;
1298
+ // Its sender could not hear it: a durable queue took it first.
1299
+ this.agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(error) });
1300
+ return undefined;
1301
+ }
1302
+ if (wrote > 0) {
1303
+ entries = await log.read();
1304
+ state = this.fold(entries, delivery);
1305
+ }
1306
+ if (this.running) return { timeoutSeconds: runningBackstopSeconds };
1307
+ if (!state.hasWork) {
1308
+ if (message.type === "resume") await this.answerIdle(log, message);
1309
+ return undefined;
1310
+ }
1311
+ if (state.lease) {
1312
+ // The live holder takes this input from the log; come back as a
1313
+ // backstop once its lease would have run out.
1314
+ const left = state.lease.expiresAt ? Math.ceil((state.lease.expiresAt - Date.now()) / 1000) + 1 : 5;
1315
+ return { timeoutSeconds: Math.max(1, Math.min(maxBackstopSeconds, left)) };
1316
+ }
1317
+ outcome = await this.claim(log, state, deadline, null, delivery);
1318
+ }
1319
+ if (outcome === "yielded") return { timeoutSeconds: 0 };
1320
+ // A write that failed for any other reason: the same message runs the
1321
+ // session again shortly. A lease held to the deadline, which a release
1322
+ // that failed may leave standing, is let go only by a later delivery of
1323
+ // this message, and a queue may send `{ timeoutSeconds }` on as a new
1324
+ // one, so the queue is asked for this one again.
1325
+ if (outcome === "failed") return queueHeld(this.backend, deadline) ? { redeliver: true } : { timeoutSeconds: 1 };
1326
+ if (outcome === "fenced") {
1327
+ // Another worker took the session over; this one stops.
1328
+ this.agent.emit("session.fenced", { sessionId: this.sessionId, epoch: state.lastEpoch + 1 });
1329
+ return undefined;
1330
+ }
1331
+ // The turns ran, or another worker's write came before this claim:
1332
+ // read the log again.
1333
+ entries = await log.read();
1334
+ state = this.fold(entries, delivery);
1335
+ }
1336
+ return { timeoutSeconds: 1 };
1337
+ }
1338
+
1339
+ // Claims the session where `state` read it, with a lease that continues
1340
+ // the chain there, and runs it. With `early`, what `earlyStart` returned,
1341
+ // the message's input is written with the lease and the next turn starts
1342
+ // before either lands. The lease names `delivery`, so a later delivery of
1343
+ // the same message can tell it lost its holder. Returns what run()
1344
+ // returns, or "refused" when another worker's write came before the claim.
1345
+ async claim(log, state, deadline, early, delivery = null) {
1346
+ // Claimed before the first await, so a delivery that arrives meanwhile
1347
+ // sees the session running here.
1348
+ this.running = true;
1349
+ this.holder = newId("wkr");
1350
+ const lease = {
1351
+ k: "lease",
1352
+ a: state.head,
1353
+ holder: this.holder,
1354
+ epoch: state.lastEpoch + 1,
1355
+ expiresAt: leaseEnd(this.backend, deadline),
1356
+ ...this.backend.holderInfo(),
1357
+ ...(delivery ? { delivery } : {}),
1358
+ };
1359
+ this.backend.holding(this.holder, true);
1360
+ try {
1361
+ // Both writes go out at once and neither waits for the other; the
1362
+ // claim settles once both have, with the lease's cursor if it landed
1363
+ // and the first failure.
1364
+ const writes = [log.append(lease), ...(early?.writes ?? []).map((entry) => log.append(entry))];
1365
+ const claimed = Promise.allSettled(writes).then(async ([leased, ...rest]) => {
1366
+ const head = leased.status === "fulfilled" ? leased.value : null;
1367
+ const error = leased.status === "rejected" ? leased.reason : rest.find((item) => item.status === "rejected")?.reason ?? null;
1368
+ if (error || !early) return { head, error };
1369
+ // A lease the World took without reporting a competing one is
1370
+ // checked against the log, as run() checks one taken first. One that
1371
+ // fails is refused like a fenced write rather than stopped: this
1372
+ // worker has run nothing, and its input may land after the other
1373
+ // worker's last read, so the delivery reads the log again.
1374
+ try {
1375
+ const now = this.fold(await log.read());
1376
+ return { head, error: now.lastLease?.holder === this.holder ? null : new FencedError(`another worker continued session ${this.sessionId}`) };
1377
+ } catch (readError) {
1378
+ return { head, error: readError };
1379
+ }
1380
+ });
1381
+ let outcome;
1382
+ try {
1383
+ outcome = await this.run(log, claimed, lease, deadline, early?.state ?? null);
1384
+ } catch (error) {
1385
+ if ((await claimed).error === null) throw error;
1386
+ }
1387
+ const settled = await claimed;
1388
+ if (settled.error === null) return outcome;
1389
+ // A claim that failed counts for nothing, whatever its turn did: it
1390
+ // wrote nothing and showed nothing. A fenced one leaves the delivery
1391
+ // to read the log and try again; after any other failure the same
1392
+ // message runs the session again shortly, once a lease that landed is
1393
+ // let go.
1394
+ if (isFenced(settled.error)) return "refused";
1395
+ this.agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(settled.error) });
1396
+ if (settled.head !== null) await log.append({ k: "release", a: settled.head, holder: this.holder }).catch(() => {});
1397
+ return "failed";
1398
+ } finally {
1399
+ this.backend.holding(this.holder, false);
1400
+ this.running = false;
1401
+ }
1402
+ }
1403
+
1404
+ // A resume with nothing to continue still answers its viewer.
1405
+ async answerIdle(log, message) {
1406
+ const ui = uiWriter(log, (error) => this.agent.emit("ui.error", { sessionId: this.sessionId, error: error?.name ?? "Error" }));
1407
+ ui.push({ type: "idle", requestId: message.messageId });
1408
+ await ui.settle();
1409
+ }
1410
+
1411
+ // Runs turns under the lease `claimed` settles with, `{ head, error }`,
1412
+ // until the log holds no work, the time runs out, or another worker takes
1413
+ // over. With `early`, the state the session was read in, the first turn
1414
+ // starts before the claim lands: every write waits for it, and once it
1415
+ // failed nothing more is written and the turn stops. The caller answers
1416
+ // for a claim that failed.
1417
+ async run(raw, claimed, lease, deadline, early) {
1418
+ const agent = this.agent;
1419
+ let head = null;
1420
+ let failed = null;
1421
+ let claimFailed = false;
1422
+ let released = false;
1423
+ // Whether the claim landed.
1424
+ const ready = claimed.then((claim) => {
1425
+ head = claim.head;
1426
+ if (claim.error === null) return true;
1427
+ failed = claim.error;
1428
+ claimFailed = true;
1429
+ return false;
1430
+ });
1431
+ const log = gatedLog(raw, ready, () => failed);
1432
+ let chain = ready;
1433
+ // The turns a record this worker stored started, and the open turn's
1434
+ // cut-offs as this worker's own records leave them.
1435
+ const started = new Set();
1436
+ let cut = early?.cut ?? noCutoffs;
1437
+ // Every chained entry, the harness's records included, continues the
1438
+ // newest one this worker wrote.
1439
+ const chained = (entry) => {
1440
+ if (entry.k === "record") cut = afterRecord(cut, entry.marks);
1441
+ const appended = chain.then(async () => {
1442
+ if (failed) throw failed;
1443
+ try {
1444
+ head = await log.append({ ...entry, a: head });
1445
+ for (const mark of entry.marks ?? []) if (typeof mark.start === "string") started.add(mark.start);
1446
+ return head;
1447
+ } catch (error) {
1448
+ failed = error;
1449
+ throw error;
1450
+ }
1451
+ });
1452
+ chain = appended.catch(() => {});
1453
+ return appended;
1454
+ };
1455
+ // A release after the deadline cut off a turn's step names that turn.
1456
+ const release = async (cutoff = null) => {
1457
+ await chain;
1458
+ if (failed || released) return;
1459
+ released = true;
1460
+ await chained({ k: "release", holder: this.holder, ...(cutoff ? { cutoff } : {}) }).catch(() => {});
1461
+ };
1462
+ // Why the chain broke: another worker's write fenced this one out, or a
1463
+ // write failed for another reason, which the same message retries.
1464
+ const broken = () => {
1465
+ if (isFenced(failed)) return "fenced";
1466
+ if (!claimFailed) agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(failed) });
1467
+ return "failed";
1468
+ };
1469
+ let state = early;
1470
+ if (!state) {
1471
+ if (!(await ready)) return broken();
1472
+ state = this.fold(await log.read());
1473
+ if (state.lastLease?.holder !== this.holder) return "fenced";
1474
+ cut = state.cut;
1475
+ }
1476
+ const ui = uiWriter(log, (error) => agent.emit("ui.error", { sessionId: this.sessionId, error: error?.name ?? "Error" }), lease.epoch);
1477
+ // When the lease runs out, as this worker last renewed it.
1478
+ let leaseExpiresAt = lease.expiresAt;
1479
+ const store = {
1480
+ load: async () => ({
1481
+ ...(state.checkpoint?.data ? { checkpoint: { through: state.checkpoint.through ?? "0", data: fromBase64(state.checkpoint.data) } } : {}),
1482
+ journal: state.records.map((record) => ({ cursor: record.cursor, data: fromBase64(record.data) })),
1483
+ // Until the claim lands, the chain where the worker read it.
1484
+ head: head ?? state.head,
1485
+ }),
1486
+ append: ({ idempotencyKey, data, marks }) => chained({ k: "record", key: idempotencyKey, data: toBase64(data), marks }).then((cursor) => ({ cursor })),
1487
+ saveCheckpoint: async ({ through, data }) => {
1488
+ // What a fold that starts at this checkpoint would otherwise miss:
1489
+ // inputs before it that no turn took or that the open turn holds
1490
+ // unplaced, the ids it ran, the turn a yield left open, and the lease.
1491
+ const now = this.fold(await log.read());
1492
+ // In log order, so a fold from here orders turns as one from the start.
1493
+ const kept = [...now.unconsumed, ...now.held].filter((input) => input.cursor <= Number(through)).sort((a, b) => a.cursor - b.cursor);
1494
+ await log.append({
1495
+ k: "checkpoint",
1496
+ through,
1497
+ data: toBase64(data),
1498
+ pending: kept,
1499
+ ...(now.heldIds.length > 0 ? { held: now.heldIds } : {}),
1500
+ recent: now.recent,
1501
+ recentSteers: now.recentSteers,
1502
+ lastTurnId: now.lastTurnId,
1503
+ ...(now.agent === null ? {} : { agent: now.agent }),
1504
+ ...(now.openTurn ? { openTurn: now.openTurn, yielded: now.yielded } : {}),
1505
+ lease: { holder: this.holder, epoch: lease.epoch, expiresAt: leaseExpiresAt, pid: lease.pid, host: lease.host, ...(lease.delivery ? { delivery: lease.delivery } : {}) },
1506
+ });
1507
+ },
1508
+ };
1509
+ // The turn running now, which a deadline or a lost lease cancels.
1510
+ let turnNow = null;
1511
+ let renew = null;
1512
+ if (!heldToDeadline(this.backend, deadline)) {
1513
+ // A lease nothing else shows alive is renewed while its worker runs,
1514
+ // and never after its release, which would take the session back. A
1515
+ // renewal fenced out by another worker's write stops the turn at once,
1516
+ // as a claim that failed does.
1517
+ renew = setInterval(() => {
1518
+ if (released || failed || this.stopping) return;
1519
+ leaseExpiresAt = leaseEnd(this.backend, deadline);
1520
+ void chained({ k: "lease", holder: this.holder, epoch: lease.epoch, expiresAt: leaseExpiresAt, ...this.backend.holderInfo(), ...(lease.delivery ? { delivery: lease.delivery } : {}) })
1521
+ .catch((error) => { if (isFenced(error)) turnNow?.cancel({ reason: "handoff" }); });
1522
+ }, Math.max(1, Math.floor(this.backend.leaseMs / 3)));
1523
+ }
1524
+ // Shortly before the deadline the worker hands the open turn back to the
1525
+ // log, cutting off whatever call is still running, and the same message
1526
+ // continues the turn in a new delivery. Half the reserve, at most
1527
+ // hardStopMs; a reserve of zero never stops a step.
1528
+ const stopMargin = deadline === null ? 0 : Math.min(hardStopMs, Math.floor(this.backend.reserveMs / 2));
1529
+ this.stopping = false;
1530
+ this.stopNow = () => {
1531
+ if (this.stopping) return;
1532
+ this.stopping = true;
1533
+ // Which cut-off in a row of the same step this is.
1534
+ agent.emit("session.deadline", { sessionId: this.sessionId, epoch: lease.epoch, cutoffs: afterCutoff(cut).cutoffs });
1535
+ turnNow?.cancel({ reason: "handoff" });
1536
+ };
1537
+ // A turn that started under a claim that then failed stops at once and
1538
+ // stores nothing.
1539
+ void ready.then((landed) => { if (!landed) turnNow?.cancel({ reason: "handoff" }); });
1540
+ const hardStop = stopMargin > 0 && deadline - stopMargin > Date.now()
1541
+ ? setTimeout(this.stopNow, deadline - stopMargin - Date.now())
1542
+ : null;
1543
+ let harness = null;
1544
+ let harnessRun = runOf(null);
1545
+ this.settled = () => (harness ? harness.settled() : Promise.resolve());
1546
+ // A harness that throws instead of returning a turn cannot run one; the
1547
+ // same message runs the session again with a new harness.
1548
+ const unusable = (error) => {
1549
+ agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(error) });
1550
+ return "failed";
1551
+ };
1552
+ try {
1553
+ if (state.halted) {
1554
+ // Even cancelling the open turn stopped its engine, so the session
1555
+ // can run no more turns. The turn and every prompt waiting end with
1556
+ // an error, without starting an engine.
1557
+ const id = state.openTurn.id;
1558
+ if (!state.failed.has(id)) {
1559
+ const error = { name: "Error", message: "the engine stopped each time it continued this turn, even to cancel it" };
1560
+ await log.append({ k: "failed", messageId: id, error });
1561
+ ui.push({ type: "turn_end", messageId: id, stopReason: "error", usage: {}, error });
1562
+ }
1563
+ for (const waiting of state.pending) {
1564
+ await log.append({ k: "ended", messageId: waiting.messageId });
1565
+ ui.push({ type: "turn_end", messageId: waiting.messageId, stopReason: "error", usage: {}, error: { name: "Error", message: "the session's open turn cannot continue" } });
1566
+ }
1567
+ await ui.settle();
1568
+ return "done";
1569
+ }
1570
+ try {
1571
+ // What the turn it runs first runs with: the open turn's context and
1572
+ // settings, or the next prompt's.
1573
+ harnessRun = runOf(state.openTurn ?? state.pending[0]);
1574
+ harness = await agent.openHarness(this.sessionId, store, harnessRun, ready);
1575
+ } catch (error) {
1576
+ // A harness that cannot start fails the turn waiting on it, once:
1577
+ // the open turn, or after it failed, the next prompt.
1578
+ const waiting = state.openTurn && !state.openFailed ? state.openTurn.id : state.pending[0]?.messageId;
1579
+ if (waiting) {
1580
+ const summary = errorSummary(error);
1581
+ await log.append({ k: "failed", messageId: waiting, error: summary });
1582
+ ui.push({ type: "turn_end", messageId: waiting, stopReason: "error", usage: {}, error: summary });
1583
+ }
1584
+ agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(error) });
1585
+ return "done";
1586
+ }
1587
+ const yieldAt = deadline === null ? undefined : Math.max(1, deadline - this.backend.reserveMs);
1588
+ for (;;) {
1589
+ if (failed) return broken();
1590
+ if (this.stopping) {
1591
+ await harness.close();
1592
+ harness = null;
1593
+ await release();
1594
+ // A worker replaced without knowing it learns so at the release.
1595
+ return failed ? broken() : "yielded";
1596
+ }
1597
+ const open = harness.openTurn;
1598
+ let turn = null;
1599
+ let messageId = null;
1600
+ let fresh = false;
1601
+ let spent = false;
1602
+ if (open?.id) {
1603
+ // A call left running reruns only when running it twice is safe.
1604
+ // Any other call is never run again: the model is told it may have
1605
+ // partly run, and the turn goes on.
1606
+ messageId = open.id;
1607
+ ui.push({ type: "turn_resume", messageId, sessionId: this.sessionId });
1608
+ // It lands before the turn goes on, so any line the replaced
1609
+ // worker writes after it ranks below it and stays hidden.
1610
+ await ui.settle();
1611
+ // A step the deadline cut off maxCutoffs times in a row would be
1612
+ // cut off forever. A cut-off call is not run again, so the model
1613
+ // hears it may have partly run; a model request ends the turn,
1614
+ // cancelled, as a turn whose engine keeps stopping does.
1615
+ spent = state.openTurn?.id === messageId && state.cutoffs >= maxCutoffs;
1616
+ try {
1617
+ turn = harness.resume({ yieldAt, ...(spent ? { rerun: false } : {}) });
1618
+ } catch (error) {
1619
+ await log.append({ k: "stopped", messageId });
1620
+ return unusable(error);
1621
+ }
1622
+ }
1623
+ if (!turn) {
1624
+ const next = state.pending[0];
1625
+ if (!next) break;
1626
+ messageId = next.messageId;
1627
+ if ((state.stops.get(messageId) ?? 0) >= maxHarnessStops) {
1628
+ // An engine that stops each time it starts this turn would hold
1629
+ // the session forever; the turn ends with an error instead.
1630
+ await log.append({ k: "ended", messageId });
1631
+ ui.push({ type: "turn_end", messageId, stopReason: "error", usage: {}, error: { name: "Error", message: `the engine stopped each of the ${maxHarnessStops} times it started this turn` } });
1632
+ await ui.settle();
1633
+ state = this.fold(await log.read());
1634
+ continue;
1635
+ }
1636
+ if (next.cancelled) {
1637
+ // Its own cancel came before it started, so it never runs.
1638
+ await log.append({ k: "ended", messageId });
1639
+ ui.push({ type: "turn_end", messageId, stopReason: "cancelled", usage: {} });
1640
+ await ui.settle();
1641
+ state = this.fold(await log.read());
1642
+ continue;
1643
+ }
1644
+ // Each prompt runs with the context its caller gave and its session
1645
+ // object's settings.
1646
+ const wanted = runOf(next);
1647
+ if (JSON.stringify(wanted) !== JSON.stringify(harnessRun)) {
1648
+ await harness.close();
1649
+ harness = null;
1650
+ harness = await agent.openHarness(this.sessionId, store, wanted, ready);
1651
+ harnessRun = wanted;
1652
+ }
1653
+ try {
1654
+ turn = harness.prompt(next.input ?? "", { turnId: messageId, yieldAt });
1655
+ } catch (error) {
1656
+ await log.append({ k: "stopped", messageId });
1657
+ return unusable(error);
1658
+ }
1659
+ ui.push({ type: "turn_start", messageId, sessionId: this.sessionId, ...(typeof next.input === "string" ? { input: next.input } : {}) });
1660
+ fresh = true;
1661
+ }
1662
+ turnNow = turn;
1663
+ if (this.stopping || claimFailed) turn.cancel({ reason: "handoff" });
1664
+ else if (spent && !(open.calls?.length > 0)) turn.cancel();
1665
+ // A new turn takes the steers sent while it waited to start.
1666
+ const joining = fresh ? { ...state, steers: state.pending[0]?.joiners ?? [], endOpen: state.pending[0]?.ends === true } : state;
1667
+ const outcome = await this.drive(log, harness, turn, messageId, joining, ui);
1668
+ turnNow = null;
1669
+ if (outcome === "fenced") return "fenced";
1670
+ if (outcome === "yielded") {
1671
+ // Commit, checkpoint, release: the next delivery loads the
1672
+ // checkpoint and the few records after it. A turn handed back at
1673
+ // the deadline stored nothing more, so it has none to save.
1674
+ await harness.saveCheckpoint();
1675
+ await harness.close();
1676
+ harness = null;
1677
+ await release(this.stopping ? messageId : null);
1678
+ return failed ? broken() : "yielded";
1679
+ }
1680
+ if (outcome.stopped) {
1681
+ // Closing the engine waits for the records it still had in flight,
1682
+ // so the log shows how far the turn got.
1683
+ await harness.close().catch(() => {});
1684
+ harness = null;
1685
+ await chain;
1686
+ if (failed) return broken();
1687
+ state = this.fold(await log.read());
1688
+ if (state.openTurn?.id !== messageId && state.consumed.has(messageId)) {
1689
+ // Its end landed before its engine stopped, so it ended, though
1690
+ // how is unknown.
1691
+ ui.push({ type: "turn_end", messageId, stopReason: "unknown", usage: {} });
1692
+ await ui.settle();
1693
+ } else {
1694
+ await log.append({ k: "stopped", messageId });
1695
+ }
1696
+ return unusable(outcome.stopped);
1697
+ }
1698
+ const { result } = outcome;
1699
+ if (fresh && !failed && !started.has(messageId)) {
1700
+ // A turn that ended before writing a record, such as a prompt the
1701
+ // harness refused or one a cancel stopped first, is done too. The
1702
+ // log says so before its viewers hear it ended, so a retry that
1703
+ // reads the log finds it ended.
1704
+ state = this.fold(await log.read());
1705
+ if (!state.consumed.has(messageId)) await log.append({ k: "ended", messageId });
1706
+ }
1707
+ // The turn's records have landed, so its viewers hear it ended now;
1708
+ // the log is read after, for what comes next.
1709
+ ui.push({ type: "turn_end", messageId, stopReason: result.stopReason, usage: result.usage ?? {}, ...(result.error ? { error: result.error } : {}) });
1710
+ await ui.settle();
1711
+ state = this.fold(await log.read());
1712
+ if (!fresh && !failed && harness.openTurn?.id === messageId) {
1713
+ // A harness that leaves a turn open after it ended would run it
1714
+ // again forever; the worker stops instead.
1715
+ agent.emit("session.error", { sessionId: this.sessionId, error: { name: "Error", message: `turn ${messageId} ended but stayed open` } });
1716
+ break;
1717
+ }
1718
+ if (state.lastLease?.holder !== this.holder && !failed) return "fenced";
1719
+ }
1720
+ if (failed) {
1721
+ await harness.close();
1722
+ harness = null;
1723
+ return broken();
1724
+ }
1725
+ // The last turn's records and end have landed, so the worker lets go
1726
+ // before its engine shuts down: a prompt that lands meanwhile starts at
1727
+ // once, here or in another process, instead of waiting for the
1728
+ // shutdown. The engine stores nothing more; the agent waits for it on
1729
+ // close.
1730
+ await release();
1731
+ agent.track(harness.close().catch(() => {}));
1732
+ harness = null;
1733
+ return failed ? broken() : "done";
1734
+ } finally {
1735
+ if (hardStop) clearTimeout(hardStop);
1736
+ this.stopNow = null;
1737
+ this.settled = null;
1738
+ if (renew) clearInterval(renew);
1739
+ if (harness) await harness.close().catch(() => {});
1740
+ await ui.settle().catch(() => {});
1741
+ // A worker that stops on an error still lets the next one in. One
1742
+ // whose write failed before its release releases outside its broken
1743
+ // chain, so the retry need not wait out the lease; a fenced one holds
1744
+ // nothing to release.
1745
+ if (!failed && !released) await release().catch(() => {});
1746
+ else if (failed && !isFenced(failed) && !released) {
1747
+ released = true;
1748
+ await log.append({ k: "release", a: head, holder: this.holder }).catch(() => {});
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ // Streams one turn to the session's viewers and applies the steers and
1754
+ // cancels the log receives while it runs. Returns "fenced", "yielded",
1755
+ // `{ stopped }` when its harness stopped under it, or `{ result }` for a
1756
+ // turn that ended, whose end the caller writes.
1757
+ async drive(log, harness, turn, messageId, state, ui) {
1758
+ const applied = new Set();
1759
+ let since = String(state.maxCursor);
1760
+ let polling = false;
1761
+ let again = false;
1762
+ let stopped = false;
1763
+ // A text steer joins the turn. Any other ends it at its next safe point
1764
+ // and, never taken, runs as the next turn, as the fx CLI hands off; the
1765
+ // steers after that one join its turn.
1766
+ let ending = false;
1767
+ const endTurn = () => {
1768
+ if (ending) return;
1769
+ ending = true;
1770
+ void Promise.resolve().then(() => turn.endForSteer?.()).catch(() => {});
1771
+ };
1772
+ const steerTurn = (steer) => {
1773
+ if (!joinable(steer.input)) endTurn();
1774
+ else if (!ending) void turn.steer(steer.input, steer.messageId).catch(() => {});
1775
+ };
1776
+ const apply = async () => {
1777
+ if (stopped) return;
1778
+ if (polling) { again = true; return; }
1779
+ polling = true;
1780
+ try {
1781
+ do {
1782
+ again = false;
1783
+ const entries = await log.readSince(since);
1784
+ for (const { cursor, entry } of entries) {
1785
+ if (Number(cursor) > Number(since)) since = cursor;
1786
+ if (entry?.k !== "input" || applied.has(entry.key)) continue;
1787
+ applied.add(entry.key);
1788
+ if (entry.type === "steer") steerTurn(entry);
1789
+ else if (entry.type === "cancel" && (entry.target === undefined || entry.target === messageId)) turn.cancel();
1790
+ }
1791
+ } while (again && !stopped);
1792
+ } catch {} finally {
1793
+ polling = false;
1794
+ }
1795
+ };
1796
+ for (const steer of state.steers) {
1797
+ applied.add(steer.key);
1798
+ steerTurn(steer);
1799
+ }
1800
+ if (state.endOpen) endTurn();
1801
+ // An open turn whose engine kept stopping is cancelled the way a user
1802
+ // would, so the prompts behind it can run.
1803
+ if (state.cancelOpen || (state.stuck && state.openTurn?.id === messageId)) turn.cancel();
1804
+ const unwatch = log.watch?.(() => { void apply(); });
1805
+ const timer = this.backend.pollMs ? setInterval(() => { void apply(); }, this.backend.pollMs) : null;
1806
+ void apply();
1807
+ let result;
1808
+ try {
1809
+ for await (const event of turn) ui.push({ ...event, messageId });
1810
+ result = await turn.result;
1811
+ } catch (error) {
1812
+ if (isFenced(error)) return "fenced";
1813
+ // The harness stopped under the turn; the next delivery continues it
1814
+ // with a new one.
1815
+ if (error?.code === "FX_HARNESS_STOPPED") return { stopped: error };
1816
+ result = { stopReason: "error", error: errorSummary(error) };
1817
+ } finally {
1818
+ stopped = true;
1819
+ unwatch?.();
1820
+ if (timer) clearInterval(timer);
1821
+ }
1822
+ // Viewers hear a turn ended only once the log says so too, so a retry
1823
+ // that reads the log finds it ended.
1824
+ try {
1825
+ await harness.settled();
1826
+ } catch (error) {
1827
+ if (isFenced(error)) return "fenced";
1828
+ throw error;
1829
+ }
1830
+ // A turn handed back at the deadline continues in the next delivery,
1831
+ // like one that yielded.
1832
+ if (result.stopReason === "yielded" || (this.stopping && result.stopReason === "cancelled")) {
1833
+ ui.push({ type: "turn_yield", messageId });
1834
+ await ui.settle();
1835
+ return "yielded";
1836
+ }
1837
+ return { result };
1838
+ }
1839
+ }
1840
+
1841
+ // ---------------------------------------------------------------------------
1842
+ // The public API.
1843
+
1844
+ const internalEvent = new Set(["turn_start", "turn_resume", "turn_yield", "turn_end", "idle"]);
1845
+
1846
+ // Which UI lines a reader shows: each line no earlier line outranks. A
1847
+ // replaced worker's lines carry its older lease epoch, so once its successor
1848
+ // has written, they stay hidden. The same stored stream always shows the
1849
+ // same lines, from any server and after any refresh.
1850
+ function uiRanker(high = 0) {
1851
+ return (event) => {
1852
+ if (!Number.isSafeInteger(event?.epoch)) return true;
1853
+ if (event.epoch < high) return false;
1854
+ high = event.epoch;
1855
+ return true;
1856
+ };
1857
+ }
1858
+
1859
+ // The ranker as it stands after the first `count` lines of the stream.
1860
+ async function uiRankerAt(log, count) {
1861
+ const shows = uiRanker();
1862
+ if (count === 0) return shows;
1863
+ const reader = log.ui.read(0).getReader();
1864
+ let buffered = "";
1865
+ let seen = 0;
1866
+ try {
1867
+ while (seen < count) {
1868
+ const { value, done } = await reader.read();
1869
+ if (done) break;
1870
+ buffered += decoder.decode(value, { stream: true });
1871
+ for (let index = buffered.indexOf("\n"); index >= 0 && seen < count; index = buffered.indexOf("\n")) {
1872
+ const line = buffered.slice(0, index);
1873
+ buffered = buffered.slice(index + 1);
1874
+ seen += 1;
1875
+ try { shows(JSON.parse(line)); } catch {}
1876
+ }
1877
+ }
1878
+ } finally {
1879
+ void reader.cancel().catch(() => {});
1880
+ }
1881
+ return shows;
1882
+ }
1883
+
1884
+ // What a retried `messageId` finds in its session: its turn still waiting or
1885
+ // running (`follow`), no trace of it (`fresh`), a turn it already ran,
1886
+ // settled from that turn's end on the stream before `from` or from the
1887
+ // log's record of its failure, or `unsure` when the log says it ran but
1888
+ // neither shows how. Once this call has `sent` the message, a recorded
1889
+ // failure may be this send's own run, so it is not called a repeat.
1890
+ async function retryOutcome(log, state, messageId, from, { sent }) {
1891
+ const waiting = state.inputKeys.has(`prompt:${messageId}`) && !state.consumed.has(messageId);
1892
+ if ((state.openTurn?.id === messageId && !state.failed.has(messageId)) || waiting) return { follow: true };
1893
+ if (!state.consumed.has(messageId)) return { fresh: true };
1894
+ const ended = await turnEndIn(log, messageId, from);
1895
+ if (ended) return { settled: { ...ended, repeated: true } };
1896
+ const error = state.failed.get(messageId);
1897
+ if (error) return { settled: { messageId, stopReason: "error", error, ...(sent ? {} : { repeated: true }) } };
1898
+ return { unsure: true };
1899
+ }
1900
+
1901
+ // The end a turn's worker wrote, among the first `count` lines of the
1902
+ // stream, as a reader shows them; null when none did.
1903
+ async function turnEndIn(log, messageId, count) {
1904
+ if (count === 0) return null;
1905
+ const shows = uiRanker();
1906
+ const reader = log.ui.read(0).getReader();
1907
+ let buffered = "";
1908
+ let seen = 0;
1909
+ let found = null;
1910
+ try {
1911
+ while (seen < count) {
1912
+ const { value, done } = await reader.read();
1913
+ if (done) break;
1914
+ buffered += decoder.decode(value, { stream: true });
1915
+ for (let index = buffered.indexOf("\n"); index >= 0 && seen < count; index = buffered.indexOf("\n")) {
1916
+ const line = buffered.slice(0, index);
1917
+ buffered = buffered.slice(index + 1);
1918
+ seen += 1;
1919
+ let event;
1920
+ try { event = JSON.parse(line); } catch { continue; }
1921
+ if (shows(event) && event.type === "turn_end" && event.messageId === messageId) found = event;
1922
+ }
1923
+ }
1924
+ } finally {
1925
+ void reader.cancel().catch(() => {});
1926
+ }
1927
+ if (!found) return null;
1928
+ const { type: _type, ...rest } = found;
1929
+ return rest;
1930
+ }
1931
+
1932
+ // How long a retried turn waits for an end the log already holds.
1933
+ const retryEndWaitMs = 30_000;
1934
+
1935
+ // One turn as its viewer sees it: the session stream from `from` on, its
1936
+ // events only, until its end. `start` queues the turn at once and returns
1937
+ // the stream; a resume learns its turn from the first one the stream shows.
1938
+ // The stream is read only once something reads the view, its events,
1939
+ // `readable` or `result`, so a prompt nobody follows holds no read open.
1940
+ function turnView({ messageId, resumeRequest = null, start }) {
1941
+ // A retried turn reports the outcome of the turn its id already ran.
1942
+ let repeated = false;
1943
+ const events = [];
1944
+ const waiters = [];
1945
+ let done = false;
1946
+ let failure = null;
1947
+ let id = resumeRequest ? null : messageId;
1948
+ let settle;
1949
+ const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
1950
+ void result.catch(() => {});
1951
+ const wake = () => { for (const waiter of waiters.splice(0)) waiter(); };
1952
+ const finish = (value, error) => {
1953
+ if (done) return;
1954
+ done = true;
1955
+ if (error) {
1956
+ failure = error;
1957
+ settle.reject(error);
1958
+ } else settle.resolve(repeated ? { ...value, repeated: true } : value);
1959
+ wake();
1960
+ };
1961
+ const started = start();
1962
+ void started.catch(() => {});
1963
+ let following = false;
1964
+ const follow = () => {
1965
+ if (following) return;
1966
+ following = true;
1967
+ void read();
1968
+ };
1969
+ const read = async () => {
1970
+ try {
1971
+ const { log, from, settled, attaching, endWithinMs, unsure } = await started;
1972
+ // A turn whose end this view finds after its own start may be the
1973
+ // message's first run, so only an end found before it is a repeat.
1974
+ repeated = settled?.repeated === true || (endWithinMs !== undefined && !unsure);
1975
+ if (settled) {
1976
+ // A turn that already ended still tells a viewer how, so a route
1977
+ // returning `readable` sends its outcome.
1978
+ const { messageId: _id, ...rest } = settled;
1979
+ events.push({ type: "turn_end", messageId, ...rest });
1980
+ return finish(settled);
1981
+ }
1982
+ // A view of a turn already running ranks from what came before it;
1983
+ // a new turn's takeovers all come after its own start.
1984
+ const shows = attaching ? await uiRankerAt(log, from) : uiRanker();
1985
+ const reader = log.ui.read(from).getReader();
1986
+ // A turn whose end the log holds but no line shows yet: a worker that
1987
+ // stopped between the two never writes it, so the outcome is unknown.
1988
+ let gaveUp = null;
1989
+ if (endWithinMs !== undefined) {
1990
+ gaveUp = setTimeout(() => {
1991
+ void reader.cancel().catch(() => {});
1992
+ finish({ messageId, stopReason: "unknown", usage: {} });
1993
+ }, endWithinMs);
1994
+ }
1995
+ let buffered = "";
1996
+ let cursor = from;
1997
+ for (;;) {
1998
+ const { value, done: ended } = await reader.read();
1999
+ if (ended) break;
2000
+ buffered += decoder.decode(value, { stream: true });
2001
+ for (let index = buffered.indexOf("\n"); index >= 0; index = buffered.indexOf("\n")) {
2002
+ const line = buffered.slice(0, index);
2003
+ buffered = buffered.slice(index + 1);
2004
+ cursor += 1;
2005
+ let event;
2006
+ try { event = JSON.parse(line); } catch { continue; }
2007
+ if (!shows(event)) continue;
2008
+ if (id === null) {
2009
+ if (event.type === "idle" && event.requestId === resumeRequest) {
2010
+ void reader.cancel().catch(() => {});
2011
+ return finish({ stopReason: "idle", usage: {} });
2012
+ }
2013
+ if (event.type !== "turn_resume" && event.type !== "turn_start") continue;
2014
+ id = event.messageId;
2015
+ }
2016
+ if (event.messageId !== id) continue;
2017
+ events.push({ ...event, cursor });
2018
+ wake();
2019
+ if (event.type === "turn_end") {
2020
+ if (gaveUp) clearTimeout(gaveUp);
2021
+ void reader.cancel().catch(() => {});
2022
+ const { type: _type, messageId: _id, ...rest } = event;
2023
+ return finish({ messageId: id, ...rest });
2024
+ }
2025
+ }
2026
+ }
2027
+ if (gaveUp) clearTimeout(gaveUp);
2028
+ finish(null, new Error("the session stream ended before the turn did"));
2029
+ } catch (error) {
2030
+ finish(null, error);
2031
+ }
2032
+ };
2033
+ async function* iterate(raw) {
2034
+ for (let index = 0; ; index += 1) {
2035
+ while (index >= events.length) {
2036
+ if (done) {
2037
+ if (failure) throw failure;
2038
+ return;
2039
+ }
2040
+ await new Promise((resolve) => waiters.push(resolve));
2041
+ }
2042
+ const event = events[index];
2043
+ if (raw) yield event;
2044
+ else if (!internalEvent.has(event.type)) {
2045
+ const { messageId: _id, cursor: _cursor, ...rest } = event;
2046
+ yield rest;
2047
+ }
2048
+ }
2049
+ }
2050
+ return {
2051
+ get messageId() { return id; },
2052
+ get result() {
2053
+ follow();
2054
+ return result;
2055
+ },
2056
+ // Dropping a turn cancels nothing; `session.cancel()` does.
2057
+ [Symbol.asyncIterator]() {
2058
+ follow();
2059
+ return iterate(false);
2060
+ },
2061
+ // Each line is one event with the cursor `stream()` resumes after it.
2062
+ get readable() {
2063
+ follow();
2064
+ const iterator = iterate(true);
2065
+ return new ReadableStream({
2066
+ async pull(controller) {
2067
+ try {
2068
+ const { value, done: ended } = await iterator.next();
2069
+ if (ended) controller.close();
2070
+ else controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
2071
+ } catch (error) {
2072
+ controller.error(error);
2073
+ }
2074
+ },
2075
+ cancel() { void iterator.return?.(); },
2076
+ });
2077
+ },
2078
+ };
2079
+ }
2080
+
2081
+ /**
2082
+ * Builds a durable agent over a harness: the agent loop that runs a turn.
2083
+ * The core owns the session's log, its lease, the queue, deadlines and the
2084
+ * UI stream; the harness owns turns and what its records mean.
2085
+ *
2086
+ * `harness(options)` receives the caller's options without `durability` and
2087
+ * returns:
2088
+ *
2089
+ * - `open({ sessionId, store, context, settings, durability, ready })`: the
2090
+ * harness session for one worker, for turns with that `context` and those
2091
+ * `settings`. `store` is where it keeps the session: `load()` resolves
2092
+ * `{ checkpoint?, journal, head }`, `append({ idempotencyKey, data, marks })`
2093
+ * stores one opaque record durably and resolves `{ cursor }`, and
2094
+ * `saveCheckpoint({ through, data })` stores what the records through
2095
+ * `through` add up to. Each record's `marks` tell the core where turns
2096
+ * start (`{ start: turnId }`), yield (`{ yield: true }`) and end
2097
+ * (`{ end: true }`), which inputs a turn took (`{ accepted: id }`), when
2098
+ * its model request first carries one (`{ placed: id }`), and which it
2099
+ * ended without placing (`{ withdrawn: id }`), which wait for a turn again.
2100
+ * A write another worker's write fenced out rejects with a `FX_FENCED`
2101
+ * code. `ready`, when given, resolves true once the worker's claim on the
2102
+ * session lands and false when it failed: the core may start a turn
2103
+ * before then, and the harness runs no tool until it resolves true. The
2104
+ * core holds the store's writes and the turn's UI lines meanwhile.
2105
+ * - `seed` (optional): the bytes of a checkpoint a new session starts from.
2106
+ * - `settings(sessionOptions)` (optional): what an `agent.session()` call
2107
+ * sets over the agent's options for the turns it starts, as JSON the core
2108
+ * stores with each prompt and passes to `open()`, or undefined for none.
2109
+ * It throws a TypeError for options it refuses.
2110
+ *
2111
+ * A harness session has `prompt(input, { turnId, yieldAt })` and
2112
+ * `resume({ yieldAt, rerun })`, each returning a turn, where `rerun: false`
2113
+ * runs no call left running again; `openTurn`, the turn its records left
2114
+ * open as `{ id, calls? }`, with the calls it left running, or null;
2115
+ * `settled()`, which resolves once
2116
+ * every record it started is stored; `saveCheckpoint()`;
2117
+ * `exportCheckpoint()`, the conversation as bytes; and `close()`. A turn is
2118
+ * an async iterable of the events its viewers see, with `result` resolving
2119
+ * `{ stopReason, usage? }`, `steer(text, id)`, and `cancel(options)`, where
2120
+ * `{ reason: "handoff" }` stops it at once and stores nothing more, so the
2121
+ * next worker continues it. A turn may have `endForSteer()`, which ends it
2122
+ * at its next safe point, at once while its model streams a reply, for a
2123
+ * steer that cannot join it; without it, such a steer waits for the turn to
2124
+ * end. Either way the steer runs as the next turn. A turn stops before a
2125
+ * model request once `yieldAt` has passed, with the stop reason `yielded`.
2126
+ *
2127
+ * An input the harness refuses is a turn that ends with an error.
2128
+ * `prompt()` and `resume()` throw only when the harness cannot run a turn,
2129
+ * and a turn whose harness stopped under it rejects with a
2130
+ * `FX_HARNESS_STOPPED` code; either way the same message runs the session
2131
+ * again with a new harness. After 3 stops a turn is resumed and cancelled at
2132
+ * once, or ends with an error when it never started; when its harness stops
2133
+ * twice more, even to cancel it, the session runs no more turns.
2134
+ *
2135
+ * `defaultDurability()` picks the durability when the caller names none,
2136
+ * `name` is the factory's name in errors, and `label` the agent's.
2137
+ */
2138
+ export function createDurableAgentFactory({ harness, defaultDurability, name = "createAgent", label = "agent" }) {
2139
+ return function createDurableAgent(options = {}) {
2140
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
2141
+ throw new TypeError(`${name}() options must be an object`);
2142
+ }
2143
+ const { durability, name: agentName = defaultAgentName, ...harnessOptions } = options;
2144
+ if (durability !== undefined && !isDurability(durability)) {
2145
+ throw new TypeError("durability must come from memory(), local(), vercel() or world()");
2146
+ }
2147
+ if (typeof agentName !== "string" || !agentNameRule.test(agentName)) {
2148
+ throw new TypeError(`${name}() name must be 1 to 64 letters, digits, ".", "_" or "-", starting with a letter or digit`);
2149
+ }
2150
+ const plugged = harness(harnessOptions);
2151
+ const seed = plugged.seed === undefined ? undefined : toBase64(plugged.seed);
2152
+ const emit = (type, detail = {}) => {
2153
+ try { options.onEvent?.({ type, timestamp: performance.now(), ...detail }); } catch {}
2154
+ };
2155
+ const agent = {
2156
+ name: agentName,
2157
+ emit,
2158
+ // Work that outlives the delivery that started it, which close() waits
2159
+ // for: an engine that shuts down after its worker let go.
2160
+ track(work) {
2161
+ active.add(work);
2162
+ void work.finally(() => active.delete(work));
2163
+ },
2164
+ openHarness: (sessionId, store, run, ready) => plugged.open({
2165
+ sessionId,
2166
+ store,
2167
+ context: run?.context ?? null,
2168
+ ...(run?.settings == null ? {} : { settings: run.settings }),
2169
+ durability: chosenDurability,
2170
+ ...(ready ? { ready } : {}),
2171
+ }),
2172
+ };
2173
+ let backendPromise = null;
2174
+ let durabilityPromise = null;
2175
+ let chosenDurability = null;
2176
+ let unlisten = null;
2177
+ let closed = false;
2178
+ const workers = new Map();
2179
+ const active = new Set();
2180
+ const backend = () => {
2181
+ if (closed) return Promise.reject(new Error(`${label} is closed`));
2182
+ backendPromise ??= (async () => {
2183
+ const chosen = await durabilityOf();
2184
+ chosenDurability = chosen;
2185
+ const created = await createBackend(chosen);
2186
+ await created.start?.();
2187
+ unlisten = created.listen((message, meta) => receive(created, message, deliveryOf(meta)), agentName);
2188
+ return created;
2189
+ })();
2190
+ return backendPromise;
2191
+ };
2192
+ // The durability the agent's sessions live in, chosen once.
2193
+ const durabilityOf = () => (durabilityPromise ??= durability ? Promise.resolve(durability) : defaultDurability());
2194
+ // In the process's agents from now, so a route that receives a message
2195
+ // for this agent finds it before anything uses it.
2196
+ const member = { open: backend, durability: durabilityOf };
2197
+ const registered = joinGroup(agentsByName, agentName, member);
2198
+ // One delivery of `message` to the session's worker here: from the
2199
+ // queue, with `delivery` naming the queue's message and attempt, or with
2200
+ // `inRequest` from the request that took the prompt.
2201
+ const receive = (created, message, delivery, { inRequest = false } = {}) => {
2202
+ let worker = workers.get(message.sessionId);
2203
+ if (!worker) {
2204
+ worker = new SessionWorker(agent, created, message.sessionId);
2205
+ workers.set(message.sessionId, worker);
2206
+ }
2207
+ worker.deliveries += 1;
2208
+ let work = worker.consume(message, delivery);
2209
+ if (inRequest) {
2210
+ // No queue delivered this message, so none delivers it again: a turn
2211
+ // that must go on later continues in a fresh message. One whose
2212
+ // write failed is left to the backstop, which takes its lease over.
2213
+ work = work.then(async (result) => {
2214
+ if (Number.isFinite(result?.timeoutSeconds)) {
2215
+ await created.queue.send(message, {
2216
+ idempotencyKey: `${message.sessionId}:${message.type}:${message.messageId}:${newId("again")}`,
2217
+ ...(result.timeoutSeconds > 0 ? { delaySeconds: result.timeoutSeconds } : {}),
2218
+ });
2219
+ }
2220
+ return result;
2221
+ });
2222
+ }
2223
+ active.add(work);
2224
+ void work.catch((error) => {
2225
+ // Its queue delivers the message again; the host hears why.
2226
+ emit("session.error", { sessionId: message.sessionId, error: errorSummary(error) });
2227
+ }).finally(() => {
2228
+ active.delete(work);
2229
+ worker.deliveries -= 1;
2230
+ // A worker lives while a delivery runs it.
2231
+ if (worker.deliveries === 0 && !worker.running && workers.get(message.sessionId) === worker) workers.delete(message.sessionId);
2232
+ });
2233
+ return work;
2234
+ };
2235
+
2236
+ const openSession = (requestedId, sessionOptions = {}) => {
2237
+ if (requestedId !== undefined && !validId(requestedId)) throw new TypeError(`session id must be ${idRule}`);
2238
+ const context = sessionOptions?.context;
2239
+ if (context !== undefined) {
2240
+ try { JSON.stringify(context); } catch { throw new TypeError("session context must be JSON"); }
2241
+ }
2242
+ const settings = plugged.settings?.(sessionOptions ?? {});
2243
+ let id = requestedId ?? null;
2244
+ let seeded = seed === undefined || requestedId !== undefined;
2245
+ const resolved = backend().then((created) => {
2246
+ id ??= created.newSessionId();
2247
+ if (!created.validSessionId(id)) throw new TypeError(`${created.name} durability cannot hold session id ${JSON.stringify(id)}`);
2248
+ return { created, id };
2249
+ });
2250
+ void resolved.catch(() => {});
2251
+ // With `inRequest`, a prompt runs here, in the request that took it,
2252
+ // where the durability allows (see `runInRequest`).
2253
+ const send = async (message, { inRequest = false } = {}) => {
2254
+ const { created, id: sessionId } = await resolved;
2255
+ const extra = {
2256
+ ...(seeded ? {} : { seed }),
2257
+ ...(context === undefined ? {} : { context }),
2258
+ ...(settings === undefined ? {} : { settings }),
2259
+ };
2260
+ const full = { ...message, ...extra, agent: agentName, sessionId };
2261
+ // A queue that ends with the process could lose the message, so its
2262
+ // input reaches the log before the caller hears it was accepted.
2263
+ if (!created.queueDurable) {
2264
+ const log = await created.session(sessionId);
2265
+ await recordMessage(log, foldSessionLog(await log.read()), full);
2266
+ }
2267
+ const idempotencyKey = `${sessionId}:${message.type}:${message.messageId}`;
2268
+ if (inRequest && created.runInRequest) {
2269
+ await created.refreshDeadline?.();
2270
+ const deadline = deliveryDeadline(created);
2271
+ if (deadline !== null) {
2272
+ // This request runs the turn, and the queue keeps the message as
2273
+ // a backstop it delivers only after the request's deadline, by
2274
+ // when the request has stopped. The lease names the backstop, so
2275
+ // if the request died, the backstop takes the session over and
2276
+ // continues the turn.
2277
+ const delaySeconds = Math.ceil(Math.max(0, deadline - Date.now()) / 1000) + inlineBackstopMarginSeconds;
2278
+ const { messageId: backstop } = await created.queue.send(full, { idempotencyKey, delaySeconds }) ?? {};
2279
+ seeded = true;
2280
+ const delivery = backstop ? { messageId: backstop, attempt: 0 } : null;
2281
+ // receive() reports a failure to the host; the backstop retries it.
2282
+ const work = receive(created, full, delivery, { inRequest: true }).then(() => {}, () => {});
2283
+ created.waitUntil?.(work);
2284
+ return;
2285
+ }
2286
+ }
2287
+ await created.queue.send(full, { idempotencyKey });
2288
+ seeded = true;
2289
+ };
2290
+ const startTurn = (type, messageId, input, retried = false, aborted = false) => {
2291
+ let accept;
2292
+ const accepted = new Promise((resolve, reject) => { accept = { resolve, reject }; });
2293
+ void accepted.catch(() => {});
2294
+ const view = turnView({
2295
+ messageId,
2296
+ resumeRequest: type === "resume" ? messageId : null,
2297
+ start: async () => {
2298
+ try {
2299
+ const { created, id: sessionId } = await resolved;
2300
+ const log = await created.session(sessionId);
2301
+ let attaching = type === "resume";
2302
+ let from = null;
2303
+ if (retried && !aborted) {
2304
+ // The viewer's start is read before the message is queued, so
2305
+ // every line this message's own turn writes comes after it,
2306
+ // and an end for this id before it can only be from a turn
2307
+ // this id already ran. The log, for that answer, is read
2308
+ // alongside the one queue send.
2309
+ from = await log.ui.length();
2310
+ const [state] = await Promise.all([
2311
+ log.read().then(foldSessionLog),
2312
+ send({ type, messageId, ...(input === undefined ? {} : { input }) }, { inRequest: type === "prompt" }).then(() => accept.resolve({ messageId, sessionId })),
2313
+ ]);
2314
+ // One the log does not know yet is this send's own. When the
2315
+ // log says it ran and nothing shows how, either this send's
2316
+ // own turn already ended, its end after `from`, or a turn
2317
+ // this id ran before never wrote its end.
2318
+ const outcome = await retryOutcome(log, state, messageId, from, { sent: true });
2319
+ if (outcome.settled) return { settled: outcome.settled };
2320
+ if (outcome.unsure) return { log, from, attaching: true, endWithinMs: retryEndWaitMs, unsure: true };
2321
+ return { log, from, attaching: outcome.follow === true };
2322
+ }
2323
+ if (retried) {
2324
+ // A turn this id already ran answers from the log, without
2325
+ // queueing it again. The viewer's start is read first, so a
2326
+ // turn that ends after it has its end in view.
2327
+ from = await log.ui.length();
2328
+ const outcome = await retryOutcome(log, foldSessionLog(await log.read()), messageId, from, { sent: false });
2329
+ if (outcome.follow) attaching = true;
2330
+ else if (!outcome.fresh) {
2331
+ accept.resolve({ messageId, sessionId });
2332
+ if (outcome.settled) return { settled: outcome.settled };
2333
+ return { log, from, attaching: true, endWithinMs: retryEndWaitMs };
2334
+ }
2335
+ }
2336
+ if (aborted && !attaching) {
2337
+ // A new prompt whose signal had aborted is never stored.
2338
+ accept.resolve({ messageId, sessionId });
2339
+ return { settled: { messageId, stopReason: "cancelled", usage: {} } };
2340
+ }
2341
+ // The viewer starts where the stream is now, before the
2342
+ // message can produce anything.
2343
+ from ??= await log.ui.length();
2344
+ await send({ type, messageId, ...(input === undefined ? {} : { input }) }, { inRequest: type === "prompt" });
2345
+ accept.resolve({ messageId, sessionId });
2346
+ return { log, from, attaching };
2347
+ } catch (error) {
2348
+ accept.reject(error);
2349
+ throw error;
2350
+ }
2351
+ },
2352
+ });
2353
+ return Object.assign(view, { accepted });
2354
+ };
2355
+ const session = {
2356
+ get id() { return id; },
2357
+ /**
2358
+ * Queues `input` as the session's next turn and returns a view of it.
2359
+ * With `messageId`, a retried call is the same turn: it continues a
2360
+ * turn still running and answers one that ended, without running it
2361
+ * again.
2362
+ */
2363
+ prompt(input, promptOptions = {}) {
2364
+ if (closed) throw new Error(`${label} is closed`);
2365
+ const messageId = promptOptions.messageId ?? newId("msg");
2366
+ if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
2367
+ if (typeof input !== "string" && !Array.isArray(input)) throw new TypeError("prompt input must be a string or an array of content blocks");
2368
+ // The input travels through the queue and the log as JSON.
2369
+ if (Array.isArray(input) && input.some((block) => block?.type === "image" && block.data !== undefined && typeof block.data !== "string")) {
2370
+ throw new TypeError("a durable prompt carries image data as a base64 string");
2371
+ }
2372
+ const signal = promptOptions.signal;
2373
+ if (signal !== undefined && typeof signal?.addEventListener !== "function") throw new TypeError("prompt signal must be an AbortSignal");
2374
+ const turn = startTurn("prompt", messageId, input, promptOptions.messageId !== undefined, signal?.aborted === true);
2375
+ if (signal !== undefined && !signal.aborted) {
2376
+ // The prompt's own cancel: it stops this turn whether it runs or
2377
+ // still waits, and never a later one.
2378
+ const stop = () => { void send({ type: "cancel", messageId: newId("cnl"), target: messageId }).catch(() => {}); };
2379
+ signal.addEventListener("abort", stop, { once: true });
2380
+ void turn.result.finally(() => signal.removeEventListener("abort", stop)).catch(() => {});
2381
+ }
2382
+ return turn;
2383
+ },
2384
+ /** Continues the turn a stopped worker left open, if any. */
2385
+ resume() {
2386
+ if (closed) throw new Error(`${label} is closed`);
2387
+ return startTurn("resume", newId("res"));
2388
+ },
2389
+ /**
2390
+ * The session's events after `cursor`, as NDJSON, from any server.
2391
+ * A cursor is one a line carried, and a line shows only when no line
2392
+ * before it outranks it, so the epoch of the line a cursor names is
2393
+ * the rank every line after it is held to: the read starts there, not
2394
+ * at the stream's start, and lines rank the same however a reader
2395
+ * reconnects. After a line with no epoch it reads from the start.
2396
+ */
2397
+ stream(cursor = 0) {
2398
+ if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError("stream cursor must be a non-negative integer");
2399
+ let reader = null;
2400
+ let buffered = "";
2401
+ let next = 0;
2402
+ let shows = uiRanker();
2403
+ // Opens the read: at the cursor's own line, which sets the rank,
2404
+ // or from the start when it carries none.
2405
+ const open = async (ui) => {
2406
+ if (cursor === 0) return ui.read(0).getReader();
2407
+ const at = ui.read(cursor - 1).getReader();
2408
+ let first = "";
2409
+ for (;;) {
2410
+ const index = first.indexOf("\n");
2411
+ if (index >= 0) {
2412
+ let epoch = null;
2413
+ try { epoch = JSON.parse(first.slice(0, index))?.epoch; } catch {}
2414
+ if (!Number.isSafeInteger(epoch)) break;
2415
+ shows = uiRanker(epoch);
2416
+ buffered = first.slice(index + 1);
2417
+ next = cursor;
2418
+ return at;
2419
+ }
2420
+ const { value, done } = await at.read();
2421
+ if (done) return at;
2422
+ first += decoder.decode(value, { stream: true });
2423
+ }
2424
+ void at.cancel().catch(() => {});
2425
+ return ui.read(0).getReader();
2426
+ };
2427
+ return new ReadableStream({
2428
+ async pull(controller) {
2429
+ if (!reader) {
2430
+ const { created, id: sessionId } = await resolved;
2431
+ reader = await open((await created.session(sessionId)).ui);
2432
+ }
2433
+ for (;;) {
2434
+ const index = buffered.indexOf("\n");
2435
+ if (index >= 0) {
2436
+ const line = buffered.slice(0, index);
2437
+ buffered = buffered.slice(index + 1);
2438
+ next += 1;
2439
+ let event;
2440
+ try { event = JSON.parse(line); } catch { continue; }
2441
+ if (!shows(event) || next <= cursor) continue;
2442
+ controller.enqueue(encoder.encode(`${JSON.stringify({ ...event, cursor: next })}\n`));
2443
+ return;
2444
+ }
2445
+ const { value, done } = await reader.read();
2446
+ if (done) {
2447
+ controller.close();
2448
+ return;
2449
+ }
2450
+ buffered += decoder.decode(value, { stream: true });
2451
+ }
2452
+ },
2453
+ cancel() { void reader?.cancel().catch(() => {}); },
2454
+ });
2455
+ },
2456
+ /**
2457
+ * Guidance for the running turn, or the next turn when none runs: it
2458
+ * cuts a streaming reply short, waits for a running tool, and joins a
2459
+ * turn waiting to start. A steer with images or resources cannot
2460
+ * join a turn: the running one ends at its next safe point and the
2461
+ * steer runs next. With `messageId`, a retried call adds nothing;
2462
+ * one id names one input of the session, prompt or steer: reusing a
2463
+ * prompt's id throws `FX_MESSAGE_ID_IN_USE`, or, where the queue
2464
+ * outlives the process and the sender reads no log, the worker drops
2465
+ * the steer and emits a `session.error` with that code. Resolves once
2466
+ * the steer is stored, with its id, which the stream's events for it
2467
+ * carry.
2468
+ */
2469
+ async steer(input, steerOptions = {}) {
2470
+ if (typeof input === "string" ? input.length === 0 : !Array.isArray(input) || input.length === 0) {
2471
+ throw new TypeError("steer input must be a non-empty string or array of content blocks");
2472
+ }
2473
+ if (Array.isArray(input) && input.some((block) => block?.type === "image" && block.data !== undefined && typeof block.data !== "string")) {
2474
+ throw new TypeError("a durable steer carries image data as a base64 string");
2475
+ }
2476
+ if (steerOptions === null || typeof steerOptions !== "object") throw new TypeError("steer options must be an object");
2477
+ const messageId = steerOptions.messageId ?? newId("str");
2478
+ if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
2479
+ await send({ type: "steer", messageId, input });
2480
+ return { messageId };
2481
+ },
2482
+ /** Cancels the running turn; dropping a turn's view does not. */
2483
+ async cancel() {
2484
+ await send({ type: "cancel", messageId: newId("cnl") });
2485
+ },
2486
+ /** The session's state as opaque bytes, read without changing it. */
2487
+ async checkpoint() {
2488
+ const { created, id: sessionId } = await resolved;
2489
+ const log = await created.session(sessionId);
2490
+ const state = foldSessionLog(await log.read());
2491
+ const readOnly = {
2492
+ load: async () => ({
2493
+ ...(state.checkpoint?.data ? { checkpoint: { through: state.checkpoint.through ?? "0", data: fromBase64(state.checkpoint.data) } } : {}),
2494
+ journal: state.records.map((record) => ({ cursor: record.cursor, data: fromBase64(record.data) })),
2495
+ head: state.head,
2496
+ }),
2497
+ append: async () => { throw new Error("a checkpoint reads the session without changing it"); },
2498
+ };
2499
+ const harness = await agent.openHarness(sessionId, readOnly, null);
2500
+ try {
2501
+ return await harness.exportCheckpoint();
2502
+ } finally {
2503
+ await harness.close().catch(() => {});
2504
+ }
2505
+ },
2506
+ };
2507
+ return session;
2508
+ };
2509
+
2510
+ let defaultSession = null;
2511
+ const durableAgent = {
2512
+ // For tests and drivers: stops a session's running worker as its
2513
+ // deadline would.
2514
+ [durableInternals]: {
2515
+ stopAtDeadline: (sessionId) => workers.get(sessionId)?.stopNow?.(),
2516
+ // Waits for what a session's running turn has written so far.
2517
+ settled: async (sessionId) => { await workers.get(sessionId)?.settled?.(); },
2518
+ // Delivers a queue message to this agent as its queue would.
2519
+ deliver: async (message, delivery) => receive(await backend(), message, delivery),
2520
+ liveWorkers: () => workers.size,
2521
+ // A session's log as one worker holds it, with its own view of the
2522
+ // chain.
2523
+ openLog: async (sessionId) => (await backend()).session(sessionId),
2524
+ // The newest lease the session's log holds, standing or not.
2525
+ lastLease: async (sessionId) => {
2526
+ const log = await (await backend()).session(sessionId);
2527
+ return foldSessionLog(await log.read()).lastLease;
2528
+ },
2529
+ // Whether a lease stands on the session now, as a worker reading it
2530
+ // would judge: unexpired, and not held by a worker known dead.
2531
+ leaseStands: async (sessionId) => {
2532
+ const held = await backend();
2533
+ const log = await held.session(sessionId);
2534
+ return foldSessionLog(await log.read(), { now: Date.now(), alive: (lease) => held.alive(lease) }).lease !== null;
2535
+ },
2536
+ },
2537
+ /** Opens session `id`, or a new one; no I/O until it is used. */
2538
+ session(id, sessionOptions) {
2539
+ if (closed) throw new Error(`${label} is closed`);
2540
+ // A session object holds nothing a later call needs, so each call
2541
+ // gets its own, with its own context. Only a missing id starts a
2542
+ // new session; `null` is as invalid as any other non-id.
2543
+ return openSession(id, sessionOptions);
2544
+ },
2545
+ /** An id for a new session, before its first prompt. */
2546
+ async newSessionId() {
2547
+ return (await backend()).newSessionId();
2548
+ },
2549
+ /** The agent's name: its sessions' turns run only on agents with this name. */
2550
+ get name() {
2551
+ return agentName;
2552
+ },
2553
+ /** The agent's own session, for code that holds one conversation. */
2554
+ get sessionId() {
2555
+ return defaultSession?.id ?? null;
2556
+ },
2557
+ prompt(input, promptOptions) {
2558
+ defaultSession ??= openSession(undefined);
2559
+ return defaultSession.prompt(input, promptOptions);
2560
+ },
2561
+ checkpoint() {
2562
+ defaultSession ??= openSession(undefined);
2563
+ return defaultSession.checkpoint();
2564
+ },
2565
+ /**
2566
+ * The request handler a deployment mounts for queue deliveries, until
2567
+ * its World can deliver to this process itself.
2568
+ */
2569
+ wakeHandler() {
2570
+ return async (request) => {
2571
+ const created = await backend();
2572
+ if (typeof created.queueHandler !== "function") return new Response("this durability delivers in process", { status: 404 });
2573
+ return created.queueHandler(request);
2574
+ };
2575
+ },
2576
+ /** Waits for the work this process is running, then lets go. */
2577
+ async close() {
2578
+ if (closed) return;
2579
+ closed = true;
2580
+ leaveGroup(agentsByName, agentName, registered, member);
2581
+ while (active.size > 0) await Promise.allSettled([...active]);
2582
+ if (backendPromise) {
2583
+ const created = await backendPromise.catch(() => null);
2584
+ unlisten?.();
2585
+ await created?.close?.();
2586
+ }
2587
+ },
2588
+ };
2589
+ return durableAgent;
2590
+ };
2591
+ }