@earendil-works/pi-durable 0.99.1 → 0.99.2

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 (172) hide show
  1. package/README.md +435 -46
  2. package/dist/entries.d.ts +20 -2
  3. package/dist/entries.d.ts.map +1 -1
  4. package/dist/entries.js +14 -1
  5. package/dist/entries.js.map +1 -1
  6. package/dist/env/index.d.ts +13 -38
  7. package/dist/env/index.d.ts.map +1 -1
  8. package/dist/env/index.js +2 -0
  9. package/dist/env/index.js.map +1 -1
  10. package/dist/env/node.d.ts.map +1 -1
  11. package/dist/env/node.js +51 -58
  12. package/dist/env/node.js.map +1 -1
  13. package/dist/harness/compaction.d.ts +56 -0
  14. package/dist/harness/compaction.d.ts.map +1 -0
  15. package/dist/harness/compaction.js +385 -0
  16. package/dist/harness/compaction.js.map +1 -0
  17. package/dist/harness/config.d.ts +10 -1
  18. package/dist/harness/config.d.ts.map +1 -1
  19. package/dist/harness/config.js +6 -0
  20. package/dist/harness/config.js.map +1 -1
  21. package/dist/harness/context.d.ts +8 -0
  22. package/dist/harness/context.d.ts.map +1 -1
  23. package/dist/harness/context.js +32 -21
  24. package/dist/harness/context.js.map +1 -1
  25. package/dist/harness/events.d.ts +166 -0
  26. package/dist/harness/events.d.ts.map +1 -0
  27. package/dist/harness/events.js +319 -0
  28. package/dist/harness/events.js.map +1 -0
  29. package/dist/harness/generation.d.ts +37 -6
  30. package/dist/harness/generation.d.ts.map +1 -1
  31. package/dist/harness/generation.js +350 -72
  32. package/dist/harness/generation.js.map +1 -1
  33. package/dist/harness/harness.d.ts.map +1 -1
  34. package/dist/harness/harness.js +124 -3
  35. package/dist/harness/harness.js.map +1 -1
  36. package/dist/harness/inbox.d.ts +57 -0
  37. package/dist/harness/inbox.d.ts.map +1 -0
  38. package/dist/harness/inbox.js +99 -0
  39. package/dist/harness/inbox.js.map +1 -0
  40. package/dist/harness/json.d.ts +10 -0
  41. package/dist/harness/json.d.ts.map +1 -0
  42. package/dist/harness/json.js +33 -0
  43. package/dist/harness/json.js.map +1 -0
  44. package/dist/harness/live.d.ts +66 -7
  45. package/dist/harness/live.d.ts.map +1 -1
  46. package/dist/harness/live.js +71 -7
  47. package/dist/harness/live.js.map +1 -1
  48. package/dist/harness/output.d.ts +62 -0
  49. package/dist/harness/output.d.ts.map +1 -0
  50. package/dist/harness/output.js +261 -0
  51. package/dist/harness/output.js.map +1 -0
  52. package/dist/harness/prompt.d.ts +10 -5
  53. package/dist/harness/prompt.d.ts.map +1 -1
  54. package/dist/harness/prompt.js +66 -13
  55. package/dist/harness/prompt.js.map +1 -1
  56. package/dist/harness/registry.d.ts.map +1 -1
  57. package/dist/harness/registry.js +11 -2
  58. package/dist/harness/registry.js.map +1 -1
  59. package/dist/harness/scheduler.d.ts +37 -12
  60. package/dist/harness/scheduler.d.ts.map +1 -1
  61. package/dist/harness/scheduler.js +632 -72
  62. package/dist/harness/scheduler.js.map +1 -1
  63. package/dist/harness/submissions.d.ts +12 -7
  64. package/dist/harness/submissions.d.ts.map +1 -1
  65. package/dist/harness/submissions.js +72 -33
  66. package/dist/harness/submissions.js.map +1 -1
  67. package/dist/harness/tool.d.ts +36 -0
  68. package/dist/harness/tool.d.ts.map +1 -0
  69. package/dist/harness/tool.js +412 -0
  70. package/dist/harness/tool.js.map +1 -0
  71. package/dist/harness/types.d.ts +185 -11
  72. package/dist/harness/types.d.ts.map +1 -1
  73. package/dist/harness/types.js.map +1 -1
  74. package/dist/harness/usage.d.ts +18 -0
  75. package/dist/harness/usage.d.ts.map +1 -0
  76. package/dist/harness/usage.js +57 -0
  77. package/dist/harness/usage.js.map +1 -0
  78. package/dist/harness/view.d.ts +43 -0
  79. package/dist/harness/view.d.ts.map +1 -0
  80. package/dist/harness/view.js +194 -0
  81. package/dist/harness/view.js.map +1 -0
  82. package/dist/index.d.ts +10 -4
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +6 -1
  85. package/dist/index.js.map +1 -1
  86. package/dist/session/observation.d.ts +18 -11
  87. package/dist/session/observation.d.ts.map +1 -1
  88. package/dist/session/observation.js +24 -13
  89. package/dist/session/observation.js.map +1 -1
  90. package/dist/session/session.d.ts +10 -1
  91. package/dist/session/session.d.ts.map +1 -1
  92. package/dist/session/session.js +18 -4
  93. package/dist/session/session.js.map +1 -1
  94. package/dist/session/transaction.d.ts +10 -1
  95. package/dist/session/transaction.d.ts.map +1 -1
  96. package/dist/session/transaction.js +83 -27
  97. package/dist/session/transaction.js.map +1 -1
  98. package/dist/storage/jsonl/storage.d.ts +22 -2
  99. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  100. package/dist/storage/jsonl/storage.js +1 -1
  101. package/dist/storage/jsonl/storage.js.map +1 -1
  102. package/dist/storage/memory.js +1 -1
  103. package/dist/storage/memory.js.map +1 -1
  104. package/dist/storage/sqlite/migrations.js +1 -1
  105. package/dist/storage/sqlite/migrations.js.map +1 -1
  106. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  107. package/dist/testing/storage-benchmark.js +0 -1
  108. package/dist/testing/storage-benchmark.js.map +1 -1
  109. package/dist/testing/storage-conformance.d.ts.map +1 -1
  110. package/dist/testing/storage-conformance.js +34 -2
  111. package/dist/testing/storage-conformance.js.map +1 -1
  112. package/dist/tools/bash.d.ts +28 -0
  113. package/dist/tools/bash.d.ts.map +1 -0
  114. package/dist/tools/bash.js +69 -0
  115. package/dist/tools/bash.js.map +1 -0
  116. package/dist/tools/edit-diff.d.ts +89 -0
  117. package/dist/tools/edit-diff.d.ts.map +1 -0
  118. package/dist/tools/edit-diff.js +386 -0
  119. package/dist/tools/edit-diff.js.map +1 -0
  120. package/dist/tools/edit.d.ts +18 -0
  121. package/dist/tools/edit.d.ts.map +1 -0
  122. package/dist/tools/edit.js +116 -0
  123. package/dist/tools/edit.js.map +1 -0
  124. package/dist/tools/env.d.ts +5 -0
  125. package/dist/tools/env.d.ts.map +1 -0
  126. package/dist/tools/env.js +7 -0
  127. package/dist/tools/env.js.map +1 -0
  128. package/dist/tools/file-mutation-queue.d.ts +5 -0
  129. package/dist/tools/file-mutation-queue.d.ts.map +1 -0
  130. package/dist/tools/file-mutation-queue.js +46 -0
  131. package/dist/tools/file-mutation-queue.js.map +1 -0
  132. package/dist/tools/image.d.ts +2 -0
  133. package/dist/tools/image.d.ts.map +1 -0
  134. package/dist/tools/image.js +92 -0
  135. package/dist/tools/image.js.map +1 -0
  136. package/dist/tools/index.d.ts +5 -0
  137. package/dist/tools/index.d.ts.map +1 -0
  138. package/dist/tools/index.js +5 -0
  139. package/dist/tools/index.js.map +1 -0
  140. package/dist/tools/path-utils.d.ts +5 -0
  141. package/dist/tools/path-utils.d.ts.map +1 -0
  142. package/dist/tools/path-utils.js +26 -0
  143. package/dist/tools/path-utils.js.map +1 -0
  144. package/dist/tools/read.d.ts +17 -0
  145. package/dist/tools/read.d.ts.map +1 -0
  146. package/dist/tools/read.js +100 -0
  147. package/dist/tools/read.js.map +1 -0
  148. package/dist/tools/write.d.ts +10 -0
  149. package/dist/tools/write.d.ts.map +1 -0
  150. package/dist/tools/write.js +30 -0
  151. package/dist/tools/write.js.map +1 -0
  152. package/dist/{env/utils/truncate.d.ts → truncate.d.ts} +1 -17
  153. package/dist/truncate.d.ts.map +1 -0
  154. package/dist/truncate.js +146 -0
  155. package/dist/truncate.js.map +1 -0
  156. package/dist/types.d.ts +86 -19
  157. package/dist/types.d.ts.map +1 -1
  158. package/dist/types.js.map +1 -1
  159. package/docs/pico-v5-handoff.md +366 -86
  160. package/docs/pico-v5.md +1373 -283
  161. package/package.json +11 -3
  162. package/dist/env/utils/adaptive-publisher.d.ts +0 -24
  163. package/dist/env/utils/adaptive-publisher.d.ts.map +0 -1
  164. package/dist/env/utils/adaptive-publisher.js +0 -79
  165. package/dist/env/utils/adaptive-publisher.js.map +0 -1
  166. package/dist/env/utils/output-capture.d.ts +0 -31
  167. package/dist/env/utils/output-capture.d.ts.map +0 -1
  168. package/dist/env/utils/output-capture.js +0 -238
  169. package/dist/env/utils/output-capture.js.map +0 -1
  170. package/dist/env/utils/truncate.d.ts.map +0 -1
  171. package/dist/env/utils/truncate.js +0 -294
  172. package/dist/env/utils/truncate.js.map +0 -1
