@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.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +35 -22
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +5 -7
- package/src/attempt-output.ts +15 -32
- package/src/attempt-view.ts +34 -33
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +41 -11
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +41 -33
- package/src/deadlines.ts +40 -7
- package/src/decisions/directory.ts +51 -40
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +43 -27
- package/src/emit-command.ts +88 -58
- package/src/emit.ts +254 -56
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +18 -6
- package/src/help/inbox.txt +6 -4
- package/src/help/logs.txt +9 -6
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +30 -11
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +7 -15
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +56 -133
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +40 -57
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +5 -4
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +15 -12
- package/src/logs-command.ts +128 -47
- package/src/message-operation.ts +6 -3
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +31 -28
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +9 -7
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +224 -94
- package/src/operator-runtime.ts +29 -7
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +105 -59
- package/src/progress-view.ts +10 -10
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +45 -28
- package/src/run-commands.ts +43 -115
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +205 -43
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +18 -12
- package/src/run-view.ts +81 -17
- package/src/runs-command.ts +83 -63
- package/src/runs.ts +310 -212
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +28 -29
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +119 -131
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +36 -14
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +73 -26
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +25 -16
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +54 -4
- package/src/workflow-runner.ts +805 -580
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +3 -3
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +121 -53
- package/src/workflow-testing/index.ts +33 -842
- package/src/workflow-testing/run-test.ts +1128 -0
- package/src/workflow-testing/script.ts +74 -45
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -990
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
package/src/waits.ts
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import type { RunLine, Taken, WaitingJson, WaitTake } from "@agentswf/contract/records";
|
|
3
|
+
import type {
|
|
4
|
+
AgentExecution,
|
|
5
|
+
JsonValue,
|
|
6
|
+
TurnOutcome,
|
|
7
|
+
WorkflowEvent,
|
|
8
|
+
} from "@agentswf/contract/workflow";
|
|
9
|
+
import { DERIVED_IDS, endIdOf } from "./event-ids";
|
|
10
|
+
import { inSize, readIn } from "./in-lines";
|
|
11
|
+
import { type RunLines, readRunLines, runLinesFile } from "./run-lines";
|
|
12
|
+
import {
|
|
13
|
+
acksOf,
|
|
14
|
+
branchOf,
|
|
15
|
+
firstReceive,
|
|
16
|
+
firstSelect,
|
|
17
|
+
foldWaits,
|
|
18
|
+
hasWork,
|
|
19
|
+
nextTimer,
|
|
20
|
+
pendingTimes,
|
|
21
|
+
type RestoredTurn,
|
|
22
|
+
readyFor,
|
|
23
|
+
restoredTurns,
|
|
24
|
+
type TimerState,
|
|
25
|
+
type WaitEvent,
|
|
26
|
+
type WaitState,
|
|
27
|
+
} from "./run-policy/waits";
|
|
28
|
+
|
|
29
|
+
/** What `agent:ended` says of a turn: its id and meta, as `start()` was given them, and its outcome. */
|
|
30
|
+
export type TurnEnd = { turn: string; meta: JsonValue; outcome: TurnOutcome<JsonValue> };
|
|
31
|
+
|
|
32
|
+
/** What the waits see of a run at `now`: its `run.jsonl` and `in.jsonl`, folded. */
|
|
33
|
+
export async function readWaits(runDir: string, now: number): Promise<WaitState> {
|
|
34
|
+
const [{ lines }, events] = await Promise.all([readRunLines(runDir), readIn(runDir)]);
|
|
35
|
+
return foldWaits({ lines, events, now, ids: DERIVED_IDS });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a `took` or `race` line names of `event`: its id, and which timer or time it was. */
|
|
39
|
+
function takenOf(event: WaitEvent): Taken {
|
|
40
|
+
if (event.origin === "timer") return { id: event.id, timer: event.from };
|
|
41
|
+
if (event.origin === "time") return { id: event.id, branch: event.from, due: event.at };
|
|
42
|
+
return { id: event.id };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An event as a wait returns it. */
|
|
46
|
+
function eventOf(event: WaitEvent): WorkflowEvent<string, JsonValue> {
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
id: event.id,
|
|
49
|
+
name: event.name,
|
|
50
|
+
data: event.data,
|
|
51
|
+
from: event.from,
|
|
52
|
+
...(event.source === undefined ? {} : { source: event.source }),
|
|
53
|
+
at: event.at,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Thrown from a wait that parked the run: nothing waits to be taken, no turn runs, and the
|
|
59
|
+
* attempt's `parked` ending is written. The runner ends the attempt with it.
|
|
60
|
+
*/
|
|
61
|
+
export class RunParked extends Error {
|
|
62
|
+
constructor(readonly wait: WaitingJson) {
|
|
63
|
+
super(`parked${wait.label === undefined ? "" : `: ${wait.label}`}`);
|
|
64
|
+
this.name = "RunParked";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** How a run parks at a wait: whether it may, and the ending it writes when it does. */
|
|
69
|
+
export type Parking = {
|
|
70
|
+
/** Nothing of the run works but this wait, so only a new event can change what the code does. */
|
|
71
|
+
idle(): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* With the run idle and nothing to take: calls one agent's due `onTimeout` and resolves true once
|
|
74
|
+
* it returns, or false with none due. The signal stops waiting on it.
|
|
75
|
+
*/
|
|
76
|
+
timeout(signal?: AbortSignal): Promise<boolean>;
|
|
77
|
+
/** The end of the earliest agent window still open: the run stays live to it, not parked. */
|
|
78
|
+
liveUntil(): number | undefined;
|
|
79
|
+
/** Writes the attempt's `parked` ending, holding the run's lock, so a waker sees it at once. */
|
|
80
|
+
park(wait: WaitingJson): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* The wait stays live to `until` for an agent's window rather than parking, called on each pass
|
|
83
|
+
* that does: a test's hook, which sends what it scripted after a turn.
|
|
84
|
+
*/
|
|
85
|
+
live?(until: number): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* With the run about to park and a timer it waits for due at `until`: moves the clock there, and
|
|
88
|
+
* resolves true, rather than parking. A test's virtual clock; false parks.
|
|
89
|
+
*/
|
|
90
|
+
skip?(until: number): Promise<boolean>;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `in.jsonl`'s and `run.jsonl`'s sizes together, which a waiting run polls: a sender's event, or a
|
|
95
|
+
* turn's end this attempt wrote, changes them.
|
|
96
|
+
*/
|
|
97
|
+
async function sizeOf(runDir: string): Promise<number> {
|
|
98
|
+
const [events, lines] = await Promise.all([
|
|
99
|
+
inSize(runDir),
|
|
100
|
+
stat(runLinesFile(runDir)).then(
|
|
101
|
+
(stats) => stats.size,
|
|
102
|
+
() => 0,
|
|
103
|
+
),
|
|
104
|
+
]);
|
|
105
|
+
return events + lines;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** How often a wait looks at its files when no nudge comes: a nudge that failed still arrives. */
|
|
109
|
+
const POLL_MILLISECONDS = 1_000;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A live run's waits. What they take is the merge of `in.jsonl`'s events, the turns' ends and the
|
|
113
|
+
* timers due; what they took, and the timers and turns the code began, are the attempt's own lines
|
|
114
|
+
* in `run.jsonl`, written through its writer. Parking holds the run's lock, which every sender
|
|
115
|
+
* appends to `in.jsonl` under; a nudge, or the poll when one is lost, makes a waiting `receive`
|
|
116
|
+
* read again.
|
|
117
|
+
*/
|
|
118
|
+
export class RunWaits {
|
|
119
|
+
#first = true;
|
|
120
|
+
readonly #parked = Promise.withResolvers<never>();
|
|
121
|
+
/** Set once the run parked: its ending is `parked` whatever the code does after. */
|
|
122
|
+
parked: RunParked | undefined;
|
|
123
|
+
/** Rejects with the park, for the runner to race the workflow's body against. */
|
|
124
|
+
readonly whenParked: Promise<never> = this.#parked.promise;
|
|
125
|
+
|
|
126
|
+
/** One resolver per wait idle now: a nudge wakes every one, parallel branches' included. */
|
|
127
|
+
readonly #wakes = new Set<() => void>();
|
|
128
|
+
/** This attempt's turns begun, and those whose end is written: only this attempt writes either. */
|
|
129
|
+
readonly #begun = new Set<string>();
|
|
130
|
+
readonly #ended = new Set<string>();
|
|
131
|
+
|
|
132
|
+
constructor(
|
|
133
|
+
private readonly options: {
|
|
134
|
+
runDir: string;
|
|
135
|
+
/** The attempt's writer on `run.jsonl`. */
|
|
136
|
+
lines: RunLines;
|
|
137
|
+
/** Runs `work` holding the run's lock. */
|
|
138
|
+
locked<T>(work: () => Promise<T>): Promise<T>;
|
|
139
|
+
/** Absent, the run never parks: it waits in its process. */
|
|
140
|
+
parking?: Parking;
|
|
141
|
+
now?: () => number;
|
|
142
|
+
},
|
|
143
|
+
) {
|
|
144
|
+
// A run nobody races, such as a test's, leaves no rejection unhandled.
|
|
145
|
+
this.#parked.promise.catch(() => undefined);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#now(): number {
|
|
149
|
+
return this.options.now?.() ?? Date.now();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#at(): string {
|
|
153
|
+
return new Date(this.#now()).toISOString();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get #attempt(): number {
|
|
157
|
+
return this.options.lines.attempt;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#state(): Promise<WaitState> {
|
|
161
|
+
return readWaits(this.options.runDir, this.#now());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Appends one of this attempt's lines. */
|
|
165
|
+
#append(line: RunLine): Promise<void> {
|
|
166
|
+
return this.options.lines.append(line);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Records a turn `start()` begins, before its agent is launched. A turn id this attempt began
|
|
171
|
+
* already is refused: its end is kept once, so a second turn under it would never end.
|
|
172
|
+
*/
|
|
173
|
+
async beginTurn(
|
|
174
|
+
agent: string,
|
|
175
|
+
turn: string,
|
|
176
|
+
meta: JsonValue,
|
|
177
|
+
execution: AgentExecution,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
const key = JSON.stringify([agent, turn]);
|
|
180
|
+
if (this.#begun.has(key))
|
|
181
|
+
throw new Error(
|
|
182
|
+
`agent ${agent} began turn ${turn} already in this attempt; an id names one turn`,
|
|
183
|
+
);
|
|
184
|
+
this.#begun.add(key);
|
|
185
|
+
await this.#append({
|
|
186
|
+
kind: "turn-started",
|
|
187
|
+
attempt: this.#attempt,
|
|
188
|
+
at: this.#at(),
|
|
189
|
+
agent,
|
|
190
|
+
turn,
|
|
191
|
+
meta,
|
|
192
|
+
execution,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Writes a turn's end once; then this run's wait reads again. */
|
|
197
|
+
async endTurn(agent: string, end: TurnEnd): Promise<void> {
|
|
198
|
+
const id = endIdOf(agent, end.turn, this.#attempt);
|
|
199
|
+
if (!this.#ended.has(id)) {
|
|
200
|
+
this.#ended.add(id);
|
|
201
|
+
await this.#append({
|
|
202
|
+
kind: "turn-ended",
|
|
203
|
+
attempt: this.#attempt,
|
|
204
|
+
at: this.#at(),
|
|
205
|
+
id,
|
|
206
|
+
agent,
|
|
207
|
+
turn: end.turn,
|
|
208
|
+
began: this.#attempt,
|
|
209
|
+
end,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
this.nudge();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A new event was appended: a waiting `receive` reads again. */
|
|
216
|
+
nudge(): void {
|
|
217
|
+
for (const wake of this.#wakes) wake();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The run's timers as set, for `setTimer` and `cancelTimer` to answer at once; whether one fired
|
|
222
|
+
* is the lines' to say.
|
|
223
|
+
*/
|
|
224
|
+
readonly #timers = new Map<string, TimerState>();
|
|
225
|
+
/** Timer lines not yet written: each wait, and `flush`, waits for them. */
|
|
226
|
+
#writes: Promise<void> = Promise.resolve();
|
|
227
|
+
|
|
228
|
+
/** Each agent's turn an earlier attempt started whose end no wait took, once `open` has read it. */
|
|
229
|
+
restored: ReadonlyMap<string, RestoredTurn> = new Map();
|
|
230
|
+
|
|
231
|
+
/** Reads what earlier attempts left: once, before the code runs. */
|
|
232
|
+
async open(): Promise<void> {
|
|
233
|
+
const state = await this.#state();
|
|
234
|
+
for (const timer of state.timers.values()) this.#timers.set(timer.name, { ...timer });
|
|
235
|
+
if (state.started) this.started = eventOf(state.started);
|
|
236
|
+
this.restored = restoredTurns(state);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The event that created the run, once `open` has read it. */
|
|
240
|
+
started: WorkflowEvent<string, JsonValue> | undefined;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Timer `name`: the one recorded, or one set now, due at `due`, epoch ms, from `when` as the code
|
|
244
|
+
* gave it. `asked` is a `when` that differs from the recorded one, which is kept. Its line is
|
|
245
|
+
* written before the next wait.
|
|
246
|
+
*/
|
|
247
|
+
setTimer(name: string, due: number, when: string): { timer: TimerState; asked?: string } {
|
|
248
|
+
const known = this.#timers.get(name);
|
|
249
|
+
if (known) return known.when === when ? { timer: known } : { timer: known, asked: when };
|
|
250
|
+
const timer: TimerState = {
|
|
251
|
+
name,
|
|
252
|
+
due: new Date(due).toISOString(),
|
|
253
|
+
when,
|
|
254
|
+
cancelled: false,
|
|
255
|
+
taken: false,
|
|
256
|
+
fired: false,
|
|
257
|
+
};
|
|
258
|
+
this.#timers.set(name, timer);
|
|
259
|
+
this.#queue({
|
|
260
|
+
kind: "timer",
|
|
261
|
+
attempt: this.#attempt,
|
|
262
|
+
at: this.#at(),
|
|
263
|
+
name,
|
|
264
|
+
due: timer.due,
|
|
265
|
+
when,
|
|
266
|
+
});
|
|
267
|
+
return { timer };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Cancels timer `name`: it won't fire, and if it is due and not taken, it is withdrawn. */
|
|
271
|
+
async cancelTimer(name: string): Promise<void> {
|
|
272
|
+
const timer = this.#timers.get(name);
|
|
273
|
+
if (timer && !timer.cancelled) {
|
|
274
|
+
timer.cancelled = true;
|
|
275
|
+
this.#queue(this.#cancelled(timer));
|
|
276
|
+
}
|
|
277
|
+
await this.flush();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A timer's line again, cancelled. */
|
|
281
|
+
#cancelled(timer: TimerState): RunLine {
|
|
282
|
+
return {
|
|
283
|
+
kind: "timer",
|
|
284
|
+
attempt: this.#attempt,
|
|
285
|
+
at: this.#at(),
|
|
286
|
+
name: timer.name,
|
|
287
|
+
due: timer.due,
|
|
288
|
+
when: timer.when,
|
|
289
|
+
cancelled: true,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#queue(line: RunLine): void {
|
|
294
|
+
const written = this.#writes.then(() => this.#append(line));
|
|
295
|
+
// Its failure is the next wait's, which awaits it.
|
|
296
|
+
written.catch(() => undefined);
|
|
297
|
+
this.#writes = written;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Writes the timer lines set and not yet written. */
|
|
301
|
+
async flush(): Promise<void> {
|
|
302
|
+
await this.#writes;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Acknowledges what the last call returned, then takes the events not yet acknowledged, waiting
|
|
307
|
+
* for one when there is none. The attempt's first call returns a batch an earlier attempt took
|
|
308
|
+
* and never acknowledged, alone, and writes nothing.
|
|
309
|
+
*/
|
|
310
|
+
async receive(
|
|
311
|
+
label: string | undefined,
|
|
312
|
+
/** Rejects the wait: the run ended, or the scope it waits in was cancelled. */
|
|
313
|
+
signal?: AbortSignal,
|
|
314
|
+
): Promise<WorkflowEvent<string, JsonValue>[]> {
|
|
315
|
+
if (this.#first) {
|
|
316
|
+
this.#first = false;
|
|
317
|
+
const state = await this.#state();
|
|
318
|
+
const held = firstReceive(state);
|
|
319
|
+
if (held) return this.#events(state, held);
|
|
320
|
+
}
|
|
321
|
+
return this.#wait({ ...(label === undefined ? {} : { label }), take: "all" }, signal);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* As `receive`, taking the oldest event `takes` names, alone: a select's next. After a crash, the
|
|
326
|
+
* attempt's first call returns the one an earlier attempt took and never acknowledged.
|
|
327
|
+
*/
|
|
328
|
+
async select(
|
|
329
|
+
takes: readonly WaitTake[] | (() => readonly WaitTake[]),
|
|
330
|
+
label: string | undefined,
|
|
331
|
+
signal?: AbortSignal,
|
|
332
|
+
): Promise<WorkflowEvent<string, JsonValue>> {
|
|
333
|
+
const read = typeof takes === "function" ? takes : undefined;
|
|
334
|
+
const first = read ? read() : (takes as readonly WaitTake[]);
|
|
335
|
+
let returns = false;
|
|
336
|
+
if (this.#first) {
|
|
337
|
+
this.#first = false;
|
|
338
|
+
const held = firstSelect(await this.#state(), first);
|
|
339
|
+
if ("event" in held) return eventOf(held.event);
|
|
340
|
+
returns = held.returns;
|
|
341
|
+
}
|
|
342
|
+
const [event] = await this.#wait(
|
|
343
|
+
{
|
|
344
|
+
...(label === undefined ? {} : { label }),
|
|
345
|
+
take: "oldest",
|
|
346
|
+
takes: [...first],
|
|
347
|
+
...(read ? { read } : {}),
|
|
348
|
+
...(returns ? { returns } : {}),
|
|
349
|
+
},
|
|
350
|
+
signal,
|
|
351
|
+
);
|
|
352
|
+
return event as WorkflowEvent<string, JsonValue>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Race `name`: what it took, recorded, or the first event its branches take, under the first
|
|
357
|
+
* branch that takes it. Its own timers, by branch, are cancelled with the win when another branch
|
|
358
|
+
* wins. It acknowledges nothing else, so a loop's batch stays held.
|
|
359
|
+
*/
|
|
360
|
+
async race(
|
|
361
|
+
name: string,
|
|
362
|
+
branches: readonly (readonly [string, WaitTake])[],
|
|
363
|
+
owned: ReadonlyMap<string, string>,
|
|
364
|
+
label: string | undefined,
|
|
365
|
+
signal?: AbortSignal,
|
|
366
|
+
): Promise<{ branch: string; event: WorkflowEvent<string, JsonValue> }> {
|
|
367
|
+
await this.flush();
|
|
368
|
+
const state = await this.#state();
|
|
369
|
+
const won = state.races.get(name);
|
|
370
|
+
const recorded = won && state.events.get(won.ids[0] as string);
|
|
371
|
+
if (won && recorded) {
|
|
372
|
+
// A crash between its win and its cancels left a loser to cancel now.
|
|
373
|
+
for (const [branch, timer] of owned)
|
|
374
|
+
if (branch !== won.branch && !state.timers.get(timer)?.cancelled)
|
|
375
|
+
await this.cancelTimer(timer);
|
|
376
|
+
return { branch: won.branch, event: eventOf(recorded) };
|
|
377
|
+
}
|
|
378
|
+
const [event] = await this.#wait(
|
|
379
|
+
{
|
|
380
|
+
...(label === undefined ? {} : { label }),
|
|
381
|
+
take: "oldest",
|
|
382
|
+
takes: branches.map(([, take]) => take),
|
|
383
|
+
race: { name, branches, owned },
|
|
384
|
+
},
|
|
385
|
+
signal,
|
|
386
|
+
);
|
|
387
|
+
const taken = event as WorkflowEvent<string, JsonValue>;
|
|
388
|
+
return { branch: branchOf(branches, taken) as string, event: taken };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async #wait(
|
|
392
|
+
spec: {
|
|
393
|
+
label?: string;
|
|
394
|
+
take: "all" | "oldest";
|
|
395
|
+
takes?: WaitTake[];
|
|
396
|
+
/** Reads the takes again at each pass: a select's times read from state. */
|
|
397
|
+
read?: () => readonly WaitTake[];
|
|
398
|
+
returns?: boolean;
|
|
399
|
+
race?: {
|
|
400
|
+
name: string;
|
|
401
|
+
branches: readonly (readonly [string, WaitTake])[];
|
|
402
|
+
owned: ReadonlyMap<string, string>;
|
|
403
|
+
};
|
|
404
|
+
},
|
|
405
|
+
signal: AbortSignal | undefined,
|
|
406
|
+
): Promise<WorkflowEvent<string, JsonValue>[]> {
|
|
407
|
+
const { label, take, read, returns, race } = spec;
|
|
408
|
+
let { takes } = spec;
|
|
409
|
+
const { runDir } = this.options;
|
|
410
|
+
await this.flush();
|
|
411
|
+
// Outside a race, the wait acknowledges the held batch, unless it hands it back.
|
|
412
|
+
const loop = race === undefined;
|
|
413
|
+
/** The wait's line: the first carries what it acknowledges, or `returns`; one rewritten neither. */
|
|
414
|
+
const waitLine = (first: { acks?: string[] } | undefined): RunLine => ({
|
|
415
|
+
kind: "wait",
|
|
416
|
+
attempt: this.#attempt,
|
|
417
|
+
at: this.#at(),
|
|
418
|
+
...(label === undefined ? {} : { label }),
|
|
419
|
+
...(takes === undefined ? {} : { takes }),
|
|
420
|
+
...(race === undefined ? {} : { race: race.name }),
|
|
421
|
+
...(first?.acks ? { acks: first.acks } : {}),
|
|
422
|
+
...(first && returns ? { returns: true as const } : {}),
|
|
423
|
+
});
|
|
424
|
+
// What it acknowledges is read and written in one step, ordered with every took this attempt
|
|
425
|
+
// writes: a wait beside it, in a parallel branch, can't hold a batch in between.
|
|
426
|
+
await this.#serial(async () => {
|
|
427
|
+
const acks = loop && !returns ? acksOf(await this.#state()) : undefined;
|
|
428
|
+
await this.#append(waitLine(acks ? { acks } : {}));
|
|
429
|
+
});
|
|
430
|
+
/**
|
|
431
|
+
* Takes what is ready now, read again on the chain every take is written on: a wait beside it,
|
|
432
|
+
* in a parallel branch, can't have taken the same event in between. Undefined with none.
|
|
433
|
+
*/
|
|
434
|
+
const took = (): Promise<WaitEvent[] | undefined> =>
|
|
435
|
+
this.#serial(async () => {
|
|
436
|
+
const state = await this.#state();
|
|
437
|
+
const ready = readyFor(state, takes);
|
|
438
|
+
if (ready.length === 0) return undefined;
|
|
439
|
+
const ids = take === "oldest" ? ready.slice(0, 1) : ready;
|
|
440
|
+
const events = ids.map((id) => state.events.get(id) as WaitEvent);
|
|
441
|
+
await this.#took(events, race);
|
|
442
|
+
return events;
|
|
443
|
+
});
|
|
444
|
+
for (;;) {
|
|
445
|
+
signal?.throwIfAborted();
|
|
446
|
+
if (read) {
|
|
447
|
+
// A time moved while it waited, by an agent's onTimeout or a parallel branch: a parked
|
|
448
|
+
// run's `until`, and its wake, read the last wait line.
|
|
449
|
+
const fresh = [...read()];
|
|
450
|
+
if (JSON.stringify(fresh) !== JSON.stringify(takes)) {
|
|
451
|
+
takes = fresh;
|
|
452
|
+
await this.#append(waitLine(undefined));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// Armed before reading, so a nudge between the read and the wait is not lost.
|
|
456
|
+
let wake: () => void = () => undefined;
|
|
457
|
+
const woken = new Promise<void>((resolve) => {
|
|
458
|
+
wake = resolve;
|
|
459
|
+
});
|
|
460
|
+
this.#wakes.add(wake);
|
|
461
|
+
try {
|
|
462
|
+
// Before the read too: what is appended after the read changes it, nudged or not.
|
|
463
|
+
const size = await sizeOf(runDir);
|
|
464
|
+
const state = await this.#state();
|
|
465
|
+
if (readyFor(state, takes).length > 0) {
|
|
466
|
+
const events = await took();
|
|
467
|
+
if (events) return events.map(eventOf);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const time = pendingTimes(state, takes)[0];
|
|
471
|
+
if (time && Date.parse(time.due) <= this.#now()) {
|
|
472
|
+
const taken = await this.#takeTime(time, takes, read);
|
|
473
|
+
if (taken) return [eventOf(taken)];
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const next = nextTimer(state, takes);
|
|
477
|
+
if (next !== undefined && next <= this.#now()) continue;
|
|
478
|
+
const { parking } = this.options;
|
|
479
|
+
const idle = parking?.idle() ?? false;
|
|
480
|
+
// Before the handlers, on the same clock: a window that passes between the two is due by the
|
|
481
|
+
// time `timeout` looks, never parked past.
|
|
482
|
+
const live = parking && idle ? parking.liveUntil() : undefined;
|
|
483
|
+
// Read again after a handler: what arrived meanwhile is taken first.
|
|
484
|
+
if (parking && idle && (await parking.timeout(signal))) continue;
|
|
485
|
+
if (parking && live !== undefined) await parking.live?.(live);
|
|
486
|
+
else if (parking && idle) {
|
|
487
|
+
if (next !== undefined && (await parking.skip?.(next))) continue;
|
|
488
|
+
const wait: WaitingJson = {
|
|
489
|
+
...(label === undefined ? {} : { label }),
|
|
490
|
+
...(next === undefined ? {} : { until: new Date(next).toISOString() }),
|
|
491
|
+
};
|
|
492
|
+
// Checked once more under the lock, which every sender appends under: an event sent now is
|
|
493
|
+
// either seen here, or sent after `parked` is written and continues the run.
|
|
494
|
+
const parked = await this.options.locked(async () => {
|
|
495
|
+
const now = this.#now();
|
|
496
|
+
if (hasWork(await readWaits(runDir, now), takes)) return false;
|
|
497
|
+
await parking.park(wait);
|
|
498
|
+
return true;
|
|
499
|
+
});
|
|
500
|
+
if (parked) {
|
|
501
|
+
this.parked = new RunParked(wait);
|
|
502
|
+
this.#parked.reject(this.parked);
|
|
503
|
+
throw this.parked;
|
|
504
|
+
}
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
// Only a nudge, the size poll or the next timer wakes it, or, with a turn running, its end,
|
|
508
|
+
// which may let the run park: looked at each second. In a window, its end.
|
|
509
|
+
const longest =
|
|
510
|
+
parking && !idle
|
|
511
|
+
? POLL_MILLISECONDS
|
|
512
|
+
: live !== undefined
|
|
513
|
+
? live - this.#now()
|
|
514
|
+
: Number.POSITIVE_INFINITY;
|
|
515
|
+
await this.#idle(
|
|
516
|
+
woken,
|
|
517
|
+
size,
|
|
518
|
+
Math.min(longest, next === undefined ? longest : next - this.#now()),
|
|
519
|
+
signal,
|
|
520
|
+
);
|
|
521
|
+
} finally {
|
|
522
|
+
this.#wakes.delete(wake);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The wait lines and takes this attempt writes, one at a time. */
|
|
528
|
+
#serialized: Promise<void> = Promise.resolve();
|
|
529
|
+
|
|
530
|
+
#serial<T>(work: () => Promise<T>): Promise<T> {
|
|
531
|
+
const done = this.#serialized.then(work);
|
|
532
|
+
this.#serialized = done.then(
|
|
533
|
+
() => undefined,
|
|
534
|
+
() => undefined,
|
|
535
|
+
);
|
|
536
|
+
return done;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Writes what a wait took: a `took`, or a race's win and its cancels. */
|
|
540
|
+
async #took(
|
|
541
|
+
events: readonly WaitEvent[],
|
|
542
|
+
race:
|
|
543
|
+
| {
|
|
544
|
+
name: string;
|
|
545
|
+
branches: readonly (readonly [string, WaitTake])[];
|
|
546
|
+
owned: ReadonlyMap<string, string>;
|
|
547
|
+
}
|
|
548
|
+
| undefined,
|
|
549
|
+
): Promise<void> {
|
|
550
|
+
const entries = events.map(takenOf);
|
|
551
|
+
const at = this.#at();
|
|
552
|
+
if (!race) {
|
|
553
|
+
await this.#append({
|
|
554
|
+
kind: "took",
|
|
555
|
+
attempt: this.#attempt,
|
|
556
|
+
at,
|
|
557
|
+
took: entries,
|
|
558
|
+
});
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const branch = branchOf(race.branches, events[0] as WaitEvent) as string;
|
|
562
|
+
await this.#append({
|
|
563
|
+
kind: "race",
|
|
564
|
+
attempt: this.#attempt,
|
|
565
|
+
at,
|
|
566
|
+
name: race.name,
|
|
567
|
+
branch,
|
|
568
|
+
took: entries,
|
|
569
|
+
});
|
|
570
|
+
// A crash before these leaves them to the rerun, which answers from the win and cancels them.
|
|
571
|
+
for (const [owner, timer] of race.owned) {
|
|
572
|
+
const known = this.#timers.get(timer);
|
|
573
|
+
if (owner === branch || !known || known.cancelled) continue;
|
|
574
|
+
known.cancelled = true;
|
|
575
|
+
await this.#append(this.#cancelled(known));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* A select's time read from state, due with no event ready: taken, its `took` naming its branch and
|
|
581
|
+
* time. Undefined when it moved meanwhile, or an event came, which goes first.
|
|
582
|
+
*/
|
|
583
|
+
async #takeTime(
|
|
584
|
+
time: { branch: string; due: string },
|
|
585
|
+
takes: readonly WaitTake[] | undefined,
|
|
586
|
+
read: (() => readonly WaitTake[]) | undefined,
|
|
587
|
+
): Promise<WaitEvent | undefined> {
|
|
588
|
+
// Read again on the takes' chain: a wait beside it may have taken it, or an event come.
|
|
589
|
+
return this.#serial(async () => {
|
|
590
|
+
const again = await readWaits(this.options.runDir, this.#now());
|
|
591
|
+
// Code beside the wait may have moved it while the lines were read.
|
|
592
|
+
if (read && !read().some((take) => take.from === time.branch && take.due === time.due))
|
|
593
|
+
return undefined;
|
|
594
|
+
const id = DERIVED_IDS.at(time.branch, time.due);
|
|
595
|
+
if (again.events.has(id) || readyFor(again, takes).length > 0) return undefined;
|
|
596
|
+
const event: WaitEvent = {
|
|
597
|
+
id,
|
|
598
|
+
name: "at:due",
|
|
599
|
+
data: {},
|
|
600
|
+
from: time.branch,
|
|
601
|
+
at: time.due,
|
|
602
|
+
origin: "time",
|
|
603
|
+
};
|
|
604
|
+
await this.#took([event], undefined);
|
|
605
|
+
return event;
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async #idle(
|
|
610
|
+
woken: Promise<void>,
|
|
611
|
+
size: number,
|
|
612
|
+
ms: number,
|
|
613
|
+
signal: AbortSignal | undefined,
|
|
614
|
+
): Promise<void> {
|
|
615
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
616
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
617
|
+
let onAbort: (() => void) | undefined;
|
|
618
|
+
try {
|
|
619
|
+
await Promise.race([
|
|
620
|
+
woken,
|
|
621
|
+
new Promise<void>((resolve) => {
|
|
622
|
+
timer = setTimeout(resolve, Math.max(0, Math.min(ms, 2 ** 31 - 1)));
|
|
623
|
+
}),
|
|
624
|
+
new Promise<void>((resolve) => {
|
|
625
|
+
poll = setInterval(() => {
|
|
626
|
+
void sizeOf(this.options.runDir).then((now) => {
|
|
627
|
+
if (now !== size) resolve();
|
|
628
|
+
});
|
|
629
|
+
}, POLL_MILLISECONDS);
|
|
630
|
+
}),
|
|
631
|
+
new Promise<void>((_, reject) => {
|
|
632
|
+
if (!signal) return;
|
|
633
|
+
onAbort = () => reject(signal.reason);
|
|
634
|
+
if (signal.aborted) onAbort();
|
|
635
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
636
|
+
}),
|
|
637
|
+
]);
|
|
638
|
+
} finally {
|
|
639
|
+
clearTimeout(timer);
|
|
640
|
+
clearInterval(poll);
|
|
641
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
#events(state: WaitState, ids: readonly string[]): WorkflowEvent<string, JsonValue>[] {
|
|
646
|
+
return ids.flatMap((id) => {
|
|
647
|
+
const event = state.events.get(id);
|
|
648
|
+
return event ? [eventOf(event)] : [];
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|