@earendil-works/pi-durable 1.3.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.
Files changed (119) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +111 -3
  3. package/dist/codemode/codemode.d.ts.map +1 -1
  4. package/dist/codemode/codemode.js +12 -4
  5. package/dist/codemode/codemode.js.map +1 -1
  6. package/dist/codemode/models.d.ts.map +1 -1
  7. package/dist/codemode/models.js +2 -0
  8. package/dist/codemode/models.js.map +1 -1
  9. package/dist/documents.d.ts +1 -1
  10. package/dist/documents.d.ts.map +1 -1
  11. package/dist/documents.js.map +1 -1
  12. package/dist/env/node.d.ts.map +1 -1
  13. package/dist/env/node.js +11 -3
  14. package/dist/env/node.js.map +1 -1
  15. package/dist/harness/agent.d.ts +13 -2
  16. package/dist/harness/agent.d.ts.map +1 -1
  17. package/dist/harness/agent.js +39 -5
  18. package/dist/harness/agent.js.map +1 -1
  19. package/dist/harness/compaction.d.ts +3 -1
  20. package/dist/harness/compaction.d.ts.map +1 -1
  21. package/dist/harness/compaction.js +19 -4
  22. package/dist/harness/compaction.js.map +1 -1
  23. package/dist/harness/generation.d.ts.map +1 -1
  24. package/dist/harness/generation.js +67 -27
  25. package/dist/harness/generation.js.map +1 -1
  26. package/dist/harness/harness.d.ts +5 -2
  27. package/dist/harness/harness.d.ts.map +1 -1
  28. package/dist/harness/harness.js +78 -13
  29. package/dist/harness/harness.js.map +1 -1
  30. package/dist/harness/inbox.d.ts +2 -2
  31. package/dist/harness/inbox.d.ts.map +1 -1
  32. package/dist/harness/inbox.js +3 -3
  33. package/dist/harness/inbox.js.map +1 -1
  34. package/dist/harness/prompt.d.ts +1 -1
  35. package/dist/harness/prompt.d.ts.map +1 -1
  36. package/dist/harness/prompt.js +1 -1
  37. package/dist/harness/prompt.js.map +1 -1
  38. package/dist/harness/scheduler.d.ts +55 -10
  39. package/dist/harness/scheduler.d.ts.map +1 -1
  40. package/dist/harness/scheduler.js +617 -124
  41. package/dist/harness/scheduler.js.map +1 -1
  42. package/dist/harness/submissions.d.ts +7 -2
  43. package/dist/harness/submissions.d.ts.map +1 -1
  44. package/dist/harness/submissions.js +48 -17
  45. package/dist/harness/submissions.js.map +1 -1
  46. package/dist/harness/task-graph.d.ts +18 -1
  47. package/dist/harness/task-graph.d.ts.map +1 -1
  48. package/dist/harness/task-graph.js +50 -11
  49. package/dist/harness/task-graph.js.map +1 -1
  50. package/dist/harness/telemetry.d.ts +307 -0
  51. package/dist/harness/telemetry.d.ts.map +1 -0
  52. package/dist/harness/telemetry.js +136 -0
  53. package/dist/harness/telemetry.js.map +1 -0
  54. package/dist/harness/tool.d.ts.map +1 -1
  55. package/dist/harness/tool.js +53 -7
  56. package/dist/harness/tool.js.map +1 -1
  57. package/dist/harness/types.d.ts +110 -15
  58. package/dist/harness/types.d.ts.map +1 -1
  59. package/dist/harness/types.js.map +1 -1
  60. package/dist/harness/util.d.ts +4 -0
  61. package/dist/harness/util.d.ts.map +1 -1
  62. package/dist/harness/util.js +4 -0
  63. package/dist/harness/util.js.map +1 -1
  64. package/dist/index.d.ts +6 -4
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +3 -2
  67. package/dist/index.js.map +1 -1
  68. package/dist/session/session.d.ts +17 -6
  69. package/dist/session/session.d.ts.map +1 -1
  70. package/dist/session/session.js +64 -32
  71. package/dist/session/session.js.map +1 -1
  72. package/dist/session/transaction.d.ts +5 -1
  73. package/dist/session/transaction.d.ts.map +1 -1
  74. package/dist/session/transaction.js +26 -1
  75. package/dist/session/transaction.js.map +1 -1
  76. package/dist/storage/indexeddb.d.ts +60 -0
  77. package/dist/storage/indexeddb.d.ts.map +1 -0
  78. package/dist/storage/indexeddb.js +831 -0
  79. package/dist/storage/indexeddb.js.map +1 -0
  80. package/dist/storage/jsonl/storage.d.ts +16 -0
  81. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  82. package/dist/storage/memory.js +1 -1
  83. package/dist/storage/memory.js.map +1 -1
  84. package/dist/storage/sqlite/cloudflare.d.ts +2 -51
  85. package/dist/storage/sqlite/cloudflare.d.ts.map +1 -1
  86. package/dist/storage/sqlite/cloudflare.js +2 -133
  87. package/dist/storage/sqlite/cloudflare.js.map +1 -1
  88. package/dist/storage/sqlite/database.d.ts +1 -36
  89. package/dist/storage/sqlite/database.d.ts.map +1 -1
  90. package/dist/storage/sqlite/database.js.map +1 -1
  91. package/dist/storage/sqlite/index.d.ts +1 -1
  92. package/dist/storage/sqlite/index.d.ts.map +1 -1
  93. package/dist/storage/sqlite/index.js.map +1 -1
  94. package/dist/storage/sqlite/migrations.d.ts +2 -5
  95. package/dist/storage/sqlite/migrations.d.ts.map +1 -1
  96. package/dist/storage/sqlite/migrations.js +28 -28
  97. package/dist/storage/sqlite/migrations.js.map +1 -1
  98. package/dist/storage/sqlite/node.d.ts +2 -37
  99. package/dist/storage/sqlite/node.d.ts.map +1 -1
  100. package/dist/storage/sqlite/node.js +3 -174
  101. package/dist/storage/sqlite/node.js.map +1 -1
  102. package/dist/storage/sqlite/schema.d.ts +1 -3
  103. package/dist/storage/sqlite/schema.d.ts.map +1 -1
  104. package/dist/storage/sqlite/schema.js +2 -9
  105. package/dist/storage/sqlite/schema.js.map +1 -1
  106. package/dist/storage/sqlite/storage.d.ts +1 -1
  107. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  108. package/dist/storage/sqlite/storage.js.map +1 -1
  109. package/dist/testing/storage-conformance.d.ts.map +1 -1
  110. package/dist/testing/storage-conformance.js +17 -0
  111. package/dist/testing/storage-conformance.js.map +1 -1
  112. package/dist/timers.d.ts +21 -0
  113. package/dist/timers.d.ts.map +1 -0
  114. package/dist/timers.js +72 -0
  115. package/dist/timers.js.map +1 -0
  116. package/dist/types.d.ts +130 -9
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/types.js.map +1 -1
  119. package/package.json +15 -5
