@earendil-works/pi-durable 1.2.0 → 1.4.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/CHANGELOG.md +56 -0
- package/README.md +128 -3
- package/dist/codemode/codemode.d.ts.map +1 -1
- package/dist/codemode/codemode.js +12 -4
- package/dist/codemode/codemode.js.map +1 -1
- package/dist/codemode/models.d.ts.map +1 -1
- package/dist/codemode/models.js +2 -0
- package/dist/codemode/models.js.map +1 -1
- package/dist/documents.d.ts +1 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js.map +1 -1
- package/dist/env/index.d.ts +1 -0
- package/dist/env/index.d.ts.map +1 -1
- package/dist/env/index.js.map +1 -1
- package/dist/env/node.d.ts.map +1 -1
- package/dist/env/node.js +11 -3
- package/dist/env/node.js.map +1 -1
- package/dist/harness/agent.d.ts +13 -2
- package/dist/harness/agent.d.ts.map +1 -1
- package/dist/harness/agent.js +39 -5
- package/dist/harness/agent.js.map +1 -1
- package/dist/harness/compaction.d.ts +3 -1
- package/dist/harness/compaction.d.ts.map +1 -1
- package/dist/harness/compaction.js +19 -4
- package/dist/harness/compaction.js.map +1 -1
- package/dist/harness/context.d.ts +5 -0
- package/dist/harness/context.d.ts.map +1 -1
- package/dist/harness/context.js +46 -12
- package/dist/harness/context.js.map +1 -1
- package/dist/harness/generation.d.ts.map +1 -1
- package/dist/harness/generation.js +67 -27
- package/dist/harness/generation.js.map +1 -1
- package/dist/harness/harness.d.ts +5 -2
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +78 -13
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/inbox.d.ts +2 -2
- package/dist/harness/inbox.d.ts.map +1 -1
- package/dist/harness/inbox.js +3 -3
- package/dist/harness/inbox.js.map +1 -1
- package/dist/harness/prompt.d.ts +1 -1
- package/dist/harness/prompt.d.ts.map +1 -1
- package/dist/harness/prompt.js +1 -1
- package/dist/harness/prompt.js.map +1 -1
- package/dist/harness/scheduler.d.ts +55 -10
- package/dist/harness/scheduler.d.ts.map +1 -1
- package/dist/harness/scheduler.js +617 -124
- package/dist/harness/scheduler.js.map +1 -1
- package/dist/harness/submissions.d.ts +7 -2
- package/dist/harness/submissions.d.ts.map +1 -1
- package/dist/harness/submissions.js +48 -17
- package/dist/harness/submissions.js.map +1 -1
- package/dist/harness/task-graph.d.ts +18 -1
- package/dist/harness/task-graph.d.ts.map +1 -1
- package/dist/harness/task-graph.js +50 -11
- package/dist/harness/task-graph.js.map +1 -1
- package/dist/harness/telemetry.d.ts +307 -0
- package/dist/harness/telemetry.d.ts.map +1 -0
- package/dist/harness/telemetry.js +136 -0
- package/dist/harness/telemetry.js.map +1 -0
- package/dist/harness/tool.d.ts.map +1 -1
- package/dist/harness/tool.js +54 -8
- package/dist/harness/tool.js.map +1 -1
- package/dist/harness/types.d.ts +110 -15
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/harness/util.d.ts +4 -0
- package/dist/harness/util.d.ts.map +1 -1
- package/dist/harness/util.js +4 -0
- package/dist/harness/util.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/session/session.d.ts +17 -6
- package/dist/session/session.d.ts.map +1 -1
- package/dist/session/session.js +64 -32
- package/dist/session/session.js.map +1 -1
- package/dist/session/transaction.d.ts +5 -1
- package/dist/session/transaction.d.ts.map +1 -1
- package/dist/session/transaction.js +26 -1
- package/dist/session/transaction.js.map +1 -1
- package/dist/storage/indexeddb.d.ts +60 -0
- package/dist/storage/indexeddb.d.ts.map +1 -0
- package/dist/storage/indexeddb.js +831 -0
- package/dist/storage/indexeddb.js.map +1 -0
- package/dist/storage/jsonl/storage.d.ts +16 -0
- package/dist/storage/jsonl/storage.d.ts.map +1 -1
- package/dist/storage/memory.js +1 -1
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite/cloudflare.d.ts +6 -43
- package/dist/storage/sqlite/cloudflare.d.ts.map +1 -1
- package/dist/storage/sqlite/cloudflare.js +6 -101
- package/dist/storage/sqlite/cloudflare.js.map +1 -1
- package/dist/storage/sqlite/database.d.ts +1 -36
- package/dist/storage/sqlite/database.d.ts.map +1 -1
- package/dist/storage/sqlite/database.js.map +1 -1
- package/dist/storage/sqlite/index.d.ts +2 -2
- package/dist/storage/sqlite/index.d.ts.map +1 -1
- package/dist/storage/sqlite/index.js.map +1 -1
- package/dist/storage/sqlite/migrations.d.ts +7 -6
- package/dist/storage/sqlite/migrations.d.ts.map +1 -1
- package/dist/storage/sqlite/migrations.js +116 -106
- package/dist/storage/sqlite/migrations.js.map +1 -1
- package/dist/storage/sqlite/node.d.ts +6 -39
- package/dist/storage/sqlite/node.d.ts.map +1 -1
- package/dist/storage/sqlite/node.js +8 -175
- package/dist/storage/sqlite/node.js.map +1 -1
- package/dist/storage/sqlite/schema.d.ts +45 -0
- package/dist/storage/sqlite/schema.d.ts.map +1 -0
- package/dist/storage/sqlite/schema.js +50 -0
- package/dist/storage/sqlite/schema.js.map +1 -0
- package/dist/storage/sqlite/storage.d.ts +10 -2
- package/dist/storage/sqlite/storage.d.ts.map +1 -1
- package/dist/storage/sqlite/storage.js +46 -43
- package/dist/storage/sqlite/storage.js.map +1 -1
- package/dist/testing/storage-conformance.d.ts.map +1 -1
- package/dist/testing/storage-conformance.js +17 -0
- package/dist/testing/storage-conformance.js.map +1 -1
- package/dist/timers.d.ts +21 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +72 -0
- package/dist/timers.js.map +1 -0
- package/dist/tools/image-processor.d.ts +4 -1
- package/dist/tools/image-processor.d.ts.map +1 -1
- package/dist/tools/image-processor.js +8 -18
- package/dist/tools/image-processor.js.map +1 -1
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +1 -1
- package/dist/tools/read.js.map +1 -1
- package/dist/truncate.d.ts +3 -2
- package/dist/truncate.d.ts.map +1 -1
- package/dist/truncate.js +4 -34
- package/dist/truncate.js.map +1 -1
- package/dist/types.d.ts +130 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +15 -5
- package/dist/harness/output.d.ts +0 -69
- package/dist/harness/output.d.ts.map +0 -1
- package/dist/harness/output.js +0 -318
- package/dist/harness/output.js.map +0 -1
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { copyJson } from "@earendil-works/chord";
|
|
2
|
-
import { awaitWithContext, withAbortSignal } from "@earendil-works/chord/context";
|
|
2
|
+
import { awaitWithContext, withAbortSignal, withoutAbortSignal, withTelemetry } from "@earendil-works/chord/context";
|
|
3
|
+
import { createTypedMetrics, createTypedSpanStarter, NOOP_TELEMETRY_CONTEXT, NOOP_TELEMETRY_METRICS, } from "@earendil-works/pi-telemetry";
|
|
3
4
|
import { errorMessage } from "../errors.js";
|
|
4
|
-
import { agentHooks } from "./agent.js";
|
|
5
|
+
import { agentHooks, DEFAULT_RESTART_LIMIT } from "./agent.js";
|
|
5
6
|
import { readContextFrom } from "./context.js";
|
|
6
|
-
import {
|
|
7
|
+
import { DURABLE_TELEMETRY_SCHEMA } from "./telemetry.js";
|
|
8
|
+
import { closedError, optionalExtension, scanAll, Waiters } from "./util.js";
|
|
7
9
|
const SCAN_PAGE_SIZE = 256;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const LIVE_STATUSES = ["pending", "running", "waiting", "sleeping", "completing"];
|
|
11
|
+
/** The part of a record `blockedReason()` reads. */
|
|
12
|
+
export function blockedSubject(record) {
|
|
13
|
+
return {
|
|
14
|
+
id: record.id,
|
|
15
|
+
kind: record.kind,
|
|
16
|
+
version: record.version,
|
|
17
|
+
abortRequested: record.abortRequested,
|
|
18
|
+
...(record.abortReason === undefined ? {} : { abortReason: record.abortReason }),
|
|
19
|
+
...(record.restarts === undefined ? {} : { restarts: record.restarts }),
|
|
20
|
+
status: record.state.status,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
11
23
|
function conversationNode(id) {
|
|
12
24
|
return `c${id}`;
|
|
13
25
|
}
|
|
@@ -21,8 +33,9 @@ function parentNode(node) {
|
|
|
21
33
|
/**
|
|
22
34
|
* Durable task scheduler of one Harness.
|
|
23
35
|
*
|
|
24
|
-
* `#live` mirrors every committed non-terminal task record: pending, running, waiting, and completing. The
|
|
25
|
-
* commit listener updates it on the Session line, so code running on the line reads exactly the committed
|
|
36
|
+
* `#live` mirrors every committed non-terminal task record: pending, running, waiting, sleeping, and completing. The
|
|
37
|
+
* synchronous commit listener updates it on the Session line, so code running on the line reads exactly the committed
|
|
38
|
+
* state from it.
|
|
26
39
|
*
|
|
27
40
|
* Tasks and conversations form one ownership tree (spec §5.5): a task's parent is its owner task, or its conversation;
|
|
28
41
|
* a conversation's parent is its owner task, if any. Walks up that tree decide what reaches one task: a cancelling
|
|
@@ -40,6 +53,10 @@ function parentNode(node) {
|
|
|
40
53
|
* step that decides its end, so a runtime commit it queued either lands before that decision or is rejected.
|
|
41
54
|
*/
|
|
42
55
|
export class TaskScheduler {
|
|
56
|
+
#telemetry;
|
|
57
|
+
#activeMetric;
|
|
58
|
+
#durationMetric;
|
|
59
|
+
#resumed = new Set();
|
|
43
60
|
#session;
|
|
44
61
|
#storage;
|
|
45
62
|
#registry;
|
|
@@ -48,6 +65,9 @@ export class TaskScheduler {
|
|
|
48
65
|
#settings;
|
|
49
66
|
#env;
|
|
50
67
|
#now;
|
|
68
|
+
#onWake;
|
|
69
|
+
#timers;
|
|
70
|
+
#writeAtClose;
|
|
51
71
|
#report;
|
|
52
72
|
#settleOutcome;
|
|
53
73
|
#withdrawInputs;
|
|
@@ -60,7 +80,7 @@ export class TaskScheduler {
|
|
|
60
80
|
* first idle check after `settings.contextRetentionMs` of idleness, and at close.
|
|
61
81
|
*/
|
|
62
82
|
#contexts = new Map();
|
|
63
|
-
/**
|
|
83
|
+
/** Background timer for the earliest expiry of an idle context, unless the host declined it (see `#scheduleExpiry`). */
|
|
64
84
|
#expiry;
|
|
65
85
|
#invocations = new Map();
|
|
66
86
|
#taskWaiters = new Waiters();
|
|
@@ -103,7 +123,21 @@ export class TaskScheduler {
|
|
|
103
123
|
* them with reason `restart`, so nothing of them or below them runs a phase again.
|
|
104
124
|
*/
|
|
105
125
|
#abandoned = new Set();
|
|
126
|
+
/** Sleeping live tasks, due or not: the candidates of `#wakeDue()`. */
|
|
127
|
+
#sleeping = new Set();
|
|
128
|
+
/** Timer for the earliest sleep, without `onWake` while scheduling runs. */
|
|
129
|
+
#timer;
|
|
130
|
+
/** Last value given to `onWake`, so an unchanged one is not repeated. */
|
|
131
|
+
#lastWake;
|
|
132
|
+
#wakeScheduled = false;
|
|
133
|
+
#blockedListeners = new Set();
|
|
134
|
+
/** A registry snapshot that threw for the task graph was reported; it is read per record, so only once. */
|
|
135
|
+
#snapshotReported = false;
|
|
136
|
+
/** Tasks a pass left out as parked; each pass checks them again, as `settings.restartLimit` may have risen. */
|
|
137
|
+
#parkedTasks = new Set();
|
|
106
138
|
#reconcileScheduled = false;
|
|
139
|
+
/** Reconcile passes underway with work (they may overlap): their commits finalize held outcomes and mark cascades. */
|
|
140
|
+
#reconcilingWork = 0;
|
|
107
141
|
#sweepScheduled = false;
|
|
108
142
|
#cascadePending = false;
|
|
109
143
|
#unsubscribeRegistry = () => { };
|
|
@@ -112,6 +146,10 @@ export class TaskScheduler {
|
|
|
112
146
|
#dirty = false;
|
|
113
147
|
#draining = false;
|
|
114
148
|
constructor(options) {
|
|
149
|
+
this.#telemetry = options.telemetry ?? NOOP_TELEMETRY_CONTEXT;
|
|
150
|
+
const metrics = createTypedMetrics(this.#telemetry.metrics ?? NOOP_TELEMETRY_METRICS, [DURABLE_TELEMETRY_SCHEMA]);
|
|
151
|
+
this.#activeMetric = metrics.upDownCounter("pi.durable.tasks.active");
|
|
152
|
+
this.#durationMetric = metrics.histogram("pi.durable.phase.duration");
|
|
115
153
|
this.#session = options.session;
|
|
116
154
|
this.#storage = options.storage;
|
|
117
155
|
this.#registry = options.registry;
|
|
@@ -120,17 +158,28 @@ export class TaskScheduler {
|
|
|
120
158
|
this.#settings = options.settings;
|
|
121
159
|
this.#env = options.env;
|
|
122
160
|
this.#now = options.now;
|
|
161
|
+
this.#onWake = options.onWake;
|
|
162
|
+
this.#timers = options.timers;
|
|
163
|
+
this.#writeAtClose = options.writeAtClose;
|
|
123
164
|
this.#report = options.report;
|
|
124
165
|
this.#settleOutcome = options.settleOutcome;
|
|
125
166
|
this.#withdrawInputs = options.withdrawInputs;
|
|
126
167
|
this.#conversation = options.conversation;
|
|
127
168
|
this.#context = options.context;
|
|
128
169
|
}
|
|
129
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing. A
|
|
172
|
+
* task that reaches the restart limit this way is parked and reported.
|
|
173
|
+
*/
|
|
130
174
|
async open(context) {
|
|
131
175
|
this.#session.observeCommits((publication) => this.#observe(publication));
|
|
132
176
|
this.#session.subscribeClose(() => this.#seal());
|
|
133
|
-
this.#unsubscribeRegistry = this.#registry.subscribe(() =>
|
|
177
|
+
this.#unsubscribeRegistry = this.#registry.subscribe(() => {
|
|
178
|
+
this.#notifyBlocked();
|
|
179
|
+
this.#kick();
|
|
180
|
+
});
|
|
181
|
+
const limit = this.#restartLimit();
|
|
182
|
+
const parked = [];
|
|
134
183
|
await this.#session.commitWith(async (tx) => {
|
|
135
184
|
// Every table read before the first write.
|
|
136
185
|
const scans = [];
|
|
@@ -140,10 +189,15 @@ export class TaskScheduler {
|
|
|
140
189
|
for (const records of scans) {
|
|
141
190
|
for (const record of records) {
|
|
142
191
|
this.#track(undefined, record);
|
|
192
|
+
this.#resumed.add(record.id);
|
|
143
193
|
if (record.abandonOnRestart === true)
|
|
144
194
|
this.#abandoned.add(record.id);
|
|
145
195
|
if (record.state.status === "running") {
|
|
146
|
-
|
|
196
|
+
const restarts = (record.restarts ?? 0) + 1;
|
|
197
|
+
const pending = withState(record, { status: "pending", checkpoint: record.state.checkpoint });
|
|
198
|
+
tx.setTask({ ...pending, restarts });
|
|
199
|
+
if (restarts >= limit && !record.abortRequested)
|
|
200
|
+
parked.push(record);
|
|
147
201
|
}
|
|
148
202
|
if (record.state.status === "waiting" && record.state.policy === "failFast") {
|
|
149
203
|
this.#failFastChecks.add(record.id);
|
|
@@ -151,23 +205,143 @@ export class TaskScheduler {
|
|
|
151
205
|
}
|
|
152
206
|
}
|
|
153
207
|
}, context);
|
|
208
|
+
for (const record of parked) {
|
|
209
|
+
const restarts = (record.restarts ?? 0) + 1;
|
|
210
|
+
const error = new Error(`Task ${record.id} (${record.kind}) was cut off ${restarts} times without progress; it is parked until retryTask()`);
|
|
211
|
+
this.#report(error, { type: "task_parked", ...this.#aboutTask(recordTask(record)) });
|
|
212
|
+
}
|
|
154
213
|
// Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes.
|
|
155
214
|
this.#cascadePending = true;
|
|
156
215
|
this.#scheduleReconcile();
|
|
216
|
+
this.#scheduleWake();
|
|
157
217
|
}
|
|
158
|
-
/**
|
|
218
|
+
/**
|
|
219
|
+
* Enable scheduling, and make sleeping tasks that are due runnable: a host wakeup, such as an alarm, calls this.
|
|
220
|
+
* Idempotent; the kick does nothing once closing.
|
|
221
|
+
*/
|
|
159
222
|
resume() {
|
|
160
223
|
this.#enabled = true;
|
|
224
|
+
this.#wakeDue();
|
|
161
225
|
this.#kick();
|
|
162
226
|
}
|
|
163
227
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
228
|
+
* Run a task parked by the restart limit again: clears its restart count and enables scheduling. Does nothing for a
|
|
229
|
+
* task that is not parked; rejects for an unknown one.
|
|
230
|
+
*/
|
|
231
|
+
async retry(id, context) {
|
|
232
|
+
await this.#session.commitWith(async (tx) => {
|
|
233
|
+
const current = await tx.task(id);
|
|
234
|
+
if (current === undefined)
|
|
235
|
+
throw new Error(`Task ${id} does not exist`);
|
|
236
|
+
const status = current.state.status;
|
|
237
|
+
if (status === "terminal" || status === "completing" || !this.#parked(current))
|
|
238
|
+
return;
|
|
239
|
+
tx.setTask(progressed(current));
|
|
240
|
+
}, context);
|
|
241
|
+
this.resume();
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Why a live task cannot run here, if it cannot: its definition (missing, too old, failed migration) or the restart
|
|
245
|
+
* limit. A running task, an abort-marked one, and one holding its outcome are never blocked.
|
|
246
|
+
*/
|
|
247
|
+
blockedReason(record) {
|
|
248
|
+
const status = record.status;
|
|
249
|
+
if (status === "running" || status === "completing" || status === "terminal")
|
|
250
|
+
return undefined;
|
|
251
|
+
// An abort request runs its handler or orphans the task; a `restart` mark waits for the definition, blocked.
|
|
252
|
+
if (record.abortRequested && record.abortReason === undefined)
|
|
253
|
+
return undefined;
|
|
254
|
+
if (this.#parked(record))
|
|
255
|
+
return "restart_limit";
|
|
256
|
+
let snapshot;
|
|
257
|
+
try {
|
|
258
|
+
snapshot = this.#registry.snapshot();
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (!this.#snapshotReported)
|
|
262
|
+
this.#report(error, REGISTRY);
|
|
263
|
+
this.#snapshotReported = true;
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
const fit = this.#fit(record, snapshot.task(record.kind));
|
|
267
|
+
if ("reason" in fit)
|
|
268
|
+
return fit.reason;
|
|
269
|
+
return fit.migrates && fit.task.definition.migrate === undefined ? "migration_failed" : undefined;
|
|
270
|
+
}
|
|
271
|
+
/** Call `listener` when blocked reasons may have changed without a commit: a registry change or a failed migration. */
|
|
272
|
+
subscribeBlocked(listener) {
|
|
273
|
+
this.#blockedListeners.add(listener);
|
|
274
|
+
return () => this.#blockedListeners.delete(listener);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* When the Session next needs a process: `active` while some task can run now or is running, `at` for the earliest
|
|
278
|
+
* sleep not yet due. Synchronous, from memory; a task whose chain is not loaded yet counts as it would alone.
|
|
279
|
+
*/
|
|
280
|
+
wake() {
|
|
281
|
+
const now = this.#now();
|
|
282
|
+
// A reconcile pass with work to do is work too: it finalizes held outcomes and marks cascades, at open as
|
|
283
|
+
// recovery. A process that dies before it lands must be woken again. A pass with nothing to do is not.
|
|
284
|
+
let active = this.#invocations.size > 0 || this.#reconcilingWork > 0 || this.#reconcileWork();
|
|
285
|
+
let at;
|
|
286
|
+
let snapshot;
|
|
287
|
+
let snapshotFailed = false;
|
|
288
|
+
for (const record of this.#live.values()) {
|
|
289
|
+
const state = record.state;
|
|
290
|
+
if (state.status === "completing")
|
|
291
|
+
continue;
|
|
292
|
+
if (state.status === "waiting" && !record.abortRequested && state.on.some((id) => this.#live.has(id)))
|
|
293
|
+
continue;
|
|
294
|
+
if (state.status === "sleeping" && !record.abortRequested && state.until > now) {
|
|
295
|
+
at = at === undefined ? state.until : Math.min(at, state.until);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (active)
|
|
299
|
+
continue;
|
|
300
|
+
if (this.#parked(record))
|
|
301
|
+
continue;
|
|
302
|
+
// An abort invocation waits for the live work below it, which counts itself.
|
|
303
|
+
if (record.abortRequested && this.#waitingOn(record, true).length > 0)
|
|
304
|
+
continue;
|
|
305
|
+
// A task no definition can take waits for a registry change, except an abort-marked one, which is orphaned.
|
|
306
|
+
if (!record.abortRequested || record.abortReason !== undefined) {
|
|
307
|
+
if (snapshot === undefined && !snapshotFailed) {
|
|
308
|
+
try {
|
|
309
|
+
snapshot = this.#registry.snapshot();
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
snapshotFailed = true;
|
|
313
|
+
this.#report(error, REGISTRY);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (snapshot !== undefined && "reason" in this.#fit(record, snapshot.task(record.kind)))
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
active = true;
|
|
320
|
+
}
|
|
321
|
+
return at === undefined ? { active } : { active, at };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Signal every invocation and wait for them, then write the tasks they ran back as `pending` (`writeAtClose`), as a
|
|
325
|
+
* close is not a crash. Close calls this after every close listener has run, so waits, watches, and streams a task
|
|
326
|
+
* holds end with the Session's reason, not as cancelled by its signal.
|
|
166
327
|
*/
|
|
167
328
|
async join() {
|
|
168
|
-
|
|
329
|
+
const stopped = [...this.#invocations.values()];
|
|
330
|
+
for (const invocation of stopped)
|
|
169
331
|
invocation.controller.abort();
|
|
170
|
-
await Promise.allSettled(
|
|
332
|
+
await Promise.allSettled(stopped.map((invocation) => invocation.done));
|
|
333
|
+
if (stopped.length === 0)
|
|
334
|
+
return;
|
|
335
|
+
// A close is not a crash: a task it stopped is `pending` again, so the next open counts no restart for it. Read
|
|
336
|
+
// on the line after commits admitted before the seal, such as an abort mark, so the write keeps them. A failure
|
|
337
|
+
// leaves them `running`, counted as if cut off; the Storage guard fails the Session and reports it.
|
|
338
|
+
await this.#writeAtClose(() => stopped.flatMap((invocation) => {
|
|
339
|
+
const record = this.#live.get(invocation.taskId);
|
|
340
|
+
if (record?.state.status !== "running")
|
|
341
|
+
return [];
|
|
342
|
+
const value = withState(record, { status: "pending", checkpoint: record.state.checkpoint });
|
|
343
|
+
return [{ type: "task", value }];
|
|
344
|
+
})).catch(() => { });
|
|
171
345
|
}
|
|
172
346
|
/**
|
|
173
347
|
* Commit the abort mark, or settle a task that no registered definition can take as `orphaned` when nothing it owns
|
|
@@ -362,7 +536,7 @@ export class TaskScheduler {
|
|
|
362
536
|
return this.#settings().contextRetentionMs;
|
|
363
537
|
}
|
|
364
538
|
catch (error) {
|
|
365
|
-
this.#report(error);
|
|
539
|
+
this.#report(error, SETTINGS);
|
|
366
540
|
return 0;
|
|
367
541
|
}
|
|
368
542
|
}
|
|
@@ -391,9 +565,9 @@ export class TaskScheduler {
|
|
|
391
565
|
this.#scheduleExpiry();
|
|
392
566
|
}
|
|
393
567
|
/**
|
|
394
|
-
* Run `#settleIdle()` when the earliest idle context expires
|
|
395
|
-
* alive. Where
|
|
396
|
-
* Durable Object from being evicted, and eviction frees the contexts. There, task changes alone check expiry.
|
|
568
|
+
* Run `#settleIdle()` when the earliest idle context expires, on a background timer, which never keeps the process
|
|
569
|
+
* alive. Where the host declines it, as the default does in Cloudflare Workers, none is kept: a pending timer could
|
|
570
|
+
* keep a Durable Object from being evicted, and eviction frees the contexts. There, task changes alone check expiry.
|
|
397
571
|
*/
|
|
398
572
|
#scheduleExpiry() {
|
|
399
573
|
const retention = this.#contextRetentionMs();
|
|
@@ -405,22 +579,16 @@ export class TaskScheduler {
|
|
|
405
579
|
}
|
|
406
580
|
if (this.#expiry !== undefined && this.#expiry.at === at)
|
|
407
581
|
return;
|
|
408
|
-
|
|
409
|
-
clearTimeout(this.#expiry.timer);
|
|
582
|
+
this.#expiry?.disarm();
|
|
410
583
|
this.#expiry = undefined;
|
|
411
584
|
if (at === undefined || this.#closing)
|
|
412
585
|
return;
|
|
413
|
-
const
|
|
586
|
+
const disarm = this.#timers.background(() => {
|
|
414
587
|
this.#expiry = undefined;
|
|
415
588
|
this.#settleIdle();
|
|
416
|
-
},
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
clearTimeout(timer);
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
unref.call(timer);
|
|
423
|
-
this.#expiry = { at, timer };
|
|
589
|
+
}, at - this.#now());
|
|
590
|
+
if (disarm !== undefined)
|
|
591
|
+
this.#expiry = { at, disarm };
|
|
424
592
|
}
|
|
425
593
|
// ─── Ownership ───────────────────────────────────────────────────────────
|
|
426
594
|
#scheduleReconcile() {
|
|
@@ -437,6 +605,9 @@ export class TaskScheduler {
|
|
|
437
605
|
*/
|
|
438
606
|
async #reconcile() {
|
|
439
607
|
this.#reconcileScheduled = false;
|
|
608
|
+
const work = this.#reconcileWork();
|
|
609
|
+
if (work)
|
|
610
|
+
this.#reconcilingWork++;
|
|
440
611
|
const cascade = this.#cascadePending;
|
|
441
612
|
this.#cascadePending = false;
|
|
442
613
|
const checks = [...this.#failFastChecks];
|
|
@@ -493,8 +664,15 @@ export class TaskScheduler {
|
|
|
493
664
|
// running the pass again, so it fails the Session, which reports it.
|
|
494
665
|
this.#failSession(error);
|
|
495
666
|
}
|
|
667
|
+
if (work)
|
|
668
|
+
this.#reconcilingWork--;
|
|
669
|
+
this.#scheduleWake();
|
|
496
670
|
this.#settleIdle();
|
|
497
671
|
}
|
|
672
|
+
/** Whether a reconcile pass would have work: a cascade to derive, held outcomes to check, or `failFast` waits. */
|
|
673
|
+
#reconcileWork() {
|
|
674
|
+
return this.#cascadePending || this.#finalizeChecks.size > 0 || this.#failFastChecks.size > 0;
|
|
675
|
+
}
|
|
498
676
|
/** Whether any of `ids` holds or ended with an outcome other than `completed`. */
|
|
499
677
|
async #anyFailed(ids) {
|
|
500
678
|
for (const id of ids) {
|
|
@@ -672,12 +850,24 @@ export class TaskScheduler {
|
|
|
672
850
|
*/
|
|
673
851
|
#track(previous, record) {
|
|
674
852
|
const id = record.id;
|
|
853
|
+
const invocation = this.#invocations.get(id);
|
|
854
|
+
if (invocation !== undefined)
|
|
855
|
+
invocation.record = record;
|
|
856
|
+
if (record.state.status === "terminal") {
|
|
857
|
+
if (previous !== undefined && !this.#closing)
|
|
858
|
+
this.#activeMetric.add(-1, { task: record.kind });
|
|
859
|
+
this.#resumed.delete(id);
|
|
860
|
+
}
|
|
861
|
+
else if (previous === undefined && !this.#closing) {
|
|
862
|
+
this.#activeMetric.add(1, { task: record.kind });
|
|
863
|
+
}
|
|
675
864
|
if (previous?.state.status === "waiting")
|
|
676
865
|
for (const on of previous.state.on)
|
|
677
866
|
deleteFrom(this.#waiters, on, id);
|
|
678
867
|
if (record.state.status === "terminal") {
|
|
679
868
|
const node = nodeOf(record);
|
|
680
869
|
this.#live.delete(id);
|
|
870
|
+
this.#sleeping.delete(id);
|
|
681
871
|
this.#intent.delete(id);
|
|
682
872
|
this.#runnable.delete(id);
|
|
683
873
|
this.#finalizeChecks.delete(id);
|
|
@@ -711,6 +901,10 @@ export class TaskScheduler {
|
|
|
711
901
|
if (record.state.status === "waiting")
|
|
712
902
|
for (const on of record.state.on)
|
|
713
903
|
addTo(this.#waiters, on, id);
|
|
904
|
+
if (record.state.status === "sleeping")
|
|
905
|
+
this.#sleeping.add(id);
|
|
906
|
+
else
|
|
907
|
+
this.#sleeping.delete(id);
|
|
714
908
|
}
|
|
715
909
|
/** The nearest live task above ended ordinary work may be free now: one holding its outcome, or an aborting one. */
|
|
716
910
|
#release(node, overlay) {
|
|
@@ -962,28 +1156,152 @@ export class TaskScheduler {
|
|
|
962
1156
|
return undefined;
|
|
963
1157
|
}
|
|
964
1158
|
/** Fail the Session, which reports it, for a throw in one of the scheduler's own commits; ignored once closing. */
|
|
965
|
-
#failSession(error) {
|
|
1159
|
+
#failSession(error, context) {
|
|
966
1160
|
if (!this.#closing)
|
|
967
|
-
this.#session.fail(error);
|
|
1161
|
+
this.#session.fail(error, context);
|
|
968
1162
|
}
|
|
969
1163
|
/** Close listener: runs synchronously once admission is sealed, before `join()`, or when the Session fails. */
|
|
970
1164
|
#seal() {
|
|
1165
|
+
for (const record of this.#live.values())
|
|
1166
|
+
this.#activeMetric.add(-1, { task: record.kind });
|
|
1167
|
+
this.#resumed.clear();
|
|
971
1168
|
this.#closing = true;
|
|
972
1169
|
try {
|
|
973
1170
|
this.#unsubscribeRegistry();
|
|
974
1171
|
}
|
|
975
1172
|
catch (error) {
|
|
976
|
-
this.#report(error);
|
|
1173
|
+
this.#report(error, REGISTRY);
|
|
977
1174
|
}
|
|
978
1175
|
const error = closedError(this.#session);
|
|
979
1176
|
this.#taskWaiters.rejectAll(error);
|
|
980
1177
|
this.#idleWaiters.rejectAll(error);
|
|
981
1178
|
this.#contexts.clear();
|
|
982
|
-
|
|
983
|
-
clearTimeout(this.#expiry.timer);
|
|
1179
|
+
this.#expiry?.disarm();
|
|
984
1180
|
this.#expiry = undefined;
|
|
1181
|
+
this.#timer?.disarm();
|
|
1182
|
+
this.#timer = undefined;
|
|
1183
|
+
}
|
|
1184
|
+
/** Recompute the wake in a microtask, off the Session line: arm the built-in timer, or tell `onWake` of a change. */
|
|
1185
|
+
#scheduleWake() {
|
|
1186
|
+
if (this.#wakeScheduled || this.#closing)
|
|
1187
|
+
return;
|
|
1188
|
+
this.#wakeScheduled = true;
|
|
1189
|
+
queueMicrotask(() => {
|
|
1190
|
+
this.#wakeScheduled = false;
|
|
1191
|
+
if (this.#closing)
|
|
1192
|
+
return;
|
|
1193
|
+
const wake = this.wake();
|
|
1194
|
+
if (this.#onWake === undefined) {
|
|
1195
|
+
this.#armTimer(this.#enabled ? wake.at : undefined);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const last = this.#lastWake;
|
|
1199
|
+
if (last !== undefined && last.active === wake.active && last.at === wake.at)
|
|
1200
|
+
return;
|
|
1201
|
+
try {
|
|
1202
|
+
this.#onWake(wake);
|
|
1203
|
+
// Only a delivered wake counts: after a throw, the next change or kick offers it again.
|
|
1204
|
+
this.#lastWake = wake;
|
|
1205
|
+
}
|
|
1206
|
+
catch (error) {
|
|
1207
|
+
this.#report(error, ON_WAKE);
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Keep the timer for the earliest sleep. It keeps the process alive, unlike the context expiry timer: a process with a
|
|
1213
|
+
* sleeping task has work left, such as a retry backoff. Rechecks the clock when it fires, so a wake before `until`
|
|
1214
|
+
* only arms it again.
|
|
1215
|
+
*/
|
|
1216
|
+
#armTimer(at) {
|
|
1217
|
+
if (this.#timer !== undefined && this.#timer.at === at)
|
|
1218
|
+
return;
|
|
1219
|
+
this.#timer?.disarm();
|
|
1220
|
+
this.#timer = undefined;
|
|
1221
|
+
if (at === undefined || this.#closing)
|
|
1222
|
+
return;
|
|
1223
|
+
const disarm = this.#timers.foreground(() => {
|
|
1224
|
+
this.#timer = undefined;
|
|
1225
|
+
this.#wakeDue();
|
|
1226
|
+
this.#kick();
|
|
1227
|
+
}, at - this.#now());
|
|
1228
|
+
this.#timer = { at, disarm };
|
|
1229
|
+
}
|
|
1230
|
+
/** Make every sleeping task whose time has come a reservation candidate. */
|
|
1231
|
+
#wakeDue() {
|
|
1232
|
+
for (const id of this.#sleeping) {
|
|
1233
|
+
const record = this.#live.get(id);
|
|
1234
|
+
if (record !== undefined && !this.#notDue(record))
|
|
1235
|
+
this.#runnable.add(id);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/** A task cut off `restartLimit` times in a row; an abort mark still runs its abort handler. */
|
|
1239
|
+
#parked(record) {
|
|
1240
|
+
return !record.abortRequested && record.restarts !== undefined && record.restarts >= this.#restartLimit();
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* The report info of a task's failure, with the extension that installed its definition: the code that failed is
|
|
1244
|
+
* that extension's. Built-in tasks have none.
|
|
1245
|
+
*/
|
|
1246
|
+
/** What a report about `task`'s own code says of it: the task, and the extension that installed its definition. */
|
|
1247
|
+
#aboutTask(task) {
|
|
1248
|
+
let extension;
|
|
1249
|
+
if ("extension" in task)
|
|
1250
|
+
extension = task.extension;
|
|
1251
|
+
else {
|
|
1252
|
+
try {
|
|
1253
|
+
const snapshot = this.#registry.snapshot();
|
|
1254
|
+
extension = installerOf(snapshot, snapshot.task(task.kind));
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
// A registry that throws is reported where it is read for scheduling; a report goes out without the name.
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return { task: reportTask(task), ...optionalExtension(extension) };
|
|
1261
|
+
}
|
|
1262
|
+
/** Make parked tasks that a raised `settings.restartLimit` no longer parks candidates again, and tell the graph. */
|
|
1263
|
+
#unparkRaised() {
|
|
1264
|
+
let unparked = false;
|
|
1265
|
+
for (const id of this.#parkedTasks) {
|
|
1266
|
+
const record = this.#live.get(id);
|
|
1267
|
+
if (record !== undefined && this.#parked(record))
|
|
1268
|
+
continue;
|
|
1269
|
+
this.#parkedTasks.delete(id);
|
|
1270
|
+
if (record === undefined)
|
|
1271
|
+
continue;
|
|
1272
|
+
this.#runnable.add(id);
|
|
1273
|
+
unparked = true;
|
|
1274
|
+
}
|
|
1275
|
+
if (unparked)
|
|
1276
|
+
queueMicrotask(() => this.#notifyBlocked());
|
|
1277
|
+
}
|
|
1278
|
+
/** `settings.restartLimit`, or the default when the host's settings throw. */
|
|
1279
|
+
#restartLimit() {
|
|
1280
|
+
try {
|
|
1281
|
+
return this.#settings().restartLimit;
|
|
1282
|
+
}
|
|
1283
|
+
catch (error) {
|
|
1284
|
+
this.#report(error, SETTINGS);
|
|
1285
|
+
return DEFAULT_RESTART_LIMIT;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
#notifyBlocked() {
|
|
1289
|
+
for (const listener of [...this.#blockedListeners]) {
|
|
1290
|
+
try {
|
|
1291
|
+
listener();
|
|
1292
|
+
}
|
|
1293
|
+
catch (error) {
|
|
1294
|
+
this.#report(error, { type: "listener_error" });
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
/** A sleep whose `until` the clock has not reached; an abort mark ends a sleep. */
|
|
1299
|
+
#notDue(record) {
|
|
1300
|
+
const state = record.state;
|
|
1301
|
+
return state.status === "sleeping" && !record.abortRequested && state.until > this.#now();
|
|
985
1302
|
}
|
|
986
1303
|
#kick() {
|
|
1304
|
+
this.#scheduleWake();
|
|
987
1305
|
this.#dirty = true;
|
|
988
1306
|
if (this.#draining || !this.#enabled || this.#closing)
|
|
989
1307
|
return;
|
|
@@ -1016,6 +1334,7 @@ export class TaskScheduler {
|
|
|
1016
1334
|
*/
|
|
1017
1335
|
async #reserve() {
|
|
1018
1336
|
const reservations = [];
|
|
1337
|
+
const faulted = [];
|
|
1019
1338
|
let abandoned = [];
|
|
1020
1339
|
try {
|
|
1021
1340
|
await this.#session.commitWith(async (tx) => {
|
|
@@ -1031,6 +1350,10 @@ export class TaskScheduler {
|
|
|
1031
1350
|
return;
|
|
1032
1351
|
}
|
|
1033
1352
|
await this.#loadScopes(false);
|
|
1353
|
+
// Every pass picks up sleeps whose time has come: a pass started by any change may be the first to run after
|
|
1354
|
+
// a deadline, and the wake it schedules no longer keeps a timer for a due sleep.
|
|
1355
|
+
this.#wakeDue();
|
|
1356
|
+
this.#unparkRaised();
|
|
1034
1357
|
// Taken once per pass, and only when some task is a candidate.
|
|
1035
1358
|
let snapshot;
|
|
1036
1359
|
for (const id of [...this.#runnable]) {
|
|
@@ -1044,6 +1367,20 @@ export class TaskScheduler {
|
|
|
1044
1367
|
this.#runnable.delete(id);
|
|
1045
1368
|
continue;
|
|
1046
1369
|
}
|
|
1370
|
+
// It returns at the first pass after its time comes, which the timer, `resume()`, or any change starts.
|
|
1371
|
+
if (this.#notDue(record)) {
|
|
1372
|
+
this.#runnable.delete(id);
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
// It returns when `retry()` clears its restart count, or at a pass after `settings.restartLimit` rose.
|
|
1376
|
+
if (this.#parked(record)) {
|
|
1377
|
+
this.#runnable.delete(id);
|
|
1378
|
+
// Newly parked without a commit, as after a lowered limit: the graph must hear it.
|
|
1379
|
+
if (!this.#parkedTasks.has(id))
|
|
1380
|
+
queueMicrotask(() => this.#notifyBlocked());
|
|
1381
|
+
this.#parkedTasks.add(id);
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1047
1384
|
const runnable = record;
|
|
1048
1385
|
const mode = record.abortRequested ? "abort" : "run";
|
|
1049
1386
|
// Work below an owner with cancellation intent waits for its cascade mark instead of running a phase; schedule
|
|
@@ -1053,6 +1390,16 @@ export class TaskScheduler {
|
|
|
1053
1390
|
this.#scheduleReconcile();
|
|
1054
1391
|
continue;
|
|
1055
1392
|
}
|
|
1393
|
+
// An abort handler cut off `restartLimit` times in a row would run forever; nothing is left to park it for.
|
|
1394
|
+
if (mode === "abort" && (record.restarts ?? 0) >= this.#restartLimit()) {
|
|
1395
|
+
const message = `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times`;
|
|
1396
|
+
await this.#terminate(tx, record, {
|
|
1397
|
+
status: "faulted",
|
|
1398
|
+
error: { message, detail: { reason: "restart_limit" } },
|
|
1399
|
+
});
|
|
1400
|
+
faulted.push({ record, message });
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1056
1403
|
snapshot ??= this.#registry.snapshot();
|
|
1057
1404
|
const resolution = this.#resolve(runnable, snapshot);
|
|
1058
1405
|
if (resolution.kind === "blocked") {
|
|
@@ -1071,7 +1418,7 @@ export class TaskScheduler {
|
|
|
1071
1418
|
// Registered on the line, so marks and later reservations see it and close joins it; `#end` makes it a
|
|
1072
1419
|
// candidate again.
|
|
1073
1420
|
this.#runnable.delete(id);
|
|
1074
|
-
const invocation = this.#createInvocation(
|
|
1421
|
+
const invocation = this.#createInvocation(runnable, mode, installerOf(snapshot, resolution.task));
|
|
1075
1422
|
reservations.push({ invocation, task: resolution.task, snapshot });
|
|
1076
1423
|
}
|
|
1077
1424
|
}, this.#context);
|
|
@@ -1084,6 +1431,13 @@ export class TaskScheduler {
|
|
|
1084
1431
|
}
|
|
1085
1432
|
throw error;
|
|
1086
1433
|
}
|
|
1434
|
+
for (const { record, message } of faulted) {
|
|
1435
|
+
this.#report(new Error(message), {
|
|
1436
|
+
type: "task_faulted",
|
|
1437
|
+
...this.#aboutTask(recordTask(record)),
|
|
1438
|
+
reason: "restart_limit",
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1087
1441
|
// Marked, or already marked or gone: done with them. Reserve again, now seeing the marks.
|
|
1088
1442
|
for (const id of abandoned)
|
|
1089
1443
|
this.#abandoned.delete(id);
|
|
@@ -1123,7 +1477,11 @@ export class TaskScheduler {
|
|
|
1123
1477
|
}
|
|
1124
1478
|
catch (error) {
|
|
1125
1479
|
this.#failedMigrations.set(record.id, { task: fit.task, error });
|
|
1126
|
-
|
|
1480
|
+
// Blocked now without a commit: the graph and the host's wake must hear it.
|
|
1481
|
+
queueMicrotask(() => this.#notifyBlocked());
|
|
1482
|
+
this.#scheduleWake();
|
|
1483
|
+
const extension = installerOf(snapshot, fit.task);
|
|
1484
|
+
this.#report(error, { type: "migration_failed", ...this.#aboutTask({ ...recordTask(record), extension }) });
|
|
1127
1485
|
return { kind: "blocked", reason: "migration_failed" };
|
|
1128
1486
|
}
|
|
1129
1487
|
}
|
|
@@ -1176,6 +1534,10 @@ export class TaskScheduler {
|
|
|
1176
1534
|
const on = this.#waitingOn(record);
|
|
1177
1535
|
if (on.length > 0)
|
|
1178
1536
|
return { kind: "waiting", on };
|
|
1537
|
+
if (this.#notDue(record))
|
|
1538
|
+
return { kind: "sleeping", until: record.state.until };
|
|
1539
|
+
if (this.#parked(record))
|
|
1540
|
+
return { kind: "blocked", reason: "restart_limit" };
|
|
1179
1541
|
const fit = this.#fit(record, snapshot.task(record.kind));
|
|
1180
1542
|
if ("reason" in fit)
|
|
1181
1543
|
return { kind: "blocked", ...fit };
|
|
@@ -1184,16 +1546,21 @@ export class TaskScheduler {
|
|
|
1184
1546
|
}
|
|
1185
1547
|
return { kind: "ready", migrates: fit.migrates };
|
|
1186
1548
|
}
|
|
1187
|
-
#createInvocation(record, mode) {
|
|
1549
|
+
#createInvocation(record, mode, extension) {
|
|
1188
1550
|
const controller = new AbortController();
|
|
1189
1551
|
const { promise: done, resolve: finish } = Promise.withResolvers();
|
|
1190
1552
|
const invocation = {
|
|
1191
1553
|
taskId: record.id,
|
|
1554
|
+
kind: record.kind,
|
|
1555
|
+
extension,
|
|
1192
1556
|
conversationId: record.conversationId,
|
|
1193
1557
|
mode,
|
|
1194
1558
|
controller,
|
|
1559
|
+
links: record.startedAt === undefined ? record.links : undefined,
|
|
1195
1560
|
context: withAbortSignal(controller.signal, this.#context),
|
|
1561
|
+
record: record,
|
|
1196
1562
|
watches: new Set(),
|
|
1563
|
+
timers: new Set(),
|
|
1197
1564
|
ended: false,
|
|
1198
1565
|
done,
|
|
1199
1566
|
finish: () => finish(),
|
|
@@ -1211,10 +1578,13 @@ export class TaskScheduler {
|
|
|
1211
1578
|
await this.#runAbort(reservation);
|
|
1212
1579
|
}
|
|
1213
1580
|
catch (error) {
|
|
1214
|
-
this.#report(error);
|
|
1581
|
+
this.#report(error, { type: "task_error", ...this.#aboutTask(invocation) }, invocation.context);
|
|
1215
1582
|
}
|
|
1216
1583
|
finally {
|
|
1217
1584
|
this.#end(invocation);
|
|
1585
|
+
invocation.record = undefined;
|
|
1586
|
+
invocation.links = undefined;
|
|
1587
|
+
invocation.fault = undefined;
|
|
1218
1588
|
invocation.finish();
|
|
1219
1589
|
this.#kick();
|
|
1220
1590
|
}
|
|
@@ -1226,29 +1596,87 @@ export class TaskScheduler {
|
|
|
1226
1596
|
const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined };
|
|
1227
1597
|
const phase = { snapshot: () => state.snapshot, task: () => state.task, agent: undefined };
|
|
1228
1598
|
const runtime = this.#runtime(invocation, phase);
|
|
1229
|
-
let
|
|
1230
|
-
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1599
|
+
let current = await this.#step(invocation, (tx, next) => this.#decide(tx, next, undefined, state, invocation.extension, invocation.context));
|
|
1600
|
+
while (current !== undefined && !this.#closing) {
|
|
1601
|
+
const task = current;
|
|
1602
|
+
current = await this.#phase(invocation, task, async () => {
|
|
1603
|
+
const checkpoint = task.state.checkpoint;
|
|
1604
|
+
// Each phase handler resolves its agent afresh, at first use.
|
|
1605
|
+
phase.agent = undefined;
|
|
1606
|
+
let previous;
|
|
1607
|
+
try {
|
|
1608
|
+
await erased(state.task).phases[checkpoint.phase](task, runtime, invocation.context);
|
|
1609
|
+
previous = { checkpoint };
|
|
1610
|
+
}
|
|
1611
|
+
catch (error) {
|
|
1612
|
+
previous = { checkpoint, failure: { error } };
|
|
1613
|
+
}
|
|
1614
|
+
return this.#step(invocation, (tx, next) => this.#decide(tx, next, previous, state, invocation.extension, invocation.context));
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
/** A phase owns its decision commit, not the next handler, a durable wait, or recovery. */
|
|
1619
|
+
#phase(invocation, task, run) {
|
|
1620
|
+
const parent = task.trace === undefined
|
|
1621
|
+
? this.#telemetry
|
|
1622
|
+
: (this.#telemetry.propagation?.extract(task.trace) ?? this.#telemetry);
|
|
1623
|
+
const checkpoint = task.state.checkpoint;
|
|
1624
|
+
const attempt = checkpoint.attempt;
|
|
1625
|
+
const resumed = this.#resumed.delete(task.id);
|
|
1626
|
+
return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])("pi.durable.phase", {
|
|
1627
|
+
"task.id": task.id,
|
|
1628
|
+
"task.name": task.kind,
|
|
1629
|
+
phase: checkpoint.phase,
|
|
1630
|
+
kind: invocation.mode,
|
|
1631
|
+
resumed,
|
|
1632
|
+
...(attempt === undefined ? {} : { attempt }),
|
|
1633
|
+
}, async (span) => {
|
|
1634
|
+
if (span.propagation !== undefined && invocation.links !== undefined) {
|
|
1635
|
+
for (const carrier of invocation.links)
|
|
1636
|
+
span.addLink({ context: span.propagation.extract(carrier) });
|
|
1637
|
+
}
|
|
1638
|
+
const previous = invocation.context;
|
|
1639
|
+
invocation.context = withTelemetry(span, previous);
|
|
1640
|
+
const started = performance.now();
|
|
1238
1641
|
try {
|
|
1239
|
-
await
|
|
1240
|
-
previous = { checkpoint };
|
|
1642
|
+
return await run();
|
|
1241
1643
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1644
|
+
finally {
|
|
1645
|
+
invocation.context = previous;
|
|
1646
|
+
const state = invocation.record.state;
|
|
1647
|
+
const terminal = invocation.fault ??
|
|
1648
|
+
(state.status === "terminal" || state.status === "completing" ? state.outcome : undefined);
|
|
1649
|
+
const outcome = terminal !== undefined
|
|
1650
|
+
? terminal.status === "completed"
|
|
1651
|
+
? "done"
|
|
1652
|
+
: terminal.status === "aborted"
|
|
1653
|
+
? "aborted"
|
|
1654
|
+
: "faulted"
|
|
1655
|
+
: state.status === "waiting" || state.status === "sleeping"
|
|
1656
|
+
? state.status
|
|
1657
|
+
: invocation.ended || this.#closing
|
|
1658
|
+
? "aborted"
|
|
1659
|
+
: `next=${state.checkpoint.phase}`;
|
|
1660
|
+
span.setAttributes({ outcome });
|
|
1661
|
+
if (terminal?.status === "faulted" || terminal?.status === "failed" || terminal?.status === "orphaned") {
|
|
1662
|
+
const message = terminal.status === "orphaned" ? terminal.reason : terminal.error.message;
|
|
1663
|
+
span.setStatus({
|
|
1664
|
+
status: "error",
|
|
1665
|
+
error: { name: `task_${terminal.status}`, message: message.slice(0, 500) },
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
else if (outcome === "aborted") {
|
|
1669
|
+
span.setStatus({ status: "ok" });
|
|
1670
|
+
}
|
|
1671
|
+
this.#durationMetric.record(performance.now() - started, { task: task.kind, phase: checkpoint.phase, outcome }, span);
|
|
1244
1672
|
}
|
|
1245
|
-
}
|
|
1673
|
+
});
|
|
1246
1674
|
}
|
|
1247
1675
|
/**
|
|
1248
|
-
* Precedence rules for a run invocation, on the line. Rules 1 (terminal, `completing`, or `
|
|
1249
|
-
* are applied by `#step`. Returns whether the invocation continues with the next phase.
|
|
1676
|
+
* Precedence rules for a run invocation, on the line. Rules 1 (terminal, `completing`, `waiting`, or `sleeping`) and 2
|
|
1677
|
+
* (closing) are applied by `#step`. Returns whether the invocation continues with the next phase.
|
|
1250
1678
|
*/
|
|
1251
|
-
#decide(tx, current, previous, state) {
|
|
1679
|
+
#decide(tx, current, previous, state, extension, context) {
|
|
1252
1680
|
// 3. abort mark: end; a fresh abort invocation starts once the task's ordinary owned work is gone.
|
|
1253
1681
|
if (current.abortRequested)
|
|
1254
1682
|
return false;
|
|
@@ -1278,8 +1706,11 @@ export class TaskScheduler {
|
|
|
1278
1706
|
}
|
|
1279
1707
|
if (state.reported === undefined || state.reported.task !== next) {
|
|
1280
1708
|
state.reported = { task: next };
|
|
1281
|
-
|
|
1282
|
-
|
|
1709
|
+
this.#report(new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`), {
|
|
1710
|
+
type: "task_outdated",
|
|
1711
|
+
...this.#aboutTask({ ...recordTask(current), extension }),
|
|
1712
|
+
reason: next === undefined ? "missing_task" : "incompatible_task",
|
|
1713
|
+
}, context);
|
|
1283
1714
|
}
|
|
1284
1715
|
}
|
|
1285
1716
|
return true;
|
|
@@ -1290,27 +1721,34 @@ export class TaskScheduler {
|
|
|
1290
1721
|
const current = this.#live.get(invocation.taskId);
|
|
1291
1722
|
if (current === undefined || this.#closing)
|
|
1292
1723
|
return;
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1724
|
+
await this.#phase(invocation, current, async () => {
|
|
1725
|
+
let failure;
|
|
1726
|
+
try {
|
|
1727
|
+
const phase = {
|
|
1728
|
+
snapshot: () => reservation.snapshot,
|
|
1729
|
+
task: () => reservation.task,
|
|
1730
|
+
agent: undefined,
|
|
1731
|
+
};
|
|
1732
|
+
const runtime = this.#runtime(invocation, phase);
|
|
1733
|
+
await erased(reservation.task).abort(current, runtime, invocation.context);
|
|
1734
|
+
}
|
|
1735
|
+
catch (error) {
|
|
1736
|
+
failure = { error };
|
|
1737
|
+
}
|
|
1738
|
+
const message = `Abort handler of task ${invocation.taskId} returned without a terminal outcome`;
|
|
1739
|
+
await this.#step(invocation, () => ({ fault: failure?.error ?? new Error(message) }));
|
|
1740
|
+
});
|
|
1304
1741
|
}
|
|
1305
1742
|
/**
|
|
1306
|
-
* One synchronous decision on the Session line. A task that is no longer running (rule 1: terminal, `completing`,
|
|
1307
|
-
* `waiting`) or a closing Harness (rule 2) ends the invocation without a write; otherwise `decide` may
|
|
1308
|
-
* and returns whether the invocation continues. Ending happens inside the callback, before a fault's
|
|
1309
|
-
* cleanup. A rejected step, such as admission after close, also ends the invocation.
|
|
1743
|
+
* One synchronous decision on the Session line. A task that is no longer running (rule 1: terminal, `completing`,
|
|
1744
|
+
* `waiting`, or `sleeping`) or a closing Harness (rule 2) ends the invocation without a write; otherwise `decide` may
|
|
1745
|
+
* stage a write and returns whether the invocation continues. Ending happens inside the callback, before a fault's
|
|
1746
|
+
* Harness cleanup. A rejected step, such as admission after close, also ends the invocation.
|
|
1310
1747
|
*/
|
|
1311
1748
|
async #step(invocation, decide) {
|
|
1749
|
+
let fault;
|
|
1312
1750
|
try {
|
|
1313
|
-
|
|
1751
|
+
const continued = await this.#session.commitWith(async (tx) => {
|
|
1314
1752
|
const found = this.#live.get(invocation.taskId);
|
|
1315
1753
|
const current = found?.state.status === "running" ? found : undefined;
|
|
1316
1754
|
const decision = current !== undefined && !this.#closing ? decide(tx, current) : false;
|
|
@@ -1318,17 +1756,23 @@ export class TaskScheduler {
|
|
|
1318
1756
|
return current;
|
|
1319
1757
|
this.#end(invocation);
|
|
1320
1758
|
if (decision !== false) {
|
|
1759
|
+
fault = { error: decision.fault };
|
|
1321
1760
|
const message = errorMessage(decision.fault);
|
|
1322
|
-
|
|
1761
|
+
invocation.fault = { status: "faulted", error: { message } };
|
|
1762
|
+
await this.#terminate(tx, current, invocation.fault);
|
|
1323
1763
|
}
|
|
1324
1764
|
return undefined;
|
|
1325
|
-
},
|
|
1765
|
+
}, withoutAbortSignal(invocation.context));
|
|
1766
|
+
// Reported once the fault is durable: a host may count it, for example toward a breaker.
|
|
1767
|
+
if (fault !== undefined)
|
|
1768
|
+
this.#report(fault.error, { type: "task_faulted", ...this.#aboutTask(invocation), reason: "threw" }, invocation.context);
|
|
1769
|
+
return continued;
|
|
1326
1770
|
}
|
|
1327
1771
|
catch (error) {
|
|
1328
1772
|
// The step writes the scheduler's own decision, a fault or a terminal record; a throw there would otherwise
|
|
1329
1773
|
// leave the task running, to be reserved and run again.
|
|
1330
1774
|
this.#end(invocation);
|
|
1331
|
-
this.#failSession(error);
|
|
1775
|
+
this.#failSession(error, invocation.context);
|
|
1332
1776
|
return undefined;
|
|
1333
1777
|
}
|
|
1334
1778
|
}
|
|
@@ -1353,17 +1797,22 @@ export class TaskScheduler {
|
|
|
1353
1797
|
async #commitState(tx, invocation, current, next) {
|
|
1354
1798
|
if (next.status === "waiting")
|
|
1355
1799
|
await this.#validateWait(tx, invocation, current, next.on, next.policy);
|
|
1800
|
+
if (next.status === "sleeping")
|
|
1801
|
+
validateSleep(invocation, current.id, next.until);
|
|
1356
1802
|
if (next.status === "terminal") {
|
|
1357
1803
|
const overlay = overlayOf(tx);
|
|
1358
1804
|
await this.#loadScopes(false);
|
|
1359
1805
|
for (const record of overlay.tasks.values())
|
|
1360
1806
|
await this.#loadChain(parentOf(record), overlay);
|
|
1361
1807
|
if (this.#hasOwnedLive(current.id, overlay)) {
|
|
1362
|
-
tx.setTask(withState(current, { status: "completing", outcome: next.outcome }));
|
|
1808
|
+
tx.setTask(withState(progressed(current), { status: "completing", outcome: next.outcome }));
|
|
1363
1809
|
return;
|
|
1364
1810
|
}
|
|
1365
1811
|
}
|
|
1366
|
-
|
|
1812
|
+
// A state the task committed that differs from its current one is progress: its restart count starts over. The
|
|
1813
|
+
// same checkpoint committed again, for example as intent before an effect, is not.
|
|
1814
|
+
const same = jsonEqual(current.state, next);
|
|
1815
|
+
tx.setTask(withState(same ? current : progressed(current), next));
|
|
1367
1816
|
}
|
|
1368
1817
|
/**
|
|
1369
1818
|
* A wait names existing tasks, and `failFast` only tasks the waiter owns. It may not close a cycle in the wait-for
|
|
@@ -1464,6 +1913,9 @@ export class TaskScheduler {
|
|
|
1464
1913
|
}
|
|
1465
1914
|
for (const watch of invocation.watches)
|
|
1466
1915
|
void watch.stop();
|
|
1916
|
+
for (const disarm of invocation.timers)
|
|
1917
|
+
disarm();
|
|
1918
|
+
invocation.timers.clear();
|
|
1467
1919
|
// Pending waits bound to the invocation, such as a tool's waitForTask(), reject with it.
|
|
1468
1920
|
invocation.controller.abort(endedError(invocation));
|
|
1469
1921
|
}
|
|
@@ -1501,7 +1953,7 @@ export class TaskScheduler {
|
|
|
1501
1953
|
};
|
|
1502
1954
|
const hooks = {
|
|
1503
1955
|
each: async (name, invoke) => {
|
|
1504
|
-
for (const handlers of agentHooks(await agent(), phase.task().definition.name)) {
|
|
1956
|
+
for (const { extension, handlers } of agentHooks(await agent(), phase.task().definition.name)) {
|
|
1505
1957
|
const handler = handlers[name];
|
|
1506
1958
|
if (typeof handler !== "function")
|
|
1507
1959
|
continue;
|
|
@@ -1511,17 +1963,20 @@ export class TaskScheduler {
|
|
|
1511
1963
|
catch (error) {
|
|
1512
1964
|
if (invocation.controller.signal.aborted)
|
|
1513
1965
|
throw error;
|
|
1514
|
-
this.#report(error);
|
|
1966
|
+
this.#report(error, { type: "extension_error", extension, task: reportTask(invocation) }, invocation.context);
|
|
1515
1967
|
}
|
|
1516
1968
|
}
|
|
1517
1969
|
},
|
|
1518
1970
|
};
|
|
1519
1971
|
const settings = this.#settings;
|
|
1972
|
+
const models = this.#models;
|
|
1520
1973
|
return {
|
|
1521
1974
|
taskId: invocation.taskId,
|
|
1522
1975
|
conversationId: invocation.conversationId,
|
|
1523
1976
|
signal: invocation.controller.signal,
|
|
1524
|
-
models
|
|
1977
|
+
get models() {
|
|
1978
|
+
return models();
|
|
1979
|
+
},
|
|
1525
1980
|
agent: (context) => invocation.ended ? Promise.reject(endedError(invocation)) : awaitWithContext(agent(), context),
|
|
1526
1981
|
get settings() {
|
|
1527
1982
|
return settings();
|
|
@@ -1531,11 +1986,22 @@ export class TaskScheduler {
|
|
|
1531
1986
|
get registry() {
|
|
1532
1987
|
return phase.snapshot();
|
|
1533
1988
|
},
|
|
1534
|
-
commit: (change, context) =>
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
await
|
|
1538
|
-
|
|
1989
|
+
commit: async (change, context) => {
|
|
1990
|
+
let checkpoint;
|
|
1991
|
+
await this.#gated(invocation, async (tx, current) => {
|
|
1992
|
+
const next = await change(tx, current);
|
|
1993
|
+
if (next !== undefined)
|
|
1994
|
+
await this.#commitState(tx, invocation, current, next);
|
|
1995
|
+
if (next?.status === "running" && current.state.checkpoint.phase !== next.checkpoint.phase) {
|
|
1996
|
+
checkpoint = next.checkpoint.phase;
|
|
1997
|
+
}
|
|
1998
|
+
}, context);
|
|
1999
|
+
if (checkpoint !== undefined) {
|
|
2000
|
+
invocation.context.telemetry.addEvent("pi.durable.checkpoint", {
|
|
2001
|
+
phase: checkpoint,
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
},
|
|
1539
2005
|
memo: ((name, ...rest) => {
|
|
1540
2006
|
if (rest.length === 1) {
|
|
1541
2007
|
return this.#read(invocation, async () => memoOf(this.#live.get(invocation.taskId), name));
|
|
@@ -1549,7 +2015,7 @@ export class TaskScheduler {
|
|
|
1549
2015
|
return candidate;
|
|
1550
2016
|
}, rest[1]);
|
|
1551
2017
|
}),
|
|
1552
|
-
|
|
2018
|
+
sleepUntil: (until, checkpoint, context) => this.#gated(invocation, (tx, current) => this.#commitState(tx, invocation, current, { status: "sleeping", checkpoint, until }), context),
|
|
1553
2019
|
watchDoc: ((...args) => this.#watchDoc(invocation, args)),
|
|
1554
2020
|
snapshot: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshot")(...args))),
|
|
1555
2021
|
snapshotAsOf: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshotAsOf")(...args))),
|
|
@@ -1634,10 +2100,31 @@ export class TaskScheduler {
|
|
|
1634
2100
|
throw endedError(invocation);
|
|
1635
2101
|
return this.#now();
|
|
1636
2102
|
},
|
|
1637
|
-
|
|
2103
|
+
setTimer: (callback, delayMs) => {
|
|
2104
|
+
// An ended invocation's timers never fire.
|
|
2105
|
+
if (invocation.ended)
|
|
2106
|
+
return () => { };
|
|
2107
|
+
const disarm = this.#timers.foreground(() => {
|
|
2108
|
+
invocation.timers.delete(disarm);
|
|
2109
|
+
if (!invocation.ended)
|
|
2110
|
+
callback();
|
|
2111
|
+
}, delayMs);
|
|
2112
|
+
invocation.timers.add(disarm);
|
|
2113
|
+
return () => {
|
|
2114
|
+
invocation.timers.delete(disarm);
|
|
2115
|
+
disarm();
|
|
2116
|
+
};
|
|
2117
|
+
},
|
|
2118
|
+
report: (error, from) => {
|
|
1638
2119
|
if (invocation.ended)
|
|
1639
2120
|
throw endedError(invocation);
|
|
1640
|
-
|
|
2121
|
+
// The task's own failure names the task's extension; any other names whose code failed, if anyone's.
|
|
2122
|
+
const task = reportTask(invocation);
|
|
2123
|
+
this.#report(error, from === undefined
|
|
2124
|
+
? { type: "task_error", ...this.#aboutTask(invocation) }
|
|
2125
|
+
: from.type === "extension"
|
|
2126
|
+
? { type: "extension_error", task, ...optionalExtension(from.extension) }
|
|
2127
|
+
: { type: "host_error", callback: from.callback, task }, invocation.context);
|
|
1641
2128
|
},
|
|
1642
2129
|
};
|
|
1643
2130
|
}
|
|
@@ -1668,22 +2155,6 @@ export class TaskScheduler {
|
|
|
1668
2155
|
return change(tx, current);
|
|
1669
2156
|
}, context, { conversationId: invocation.conversationId, taskId: invocation.taskId });
|
|
1670
2157
|
}
|
|
1671
|
-
/** Wait until the Harness clock reaches `until`, rechecking it after every timer. */
|
|
1672
|
-
async #sleep(invocation, until, context) {
|
|
1673
|
-
if (invocation.ended)
|
|
1674
|
-
throw endedError(invocation);
|
|
1675
|
-
const signals = [invocation.controller.signal];
|
|
1676
|
-
if (context.abortSignal !== undefined)
|
|
1677
|
-
signals.push(context.abortSignal);
|
|
1678
|
-
const signal = AbortSignal.any(signals);
|
|
1679
|
-
for (;;) {
|
|
1680
|
-
signal.throwIfAborted();
|
|
1681
|
-
const remaining = until - this.#now();
|
|
1682
|
-
if (remaining <= 0)
|
|
1683
|
-
return;
|
|
1684
|
-
await delay(Math.min(remaining, MAX_TIMER_DELAY), signal);
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
2158
|
async #watchDoc(invocation, args) {
|
|
1688
2159
|
if (invocation.ended)
|
|
1689
2160
|
throw endedError(invocation);
|
|
@@ -1699,9 +2170,29 @@ export class TaskScheduler {
|
|
|
1699
2170
|
return watch;
|
|
1700
2171
|
}
|
|
1701
2172
|
}
|
|
1702
|
-
|
|
2173
|
+
const REGISTRY = { type: "host_error", callback: "registry" };
|
|
2174
|
+
const SETTINGS = { type: "host_error", callback: "settings" };
|
|
2175
|
+
const ON_WAKE = { type: "host_error", callback: "onWake" };
|
|
2176
|
+
/** The extension that installed `task` in `snapshot`, the last one that lists it; none for a built-in task. */
|
|
2177
|
+
function installerOf(snapshot, task) {
|
|
2178
|
+
if (task === undefined)
|
|
2179
|
+
return undefined;
|
|
2180
|
+
return snapshot.installed().findLast((installed) => installed.tasks?.includes(task) === true)?.name;
|
|
2181
|
+
}
|
|
2182
|
+
/** The report info of a failure of a task. */
|
|
2183
|
+
function reportTask(task) {
|
|
2184
|
+
return { id: task.taskId, kind: task.kind, conversationId: task.conversationId };
|
|
2185
|
+
}
|
|
2186
|
+
/** A stored task's identity, as an invocation names it. */
|
|
2187
|
+
function recordTask(record) {
|
|
2188
|
+
return { taskId: record.id, kind: record.kind, conversationId: record.conversationId };
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* The record with an abort mark: `restart` for an abandonment; without a reason, a request, which replaces `restart`.
|
|
2192
|
+
* The restart count starts over: it now counts cut-offs of the abort handler.
|
|
2193
|
+
*/
|
|
1703
2194
|
function withAbortMark(record, reason) {
|
|
1704
|
-
const { abortReason: _reason, ...rest } = record;
|
|
2195
|
+
const { abortReason: _reason, restarts: _restarts, ...rest } = record;
|
|
1705
2196
|
return { ...rest, abortRequested: true, ...(reason === undefined ? {} : { abortReason: reason }) };
|
|
1706
2197
|
}
|
|
1707
2198
|
/** A live owner's durable cancellation intent: its abort mark, or a held outcome other than `completed`. */
|
|
@@ -1761,11 +2252,18 @@ function erased(task) {
|
|
|
1761
2252
|
function endedError(invocation) {
|
|
1762
2253
|
return new Error(`Task ${invocation.taskId} invocation has ended`);
|
|
1763
2254
|
}
|
|
1764
|
-
/**
|
|
2255
|
+
/** The record without its restart count, as after a state the task committed. */
|
|
2256
|
+
function progressed(record) {
|
|
2257
|
+
if (record.restarts === undefined)
|
|
2258
|
+
return record;
|
|
2259
|
+
const { restarts: _restarts, ...rest } = record;
|
|
2260
|
+
return rest;
|
|
2261
|
+
}
|
|
2262
|
+
/** Replace a live record's state; memos and the restart count disappear once an outcome is decided. */
|
|
1765
2263
|
function withState(record, state) {
|
|
1766
2264
|
if (state.status !== "terminal" && state.status !== "completing")
|
|
1767
2265
|
return { ...record, state };
|
|
1768
|
-
const { memos: _memos, ...rest } = record;
|
|
2266
|
+
const { memos: _memos, restarts: _restarts, ...rest } = record;
|
|
1769
2267
|
return { ...rest, state };
|
|
1770
2268
|
}
|
|
1771
2269
|
/** Whether a definition can take the task at reservation: same version, or newer with a migration. */
|
|
@@ -1773,18 +2271,13 @@ function canReserve(task, record) {
|
|
|
1773
2271
|
const definition = task.definition;
|
|
1774
2272
|
return (definition.version === record.version || (definition.version > record.version && definition.migrate !== undefined));
|
|
1775
2273
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
};
|
|
1782
|
-
|
|
1783
|
-
signal.removeEventListener("abort", onAbort);
|
|
1784
|
-
resolve();
|
|
1785
|
-
}, ms);
|
|
1786
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
1787
|
-
});
|
|
2274
|
+
/** A sleep's `until` is a time on the Harness clock; an abort handler cannot sleep. */
|
|
2275
|
+
function validateSleep(invocation, task, until) {
|
|
2276
|
+
if (invocation.mode === "abort")
|
|
2277
|
+
throw new Error(`Abort handler of task ${task} cannot sleep`);
|
|
2278
|
+
if (typeof until !== "number" || !Number.isFinite(until) || until < 0) {
|
|
2279
|
+
throw new Error(`Task ${task} cannot sleep until ${until}: not a time in milliseconds`);
|
|
2280
|
+
}
|
|
1788
2281
|
}
|
|
1789
2282
|
/** Structural equality of two JSON values; object key order is ignored. */
|
|
1790
2283
|
function jsonEqual(left, right) {
|