@@ -5,25 +5,33 @@ import { closedError, scanAll, Waiters } from "./util.js";
5
5
  const SCAN_PAGE_SIZE = 256;
6
6
  /** Longest delay `setTimeout` supports; longer sleeps wait in several steps. */
7
7
  const MAX_TIMER_DELAY = 2_147_483_647;
8
+ const LIVE_STATUSES = ["pending", "running", "waiting", "completing"];
8
9
  /**
9
10
  * Durable task scheduler of one Harness.
10
11
  *
11
- * `#live` mirrors every committed pending/running task record. The synchronous commit listener updates it on the
12
- * Session line, so code running on the line reads exactly the committed state from it.
12
+ * `#live` mirrors every committed non-terminal task record: pending, running, waiting, and completing. The synchronous
13
+ * commit listener updates it on the Session line, so code running on the line reads exactly the committed state from it.
14
+ *
15
+ * Tasks and conversations form one ownership tree (spec §5.5): a task's parent is its owner task, or its conversation;
16
+ * a conversation's parent is its owner task, if any. Walks up that tree decide cascades, idle scopes, and whether a
17
+ * task's ordinary owned work is live, which holds its outcome as `completing` and delays its abort handler.
13
18
  *
14
19
  * Invariant: every task transition is decided and written by one callback serialized on the Session line. That covers
15
- * reservation, marks, runtime commits, and the synchronous step before each phase, which applies the precedence rules
16
- * and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the step that decides its end,
17
- * so a runtime commit it queued either lands before that decision or is rejected.
20
+ * reservation, marks, runtime commits, finalization, and the synchronous step before each phase, which applies the
21
+ * precedence rules and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the
22
+ * step that decides its end, so a runtime commit it queued either lands before that decision or is rejected.
18
23
  */
19
24
  export class TaskScheduler {
20
25
  #session;
21
26
  #storage;
22
27
  #registry;
23
28
  #models;
29
+ #env;
24
30
  #now;
25
31
  #report;
26
32
  #settleOutcome;
33
+ #withdrawInputs;
34
+ #conversation;
27
35
  #context;
28
36
  #live = new Map();
29
37
  #invocations = new Map();
@@ -32,6 +40,19 @@ export class TaskScheduler {
32
40
  #idleWaiters = new Waiters();
33
41
  /** Definition whose migration failed per task; retried only once the registry resolves another definition. */
34
42
  #failedMigrations = new Map();
43
+ /** Owner task of each loaded conversation, `null` when ownerless. */
44
+ #edges = new Map();
45
+ /** Tasks that own a loaded conversation; their nodes stay in `#settled` once terminal. */
46
+ #conversationOwners = new Set();
47
+ /** Ownership fields of terminal tasks that walks pass through. */
48
+ #settled = new Map();
49
+ /**
50
+ * `failFast` waiters the next reconcile checks for a failed task in `on`: at open, when they start waiting, and when
51
+ * one of their tasks fails.
52
+ */
53
+ #failFastChecks = new Set();
54
+ #reconcileScheduled = false;
55
+ #cascadePending = false;
35
56
  #unsubscribeRegistry = () => { };
36
57
  #enabled = false;
37
58
  #closing = false;
@@ -42,9 +63,12 @@ export class TaskScheduler {
42
63
  this.#storage = options.storage;
43
64
  this.#registry = options.registry;
44
65
  this.#models = options.models;
66
+ this.#env = options.env;
45
67
  this.#now = options.now;
46
68
  this.#report = options.report;
47
69
  this.#settleOutcome = options.settleOutcome;
70
+ this.#withdrawInputs = options.withdrawInputs;
71
+ this.#conversation = options.conversation;
48
72
  this.#context = options.context;
49
73
  }
50
74
  /** Load live tasks and change surviving `running` tasks back to `pending`. Dispatches nothing. */
@@ -53,15 +77,26 @@ export class TaskScheduler {
53
77
  this.#session.subscribeClose(() => this.#seal());
54
78
  this.#unsubscribeRegistry = this.#registry.subscribe(() => this.#kick());
55
79
  await this.#session.commitWith(async (tx) => {
56
- const scan = (status) => scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor));
57
- const pending = await scan("pending");
58
- const running = await scan("running");
59
- for (const record of [...pending, ...running])
60
- this.#live.set(record.id, record);
61
- for (const record of running) {
62
- tx.setTask(withState(record, { status: "pending", checkpoint: record.state.checkpoint }));
80
+ // Every table read before the first write.
81
+ const scans = [];
82
+ for (const status of LIVE_STATUSES) {
83
+ scans.push(await scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor)));
84
+ }
85
+ for (const records of scans) {
86
+ for (const record of records) {
87
+ this.#live.set(record.id, record);
88
+ if (record.state.status === "running") {
89
+ tx.setTask(withState(record, { status: "pending", checkpoint: record.state.checkpoint }));
90
+ }
91
+ if (record.state.status === "waiting" && record.state.policy === "failFast") {
92
+ this.#failFastChecks.add(record.id);
93
+ }
94
+ }
63
95
  }