@@ -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 { closedError, scanAll, Waiters } from "./util.js";
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
- /** Longest delay `setTimeout` supports; longer sleeps wait in several steps. */
9
- const MAX_TIMER_DELAY = 2_147_483_647;
10
- const LIVE_STATUSES = ["pending", "running", "waiting", "completing"];
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 synchronous
25
- * commit listener updates it on the Session line, so code running on the line reads exactly the committed state from it.
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
- /** Timer for the earliest expiry of an idle context, only where timers can be unreferenced (see `#scheduleExpiry`). */
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
- /** Load live tasks and change surviving `running` tasks back to `pending`. Dispatches nothing. */
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(() => this.#kick());
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
- tx.setTask(withState(record, { status: "pending", checkpoint: record.state.checkpoint }));
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
- /** Enable scheduling. Idempotent; the kick does nothing once closing. */
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
- * Signal every invocation and wait for them; writes nothing. Close calls this after every close listener has run, so
165
- * waits, watches, and streams a task holds end with the Session's reason, not as cancelled by its signal.
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
- for (const invocation of this.#invocations.values())
329
+ const stopped = [...this.#invocations.values()];
330
+ for (const invocation of stopped)
169
331
  invocation.controller.abort();
170
- await Promise.allSettled([...this.#invocations.values()].map((invocation) => invocation.done));
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. The timer is unreferenced, so it never keeps the process
395
- * alive. Where timers cannot be unreferenced, as in Cloudflare Workers, none is kept: a pending timer could keep a
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
- if (this.#expiry !== undefined)
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 timer = setTimeout(() => {
586
+ const disarm = this.#timers.background(() => {
414
587
  this.#expiry = undefined;
415
588
  this.#settleIdle();
416
- }, Math.min(Math.max(0, at - this.#now()), MAX_TIMER_DELAY));
417
- const unref = timer.unref;
418
- if (typeof unref !== "function") {
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
- if (this.#expiry !== undefined)
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(record, mode);
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
- this.#report(error);
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 previous;
1230
- for (;;) {
1231
- const current = await this.#step(invocation, (tx, current) => this.#decide(tx, current, previous, state));
1232
- // Close may seal between the decision and dispatch.
1233
- if (current === undefined || this.#closing)
1234
- return;
1235
- const checkpoint = current.state.checkpoint;
1236
- // Each phase handler resolves its agent afresh, at first use.
1237
- phase.agent = undefined;
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 erased(state.task).phases[checkpoint.phase](current, runtime, invocation.context);
1240
- previous = { checkpoint };
1642
+ return await run();
1241
1643
  }
1242
- catch (error) {
1243
- previous = { checkpoint, failure: { error } };
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 `waiting`) and 2 (closing)
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
- const cause = next === undefined ? "missing_task" : "incompatible_task";
1282
- this.#report(new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`, { cause }));
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
- let failure;
1294
- try {
1295
- const phase = { snapshot: () => reservation.snapshot, task: () => reservation.task, agent: undefined };
1296
- const runtime = this.#runtime(invocation, phase);
1297
- await erased(reservation.task).abort(current, runtime, invocation.context);
1298
- }
1299
- catch (error) {
1300
- failure = { error };
1301
- }
1302
- const message = `Abort handler of task ${invocation.taskId} returned without a terminal outcome`;
1303
- await this.#step(invocation, () => ({ fault: failure?.error ?? new Error(message) }));
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`, or
1307
- * `waiting`) or a closing Harness (rule 2) ends the invocation without a write; otherwise `decide` may stage a write
1308
- * and returns whether the invocation continues. Ending happens inside the callback, before a fault's Harness
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
- return await this.#session.commitWith(async (tx) => {
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
- await this.#terminate(tx, current, { status: "faulted", error: { message } });
1761
+ invocation.fault = { status: "faulted", error: { message } };
1762
+ await this.#terminate(tx, current, invocation.fault);
1323
1763
  }
1324
1764
  return undefined;
1325
- }, this.#context);
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
- tx.setTask(withState(current, next));
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: this.#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) => this.#gated(invocation, async (tx, current) => {
1535
- const next = await change(tx, current);
1536
- if (next !== undefined)
1537
- await this.#commitState(tx, invocation, current, next);
1538
- }, context),
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
- sleep: (until, context) => this.#sleep(invocation, until, context),
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
- report: (error) => {
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
- this.#report(error);
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
- /** The record with an abort mark: `restart` for an abandonment; without a reason, a request, which replaces `restart`. */
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
- /** Replace a live record's state; memos disappear once an outcome is decided. */
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
- function delay(ms, signal) {
1777
- return new Promise((resolve, reject) => {
1778
- const onAbort = () => {
1779
- clearTimeout(timer);
1780
- reject(signal.reason);
1781
- };
1782
- const timer = setTimeout(() => {
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) {