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