@agentswf/engine 0.1.0-beta.4 → 0.1.0

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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
package/src/mailbox.ts DELETED
@@ -1,990 +0,0 @@
1
- import { stat } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import {
4
- MAILBOX_RECORD_VERSION,
5
- type MailboxRecord,
6
- type WaitingJson,
7
- type WaitTake,
8
- } from "@agentswf/contract/records";
9
- import {
10
- type AgentExecution,
11
- AWF_ID_PREFIX,
12
- type JsonValue,
13
- type OperationRecord,
14
- type ReceiveOptions,
15
- type TurnOutcome,
16
- type WorkflowEvent,
17
- } from "@agentswf/contract/workflow";
18
- import { appendLine, endTornLine, readLines } from "./jsonl";
19
- import { branchOf, wouldTake } from "./sources";
20
-
21
- /** `mailbox.jsonl`: a run's events, and what its waits did with them. */
22
- export function mailboxFile(runDir: string): string {
23
- return join(runDir, "mailbox.jsonl");
24
- }
25
-
26
- /** The run's mailbox in the order written, less a line a crash tore and kinds this awf doesn't know. */
27
- export async function readMailbox(runDir: string): Promise<MailboxRecord[]> {
28
- const lines = await readLines<MailboxRecord>(mailboxFile(runDir));
29
- return lines
30
- .filter((line) => line.version === MAILBOX_RECORD_VERSION && KINDS.has(line.kind))
31
- .map(withAnswered);
32
- }
33
-
34
- /** A turn's end written before 0.1.0-beta.4 has no `answered`, which a workflow would read as false. */
35
- function withAnswered(line: MailboxRecord): MailboxRecord {
36
- if (line.kind !== "event" || line.name !== "agent:ended") return line;
37
- const end = line.data as { outcome?: { kind: string; answered?: boolean } };
38
- if (end?.outcome === undefined || end.outcome.answered !== undefined) return line;
39
- const answered = end.outcome.kind === "answered";
40
- const data = { ...end, outcome: { ...end.outcome, answered } } as unknown as JsonValue;
41
- return { ...line, data };
42
- }
43
-
44
- const KINDS = new Set<string>([
45
- "event",
46
- "wait",
47
- "took",
48
- "turn",
49
- "launch-failed",
50
- "timer",
51
- "timer-cancelled",
52
- ]);
53
-
54
- /** Appends a record; the caller holds the run's lock. */
55
- export async function appendMailbox(runDir: string, record: MailboxRecord): Promise<void> {
56
- await endTornLine(mailboxFile(runDir));
57
- await appendLine(mailboxFile(runDir), JSON.stringify(record));
58
- }
59
-
60
- type EventRecord = Extract<MailboxRecord, { kind: "event" }>;
61
- type TurnRecord = Extract<MailboxRecord, { kind: "turn" }>;
62
-
63
- type WaitRecord = Extract<MailboxRecord, { kind: "wait" }>;
64
-
65
- /** The id of timer `name`'s `timer:due`: made from its name, so it fires once. */
66
- export function timerEventId(name: string): string {
67
- const hash = new Bun.CryptoHasher("sha256").update(name).digest("hex");
68
- return `${AWF_ID_PREFIX}timer.${hash.slice(0, 16)}`;
69
- }
70
-
71
- /** The name of the event a select's time read from state becomes, from its branch, as it is taken. */
72
- export const AT_DUE = "at:due";
73
-
74
- /**
75
- * The id of branch `branch`'s `at:due` for time `due`, an ISO time: made from both, so a time is
76
- * returned once by a branch of that name, whichever loop reads it.
77
- */
78
- export function atEventId(branch: string, due: string): string {
79
- const hash = new Bun.CryptoHasher("sha256").update(`${branch}\n${due}`).digest("hex");
80
- return `${AWF_ID_PREFIX}at.${hash.slice(0, 16)}`;
81
- }
82
-
83
- /**
84
- * The id of the end of `agent`'s turn `turn`: made from both, so it fits the grammar whatever
85
- * either is, and is the same whoever writes it, so only one end is ever kept.
86
- */
87
- export function endIdOf(agent: string, turn: string, attempt: number): string {
88
- const hash = new Bun.CryptoHasher("sha256").update(`${agent}\0${turn}\0${attempt}`).digest("hex");
89
- return `${AWF_ID_PREFIX}end.${hash.slice(0, 16)}`;
90
- }
91
-
92
- /** An agent's turn an earlier attempt started whose end no wait took: its id and meta. */
93
- export type RestoredTurn = { turn: string; meta: JsonValue };
94
-
95
- /** What `agent:ended` says of a turn: its id and meta, as `start()` was given them, and its outcome. */
96
- export type TurnEnd = { turn: string; meta: JsonValue; outcome: TurnOutcome<JsonValue> };
97
-
98
- /** The usage of a turn no delivery settled: one refused, cancelled while queued, or cut off by a crash. */
99
- export function unrunUsage(
100
- agent: string,
101
- operationId: string,
102
- execution: AgentExecution,
103
- ): OperationRecord {
104
- return { callPath: [], agent, operationId, execution, sessions: [] };
105
- }
106
-
107
- /**
108
- * Appends a turn's end, `agent:ended` from its agent, once: an end already there is kept. The
109
- * caller holds the run's lock.
110
- */
111
- async function appendEnd(
112
- runDir: string,
113
- agent: string,
114
- attempt: number,
115
- end: TurnEnd,
116
- at: string,
117
- ): Promise<void> {
118
- const id = endIdOf(agent, end.turn, attempt);
119
- if (foldMailbox(await readMailbox(runDir)).events.has(id)) return;
120
- await appendMailbox(runDir, {
121
- version: MAILBOX_RECORD_VERSION,
122
- at,
123
- kind: "event",
124
- id,
125
- name: "agent:ended",
126
- data: end as unknown as JsonValue,
127
- from: agent,
128
- });
129
- }
130
-
131
- /**
132
- * Writes a failed end for each turn the mailbox has with no end, whose attempt is over, and returns
133
- * each agent's last turn whose end no wait took: what `agent.turn` is when the agent opens.
134
- */
135
- export async function recoverTurns(
136
- runDir: string,
137
- attempt: number,
138
- locked: <T>(work: () => Promise<T>) => Promise<T>,
139
- now: () => number = Date.now,
140
- ): Promise<Map<string, RestoredTurn>> {
141
- await locked(async () => {
142
- for (const turn of foldMailbox(await readMailbox(runDir)).turns) {
143
- if (turn.attempt >= attempt) continue;
144
- await appendEnd(
145
- runDir,
146
- turn.agent,
147
- turn.attempt,
148
- {
149
- turn: turn.turn,
150
- meta: turn.meta,
151
- outcome: {
152
- answered: false,
153
- kind: "failed",
154
- reason: `attempt ${turn.attempt} ended before this turn did; its work, if any, is in the agent's session`,
155
- retryable: true,
156
- usage: unrunUsage(turn.agent, turn.turn, turn.execution),
157
- },
158
- },
159
- new Date(now()).toISOString(),
160
- );
161
- }
162
- });
163
- const state = foldMailbox(await readMailbox(runDir));
164
- const untaken = new Set([...state.unacknowledged, ...(state.held ?? [])]);
165
- const restored = new Map<string, RestoredTurn>();
166
- for (const turn of state.turns)
167
- if (untaken.has(endIdOf(turn.agent, turn.turn, turn.attempt)))
168
- restored.set(turn.agent, { turn: turn.turn, meta: turn.meta });
169
- return restored;
170
- }
171
-
172
- /** What a run's mailbox says, folded from its records in order. */
173
- export type MailboxState = {
174
- /** Every event by id, the first appended under an id being the one kept. */
175
- events: ReadonlyMap<string, EventRecord>;
176
- /** Event ids no wait has acknowledged and none has taken, in arrival order. */
177
- unacknowledged: string[];
178
- /**
179
- * What the last wait outside a stage took that no later wait outside one acknowledged: a crash's
180
- * batch, returned again.
181
- */
182
- held?: string[];
183
- /** What the wait that took `held` takes: absent for a `receive`'s batch. */
184
- heldTakes?: WaitTake[];
185
- /** The last wait called, and whether it is still waiting: no `took` after it. */
186
- lastWait?: WaitRecord & { waiting: boolean };
187
- /** Each stage's batch, as a wait in it took it. */
188
- batches: ReadonlyMap<string, string[]>;
189
- /** Every turn `start()` began, in order. */
190
- turns: readonly TurnRecord[];
191
- /** Every timer by name, as first set. */
192
- timers: ReadonlyMap<string, TimerState>;
193
- /** What each race took, by its name: the branch that won and the event. */
194
- races: ReadonlyMap<string, { branch: string; ids: string[] }>;
195
- /** The event that created the run. */
196
- started?: EventRecord;
197
- };
198
-
199
- /** A timer as the mailbox has it: when it is due, an ISO time, and what became of it. */
200
- export type TimerState = {
201
- name: string;
202
- due: string;
203
- /** As the code gave it: a duration, or an ISO time. */
204
- when: string;
205
- cancelled: boolean;
206
- /** Its `timer:due` is in the mailbox. */
207
- fired: boolean;
208
- };
209
-
210
- /**
211
- * Folds a mailbox. A `wait` outside a stage and a race acknowledges what the last `took` outside one
212
- * returned; a `took` in a stage or a race acknowledges its own, which the stage or race holds. A
213
- * cancelled timer's `timer:due` counts as acknowledged. Events not acknowledged are in order of `at`,
214
- * so a timer that fired late takes its place at its due time.
215
- */
216
- export function foldMailbox(records: readonly MailboxRecord[]): MailboxState {
217
- const events = new Map<string, EventRecord>();
218
- const order: string[] = [];
219
- const acknowledged = new Set<string>();
220
- const batches = new Map<string, string[]>();
221
- const races = new Map<string, { branch: string; ids: string[] }>();
222
- const timers = new Map<string, TimerState>();
223
- const tookIds = new Set<string>();
224
- let held: string[] | undefined;
225
- let heldTakes: WaitTake[] | undefined;
226
- let lastWait: (WaitRecord & { waiting: boolean }) | undefined;
227
- const turns: TurnRecord[] = [];
228
- for (const record of records) {
229
- switch (record.kind) {
230
- case "event":
231
- if (!events.has(record.id)) {
232
- events.set(record.id, record);
233
- order.push(record.id);
234
- }
235
- break;
236
- case "wait":
237
- // A wait in a stage or a race leaves the loop's batch to the loop.
238
- if (record.stage === undefined && record.race === undefined) {
239
- if (!record.returns) for (const id of held ?? []) acknowledged.add(id);
240
- held = undefined;
241
- }
242
- lastWait = { ...record, waiting: true };
243
- break;
244
- case "took":
245
- for (const id of record.ids) tookIds.add(id);
246
- if (lastWait) lastWait.waiting = false;
247
- if (record.race !== undefined) {
248
- for (const id of record.ids) acknowledged.add(id);
249
- if (!races.has(record.race))
250
- races.set(record.race, { branch: record.branch ?? "", ids: record.ids });
251
- } else if (record.stage !== undefined) {
252
- for (const id of record.ids) acknowledged.add(id);
253
- batches.set(record.stage, record.ids);
254
- } else {
255
- held = record.ids.length > 0 ? record.ids : undefined;
256
- heldTakes = lastWait?.takes;
257
- }
258
- break;
259
- case "turn":
260
- turns.push(record);
261
- break;
262
- case "timer":
263
- if (!timers.has(record.name))
264
- timers.set(record.name, {
265
- name: record.name,
266
- due: record.due,
267
- when: record.when,
268
- cancelled: false,
269
- fired: false,
270
- });
271
- break;
272
- case "timer-cancelled": {
273
- const timer = timers.get(record.name);
274
- if (timer) timer.cancelled = true;
275
- acknowledged.add(timerEventId(record.name));
276
- break;
277
- }
278
- }
279
- }
280
- // An `at:due` is appended with its `took`; one a crash tore from it was never returned.
281
- for (const [id, record] of events)
282
- if (record.name === AT_DUE && !tookIds.has(id)) {
283
- events.delete(id);
284
- order.splice(order.indexOf(id), 1);
285
- }
286
- for (const timer of timers.values()) timer.fired = events.has(timerEventId(timer.name));
287
- const started = [...events.values()].find((event) => event.starts);
288
- const taken = new Set(held);
289
- const at = (id: string) => Date.parse(events.get(id)?.at ?? "");
290
- return {
291
- events,
292
- unacknowledged: order
293
- .filter((id) => !acknowledged.has(id) && !taken.has(id))
294
- .sort((a, b) => at(a) - at(b)),
295
- ...(held ? { held } : {}),
296
- ...(held && heldTakes ? { heldTakes } : {}),
297
- ...(lastWait ? { lastWait } : {}),
298
- batches,
299
- turns,
300
- timers,
301
- races,
302
- ...(started ? { started } : {}),
303
- };
304
- }
305
-
306
- /** The events not acknowledged that a wait taking `takes` would take, in order. */
307
- export function readyFor(state: MailboxState, takes: readonly WaitTake[] | undefined): string[] {
308
- return state.unacknowledged.filter((id) => {
309
- const event = state.events.get(id);
310
- return event !== undefined && wouldTake(event, takes);
311
- });
312
- }
313
-
314
- /** Whether `ids`, a crash's held batch, is a select's time read from state, alone. */
315
- function isTimeRead(state: MailboxState, ids: readonly string[]): boolean {
316
- return ids.length === 1 && state.events.get(ids[0] as string)?.name === AT_DUE;
317
- }
318
-
319
- /** A timer's `timer:due`, as a wait would test it. */
320
- function dueEventOf(timer: TimerState): { name: string; from: string } {
321
- return { name: "timer:due", from: timer.name };
322
- }
323
-
324
- /** Timers neither fired nor cancelled, earliest first. */
325
- function pendingTimers(state: MailboxState): TimerState[] {
326
- return [...state.timers.values()]
327
- .filter((timer) => !timer.fired && !timer.cancelled)
328
- .sort((a, b) => Date.parse(a.due) - Date.parse(b.due));
329
- }
330
-
331
- /**
332
- * A select's times read from state that it hasn't returned, earliest first, and of two at once the
333
- * branch declared first. A time returned is no deadline, so state left as it was doesn't spin.
334
- */
335
- function pendingTimes(
336
- state: MailboxState,
337
- takes: readonly WaitTake[] | undefined,
338
- ): { branch: string; due: string }[] {
339
- return (takes ?? [])
340
- .flatMap((take, order) =>
341
- take.name === AT_DUE &&
342
- take.from !== undefined &&
343
- take.due !== undefined &&
344
- !state.events.has(atEventId(take.from, take.due))
345
- ? [{ branch: take.from, due: take.due, order }]
346
- : [],
347
- )
348
- .sort((a, b) => Date.parse(a.due) - Date.parse(b.due) || a.order - b.order)
349
- .map(({ branch, due }) => ({ branch, due }));
350
- }
351
-
352
- /**
353
- * When the earliest timer, or time read from state, a wait taking `takes` would take is due, epoch
354
- * ms; undefined with none.
355
- */
356
- export function nextTimer(
357
- state: MailboxState,
358
- takes: readonly WaitTake[] | undefined,
359
- ): number | undefined {
360
- const timer = pendingTimers(state).find((each) => wouldTake(dueEventOf(each), takes));
361
- const time = pendingTimes(state, takes)[0];
362
- const dues = [timer?.due, time?.due].flatMap((due) => (due ? [Date.parse(due)] : []));
363
- return dues.length > 0 ? Math.min(...dues) : undefined;
364
- }
365
-
366
- /**
367
- * Whether a wait taking `takes` has something to take at `now`: an event, or a timer due. The one
368
- * test for taking, parking, and waking a parked run.
369
- */
370
- export function hasWork(
371
- state: MailboxState,
372
- takes: readonly WaitTake[] | undefined,
373
- now: number,
374
- ): boolean {
375
- if (readyFor(state, takes).length > 0) return true;
376
- const due = nextTimer(state, takes);
377
- return due !== undefined && due <= now;
378
- }
379
-
380
- /** The `timer:due` records of the timers due at `now` and not fired, to append. */
381
- export function dueRecords(state: MailboxState, now: number): MailboxRecord[] {
382
- return pendingTimers(state)
383
- .filter((timer) => Date.parse(timer.due) <= now)
384
- .map((timer) => ({
385
- version: MAILBOX_RECORD_VERSION,
386
- at: timer.due,
387
- kind: "event",
388
- id: timerEventId(timer.name),
389
- name: "timer:due",
390
- data: { name: timer.name },
391
- from: timer.name,
392
- }));
393
- }
394
-
395
- /** An event as a wait returns it. */
396
- function eventOf(record: EventRecord): WorkflowEvent<string, JsonValue> {
397
- return Object.freeze({
398
- id: record.id,
399
- name: record.name,
400
- data: record.data,
401
- from: record.from,
402
- at: record.at,
403
- });
404
- }
405
-
406
- /**
407
- * Thrown from a wait that parked the run: nothing waits to be taken, no turn runs, and the
408
- * attempt's `parked` ending is written. The runner ends the attempt with it.
409
- */
410
- export class RunParked extends Error {
411
- constructor(readonly wait: WaitingJson) {
412
- super(`parked${wait.label === undefined ? "" : `: ${wait.label}`}`);
413
- this.name = "RunParked";
414
- }
415
- }
416
-
417
- /** How a run parks at a wait: whether it may, and the ending it writes when it does. */
418
- export type Parking = {
419
- /**
420
- * Nothing of the run works but this wait, in `stage` if it is in one: so nothing but a new event
421
- * can change what the code would do.
422
- */
423
- idle(stage?: string): boolean;
424
- /**
425
- * With the run idle and nothing to take: calls one agent's due `onTimeout` and resolves true once
426
- * it returns, or false with none due. The signal stops waiting on it.
427
- */
428
- timeout(signal?: AbortSignal): Promise<boolean>;
429
- /** The end of the earliest agent window still open: the run stays live to it, not parked. */
430
- liveUntil(): number | undefined;
431
- /** Writes the attempt's `parked` ending, holding the run's lock, so a waker sees it at once. */
432
- park(wait: WaitingJson): Promise<void>;
433
- /**
434
- * The wait stays live to `until` for an agent's window rather than parking, called on each pass
435
- * that does: a test's hook, which sends what it scripted after a turn.
436
- */
437
- live?(until: number): Promise<void>;
438
- /**
439
- * With the run about to park and a timer it waits for due at `until`: moves the clock there, and
440
- * resolves true, rather than parking. A test's virtual clock; false parks.
441
- */
442
- skip?(until: number): Promise<boolean>;
443
- };
444
-
445
- /** How often a wait looks at its mailbox when no nudge comes: a nudge that failed still arrives. */
446
- const POLL_MILLISECONDS = 1_000;
447
-
448
- /**
449
- * A live run's waits on its mailbox. Every append is made inside `locked`, which holds the run's
450
- * lock, as anything that decides from the mailbox and then writes must be; a nudge, or the poll
451
- * when one is lost, makes a waiting `receive` read the mailbox again.
452
- */
453
- export class RunMailbox {
454
- #first = true;
455
- readonly #parked = Promise.withResolvers<never>();
456
- /** Set once the run parked: its ending is `parked` whatever the code does after. */
457
- parked: RunParked | undefined;
458
- /** Rejects with the park, for the runner to race the workflow's body against. */
459
- readonly whenParked: Promise<never> = this.#parked.promise;
460
-
461
- #wake: (() => void) | undefined;
462
-
463
- constructor(
464
- private readonly options: {
465
- runDir: string;
466
- attempt: number;
467
- /** Runs `work` holding the run's lock. */
468
- locked<T>(work: () => Promise<T>): Promise<T>;
469
- /** Absent, the run never parks: it waits in its process. */
470
- parking?: Parking;
471
- now?: () => number;
472
- },
473
- ) {
474
- // A run nobody races, such as a test's, leaves no rejection unhandled.
475
- this.#parked.promise.catch(() => undefined);
476
- }
477
-
478
- #now(): number {
479
- return this.options.now?.() ?? Date.now();
480
- }
481
-
482
- #at(): string {
483
- return new Date(this.#now()).toISOString();
484
- }
485
-
486
- async #append(record: MailboxRecord): Promise<void> {
487
- await this.options.locked(() => appendMailbox(this.options.runDir, record));
488
- }
489
-
490
- /**
491
- * Records a turn `start()` begins, before its agent is launched. A turn id this attempt began
492
- * already is refused: its end is kept once, so a second turn under it would never end.
493
- */
494
- async beginTurn(
495
- agent: string,
496
- turn: string,
497
- meta: JsonValue,
498
- execution: AgentExecution,
499
- ): Promise<void> {
500
- const { attempt, runDir } = this.options;
501
- await this.options.locked(async () => {
502
- const begun = foldMailbox(await readMailbox(runDir)).turns.some(
503
- (each) => each.agent === agent && each.turn === turn && each.attempt === attempt,
504
- );
505
- if (begun)
506
- throw new Error(
507
- `agent ${agent} began turn ${turn} already in this attempt; an id names one turn`,
508
- );
509
- await appendMailbox(runDir, {
510
- version: MAILBOX_RECORD_VERSION,
511
- at: this.#at(),
512
- kind: "turn",
513
- attempt,
514
- agent,
515
- turn,
516
- meta,
517
- execution,
518
- });
519
- });
520
- }
521
-
522
- /**
523
- * Appends a turn's end, `agent:ended` from its agent, once: an end already there, as a crash's
524
- * recovery may have written, is kept. Then this run's wait reads the mailbox again.
525
- */
526
- async endTurn(agent: string, end: TurnEnd): Promise<void> {
527
- const { runDir, attempt } = this.options;
528
- await this.options.locked(() => appendEnd(runDir, agent, attempt, end, this.#at()));
529
- this.nudge();
530
- }
531
-
532
- /** A new event was appended: a waiting `receive` reads the mailbox again. */
533
- nudge(): void {
534
- this.#wake?.();
535
- }
536
-
537
- /** The events of the batch a wait in `stage` took; undefined when none did. */
538
- async batchOf(stage: string): Promise<WorkflowEvent<string, JsonValue>[] | undefined> {
539
- const state = foldMailbox(await readMailbox(this.options.runDir));
540
- const ids = state.batches.get(stage);
541
- return ids && this.#events(state, ids);
542
- }
543
-
544
- /**
545
- * The run's timers as set, for `setTimer` and `cancelTimer` to answer at once; whether one fired
546
- * is the file's to say.
547
- */
548
- readonly #timers = new Map<string, TimerState>();
549
- /** Timer records not yet written: each wait, and `flush`, waits for them. */
550
- #writes: Promise<void> = Promise.resolve();
551
-
552
- /** Reads the timers an earlier attempt set: once, before the code runs. */
553
- async open(): Promise<void> {
554
- const state = foldMailbox(await readMailbox(this.options.runDir));
555
- for (const timer of state.timers.values()) this.#timers.set(timer.name, { ...timer });
556
- if (state.started) this.started = eventOf(state.started);
557
- }
558
-
559
- /** The event that created the run, once `open` has read it. */
560
- started: WorkflowEvent<string, JsonValue> | undefined;
561
-
562
- /**
563
- * Timer `name`: the one recorded, or one set now, due at `due`, epoch ms, from `when` as the code
564
- * gave it. `asked` is a `when` that differs from the recorded one, which is kept. Its record is
565
- * written before the next wait.
566
- */
567
- setTimer(name: string, due: number, when: string): { timer: TimerState; asked?: string } {
568
- const known = this.#timers.get(name);
569
- if (known) return known.when === when ? { timer: known } : { timer: known, asked: when };
570
- const timer: TimerState = {
571
- name,
572
- due: new Date(due).toISOString(),
573
- when,
574
- cancelled: false,
575
- fired: false,
576
- };
577
- this.#timers.set(name, timer);
578
- this.#queue({
579
- version: MAILBOX_RECORD_VERSION,
580
- at: this.#at(),
581
- kind: "timer",
582
- attempt: this.options.attempt,
583
- name,
584
- due: timer.due,
585
- when,
586
- });
587
- return { timer };
588
- }
589
-
590
- /** Cancels timer `name`: it won't fire, and its `timer:due` not taken is withdrawn. */
591
- async cancelTimer(name: string): Promise<void> {
592
- const timer = this.#timers.get(name);
593
- if (timer && !timer.cancelled) {
594
- timer.cancelled = true;
595
- this.#queue(this.#cancelled(name));
596
- }
597
- await this.flush();
598
- }
599
-
600
- #cancelled(name: string): MailboxRecord {
601
- return {
602
- version: MAILBOX_RECORD_VERSION,
603
- at: this.#at(),
604
- kind: "timer-cancelled",
605
- attempt: this.options.attempt,
606
- name,
607
- };
608
- }
609
-
610
- #queue(record: MailboxRecord): void {
611
- const written = this.#writes.then(() => this.#append(record));
612
- // Its failure is the next wait's, which awaits it.
613
- written.catch(() => undefined);
614
- this.#writes = written;
615
- }
616
-
617
- /** Writes the timer records set and not yet written. */
618
- async flush(): Promise<void> {
619
- await this.#writes;
620
- }
621
-
622
- /**
623
- * Acknowledges what the last call returned, then takes the events not yet acknowledged, waiting
624
- * for one when there is none. The attempt's first call, outside a stage, returns a batch an earlier
625
- * attempt took and never acknowledged, alone, and writes nothing. In a stage that has taken its
626
- * batch, as one that failed or died before its record did has, it returns that batch and writes
627
- * nothing.
628
- */
629
- async receive(
630
- options: ReceiveOptions | undefined,
631
- stage?: string,
632
- /** Rejects the wait: the run ended, or the scope it waits in was cancelled. */
633
- signal?: AbortSignal,
634
- ): Promise<WorkflowEvent<string, JsonValue>[]> {
635
- const { label, take } = checked(options);
636
- if (stage !== undefined) {
637
- const batch = await this.batchOf(stage);
638
- if (batch) return batch;
639
- } else if (this.#first) {
640
- this.#first = false;
641
- const state = foldMailbox(await readMailbox(this.options.runDir));
642
- // A select's time read from state is the select's: this wait acknowledges it.
643
- if (state.held && !isTimeRead(state, state.held)) return this.#events(state, state.held);
644
- }
645
- return this.#wait(
646
- {
647
- ...(label === undefined ? {} : { label }),
648
- take,
649
- ...(stage === undefined ? {} : { stage }),
650
- },
651
- signal,
652
- );
653
- }
654
-
655
- /**
656
- * As `receive`, taking the oldest event `takes` names, alone: a select's next. After a crash, the
657
- * attempt's first call returns the one an earlier attempt took and never acknowledged.
658
- */
659
- async select(
660
- takes: readonly WaitTake[] | (() => readonly WaitTake[]),
661
- label: string | undefined,
662
- signal?: AbortSignal,
663
- ): Promise<WorkflowEvent<string, JsonValue>> {
664
- const read = typeof takes === "function" ? takes : undefined;
665
- const first = read ? read() : (takes as readonly WaitTake[]);
666
- let returns = false;
667
- if (this.#first) {
668
- this.#first = false;
669
- const state = foldMailbox(await readMailbox(this.options.runDir));
670
- // A select's own, not a receive's batch of one.
671
- const held =
672
- state.held?.length === 1 && state.heldTakes
673
- ? state.events.get(state.held[0] as string)
674
- : undefined;
675
- if (held?.name === AT_DUE) {
676
- // Its time, if the state still says it; moved or dropped, this wait acknowledges it.
677
- if (
678
- first.some(
679
- (take) => take.name === AT_DUE && take.from === held.from && take.due === held.at,
680
- )
681
- )
682
- return eventOf(held);
683
- } else {
684
- if (held && wouldTake(held, first)) return eventOf(held);
685
- // What this select doesn't take is waiting again, for the wait that does.
686
- returns = state.held !== undefined;
687
- }
688
- }
689
- const [event] = await this.#wait(
690
- {
691
- ...(label === undefined ? {} : { label }),
692
- take: "oldest",
693
- takes: [...first],
694
- ...(read ? { read } : {}),
695
- ...(returns ? { returns } : {}),
696
- },
697
- signal,
698
- );
699
- return event as WorkflowEvent<string, JsonValue>;
700
- }
701
-
702
- /**
703
- * Race `name`: what it took, recorded, or the first event its branches take, under the first
704
- * branch that takes it. Its own timers, by branch, are cancelled with the win when another branch
705
- * wins. It acknowledges nothing else, so a loop's batch stays held.
706
- */
707
- async race(
708
- name: string,
709
- branches: readonly (readonly [string, WaitTake])[],
710
- owned: ReadonlyMap<string, string>,
711
- label: string | undefined,
712
- signal?: AbortSignal,
713
- ): Promise<{ branch: string; event: WorkflowEvent<string, JsonValue> }> {
714
- await this.flush();
715
- const state = foldMailbox(await readMailbox(this.options.runDir));
716
- const won = state.races.get(name);
717
- const recorded = won && state.events.get(won.ids[0] as string);
718
- if (won && recorded) {
719
- // A crash between its win and its cancels left a loser to cancel now.
720
- for (const [branch, timer] of owned)
721
- if (branch !== won.branch && !state.timers.get(timer)?.cancelled)
722
- await this.cancelTimer(timer);
723
- return { branch: won.branch, event: eventOf(recorded) };
724
- }
725
- const [event] = await this.#wait(
726
- {
727
- ...(label === undefined ? {} : { label }),
728
- take: "oldest",
729
- takes: branches.map(([, take]) => take),
730
- race: { name, branches, owned },
731
- },
732
- signal,
733
- );
734
- const taken = event as WorkflowEvent<string, JsonValue>;
735
- return { branch: branchOf(branches, taken) as string, event: taken };
736
- }
737
-
738
- async #wait(
739
- spec: {
740
- label?: string;
741
- take: "all" | "oldest";
742
- takes?: WaitTake[];
743
- /** Reads the takes again at each pass: a select's times read from state. */
744
- read?: () => readonly WaitTake[];
745
- returns?: boolean;
746
- stage?: string;
747
- race?: {
748
- name: string;
749
- branches: readonly (readonly [string, WaitTake])[];
750
- owned: ReadonlyMap<string, string>;
751
- };
752
- },
753
- signal: AbortSignal | undefined,
754
- ): Promise<WorkflowEvent<string, JsonValue>[]> {
755
- const { label, take, read, returns, stage, race } = spec;
756
- let { takes } = spec;
757
- const { attempt, runDir } = this.options;
758
- await this.flush();
759
- const waitRecord = (again?: boolean): MailboxRecord => ({
760
- version: MAILBOX_RECORD_VERSION,
761
- at: this.#at(),
762
- kind: "wait",
763
- attempt,
764
- ...(label === undefined ? {} : { label }),
765
- ...(stage === undefined ? {} : { stage }),
766
- ...(race === undefined ? {} : { race: race.name }),
767
- ...(takes === undefined ? {} : { takes }),
768
- ...(returns && !again ? { returns: true as const } : {}),
769
- });
770
- await this.#append(waitRecord());
771
- const took = (events: EventRecord[]) =>
772
- this.options.locked(async () => {
773
- const branch = race && branchOf(race.branches, events[0] as EventRecord);
774
- await appendMailbox(runDir, {
775
- version: MAILBOX_RECORD_VERSION,
776
- at: this.#at(),
777
- kind: "took",
778
- attempt,
779
- ids: events.map((event) => event.id),
780
- ...(stage === undefined ? {} : { stage }),
781
- ...(race === undefined ? {} : { race: race.name }),
782
- ...(branch === undefined ? {} : { branch }),
783
- });
784
- // A crash before these leaves them to the rerun, which answers from the win and cancels them.
785
- for (const [owner, timer] of race?.owned ?? []) {
786
- const known = this.#timers.get(timer);
787
- if (owner === branch || known?.cancelled) continue;
788
- if (known) known.cancelled = true;
789
- await appendMailbox(runDir, this.#cancelled(timer));
790
- }
791
- });
792
- for (;;) {
793
- signal?.throwIfAborted();
794
- if (read) {
795
- // A time moved while it waited, by an agent's onTimeout or a parallel branch: a parked
796
- // run's `until`, and its wake, read the last wait record.
797
- const fresh = [...read()];
798
- if (JSON.stringify(fresh) !== JSON.stringify(takes)) {
799
- takes = fresh;
800
- await this.#append(waitRecord(true));
801
- }
802
- }
803
- // Armed before reading, so a nudge between the read and the wait is not lost.
804
- const woken = new Promise<void>((resolve) => {
805
- this.#wake = resolve;
806
- });
807
- // Before the read too: what is appended after the read changes it, nudged or not.
808
- const size = await mailboxSize(runDir);
809
- const state = await this.#fired();
810
- const ready = readyFor(state, takes);
811
- if (ready.length > 0) {
812
- const ids = take === "oldest" ? ready.slice(0, 1) : ready;
813
- const events = ids.map((id) => state.events.get(id) as EventRecord);
814
- await took(events);
815
- return events.map(eventOf);
816
- }
817
- const time = pendingTimes(state, takes)[0];
818
- if (time && Date.parse(time.due) <= this.#now()) {
819
- const taken = await this.#takeTime(time, takes, read);
820
- if (taken) return [eventOf(taken)];
821
- continue;
822
- }
823
- const next = nextTimer(state, takes);
824
- if (next !== undefined && next <= this.#now()) continue;
825
- const { parking } = this.options;
826
- const idle = parking?.idle(stage) ?? false;
827
- // Before the handlers, on the same clock: a window that passes between the two is due by the
828
- // time `timeout` looks, never parked past.
829
- const live = parking && idle ? parking.liveUntil() : undefined;
830
- // Read again after a handler: what arrived meanwhile is taken first.
831
- if (parking && idle && (await parking.timeout(signal))) continue;
832
- if (parking && live !== undefined) await parking.live?.(live);
833
- else if (parking && idle) {
834
- if (next !== undefined && (await parking.skip?.(next))) continue;
835
- const wait: WaitingJson = {
836
- ...(label === undefined ? {} : { label }),
837
- ...(next === undefined ? {} : { until: new Date(next).toISOString() }),
838
- };
839
- // Checked once more under the lock, which every sender appends under: an event sent now is
840
- // either seen here, or sent after `parked` is written and continues the run.
841
- const parked = await this.options.locked(async () => {
842
- const again = foldMailbox(await readMailbox(runDir));
843
- if (hasWork(again, takes, this.#now())) return false;
844
- await parking.park(wait);
845
- return true;
846
- });
847
- if (parked) {
848
- this.parked = new RunParked(wait);
849
- this.#parked.reject(this.parked);
850
- throw this.parked;
851
- }
852
- continue;
853
- }
854
- // Only a nudge, the size poll or the next timer wakes it, or, with a turn running, its end,
855
- // which may let the run park: looked at each second. In a window, its end.
856
- const longest =
857
- parking && !idle
858
- ? POLL_MILLISECONDS
859
- : live !== undefined
860
- ? live - this.#now()
861
- : Number.POSITIVE_INFINITY;
862
- await this.#idle(
863
- woken,
864
- size,
865
- Math.min(longest, next === undefined ? longest : next - this.#now()),
866
- signal,
867
- );
868
- }
869
- }
870
-
871
- /**
872
- * A select's time read from state, due with no event ready: its `at:due` appended and taken in one
873
- * step under the lock, so it is never queued ahead to fire after the time moved. Undefined when an
874
- * event arrived meanwhile, which goes first.
875
- */
876
- async #takeTime(
877
- time: { branch: string; due: string },
878
- takes: readonly WaitTake[] | undefined,
879
- read: (() => readonly WaitTake[]) | undefined,
880
- ): Promise<EventRecord | undefined> {
881
- const { attempt, runDir } = this.options;
882
- const id = atEventId(time.branch, time.due);
883
- return this.options.locked(async () => {
884
- const again = foldMailbox(await readMailbox(runDir));
885
- // Code beside the wait may have moved it while the lock was awaited.
886
- if (read && !read().some((take) => take.from === time.branch && take.due === time.due))
887
- return undefined;
888
- if (again.events.has(id) || readyFor(again, takes).length > 0) return undefined;
889
- if (dueRecords(again, this.#now()).length > 0) return undefined;
890
- const event: EventRecord = {
891
- version: MAILBOX_RECORD_VERSION,
892
- at: time.due,
893
- kind: "event",
894
- id,
895
- name: AT_DUE,
896
- data: {},
897
- from: time.branch,
898
- };
899
- await appendMailbox(runDir, event);
900
- await appendMailbox(runDir, {
901
- version: MAILBOX_RECORD_VERSION,
902
- at: this.#at(),
903
- kind: "took",
904
- attempt,
905
- ids: [id],
906
- });
907
- return event;
908
- });
909
- }
910
-
911
- /** The mailbox, with every timer due by now fired into it. */
912
- async #fired(): Promise<MailboxState> {
913
- const { runDir } = this.options;
914
- const state = foldMailbox(await readMailbox(runDir));
915
- if (dueRecords(state, this.#now()).length === 0) return state;
916
- return this.options.locked(async () => {
917
- const again = foldMailbox(await readMailbox(runDir));
918
- const due = dueRecords(again, this.#now());
919
- for (const record of due) await appendMailbox(runDir, record);
920
- return foldMailbox(await readMailbox(runDir));
921
- });
922
- }
923
-
924
- async #idle(
925
- woken: Promise<void>,
926
- size: number,
927
- ms: number,
928
- signal: AbortSignal | undefined,
929
- ): Promise<void> {
930
- let timer: ReturnType<typeof setTimeout> | undefined;
931
- let poll: ReturnType<typeof setInterval> | undefined;
932
- let onAbort: (() => void) | undefined;
933
- try {
934
- await Promise.race([
935
- woken,
936
- new Promise<void>((resolve) => {
937
- timer = setTimeout(resolve, Math.max(0, Math.min(ms, 2 ** 31 - 1)));
938
- }),
939
- new Promise<void>((resolve) => {
940
- poll = setInterval(() => {
941
- void mailboxSize(this.options.runDir).then((now) => {
942
- if (now !== size) resolve();
943
- });
944
- }, POLL_MILLISECONDS);
945
- }),
946
- new Promise<void>((_, reject) => {
947
- if (!signal) return;
948
- onAbort = () => reject(signal.reason);
949
- if (signal.aborted) onAbort();
950
- else signal.addEventListener("abort", onAbort, { once: true });
951
- }),
952
- ]);
953
- } finally {
954
- clearTimeout(timer);
955
- clearInterval(poll);
956
- if (onAbort) signal?.removeEventListener("abort", onAbort);
957
- this.#wake = undefined;
958
- }
959
- }
960
-
961
- #events(state: MailboxState, ids: readonly string[]): WorkflowEvent<string, JsonValue>[] {
962
- return ids.flatMap((id) => {
963
- const record = state.events.get(id);
964
- return record ? [eventOf(record)] : [];
965
- });
966
- }
967
- }
968
-
969
- async function mailboxSize(runDir: string): Promise<number> {
970
- return stat(mailboxFile(runDir)).then(
971
- (stats) => stats.size,
972
- () => 0,
973
- );
974
- }
975
-
976
- /** A wait's options as given, checked: what a typed caller can't get wrong, a plain one can. */
977
- function checked(options: ReceiveOptions | undefined): {
978
- label?: string;
979
- take: "all" | "oldest";
980
- } {
981
- if (options !== undefined && (typeof options !== "object" || options === null))
982
- throw new Error("receive takes { label?, take? }");
983
- const { label, take = "all" } = options ?? {};
984
- if (label !== undefined && typeof label !== "string")
985
- throw new Error("receive's label is a string");
986
- if (take !== "all" && take !== "oldest") throw new Error('receive\'s take is "all" or "oldest"');
987
- if ("until" in (options ?? {}))
988
- throw new Error("receive has no until: a timer, wf.timer(name, when), wakes it");
989
- return { ...(label === undefined ? {} : { label }), take };
990
- }