@earendil-works/pi-durable 1.4.0 → 1.5.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 (221) hide show
  1. package/CHANGELOG.md +186 -0
  2. package/README.md +181 -84
  3. package/dist/codemode/codemode.d.ts +4 -4
  4. package/dist/codemode/codemode.d.ts.map +1 -1
  5. package/dist/codemode/codemode.js +9 -12
  6. package/dist/codemode/codemode.js.map +1 -1
  7. package/dist/codemode/models.js +4 -4
  8. package/dist/codemode/models.js.map +1 -1
  9. package/dist/env/index.d.ts +12 -10
  10. package/dist/env/index.d.ts.map +1 -1
  11. package/dist/env/index.js.map +1 -1
  12. package/dist/env/node-watch.d.ts +3 -3
  13. package/dist/env/node-watch.d.ts.map +1 -1
  14. package/dist/env/node-watch.js +18 -16
  15. package/dist/env/node-watch.js.map +1 -1
  16. package/dist/env/node.d.ts +2 -2
  17. package/dist/env/node.d.ts.map +1 -1
  18. package/dist/env/node.js +1 -1
  19. package/dist/env/node.js.map +1 -1
  20. package/dist/errors.d.ts +18 -2
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +26 -3
  23. package/dist/errors.js.map +1 -1
  24. package/dist/harness/agent.d.ts.map +1 -1
  25. package/dist/harness/agent.js +10 -2
  26. package/dist/harness/agent.js.map +1 -1
  27. package/dist/harness/compaction.d.ts.map +1 -1
  28. package/dist/harness/compaction.js +19 -2
  29. package/dist/harness/compaction.js.map +1 -1
  30. package/dist/harness/context.d.ts +23 -7
  31. package/dist/harness/context.d.ts.map +1 -1
  32. package/dist/harness/context.js +71 -25
  33. package/dist/harness/context.js.map +1 -1
  34. package/dist/harness/define.d.ts +7 -3
  35. package/dist/harness/define.d.ts.map +1 -1
  36. package/dist/harness/define.js +4 -0
  37. package/dist/harness/define.js.map +1 -1
  38. package/dist/harness/generation.d.ts +38 -5
  39. package/dist/harness/generation.d.ts.map +1 -1
  40. package/dist/harness/generation.js +445 -96
  41. package/dist/harness/generation.js.map +1 -1
  42. package/dist/harness/harness.d.ts +2 -0
  43. package/dist/harness/harness.d.ts.map +1 -1
  44. package/dist/harness/harness.js +87 -62
  45. package/dist/harness/harness.js.map +1 -1
  46. package/dist/harness/inbox.d.ts +5 -1
  47. package/dist/harness/inbox.d.ts.map +1 -1
  48. package/dist/harness/inbox.js +4 -1
  49. package/dist/harness/inbox.js.map +1 -1
  50. package/dist/harness/json.d.ts +20 -4
  51. package/dist/harness/json.d.ts.map +1 -1
  52. package/dist/harness/json.js +23 -5
  53. package/dist/harness/json.js.map +1 -1
  54. package/dist/harness/live.d.ts +122 -16
  55. package/dist/harness/live.d.ts.map +1 -1
  56. package/dist/harness/live.js +93 -12
  57. package/dist/harness/live.js.map +1 -1
  58. package/dist/harness/partial-changes.d.ts +54 -0
  59. package/dist/harness/partial-changes.d.ts.map +1 -0
  60. package/dist/harness/partial-changes.js +162 -0
  61. package/dist/harness/partial-changes.js.map +1 -0
  62. package/dist/harness/registry-reader.d.ts +11 -0
  63. package/dist/harness/registry-reader.d.ts.map +1 -0
  64. package/dist/harness/registry-reader.js +25 -0
  65. package/dist/harness/registry-reader.js.map +1 -0
  66. package/dist/harness/scheduler.d.ts +46 -19
  67. package/dist/harness/scheduler.d.ts.map +1 -1
  68. package/dist/harness/scheduler.js +973 -376
  69. package/dist/harness/scheduler.js.map +1 -1
  70. package/dist/harness/submissions.d.ts +17 -10
  71. package/dist/harness/submissions.d.ts.map +1 -1
  72. package/dist/harness/submissions.js +62 -25
  73. package/dist/harness/submissions.js.map +1 -1
  74. package/dist/harness/task-graph.d.ts +12 -9
  75. package/dist/harness/task-graph.d.ts.map +1 -1
  76. package/dist/harness/task-graph.js +38 -28
  77. package/dist/harness/task-graph.js.map +1 -1
  78. package/dist/harness/telemetry.d.ts +11 -11
  79. package/dist/harness/telemetry.d.ts.map +1 -1
  80. package/dist/harness/telemetry.js +7 -7
  81. package/dist/harness/telemetry.js.map +1 -1
  82. package/dist/harness/tool.d.ts +2 -0
  83. package/dist/harness/tool.d.ts.map +1 -1
  84. package/dist/harness/tool.js +171 -55
  85. package/dist/harness/tool.js.map +1 -1
  86. package/dist/harness/transcript.d.ts +72 -0
  87. package/dist/harness/transcript.d.ts.map +1 -0
  88. package/dist/harness/transcript.js +264 -0
  89. package/dist/harness/transcript.js.map +1 -0
  90. package/dist/harness/types.d.ts +218 -59
  91. package/dist/harness/types.d.ts.map +1 -1
  92. package/dist/harness/types.js.map +1 -1
  93. package/dist/harness/util.d.ts +3 -1
  94. package/dist/harness/util.d.ts.map +1 -1
  95. package/dist/harness/util.js +9 -0
  96. package/dist/harness/util.js.map +1 -1
  97. package/dist/harness/view.d.ts +43 -22
  98. package/dist/harness/view.d.ts.map +1 -1
  99. package/dist/harness/view.js +130 -71
  100. package/dist/harness/view.js.map +1 -1
  101. package/dist/index.d.ts +9 -7
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +4 -3
  104. package/dist/index.js.map +1 -1
  105. package/dist/search.d.ts +19 -3
  106. package/dist/search.d.ts.map +1 -1
  107. package/dist/search.js +107 -44
  108. package/dist/search.js.map +1 -1
  109. package/dist/session/cached-storage.d.ts +71 -0
  110. package/dist/session/cached-storage.d.ts.map +1 -0
  111. package/dist/session/cached-storage.js +269 -0
  112. package/dist/session/cached-storage.js.map +1 -0
  113. package/dist/session/conversation-tails.d.ts +33 -0
  114. package/dist/session/conversation-tails.d.ts.map +1 -0
  115. package/dist/session/conversation-tails.js +101 -0
  116. package/dist/session/conversation-tails.js.map +1 -0
  117. package/dist/session/keys.d.ts +68 -0
  118. package/dist/session/keys.d.ts.map +1 -0
  119. package/dist/session/keys.js +161 -0
  120. package/dist/session/keys.js.map +1 -0
  121. package/dist/session/session.d.ts +167 -26
  122. package/dist/session/session.d.ts.map +1 -1
  123. package/dist/session/session.js +1029 -171
  124. package/dist/session/session.js.map +1 -1
  125. package/dist/session/transaction.d.ts +81 -3
  126. package/dist/session/transaction.d.ts.map +1 -1
  127. package/dist/session/transaction.js +224 -37
  128. package/dist/session/transaction.js.map +1 -1
  129. package/dist/session/writer.d.ts +74 -0
  130. package/dist/session/writer.d.ts.map +1 -0
  131. package/dist/session/writer.js +294 -0
  132. package/dist/session/writer.js.map +1 -0
  133. package/dist/storage/indexeddb.d.ts +10 -2
  134. package/dist/storage/indexeddb.d.ts.map +1 -1
  135. package/dist/storage/indexeddb.js +91 -41
  136. package/dist/storage/indexeddb.js.map +1 -1
  137. package/dist/storage/jsonl/storage.d.ts +14 -2
  138. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  139. package/dist/storage/jsonl/storage.js +88 -24
  140. package/dist/storage/jsonl/storage.js.map +1 -1
  141. package/dist/storage/memory.d.ts +7 -2
  142. package/dist/storage/memory.d.ts.map +1 -1
  143. package/dist/storage/memory.js +28 -2
  144. package/dist/storage/memory.js.map +1 -1
  145. package/dist/storage/sqlite/storage.d.ts +14 -2
  146. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  147. package/dist/storage/sqlite/storage.js +110 -36
  148. package/dist/storage/sqlite/storage.js.map +1 -1
  149. package/dist/storage/store/commit.d.ts +33 -0
  150. package/dist/storage/store/commit.d.ts.map +1 -0
  151. package/dist/storage/store/commit.js +286 -0
  152. package/dist/storage/store/commit.js.map +1 -0
  153. package/dist/storage/store/documents.d.ts +43 -0
  154. package/dist/storage/store/documents.d.ts.map +1 -0
  155. package/dist/storage/store/documents.js +102 -0
  156. package/dist/storage/store/documents.js.map +1 -0
  157. package/dist/storage/store/index.d.ts +2 -0
  158. package/dist/storage/store/index.d.ts.map +1 -0
  159. package/dist/storage/store/index.js +2 -0
  160. package/dist/storage/store/index.js.map +1 -0
  161. package/dist/storage/store/queries.d.ts +49 -0
  162. package/dist/storage/store/queries.d.ts.map +1 -0
  163. package/dist/storage/store/queries.js +93 -0
  164. package/dist/storage/store/queries.js.map +1 -0
  165. package/dist/storage/store/storage.d.ts +59 -0
  166. package/dist/storage/store/storage.d.ts.map +1 -0
  167. package/dist/storage/store/storage.js +438 -0
  168. package/dist/storage/store/storage.js.map +1 -0
  169. package/dist/storage/store/tables.d.ts +54 -0
  170. package/dist/storage/store/tables.d.ts.map +1 -0
  171. package/dist/storage/store/tables.js +108 -0
  172. package/dist/storage/store/tables.js.map +1 -0
  173. package/dist/testing/commit-writes.d.ts +5 -0
  174. package/dist/testing/commit-writes.d.ts.map +1 -0
  175. package/dist/testing/commit-writes.js +12 -0
  176. package/dist/testing/commit-writes.js.map +1 -0
  177. package/dist/testing/env-conformance.js +7 -7
  178. package/dist/testing/env-conformance.js.map +1 -1
  179. package/dist/testing/index.d.ts +1 -0
  180. package/dist/testing/index.d.ts.map +1 -1
  181. package/dist/testing/index.js +1 -0
  182. package/dist/testing/index.js.map +1 -1
  183. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  184. package/dist/testing/storage-benchmark.js +18 -17
  185. package/dist/testing/storage-benchmark.js.map +1 -1
  186. package/dist/testing/storage-conformance.d.ts.map +1 -1
  187. package/dist/testing/storage-conformance.js +354 -69
  188. package/dist/testing/storage-conformance.js.map +1 -1
  189. package/dist/tool-search/tool-search.d.ts +6 -12
  190. package/dist/tool-search/tool-search.d.ts.map +1 -1
  191. package/dist/tool-search/tool-search.js +7 -7
  192. package/dist/tool-search/tool-search.js.map +1 -1
  193. package/dist/tools/bash.d.ts +12 -12
  194. package/dist/tools/bash.d.ts.map +1 -1
  195. package/dist/tools/bash.js +16 -15
  196. package/dist/tools/bash.js.map +1 -1
  197. package/dist/tools/edit.d.ts +7 -7
  198. package/dist/tools/edit.d.ts.map +1 -1
  199. package/dist/tools/edit.js +9 -9
  200. package/dist/tools/edit.js.map +1 -1
  201. package/dist/tools/index.d.ts +32 -32
  202. package/dist/tools/read.d.ts +6 -6
  203. package/dist/tools/read.d.ts.map +1 -1
  204. package/dist/tools/read.js +19 -8
  205. package/dist/tools/read.js.map +1 -1
  206. package/dist/tools/write.d.ts +4 -4
  207. package/dist/tools/write.d.ts.map +1 -1
  208. package/dist/tools/write.js +5 -5
  209. package/dist/tools/write.js.map +1 -1
  210. package/dist/types.d.ts +223 -77
  211. package/dist/types.d.ts.map +1 -1
  212. package/dist/types.js.map +1 -1
  213. package/package.json +19 -12
  214. package/dist/harness/events.d.ts +0 -186
  215. package/dist/harness/events.d.ts.map +0 -1
  216. package/dist/harness/events.js +0 -373
  217. package/dist/harness/events.js.map +0 -1
  218. package/dist/session/observation.d.ts +0 -42
  219. package/dist/session/observation.d.ts.map +0 -1
  220. package/dist/session/observation.js +0 -281
  221. package/dist/session/observation.js.map +0 -1
@@ -1,12 +1,15 @@
1
1
  import { copyJson } from "@earendil-works/chord";
2
2
  import { awaitWithContext, withAbortSignal, withoutAbortSignal, withTelemetry } from "@earendil-works/chord/context";
3
+ import { ownedSlice } from "@earendil-works/chord/delta";
3
4
  import { createTypedMetrics, createTypedSpanStarter, NOOP_TELEMETRY_CONTEXT, NOOP_TELEMETRY_METRICS, } from "@earendil-works/pi-telemetry";
4
- import { errorMessage } from "../errors.js";
5
+ import { errorMessage, ReadOnlySession } from "../errors.js";
6
+ import { ALL_KEYS, conversationKey, INTENT_KEY, ownedKey, ROOTS_KEY, taskKey } from "../session/keys.js";
5
7
  import { agentHooks, DEFAULT_RESTART_LIMIT } from "./agent.js";
6
- import { readContextFrom } from "./context.js";
8
+ import { advanceRange, rangeEntry, readContextFrom } from "./context.js";
7
9
  import { DURABLE_TELEMETRY_SCHEMA } from "./telemetry.js";
8
- import { closedError, optionalExtension, scanAll, Waiters } from "./util.js";
10
+ import { closedError, conversationEntries, optionalExtension, scanAll, Waiters } from "./util.js";
9
11
  const SCAN_PAGE_SIZE = 256;
12
+ const NO_KEYS = [];
10
13
  const LIVE_STATUSES = ["pending", "running", "waiting", "sleeping", "completing"];
11
14
  /** The part of a record `blockedReason()` reads. */
