@bevel-software/ael 0.0.0-stage → 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 +13 -0
- package/README.md +32 -3
- package/bin/bevel-agent.mjs +15 -0
- package/executor-entrypoint.sh +41 -0
- package/executor.Dockerfile +37 -0
- package/package.json +61 -6
- package/src/agent/agent-file.ts +242 -0
- package/src/cli/main.ts +73 -0
- package/src/cli/run-local.ts +224 -0
- package/src/cli/run-step.ts +147 -0
- package/src/executor/activities.ts +295 -0
- package/src/executor/docker-cli.ts +148 -0
- package/src/executor/driver.ts +80 -0
- package/src/executor/harness-mount.ts +66 -0
- package/src/executor/in-place.ts +180 -0
- package/src/executor/main.ts +197 -0
- package/src/executor/personal.ts +355 -0
- package/src/executor/slots.ts +60 -0
- package/src/harness/claude-code.ts +397 -0
- package/src/harness/pi-in-process.ts +444 -0
- package/src/harness/session-state.ts +86 -0
- package/src/harness/stall-guard.ts +97 -0
- package/src/index.ts +164 -0
- package/src/interpreter/contract.ts +403 -0
- package/src/interpreter/dispatcher.workflow.ts +412 -0
- package/src/interpreter/interpret.workflow.ts +907 -0
- package/src/interpreter/scheduled.workflow.ts +28 -0
- package/src/interpreter/workflows.ts +8 -0
- package/src/run/contract.ts +150 -0
- package/src/run/outcome.ts +63 -0
- package/src/run/reporter.ts +127 -0
- package/src/run/system-prompt.ts +35 -0
- package/src/run/verdict.ts +46 -0
- package/src/shared/front-matter.ts +27 -0
- package/src/shared/redact.ts +78 -0
- package/src/shared/schedule.ts +93 -0
- package/src/temporal/payload-codec.ts +84 -0
- package/src/transcript/render.ts +106 -0
- package/src/workflow/definition.ts +196 -0
- package/src/workflow/failure-policy.ts +100 -0
- package/src/workflow/parameters.ts +175 -0
- package/src/workflow/workflow-file.ts +457 -0
- package/src/workitem/node.ts +170 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { ParentClosePolicy, condition, continueAsNew, defineQuery, defineSignal, getExternalWorkflowHandle, log, proxyActivities, setHandler, startChild, workflowInfo } from '@temporalio/workflow';
|
|
2
|
+
import { PLATFORM_TASK_QUEUE, RUN_AT_GATE_SIGNAL, RUN_NEEDS_SLOT_SIGNAL, RUN_SETTLED_SIGNAL, SLOT_GRANTED_SIGNAL, type InterpretInput, type InterpreterActivities } from './contract.js';
|
|
3
|
+
import { interpret } from './interpret.workflow.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The dispatcher: one per trigger. It receives the trigger's firings as
|
|
7
|
+
* signals, orders them by priority and filing time, keeps at most `cap` runs
|
|
8
|
+
* at work, and allows one active run per key (a kb node) with one follow-up
|
|
9
|
+
* queued behind it. Temporal task queues are FIFO; this is where priority
|
|
10
|
+
* lives. It is the one place that admits a firing: a change while a run of
|
|
11
|
+
* the node is active becomes its follow-up, a change while one waits
|
|
12
|
+
* replaces it, and a firing that will never run (replaced, or waiting when
|
|
13
|
+
* the trigger is removed) has its run thread closed on the platform.
|
|
14
|
+
*
|
|
15
|
+
* The cap counts runs at work, not runs in flight: a run waiting at a gate
|
|
16
|
+
* says so and its slot is free for another, while its key stays held. Past
|
|
17
|
+
* its gate it asks for a slot and goes on when it is told it has one — ahead
|
|
18
|
+
* of the firings that wait, since work begun is finished before more starts.
|
|
19
|
+
*
|
|
20
|
+
* The trigger's row reaches it as a `settings` signal: its cap and
|
|
21
|
+
* priorities, and whether it is paused — a paused dispatcher keeps its
|
|
22
|
+
* queue and starts nothing.
|
|
23
|
+
*
|
|
24
|
+
* A run's end reaches the dispatcher three ways: the child's result, in the
|
|
25
|
+
* execution that started it; the `runSettled` signal every run sends its
|
|
26
|
+
* parent as it finishes; and, for the runs an execution inherited from the
|
|
27
|
+
* one before it, Temporal's own word, asked after a while without news. The
|
|
28
|
+
* signal is what lets the dispatcher continue as new while runs are in
|
|
29
|
+
* flight — a busy trigger never has an idle moment, and a history that only
|
|
30
|
+
* ever grows is terminated by Temporal with every queued firing in it — and
|
|
31
|
+
* the new execution, which started none of the runs it inherits, still
|
|
32
|
+
* learns when each one is over. The check behind it is for the run that
|
|
33
|
+
* never signals: terminated, or ended without finishing. Without it such a
|
|
34
|
+
* run would hold its key, and a slot under the cap, for ever.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export interface Enqueued {
|
|
38
|
+
/** One active run per key; a `kb` trigger uses the node's path. */
|
|
39
|
+
key: string;
|
|
40
|
+
/** The level as the work item writes it; ranked by `priorities`. */
|
|
41
|
+
priority: string | null;
|
|
42
|
+
/** ISO time the firing was filed; earlier first within a level. */
|
|
43
|
+
filedAt: string;
|
|
44
|
+
run: InterpretInput;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The trigger row's part of the dispatcher's state, sent whenever the row changes. */
|
|
48
|
+
export interface DispatchSettings {
|
|
49
|
+
/** Runs at work at once; a run waiting at a gate is not counted. */
|
|
50
|
+
cap: number;
|
|
51
|
+
/** Priority levels, highest first; a level not listed ranks after every listed one. */
|
|
52
|
+
priorities: string[];
|
|
53
|
+
/** A paused dispatcher keeps its queue and starts nothing. */
|
|
54
|
+
paused: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DispatchInput extends DispatchSettings {
|
|
58
|
+
triggerId: string;
|
|
59
|
+
/** Carried across continue-as-new. */
|
|
60
|
+
queued?: Enqueued[];
|
|
61
|
+
followUps?: Enqueued[];
|
|
62
|
+
/** The runs in flight when the previous execution continued as new: key and run id. */
|
|
63
|
+
active?: Array<[string, string]>;
|
|
64
|
+
/** The runs in flight that had given their slot back at a gate, by run id. */
|
|
65
|
+
atGate?: string[];
|
|
66
|
+
/** Those of them past their gate and waiting for a slot, first asked first. */
|
|
67
|
+
resuming?: string[];
|
|
68
|
+
replaced?: number;
|
|
69
|
+
/** Continue as new once this many runs were started by one execution (tests lower it). */
|
|
70
|
+
continueAfterStarts?: number;
|
|
71
|
+
/** Continue as new once this many signals were handled by one execution, starts or none (tests lower it). */
|
|
72
|
+
continueAfterSignals?: number;
|
|
73
|
+
/** How long an inherited run goes without news before Temporal is asked whether it still runs (tests lower it). */
|
|
74
|
+
carriedRunCheckMs?: number;
|
|
75
|
+
/** When the inherited runs are next asked about (epoch ms), carried across continue-as-new so rotating often never postpones it. */
|
|
76
|
+
nextCarriedCheckAt?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What the dispatcher holds, for the Triggers screen: the keys in flight,
|
|
81
|
+
* the keys waiting in priority order, the keys with a follow-up queued
|
|
82
|
+
* behind their active run, how many firings were dropped because a newer
|
|
83
|
+
* one for the same key replaced them (a change filed twice while a run
|
|
84
|
+
* waited, or while it ran) — those are the trigger's skipped fires — and
|
|
85
|
+
* the settings it runs with.
|
|
86
|
+
*/
|
|
87
|
+
export interface DispatcherState {
|
|
88
|
+
queued: string[];
|
|
89
|
+
active: string[];
|
|
90
|
+
followUps: string[];
|
|
91
|
+
replaced: number;
|
|
92
|
+
paused: boolean;
|
|
93
|
+
cap: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const enqueueSignal = defineSignal<[Enqueued]>('enqueue');
|
|
97
|
+
export const settingsSignal = defineSignal<[DispatchSettings]>('settings');
|
|
98
|
+
export const stopDispatcherSignal = defineSignal<[]>('stop');
|
|
99
|
+
/** A run this dispatcher started is over (its run id); sent by the run itself. */
|
|
100
|
+
export const runSettledSignal = defineSignal<[string]>(RUN_SETTLED_SIGNAL);
|
|
101
|
+
/** A run this dispatcher started waits at a gate (its run id): its slot is free. */
|
|
102
|
+
export const runAtGateSignal = defineSignal<[string]>(RUN_AT_GATE_SIGNAL);
|
|
103
|
+
/** A run past its gate asks for a slot (its run id); answered with `SLOT_GRANTED_SIGNAL`. */
|
|
104
|
+
export const runNeedsSlotSignal = defineSignal<[string]>(RUN_NEEDS_SLOT_SIGNAL);
|
|
105
|
+
export const dispatcherStateQuery = defineQuery<DispatcherState>('dispatcherState');
|
|
106
|
+
/**
|
|
107
|
+
* The run ids this dispatcher holds — queued, waiting as a follow-up, or in
|
|
108
|
+
* flight. A run thread of this trigger that is not among them has no
|
|
109
|
+
* dispatcher behind it (its firing was never admitted), whatever the
|
|
110
|
+
* dispatcher's own state; the platform's orphan check asks this.
|
|
111
|
+
*/
|
|
112
|
+
export const dispatcherRunsQuery = defineQuery<string[]>('dispatcherRuns');
|
|
113
|
+
|
|
114
|
+
/** Continue as new once this many runs were started by one execution, to keep the history short. */
|
|
115
|
+
const CONTINUE_AFTER_STARTS = 500;
|
|
116
|
+
/**
|
|
117
|
+
* Continue as new once this many signals were handled by one execution,
|
|
118
|
+
* whether or not anything started: a paused dispatcher, or one at its cap,
|
|
119
|
+
* still takes every firing, replacement and settings change into its
|
|
120
|
+
* history, and a history that only ever grows is terminated by Temporal
|
|
121
|
+
* with the queue in it. Well under the server's own suggestion, which is
|
|
122
|
+
* honoured too when it comes first.
|
|
123
|
+
*/
|
|
124
|
+
const CONTINUE_AFTER_SIGNALS = 1000;
|
|
125
|
+
/**
|
|
126
|
+
* How long an inherited run goes without news before Temporal is asked
|
|
127
|
+
* whether it still runs. The run's own signal is the fast path; this is the
|
|
128
|
+
* net under it, so minutes are fine — a slot held a few minutes past a run
|
|
129
|
+
* that died is nothing, a slot held for ever is a trigger that stopped.
|
|
130
|
+
*/
|
|
131
|
+
const CARRIED_RUN_CHECK_MS = 5 * 60_000;
|
|
132
|
+
|
|
133
|
+
/** The workflow id a run is started under, and reached by afterwards. */
|
|
134
|
+
const runWorkflowId = (runId: string) => `run-${runId}`;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A withdrawal closes a firing's run thread on the platform: cleanup that
|
|
138
|
+
* must land, so it is retried the way a step's end is — for about a day,
|
|
139
|
+
* the interval capped — rather than given up after a few tries, after which
|
|
140
|
+
* a replaced or removed firing would read as running for ever.
|
|
141
|
+
*/
|
|
142
|
+
const platform = proxyActivities<Pick<InterpreterActivities, 'withdrawRun'>>({
|
|
143
|
+
startToCloseTimeout: '1 minute',
|
|
144
|
+
retry: { maximumAttempts: 300, initialInterval: '2 seconds', backoffCoefficient: 2, maximumInterval: '5 minutes' },
|
|
145
|
+
});
|
|
146
|
+
/** The check of inherited runs: a read, asked again at the next interval when it fails. */
|
|
147
|
+
const temporal = proxyActivities<Pick<InterpreterActivities, 'runsInFlight'>>({
|
|
148
|
+
startToCloseTimeout: '1 minute',
|
|
149
|
+
retry: { maximumAttempts: 3, initialInterval: '2 seconds' },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export async function dispatch(input: DispatchInput): Promise<void> {
|
|
153
|
+
const queued: Enqueued[] = [...(input.queued ?? [])];
|
|
154
|
+
const followUps = new Map<string, Enqueued>((input.followUps ?? []).map((f) => [f.key, f]));
|
|
155
|
+
/** key → run id */
|
|
156
|
+
const active = new Map<string, string>(input.active ?? []);
|
|
157
|
+
/**
|
|
158
|
+
* The runs in flight that this execution inherited and did not start: no
|
|
159
|
+
* child result will ever come for them, so their end is learned from the
|
|
160
|
+
* run's own signal — or, after a while without one, from Temporal.
|
|
161
|
+
*/
|
|
162
|
+
const carried = new Set<string>(active.values());
|
|
163
|
+
/** The runs in flight that gave their slot back at a gate: they hold their key and no slot. */
|
|
164
|
+
const atGate = new Set<string>(input.atGate ?? []);
|
|
165
|
+
/** Those of them past their gate and waiting for a slot, first asked first. */
|
|
166
|
+
const resuming: string[] = [...(input.resuming ?? [])];
|
|
167
|
+
/** The runs to be told they hold a slot. */
|
|
168
|
+
const granted: string[] = [];
|
|
169
|
+
const atWork = () => active.size - atGate.size;
|
|
170
|
+
const continueAfterStarts = input.continueAfterStarts ?? CONTINUE_AFTER_STARTS;
|
|
171
|
+
const continueAfterSignals = input.continueAfterSignals ?? CONTINUE_AFTER_SIGNALS;
|
|
172
|
+
const carriedRunCheckMs = input.carriedRunCheckMs ?? CARRIED_RUN_CHECK_MS;
|
|
173
|
+
let cap = input.cap;
|
|
174
|
+
let priorities = input.priorities;
|
|
175
|
+
let paused = input.paused;
|
|
176
|
+
let stopped = false;
|
|
177
|
+
let settled = 0;
|
|
178
|
+
let started = 0;
|
|
179
|
+
/** Signals handled by this execution: each is history, whether or not it started anything. */
|
|
180
|
+
let signalled = 0;
|
|
181
|
+
let replaced = input.replaced ?? 0;
|
|
182
|
+
/** Firings that will never run, closed on the platform; awaited before this execution ends. */
|
|
183
|
+
const withdrawals: Promise<void>[] = [];
|
|
184
|
+
|
|
185
|
+
const withdraw = (item: Enqueued, reason: string) => {
|
|
186
|
+
// Logged only once the day-long retry is spent: the dispatcher goes on
|
|
187
|
+
// for the firings that can still run rather than failing with them.
|
|
188
|
+
withdrawals.push(platform.withdrawRun({ runId: item.run.runId, reason }).catch((err) => log.warn(`run ${item.run.runId} not withdrawn after a day of retries: ${String(err)}`)));
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Every withdrawal started so far, landed — the ones started while this
|
|
192
|
+
* waited included: a signal handled during the wait may add one, and a
|
|
193
|
+
* snapshot of the list would leave it behind.
|
|
194
|
+
*/
|
|
195
|
+
let drained = 0;
|
|
196
|
+
const drainWithdrawals = async () => {
|
|
197
|
+
while (drained < withdrawals.length) {
|
|
198
|
+
const upTo = withdrawals.length;
|
|
199
|
+
await Promise.all(withdrawals.slice(drained, upTo));
|
|
200
|
+
drained = upTo;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const rank = (p: string | null) => {
|
|
205
|
+
if (p === null) return priorities.length;
|
|
206
|
+
const i = priorities.findIndex((level) => level.toLowerCase() === p.toLowerCase());
|
|
207
|
+
return i === -1 ? priorities.length : i;
|
|
208
|
+
};
|
|
209
|
+
const sortQueue = () => queued.sort((a, b) => rank(a.priority) - rank(b.priority) || a.filedAt.localeCompare(b.filedAt));
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A run is over: its key is free, its follow-up (if any) joins the queue.
|
|
213
|
+
* By run id, not by key: the run's own signal and its result both say so,
|
|
214
|
+
* and by the time the second arrives a follow-up may hold the key — that
|
|
215
|
+
* follow-up keeps it. A run nobody holds is nothing to settle.
|
|
216
|
+
*/
|
|
217
|
+
const settleRun = (runId: string) => {
|
|
218
|
+
carried.delete(runId);
|
|
219
|
+
atGate.delete(runId);
|
|
220
|
+
if (resuming.includes(runId)) resuming.splice(resuming.indexOf(runId), 1);
|
|
221
|
+
for (const [key, id] of active) {
|
|
222
|
+
if (id !== runId) continue;
|
|
223
|
+
active.delete(key);
|
|
224
|
+
settled += 1;
|
|
225
|
+
const next = followUps.get(key);
|
|
226
|
+
if (next) {
|
|
227
|
+
followUps.delete(key);
|
|
228
|
+
queued.push(next);
|
|
229
|
+
sortQueue();
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
setHandler(enqueueSignal, (item) => {
|
|
236
|
+
signalled += 1;
|
|
237
|
+
// A firing admitted while the dispatcher stops (the row went between the
|
|
238
|
+
// listener's read and its signal) will never run: told so now, never
|
|
239
|
+
// queued behind a stop that has already withdrawn the queue.
|
|
240
|
+
if (stopped) {
|
|
241
|
+
withdraw(item, 'the trigger was removed');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (active.has(item.key)) {
|
|
245
|
+
// A change during a run queues ONE follow-up; a newer one replaces it.
|
|
246
|
+
const prior = followUps.get(item.key);
|
|
247
|
+
if (prior) {
|
|
248
|
+
replaced += 1;
|
|
249
|
+
if (prior.run.runId !== item.run.runId) withdraw(prior, 'replaced by a newer firing for the same node');
|
|
250
|
+
}
|
|
251
|
+
followUps.set(item.key, item);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const i = queued.findIndex((q) => q.key === item.key);
|
|
255
|
+
if (i === -1) queued.push(item);
|
|
256
|
+
else {
|
|
257
|
+
replaced += 1;
|
|
258
|
+
if (queued[i].run.runId !== item.run.runId) withdraw(queued[i], 'replaced by a newer firing for the same node');
|
|
259
|
+
queued[i] = item;
|
|
260
|
+
}
|
|
261
|
+
sortQueue();
|
|
262
|
+
});
|
|
263
|
+
setHandler(settingsSignal, (settings) => {
|
|
264
|
+
signalled += 1;
|
|
265
|
+
cap = settings.cap;
|
|
266
|
+
priorities = settings.priorities;
|
|
267
|
+
paused = settings.paused;
|
|
268
|
+
sortQueue();
|
|
269
|
+
});
|
|
270
|
+
setHandler(stopDispatcherSignal, () => {
|
|
271
|
+
stopped = true;
|
|
272
|
+
});
|
|
273
|
+
setHandler(runSettledSignal, (runId) => {
|
|
274
|
+
signalled += 1;
|
|
275
|
+
settleRun(runId);
|
|
276
|
+
});
|
|
277
|
+
setHandler(runAtGateSignal, (runId) => {
|
|
278
|
+
signalled += 1;
|
|
279
|
+
if ([...active.values()].includes(runId)) atGate.add(runId);
|
|
280
|
+
});
|
|
281
|
+
setHandler(runNeedsSlotSignal, (runId) => {
|
|
282
|
+
signalled += 1;
|
|
283
|
+
// A run that asks again while it waits keeps its place. One this
|
|
284
|
+
// dispatcher holds no slot back for (its word from the gate never came,
|
|
285
|
+
// or it is not a run held here) has nothing to wait for and is told so.
|
|
286
|
+
if (!atGate.has(runId)) granted.push(runId);
|
|
287
|
+
else if (!resuming.includes(runId)) resuming.push(runId);
|
|
288
|
+
});
|
|
289
|
+
setHandler(dispatcherStateQuery, () => ({ queued: queued.map((q) => q.key), active: [...active.keys()], followUps: [...followUps.keys()], replaced, paused, cap }));
|
|
290
|
+
setHandler(dispatcherRunsQuery, () => [...queued.map((q) => q.run.runId), ...[...followUps.values()].map((f) => f.run.runId), ...active.values()]);
|
|
291
|
+
// A queue handed in (a continue-as-new, a seeded start) is ordered the same way a signalled one is.
|
|
292
|
+
sortQueue();
|
|
293
|
+
|
|
294
|
+
// Asked before every start, `stopped` included: a stop that lands while a
|
|
295
|
+
// child is being started ends the starting there, and the loop's top
|
|
296
|
+
// withdraws what is left.
|
|
297
|
+
const mayStart = () => !stopped && !paused && queued.length > 0 && atWork() < cap;
|
|
298
|
+
// A run past its gate is in flight already, so a pause, which starts
|
|
299
|
+
// nothing new, does not hold it; a stop ends the cap with the dispatcher.
|
|
300
|
+
const mayResume = () => resuming.length > 0 && (stopped || atWork() < cap);
|
|
301
|
+
/**
|
|
302
|
+
* Time to continue as new: this execution started or handled enough to
|
|
303
|
+
* keep its history short, or Temporal says so. Asked in the wait as well
|
|
304
|
+
* as after the starts — a dispatcher that is paused, or at its cap, starts
|
|
305
|
+
* nothing and would otherwise never look, while every firing it takes
|
|
306
|
+
* lengthens a history that Temporal ends with the queue in it.
|
|
307
|
+
*/
|
|
308
|
+
const rotate = () => started >= continueAfterStarts || signalled >= continueAfterSignals || workflowInfo().continueAsNewSuggested;
|
|
309
|
+
const wakeUp = () => stopped || mayStart() || mayResume() || granted.length > 0 || settled > 0 || rotate();
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The inherited runs without news: those Temporal no longer runs are
|
|
313
|
+
* settled as if they had signalled. A check that fails is asked again at
|
|
314
|
+
* the next interval; a signal that lands while it runs has settled its
|
|
315
|
+
* run already, and the answer about that run is then nobody's.
|
|
316
|
+
*/
|
|
317
|
+
const checkCarried = async () => {
|
|
318
|
+
const asked = [...carried];
|
|
319
|
+
try {
|
|
320
|
+
const { running } = await temporal.runsInFlight({ runIds: asked });
|
|
321
|
+
for (const runId of asked) {
|
|
322
|
+
if (running.includes(runId) || !carried.has(runId)) continue;
|
|
323
|
+
log.warn(`run ${runId} is no longer running and never said so; its key is freed`);
|
|
324
|
+
settleRun(runId);
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
log.warn(`inherited runs not checked: ${String(err)}`);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* When the inherited runs are next asked about: a fixed deadline, not a
|
|
333
|
+
* quiet spell — on a busy trigger something settles or arrives every few
|
|
334
|
+
* minutes, and a wait restarted by each would never reach the check, so a
|
|
335
|
+
* dead run would hold its slot for ever.
|
|
336
|
+
*/
|
|
337
|
+
let nextCarriedCheck = input.nextCarriedCheckAt ?? Date.now() + carriedRunCheckMs;
|
|
338
|
+
|
|
339
|
+
for (;;) {
|
|
340
|
+
// With inherited runs in flight the wait ends at the check's deadline at
|
|
341
|
+
// the latest. Without any, nothing but a signal or a start can change the
|
|
342
|
+
// picture, and the wait is open.
|
|
343
|
+
if (carried.size > 0) {
|
|
344
|
+
await condition(wakeUp, Math.max(1, nextCarriedCheck - Date.now()));
|
|
345
|
+
if (Date.now() >= nextCarriedCheck) {
|
|
346
|
+
await checkCarried();
|
|
347
|
+
nextCarriedCheck = Date.now() + carriedRunCheckMs;
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
await condition(wakeUp);
|
|
351
|
+
}
|
|
352
|
+
settled = 0;
|
|
353
|
+
// Free slots go to the runs past their gate first, then to new firings.
|
|
354
|
+
while (mayResume()) {
|
|
355
|
+
const runId = resuming.shift()!;
|
|
356
|
+
atGate.delete(runId);
|
|
357
|
+
granted.push(runId);
|
|
358
|
+
}
|
|
359
|
+
while (granted.length > 0) {
|
|
360
|
+
const runId = granted.shift()!;
|
|
361
|
+
try {
|
|
362
|
+
await getExternalWorkflowHandle(runWorkflowId(runId)).signal(SLOT_GRANTED_SIGNAL);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
// The run ended meanwhile: its own signal, or the check, settles it.
|
|
365
|
+
log.warn(`run ${runId} could not be told it holds a slot: ${String(err)}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (stopped) {
|
|
369
|
+
// The trigger is gone: what waited will never run, and its threads say
|
|
370
|
+
// so — the firings that arrive while those threads are being told
|
|
371
|
+
// included (the handler withdraws them itself once `stopped` is set,
|
|
372
|
+
// and the drain waits for every withdrawal, however late it started).
|
|
373
|
+
for (const item of [...queued, ...followUps.values()]) withdraw(item, 'the trigger was removed');
|
|
374
|
+
queued.length = 0;
|
|
375
|
+
followUps.clear();
|
|
376
|
+
await drainWithdrawals();
|
|
377
|
+
log.info(`dispatcher ${input.triggerId} stopped with ${active.size} active`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
while (mayStart()) {
|
|
381
|
+
const item = queued.shift()!;
|
|
382
|
+
started += 1;
|
|
383
|
+
const workflowId = runWorkflowId(item.run.runId);
|
|
384
|
+
active.set(item.key, item.run.runId);
|
|
385
|
+
const handle = await startChild(interpret, {
|
|
386
|
+
workflowId,
|
|
387
|
+
args: [{ ...item.run, capped: true }],
|
|
388
|
+
taskQueue: PLATFORM_TASK_QUEUE,
|
|
389
|
+
// A run outlives the dispatcher that started it: a continue-as-new or
|
|
390
|
+
// a stop must never cancel work in flight.
|
|
391
|
+
parentClosePolicy: ParentClosePolicy.ABANDON,
|
|
392
|
+
});
|
|
393
|
+
void handle
|
|
394
|
+
.result()
|
|
395
|
+
.catch((err) => log.warn(`run ${workflowId} ended with ${String(err)}`))
|
|
396
|
+
.finally(() => settleRun(item.run.runId));
|
|
397
|
+
}
|
|
398
|
+
// A stop that landed during the starts is handled at the top, never
|
|
399
|
+
// carried into a new execution that would not know of it.
|
|
400
|
+
if (stopped) continue;
|
|
401
|
+
// Whether or not runs are in flight: the ones in flight are carried, and
|
|
402
|
+
// their end reaches the next execution as a signal.
|
|
403
|
+
if (rotate()) {
|
|
404
|
+
await drainWithdrawals();
|
|
405
|
+
// A stop that landed while the withdrawals were being waited for is
|
|
406
|
+
// handled at the top too: a new execution would start un-stopped.
|
|
407
|
+
// So is a run that asked for a slot meanwhile and is owed its answer.
|
|
408
|
+
if (stopped || granted.length > 0) continue;
|
|
409
|
+
await continueAsNew<typeof dispatch>({ ...input, cap, priorities, paused, queued, followUps: [...followUps.values()], active: [...active], atGate: [...atGate], resuming, replaced, carriedRunCheckMs, nextCarriedCheckAt: nextCarriedCheck });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|