64
96
  }, context);
97
+ // Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes.
98
+ this.#cascadePending = true;
99
+ this.#scheduleReconcile();
65
100
  }
66
101
  /** Enable scheduling. Idempotent; the kick does nothing once closing. */
67
102
  resume() {
@@ -73,8 +108,9 @@ export class TaskScheduler {
73
108
  await Promise.allSettled([...this.#invocations.values()].map((invocation) => invocation.done));
74
109
  }
75
110
  /**
76
- * Commit the abort mark, or settle a task that no registered definition can take as `orphaned`, then signal and
77
- * join the run invocation seen on the line. The next drain starts the abort invocation.
111
+ * Commit the abort mark, or settle a task that no registered definition can take as `orphaned` when nothing it owns
112
+ * is live, then join the run invocation seen on the line; the commit listener signalled it. The abort invocation
113
+ * starts once the task's ordinary owned work is gone. A `completing` task is only marked.
78
114
  */
79
115
  async abort(id, context) {
80
116
  const marked = await this.#session.commitWith(async (tx) => {
@@ -83,23 +119,24 @@ export class TaskScheduler {
83
119
  throw new Error(`Task ${id} does not exist`);
84
120
  if (current.state.status === "terminal")
85
121
  return { result: "terminal" };
86
- const live = current;
87
122
  const invocation = this.#invocations.get(id);
88
- if (invocation === undefined) {
89
- const resolution = this.#resolve(live, this.#registry.snapshot());
90
- if (resolution.kind === "blocked") {
91
- await this.#terminate(tx, live, { status: "orphaned", reason: resolution.reason });
92
- return { result: "marked" };
123
+ if (invocation === undefined && current.state.status !== "completing") {
124
+ await this.#loadScopes(false);
125
+ if (!this.#ownedLive().has(id)) {
126
+ const resolution = this.#resolve(current, this.#registry.snapshot());
127
+ if (resolution.kind === "blocked") {
128
+ await this.#terminate(tx, current, { status: "orphaned", reason: resolution.reason });
129
+ return { result: "marked" };
130
+ }
93
131
  }
94
132
  }
95
- if (!live.abortRequested)
96
- tx.setTask({ ...live, abortRequested: true });
133
+ if (!current.abortRequested)
134
+ tx.setTask({ ...current, abortRequested: true });
97
135
  return { result: "marked", run: invocation?.mode === "run" ? invocation : undefined };
98
136
  }, context);
99
- if (marked.run !== undefined) {
100
- marked.run.controller.abort();
137
+ // The commit listener signalled the run; join it.
138
+ if (marked.run !== undefined)
101
139
  await awaitWithContext(marked.run.done, context);
102
- }
103
140
  return marked.result;
104
141
  }
105
142
  async waitForTask(id, context) {
@@ -116,37 +153,392 @@ export class TaskScheduler {
116
153
  });
117
154
  return found.promise;
118
155
  }
119
- /** Resolve when no live non-background task exists, optionally within one conversation. */
156
+ /**
157
+ * Resolve when ordinary traversal from the conversation, or from every ownerless conversation, reaches no live
158
+ * non-background task.
159
+ */
120
160
  waitForIdle(conversationId, context) {
121
161
  if (this.#closing)
122
162
  return Promise.reject(closedError());
123
163
  if (this.#idle(conversationId))
124
164
  return Promise.resolve();
165
+ this.#scheduleReconcile();
125
166
  return this.#idleWaiters.add(conversationId, context);
126
167
  }
168
+ /**
169
+ * `Conversation.abort()`: in one commit, withdraw the queued inputs and mark every live non-background task that
170
+ * ordinary traversal from the conversation reaches; resolves once the scope is idle. With `background`, traversal
171
+ * crosses background boundaries, and the wait also covers every task it reached.
172
+ */
173
+ async abortConversation(conversationId, background, context) {
174
+ const reached = await this.#session.commitWith(async (tx) => {
175
+ const queued = await this.#loadScopes(true);
176
+ const scope = { conversation: conversationId };
177
+ const reached = [];
178
+ for (const record of this.#live.values()) {
179
+ if (record.background && !background)
180
+ continue;
181
+ if (this.#inScope(parentOf(record), scope, background) !== true)
182
+ continue;
183
+ reached.push(record.id);
184
+ if (!record.abortRequested)
185
+ tx.setTask({ ...record, abortRequested: true });
186
+ }
187
+ for (const id of queued) {
188
+ if (this.#inScope({ conversation: id }, scope, background) === true)
189
+ await this.#withdrawInputs(tx, id);
190
+ }
191
+ return reached;
192
+ }, context);
193
+ if (background)
194
+ for (const id of reached)
195
+ await this.waitForTask(id, context);
196
+ await this.waitForIdle(conversationId, context);
197
+ }
127
198
  // ─── Scheduling ────────────────────────────────────────────────────────
128
199
  #observe(publication) {
200
+ const updated = [];
201
+ const failed = [];
129
202
  let changed = false;
130
203
  for (const change of publication.changes) {
131
204
  if (change.type !== "task")
132
205
  continue;
133
206
  changed = true;
134
207
  const record = change.value;
135
- if (record.state.status !== "terminal") {
136
- this.#live.set(record.id, record);
208
+ const previous = this.#live.get(record.id);
209
+ if (failedOutcome(record) && (previous === undefined || !failedOutcome(previous)))
210
+ failed.push(record.id);
211
+ if (record.state.status === "terminal") {
212
+ this.#live.delete(record.id);
213
+ this.#failedMigrations.delete(record.id);
214
+ this.#failFastChecks.delete(record.id);
215
+ if (this.#conversationOwners.has(record.id))
216
+ this.#settled.set(record.id, nodeOf(record));
217
+ this.#taskWaiters.resolve(record.id, record);
218
+ // Its owner may finalize now.
219
+ this.#scheduleReconcile();
220
+ continue;
221
+ }
222
+ if (record.abortRequested && previous?.abortRequested !== true) {
223
+ this.#cascadePending = true;
224
+ // Signal a run invocation of the newly marked task; its next step ends it.
225
+ const invocation = this.#invocations.get(record.id);
226
+ if (invocation?.mode === "run")
227
+ invocation.controller.abort();
228
+ }
229
+ const status = record.state.status;
230
+ if (status === "completing" && previous?.state.status !== "completing") {
231
+ if (cancellationIntent(record))
232
+ this.#cascadePending = true;
233
+ this.#scheduleReconcile();
234
+ }
235
+ if (status === "waiting" && record.state.policy === "failFast" && previous?.state.status !== "waiting") {
236
+ this.#failFastChecks.add(record.id);
237
+ this.#scheduleReconcile();
238
+ }
239
+ this.#live.set(record.id, record);
240
+ updated.push(record);
241
+ }
242
+ for (const id of failed) {
243
+ for (const record of this.#live.values()) {
244
+ const state = record.state;
245
+ if (state.status === "waiting" && state.policy === "failFast" && state.on.includes(id)) {
246
+ this.#failFastChecks.add(record.id);
247
+ this.#scheduleReconcile();
248
+ }
249
+ }
250
+ }
251
+ for (const change of publication.changes) {
252
+ if (change.type === "conversation" && !this.#edges.has(change.value.id)) {
253
+ this.#setEdge(change.value.id, change.value.owner?.taskId ?? null);
254
+ }
255
+ }
256
+ for (const change of publication.changes) {
257
+ // A queued input below a cancelled owner is withdrawn, even after its cascade.
258
+ if (change.type !== "submission" || change.value.status !== "queued" || change.value.type !== "input")
137
259
  continue;
260
+ const up = { conversation: change.value.conversationId };
261
+ if (!this.#chainKnown(up) || this.#belowCancelled(up))
262
+ this.#cascadePending = true;
263
+ }
264
+ for (const record of updated) {
265
+ // Work created below a cancelled owner, even after its cascade, is aborted too.
266
+ if (!this.#chainKnown(parentOf(record)))
267
+ this.#scheduleReconcile();
268
+ else if (!record.background && !record.abortRequested && this.#belowCancelled(parentOf(record))) {
269
+ this.#cascadePending = true;
138
270
  }
139
- this.#live.delete(record.id);
140
- this.#failedMigrations.delete(record.id);
141
- this.#taskWaiters.resolve(record.id, record);
142
271
  }
272
+ // Also retries, with the next commit of any kind, a cascade whose commit failed.
273
+ if (this.#cascadePending)
274
+ this.#scheduleReconcile();
143
275
  if (!changed)
144
276
  return;
277
+ this.#resolveIdleWaiters();
278
+ this.#kick();
279
+ }
280
+ #resolveIdleWaiters() {
145
281
  for (const conversationId of this.#idleWaiters.keys()) {
146
282
  if (this.#idle(conversationId))
147
283
  this.#idleWaiters.resolve(conversationId);
148
284
  }
149
- this.#kick();
285
+ }
286
+ // ─── Ownership ───────────────────────────────────────────────────────────
287
+ #scheduleReconcile() {
288
+ if (this.#reconcileScheduled || this.#closing)
289
+ return;
290
+ this.#reconcileScheduled = true;
291
+ queueMicrotask(() => void this.#reconcile());
292
+ }
293
+ /**
294
+ * One commit that applies what committed records imply: abort marks below live owners with cancellation intent
295
+ * (spec §5.4), `failFast` marks (spec §5.5), withdrawn queued inputs below cancelled owners, and the final terminal
296
+ * record of every `completing` task whose ordinary owned work is gone. The durable records are the intent, so this
297
+ * also repairs whatever a crash left unapplied. Resolves idle waiters that the loaded edges decide.
298
+ */
299
+ async #reconcile() {
300
+ this.#reconcileScheduled = false;
301
+ const cascade = this.#cascadePending;
302
+ this.#cascadePending = false;
303
+ const checks = [...this.#failFastChecks];
304
+ this.#failFastChecks.clear();
305
+ try {
306
+ await this.#session.commitWith(async (tx) => {
307
+ if (this.#closing)
308
+ return;
309
+ const queued = await this.#loadScopes(cascade);
310
+ const marked = new Set();
311
+ const mark = (record) => {
312
+ if (record.abortRequested || marked.has(record.id))
313
+ return;
314
+ marked.add(record.id);
315
+ tx.setTask({ ...record, abortRequested: true });
316
+ };
317
+ // Loading edges can reveal a cancelled owner, so marks are derived on every pass.
318
+ for (const record of this.#live.values()) {
319
+ if (!record.background && this.#belowCancelled(parentOf(record)))
320
+ mark(record);
321
+ }
322
+ for (const id of checks) {
323
+ const waiter = this.#live.get(id);
324
+ if (waiter?.state.status !== "waiting" || !(await this.#anyFailed(waiter.state.on)))
325
+ continue;
326
+ // Every other live task: the failed one keeps its own outcome.
327
+ for (const member of waiter.state.on) {
328
+ const record = this.#live.get(member);
329
+ if (record !== undefined && !failedOutcome(record))
330
+ mark(record);
331
+ }
332
+ }
333
+ for (const id of queued)
334
+ if (this.#belowCancelled({ conversation: id }))
335
+ await this.#withdrawInputs(tx, id);
336
+ await this.#finalize(tx);
337
+ }, this.#context);
338
+ }
339
+ catch (error) {
340
+ // Any pass may have staged marks, so a failed one is retried with the next commit.
341
+ this.#cascadePending = true;
342
+ for (const id of checks)
343
+ this.#failFastChecks.add(id);
344
+ if (!this.#closing)
345
+ this.#report(error);
346
+ }
347
+ this.#resolveIdleWaiters();
348
+ }
349
+ /** Whether any of `ids` holds or ended with an outcome other than `completed`. */
350
+ async #anyFailed(ids) {
351
+ for (const id of ids) {
352
+ const record = this.#live.get(id) ?? (await this.#storage.task(id, this.#context));
353
+ if (record !== undefined && failedOutcome(record))
354
+ return true;
355
+ }
356
+ return false;
357
+ }
358
+ /**
359
+ * Write the terminal record of every `completing` task without live ordinary owned work. Finalizing one can free its
360
+ * owner, so this repeats over the commit's candidates until nothing changes. A held scheduler outcome gets its
361
+ * Harness cleanup here.
362
+ */
363
+ async #finalize(tx) {
364
+ for (;;) {
365
+ const overlay = overlayOf(tx);
366
+ const owned = this.#ownedLive(overlay);
367
+ const done = [...this.#liveRecords(overlay)].filter((record) => record.state.status === "completing" && !owned.has(record.id));
368
+ if (done.length === 0)
369
+ return;
370
+ for (const record of done) {
371
+ const outcome = record.state.outcome;
372
+ tx.setTask(withState(record, { status: "terminal", outcome }));
373
+ // REMINDER: only the scheduler writes `faulted` and `orphaned` (spec §5.4); their cleanup waits for this commit.
374
+ if (outcome.status === "faulted" || outcome.status === "orphaned") {
375
+ await this.#settleOutcome(tx, record, outcome);
376
+ }
377
+ }
378
+ }
379
+ }
380
+ /**
381
+ * Load the owner chains of every live task and, with `queued`, of every conversation with queued submissions, on the
382
+ * Session line; returns the latter. Reads committed Storage directly, so it may run inside a commit callback.
383
+ */
384
+ async #loadScopes(queued) {
385
+ for (const record of [...this.#live.values()]) {
386
+ if (!this.#chainKnown(parentOf(record)))
387
+ await this.#loadChain(parentOf(record));
388
+ }
389
+ if (!queued)
390
+ return [];
391
+ const submissions = await scanAll((cursor) => this.#storage.scanSubmissions({ status: "queued" }, SCAN_PAGE_SIZE, cursor, this.#context));
392
+ const conversations = [...new Set(submissions.map((submission) => submission.conversationId))];
393
+ for (const id of conversations)
394
+ await this.#loadChain({ conversation: id });
395
+ return conversations;
396
+ }
397
+ /** Load the owner edges and task nodes from `start` up to its ownerless root. */
398
+ async #loadChain(start, overlay) {
399
+ let at = start;
400
+ while (at !== undefined) {
401
+ if ("task" in at) {
402
+ let node = this.#node(at.task, overlay);
403
+ if (node === undefined) {
404
+ const record = await this.#storage.task(at.task, this.#context);
405
+ if (record === undefined)
406
+ return;
407
+ node = nodeOf(record);
408
+ if (record.state.status === "terminal")
409
+ this.#settled.set(record.id, node);
410
+ }
411
+ at = parentOf(node);
412
+ }
413
+ else {
414
+ let edge = this.#edge(at.conversation, overlay);
415
+ if (edge === undefined) {
416
+ const record = await this.#storage.conversation(at.conversation, this.#context);
417
+ edge = record?.owner?.taskId ?? null;
418
+ this.#setEdge(at.conversation, edge);
419
+ }
420
+ at = edge === null ? undefined : { task: edge };
421
+ }
422
+ }
423
+ }
424
+ #setEdge(conversationId, owner) {
425
+ this.#edges.set(conversationId, owner);
426
+ if (owner !== null)
427
+ this.#conversationOwners.add(owner);
428
+ }
429
+ /** Owner task of a conversation, `null` when ownerless, `undefined` while not loaded. */
430
+ #edge(id, overlay) {
431
+ return overlay?.edges.has(id) === true ? overlay.edges.get(id) : this.#edges.get(id);
432
+ }
433
+ #node(id, overlay) {
434
+ return overlay?.tasks.get(id) ?? this.#live.get(id) ?? this.#settled.get(id);
435
+ }
436
+ /** Walk up from `start`: owner tasks and conversations, ending at an ownerless root or an edge not loaded yet. */
437
+ *#above(start, overlay) {
438
+ let at = start;
439
+ while (at !== undefined) {
440
+ if ("task" in at) {
441
+ const node = this.#node(at.task, overlay);
442
+ if (node === undefined) {
443
+ yield { unknown: true };
444
+ return;
445
+ }
446
+ yield { task: at.task, node };
447
+ at = parentOf(node);
448
+ }
449
+ else {
450
+ yield { conversation: at.conversation };
451
+ const edge = this.#edge(at.conversation, overlay);
452
+ if (edge === undefined) {
453
+ yield { unknown: true };
454
+ return;
455
+ }
456
+ at = edge === null ? undefined : { task: edge };
457
+ }
458
+ }
459
+ }
460
+ /** Whether every owner above `start` is loaded. */
461
+ #chainKnown(start, overlay) {
462
+ for (const step of this.#above(start, overlay))
463
+ if ("unknown" in step)
464
+ return false;
465
+ return true;
466
+ }
467
+ /** Live records, with the overlay's candidates replacing committed ones; terminal candidates are gone. */
468
+ *#liveRecords(overlay) {
469
+ for (const record of this.#live.values()) {
470
+ const candidate = overlay?.tasks.get(record.id) ?? record;
471
+ if (candidate.state.status !== "terminal")
472
+ yield candidate;
473
+ }
474
+ if (overlay === undefined)
475
+ return;
476
+ for (const record of overlay.tasks.values()) {
477
+ if (!this.#live.has(record.id) && record.state.status !== "terminal")
478
+ yield record;
479
+ }
480
+ }
481
+ /**
482
+ * Every task with live ordinary owned work (spec §5.5), mapped to that work: each live non-background task counts for
483
+ * every owner task above it up to and including the first background one. Owner chains must be loaded.
484
+ */
485
+ #ownedLive(overlay) {
486
+ const owned = new Map();
487
+ for (const record of this.#liveRecords(overlay)) {
488
+ if (record.background)
489
+ continue;
490
+ for (const step of this.#above(parentOf(record), overlay)) {
491
+ if ("unknown" in step)
492
+ break;
493
+ if (!("task" in step))
494
+ continue;
495
+ const below = owned.get(step.task);
496
+ if (below === undefined)
497
+ owned.set(step.task, [record.id]);
498
+ else
499
+ below.push(record.id);
500
+ if (step.node.background)
501
+ break;
502
+ }
503
+ }
504
+ return owned;
505
+ }
506
+ /**
507
+ * Whether ordinary traversal from `scope` reaches `start`: walking up reaches the scope's conversation, or an
508
+ * ownerless one for `roots`, without crossing a background owner, unless `crossBackground`. `undefined` while an
509
+ * edge is not loaded.
510
+ */
511
+ #inScope(start, scope, crossBackground = false) {
512
+ for (const step of this.#above(start)) {
513
+ if ("unknown" in step)
514
+ return undefined;
515
+ if ("conversation" in step) {
516
+ if ("conversation" in scope && step.conversation === scope.conversation)
517
+ return true;
518
+ }
519
+ else if (step.node.background && !crossBackground) {
520
+ return false;
521
+ }
522
+ }
523
+ return "roots" in scope;
524
+ }
525
+ /**
526
+ * Whether a live owner's cancellation intent reaches `start`: walking up finds an owner with intent before a
527
+ * background owner without it. Terminal owners never cascade (spec §5.4).
528
+ */
529
+ #belowCancelled(start) {
530
+ for (const step of this.#above(start)) {
531
+ if ("unknown" in step)
532
+ return false;
533
+ if (!("task" in step))
534
+ continue;
535
+ const live = this.#live.get(step.task);
536
+ if (live !== undefined && cancellationIntent(live))
537
+ return true;
538
+ if (step.node.background)
539
+ return false;
540
+ }
541
+ return false;
150
542
  }
151
543
  /** Close listener: runs synchronously once admission is sealed, before `join()`. */
152
544
  #seal() {
@@ -192,20 +584,26 @@ export class TaskScheduler {
192
584
  await this.#session.commitWith(async (tx) => {
193
585
  if (!this.#enabled || this.#closing)
194
586
  return;
587
+ await this.#loadScopes(false);
588
+ const owned = this.#ownedLive();
195
589
  // Taken once per pass, and only when some task is a candidate.
196
590
  let snapshot;
197
591
  for (const record of [...this.#live.values()]) {
198
- if (this.#invocations.has(record.id) || this.#waitingOn(record).length > 0)
592
+ if (this.#invocations.has(record.id) || this.#waitingOn(record, owned).length > 0)
199
593
  continue;
594
+ if (record.state.status === "completing")
595
+ continue;
596
+ const runnable = record;
200
597
  const mode = record.abortRequested ? "abort" : "run";
201
598
  snapshot ??= this.#registry.snapshot();
202
- const resolution = this.#resolve(record, snapshot);
599
+ const resolution = this.#resolve(runnable, snapshot);
203
600
  if (resolution.kind === "blocked") {
204
- if (mode === "abort")
601
+ if (mode === "abort") {
205
602
  await this.#terminate(tx, record, { status: "orphaned", reason: resolution.reason });
603
+ }
206
604
  continue;
207
605
  }
208
- if (resolution.record !== record || record.state.status !== "running") {
606
+ if (resolution.record !== runnable || record.state.status !== "running") {
209
607
  tx.setTask(withState(resolution.record, {
210
608
  status: "running",
211
609
  checkpoint: resolution.record.state.checkpoint,
@@ -226,6 +624,17 @@ export class TaskScheduler {
226
624
  }
227
625
  return reservations;
228
626
  }
627
+ /**
628
+ * Live tasks a task waits for before its next invocation: its live ordinary owned work when abort-marked, since
629
+ * abort runs bottom-up, otherwise the live part of the `on` of a wait.
630
+ */
631
+ #waitingOn(record, owned) {
632
+ if (record.abortRequested)
633
+ return owned.get(record.id) ?? [];
634
+ if (record.state.status !== "waiting")
635
+ return [];
636
+ return record.state.on.filter((id) => this.#live.has(id));
637
+ }
229
638
  /** Resolve the record's definition by kind, migrating an older stored version. */
230
639
  #resolve(record, snapshot) {
231
640
  const fit = this.#fit(record, snapshot.task(record.kind));
@@ -238,7 +647,7 @@ export class TaskScheduler {
238
647
  if (definition.migrate === undefined)
239
648
  throw missingMigration(record, definition);
240
649
  const migrated = definition.migrate(record.input, record.state.checkpoint, record.version);
241
- const state = { status: record.state.status, checkpoint: copyJson(migrated.checkpoint) };
650
+ const state = { ...record.state, checkpoint: copyJson(migrated.checkpoint) };
242
651
  const migratedRecord = { ...record, version: definition.version, input: copyJson(migrated.input), state };
243
652
  return { kind: "ready", task: fit.task, record: migratedRecord };
244
653
  }
@@ -261,35 +670,36 @@ export class TaskScheduler {
261
670
  return { reason: "migration_failed", error: failed.error };
262
671
  return { task, migrates: true };
263
672
  }
264
- /** Live dependencies a run waits for; an abort mark bypasses them and a running task has passed them. */
265
- #waitingOn(record) {
266
- if (record.abortRequested || record.state.status !== "pending")
267
- return [];
268
- return record.after.filter((id) => this.#live.has(id));
269
- }
270
673
  /**
271
674
  * Scheduling state and every live task with its derived state, read on the Session line. Runs no task code: a
272
675
  * pending migration shows as `ready` with `migrates`, and only a migration the scheduler already tried, or one that
273
676
  * cannot exist, shows as failed.
274
677
  */
275
- inspect(snapshot) {
678
+ async inspect(snapshot) {
679
+ await this.#loadScopes(false);
680
+ const owned = this.#ownedLive();
276
681
  const tasks = [];
277
- for (const record of this.#live.values())
278
- tasks.push({ record, state: this.#inspectTask(record, snapshot) });
682
+ for (const record of this.#live.values()) {
683
+ tasks.push({ record, state: this.#inspectTask(record, snapshot, owned) });
684
+ }
279
685
  const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused";
280
686
  return { scheduling, tasks };
281
687
  }
282
- #inspectTask(record, snapshot) {
688
+ #inspectTask(record, snapshot, owned) {
283
689
  if (this.#invocations.has(record.id))
284
690
  return { kind: "running" };
691
+ if (record.state.status === "completing")
692
+ return { kind: "completing" };
693
+ const on = this.#waitingOn(record, owned);
694
+ if (on.length > 0)
695
+ return { kind: "waiting", on };
285
696
  const fit = this.#fit(record, snapshot.task(record.kind));
286
697
  if ("reason" in fit)
287
698
  return { kind: "blocked", ...fit };
288
699
  if (fit.migrates && fit.task.definition.migrate === undefined) {
289
700
  return { kind: "blocked", reason: "migration_failed", error: missingMigration(record, erased(fit.task)) };
290
701
  }
291
- const on = this.#waitingOn(record);
292
- return on.length > 0 ? { kind: "waiting", on } : { kind: "ready", migrates: fit.migrates };
702
+ return { kind: "ready", migrates: fit.migrates };
293
703
  }
294
704
  #createInvocation(record, mode) {
295
705
  const controller = new AbortController();
@@ -331,7 +741,7 @@ export class TaskScheduler {
331
741
  async #run(reservation) {
332
742
  const invocation = reservation.invocation;
333
743
  const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined };
334
- const runtime = this.#runtime(invocation, () => state.snapshot);
744
+ const runtime = this.#runtime(invocation, () => state.snapshot, () => state.task);
335
745
  let previous;
336
746
  for (;;) {
337
747
  const current = await this.#step(invocation, (tx, current) => this.#decide(tx, current, previous, state));
@@ -349,11 +759,11 @@ export class TaskScheduler {
349
759
  }
350
760
  }
351
761
  /**
352
- * Precedence rules for a run invocation, on the line. Rules 1 (terminal) and 2 (closing) are applied by `#step`.
353
- * Returns whether the invocation continues with the next phase.
762
+ * Precedence rules for a run invocation, on the line. Rules 1 (terminal, `completing`, or `waiting`) and 2 (closing)
763
+ * are applied by `#step`. Returns whether the invocation continues with the next phase.
354
764
  */
355
765
  #decide(tx, current, previous, state) {
356
- // 3. abort mark: end; the next drain starts a fresh abort invocation.
766
+ // 3. abort mark: end; a fresh abort invocation starts once the task's ordinary owned work is gone.
357
767
  if (current.abortRequested)
358
768
  return false;
359
769
  if (previous === undefined)
@@ -390,7 +800,7 @@ export class TaskScheduler {
390
800
  return;
391
801
  let failure;
392
802
  try {
393
- const runtime = this.#runtime(invocation, () => reservation.snapshot);
803
+ const runtime = this.#runtime(invocation, () => reservation.snapshot, () => reservation.task);
394
804
  await erased(reservation.task).abort(current, runtime, invocation.context);
395
805
  }
396
806
  catch (error) {
@@ -400,15 +810,16 @@ export class TaskScheduler {
400
810
  await this.#step(invocation, () => ({ fault: failure?.error ?? new Error(message) }));
401
811
  }
402
812
  /**
403
- * One synchronous decision on the Session line. A terminal task (rule 1) or a closing Harness (rule 2) ends the
404
- * invocation without a write; otherwise `decide` may stage a write and returns whether the invocation continues.
405
- * Ending happens inside the callback, before a fault's Harness cleanup. A rejected step, such as admission after
406
- * close, also ends the invocation.
813
+ * One synchronous decision on the Session line. A task that is no longer running (rule 1: terminal, `completing`, or
814
+ * `waiting`) or a closing Harness (rule 2) ends the invocation without a write; otherwise `decide` may stage a write
815
+ * and returns whether the invocation continues. Ending happens inside the callback, before a fault's Harness
816
+ * cleanup. A rejected step, such as admission after close, also ends the invocation.
407
817
  */
408
818
  async #step(invocation, decide) {
409
819
  try {
410
820
  return await this.#session.commitWith(async (tx) => {
411
- const current = this.#live.get(invocation.taskId);
821
+ const found = this.#live.get(invocation.taskId);
822
+ const current = found?.state.status === "running" ? found : undefined;
412
823
  const decision = current !== undefined && !this.#closing ? decide(tx, current) : false;
413
824
  if (decision === true)
414
825
  return current;
@@ -427,12 +838,65 @@ export class TaskScheduler {
427
838
  return undefined;
428
839
  }
429
840
  }
430
- /** Write a scheduler-decided terminal outcome together with its Harness cleanup. */
431
- #terminate(tx, record, outcome) {
841
+ /**
842
+ * Write an outcome the scheduler decided. While the task's ordinary owned work is live it holds as `completing` and
843
+ * its Harness cleanup waits for the final commit (spec §5.5, rule 4); otherwise it is terminal with its cleanup.
844
+ */
845
+ async #terminate(tx, record, outcome) {
846
+ await this.#loadScopes(false);
847
+ if (this.#ownedLive(overlayOf(tx)).has(record.id)) {
848
+ tx.setTask(withState(record, { status: "completing", outcome }));
849
+ return;
850
+ }
432
851
  tx.setTask(withState(record, { status: "terminal", outcome }));
433
- return this.#settleOutcome(tx, record, outcome);
852
+ await this.#settleOutcome(tx, record, outcome);
434
853
  }
435
- /** End an invocation: its runtime operations reject from now on, its watches stop, and its task is free. */
854
+ /**
855
+ * Replace a running task's state with what it committed. A terminal state holds as `completing` while ordinary owned
856
+ * work is live, judged on the commit's candidates, so work the same commit creates below the task counts. A wait is
857
+ * validated first.
858
+ */
859
+ async #commitState(tx, invocation, current, next) {
860
+ if (next.status === "waiting")
861
+ await this.#validateWait(tx, invocation, current, next.on, next.policy);
862
+ if (next.status === "terminal") {
863
+ const overlay = overlayOf(tx);
864
+ await this.#loadScopes(false);
865
+ for (const record of overlay.tasks.values())
866
+ await this.#loadChain(parentOf(record), overlay);
867
+ if (this.#ownedLive(overlay).has(current.id)) {
868
+ tx.setTask(withState(current, { status: "completing", outcome: next.outcome }));
869
+ return;
870
+ }
871
+ }
872
+ tx.setTask(withState(current, next));
873
+ }
874
+ /**
875
+ * A wait names existing tasks other than the waiter and its owners, which could never finish first; `failFast` only
876
+ * tasks the waiter owns. An abort handler cannot wait.
877
+ */
878
+ async #validateWait(tx, invocation, current, on, policy) {
879
+ if (invocation.mode === "abort")
880
+ throw new Error(`Abort handler of task ${current.id} cannot wait`);
881
+ const overlay = overlayOf(tx);
882
+ await this.#loadChain(parentOf(current));
883
+ const owners = new Set();
884
+ for (const step of this.#above(parentOf(current)))
885
+ if ("task" in step)
886
+ owners.add(step.task);
887
+ for (const id of on) {
888
+ if (id === current.id || owners.has(id)) {
889
+ throw new Error(`Task ${current.id} cannot wait on itself or its owner ${id}`);
890
+ }
891
+ const member = overlay.tasks.get(id) ?? this.#live.get(id) ?? (await this.#storage.task(id, this.#context));
892
+ if (member === undefined)
893
+ throw new Error(`Task ${id} does not exist`);
894
+ if (policy === "failFast" && member.owner !== current.id) {
895
+ throw new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`);
896
+ }
897
+ }
898
+ }
899
+ /** End an invocation: its runtime operations reject from now on, its signal aborts, its watches stop, and its task is free. */
436
900
  #end(invocation) {
437
901
  if (invocation.ended)
438
902
  return;
@@ -441,30 +905,55 @@ export class TaskScheduler {
441
905
  this.#invocations.delete(invocation.taskId);
442
906
  for (const watch of invocation.watches)
443
907
  void watch.stop();
908
+ // Pending waits bound to the invocation, such as a tool's waitForTask(), reject with it.
909
+ invocation.controller.abort(endedError(invocation));
444
910
  }
911
+ /** No live non-background task in the scope; a task whose owner edges are not loaded yet counts as inside. */
445
912
  #idle(conversationId) {
913
+ const scope = conversationId === undefined ? { roots: true } : { conversation: conversationId };
446
914
  for (const record of this.#live.values()) {
447
- if (record.background)
448
- continue;
449
- if (conversationId === undefined || record.conversationId === conversationId)
915
+ if (!record.background && this.#inScope(parentOf(record), scope) !== false)
450
916
  return false;
451
917
  }
452
918
  return true;
453
919
  }
454
920
  // ─── Invocation runtime ──────────────────────────────────────────────────
455
- #runtime(invocation, snapshot) {
921
+ #runtime(invocation, snapshot, task) {
922
+ const hooks = {
923
+ each: async (name, invoke) => {
924
+ if (invocation.ended)
925
+ throw endedError(invocation);
926
+ for (const { handlers, scope } of snapshot().hooks(task())) {
927
+ const handler = handlers[name];
928
+ if (typeof handler !== "function")
929
+ continue;
930
+ if (scope !== undefined && !(await this.#hookMatches(invocation, scope)))
931
+ continue;
932
+ try {
933
+ await invoke(handler.bind(handlers));
934
+ }
935
+ catch (error) {
936
+ if (invocation.controller.signal.aborted)
937
+ throw error;
938
+ this.#report(error);
939
+ }
940
+ }
941
+ },
942
+ };
456
943
  return {
457
944
  taskId: invocation.taskId,
458
945
  conversationId: invocation.conversationId,
459
946
  signal: invocation.controller.signal,
460
947
  models: this.#models,
948
+ env: this.#env,
949
+ hooks: hooks,
461
950
  get registry() {
462
951
  return snapshot();
463
952
  },
464
953
  commit: (change, context) => this.#gated(invocation, async (tx, current) => {
465
954
  const next = await change(tx, current);
466
955
  if (next !== undefined)
467
- tx.setTask(withState(current, next));
956
+ await this.#commitState(tx, invocation, current, next);
468
957
  }, context),
469
958
  memo: ((name, ...rest) => {
470
959
  if (rest.length === 1) {
@@ -483,11 +972,55 @@ export class TaskScheduler {
483
972
  watchDoc: ((...args) => this.#watchDoc(invocation, args)),
484
973
  snapshot: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshot")(...args))),
485
974
  snapshotAsOf: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshotAsOf")(...args))),
975
+ getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine(() => this.#storage.task(id, context)))),
976
+ waitForTask: ((id, context) => this.#read(invocation, () => this.waitForTask(id, withAbortSignal(invocation.controller.signal, context)))),
977
+ outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async () => {
978
+ const outcomes = [];
979
+ for (const id of ids) {
980
+ const state = (await this.#storage.task(id, context))?.state;
981
+ if (state?.status !== "terminal")
982
+ throw new Error(`Task ${id} is not terminal`);
983
+ outcomes.push(state.outcome);
984
+ }
985
+ return outcomes;
986
+ }))),
987
+ conversation: (id, context) => this.#read(invocation, () => this.#conversation(id, {
988
+ signal: invocation.controller.signal,
989
+ check: () => {
990
+ if (invocation.ended)
991
+ throw endedError(invocation);
992
+ },
993
+ }, context)),
994
+ entry: ((...args) => {
995
+ const [token, id, context] = args.length === 2
996
+ ? [undefined, args[0], args[1]]
997
+ : [args[0], args[1], args[2]];
998
+ return this.#read(invocation, async () => {
999
+ const found = await this.#session.readOnLine(() => this.#storage.entry(invocation.conversationId, id, context));
1000
+ const entry = found?.entry;
1001
+ return token === undefined || entry?.kind === token.kind ? entry : undefined;
1002
+ });
1003
+ }),
486
1004
  context: (conversationId, context, at) => this.#read(invocation, () => readContext(this.#session, this.#storage, conversationId, context, at)),
487
1005
  now: () => this.#now(),
488
1006
  report: (error) => this.#report(error),
489
1007
  };
490
1008
  }
1009
+ /** Whether a scoped hook registration matches the invocation's conversation: itself, or an owner for `subtree`. */
1010
+ async #hookMatches(invocation, scope) {
1011
+ if (scope.conversationId === invocation.conversationId)
1012
+ return true;
1013
+ if (scope.subtree !== true)
1014
+ return false;
1015
+ const start = { conversation: invocation.conversationId };
1016
+ if (!this.#chainKnown(start))
1017
+ await this.#session.readOnLine(() => this.#loadChain(start));
1018
+ for (const step of this.#above(start)) {
1019
+ if ("conversation" in step && step.conversation === scope.conversationId)
1020
+ return true;
1021
+ }
1022
+ return false;
1023
+ }
491
1024
  /** Run a committed-state read unless the invocation has ended. */
492
1025
  async #read(invocation, read) {
493
1026
  if (invocation.ended)
@@ -503,9 +1036,12 @@ export class TaskScheduler {
503
1036
  throw endedError(invocation);
504
1037
  if (this.#closing)
505
1038
  throw closedError();
506
- const current = this.#live.get(invocation.taskId);
507
- if (current === undefined)
1039
+ const found = this.#live.get(invocation.taskId);
1040
+ if (found === undefined)
508
1041
  throw new Error(`Task ${invocation.taskId} is terminal`);
1042
+ if (found.state.status !== "running")
1043
+ throw new Error(`Task ${invocation.taskId} is ${found.state.status}`);
1044
+ const current = found;
509
1045
  if (invocation.mode === "run" && current.abortRequested) {
510
1046
  throw new Error(`Task ${invocation.taskId} has a durable abort mark`);
511
1047
  }
@@ -543,6 +1079,30 @@ export class TaskScheduler {
543
1079
  return watch;
544
1080
  }
545
1081
  }
1082
+ /** A live owner's durable cancellation intent: its abort mark, or a held outcome other than `completed`. */
1083
+ function cancellationIntent(record) {
1084
+ return record.state.status !== "terminal" && (record.abortRequested || failedOutcome(record));
1085
+ }
1086
+ /** Whether the record holds or ends with an outcome other than `completed`. */
1087
+ function failedOutcome(record) {
1088
+ const state = record.state;
1089
+ return (state.status === "completing" || state.status === "terminal") && state.outcome.status !== "completed";
1090
+ }
1091
+ function parentOf(node) {
1092
+ return node.owner !== undefined ? { task: node.owner } : { conversation: node.conversationId };
1093
+ }
1094
+ function nodeOf(record) {
1095
+ return {
1096
+ conversationId: record.conversationId,
1097
+ ...(record.owner === undefined ? {} : { owner: record.owner }),
1098
+ background: record.background,
1099
+ };
1100
+ }
1101
+ function overlayOf(tx) {
1102
+ const tasks = new Map(tx.stagedTasks().map((record) => [record.id, record]));
1103
+ const edges = new Map(tx.stagedConversations().map((record) => [record.id, record.owner?.taskId ?? null]));
1104
+ return { tasks, edges };
1105
+ }
546
1106
  /** Own memo entry only; memo names such as `toString` must not resolve to inherited properties. */
547
1107
  function memoOf(record, name) {
548
1108
  const memos = record?.memos;
@@ -561,9 +1121,9 @@ function erased(task) {
561
1121
  function endedError(invocation) {
562
1122
  return new Error(`Task ${invocation.taskId} invocation has ended`);
563
1123
  }
564
- /** Replace a live record's state; memos disappear in the terminal replacement. */
1124
+ /** Replace a live record's state; memos disappear once an outcome is decided. */
565
1125
  function withState(record, state) {
566
- if (state.status !== "terminal")
1126
+ if (state.status !== "terminal" && state.status !== "completing")
567
1127
  return { ...record, state };
568
1128
  const { memos: _memos, ...rest } = record;
569
1129
  return { ...rest, state };