12
15
  export function blockedSubject(record) {
@@ -16,10 +19,17 @@ export function blockedSubject(record) {
16
19
  version: record.version,
17
20
  abortRequested: record.abortRequested,
18
21
  ...(record.abortReason === undefined ? {} : { abortReason: record.abortReason }),
19
- ...(record.restarts === undefined ? {} : { restarts: record.restarts }),
20
22
  status: record.state.status,
21
23
  };
22
24
  }
25
+ /** Continue with the next phase, the first one. */
26
+ const CONTINUE = { kind: "continue" };
27
+ /** Rule 5: the phase made durable progress; `#progress()` decides once the decision stands. */
28
+ const PROGRESS = { kind: "progress" };
29
+ /** End the invocation without a write. */
30
+ const END = { kind: "end" };
31
+ /** End it: an owner's cancellation intent reaches it before its cascade did; schedule the cascade. */
32
+ const CASCADE = { kind: "cascade" };
23
33
  function conversationNode(id) {
24
34
  return `c${id}`;
25
35
  }
@@ -48,9 +58,16 @@ function parentNode(node) {
48
58
  * later below a dropped node finds it unknown and loads its chain from storage again.
49
59
  *
50
60
  * Invariant: every task transition is decided and written by one callback serialized on the Session line. That covers
51
- * reservation, marks, runtime commits, finalization, and the synchronous step before each phase, which applies the
52
- * precedence rules and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the
53
- * step that decides its end, so a runtime commit it queued either lands before that decision or is rejected.
61
+ * reservation, marks, runtime commits, finalization, and the step before each phase, which applies the precedence rules
62
+ * and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the step that decides
63
+ * its end, so a runtime commit it queued either lands before that decision or is rejected.
64
+ *
65
+ * Decisions stay serialized; their storage overlaps (group commit). The Session stores the commits of several jobs in
66
+ * one call while the line runs on, and this memory changes only as each commit is published. So every decision reads
67
+ * it in a decision region (`#region()`): synchronous, recording the keys it reads (a task's record, the live work below
68
+ * a node, the cancellation intent set), staging nothing; when one of them has an unpublished writer, the region waits
69
+ * for its publication and runs again, and only a region that read nothing unpublished is applied. Every commit writes
70
+ * the memberships its task changes alter (`membershipKeys()`), so a walk that a pending commit would change waits.
54
71
  */
55
72
  export class TaskScheduler {
56
73
  #telemetry;
@@ -70,6 +87,7 @@ export class TaskScheduler {
70
87
  #writeAtClose;
71
88
  #report;
72
89
  #settleOutcome;
90
+ #limitsRestarts;
73
91
  #withdrawInputs;
74
92
  #conversation;
75
93
  #context;
@@ -80,6 +98,20 @@ export class TaskScheduler {
80
98
  * first idle check after `settings.contextRetentionMs` of idleness, and at close.
81
99
  */
82
100
  #contexts = new Map();
101
+ /**
102
+ * The idle kept contexts by `idleSince`, oldest first: the expiry order, so neither an expiry nor finding the next one
103
+ * scans `#contexts`. Kept sorted also when the Harness clock goes back (see `#markIdle`).
104
+ */
105
+ #idleOrder = new Map();
106
+ /** At least the newest `idleSince` in `#idleOrder` (drops do not lower it): an older one added means the clock went back. */
107
+ #idleNewest = Number.NEGATIVE_INFINITY;
108
+ /**
109
+ * A chain load resolved what `#idle()` treats as unknown for a live task whose chain is not loaded, which can make a
110
+ * scope idle without a task change below it; the next idle check then covers every scope.
111
+ */
112
+ #unknownsChanged = false;
113
+ /** Idle checks that covered every scope (`idleState`, a test diagnostic). */
114
+ #fullIdleChecks = 0;
83
115
  /** Background timer for the earliest expiry of an idle context, unless the host declined it (see `#scheduleExpiry`). */
84
116
  #expiry;
85
117
  #invocations = new Map();
@@ -133,15 +165,21 @@ export class TaskScheduler {
133
165
  #blockedListeners = new Set();
134
166
  /** A registry snapshot that threw for the task graph was reported; it is read per record, so only once. */
135
167
  #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();
138
168
  #reconcileScheduled = false;
139
169
  /** Reconcile passes underway with work (they may overlap): their commits finalize held outcomes and mark cascades. */
140
170
  #reconcilingWork = 0;
141
171
  #sweepScheduled = false;
172
+ /**
173
+ * Nodes chain loads installed for jobs whose commits are not published yet, with how many such jobs: the sweep keeps
174
+ * them, as work a pending commit creates below them needs them.
175
+ */
176
+ #pins = new Map();
177
+ /** Keys the decision region underway reads (`#region()`); set only while one runs. */
178
+ #reading;
142
179
  #cascadePending = false;
143
180
  #unsubscribeRegistry = () => { };
144
181
  #enabled = false;
182
+ #readOnly;
145
183
  #closing = false;
146
184
  #dirty = false;
147
185
  #draining = false;
@@ -163,24 +201,26 @@ export class TaskScheduler {
163
201
  this.#writeAtClose = options.writeAtClose;
164
202
  this.#report = options.report;
165
203
  this.#settleOutcome = options.settleOutcome;
204
+ this.#limitsRestarts = options.limitsRestarts;
166
205
  this.#withdrawInputs = options.withdrawInputs;
167
206
  this.#conversation = options.conversation;
168
207
  this.#context = options.context;
208
+ this.#readOnly = options.readOnly === true;
169
209
  }
170
210
  /**
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.
211
+ * Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing;
212
+ * a task cut off `settings.restartLimit` times ends when it is next reserved. Read-only, it only loads them: a
213
+ * `running` task stays as committed, running in another process.
173
214
  */
174
215
  async open(context) {
175
- this.#session.observeCommits((publication) => this.#observe(publication));
216
+ this.#session.followCommits((publication) => this.#observe(publication));
176
217
  this.#session.subscribeClose(() => this.#seal());
177
218
  this.#unsubscribeRegistry = this.#registry.subscribe(() => {
178
219
  this.#notifyBlocked();
179
220
  this.#kick();
180
221
  });
181
- const limit = this.#restartLimit();
182
- const parked = [];
183
222
  await this.#session.commitWith(async (tx) => {
223
+ await this.#published();
184
224
  // Every table read before the first write.
185
225
  const scans = [];
186
226
  for (const status of LIVE_STATUSES) {
@@ -190,14 +230,14 @@ export class TaskScheduler {
190
230
  for (const record of records) {
191
231
  this.#track(undefined, record);
192
232
  this.#resumed.add(record.id);
233
+ if (this.#readOnly)
234
+ continue;
193
235
  if (record.abandonOnRestart === true)
194
236
  this.#abandoned.add(record.id);
195
237
  if (record.state.status === "running") {
196
238
  const restarts = (record.restarts ?? 0) + 1;
197
239
  const pending = withState(record, { status: "pending", checkpoint: record.state.checkpoint });
198
240
  tx.setTask({ ...pending, restarts });
199
- if (restarts >= limit && !record.abortRequested)
200
- parked.push(record);
201
241
  }
202
242
  if (record.state.status === "waiting" && record.state.policy === "failFast") {
203
243
  this.#failFastChecks.add(record.id);
@@ -205,11 +245,8 @@ export class TaskScheduler {
205
245
  }
206
246
  }
207
247
  }, 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
- }
248
+ if (this.#readOnly)
249
+ return;
213
250
  // Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes.
214
251
  this.#cascadePending = true;
215
252
  this.#scheduleReconcile();
@@ -220,29 +257,15 @@ export class TaskScheduler {
220
257
  * Idempotent; the kick does nothing once closing.
221
258
  */
222
259
  resume() {
260
+ if (this.#readOnly)
261
+ throw new ReadOnlySession("running tasks");
223
262
  this.#enabled = true;
224
263
  this.#wakeDue();
225
264
  this.#kick();
226
265
  }
227
266
  /**
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.
267
+ * Why a live task cannot run here, if it cannot: its definition is missing, too old, or failed to migrate. A running
268
+ * task, an abort-marked one, and one holding its outcome are never blocked.
246
269
  */
247
270
  blockedReason(record) {
248
271
  const status = record.status;
@@ -251,8 +274,6 @@ export class TaskScheduler {
251
274
  // An abort request runs its handler or orphans the task; a `restart` mark waits for the definition, blocked.
252
275
  if (record.abortRequested && record.abortReason === undefined)
253
276
  return undefined;
254
- if (this.#parked(record))
255
- return "restart_limit";
256
277
  let snapshot;
257
278
  try {
258
279
  snapshot = this.#registry.snapshot();
@@ -278,11 +299,26 @@ export class TaskScheduler {
278
299
  * sleep not yet due. Synchronous, from memory; a task whose chain is not loaded yet counts as it would alone.
279
300
  */
280
301
  wake() {
302
+ // A read-only Harness runs nothing, so it never needs a process.
303
+ if (this.#readOnly)
304
+ return { active: false };
281
305
  const now = this.#now();
282
306
  // A reconcile pass with work to do is work too: it finalizes held outcomes and marks cascades, at open as
283
307
  // 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();
308
+ const active = this.#invocations.size > 0 || this.#reconcilingWork > 0 || this.#reconcileWork() || this.#runsNow(now);
309
+ // The earliest sleep not yet due: only `#sleeping` holds sleeping tasks, so no pass visits every live task.
285
310
  let at;
311
+ for (const id of this.#sleeping) {
312
+ const record = this.#live.get(id);
313
+ const state = record?.state;
314
+ if (state?.status === "sleeping" && !record.abortRequested && state.until > now) {
315
+ at = at === undefined ? state.until : Math.min(at, state.until);
316
+ }
317
+ }
318
+ return at === undefined ? { active } : { active, at };
319
+ }
320
+ /** Whether a live task can run now: the scan `wake()` needs only while no invocation runs. */
321
+ #runsNow(now) {
286
322
  let snapshot;
287
323
  let snapshotFailed = false;
288
324
  for (const record of this.#live.values()) {
@@ -291,13 +327,7 @@ export class TaskScheduler {
291
327
  continue;
292
328
  if (state.status === "waiting" && !record.abortRequested && state.on.some((id) => this.#live.has(id)))
293
329
  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))
330
+ if (state.status === "sleeping" && !record.abortRequested && state.until > now)
301
331
  continue;
302
332
  // An abort invocation waits for the live work below it, which counts itself.
303
333
  if (record.abortRequested && this.#waitingOn(record, true).length > 0)
@@ -316,9 +346,9 @@ export class TaskScheduler {
316
346
  if (snapshot !== undefined && "reason" in this.#fit(record, snapshot.task(record.kind)))
317
347
  continue;
318
348
  }
319
- active = true;
349
+ return true;
320
350
  }
321
- return at === undefined ? { active } : { active, at };
351
+ return false;
322
352
  }
323
353
  /**
324
354
  * Signal every invocation and wait for them, then write the tasks they ran back as `pending` (`writeAtClose`), as a
@@ -363,12 +393,14 @@ export class TaskScheduler {
363
393
  return { result: "marked" };
364
394
  if (invocation === undefined && current.state.status !== "completing") {
365
395
  await this.#loadScopes(false);
366
- if (!this.#hasOwnedLive(id)) {
367
- const resolution = this.#resolve(current, this.#registry.snapshot());
368
- if (resolution.kind === "blocked") {
369
- await this.#terminate(tx, current, { status: "orphaned", reason: resolution.reason });
370
- return { result: "marked" };
371
- }
396
+ const owns = await this.#region(() => this.#hasOwnedLive(id), this.#reloadScopes);
397
+ // Resolved once that decision stands, as it may run `migrate`.
398
+ const resolution = owns
399
+ ? undefined
400
+ : this.#resolve(current, this.#registry.snapshot());
401
+ if (resolution?.kind === "blocked") {
402
+ await this.#terminate(tx, current, { status: "orphaned", reason: resolution.reason });
403
+ return { result: "marked" };
372
404
  }
373
405
  }
374
406
  if (!current.abortRequested || current.abortReason !== undefined)
@@ -384,12 +416,13 @@ export class TaskScheduler {
384
416
  }
385
417
  async waitForTask(id, context) {
386
418
  // Check and register on the line so no terminal publication falls between them.
387
- const found = await this.#session.readOnLine(async () => {
419
+ const found = await this.#session.readOnLine(async (storage) => {
388
420
  if (this.#closing)
389
421
  throw closedError(this.#session);
390
- if (this.#live.has(id))
422
+ if (await this.#region(() => this.#liveRecord(id) !== undefined)) {
391
423
  return { promise: this.#taskWaiters.add(id, context) };
392
- const record = await this.#storage.task(id, context);
424
+ }
425
+ const record = await storage.task(id, context);
393
426
  if (record === undefined)
394
427
  throw new Error(`Task ${id} does not exist`);
395
428
  return { promise: Promise.resolve(record) };
@@ -415,6 +448,7 @@ export class TaskScheduler {
415
448
  */
416
449
  async abortConversation(conversationId, background, context) {
417
450
  const reached = await this.#session.commitWith(async (tx) => {
451
+ await this.#published();
418
452
  const queued = await this.#loadScopes(true);
419
453
  const scope = { conversation: conversationId };
420
454
  const reached = [];
@@ -437,11 +471,100 @@ export class TaskScheduler {
437
471
  await this.waitForTask(id, context);
438
472
  await this.waitForIdle(conversationId, context);
439
473
  }
474
+ /**
475
+ * The ownership memberships a commit's task and conversation changes write (group commit §4.2), at its admission on
476
+ * the line: for each task created or made terminal, every node from its parent up to and including the nearest live
477
+ * task, or the root conversation, and then the roots too, conservatively, as a pending end may unlist the root a
478
+ * creation finds listed; for each conversation created under a task, that task; and the intent set when
479
+ * a change sets, upgrades, or clears a mark or a held or ended outcome other than `completed`. Loads the chains it
480
+ * walks; the nodes it installs stay until the commit is published.
481
+ */
482
+ membershipKeys(tx) {
483
+ const tasks = tx.stagedTasks();
484
+ const conversations = tx.stagedConversations();
485
+ if (tasks.length === 0 && conversations.every((record) => record.owner === undefined))
486
+ return NO_KEYS;
487
+ return this.#membershipKeys(tx, tasks, conversations);
488
+ }
489
+ async #membershipKeys(tx, tasks, conversations) {
490
+ const keys = new Set();
491
+ const overlay = overlayOf(tx);
492
+ const created = new Set(tx.createdTaskIds());
493
+ const reached = new Map();
494
+ for (const record of tasks) {
495
+ if (intentChanges(this.#live.get(record.id), record))
496
+ keys.add(INTENT_KEY);
497
+ if (!created.has(record.id) && record.state.status !== "terminal")
498
+ continue;
499
+ await this.#loadChain(parentOf(record), overlay);
500
+ if ((await this.#chainKeys(parentNode(record), overlay, keys, reached)) !== undefined)
501
+ keys.add(ROOTS_KEY);
502
+ }
503
+ for (const conversation of conversations) {
504
+ const owner = conversation.owner?.taskId;
505
+ if (owner === undefined)
506
+ continue;
507
+ await this.#loadChain({ task: owner }, overlay);
508
+ await this.#chainKeys(owner, overlay, keys, reached);
509
+ }
510
+ return [...keys];
511
+ }
512
+ /**
513
+ * Add the memberships from `start` up to and including the nearest live task, or the root conversation, which it
514
+ * returns. `reached` keeps, per node walked, where its walk ended, so a chain several tasks of a commit share is
515
+ * walked once. Whether an ancestor is live can change under a pending commit, so it is read after a touch of the
516
+ * ancestor's record: a pending end must not stop the chain early. Ownership fields are immutable, so not keyed.
517
+ */
518
+ async #chainKeys(start, overlay, keys, reached) {
519
+ const walked = [];
520
+ let root;
521
+ let node = start;
522
+ while (node !== undefined && node !== null) {
523
+ const key = ownedKey(node);
524
+ // Walked up from here already, for another task of the commit.
525
+ if (reached.has(key)) {
526
+ root = reached.get(key);
527
+ break;
528
+ }
529
+ keys.add(key);
530
+ walked.push(key);
531
+ if (typeof node === "string") {
532
+ const conversation = conversationOf(node);
533
+ node = this.#edge(conversation, overlay);
534
+ if (node === null)
535
+ root = conversation;
536
+ continue;
537
+ }
538
+ let record = overlay.tasks.get(node);
539
+ if (record === undefined) {
540
+ const gate = this.#session.touch(taskKey(node));
541
+ if (gate !== undefined) {
542
+ await gate;
543
+ // It may have ended meanwhile, and its node been swept: load the chain above it again.
544
+ await this.#loadChain({ task: node }, overlay);
545
+ }
546
+ record = this.#live.get(node);
547
+ }
548
+ if (record !== undefined && record.state.status !== "terminal")
549
+ break;
550
+ const above = record ?? this.#settled.get(node);
551
+ node = above === undefined ? undefined : parentNode(above);
552
+ }
553
+ for (const key of walked)
554
+ reached.set(key, root);
555
+ return root;
556
+ }
440
557
  // ─── Scheduling ────────────────────────────────────────────────────────
441
558
  #observe(publication) {
559
+ // A commit held in memory changes documents only.
560
+ if (publication.kind === "memory")
561
+ return;
562
+ this.#advanceContexts(publication);
442
563
  const updated = [];
443
564
  const failed = [];
444
565
  let changed = false;
566
+ // The conversations whose idleness the commit's task changes can change; undefined when a chain is not known.
567
+ let dirty = new Set();
445
568
  // Edges first, so the tasks a commit creates in a new conversation hang from it.
446
569
  for (const change of publication.changes) {
447
570
  if (change.type === "conversation" && !this.#edges.has(change.value.id)) {
@@ -457,6 +580,11 @@ export class TaskScheduler {
457
580
  if (failedOutcome(record) && (previous === undefined || !failedOutcome(previous)))
458
581
  failed.push(record.id);
459
582
  this.#track(previous, record);
583
+ // Idleness counts live tasks, so only a task that became live or ended can change it. Collected before `#sweep()`,
584
+ // which may forget the chain of a task that ended.
585
+ const liveness = previous === undefined || record.state.status === "terminal";
586
+ if (liveness && dirty !== undefined && !this.#addScopesAbove(record, dirty))
587
+ dirty = undefined;
460
588
  if (record.state.status === "terminal") {
461
589
  this.#failedMigrations.delete(record.id);
462
590
  this.#failFastChecks.delete(record.id);
@@ -467,10 +595,11 @@ export class TaskScheduler {
467
595
  }
468
596
  if (record.abortRequested && previous?.abortRequested !== true) {
469
597
  this.#cascadePending = true;
470
- // Signal a run invocation of the newly marked task; its next step ends it.
598
+ // Signal a run invocation of the newly marked task; its next step ends it. Abort listeners are task code, which
599
+ // never runs inside a publication: the signal follows in a microtask, after host commit listeners.
471
600
  const invocation = this.#invocations.get(record.id);
472
601
  if (invocation?.mode === "run")
473
- invocation.controller.abort();
602
+ queueMicrotask(() => invocation.controller.abort());
474
603
  }
475
604
  // A request replacing a `restart` mark upgrades the marks of the work below.
476
605
  if (previous?.abortReason !== undefined && record.abortReason === undefined)
@@ -524,9 +653,60 @@ export class TaskScheduler {
524
653
  this.#sweep();
525
654
  if (!changed)
526
655
  return;
527
- this.#settleIdle();
656
+ this.#settleIdle(dirty);
528
657
  this.#kick();
529
658
  }
659
+ /**
660
+ * Add the conversations above `record` to `scopes`: those whose idleness a change of it can change. False when a node
661
+ * on the way up is not known. A conversation already in `scopes` has everything above it there too.
662
+ */
663
+ #addScopesAbove(record, scopes) {
664
+ for (const step of this.#above(parentOf(record))) {
665
+ if ("unknown" in step)
666
+ return false;
667
+ if (!("conversation" in step))
668
+ continue;
669
+ if (scopes.has(step.conversation))
670
+ return true;
671
+ scopes.add(step.conversation);
672
+ }
673
+ return true;
674
+ }
675
+ /**
676
+ * Extend each kept context range with the entries a stored commit appended to its conversation, so a later read
677
+ * needs no Storage scan. Entries are published in ID order.
678
+ */
679
+ #advanceContexts(publication) {
680
+ if (this.#contexts.size === 0)
681
+ return;
682
+ let appended;
683
+ for (const change of publication.changes) {
684
+ if (change.type !== "entry" || !this.#contexts.has(change.value.conversationId))
685
+ continue;
686
+ appended ??= new Map();
687
+ const entries = appended.get(change.value.conversationId);
688
+ if (entries === undefined)
689
+ appended.set(change.value.conversationId, [change.value]);
690
+ else
691
+ entries.push(change.value);
692
+ }
693
+ if (appended === undefined)
694
+ return;
695
+ for (const [conversationId, entries] of appended) {
696
+ const kept = this.#contexts.get(conversationId);
697
+ // An idle range past its retention is dropped here too: entry-only commits do not run `#settleIdle()`, and a
698
+ // host may decline the expiry timer. `expiresAt` was fixed when it went idle, so no settings are read here.
699
+ if (kept.idleSince !== undefined && kept.expiresAt !== undefined && kept.expiresAt <= this.#now()) {
700
+ this.#dropContext(conversationId);
701
+ continue;
702
+ }
703
+ const range = advanceRange(kept.range, entries);
704
+ if (range === undefined)
705
+ this.#dropContext(conversationId);
706
+ else
707
+ kept.range = range;
708
+ }
709
+ }
530
710
  /**
531
711
  * `settings.contextRetentionMs`, or 0 when the host's settings throw. Kept contexts are only a cache, so dropping them
532
712
  * is safe, while a throw here would escape commit listeners, reconciliation, and the expiry timer.
@@ -540,43 +720,94 @@ export class TaskScheduler {
540
720
  return 0;
541
721
  }
542
722
  }
543
- /** Resolve idle waiters, and drop each kept context whose conversation has been idle for the retention period. */
544
- #settleIdle() {
545
- for (const conversationId of this.#idleWaiters.keys()) {
546
- if (this.#idle(conversationId))
547
- this.#idleWaiters.resolve(conversationId);
548
- }
723
+ /**
724
+ * After task changes below the conversations in `scopes` (every conversation when undefined): resolve their idle
725
+ * waiters and the Harness-wide ones, drop the kept contexts whose idle period passed the retention, then start or end
726
+ * the idle periods of the kept contexts in `scopes`. A task change can change the idleness only of the conversations
727
+ * above it, so no check visits every conversation.
728
+ */
729
+ #settleIdle(scopes) {
730
+ const all = scopes === undefined || this.#unknownsChanged;
731
+ this.#unknownsChanged = false;
732
+ if (all)
733
+ this.#fullIdleChecks++;
734
+ this.#resolveIdleWaiters(all ? undefined : scopes);
549
735
  const now = this.#now();
550
736
  const retention = this.#contextRetentionMs();
551
- for (const [conversationId, kept] of this.#contexts) {
552
- if (kept.idleSince !== undefined && now - kept.idleSince >= retention) {
553
- this.#contexts.delete(conversationId);
554
- }
555
- else if (!this.#idle(conversationId)) {
737
+ // Expired first: a context past its retention is dropped even when the same commit makes it busy again.
738
+ this.#expireContexts(now, retention);
739
+ for (const conversationId of all ? [...this.#contexts.keys()] : scopes) {
740
+ const kept = this.#contexts.get(conversationId);
741
+ if (kept === undefined)
742
+ continue;
743
+ if (!this.#idle(conversationId)) {
744
+ if (kept.idleSince === undefined)
745
+ continue;
556
746
  kept.idleSince = undefined;
747
+ this.#idleOrder.delete(conversationId);
557
748
  }
558
- else if (retention > 0) {
559
- kept.idleSince ??= now;
749
+ else if (retention <= 0) {
750
+ this.#dropContext(conversationId);
560
751
  }
561
- else {
562
- this.#contexts.delete(conversationId);
752
+ else if (kept.idleSince === undefined) {
753
+ kept.idleSince = now;
754
+ kept.expiresAt = now + retention;
755
+ this.#markIdle(conversationId, now);
563
756
  }
564
757
  }
565
758
  this.#scheduleExpiry();
566
759
  }
760
+ /** Resolve the idle waiters of the conversations in `scopes` (all when undefined) and the Harness-wide ones. */
761
+ #resolveIdleWaiters(scopes) {
762
+ for (const conversationId of this.#idleWaiters.keys()) {
763
+ if (conversationId !== undefined && scopes !== undefined && !scopes.has(conversationId))
764
+ continue;
765
+ if (this.#idle(conversationId))
766
+ this.#idleWaiters.resolve(conversationId);
767
+ }
768
+ }
567
769
  /**
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.
770
+ * Drop the kept contexts idle for at least `retention`, oldest first, with the retention setting of now; with no
771
+ * retention, every idle one, also those whose idle period a clock that went back makes start after now.
571
772
  */
572
- #scheduleExpiry() {
573
- const retention = this.#contextRetentionMs();
574
- let at;
575
- for (const kept of this.#contexts.values()) {
576
- if (kept.idleSince !== undefined && (at === undefined || kept.idleSince + retention < at)) {
577
- at = kept.idleSince + retention;
578
- }
773
+ #expireContexts(now, retention) {
774
+ for (const [conversationId, idleSince] of this.#idleOrder) {
775
+ if (retention > 0 && now - idleSince < retention)
776
+ return;
777
+ this.#dropContext(conversationId);
778
+ }
779
+ }
780
+ /**
781
+ * Start the idle period of a kept context in the expiry order. An idle period older than the newest one kept, which
782
+ * a clock that went back starts, sorts the order again, and the newest is taken from what is kept then.
783
+ */
784
+ #markIdle(conversationId, idleSince) {
785
+ this.#idleOrder.delete(conversationId);
786
+ this.#idleOrder.set(conversationId, idleSince);
787
+ if (this.#idleOrder.size === 1 || idleSince >= this.#idleNewest) {
788
+ this.#idleNewest = idleSince;
789
+ return;
579
790
  }
791
+ const sorted = [...this.#idleOrder].sort((a, b) => a[1] - b[1]);
792
+ this.#idleOrder.clear();
793
+ for (const [id, since] of sorted)
794
+ this.#idleOrder.set(id, since);
795
+ this.#idleNewest = sorted.at(-1)[1];
796
+ }
797
+ /** Forget a kept context, idle or not, and its place in the expiry order. */
798
+ #dropContext(conversationId) {
799
+ this.#contexts.delete(conversationId);
800
+ this.#idleOrder.delete(conversationId);
801
+ }
802
+ /**
803
+ * Drop the expired contexts when the earliest idle context expires, on a background timer, which never keeps the
804
+ * process alive. Where the host declines it, as the default does in Cloudflare Workers, none is kept: a pending timer
805
+ * could keep a Durable Object from being evicted, and eviction frees the contexts. There, task changes alone check
806
+ * expiry.
807
+ */
808
+ #scheduleExpiry() {
809
+ const oldest = this.#idleOrder.values().next();
810
+ const at = oldest.done === true ? undefined : oldest.value + this.#contextRetentionMs();
580
811
  if (this.#expiry !== undefined && this.#expiry.at === at)
581
812
  return;
582
813
  this.#expiry?.disarm();
@@ -585,14 +816,16 @@ export class TaskScheduler {
585
816
  return;
586
817
  const disarm = this.#timers.background(() => {
587
818
  this.#expiry = undefined;
588
- this.#settleIdle();
819
+ this.#expireContexts(this.#now(), this.#contextRetentionMs());
820
+ this.#scheduleExpiry();
589
821
  }, at - this.#now());
590
822
  if (disarm !== undefined)
591
823
  this.#expiry = { at, disarm };
592
824
  }
593
825
  // ─── Ownership ───────────────────────────────────────────────────────────
594
826
  #scheduleReconcile() {
595
- if (this.#reconcileScheduled || this.#closing)
827
+ // A read-only reconcile could only fail: what it writes is refused, and its own commits fail the Session.
828
+ if (this.#reconcileScheduled || this.#closing || this.#readOnly)
596
829
  return;
597
830
  this.#reconcileScheduled = true;
598
831
  queueMicrotask(() => void this.#reconcile());
@@ -608,54 +841,22 @@ export class TaskScheduler {
608
841
  const work = this.#reconcileWork();
609
842
  if (work)
610
843
  this.#reconcilingWork++;
611
- const cascade = this.#cascadePending;
612
- this.#cascadePending = false;
613
- const checks = [...this.#failFastChecks];
614
- this.#failFastChecks.clear();
615
844
  try {
616
- await this.#session.commitWith(async (tx) => {
845
+ await this.#session.commitInternal(async (tx) => {
617
846
  if (this.#closing)
618
847
  return;
848
+ // A cascade scans the queued inputs, which waits for every unpublished commit: a request made before the scan
849
+ // is covered by it, and none is published while the job holds the line after it.
850
+ const cascade = this.#cascadePending;
619
851
  const queued = await this.#loadScopes(cascade);
620
- // A cascade from an abandoned owner passes its `restart` reason on; any other intent marks, or upgrades a
621
- // `restart` mark, as a request. Collected first, so a request wins over a `restart` mark of the same pass.
622
- const marks = new Map();
623
- const mark = (record, reason) => {
624
- const staged = marks.get(record.id);
625
- if (staged !== undefined && staged.reason === undefined)
626
- return;
627
- if (record.abortRequested && (record.abortReason === undefined || reason !== undefined))
628
- return;
629
- marks.set(record.id, { record, reason });
630
- };
631
- // Every live foreground task below a live owner with cancellation intent, which is its nearest one: a walk down
632
- // from each owner with intent does not enter another, whose own walk covers what is below it. Derived again on
633
- // every pass, as loading edges can reveal a cancelled owner.
634
- for (const id of this.#intent) {
635
- const owner = this.#live.get(id);
636
- // Only an owner abandoned after a restart, and nothing else, passes its reason on.
637
- const restart = owner.abortReason === "restart" && !failedOutcome(owner);
638
- for (const { node, live } of this.#walkDown(id, { enter: (below) => !this.#intent.has(below) })) {
639
- if (live !== undefined && !node.background)
640
- mark(live, restart ? "restart" : undefined);
641
- }
642
- }
643
- for (const id of checks) {
644
- const waiter = this.#live.get(id);
645
- if (waiter?.state.status !== "waiting" || !(await this.#anyFailed(waiter.state.on)))
646
- continue;
647
- // Every other live task: the failed one keeps its own outcome.
648
- for (const member of waiter.state.on) {
649
- const record = this.#live.get(member);
650
- if (record !== undefined && !failedOutcome(record))
651
- mark(record, undefined);
652
- }
653
- }
852
+ if (cascade)
853
+ this.#cascadePending = false;
854
+ const marks = await this.#marks();
654
855
  for (const { record, reason } of marks.values())
655
856
  tx.setTask(withAbortMark(record, reason));
656
- for (const id of queued)
657
- if (this.#belowCancelled({ conversation: id }))
658
- await this.#withdrawInputs(tx, id);
857
+ const withdrawn = await this.#region(() => queued.filter((id) => this.#belowCancelled({ conversation: id })), this.#reloadScopes);
858
+ for (const id of withdrawn)
859
+ await this.#withdrawInputs(tx, id);
659
860
  await this.#finalize(tx);
660
861
  }, this.#context);
661
862
  }
@@ -667,63 +868,169 @@ export class TaskScheduler {
667
868
  if (work)
668
869
  this.#reconcilingWork--;
669
870
  this.#scheduleWake();
670
- this.#settleIdle();
871
+ // Its commit's task changes were settled at their publication; a waiter that asked for this pass checks again, and
872
+ // what its chain loads resolved can make any scope idle.
873
+ if (this.#unknownsChanged) {
874
+ this.#settleIdle(undefined);
875
+ return;
876
+ }
877
+ this.#resolveIdleWaiters(undefined);
878
+ // A pass checks expiry too, for hosts that decline the expiry timer.
879
+ this.#expireContexts(this.#now(), this.#contextRetentionMs());
880
+ this.#scheduleExpiry();
881
+ }
882
+ /**
883
+ * The abort marks committed records imply (spec §5.4, §5.5): every live foreground task below a live owner with
884
+ * cancellation intent, which is its nearest one (a walk down from each owner with intent does not enter another,
885
+ * whose own walk covers what is below it), and every other live task in the `on` of a `failFast` waiter one of whose
886
+ * tasks failed. A cascade from an abandoned owner passes its `restart` reason on; any other intent marks, or upgrades
887
+ * a `restart` mark, as a request. Collected first, so a request wins over a `restart` mark of the same pass. Derived
888
+ * again on every pass, as loading edges can reveal a cancelled owner. Takes the `failFast` checks once its decision
889
+ * stands.
890
+ */
891
+ async #marks() {
892
+ // Terminal tasks in a checked `on`, read from Storage: each one's outcome is final.
893
+ const ended = new Map();
894
+ for (;;) {
895
+ const plan = await this.#region(() => this.#planMarks(ended), this.#reloadScopes);
896
+ if ("missing" in plan) {
897
+ for (const id of plan.missing)
898
+ ended.set(id, (await this.#session.lineStorage.task(id, this.#context)) ?? null);
899
+ continue;
900
+ }
901
+ for (const id of plan.checked)
902
+ this.#failFastChecks.delete(id);
903
+ return plan.marks;
904
+ }
905
+ }
906
+ /** `#marks()`' decision region; the `failFast` members it needs from Storage first, when `ended` lacks them. */
907
+ #planMarks(ended) {
908
+ const marks = new Map();
909
+ // A mark rewrites the whole record, so the record it copies is read as a task record, keyed: a commit of the task
910
+ // that is not published yet makes the region wait and run again, rather than stage a mark over a stale checkpoint.
911
+ const mark = (found, reason) => {
912
+ const record = this.#liveRecord(found.id);
913
+ const staged = marks.get(record.id);
914
+ if (staged !== undefined && staged.reason === undefined)
915
+ return;
916
+ if (record.abortRequested && (record.abortReason === undefined || reason !== undefined))
917
+ return;
918
+ marks.set(record.id, { record, reason });
919
+ };
920
+ this.#record(INTENT_KEY);
921
+ for (const id of this.#intent) {
922
+ const owner = this.#liveRecord(id);
923
+ // Only an owner abandoned after a restart, and nothing else, passes its reason on.
924
+ const restart = owner.abortReason === "restart" && !failedOutcome(owner);
925
+ for (const { node, live } of this.#walkDown(id, { enter: (below) => !this.#intent.has(below) })) {
926
+ if (live !== undefined && !node.background)
927
+ mark(live, restart ? "restart" : undefined);
928
+ }
929
+ }
930
+ const checked = [...this.#failFastChecks];
931
+ const missing = [];
932
+ for (const id of checked) {
933
+ const waiter = this.#liveRecord(id);
934
+ if (waiter?.state.status !== "waiting")
935
+ continue;
936
+ let failed = false;
937
+ for (const member of waiter.state.on) {
938
+ const record = this.#liveRecord(member) ?? ended.get(member);
939
+ if (record === undefined)
940
+ missing.push(member);
941
+ else if (record !== null && failedOutcome(record))
942
+ failed = true;
943
+ }
944
+ if (!failed)
945
+ continue;
946
+ // Every other live task: the failed one keeps its own outcome.
947
+ for (const member of waiter.state.on) {
948
+ const record = this.#liveRecord(member);
949
+ if (record !== undefined && !failedOutcome(record))
950
+ mark(record, undefined);
951
+ }
952
+ }
953
+ return missing.length > 0 ? { missing } : { marks, checked };
671
954
  }
672
955
  /** Whether a reconcile pass would have work: a cascade to derive, held outcomes to check, or `failFast` waits. */
673
956
  #reconcileWork() {
674
957
  return this.#cascadePending || this.#finalizeChecks.size > 0 || this.#failFastChecks.size > 0;
675
958
  }
676
- /** Whether any of `ids` holds or ended with an outcome other than `completed`. */
677
- async #anyFailed(ids) {
678
- for (const id of ids) {
679
- const record = this.#live.get(id) ?? (await this.#storage.task(id, this.#context));
680
- if (record !== undefined && failedOutcome(record))
681
- return true;
682
- }
683
- return false;
684
- }
685
959
  /**
686
960
  * Write the terminal record of every `completing` task without live ordinary owned work. Finalizing one can free its
687
961
  * owner, so this repeats over the commit's candidates until nothing changes. A held scheduler outcome gets its
688
- * Harness cleanup here.
962
+ * Harness cleanup here. In rounds, one decision region each: the holders the tasks of one round free are checked once,
963
+ * in the next, so a holder of many tasks that end together is walked once, not once per task. The first round takes
964
+ * the `completing` tasks to check once its decision stands.
689
965
  */
690
966
  async #finalize(tx) {
691
- // A worklist: the tasks that started holding, and the holders of work that ended. Finalizing one can free the
692
- // nearest live task above it, which goes on the list. Tasks finalized here have no live work below them, so walks
693
- // down skip them.
694
- // In rounds: the holders the tasks of one round free are checked once, in the next, so a holder of many tasks that
695
- // end together is walked once, not once per task.
696
- let round = [...this.#finalizeChecks];
697
- this.#finalizeChecks.clear();
698
967
  const done = new Set();
968
+ // The commit's candidates, kept in step with the terminal records each round stages.
699
969
  const overlay = overlayOf(tx);
700
- while (round.length > 0) {
701
- const next = new Set();
702
- for (const id of round) {
703
- const record = overlay.tasks.get(id) ?? this.#live.get(id);
704
- if (record?.state.status !== "completing" || done.has(id))
705
- continue;
706
- if (this.#hasOwnedLive(id, overlay, done))
707
- continue;
708
- const outcome = record.state.outcome;
709
- const terminal = withState(record, { status: "terminal", outcome });
970
+ let round;
971
+ for (;;) {
972
+ const ids = round;
973
+ const plan = await this.#region(() => this.#planRound(ids ?? [...this.#finalizeChecks], overlay, done), this.#reloadScopes);
974
+ if (ids === undefined)
975
+ this.#finalizeChecks.clear();
976
+ for (const { record, terminal } of plan.finalized) {
710
977
  tx.setTask(terminal);
711
- overlay.tasks.set(id, terminal);
712
- done.add(id);
978
+ overlay.tasks.set(record.id, terminal);
979
+ done.add(record.id);
980
+ const outcome = record.state.outcome;
713
981
  // REMINDER: only the scheduler writes `faulted` and `orphaned` (spec §5.4); their cleanup waits for this commit.
714
982
  if (outcome.status === "faulted" || outcome.status === "orphaned") {
715
983
  await this.#settleOutcome(tx, record, outcome);
716
984
  }
985
+ }
986
+ if (plan.next.length === 0)
987
+ return;
988
+ round = plan.next;
989
+ }
990
+ }
991
+ /**
992
+ * One finalize round's decision: which of `ids` are `completing` without live ordinary owned work below them, in the
993
+ * commit's candidates with the round's own finalizations applied in order, and the holders above them to check next.
994
+ * Tasks finalized have no live work below them, so walks down skip them. It applies the round's terminal records to
995
+ * `overlay` while it decides and takes them back before it returns, so a run again starts from the same candidates.
996
+ */
997
+ #planRound(ids, overlay, finalized) {
998
+ const tasks = overlay.tasks;
999
+ // The tasks finalized so far, earlier rounds' and this one's, without copying the former.
1000
+ const round = new Set();
1001
+ const done = { has: (id) => round.has(id) || finalized.has(id) };
1002
+ const ended = [];
1003
+ const next = new Set();
1004
+ try {
1005
+ for (const id of ids) {
1006
+ const staged = tasks.get(id);
1007
+ const record = staged ?? this.#liveRecord(id);
1008
+ if (record?.state.status !== "completing" || done.has(id))
1009
+ continue;
1010
+ if (this.#hasOwnedLive(id, overlay, done))
1011
+ continue;
1012
+ const terminal = withState(record, { status: "terminal", outcome: record.state.outcome });
1013
+ tasks.set(id, terminal);
1014
+ round.add(id);
1015
+ ended.push({ record, terminal, staged: staged !== undefined });
717
1016
  const above = this.#nearestLiveAbove(record, overlay);
718
1017
  if (above?.state.status === "completing")
719
1018
  next.add(above.id);
720
1019
  }
721
- round = [...next];
722
1020
  }
1021
+ finally {
1022
+ for (const { record, staged } of ended) {
1023
+ if (staged)
1024
+ tasks.set(record.id, record);
1025
+ else
1026
+ tasks.delete(record.id);
1027
+ }
1028
+ }
1029
+ return { finalized: ended, next: [...next] };
723
1030
  }
724
1031
  /**
725
1032
  * Load the owner chains of the tasks in `#unloaded` and, with `queued`, of every conversation with queued inputs,
726
- * on the Session line; returns the latter. Reads committed Storage directly, so it may run inside a commit callback.
1033
+ * on the Session line; returns the latter. Reads through the line's Storage, so it may run inside a commit callback.
727
1034
  */
728
1035
  async #loadScopes(queued) {
729
1036
  for (const id of [...this.#unloaded]) {
@@ -733,7 +1040,8 @@ export class TaskScheduler {
733
1040
  }
734
1041
  if (!queued)
735
1042
  return [];
736
- const submissions = await scanAll((cursor) => this.#storage.scanSubmissions({ status: "queued" }, SCAN_PAGE_SIZE, cursor, this.#context));
1043
+ // A scan, which cannot be keyed: it waits for every unpublished commit.
1044
+ const submissions = await scanAll((cursor) => this.#session.lineStorage.scanSubmissions({ status: "queued" }, SCAN_PAGE_SIZE, cursor, this.#context));
737
1045
  const inputs = submissions.filter((submission) => submission.type === "input");
738
1046
  const conversations = [...new Set(inputs.map((submission) => submission.conversationId))];
739
1047
  for (const id of conversations)
@@ -743,48 +1051,76 @@ export class TaskScheduler {
743
1051
  /**
744
1052
  * Load the owner edges and task nodes from `start` up to its ownerless root. A known task outside `#unloaded` ends the
745
1053
  * walk, as its chain is loaded; the `#unloaded` tasks passed have a loaded chain afterwards. Loading a node can reveal
746
- * a cancelled owner, so it makes the next reconcile derive cascade marks.
1054
+ * a cancelled owner, so it makes the next reconcile derive cascade marks. The nodes it installs stay until the job's
1055
+ * commit is published (`#pin()`).
747
1056
  */
748
1057
  async #loadChain(start, overlay) {
749
1058
  const passed = [];
1059
+ let installed = false;
750
1060
  let at = start;
751
- while (at !== undefined) {
752
- if ("task" in at) {
753
- const id = at.task;
754
- let node = this.#live.get(id) ?? this.#settled.get(id);
755
- // Created in this commit: the load from its own record covers what is above it.
756
- if (node === undefined && overlay?.tasks.has(id) === true)
757
- break;
758
- if (node !== undefined && !this.#unloaded.has(id))
759
- break;
760
- if (node === undefined) {
761
- const record = await this.#storage.task(id, this.#context);
762
- if (record === undefined)
763
- return;
764
- node = nodeOf(record);
765
- if (record.state.status === "terminal")
766
- this.#settle(id, node);
767
- // A loaded node can reveal a cancelling owner: the next reconcile derives marks again.
768
- this.#scheduleReconcile();
1061
+ try {
1062
+ while (at !== undefined) {
1063
+ if ("task" in at) {
1064
+ const id = at.task;
1065
+ let node = this.#live.get(id) ?? this.#settled.get(id);
1066
+ // Created in this commit: the load from its own record covers what is above it.
1067
+ if (node === undefined && overlay?.tasks.has(id) === true)
1068
+ break;
1069
+ if (node !== undefined && !this.#unloaded.has(id))
1070
+ break;
1071
+ if (node === undefined) {
1072
+ // Through the line's Storage: a task whose creation or end is not published yet waits for it.
1073
+ const record = await this.#session.lineStorage.task(id, this.#context);
1074
+ if (record === undefined)
1075
+ return;
1076
+ node = nodeOf(record);
1077
+ if (record.state.status === "terminal") {
1078
+ this.#settle(id, node);
1079
+ this.#pin(id);
1080
+ installed = true;
1081
+ }
1082
+ // A loaded node can reveal a cancelling owner: the next reconcile derives marks again.
1083
+ this.#scheduleReconcile();
1084
+ }
1085
+ passed.push(id);
1086
+ at = parentOf(node);
769
1087
  }
770
- passed.push(id);
771
- at = parentOf(node);
772
- }
773
- else {
774
- let edge = this.#edge(at.conversation, overlay);
775
- if (edge === undefined) {
776
- const record = await this.#storage.conversation(at.conversation, this.#context);
777
- edge = record?.owner?.taskId ?? null;
778
- this.#setEdge(at.conversation, edge);
779
- // A loaded node can reveal a cancelling owner: the next reconcile derives marks again.
780
- this.#scheduleReconcile();
1088
+ else {
1089
+ let edge = this.#edge(at.conversation, overlay);
1090
+ if (edge === undefined) {
1091
+ // Through the line's Storage: a conversation whose creation is not published yet waits for it, so a missing
1092
+ // record is never mistaken for an ownerless one.
1093
+ const record = await this.#session.lineStorage.conversation(at.conversation, this.#context);
1094
+ edge = record?.owner?.taskId ?? null;
1095
+ this.#setEdge(at.conversation, edge);
1096
+ this.#pin(conversationNode(at.conversation));
1097
+ installed = true;
1098
+ // A loaded node can reveal a cancelling owner: the next reconcile derives marks again.
1099
+ this.#scheduleReconcile();
1100
+ }
1101
+ at = edge === null ? undefined : { task: edge };
781
1102
  }
782
- at = edge === null ? undefined : { task: edge };
1103
+ }
1104
+ }
1105
+ finally {
1106
+ // `#idle()` counts a live task whose chain is not loaded as inside every scope it may be in: what this load
1107
+ // resolved, also when it stopped early, can make such a scope idle without a task change below it.
1108
+ if (passed.some((id) => this.#live.has(id)) || (installed && this.#liveUnloaded())) {
1109
+ this.#unknownsChanged = true;
1110
+ // A waiter such a task held is checked again at the pass's end, also when this load was not a reconcile's.
1111
+ this.#scheduleReconcile();
783
1112
  }
784
1113
  }
785
1114
  for (const id of passed)
786
1115
  this.#unloaded.delete(id);
787
1116
  }
1117
+ /** Whether a live task's chain is not loaded yet: one `#idle()` counts as inside every scope it may be in. */
1118
+ #liveUnloaded() {
1119
+ for (const id of this.#unloaded)
1120
+ if (this.#live.has(id))
1121
+ return true;
1122
+ return false;
1123
+ }
788
1124
  #setEdge(conversationId, owner) {
789
1125
  this.#edges.set(conversationId, owner);
790
1126
  // The conversation now hangs from its owner, or is a root; with nothing below it, it waits for the sweep.
@@ -831,7 +1167,8 @@ export class TaskScheduler {
831
1167
  */
832
1168
  #sweep() {
833
1169
  for (const node of this.#dropQueue) {
834
- if (this.#below.has(node))
1170
+ // Queued again once its pins go.
1171
+ if (this.#below.has(node) || this.#pins.has(node))
835
1172
  continue;
836
1173
  if (typeof node === "string") {
837
1174
  this.#edges.delete(conversationOf(node));
@@ -844,6 +1181,69 @@ export class TaskScheduler {
844
1181
  }
845
1182
  this.#dropQueue.clear();
846
1183
  }
1184
+ /**
1185
+ * Run `decide`, a decision region (group commit §6): synchronous, reading scheduler memory and recording the keys it
1186
+ * reads, staging nothing and changing no scheduler state. When a key it read has an unpublished writer, its result,
1187
+ * or its error, is dropped: once that writer is published, `refresh` reloads what the region needs and it runs
1188
+ * again. A waiting job holds the line, so unpublished writes only shrink, and the runs converge. What a region read
1189
+ * that had no unpublished writer gets none for the rest of the job, so its result stands while the job applies it.
1190
+ */
1191
+ async #region(decide, refresh) {
1192
+ for (;;) {
1193
+ const keys = new Set();
1194
+ this.#reading = keys;
1195
+ let result;
1196
+ try {
1197
+ result = { value: decide() };
1198
+ }
1199
+ catch (error) {
1200
+ result = { error };
1201
+ }
1202
+ finally {
1203
+ this.#reading = undefined;
1204
+ }
1205
+ const gates = [];
1206
+ for (const key of keys) {
1207
+ const gate = this.#session.touch(key);
1208
+ if (gate !== undefined)
1209
+ gates.push(gate);
1210
+ }
1211
+ if (gates.length === 0) {
1212
+ if ("error" in result)
1213
+ throw result.error;
1214
+ return result.value;
1215
+ }
1216
+ await Promise.all(gates);
1217
+ await refresh?.();
1218
+ }
1219
+ }
1220
+ /** Load the chains a region walks, again after it waited: what was published meanwhile may list new work. */
1221
+ #reloadScopes = () => this.#loadScopes(false);
1222
+ /** In a decision region, note that it reads `key`; anywhere else, nothing. */
1223
+ #record(key) {
1224
+ this.#reading?.add(key);
1225
+ }
1226
+ /** A live task's committed record, read by a decision region. */
1227
+ #liveRecord(id) {
1228
+ this.#record(taskKey(id));
1229
+ return this.#live.get(id);
1230
+ }
1231
+ /**
1232
+ * Keep `node`, which a chain load of the job on the line installed, until that job's commit is published or fails:
1233
+ * work it creates below the node needs it, and the sweep would drop it as unneeded before then.
1234
+ */
1235
+ #pin(node) {
1236
+ this.#pins.set(node, (this.#pins.get(node) ?? 0) + 1);
1237
+ this.#session.onJobEnd(() => {
1238
+ const count = this.#pins.get(node) - 1;
1239
+ if (count > 0) {
1240
+ this.#pins.set(node, count);
1241
+ return;
1242
+ }
1243
+ this.#pins.delete(node);
1244
+ this.#dropIfUnneeded(node);
1245
+ });
1246
+ }
847
1247
  /**
848
1248
  * Bring the scheduler's sets in line with a task's committed record. A task that ended may free the nearest live task
849
1249
  * above it: one holding its outcome finalizes, an abort-marked one runs its abort handler; its waiters may run.
@@ -855,11 +1255,11 @@ export class TaskScheduler {
855
1255
  invocation.record = record;
856
1256
  if (record.state.status === "terminal") {
857
1257
  if (previous !== undefined && !this.#closing)
858
- this.#activeMetric.add(-1, { task: record.kind });
1258
+ this.#countActive(-1, record.kind);
859
1259
  this.#resumed.delete(id);
860
1260
  }
861
1261
  else if (previous === undefined && !this.#closing) {
862
- this.#activeMetric.add(1, { task: record.kind });
1262
+ this.#countActive(1, record.kind);
863
1263
  }
864
1264
  if (previous?.state.status === "waiting")
865
1265
  for (const on of previous.state.on)
@@ -967,11 +1367,12 @@ export class TaskScheduler {
967
1367
  * at or below a background task holds nothing above it.
968
1368
  */
969
1369
  #nearestLiveAbove(node, overlay) {
1370
+ this.#record(INTENT_KEY);
970
1371
  for (let current = node; !current.background;) {
971
1372
  const owner = current.owner ?? this.#edge(current.conversationId, overlay);
972
1373
  if (owner === undefined || owner === null)
973
1374
  return undefined;
974
- const record = overlay?.tasks.get(owner) ?? this.#live.get(owner);
1375
+ const record = overlay?.tasks.get(owner) ?? this.#liveRecord(owner);
975
1376
  if (record !== undefined && record.state.status !== "terminal")
976
1377
  return record;
977
1378
  const above = record ?? this.#settled.get(owner);
@@ -991,7 +1392,10 @@ export class TaskScheduler {
991
1392
  }
992
1393
  /** Owner task of a conversation, `null` when ownerless, `undefined` while not loaded. */
993
1394
  #edge(id, overlay) {
994
- return overlay?.edges.has(id) === true ? overlay.edges.get(id) : this.#edges.get(id);
1395
+ if (overlay?.edges.has(id) === true)
1396
+ return overlay.edges.get(id);
1397
+ this.#record(conversationKey(id));
1398
+ return this.#edges.get(id);
995
1399
  }
996
1400
  #node(id, overlay) {
997
1401
  return overlay?.tasks.get(id) ?? this.#live.get(id) ?? this.#settled.get(id);
@@ -1030,6 +1434,7 @@ export class TaskScheduler {
1030
1434
  const links = overlay === undefined ? undefined : this.#links(overlay);
1031
1435
  const pending = [start];
1032
1436
  for (let at = pending.pop(); at !== undefined; at = pending.pop()) {
1437
+ this.#record(ownedKey(at));
1033
1438
  for (const children of [this.#below.get(at), links?.get(at)]) {
1034
1439
  for (const child of children ?? []) {
1035
1440
  if (typeof child === "string") {
@@ -1117,6 +1522,7 @@ export class TaskScheduler {
1117
1522
  * edge is not loaded.
1118
1523
  */
1119
1524
  #inScope(start, scope, crossBackground = false) {
1525
+ this.#record(INTENT_KEY);
1120
1526
  for (const step of this.#above(start)) {
1121
1527
  if ("unknown" in step)
1122
1528
  return undefined;
@@ -1139,6 +1545,7 @@ export class TaskScheduler {
1139
1545
  }
1140
1546
  /** The nearest live owner above `start` whose cancellation intent reaches it, if any; see `#belowCancelled`. */
1141
1547
  #cancellingOwner(start) {
1548
+ this.#record(INTENT_KEY);
1142
1549
  // Nothing cancels, the common case: no walk.
1143
1550
  if (this.#intent.size === 0)
1144
1551
  return undefined;
@@ -1155,6 +1562,18 @@ export class TaskScheduler {
1155
1562
  }
1156
1563
  return undefined;
1157
1564
  }
1565
+ /**
1566
+ * Count a task in or out of `pi.durable.tasks.active`. Commit observers count at a commit's publication, which must not
1567
+ * throw, so a throwing adapter is reported.
1568
+ */
1569
+ #countActive(delta, kind) {
1570
+ try {
1571
+ this.#activeMetric.add(delta, { task: kind });
1572
+ }
1573
+ catch (error) {
1574
+ this.#report(error, TELEMETRY);
1575
+ }
1576
+ }
1158
1577
  /** Fail the Session, which reports it, for a throw in one of the scheduler's own commits; ignored once closing. */
1159
1578
  #failSession(error, context) {
1160
1579
  if (!this.#closing)
@@ -1163,7 +1582,7 @@ export class TaskScheduler {
1163
1582
  /** Close listener: runs synchronously once admission is sealed, before `join()`, or when the Session fails. */
1164
1583
  #seal() {
1165
1584
  for (const record of this.#live.values())
1166
- this.#activeMetric.add(-1, { task: record.kind });
1585
+ this.#countActive(-1, record.kind);
1167
1586
  this.#resumed.clear();
1168
1587
  this.#closing = true;
1169
1588
  try {
@@ -1176,14 +1595,18 @@ export class TaskScheduler {
1176
1595
  this.#taskWaiters.rejectAll(error);
1177
1596
  this.#idleWaiters.rejectAll(error);
1178
1597
  this.#contexts.clear();
1598
+ this.#idleOrder.clear();
1179
1599
  this.#expiry?.disarm();
1180
1600
  this.#expiry = undefined;
1181
1601
  this.#timer?.disarm();
1182
1602
  this.#timer = undefined;
1183
1603
  }
1184
- /** Recompute the wake in a microtask, off the Session line: arm the built-in timer, or tell `onWake` of a change. */
1604
+ /**
1605
+ * Recompute the wake in a microtask the line never waits for, with no order relative to host commit listeners (one
1606
+ * already queued serves later changes too): arm the built-in timer, or tell `onWake` of a change.
1607
+ */
1185
1608
  #scheduleWake() {
1186
- if (this.#wakeScheduled || this.#closing)
1609
+ if (this.#wakeScheduled || this.#closing || this.#readOnly)
1187
1610
  return;
1188
1611
  this.#wakeScheduled = true;
1189
1612
  queueMicrotask(() => {
@@ -1235,10 +1658,6 @@ export class TaskScheduler {
1235
1658
  this.#runnable.add(id);
1236
1659
  }
1237
1660
  }
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
1661
  /**
1243
1662
  * The report info of a task's failure, with the extension that installed its definition: the code that failed is
1244
1663
  * that extension's. Built-in tasks have none.
@@ -1259,22 +1678,6 @@ export class TaskScheduler {
1259
1678
  }
1260
1679
  return { task: reportTask(task), ...optionalExtension(extension) };
1261
1680
  }
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
1681
  /** `settings.restartLimit`, or the default when the host's settings throw. */
1279
1682
  #restartLimit() {
1280
1683
  try {
@@ -1291,7 +1694,7 @@ export class TaskScheduler {
1291
1694
  listener();
1292
1695
  }
1293
1696
  catch (error) {
1294
- this.#report(error, { type: "listener_error" });
1697
+ this.#report(error, { kind: "listener_error" });
1295
1698
  }
1296
1699
  }
1297
1700
  }
@@ -1337,62 +1740,45 @@ export class TaskScheduler {
1337
1740
  const faulted = [];
1338
1741
  let abandoned = [];
1339
1742
  try {
1340
- await this.#session.commitWith(async (tx) => {
1743
+ await this.#session.commitInternal(async (tx) => {
1341
1744
  if (!this.#enabled || this.#closing)
1342
1745
  return;
1343
- for (const id of this.#abandoned) {
1344
- const record = this.#live.get(id);
1345
- if (record !== undefined && !record.abortRequested)
1346
- tx.setTask(withAbortMark(record, "restart"));
1347
- }
1348
1746
  if (this.#abandoned.size > 0) {
1349
- abandoned = [...this.#abandoned];
1747
+ const ids = [...this.#abandoned];
1748
+ const unmarked = await this.#region(() => ids
1749
+ .map((id) => this.#liveRecord(id))
1750
+ .filter((record) => record !== undefined && !record.abortRequested));
1751
+ for (const record of unmarked)
1752
+ tx.setTask(withAbortMark(record, "restart"));
1753
+ abandoned = ids;
1350
1754
  return;
1351
1755
  }
1352
1756
  await this.#loadScopes(false);
1353
1757
  // Every pass picks up sleeps whose time has come: a pass started by any change may be the first to run after
1354
1758
  // a deadline, and the wake it schedules no longer keeps a timer for a due sleep.
1355
1759
  this.#wakeDue();
1356
- this.#unparkRaised();
1760
+ const plan = await this.#region(() => this.#planReservation(), this.#reloadScopes);
1761
+ for (const id of plan.dropped)
1762
+ this.#runnable.delete(id);
1763
+ // Work below an owner with cancellation intent waits for its cascade mark instead of running a phase; schedule
1764
+ // the cascade, which may not have run yet.
1765
+ if (plan.cascade) {
1766
+ this.#cascadePending = true;
1767
+ this.#scheduleReconcile();
1768
+ }
1357
1769
  // Taken once per pass, and only when some task is a candidate.
1358
1770
  let snapshot;
1359
- for (const id of [...this.#runnable]) {
1360
- const record = this.#live.get(id);
1361
- if (record === undefined || record.state.status === "completing" || this.#invocations.has(id)) {
1362
- this.#runnable.delete(id);
1363
- continue;
1364
- }
1365
- // It returns once what it waits for ends, its record changes, or the work below it ends.
1366
- if (this.#waitingOn(record, true).length > 0) {
1367
- this.#runnable.delete(id);
1368
- continue;
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
- }
1384
- const runnable = record;
1771
+ for (const record of plan.ready) {
1772
+ const id = record.id;
1385
1773
  const mode = record.abortRequested ? "abort" : "run";
1386
- // Work below an owner with cancellation intent waits for its cascade mark instead of running a phase; schedule
1387
- // the cascade, which may not have run yet.
1388
- if (mode === "run" && !record.background && this.#belowCancelled(parentOf(record))) {
1389
- this.#cascadePending = true;
1390
- this.#scheduleReconcile();
1391
- continue;
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`;
1774
+ // Work cut off `restartLimit` times in a row would run forever: an abort handler, or a phase of a kind that
1775
+ // does not end itself at the limit, ends the task `faulted` instead.
1776
+ // The settings are read only for a task that was cut off at all.
1777
+ const cutOff = record.restarts !== undefined && record.restarts >= this.#restartLimit();
1778
+ if (cutOff && (mode === "abort" || !this.#limitsRestarts(record.kind))) {
1779
+ const message = mode === "abort"
1780
+ ? `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times`
1781
+ : `Task ${id} (${record.kind}) was cut off ${record.restarts} times without progress`;
1396
1782
  await this.#terminate(tx, record, {
1397
1783
  status: "faulted",
1398
1784
  error: { message, detail: { reason: "restart_limit" } },
@@ -1401,7 +1787,7 @@ export class TaskScheduler {
1401
1787
  continue;
1402
1788
  }
1403
1789
  snapshot ??= this.#registry.snapshot();
1404
- const resolution = this.#resolve(runnable, snapshot);
1790
+ const resolution = this.#resolve(record, snapshot);
1405
1791
  if (resolution.kind === "blocked") {
1406
1792
  // An abandoned task waits for its definition, so its abort handler can clean up.
1407
1793
  if (mode === "abort" && record.abortReason === undefined) {
@@ -1409,7 +1795,7 @@ export class TaskScheduler {
1409
1795
  }
1410
1796
  continue;
1411
1797
  }
1412
- if (resolution.record !== runnable || record.state.status !== "running") {
1798
+ if (resolution.record !== record || record.state.status !== "running") {
1413
1799
  tx.setTask(withState(resolution.record, {
1414
1800
  status: "running",
1415
1801
  checkpoint: resolution.record.state.checkpoint,
@@ -1418,7 +1804,7 @@ export class TaskScheduler {
1418
1804
  // Registered on the line, so marks and later reservations see it and close joins it; `#end` makes it a
1419
1805
  // candidate again.
1420
1806
  this.#runnable.delete(id);
1421
- const invocation = this.#createInvocation(runnable, mode, installerOf(snapshot, resolution.task));
1807
+ const invocation = this.#createInvocation(record, mode, installerOf(snapshot, resolution.task));
1422
1808
  reservations.push({ invocation, task: resolution.task, snapshot });
1423
1809
  }
1424
1810
  }, this.#context);
@@ -1433,7 +1819,7 @@ export class TaskScheduler {
1433
1819
  }
1434
1820
  for (const { record, message } of faulted) {
1435
1821
  this.#report(new Error(message), {
1436
- type: "task_faulted",
1822
+ kind: "task_faulted",
1437
1823
  ...this.#aboutTask(recordTask(record)),
1438
1824
  reason: "restart_limit",
1439
1825
  });
@@ -1445,6 +1831,39 @@ export class TaskScheduler {
1445
1831
  this.#dirty = true;
1446
1832
  return reservations;
1447
1833
  }
1834
+ /**
1835
+ * A reservation pass's decision region: the candidates it drops (gone, holding, waiting, asleep, or with an
1836
+ * invocation), whether one waits for a cascade, and the runnable records to resolve. Iterating the candidates is not
1837
+ * keyed: a pass that misses a task a pending commit makes runnable only delays it, as does skipping one with an
1838
+ * invocation without reading its record, or a waiter's check whether what it waits on is still live.
1839
+ */
1840
+ #planReservation() {
1841
+ const dropped = [];
1842
+ const ready = [];
1843
+ let cascade = false;
1844
+ for (const id of this.#runnable) {
1845
+ if (this.#invocations.has(id)) {
1846
+ dropped.push(id);
1847
+ continue;
1848
+ }
1849
+ const record = this.#liveRecord(id);
1850
+ // It returns once what it waits for ends, its record changes, or the work below it ends; a sleep at the first pass
1851
+ // after its time comes, which the timer, `resume()`, or any change starts.
1852
+ if (record === undefined ||
1853
+ record.state.status === "completing" ||
1854
+ this.#waitingOn(record, true).length > 0 ||
1855
+ this.#notDue(record)) {
1856
+ dropped.push(id);
1857
+ continue;
1858
+ }
1859
+ if (!record.abortRequested && !record.background && this.#belowCancelled(parentOf(record))) {
1860
+ cascade = true;
1861
+ continue;
1862
+ }
1863
+ ready.push(record);
1864
+ }
1865
+ return { dropped, cascade, ready };
1866
+ }
1448
1867
  /**
1449
1868
  * Live tasks a task waits for before its next invocation: its live ordinary owned work when abort-marked, since
1450
1869
  * abort runs bottom-up, otherwise the live part of the `on` of a wait.
@@ -1481,7 +1900,7 @@ export class TaskScheduler {
1481
1900
  queueMicrotask(() => this.#notifyBlocked());
1482
1901
  this.#scheduleWake();
1483
1902
  const extension = installerOf(snapshot, fit.task);
1484
- this.#report(error, { type: "migration_failed", ...this.#aboutTask({ ...recordTask(record), extension }) });
1903
+ this.#report(error, { kind: "migration_failed", ...this.#aboutTask({ ...recordTask(record), extension }) });
1485
1904
  return { kind: "blocked", reason: "migration_failed" };
1486
1905
  }
1487
1906
  }
@@ -1511,6 +1930,36 @@ export class TaskScheduler {
1511
1930
  roots: this.#roots.size,
1512
1931
  unloaded: this.#unloaded.size,
1513
1932
  dropQueue: this.#dropQueue.size,
1933
+ pins: this.#pins.size,
1934
+ };
1935
+ }
1936
+ /**
1937
+ * The kept contexts' idle state, for tests (a diagnostic, computed on demand): how many are kept and how many are
1938
+ * idle, how many disagree with a full recompute (an idle period without `#idle()`, or `#idle()` without one; an
1939
+ * expiry order that differs from the idle periods, or is not sorted), and how many idle checks covered every scope.
1940
+ */
1941
+ get idleState() {
1942
+ let mismatches = 0;
1943
+ let idle = 0;
1944
+ for (const [conversationId, kept] of this.#contexts) {
1945
+ if (kept.idleSince !== undefined)
1946
+ idle++;
1947
+ if ((kept.idleSince !== undefined) !== this.#idle(conversationId))
1948
+ mismatches++;
1949
+ if (this.#idleOrder.get(conversationId) !== kept.idleSince)
1950
+ mismatches++;
1951
+ }
1952
+ let previous = Number.NEGATIVE_INFINITY;
1953
+ for (const [conversationId, idleSince] of this.#idleOrder) {
1954
+ if (idleSince < previous || this.#contexts.get(conversationId)?.idleSince !== idleSince)
1955
+ mismatches++;
1956
+ previous = idleSince;
1957
+ }
1958
+ return {
1959
+ contexts: this.#contexts.size,
1960
+ idleContexts: idle,
1961
+ idleMismatches: mismatches,
1962
+ fullIdleChecks: this.#fullIdleChecks,
1514
1963
  };
1515
1964
  }
1516
1965
  /**
@@ -1519,6 +1968,7 @@ export class TaskScheduler {
1519
1968
  * cannot exist, shows as failed.
1520
1969
  */
1521
1970
  async inspect(snapshot) {
1971
+ await this.#published();
1522
1972
  await this.#loadScopes(false);
1523
1973
  const tasks = [];
1524
1974
  for (const record of this.#live.values())
@@ -1536,15 +1986,13 @@ export class TaskScheduler {
1536
1986
  return { kind: "waiting", on };
1537
1987
  if (this.#notDue(record))
1538
1988
  return { kind: "sleeping", until: record.state.until };
1539
- if (this.#parked(record))
1540
- return { kind: "blocked", reason: "restart_limit" };
1541
1989
  const fit = this.#fit(record, snapshot.task(record.kind));
1542
1990
  if ("reason" in fit)
1543
1991
  return { kind: "blocked", ...fit };
1544
1992
  if (fit.migrates && fit.task.definition.migrate === undefined) {
1545
1993
  return { kind: "blocked", reason: "migration_failed", error: missingMigration(record, erased(fit.task)) };
1546
1994
  }
1547
- return { kind: "ready", migrates: fit.migrates };
1995
+ return { kind: "pending", migrates: fit.migrates };
1548
1996
  }
1549
1997
  #createInvocation(record, mode, extension) {
1550
1998
  const controller = new AbortController();
@@ -1578,7 +2026,7 @@ export class TaskScheduler {
1578
2026
  await this.#runAbort(reservation);
1579
2027
  }
1580
2028
  catch (error) {
1581
- this.#report(error, { type: "task_error", ...this.#aboutTask(invocation) }, invocation.context);
2029
+ this.#report(error, { kind: "task_error", ...this.#aboutTask(invocation) }, invocation.context);
1582
2030
  }
1583
2031
  finally {
1584
2032
  this.#end(invocation);
@@ -1596,7 +2044,8 @@ export class TaskScheduler {
1596
2044
  const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined };
1597
2045
  const phase = { snapshot: () => state.snapshot, task: () => state.task, agent: undefined };
1598
2046
  const runtime = this.#runtime(invocation, phase);
1599
- let current = await this.#step(invocation, (tx, next) => this.#decide(tx, next, undefined, state, invocation.extension, invocation.context));
2047
+ const progress = (tx, current) => this.#progress(tx, current, state, invocation.extension, invocation.context);
2048
+ let current = await this.#step(invocation, (task) => this.#rules(task, undefined), progress);
1600
2049
  while (current !== undefined && !this.#closing) {
1601
2050
  const task = current;
1602
2051
  current = await this.#phase(invocation, task, async () => {
@@ -1611,7 +2060,7 @@ export class TaskScheduler {
1611
2060
  catch (error) {
1612
2061
  previous = { checkpoint, failure: { error } };
1613
2062
  }
1614
- return this.#step(invocation, (tx, next) => this.#decide(tx, next, previous, state, invocation.extension, invocation.context));
2063
+ return this.#step(invocation, (next) => this.#rules(next, previous), progress);
1615
2064
  });
1616
2065
  }
1617
2066
  }
@@ -1662,7 +2111,7 @@ export class TaskScheduler {
1662
2111
  const message = terminal.status === "orphaned" ? terminal.reason : terminal.error.message;
1663
2112
  span.setStatus({
1664
2113
  status: "error",
1665
- error: { name: `task_${terminal.status}`, message: message.slice(0, 500) },
2114
+ error: { name: `task_${terminal.status}`, message: ownedSlice(message, 0, 500) },
1666
2115
  });
1667
2116
  }
1668
2117
  else if (outcome === "aborted") {
@@ -1673,45 +2122,50 @@ export class TaskScheduler {
1673
2122
  });
1674
2123
  }
1675
2124
  /**
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.
2125
+ * Precedence rules for a run invocation, a decision region of its step. Rules 1 (terminal, `completing`, `waiting`, or
2126
+ * `sleeping`) and 2 (closing) are applied by `#step`. Progress (rule 5) is decided by `#progress()` once the decision
2127
+ * stands, as it takes a registry snapshot.
1678
2128
  */
1679
- #decide(tx, current, previous, state, extension, context) {
2129
+ #rules(current, previous) {
1680
2130
  // 3. abort mark: end; a fresh abort invocation starts once the task's ordinary owned work is gone.
1681
2131
  if (current.abortRequested)
1682
- return false;
2132
+ return END;
1683
2133
  // An owner's cancellation intent ends it too, before its cascade marks it; reservation then holds it back.
1684
- if (!current.background && this.#belowCancelled(parentOf(current))) {
1685
- this.#cascadePending = true;
1686
- this.#scheduleReconcile();
1687
- return false;
1688
- }
2134
+ if (!current.background && this.#belowCancelled(parentOf(current)))
2135
+ return CASCADE;
1689
2136
  if (previous === undefined)
1690
- return true;
2137
+ return CONTINUE;
1691
2138
  // 4. uncaught error.
1692
2139
  if (previous.failure !== undefined)
1693
- return { fault: previous.failure.error };
2140
+ return { kind: "fault", error: previous.failure.error };
1694
2141
  // 6. no durable progress.
1695
2142
  if (jsonEqual(current.state.checkpoint, previous.checkpoint)) {
1696
2143
  const message = `Task ${current.kind} phase ${previous.checkpoint.phase} returned without durable progress`;
1697
- return { fault: new Error(message) };
2144
+ return { kind: "fault", error: new Error(message) };
1698
2145
  }
1699
- // 5. progress: refresh the snapshot; hand over to a replacement definition that can take the task.
2146
+ return PROGRESS;
2147
+ }
2148
+ /**
2149
+ * Rule 5, progress: refresh the snapshot; hand over to a replacement definition that can take the task, writing it
2150
+ * back as `pending`, or keep running under the old one, reported once per resolved definition. Returns whether the
2151
+ * invocation continues.
2152
+ */
2153
+ #progress(tx, current, state, extension, context) {
1700
2154
  state.snapshot = this.#registry.snapshot();
1701
2155
  const next = state.snapshot.task(current.kind);
1702
- if (next !== state.task) {
1703
- if (next !== undefined && canReserve(next, current)) {
1704
- tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));
1705
- return false;
1706
- }
1707
- if (state.reported === undefined || state.reported.task !== next) {
1708
- state.reported = { task: next };
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);
1714
- }
2156
+ if (next === state.task)
2157
+ return true;
2158
+ if (next !== undefined && canReserve(next, current)) {
2159
+ tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));
2160
+ return false;
2161
+ }
2162
+ if (state.reported === undefined || state.reported.task !== next) {
2163
+ state.reported = { task: next };
2164
+ this.#report(new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`), {
2165
+ kind: "task_outdated",
2166
+ ...this.#aboutTask({ ...recordTask(current), extension }),
2167
+ reason: outdatedReason(next, current),
2168
+ }, context);
1715
2169
  }
1716
2170
  return true;
1717
2171
  }
@@ -1736,28 +2190,42 @@ export class TaskScheduler {
1736
2190
  failure = { error };
1737
2191
  }
1738
2192
  const message = `Abort handler of task ${invocation.taskId} returned without a terminal outcome`;
1739
- await this.#step(invocation, () => ({ fault: failure?.error ?? new Error(message) }));
2193
+ await this.#step(invocation, () => ({ kind: "fault", error: failure?.error ?? new Error(message) }));
1740
2194
  });
1741
2195
  }
1742
2196
  /**
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.
2197
+ * One decision on the Session line, before a phase: a decision region (`#region()`) that reads the task's record. A
2198
+ * task that is no longer running (rule 1: terminal, `completing`, `waiting`, or `sleeping`) or a closing Harness (rule
2199
+ * 2) ends the invocation without a write; otherwise `plan` decides, and a decision that stands is applied: the
2200
+ * invocation continues, with `progress` for rule 5, or ends, with a fault written. Ending happens inside the callback,
2201
+ * before a fault's Harness cleanup. A rejected step, such as admission after close, also ends the invocation. Its
2202
+ * outcome goes to the invocation only, so it is a scheduler commit (`commitInternal()`).
1747
2203
  */
1748
- async #step(invocation, decide) {
2204
+ async #step(invocation, plan, progress) {
1749
2205
  let fault;
1750
2206
  try {
1751
- const continued = await this.#session.commitWith(async (tx) => {
1752
- const found = this.#live.get(invocation.taskId);
1753
- const current = found?.state.status === "running" ? found : undefined;
1754
- const decision = current !== undefined && !this.#closing ? decide(tx, current) : false;
1755
- if (decision === true)
2207
+ const continued = await this.#session.commitInternal(async (tx) => {
2208
+ const decided = await this.#region(() => {
2209
+ const found = this.#liveRecord(invocation.taskId);
2210
+ if (found?.state.status !== "running" || this.#closing)
2211
+ return { plan: END };
2212
+ const current = found;
2213
+ return { current, plan: plan(current) };
2214
+ });
2215
+ const { current } = decided;
2216
+ const step = decided.plan;
2217
+ if (step.kind === "continue")
2218
+ return current;
2219
+ if (step.kind === "progress" && progress(tx, current))
1756
2220
  return current;
1757
2221
  this.#end(invocation);
1758
- if (decision !== false) {
1759
- fault = { error: decision.fault };
1760
- const message = errorMessage(decision.fault);
2222
+ if (step.kind === "cascade") {
2223
+ this.#cascadePending = true;
2224
+ this.#scheduleReconcile();
2225
+ }
2226
+ if (step.kind === "fault") {
2227
+ fault = { error: step.error };
2228
+ const message = errorMessage(step.error);
1761
2229
  invocation.fault = { status: "faulted", error: { message } };
1762
2230
  await this.#terminate(tx, current, invocation.fault);
1763
2231
  }
@@ -1765,7 +2233,7 @@ export class TaskScheduler {
1765
2233
  }, withoutAbortSignal(invocation.context));
1766
2234
  // Reported once the fault is durable: a host may count it, for example toward a breaker.
1767
2235
  if (fault !== undefined)
1768
- this.#report(fault.error, { type: "task_faulted", ...this.#aboutTask(invocation), reason: "threw" }, invocation.context);
2236
+ this.#report(fault.error, { kind: "task_faulted", ...this.#aboutTask(invocation), reason: "threw" }, invocation.context);
1769
2237
  return continued;
1770
2238
  }
1771
2239
  catch (error) {
@@ -1782,7 +2250,7 @@ export class TaskScheduler {
1782
2250
  */
1783
2251
  async #terminate(tx, record, outcome) {
1784
2252
  await this.#loadScopes(false);
1785
- if (this.#hasOwnedLive(record.id, overlayOf(tx))) {
2253
+ if (await this.#region(() => this.#hasOwnedLive(record.id, overlayOf(tx)), this.#reloadScopes)) {
1786
2254
  tx.setTask(withState(record, { status: "completing", outcome }));
1787
2255
  return;
1788
2256
  }
@@ -1804,7 +2272,7 @@ export class TaskScheduler {
1804
2272
  await this.#loadScopes(false);
1805
2273
  for (const record of overlay.tasks.values())
1806
2274
  await this.#loadChain(parentOf(record), overlay);
1807
- if (this.#hasOwnedLive(current.id, overlay)) {
2275
+ if (await this.#region(() => this.#hasOwnedLive(current.id, overlayOf(tx)), this.#reloadScopes)) {
1808
2276
  tx.setTask(withState(progressed(current), { status: "completing", outcome: next.outcome }));
1809
2277
  return;
1810
2278
  }
@@ -1825,37 +2293,64 @@ export class TaskScheduler {
1825
2293
  async #validateWait(tx, invocation, current, on, policy) {
1826
2294
  if (invocation.mode === "abort")
1827
2295
  throw new Error(`Abort handler of task ${current.id} cannot wait`);
1828
- const overlay = overlayOf(tx);
2296
+ // Owned work is found by walks down, which need the chains of every live task loaded.
2297
+ await this.#loadScopes(false);
2298
+ // Tasks in `on` that are not live, read from Storage: terminal, or missing.
2299
+ const ended = new Map();
2300
+ for (;;) {
2301
+ const checked = await this.#region(() => this.#checkWait(current, on, policy, overlayOf(tx), ended), this.#reloadScopes);
2302
+ if (checked === undefined)
2303
+ return;
2304
+ if ("missing" in checked) {
2305
+ for (const id of checked.missing)
2306
+ ended.set(id, (await this.#session.lineStorage.task(id, this.#context)) ?? null);
2307
+ continue;
2308
+ }
2309
+ throw checked.error;
2310
+ }
2311
+ }
2312
+ /**
2313
+ * `#validateWait()`'s decision region: why the wait is refused, `undefined` when it may be stored, or the tasks in
2314
+ * `on` it needs from Storage first, when `ended` lacks them.
2315
+ */
2316
+ #checkWait(current, on, policy, overlay, ended) {
1829
2317
  const targets = [];
2318
+ const missing = [];
1830
2319
  for (const id of on) {
1831
- const member = overlay.tasks.get(id) ?? this.#live.get(id) ?? (await this.#storage.task(id, this.#context));
1832
- if (member === undefined)
1833
- throw new Error(`Task ${id} does not exist`);
2320
+ const member = overlay.tasks.get(id) ?? this.#liveRecord(id) ?? ended.get(id);
2321
+ if (member === undefined) {
2322
+ missing.push(id);
2323
+ continue;
2324
+ }
2325
+ if (member === null)
2326
+ return { error: new Error(`Task ${id} does not exist`) };
1834
2327
  if (policy === "failFast" && member.owner !== current.id) {
1835
- throw new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`);
2328
+ return { error: new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`) };
1836
2329
  }
1837
2330
  // A live child already holds the waiter (task-owned tasks are never background): waiting on it adds no edge. A
1838
2331
  // terminal task has no edges.
1839
2332
  if (member.state.status !== "terminal" && member.owner !== current.id)
1840
2333
  targets.push(id);
1841
2334
  }
2335
+ if (missing.length > 0)
2336
+ return { missing };
1842
2337
  if (targets.length === 0)
1843
- return;
1844
- const cycle = await this.#waitCycle(current.id, targets, overlay);
1845
- if (cycle !== undefined) {
1846
- throw new Error(`Task ${current.id} cannot wait on ${cycle[0]}: ${[current.id, ...cycle].join(" -> ")} is a wait cycle`);
1847
- }
2338
+ return undefined;
2339
+ const cycle = this.#waitCycle(current.id, targets, overlay);
2340
+ if (cycle === undefined)
2341
+ return undefined;
2342
+ return {
2343
+ error: new Error(`Task ${current.id} cannot wait on ${cycle[0]}: ${[current.id, ...cycle].join(" -> ")} is a wait cycle`),
2344
+ };
1848
2345
  }
1849
2346
  /**
1850
2347
  * The path from a member of `on` back to `task` in the wait-for graph, if the wait would close a cycle (spec §5.1).
1851
2348
  * An edge X -> Y means X cannot settle before Y: Y is in the `on` of waiting X that is not abort-marked, or Y is live
1852
- * ordinary owned work of X,
1853
- * which holds X as `completing`. Each task is visited once and each subtree walked once, so a check is bounded by the
1854
- * live work it reaches.
2349
+ * ordinary owned work of X, which holds X as `completing`. Each task is visited once and each subtree walked once,
2350
+ * so a check is bounded by the live work it reaches. Part of a decision region: it records every record it reads.
2351
+ * Owned work is found by walks down, which need the chains of every live task loaded.
1855
2352
  */
1856
- async #waitCycle(task, on, overlay) {
1857
- // Owned work is found by walks down, which need the chains of every live task loaded.
1858
- await this.#loadScopes(false);
2353
+ #waitCycle(task, on, overlay) {
1859
2354
  const from = new Map();
1860
2355
  const walked = new Set();
1861
2356
  const pending = [];
@@ -1876,7 +2371,7 @@ export class TaskScheduler {
1876
2371
  for (let id = pending.pop(); id !== undefined; id = pending.pop()) {
1877
2372
  if (id === task)
1878
2373
  return path(id);
1879
- const record = overlay.tasks.get(id) ?? this.#live.get(id);
2374
+ const record = overlay.tasks.get(id) ?? this.#liveRecord(id);
1880
2375
  if (record === undefined || record.state.status === "terminal")
1881
2376
  continue;
1882
2377
  // An abort-marked task waits only for its owned work, as abort runs bottom-up (`#waitingOn`).
@@ -1922,6 +2417,9 @@ export class TaskScheduler {
1922
2417
  /** No live non-background task in the scope; a task whose owner edges are not loaded yet counts as inside. */
1923
2418
  #idle(conversationId) {
1924
2419
  const scope = conversationId === undefined ? { roots: true } : { conversation: conversationId };
2420
+ // In a decision region, global idle reads the roots it starts from.
2421
+ if (conversationId === undefined)
2422
+ this.#record(ROOTS_KEY);
1925
2423
  // Tasks whose chain is not loaded are not listed below their scope yet; one counts as inside unless its walk up
1926
2424
  // decides otherwise.
1927
2425
  for (const id of this.#unloaded) {
@@ -1963,7 +2461,7 @@ export class TaskScheduler {
1963
2461
  catch (error) {
1964
2462
  if (invocation.controller.signal.aborted)
1965
2463
  throw error;
1966
- this.#report(error, { type: "extension_error", extension, task: reportTask(invocation) }, invocation.context);
2464
+ this.#report(error, { kind: "extension_error", extension, task: reportTask(invocation) }, invocation.context);
1967
2465
  }
1968
2466
  }
1969
2467
  },
@@ -1986,8 +2484,9 @@ export class TaskScheduler {
1986
2484
  get registry() {
1987
2485
  return phase.snapshot();
1988
2486
  },
1989
- commit: async (change, context) => {
2487
+ commit: async (change, context, options) => {
1990
2488
  let checkpoint;
2489
+ let interrupted;
1991
2490
  await this.#gated(invocation, async (tx, current) => {
1992
2491
  const next = await change(tx, current);
1993
2492
  if (next !== undefined)
@@ -1995,7 +2494,30 @@ export class TaskScheduler {
1995
2494
  if (next?.status === "running" && current.state.checkpoint.phase !== next.checkpoint.phase) {
1996
2495
  checkpoint = next.checkpoint.phase;
1997
2496
  }
1998
- }, context);
2497
+ // A task that ends for crashes says so (`task_interrupted`), so a host keeps a crash-loop signal.
2498
+ if (next?.status === "terminal") {
2499
+ const restarts = current.restarts ?? 0;
2500
+ const reason = next.outcome.status === "failed" ? interruptionOf(next.outcome.error) : undefined;
2501
+ if (reason !== undefined && next.outcome.status === "failed") {
2502
+ interrupted = { message: next.outcome.error.message, reason, restarts };
2503
+ }
2504
+ else {
2505
+ // A task that ended for crashes with another outcome says so itself, as a generation whose run
2506
+ // answered ends `completed` without its after-run tasks.
2507
+ const message = endedByCrashesStates.get(next);
2508
+ if (message !== undefined)
2509
+ interrupted = { message, reason: "restart_limit", restarts };
2510
+ }
2511
+ }
2512
+ }, context, options);
2513
+ if (interrupted !== undefined) {
2514
+ this.#report(new Error(interrupted.message), {
2515
+ kind: "task_interrupted",
2516
+ ...this.#aboutTask(invocation),
2517
+ reason: interrupted.reason,
2518
+ restarts: interrupted.restarts,
2519
+ }, invocation.context);
2520
+ }
1999
2521
  if (checkpoint !== undefined) {
2000
2522
  invocation.context.telemetry.addEvent("pi.durable.checkpoint", {
2001
2523
  phase: checkpoint,
@@ -2019,7 +2541,7 @@ export class TaskScheduler {
2019
2541
  watchDoc: ((...args) => this.#watchDoc(invocation, args)),
2020
2542
  snapshot: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshot")(...args))),
2021
2543
  snapshotAsOf: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshotAsOf")(...args))),
2022
- getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine(() => this.#storage.task(id, context)))),
2544
+ getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine((storage) => storage.task(id, context)))),
2023
2545
  waitForTask: ((id, context) => this.#read(invocation, () => this.waitForTask(id, withAbortSignal(invocation.controller.signal, context)))),
2024
2546
  ownedTasks: (context) => this.#read(invocation, () => this.#session.readOnLine(async () => {
2025
2547
  // The step that ends the invocation may have run on the line before this.
@@ -2027,12 +2549,16 @@ export class TaskScheduler {
2027
2549
  throw endedError(invocation);
2028
2550
  context.abortSignal?.throwIfAborted();
2029
2551
  // Every live task is listed under its parent node, its owner task when it has one.
2030
- const owned = [];
2031
- for (const node of this.#below.get(invocation.taskId) ?? []) {
2032
- const record = typeof node === "string" ? undefined : this.#live.get(node);
2033
- if (record?.owner === invocation.taskId)
2034
- owned.push(record);
2035
- }
2552
+ const owned = await this.#region(() => {
2553
+ this.#record(ownedKey(invocation.taskId));
2554
+ const records = [];
2555
+ for (const node of this.#below.get(invocation.taskId) ?? []) {
2556
+ const record = typeof node === "string" ? undefined : this.#liveRecord(node);
2557
+ if (record?.owner === invocation.taskId)
2558
+ records.push(record);
2559
+ }
2560
+ return records;
2561
+ });
2036
2562
  // Copies, as from storage: the scheduler reads its own.
2037
2563
  return owned
2038
2564
  .sort((a, b) => a.id - b.id)
@@ -2040,7 +2566,7 @@ export class TaskScheduler {
2040
2566
  })),
2041
2567
  abortOwned: (id, context) => this.#read(invocation, async () => {
2042
2568
  const bound = withAbortSignal(invocation.controller.signal, context);
2043
- const record = await this.#session.readOnLine(() => this.#storage.task(id, bound));
2569
+ const record = await this.#session.readOnLine((storage) => storage.task(id, bound));
2044
2570
  if (record?.owner !== invocation.taskId) {
2045
2571
  throw new Error(`Task ${id} is not owned by task ${invocation.taskId}`);
2046
2572
  }
@@ -2049,10 +2575,10 @@ export class TaskScheduler {
2049
2575
  await this.abort(id, bound, true);
2050
2576
  await this.waitForTask(id, bound);
2051
2577
  }),
2052
- outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async () => {
2578
+ outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async (storage) => {
2053
2579
  const outcomes = [];
2054
2580
  for (const id of ids) {
2055
- const state = (await this.#storage.task(id, context))?.state;
2581
+ const state = (await storage.task(id, context))?.state;
2056
2582
  if (state?.status !== "terminal")
2057
2583
  throw new Error(`Task ${id} is not terminal`);
2058
2584
  outcomes.push(state.outcome);
@@ -2071,13 +2597,24 @@ export class TaskScheduler {
2071
2597
  ? [undefined, args[0], args[1]]
2072
2598
  : [args[0], args[1], args[2]];
2073
2599
  return this.#read(invocation, async () => {
2074
- const found = await this.#session.readOnLine(() => this.#storage.entry(invocation.conversationId, id, context));
2075
- const entry = found?.entry;
2600
+ // An entry of the conversation's kept context range is committed and visible: no Storage read.
2601
+ const kept = this.#contexts.get(invocation.conversationId);
2602
+ const entry = (kept === undefined ? undefined : rangeEntry(kept.range, id)) ??
2603
+ (await this.#session.readOnLine((storage) => storage.entry(invocation.conversationId, id, context)))
2604
+ ?.entry;
2076
2605
  return token === undefined || entry?.kind === token.kind ? entry : undefined;
2077
2606
  });
2078
2607
  }),
2608
+ entries: (query, limit, cursor, context) => this.#read(invocation, () => this.#session.readOnLine((storage) => storage.scanEntries(conversationEntries(invocation.conversationId, query), limit, cursor, context))),
2079
2609
  context: (conversationId, context, options) => this.#read(invocation, async () => {
2080
- const { view, range } = await readContextFrom(this.#session, this.#storage, conversationId, context, options?.at, this.#contexts.get(conversationId)?.range);
2610
+ const read = await readContextFrom(this.#session, this.#storage, conversationId, context, options?.at, this.#contexts.get(conversationId)?.range);
2611
+ const { view } = read;
2612
+ // Checked again here, synchronously up to the install below: a commit published since the read returned
2613
+ // was not seen by `#advanceContexts`, so a range that missed it must not be advanced.
2614
+ const range = read.range?.current === true &&
2615
+ this.#session.knownLatestEntryId(conversationId) !== read.range.bounds.tail
2616
+ ? { ...read.range, current: false }
2617
+ : read.range;
2081
2618
  // Keep it unless the invocation ended or a concurrent read already kept a newer range.
2082
2619
  const kept = this.#contexts.get(conversationId);
2083
2620
  if (range !== undefined &&
@@ -2087,9 +2624,17 @@ export class TaskScheduler {
2087
2624
  // A read of another, idle conversation starts or continues its retention period.
2088
2625
  if (!this.#idle(conversationId)) {
2089
2626
  this.#contexts.set(conversationId, { range, idleSince: undefined });
2627
+ this.#idleOrder.delete(conversationId);
2090
2628
  }
2091
2629
  else if (this.#contextRetentionMs() > 0) {
2092
- this.#contexts.set(conversationId, { range, idleSince: kept?.idleSince ?? this.#now() });
2630
+ // An idle period that already started keeps its expiry and its place; a new one starts now.
2631
+ const idleSince = kept?.idleSince ?? this.#now();
2632
+ const expiresAt = kept?.idleSince !== undefined && kept.expiresAt !== undefined
2633
+ ? kept.expiresAt
2634
+ : idleSince + this.#contextRetentionMs();
2635
+ this.#contexts.set(conversationId, { range, idleSince, expiresAt });
2636
+ if (kept?.idleSince === undefined)
2637
+ this.#markIdle(conversationId, idleSince);
2093
2638
  this.#scheduleExpiry();
2094
2639
  }
2095
2640
  }
@@ -2121,13 +2666,22 @@ export class TaskScheduler {
2121
2666
  // The task's own failure names the task's extension; any other names whose code failed, if anyone's.
2122
2667
  const task = reportTask(invocation);
2123
2668
  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);
2669
+ ? { kind: "task_error", ...this.#aboutTask(invocation) }
2670
+ : from.kind === "extension"
2671
+ ? { kind: "extension_error", task, ...optionalExtension(from.extension) }
2672
+ : { kind: "host_error", callback: from.callback, task }, invocation.context);
2128
2673
  },
2129
2674
  };
2130
2675
  }
2676
+ /**
2677
+ * On the line: wait until every commit admitted before is published, for a decision that reads what cannot be keyed,
2678
+ * every live task (open, inspection) or every task a conversation abort reaches.
2679
+ */
2680
+ async #published() {
2681
+ const gate = this.#session.touch(ALL_KEYS);
2682
+ if (gate !== undefined)
2683
+ await gate;
2684
+ }
2131
2685
  /** Run a committed-state read unless the invocation has ended. */
2132
2686
  async #read(invocation, read) {
2133
2687
  if (invocation.ended)
@@ -2135,7 +2689,7 @@ export class TaskScheduler {
2135
2689
  return read();
2136
2690
  }
2137
2691
  /** Commit after rereading the task on the line and gating the invocation. */
2138
- #gated(invocation, change, context) {
2692
+ #gated(invocation, change, context, options) {
2139
2693
  if (invocation.ended)
2140
2694
  return Promise.reject(endedError(invocation));
2141
2695
  return this.#session.commitWith(async (tx) => {
@@ -2143,17 +2697,22 @@ export class TaskScheduler {
2143
2697
  throw endedError(invocation);
2144
2698
  if (this.#closing)
2145
2699
  throw closedError(this.#session);
2146
- const found = this.#live.get(invocation.taskId);
2147
- if (found === undefined)
2148
- throw new Error(`Task ${invocation.taskId} is terminal`);
2149
- if (found.state.status !== "running")
2150
- throw new Error(`Task ${invocation.taskId} is ${found.state.status}`);
2151
- const current = found;
2152
- if (invocation.mode === "run" && current.abortRequested) {
2153
- throw new Error(`Task ${invocation.taskId} has a durable abort mark`);
2154
- }
2700
+ const current = await this.#region(() => {
2701
+ const found = this.#liveRecord(invocation.taskId);
2702
+ if (found === undefined)
2703
+ throw new Error(`Task ${invocation.taskId} is terminal`);
2704
+ if (found.state.status !== "running")
2705
+ throw new Error(`Task ${invocation.taskId} is ${found.state.status}`);
2706
+ if (invocation.mode === "run" && found.abortRequested) {
2707
+ throw new Error(`Task ${invocation.taskId} has a durable abort mark`);
2708
+ }
2709
+ return found;
2710
+ });
2711
+ // The step that ends it may have run while the region waited.
2712
+ if (invocation.ended)
2713
+ throw endedError(invocation);
2155
2714
  return change(tx, current);
2156
- }, context, { conversationId: invocation.conversationId, taskId: invocation.taskId });
2715
+ }, context, { conversationId: invocation.conversationId, taskId: invocation.taskId }, options);
2157
2716
  }
2158
2717
  async #watchDoc(invocation, args) {
2159
2718
  if (invocation.ended)
@@ -2170,9 +2729,10 @@ export class TaskScheduler {
2170
2729
  return watch;
2171
2730
  }
2172
2731
  }
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" };
2732
+ const REGISTRY = { kind: "host_error", callback: "registry" };
2733
+ const SETTINGS = { kind: "host_error", callback: "settings" };
2734
+ const ON_WAKE = { kind: "host_error", callback: "onWake" };
2735
+ const TELEMETRY = { kind: "host_error", callback: "telemetry" };
2176
2736
  /** The extension that installed `task` in `snapshot`, the last one that lists it; none for a built-in task. */
2177
2737
  function installerOf(snapshot, task) {
2178
2738
  if (task === undefined)
@@ -2191,6 +2751,24 @@ function recordTask(record) {
2191
2751
  * The record with an abort mark: `restart` for an abandonment; without a reason, a request, which replaces `restart`.
2192
2752
  * The restart count starts over: it now counts cut-offs of the abort handler.
2193
2753
  */
2754
+ /** Why a failed outcome's error says crashes ended the task (`detail.reason`), if it does. */
2755
+ /**
2756
+ * Terminal states a built-in task commits because it was cut off `settings.restartLimit` times, when their outcome does
2757
+ * not say so (a failure with detail `{ reason: "restart_limit" }` does), with the message the scheduler reports them
2758
+ * `task_interrupted` with. Keyed by the state object the task returns, so nothing is added to the stored state.
2759
+ */
2760
+ const endedByCrashesStates = new WeakMap();
2761
+ /** `state`, marked as ending its task for crashes (`task_interrupted` with `message`), whatever its outcome. */
2762
+ export function endedByCrashes(state, message) {
2763
+ endedByCrashesStates.set(state, message);
2764
+ return state;
2765
+ }
2766
+ function interruptionOf(error) {
2767
+ const detail = error.detail;
2768
+ if (typeof detail !== "object" || detail === null || !("reason" in detail))
2769
+ return undefined;
2770
+ return detail.reason === "restart_limit" || detail.reason === "interrupted" ? detail.reason : undefined;
2771
+ }
2194
2772
  function withAbortMark(record, reason) {
2195
2773
  const { abortReason: _reason, restarts: _restarts, ...rest } = record;
2196
2774
  return { ...rest, abortRequested: true, ...(reason === undefined ? {} : { abortReason: reason }) };
@@ -2199,6 +2777,16 @@ function withAbortMark(record, reason) {
2199
2777
  function cancellationIntent(record) {
2200
2778
  return record.state.status !== "terminal" && (record.abortRequested || failedOutcome(record));
2201
2779
  }
2780
+ /**
2781
+ * Whether replacing `previous` (absent: a new task) with `next` changes the cancellation intent set or what it means: a
2782
+ * mark set, upgraded, or cleared, or a held or ended outcome other than `completed`.
2783
+ */
2784
+ function intentChanges(previous, next) {
2785
+ return ((previous?.abortRequested ?? false) !== next.abortRequested ||
2786
+ previous?.abortReason !== next.abortReason ||
2787
+ (previous !== undefined && failedOutcome(previous)) !== failedOutcome(next) ||
2788
+ (previous !== undefined && cancellationIntent(previous)) !== cancellationIntent(next));
2789
+ }
2202
2790
  /** Whether the record holds or ends with an outcome other than `completed`. */
2203
2791
  function failedOutcome(record) {
2204
2792
  const state = record.state;
@@ -2267,6 +2855,15 @@ function withState(record, state) {
2267
2855
  return { ...rest, state };
2268
2856
  }
2269
2857
  /** Whether a definition can take the task at reservation: same version, or newer with a migration. */
2858
+ /**
2859
+ * Why a replacement definition cannot take a running task, in `#fit()`'s words: none is registered, it is older than
2860
+ * the task, or it is newer without a `migrate`.
2861
+ */
2862
+ function outdatedReason(task, record) {
2863
+ if (task === undefined)
2864
+ return "missing_task";
2865
+ return task.definition.version < record.version ? "task_too_old" : "migration_failed";
2866
+ }
2270
2867
  function canReserve(task, record) {
2271
2868
  const definition = task.definition;
2272
2869
  return (definition.version === record.version || (definition.version > record.version && definition.migrate !== undefined));