@ny4n/durable 0.0.0-stage → 1.0.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 (303) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +711 -2
  3. package/dist/documents.d.ts +66 -0
  4. package/dist/documents.d.ts.map +1 -0
  5. package/dist/documents.js +98 -0
  6. package/dist/documents.js.map +1 -0
  7. package/dist/entries.d.ts +31 -0
  8. package/dist/entries.d.ts.map +1 -0
  9. package/dist/entries.js +31 -0
  10. package/dist/entries.js.map +1 -0
  11. package/dist/env/decode.d.ts +16 -0
  12. package/dist/env/decode.d.ts.map +1 -0
  13. package/dist/env/decode.js +28 -0
  14. package/dist/env/decode.js.map +1 -0
  15. package/dist/env/index.d.ts +263 -0
  16. package/dist/env/index.d.ts.map +1 -0
  17. package/dist/env/index.js +49 -0
  18. package/dist/env/index.js.map +1 -0
  19. package/dist/env/line-scan.d.ts +13 -0
  20. package/dist/env/line-scan.d.ts.map +1 -0
  21. package/dist/env/line-scan.js +147 -0
  22. package/dist/env/line-scan.js.map +1 -0
  23. package/dist/env/node-watch.d.ts +29 -0
  24. package/dist/env/node-watch.d.ts.map +1 -0
  25. package/dist/env/node-watch.js +485 -0
  26. package/dist/env/node-watch.js.map +1 -0
  27. package/dist/env/node.d.ts +56 -0
  28. package/dist/env/node.d.ts.map +1 -0
  29. package/dist/env/node.js +1166 -0
  30. package/dist/env/node.js.map +1 -0
  31. package/dist/errors.d.ts +32 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +53 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/harness/agent.d.ts +33 -0
  36. package/dist/harness/agent.d.ts.map +1 -0
  37. package/dist/harness/agent.js +235 -0
  38. package/dist/harness/agent.js.map +1 -0
  39. package/dist/harness/assistant.d.ts +15 -0
  40. package/dist/harness/assistant.d.ts.map +1 -0
  41. package/dist/harness/assistant.js +23 -0
  42. package/dist/harness/assistant.js.map +1 -0
  43. package/dist/harness/compaction.d.ts +56 -0
  44. package/dist/harness/compaction.d.ts.map +1 -0
  45. package/dist/harness/compaction.js +384 -0
  46. package/dist/harness/compaction.js.map +1 -0
  47. package/dist/harness/context.d.ts +63 -0
  48. package/dist/harness/context.d.ts.map +1 -0
  49. package/dist/harness/context.js +221 -0
  50. package/dist/harness/context.js.map +1 -0
  51. package/dist/harness/define.d.ts +18 -0
  52. package/dist/harness/define.d.ts.map +1 -0
  53. package/dist/harness/define.js +25 -0
  54. package/dist/harness/define.js.map +1 -0
  55. package/dist/harness/events.d.ts +180 -0
  56. package/dist/harness/events.d.ts.map +1 -0
  57. package/dist/harness/events.js +370 -0
  58. package/dist/harness/events.js.map +1 -0
  59. package/dist/harness/generation.d.ts +54 -0
  60. package/dist/harness/generation.d.ts.map +1 -0
  61. package/dist/harness/generation.js +514 -0
  62. package/dist/harness/generation.js.map +1 -0
  63. package/dist/harness/harness.d.ts +17 -0
  64. package/dist/harness/harness.d.ts.map +1 -0
  65. package/dist/harness/harness.js +332 -0
  66. package/dist/harness/harness.js.map +1 -0
  67. package/dist/harness/inbox.d.ts +59 -0
  68. package/dist/harness/inbox.d.ts.map +1 -0
  69. package/dist/harness/inbox.js +97 -0
  70. package/dist/harness/inbox.js.map +1 -0
  71. package/dist/harness/json.d.ts +10 -0
  72. package/dist/harness/json.d.ts.map +1 -0
  73. package/dist/harness/json.js +33 -0
  74. package/dist/harness/json.js.map +1 -0
  75. package/dist/harness/live.d.ts +142 -0
  76. package/dist/harness/live.d.ts.map +1 -0
  77. package/dist/harness/live.js +149 -0
  78. package/dist/harness/live.js.map +1 -0
  79. package/dist/harness/output.d.ts +69 -0
  80. package/dist/harness/output.d.ts.map +1 -0
  81. package/dist/harness/output.js +318 -0
  82. package/dist/harness/output.js.map +1 -0
  83. package/dist/harness/prompt.d.ts +27 -0
  84. package/dist/harness/prompt.d.ts.map +1 -0
  85. package/dist/harness/prompt.js +131 -0
  86. package/dist/harness/prompt.js.map +1 -0
  87. package/dist/harness/provider.d.ts +14 -0
  88. package/dist/harness/provider.d.ts.map +1 -0
  89. package/dist/harness/provider.js +30 -0
  90. package/dist/harness/provider.js.map +1 -0
  91. package/dist/harness/registry.d.ts +6 -0
  92. package/dist/harness/registry.d.ts.map +1 -0
  93. package/dist/harness/registry.js +101 -0
  94. package/dist/harness/registry.js.map +1 -0
  95. package/dist/harness/run.d.ts +17 -0
  96. package/dist/harness/run.d.ts.map +1 -0
  97. package/dist/harness/run.js +26 -0
  98. package/dist/harness/run.js.map +1 -0
  99. package/dist/harness/scheduler.d.ts +106 -0
  100. package/dist/harness/scheduler.d.ts.map +1 -0
  101. package/dist/harness/scheduler.js +1743 -0
  102. package/dist/harness/scheduler.js.map +1 -0
  103. package/dist/harness/submissions.d.ts +29 -0
  104. package/dist/harness/submissions.d.ts.map +1 -0
  105. package/dist/harness/submissions.js +175 -0
  106. package/dist/harness/submissions.js.map +1 -0
  107. package/dist/harness/task-graph.d.ts +49 -0
  108. package/dist/harness/task-graph.d.ts.map +1 -0
  109. package/dist/harness/task-graph.js +168 -0
  110. package/dist/harness/task-graph.js.map +1 -0
  111. package/dist/harness/tool.d.ts +76 -0
  112. package/dist/harness/tool.d.ts.map +1 -0
  113. package/dist/harness/tool.js +736 -0
  114. package/dist/harness/tool.js.map +1 -0
  115. package/dist/harness/types.d.ts +713 -0
  116. package/dist/harness/types.d.ts.map +1 -0
  117. package/dist/harness/types.js +2 -0
  118. package/dist/harness/types.js.map +1 -0
  119. package/dist/harness/usage.d.ts +18 -0
  120. package/dist/harness/usage.d.ts.map +1 -0
  121. package/dist/harness/usage.js +57 -0
  122. package/dist/harness/usage.js.map +1 -0
  123. package/dist/harness/util.d.ts +19 -0
  124. package/dist/harness/util.d.ts.map +1 -0
  125. package/dist/harness/util.js +58 -0
  126. package/dist/harness/util.js.map +1 -0
  127. package/dist/harness/view.d.ts +47 -0
  128. package/dist/harness/view.d.ts.map +1 -0
  129. package/dist/harness/view.js +206 -0
  130. package/dist/harness/view.js.map +1 -0
  131. package/dist/ids.d.ts +6 -0
  132. package/dist/ids.d.ts.map +1 -0
  133. package/dist/ids.js +9 -0
  134. package/dist/ids.js.map +1 -0
  135. package/dist/images/cloudflare.d.ts +4 -0
  136. package/dist/images/cloudflare.d.ts.map +1 -0
  137. package/dist/images/cloudflare.js +9 -0
  138. package/dist/images/cloudflare.js.map +1 -0
  139. package/dist/images/exif.d.ts +3 -0
  140. package/dist/images/exif.d.ts.map +1 -0
  141. package/dist/images/exif.js +71 -0
  142. package/dist/images/exif.js.map +1 -0
  143. package/dist/images/index.d.ts +18 -0
  144. package/dist/images/index.d.ts.map +1 -0
  145. package/dist/images/index.js +125 -0
  146. package/dist/images/index.js.map +1 -0
  147. package/dist/images/node-worker.d.ts +17 -0
  148. package/dist/images/node-worker.d.ts.map +1 -0
  149. package/dist/images/node-worker.js +16 -0
  150. package/dist/images/node-worker.js.map +1 -0
  151. package/dist/images/node.d.ts +21 -0
  152. package/dist/images/node.d.ts.map +1 -0
  153. package/dist/images/node.js +121 -0
  154. package/dist/images/node.js.map +1 -0
  155. package/dist/index.d.ts +24 -0
  156. package/dist/index.d.ts.map +1 -0
  157. package/dist/index.js +20 -0
  158. package/dist/index.js.map +1 -0
  159. package/dist/session/forks.d.ts +10 -0
  160. package/dist/session/forks.d.ts.map +1 -0
  161. package/dist/session/forks.js +42 -0
  162. package/dist/session/forks.js.map +1 -0
  163. package/dist/session/observation.d.ts +42 -0
  164. package/dist/session/observation.d.ts.map +1 -0
  165. package/dist/session/observation.js +281 -0
  166. package/dist/session/observation.js.map +1 -0
  167. package/dist/session/session.d.ts +133 -0
  168. package/dist/session/session.d.ts.map +1 -0
  169. package/dist/session/session.js +811 -0
  170. package/dist/session/session.js.map +1 -0
  171. package/dist/session/transaction.d.ts +119 -0
  172. package/dist/session/transaction.d.ts.map +1 -0
  173. package/dist/session/transaction.js +795 -0
  174. package/dist/session/transaction.js.map +1 -0
  175. package/dist/storage/jsonl/index.d.ts +2 -0
  176. package/dist/storage/jsonl/index.d.ts.map +1 -0
  177. package/dist/storage/jsonl/index.js +2 -0
  178. package/dist/storage/jsonl/index.js.map +1 -0
  179. package/dist/storage/jsonl/node.d.ts +6 -0
  180. package/dist/storage/jsonl/node.d.ts.map +1 -0
  181. package/dist/storage/jsonl/node.js +8 -0
  182. package/dist/storage/jsonl/node.js.map +1 -0
  183. package/dist/storage/jsonl/storage.d.ts +143 -0
  184. package/dist/storage/jsonl/storage.d.ts.map +1 -0
  185. package/dist/storage/jsonl/storage.js +682 -0
  186. package/dist/storage/jsonl/storage.js.map +1 -0
  187. package/dist/storage/memory.d.ts +62 -0
  188. package/dist/storage/memory.d.ts.map +1 -0
  189. package/dist/storage/memory.js +733 -0
  190. package/dist/storage/memory.js.map +1 -0
  191. package/dist/storage/scan.d.ts +18 -0
  192. package/dist/storage/scan.d.ts.map +1 -0
  193. package/dist/storage/scan.js +29 -0
  194. package/dist/storage/scan.js.map +1 -0
  195. package/dist/storage/sqlite/cloudflare.d.ts +46 -0
  196. package/dist/storage/sqlite/cloudflare.d.ts.map +1 -0
  197. package/dist/storage/sqlite/cloudflare.js +106 -0
  198. package/dist/storage/sqlite/cloudflare.js.map +1 -0
  199. package/dist/storage/sqlite/database.d.ts +37 -0
  200. package/dist/storage/sqlite/database.d.ts.map +1 -0
  201. package/dist/storage/sqlite/database.js +2 -0
  202. package/dist/storage/sqlite/database.js.map +1 -0
  203. package/dist/storage/sqlite/index.d.ts +4 -0
  204. package/dist/storage/sqlite/index.d.ts.map +1 -0
  205. package/dist/storage/sqlite/index.js +3 -0
  206. package/dist/storage/sqlite/index.js.map +1 -0
  207. package/dist/storage/sqlite/migrations.d.ts +11 -0
  208. package/dist/storage/sqlite/migrations.d.ts.map +1 -0
  209. package/dist/storage/sqlite/migrations.js +111 -0
  210. package/dist/storage/sqlite/migrations.js.map +1 -0
  211. package/dist/storage/sqlite/node.d.ts +41 -0
  212. package/dist/storage/sqlite/node.d.ts.map +1 -0
  213. package/dist/storage/sqlite/node.js +179 -0
  214. package/dist/storage/sqlite/node.js.map +1 -0
  215. package/dist/storage/sqlite/storage.d.ts +65 -0
  216. package/dist/storage/sqlite/storage.d.ts.map +1 -0
  217. package/dist/storage/sqlite/storage.js +670 -0
  218. package/dist/storage/sqlite/storage.js.map +1 -0
  219. package/dist/tasks.d.ts +6 -0
  220. package/dist/tasks.d.ts.map +1 -0
  221. package/dist/tasks.js +5 -0
  222. package/dist/tasks.js.map +1 -0
  223. package/dist/testing/assertions.d.ts +5 -0
  224. package/dist/testing/assertions.d.ts.map +1 -0
  225. package/dist/testing/assertions.js +25 -0
  226. package/dist/testing/assertions.js.map +1 -0
  227. package/dist/testing/env-conformance.d.ts +7 -0
  228. package/dist/testing/env-conformance.d.ts.map +1 -0
  229. package/dist/testing/env-conformance.js +431 -0
  230. package/dist/testing/env-conformance.js.map +1 -0
  231. package/dist/testing/index.d.ts +7 -0
  232. package/dist/testing/index.d.ts.map +1 -0
  233. package/dist/testing/index.js +6 -0
  234. package/dist/testing/index.js.map +1 -0
  235. package/dist/testing/runner.d.ts +12 -0
  236. package/dist/testing/runner.d.ts.map +1 -0
  237. package/dist/testing/runner.js +23 -0
  238. package/dist/testing/runner.js.map +1 -0
  239. package/dist/testing/storage-benchmark.d.ts +41 -0
  240. package/dist/testing/storage-benchmark.d.ts.map +1 -0
  241. package/dist/testing/storage-benchmark.js +350 -0
  242. package/dist/testing/storage-benchmark.js.map +1 -0
  243. package/dist/testing/storage-conformance.d.ts +4 -0
  244. package/dist/testing/storage-conformance.d.ts.map +1 -0
  245. package/dist/testing/storage-conformance.js +1213 -0
  246. package/dist/testing/storage-conformance.js.map +1 -0
  247. package/dist/testing/types.d.ts +37 -0
  248. package/dist/testing/types.d.ts.map +1 -0
  249. package/dist/testing/types.js +2 -0
  250. package/dist/testing/types.js.map +1 -0
  251. package/dist/tools/bash.d.ts +55 -0
  252. package/dist/tools/bash.d.ts.map +1 -0
  253. package/dist/tools/bash.js +138 -0
  254. package/dist/tools/bash.js.map +1 -0
  255. package/dist/tools/edit-diff.d.ts +89 -0
  256. package/dist/tools/edit-diff.d.ts.map +1 -0
  257. package/dist/tools/edit-diff.js +386 -0
  258. package/dist/tools/edit-diff.js.map +1 -0
  259. package/dist/tools/edit.d.ts +18 -0
  260. package/dist/tools/edit.d.ts.map +1 -0
  261. package/dist/tools/edit.js +117 -0
  262. package/dist/tools/edit.js.map +1 -0
  263. package/dist/tools/env.d.ts +5 -0
  264. package/dist/tools/env.d.ts.map +1 -0
  265. package/dist/tools/env.js +7 -0
  266. package/dist/tools/env.js.map +1 -0
  267. package/dist/tools/file-mutation-queue.d.ts +9 -0
  268. package/dist/tools/file-mutation-queue.d.ts.map +1 -0
  269. package/dist/tools/file-mutation-queue.js +52 -0
  270. package/dist/tools/file-mutation-queue.js.map +1 -0
  271. package/dist/tools/image-processor.d.ts +47 -0
  272. package/dist/tools/image-processor.d.ts.map +1 -0
  273. package/dist/tools/image-processor.js +30 -0
  274. package/dist/tools/image-processor.js.map +1 -0
  275. package/dist/tools/image.d.ts +20 -0
  276. package/dist/tools/image.d.ts.map +1 -0
  277. package/dist/tools/image.js +178 -0
  278. package/dist/tools/image.js.map +1 -0
  279. package/dist/tools/index.d.ts +46 -0
  280. package/dist/tools/index.d.ts.map +1 -0
  281. package/dist/tools/index.js +23 -0
  282. package/dist/tools/index.js.map +1 -0
  283. package/dist/tools/path-utils.d.ts +5 -0
  284. package/dist/tools/path-utils.d.ts.map +1 -0
  285. package/dist/tools/path-utils.js +26 -0
  286. package/dist/tools/path-utils.js.map +1 -0
  287. package/dist/tools/read.d.ts +25 -0
  288. package/dist/tools/read.d.ts.map +1 -0
  289. package/dist/tools/read.js +235 -0
  290. package/dist/tools/read.js.map +1 -0
  291. package/dist/tools/write.d.ts +10 -0
  292. package/dist/tools/write.d.ts.map +1 -0
  293. package/dist/tools/write.js +31 -0
  294. package/dist/tools/write.js.map +1 -0
  295. package/dist/truncate.d.ts +64 -0
  296. package/dist/truncate.d.ts.map +1 -0
  297. package/dist/truncate.js +154 -0
  298. package/dist/truncate.js.map +1 -0
  299. package/dist/types.d.ts +932 -0
  300. package/dist/types.d.ts.map +1 -0
  301. package/dist/types.js +3 -0
  302. package/dist/types.js.map +1 -0
  303. package/package.json +140 -5
@@ -0,0 +1,1743 @@
1
+ import { copyJson } from "@ny4n/chord";
2
+ import { awaitWithContext, withAbortSignal } from "@ny4n/chord/context";
3
+ import { errorMessage } from "../errors.js";
4
+ import { agentHooks } from "./agent.js";
5
+ import { readContextFrom } from "./context.js";
6
+ import { closedError, scanAll, Waiters } from "./util.js";
7
+ const SCAN_PAGE_SIZE = 256;
8
+ /** Longest delay `setTimeout` supports; longer sleeps wait in several steps. */
9
+ const MAX_TIMER_DELAY = 2_147_483_647;
10
+ const LIVE_STATUSES = ["pending", "running", "waiting", "completing"];
11
+ function conversationNode(id) {
12
+ return `c${id}`;
13
+ }
14
+ function conversationOf(node) {
15
+ return Number(node.slice(1));
16
+ }
17
+ /** The node a task hangs from: its owner task, or its conversation. */
18
+ function parentNode(node) {
19
+ return node.owner ?? conversationNode(node.conversationId);
20
+ }
21
+ /**
22
+ * Durable task scheduler of one Harness.
23
+ *
24
+ * `#live` mirrors every committed non-terminal task record: pending, running, waiting, and completing. The synchronous
25
+ * commit listener updates it on the Session line, so code running on the line reads exactly the committed state from it.
26
+ *
27
+ * Tasks and conversations form one ownership tree (spec §5.5): a task's parent is its owner task, or its conversation;
28
+ * a conversation's parent is its owner task, if any. Walks up that tree decide what reaches one task: a cancelling
29
+ * owner, a scope. `#below` lists the tree downward around live work, so what lies below one node is a walk down from
30
+ * it: a task's live ordinary owned work, which holds its outcome as `completing` and delays its abort handler, a
31
+ * scope's tasks, a cascade's reach. No pass visits every live task; the sets below hold each pass's candidates.
32
+ *
33
+ * The ownership indexes are bounded by live work: an owner edge and an ended task's ownership fields are kept only while
34
+ * something is listed below them in `#below`, so only the chains above live tasks are known, whole. Work that appears
35
+ * later below a dropped node finds it unknown and loads its chain from storage again.
36
+ *
37
+ * Invariant: every task transition is decided and written by one callback serialized on the Session line. That covers
38
+ * reservation, marks, runtime commits, finalization, and the synchronous step before each phase, which applies the
39
+ * precedence rules and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the
40
+ * step that decides its end, so a runtime commit it queued either lands before that decision or is rejected.
41
+ */
42
+ export class TaskScheduler {
43
+ #session;
44
+ #storage;
45
+ #registry;
46
+ #models;
47
+ #agent;
48
+ #settings;
49
+ #env;
50
+ #now;
51
+ #report;
52
+ #settleOutcome;
53
+ #withdrawInputs;
54
+ #conversation;
55
+ #context;
56
+ #live = new Map();
57
+ /**
58
+ * Context range last read through a task runtime, per conversation: a later read, by any of its tasks, scans only
59
+ * newer entries. `idleSince` is the Harness time it was first seen idle. Derived and never persisted; dropped at the
60
+ * first idle check after `settings.contextRetentionMs` of idleness, and at close.
61
+ */
62
+ #contexts = new Map();
63
+ /** Timer for the earliest expiry of an idle context, only where timers can be unreferenced (see `#scheduleExpiry`). */
64
+ #expiry;
65
+ #invocations = new Map();
66
+ #taskWaiters = new Waiters();
67
+ /** Idle waiters by conversation; `undefined` waits for the whole Harness. */
68
+ #idleWaiters = new Waiters();
69
+ /** Definition whose migration failed per task; retried only once the registry resolves another definition. */
70
+ #failedMigrations = new Map();
71
+ /** Owner task of each known conversation, `null` when ownerless. */
72
+ #edges = new Map();
73
+ /** Ownership fields of the known terminal tasks, which walks pass through. */
74
+ #settled = new Map();
75
+ /**
76
+ * Ended tasks and conversations found unneeded, dropped by `#sweep()` at the end of the commit's publication unless
77
+ * needed again by then: a chain load installs nodes before the work below them is listed.
78
+ */
79
+ #dropQueue = new Set();
80
+ /**
81
+ * The tree downward around live work: each node's child nodes that are live tasks or have nodes listed below them.
82
+ * A conversation's parent is its owner task; ownerless ones with work are in `#roots`.
83
+ */
84
+ #below = new Map();
85
+ #roots = new Set();
86
+ /** Known tasks whose chain may not be loaded; every other known task's chain is, and stays, loaded. */
87
+ #unloaded = new Set();
88
+ /** Live tasks with cancellation intent, the sources of abort cascades (spec §5.4). */
89
+ #intent = new Set();
90
+ /** Live tasks that are not `completing`, the candidates of a reservation pass; ones found waiting leave it. */
91
+ #runnable = new Set();
92
+ /** `completing` tasks the next finalize pass checks: those that started holding, and holders of ended work. */
93
+ #finalizeChecks = new Set();
94
+ /** Live waiting tasks by each task in their `on`. */
95
+ #waiters = new Map();
96
+ /**
97
+ * `failFast` waiters the next reconcile checks for a failed task in `on`: at open, when they start waiting, and when
98
+ * one of their tasks fails.
99
+ */
100
+ #failFastChecks = new Set();
101
+ /**
102
+ * Live tasks with `abandonOnRestart` found at open, from an earlier Harness. The first reservation pass abort-marks
103
+ * them with reason `restart`, so nothing of them or below them runs a phase again.
104
+ */
105
+ #abandoned = new Set();
106
+ #reconcileScheduled = false;
107
+ #sweepScheduled = false;
108
+ #cascadePending = false;
109
+ #unsubscribeRegistry = () => { };
110
+ #enabled = false;
111
+ #closing = false;
112
+ #dirty = false;
113
+ #draining = false;
114
+ constructor(options) {
115
+ this.#session = options.session;
116
+ this.#storage = options.storage;
117
+ this.#registry = options.registry;
118
+ this.#models = options.models;
119
+ this.#agent = options.agent;
120
+ this.#settings = options.settings;
121
+ this.#env = options.env;
122
+ this.#now = options.now;
123
+ this.#report = options.report;
124
+ this.#settleOutcome = options.settleOutcome;
125
+ this.#withdrawInputs = options.withdrawInputs;
126
+ this.#conversation = options.conversation;
127
+ this.#context = options.context;
128
+ }
129
+ /** Load live tasks and change surviving `running` tasks back to `pending`. Dispatches nothing. */
130
+ async open(context) {
131
+ this.#session.observeCommits((publication) => this.#observe(publication));
132
+ this.#session.subscribeClose(() => this.#seal());
133
+ this.#unsubscribeRegistry = this.#registry.subscribe(() => this.#kick());
134
+ await this.#session.commitWith(async (tx) => {
135
+ // Every table read before the first write.
136
+ const scans = [];
137
+ for (const status of LIVE_STATUSES) {
138
+ scans.push(await scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor)));
139
+ }
140
+ for (const records of scans) {
141
+ for (const record of records) {
142
+ this.#track(undefined, record);
143
+ if (record.abandonOnRestart === true)
144
+ this.#abandoned.add(record.id);
145
+ if (record.state.status === "running") {
146
+ tx.setTask(withState(record, { status: "pending", checkpoint: record.state.checkpoint }));
147
+ }
148
+ if (record.state.status === "waiting" && record.state.policy === "failFast") {
149
+ this.#failFastChecks.add(record.id);
150
+ }
151
+ }
152
+ }
153
+ }, context);
154
+ // Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes.
155
+ this.#cascadePending = true;
156
+ this.#scheduleReconcile();
157
+ }
158
+ /** Enable scheduling. Idempotent; the kick does nothing once closing. */
159
+ resume() {
160
+ this.#enabled = true;
161
+ this.#kick();
162
+ }
163
+ /**
164
+ * Signal every invocation and wait for them; writes nothing. Close calls this after every close listener has run, so
165
+ * waits, watches, and streams a task holds end with the Session's reason, not as cancelled by its signal.
166
+ */
167
+ async join() {
168
+ for (const invocation of this.#invocations.values())
169
+ invocation.controller.abort();
170
+ await Promise.allSettled([...this.#invocations.values()].map((invocation) => invocation.done));
171
+ }
172
+ /**
173
+ * Commit the abort mark, or settle a task that no registered definition can take as `orphaned` when nothing it owns
174
+ * is live, then join the run invocation seen on the line; the commit listener signalled it. The abort invocation
175
+ * starts once the task's ordinary owned work is gone. A `completing` task is only marked. A request replaces a
176
+ * `restart` mark, so a task abandoned after a restart that waits for its definition is orphaned; with `keepRestart`,
177
+ * as for an owner's own cleanup, such a task keeps its mark and waits on.
178
+ */
179
+ async abort(id, context, keepRestart = false) {
180
+ const marked = await this.#session.commitWith(async (tx) => {
181
+ const current = await tx.task(id);
182
+ if (current === undefined)
183
+ throw new Error(`Task ${id} does not exist`);
184
+ if (current.state.status === "terminal")
185
+ return { result: "terminal" };
186
+ const invocation = this.#invocations.get(id);
187
+ // A restart-marked task has no run invocation; it only waits for its abort.
188
+ if (keepRestart && current.abortReason === "restart")
189
+ return { result: "marked" };
190
+ if (invocation === undefined && current.state.status !== "completing") {
191
+ await this.#loadScopes(false);
192
+ if (!this.#hasOwnedLive(id)) {
193
+ const resolution = this.#resolve(current, this.#registry.snapshot());
194
+ if (resolution.kind === "blocked") {
195
+ await this.#terminate(tx, current, { status: "orphaned", reason: resolution.reason });
196
+ return { result: "marked" };
197
+ }
198
+ }
199
+ }
200
+ if (!current.abortRequested || current.abortReason !== undefined)
201
+ tx.setTask(withAbortMark(current));
202
+ return { result: "marked", run: invocation?.mode === "run" ? invocation : undefined };
203
+ }, context);
204
+ // The commit listener signalled the run; join it. A run that ignores its signal can outlive a failed Session,
205
+ // which ends the wait instead.
206
+ if (marked.run !== undefined) {
207
+ await awaitWithContext(Promise.race([marked.run.done, this.#session.failed]), context);
208
+ }
209
+ return marked.result;
210
+ }
211
+ async waitForTask(id, context) {
212
+ // Check and register on the line so no terminal publication falls between them.
213
+ const found = await this.#session.readOnLine(async () => {
214
+ if (this.#closing)
215
+ throw closedError(this.#session);
216
+ if (this.#live.has(id))
217
+ return { promise: this.#taskWaiters.add(id, context) };
218
+ const record = await this.#storage.task(id, context);
219
+ if (record === undefined)
220
+ throw new Error(`Task ${id} does not exist`);
221
+ return { promise: Promise.resolve(record) };
222
+ });
223
+ return found.promise;
224
+ }
225
+ /**
226
+ * Resolve when ordinary traversal from the conversation, or from every ownerless conversation, reaches no live
227
+ * non-background task.
228
+ */
229
+ waitForIdle(conversationId, context) {
230
+ if (this.#closing)
231
+ return Promise.reject(closedError(this.#session));
232
+ if (this.#idle(conversationId))
233
+ return Promise.resolve();
234
+ this.#scheduleReconcile();
235
+ return this.#idleWaiters.add(conversationId, context);
236
+ }
237
+ /**
238
+ * `Conversation.abort()`: in one commit, withdraw the queued inputs and mark every live non-background task that
239
+ * ordinary traversal from the conversation reaches; resolves once the scope is idle. With `background`, traversal
240
+ * crosses background boundaries, and the wait also covers every task it reached.
241
+ */
242
+ async abortConversation(conversationId, background, context) {
243
+ const reached = await this.#session.commitWith(async (tx) => {
244
+ const queued = await this.#loadScopes(true);
245
+ const scope = { conversation: conversationId };
246
+ const reached = [];
247
+ const walk = this.#walkDown(conversationNode(conversationId), { crossBackground: background });
248
+ for (const { node, live } of walk) {
249
+ if (live === undefined || (node.background && !background))
250
+ continue;
251
+ reached.push(live.id);
252
+ if (!live.abortRequested || live.abortReason !== undefined)
253
+ tx.setTask(withAbortMark(live));
254
+ }
255
+ for (const id of queued) {
256
+ if (this.#inScope({ conversation: id }, scope, background) === true)
257
+ await this.#withdrawInputs(tx, id);
258
+ }
259
+ return reached;
260
+ }, context);
261
+ if (background)
262
+ for (const id of reached)
263
+ await this.waitForTask(id, context);
264
+ await this.waitForIdle(conversationId, context);
265
+ }
266
+ // ─── Scheduling ────────────────────────────────────────────────────────
267
+ #observe(publication) {
268
+ const updated = [];
269
+ const failed = [];
270
+ let changed = false;
271
+ // Edges first, so the tasks a commit creates in a new conversation hang from it.
272
+ for (const change of publication.changes) {
273
+ if (change.type === "conversation" && !this.#edges.has(change.value.id)) {
274
+ this.#setEdge(change.value.id, change.value.owner?.taskId ?? null);
275
+ }
276
+ }
277
+ for (const change of publication.changes) {
278
+ if (change.type !== "task")
279
+ continue;
280
+ changed = true;
281
+ const record = change.value;
282
+ const previous = this.#live.get(record.id);
283
+ if (failedOutcome(record) && (previous === undefined || !failedOutcome(previous)))
284
+ failed.push(record.id);
285
+ this.#track(previous, record);
286
+ if (record.state.status === "terminal") {
287
+ this.#failedMigrations.delete(record.id);
288
+ this.#failFastChecks.delete(record.id);
289
+ this.#taskWaiters.resolve(record.id, record);
290
+ // Its owner may finalize now.
291
+ this.#scheduleReconcile();
292
+ continue;
293
+ }
294
+ if (record.abortRequested && previous?.abortRequested !== true) {
295
+ this.#cascadePending = true;
296
+ // Signal a run invocation of the newly marked task; its next step ends it.
297
+ const invocation = this.#invocations.get(record.id);
298
+ if (invocation?.mode === "run")
299
+ invocation.controller.abort();
300
+ }
301
+ // A request replacing a `restart` mark upgrades the marks of the work below.
302
+ if (previous?.abortReason !== undefined && record.abortReason === undefined)
303
+ this.#cascadePending = true;
304
+ const status = record.state.status;
305
+ if (status === "completing" && previous?.state.status !== "completing") {
306
+ if (cancellationIntent(record))
307
+ this.#cascadePending = true;
308
+ this.#scheduleReconcile();
309
+ }
310
+ if (status === "waiting" && record.state.policy === "failFast" && previous?.state.status !== "waiting") {
311
+ this.#failFastChecks.add(record.id);
312
+ this.#scheduleReconcile();
313
+ }
314
+ // Its owners and place never change: what is above it is checked once, when it becomes live.
315
+ if (previous === undefined)
316
+ updated.push(record);
317
+ }
318
+ for (const id of failed) {
319
+ for (const waiter of this.#waiters.get(id) ?? []) {
320
+ const state = this.#live.get(waiter)?.state;
321
+ if (state?.status === "waiting" && state.policy === "failFast") {
322
+ this.#failFastChecks.add(waiter);
323
+ this.#scheduleReconcile();
324
+ }
325
+ }
326
+ }
327
+ for (const change of publication.changes) {
328
+ // A queued input below a cancelled owner is withdrawn, even after its cascade.
329
+ if (change.type !== "submission" || change.value.status !== "queued" || change.value.type !== "input")
330
+ continue;
331
+ // Only an owner with cancellation intent withdraws it; a later intent's mark runs the cascade then.
332
+ if (this.#intent.size === 0)
333
+ continue;
334
+ const conversation = change.value.conversationId;
335
+ if (!this.#known(conversationNode(conversation)) || this.#belowCancelled({ conversation })) {
336
+ this.#cascadePending = true;
337
+ }
338
+ }
339
+ for (const record of updated) {
340
+ // Work created below a cancelled owner, even after its cascade, is aborted too.
341
+ if (this.#unloaded.has(record.id))
342
+ this.#scheduleReconcile();
343
+ else if (!record.background && !record.abortRequested && this.#belowCancelled(parentOf(record))) {
344
+ this.#cascadePending = true;
345
+ }
346
+ }
347
+ // A cascade that a reservation or step found pending runs with the next commit of any kind.
348
+ if (this.#cascadePending)
349
+ this.#scheduleReconcile();
350
+ this.#sweep();
351
+ if (!changed)
352
+ return;
353
+ this.#settleIdle();
354
+ this.#kick();
355
+ }
356
+ /**
357
+ * `settings.contextRetentionMs`, or 0 when the host's settings throw. Kept contexts are only a cache, so dropping them
358
+ * is safe, while a throw here would escape commit listeners, reconciliation, and the expiry timer.
359
+ */
360
+ #contextRetentionMs() {
361
+ try {
362
+ return this.#settings().contextRetentionMs;
363
+ }
364
+ catch (error) {
365
+ this.#report(error);
366
+ return 0;
367
+ }
368
+ }
369
+ /** Resolve idle waiters, and drop each kept context whose conversation has been idle for the retention period. */
370
+ #settleIdle() {
371
+ for (const conversationId of this.#idleWaiters.keys()) {
372
+ if (this.#idle(conversationId))
373
+ this.#idleWaiters.resolve(conversationId);
374
+ }
375
+ const now = this.#now();
376
+ const retention = this.#contextRetentionMs();
377
+ for (const [conversationId, kept] of this.#contexts) {
378
+ if (kept.idleSince !== undefined && now - kept.idleSince >= retention) {
379
+ this.#contexts.delete(conversationId);
380
+ }
381
+ else if (!this.#idle(conversationId)) {
382
+ kept.idleSince = undefined;
383
+ }
384
+ else if (retention > 0) {
385
+ kept.idleSince ??= now;
386
+ }
387
+ else {
388
+ this.#contexts.delete(conversationId);
389
+ }
390
+ }
391
+ this.#scheduleExpiry();
392
+ }
393
+ /**
394
+ * Run `#settleIdle()` when the earliest idle context expires. The timer is unreferenced, so it never keeps the process
395
+ * alive. Where timers cannot be unreferenced, as in Cloudflare Workers, none is kept: a pending timer could keep a
396
+ * Durable Object from being evicted, and eviction frees the contexts. There, task changes alone check expiry.
397
+ */
398
+ #scheduleExpiry() {
399
+ const retention = this.#contextRetentionMs();
400
+ let at;
401
+ for (const kept of this.#contexts.values()) {
402
+ if (kept.idleSince !== undefined && (at === undefined || kept.idleSince + retention < at)) {
403
+ at = kept.idleSince + retention;
404
+ }
405
+ }
406
+ if (this.#expiry !== undefined && this.#expiry.at === at)
407
+ return;
408
+ if (this.#expiry !== undefined)
409
+ clearTimeout(this.#expiry.timer);
410
+ this.#expiry = undefined;
411
+ if (at === undefined || this.#closing)
412
+ return;
413
+ const timer = setTimeout(() => {
414
+ this.#expiry = undefined;
415
+ this.#settleIdle();
416
+ }, Math.min(Math.max(0, at - this.#now()), MAX_TIMER_DELAY));
417
+ const unref = timer.unref;
418
+ if (typeof unref !== "function") {
419
+ clearTimeout(timer);
420
+ return;
421
+ }
422
+ unref.call(timer);
423
+ this.#expiry = { at, timer };
424
+ }
425
+ // ─── Ownership ───────────────────────────────────────────────────────────
426
+ #scheduleReconcile() {
427
+ if (this.#reconcileScheduled || this.#closing)
428
+ return;
429
+ this.#reconcileScheduled = true;
430
+ queueMicrotask(() => void this.#reconcile());
431
+ }
432
+ /**
433
+ * One commit that applies what committed records imply: abort marks below live owners with cancellation intent
434
+ * (spec §5.4), `failFast` marks (spec §5.5), withdrawn queued inputs below cancelled owners, and the final terminal
435
+ * record of every `completing` task whose ordinary owned work is gone. The durable records are the intent, so this
436
+ * also repairs whatever a crash left unapplied. Resolves idle waiters that the loaded edges decide.
437
+ */
438
+ async #reconcile() {
439
+ this.#reconcileScheduled = false;
440
+ const cascade = this.#cascadePending;
441
+ this.#cascadePending = false;
442
+ const checks = [...this.#failFastChecks];
443
+ this.#failFastChecks.clear();
444
+ try {
445
+ await this.#session.commitWith(async (tx) => {
446
+ if (this.#closing)
447
+ return;
448
+ const queued = await this.#loadScopes(cascade);
449
+ // A cascade from an abandoned owner passes its `restart` reason on; any other intent marks, or upgrades a
450
+ // `restart` mark, as a request. Collected first, so a request wins over a `restart` mark of the same pass.
451
+ const marks = new Map();
452
+ const mark = (record, reason) => {
453
+ const staged = marks.get(record.id);
454
+ if (staged !== undefined && staged.reason === undefined)
455
+ return;
456
+ if (record.abortRequested && (record.abortReason === undefined || reason !== undefined))
457
+ return;
458
+ marks.set(record.id, { record, reason });
459
+ };
460
+ // Every live foreground task below a live owner with cancellation intent, which is its nearest one: a walk down
461
+ // from each owner with intent does not enter another, whose own walk covers what is below it. Derived again on
462
+ // every pass, as loading edges can reveal a cancelled owner.
463
+ for (const id of this.#intent) {
464
+ const owner = this.#live.get(id);
465
+ // Only an owner abandoned after a restart, and nothing else, passes its reason on.
466
+ const restart = owner.abortReason === "restart" && !failedOutcome(owner);
467
+ for (const { node, live } of this.#walkDown(id, { enter: (below) => !this.#intent.has(below) })) {
468
+ if (live !== undefined && !node.background)
469
+ mark(live, restart ? "restart" : undefined);
470
+ }
471
+ }
472
+ for (const id of checks) {
473
+ const waiter = this.#live.get(id);
474
+ if (waiter?.state.status !== "waiting" || !(await this.#anyFailed(waiter.state.on)))
475
+ continue;
476
+ // Every other live task: the failed one keeps its own outcome.
477
+ for (const member of waiter.state.on) {
478
+ const record = this.#live.get(member);
479
+ if (record !== undefined && !failedOutcome(record))
480
+ mark(record, undefined);
481
+ }
482
+ }
483
+ for (const { record, reason } of marks.values())
484
+ tx.setTask(withAbortMark(record, reason));
485
+ for (const id of queued)
486
+ if (this.#belowCancelled({ conversation: id }))
487
+ await this.#withdrawInputs(tx, id);
488
+ await this.#finalize(tx);
489
+ }, this.#context);
490
+ }
491
+ catch (error) {
492
+ // No extension code runs in this commit: a throw is a storage failure, a host callback, or a bug. None is fixed by
493
+ // running the pass again, so it fails the Session, which reports it.
494
+ this.#failSession(error);
495
+ }
496
+ this.#settleIdle();
497
+ }
498
+ /** Whether any of `ids` holds or ended with an outcome other than `completed`. */
499
+ async #anyFailed(ids) {
500
+ for (const id of ids) {
501
+ const record = this.#live.get(id) ?? (await this.#storage.task(id, this.#context));
502
+ if (record !== undefined && failedOutcome(record))
503
+ return true;
504
+ }
505
+ return false;
506
+ }
507
+ /**
508
+ * Write the terminal record of every `completing` task without live ordinary owned work. Finalizing one can free its
509
+ * owner, so this repeats over the commit's candidates until nothing changes. A held scheduler outcome gets its
510
+ * Harness cleanup here.
511
+ */
512
+ async #finalize(tx) {
513
+ // A worklist: the tasks that started holding, and the holders of work that ended. Finalizing one can free the
514
+ // nearest live task above it, which goes on the list. Tasks finalized here have no live work below them, so walks
515
+ // down skip them.
516
+ // In rounds: the holders the tasks of one round free are checked once, in the next, so a holder of many tasks that
517
+ // end together is walked once, not once per task.
518
+ let round = [...this.#finalizeChecks];
519
+ this.#finalizeChecks.clear();
520
+ const done = new Set();
521
+ const overlay = overlayOf(tx);
522
+ while (round.length > 0) {
523
+ const next = new Set();
524
+ for (const id of round) {
525
+ const record = overlay.tasks.get(id) ?? this.#live.get(id);
526
+ if (record?.state.status !== "completing" || done.has(id))
527
+ continue;
528
+ if (this.#hasOwnedLive(id, overlay, done))
529
+ continue;
530
+ const outcome = record.state.outcome;
531
+ const terminal = withState(record, { status: "terminal", outcome });
532
+ tx.setTask(terminal);
533
+ overlay.tasks.set(id, terminal);
534
+ done.add(id);
535
+ // REMINDER: only the scheduler writes `faulted` and `orphaned` (spec §5.4); their cleanup waits for this commit.
536
+ if (outcome.status === "faulted" || outcome.status === "orphaned") {
537
+ await this.#settleOutcome(tx, record, outcome);
538
+ }
539
+ const above = this.#nearestLiveAbove(record, overlay);
540
+ if (above?.state.status === "completing")
541
+ next.add(above.id);
542
+ }
543
+ round = [...next];
544
+ }
545
+ }
546
+ /**
547
+ * Load the owner chains of the tasks in `#unloaded` and, with `queued`, of every conversation with queued inputs,
548
+ * on the Session line; returns the latter. Reads committed Storage directly, so it may run inside a commit callback.
549
+ */
550
+ async #loadScopes(queued) {
551
+ for (const id of [...this.#unloaded]) {
552
+ // An earlier load in this loop may have passed it.
553
+ if (this.#unloaded.has(id))
554
+ await this.#loadChain({ task: id });
555
+ }
556
+ if (!queued)
557
+ return [];
558
+ const submissions = await scanAll((cursor) => this.#storage.scanSubmissions({ status: "queued" }, SCAN_PAGE_SIZE, cursor, this.#context));
559
+ const inputs = submissions.filter((submission) => submission.type === "input");
560
+ const conversations = [...new Set(inputs.map((submission) => submission.conversationId))];
561
+ for (const id of conversations)
562
+ await this.#loadChain({ conversation: id });
563
+ return conversations;
564
+ }
565
+ /**
566
+ * Load the owner edges and task nodes from `start` up to its ownerless root. A known task outside `#unloaded` ends the
567
+ * walk, as its chain is loaded; the `#unloaded` tasks passed have a loaded chain afterwards. Loading a node can reveal
568
+ * a cancelled owner, so it makes the next reconcile derive cascade marks.
569
+ */
570
+ async #loadChain(start, overlay) {
571
+ const passed = [];
572
+ let at = start;
573
+ while (at !== undefined) {
574
+ if ("task" in at) {
575
+ const id = at.task;
576
+ let node = this.#live.get(id) ?? this.#settled.get(id);
577
+ // Created in this commit: the load from its own record covers what is above it.
578
+ if (node === undefined && overlay?.tasks.has(id) === true)
579
+ break;
580
+ if (node !== undefined && !this.#unloaded.has(id))
581
+ break;
582
+ if (node === undefined) {
583
+ const record = await this.#storage.task(id, this.#context);
584
+ if (record === undefined)
585
+ return;
586
+ node = nodeOf(record);
587
+ if (record.state.status === "terminal")
588
+ this.#settle(id, node);
589
+ // A loaded node can reveal a cancelling owner: the next reconcile derives marks again.
590
+ this.#scheduleReconcile();
591
+ }
592
+ passed.push(id);
593
+ at = parentOf(node);
594
+ }
595
+ else {
596
+ let edge = this.#edge(at.conversation, overlay);
597
+ if (edge === undefined) {
598
+ const record = await this.#storage.conversation(at.conversation, this.#context);
599
+ edge = record?.owner?.taskId ?? null;
600
+ this.#setEdge(at.conversation, edge);
601
+ // A loaded node can reveal a cancelling owner: the next reconcile derives marks again.
602
+ this.#scheduleReconcile();
603
+ }
604
+ at = edge === null ? undefined : { task: edge };
605
+ }
606
+ }
607
+ for (const id of passed)
608
+ this.#unloaded.delete(id);
609
+ }
610
+ #setEdge(conversationId, owner) {
611
+ this.#edges.set(conversationId, owner);
612
+ // The conversation now hangs from its owner, or is a root; with nothing below it, it waits for the sweep.
613
+ this.#relist(conversationNode(conversationId));
614
+ this.#dropIfUnneeded(conversationNode(conversationId));
615
+ }
616
+ /** Know a terminal task a chain load read, so walks pass through it; its own chain is not loaded yet. */
617
+ #settle(id, node) {
618
+ this.#settled.set(id, node);
619
+ this.#unloaded.add(id);
620
+ this.#relist(id);
621
+ this.#dropIfUnneeded(id);
622
+ }
623
+ /** Queue an ended task or a conversation for the sweep when nothing is listed below it. */
624
+ #dropIfUnneeded(node) {
625
+ if (typeof node !== "string" && this.#live.has(node))
626
+ return;
627
+ if (this.#below.has(node))
628
+ return;
629
+ this.#dropQueue.add(node);
630
+ this.#scheduleSweep();
631
+ }
632
+ /**
633
+ * Sweep in a job of its own on the Session line, after the operation underway: a rejected commit, one with nothing to
634
+ * write, and a read publish nothing, so the sweep at the end of `#observe` would not run for what they loaded.
635
+ */
636
+ #scheduleSweep() {
637
+ if (this.#sweepScheduled || this.#closing)
638
+ return;
639
+ this.#sweepScheduled = true;
640
+ this.#session
641
+ .readOnLine(async () => {
642
+ this.#sweepScheduled = false;
643
+ this.#sweep();
644
+ })
645
+ // Rejected only once the Session is closing or failed, when nothing is kept anyway.
646
+ .catch(() => { });
647
+ }
648
+ /**
649
+ * Drop the queued nodes that are still unneeded: not live, nothing listed below. Runs once a publication is fully
650
+ * tracked, and as a job of its own on the line, never inside an operation: work a commit creates below a chain its
651
+ * own callback loaded keeps that chain. A flat loop: unlisting already queued every node on the way up that lost its
652
+ * last listed node.
653
+ */
654
+ #sweep() {
655
+ for (const node of this.#dropQueue) {
656
+ if (this.#below.has(node))
657
+ continue;
658
+ if (typeof node === "string") {
659
+ this.#edges.delete(conversationOf(node));
660
+ this.#roots.delete(conversationOf(node));
661
+ }
662
+ else if (!this.#live.has(node)) {
663
+ this.#settled.delete(node);
664
+ this.#unloaded.delete(node);
665
+ }
666
+ }
667
+ this.#dropQueue.clear();
668
+ }
669
+ /**
670
+ * Bring the scheduler's sets in line with a task's committed record. A task that ended may free the nearest live task
671
+ * above it: one holding its outcome finalizes, an abort-marked one runs its abort handler; its waiters may run.
672
+ */
673
+ #track(previous, record) {
674
+ const id = record.id;
675
+ if (previous?.state.status === "waiting")
676
+ for (const on of previous.state.on)
677
+ deleteFrom(this.#waiters, on, id);
678
+ if (record.state.status === "terminal") {
679
+ const node = nodeOf(record);
680
+ this.#live.delete(id);
681
+ this.#intent.delete(id);
682
+ this.#runnable.delete(id);
683
+ this.#finalizeChecks.delete(id);
684
+ this.#settled.set(id, node);
685
+ // Before anything is forgotten: the walk up to the task it held passes the ended tasks between them. Its chain is
686
+ // loaded, as every commit that ends a task loads the chains of `#unloaded` first.
687
+ this.#release(node);
688
+ for (const waiter of this.#waiters.get(id) ?? [])
689
+ this.#runnable.add(waiter);
690
+ this.#relist(id);
691
+ this.#dropIfUnneeded(id);
692
+ return;
693
+ }
694
+ this.#live.set(id, record);
695
+ if (previous === undefined) {
696
+ if (!this.#known(parentNode(record)))
697
+ this.#unloaded.add(id);
698
+ this.#relist(id);
699
+ }
700
+ if (cancellationIntent(record))
701
+ this.#intent.add(id);
702
+ else
703
+ this.#intent.delete(id);
704
+ if (record.state.status === "completing") {
705
+ this.#runnable.delete(id);
706
+ this.#finalizeChecks.add(id);
707
+ }
708
+ else {
709
+ this.#runnable.add(id);
710
+ }
711
+ if (record.state.status === "waiting")
712
+ for (const on of record.state.on)
713
+ addTo(this.#waiters, on, id);
714
+ }
715
+ /** The nearest live task above ended ordinary work may be free now: one holding its outcome, or an aborting one. */
716
+ #release(node, overlay) {
717
+ const above = this.#nearestLiveAbove(node, overlay);
718
+ if (above?.state.status === "completing")
719
+ this.#finalizeChecks.add(above.id);
720
+ else if (above?.abortRequested === true)
721
+ this.#runnable.add(above.id);
722
+ return above;
723
+ }
724
+ /**
725
+ * List `start` under its parent node in `#below` while it is a live task or has nodes listed below it, and unlist it
726
+ * otherwise; then its parent, as far up as that changes. So `#below` holds exactly the tree around live work; a
727
+ * parent that loses its last listed node is queued for the sweep. A loop, as ownership chains can be deeper than the
728
+ * call stack.
729
+ */
730
+ #relist(start) {
731
+ let node = start;
732
+ for (;;) {
733
+ const needed = (typeof node !== "string" && this.#live.has(node)) || this.#below.has(node);
734
+ let parent;
735
+ if (typeof node === "string") {
736
+ parent = this.#edges.get(conversationOf(node));
737
+ }
738
+ else {
739
+ const known = this.#live.get(node) ?? this.#settled.get(node);
740
+ if (known === undefined)
741
+ return;
742
+ parent = parentNode(known);
743
+ }
744
+ if (parent === undefined)
745
+ return;
746
+ if (parent === null) {
747
+ const root = conversationOf(node);
748
+ if (needed)
749
+ this.#roots.add(root);
750
+ else
751
+ this.#roots.delete(root);
752
+ return;
753
+ }
754
+ if ((this.#below.get(parent)?.has(node) === true) === needed)
755
+ return;
756
+ if (needed) {
757
+ const first = !this.#below.has(parent);
758
+ addTo(this.#below, parent, node);
759
+ if (!first)
760
+ return;
761
+ }
762
+ else {
763
+ deleteFrom(this.#below, parent, node);
764
+ if (this.#below.has(parent))
765
+ return;
766
+ this.#dropIfUnneeded(parent);
767
+ }
768
+ node = parent;
769
+ }
770
+ }
771
+ /**
772
+ * The nearest live task above ended ordinary work `node`, in the overlay's view: the one that work held, if any. Work
773
+ * at or below a background task holds nothing above it.
774
+ */
775
+ #nearestLiveAbove(node, overlay) {
776
+ for (let current = node; !current.background;) {
777
+ const owner = current.owner ?? this.#edge(current.conversationId, overlay);
778
+ if (owner === undefined || owner === null)
779
+ return undefined;
780
+ const record = overlay?.tasks.get(owner) ?? this.#live.get(owner);
781
+ if (record !== undefined && record.state.status !== "terminal")
782
+ return record;
783
+ const above = record ?? this.#settled.get(owner);
784
+ if (above === undefined)
785
+ return undefined;
786
+ current = above;
787
+ }
788
+ return undefined;
789
+ }
790
+ /** Whether the chain above `node` is loaded, without walking: known tasks outside `#unloaded` have a loaded chain. */
791
+ #known(node) {
792
+ if (typeof node !== "string") {
793
+ return (this.#live.has(node) || this.#settled.has(node)) && !this.#unloaded.has(node);
794
+ }
795
+ const owner = this.#edges.get(conversationOf(node));
796
+ return owner === null || (owner !== undefined && this.#known(owner));
797
+ }
798
+ /** Owner task of a conversation, `null` when ownerless, `undefined` while not loaded. */
799
+ #edge(id, overlay) {
800
+ return overlay?.edges.has(id) === true ? overlay.edges.get(id) : this.#edges.get(id);
801
+ }
802
+ #node(id, overlay) {
803
+ return overlay?.tasks.get(id) ?? this.#live.get(id) ?? this.#settled.get(id);
804
+ }
805
+ /** Walk up from `start`: owner tasks and conversations, ending at an ownerless root or an edge not loaded yet. */
806
+ *#above(start, overlay) {
807
+ let at = start;
808
+ while (at !== undefined) {
809
+ if ("task" in at) {
810
+ const node = this.#node(at.task, overlay);
811
+ if (node === undefined) {
812
+ yield { unknown: true };
813
+ return;
814
+ }
815
+ yield { task: at.task, node };
816
+ at = parentOf(node);
817
+ }
818
+ else {
819
+ yield { conversation: at.conversation };
820
+ const edge = this.#edge(at.conversation, overlay);
821
+ if (edge === undefined) {
822
+ yield { unknown: true };
823
+ return;
824
+ }
825
+ at = edge === null ? undefined : { task: edge };
826
+ }
827
+ }
828
+ }
829
+ /**
830
+ * The tasks below `start` that a walk down reaches: those `#below` lists and, under an overlay, the commit's new tasks,
831
+ * with its candidates in place of committed records. Background tasks are reached but not entered, unless
832
+ * `crossBackground`, and neither are the tasks `enter` rejects. A loop, as chains can be deeper than the call stack.
833
+ */
834
+ *#walkDown(start, options = {}) {
835
+ const { overlay, crossBackground = false, enter } = options;
836
+ const links = overlay === undefined ? undefined : this.#links(overlay);
837
+ const pending = [start];
838
+ for (let at = pending.pop(); at !== undefined; at = pending.pop()) {
839
+ for (const children of [this.#below.get(at), links?.get(at)]) {
840
+ for (const child of children ?? []) {
841
+ if (typeof child === "string") {
842
+ pending.push(child);
843
+ continue;
844
+ }
845
+ const record = overlay?.tasks.get(child) ?? this.#live.get(child);
846
+ const node = record ?? this.#settled.get(child);
847
+ if (node === undefined)
848
+ continue;
849
+ const live = record !== undefined && record.state.status !== "terminal" ? record : undefined;
850
+ yield { id: child, node, live };
851
+ if ((crossBackground || !node.background) && (enter === undefined || enter(child)))
852
+ pending.push(child);
853
+ }
854
+ }
855
+ }
856
+ }
857
+ /**
858
+ * The links walks down need for the commit's new tasks: each one under its parent node, then each node above it that
859
+ * `#below` does not list yet, until one it lists, a live task (always listed), or the chain's end.
860
+ */
861
+ #links(overlay) {
862
+ if (overlay.links !== undefined)
863
+ return overlay.links;
864
+ const links = new Map();
865
+ for (const record of overlay.tasks.values()) {
866
+ if (this.#live.has(record.id) || this.#settled.has(record.id))
867
+ continue;
868
+ let child = record.id;
869
+ let parent = parentNode(record);
870
+ while (parent !== undefined && parent !== null && links.get(parent)?.has(child) !== true) {
871
+ addTo(links, parent, child);
872
+ if (typeof parent !== "string" && (this.#live.has(parent) || overlay.tasks.has(parent)))
873
+ break;
874
+ let above;
875
+ if (typeof parent === "string") {
876
+ above = this.#edge(conversationOf(parent), overlay);
877
+ }
878
+ else {
879
+ const fields = this.#settled.get(parent);
880
+ above = fields === undefined ? undefined : parentNode(fields);
881
+ }
882
+ if (above !== undefined && above !== null && this.#below.get(above)?.has(parent) === true)
883
+ break;
884
+ child = parent;
885
+ parent = above;
886
+ }
887
+ }
888
+ overlay.links = links;
889
+ return links;
890
+ }
891
+ /**
892
+ * The live ordinary owned work of `owner` (spec §5.5): the live non-background tasks below it that a walk down reaches
893
+ * without entering a background task; with `first`, only the first. Candidates in `overlay` replace committed records;
894
+ * the walk does not enter tasks in `done`, which have none. Owner chains must be loaded.
895
+ */
896
+ #ownedLive(owner, options = {}) {
897
+ const { overlay, first = false, done } = options;
898
+ const found = [];
899
+ const walk = this.#walkDown(owner, {
900
+ ...(overlay === undefined ? {} : { overlay }),
901
+ ...(done === undefined ? {} : { enter: (id) => !done.has(id) }),
902
+ });
903
+ for (const { id, node, live } of walk) {
904
+ if (live === undefined || node.background)
905
+ continue;
906
+ found.push(id);
907
+ if (first)
908
+ break;
909
+ }
910
+ return found;
911
+ }
912
+ #hasOwnedLive(owner, overlay, done) {
913
+ const options = {
914
+ first: true,
915
+ ...(overlay === undefined ? {} : { overlay }),
916
+ ...(done === undefined ? {} : { done }),
917
+ };
918
+ return this.#ownedLive(owner, options).length > 0;
919
+ }
920
+ /**
921
+ * Whether ordinary traversal from `scope` reaches `start`: walking up reaches the scope's conversation, or an
922
+ * ownerless one for `roots`, without crossing a background owner, unless `crossBackground`. `undefined` while an
923
+ * edge is not loaded.
924
+ */
925
+ #inScope(start, scope, crossBackground = false) {
926
+ for (const step of this.#above(start)) {
927
+ if ("unknown" in step)
928
+ return undefined;
929
+ if ("conversation" in step) {
930
+ if ("conversation" in scope && step.conversation === scope.conversation)
931
+ return true;
932
+ }
933
+ else if (step.node.background && !crossBackground) {
934
+ return false;
935
+ }
936
+ }
937
+ return "roots" in scope;
938
+ }
939
+ /**
940
+ * Whether a live owner's cancellation intent reaches `start`: walking up finds an owner with intent before a
941
+ * background owner without it. Terminal owners never cascade (spec §5.4).
942
+ */
943
+ #belowCancelled(start) {
944
+ return this.#cancellingOwner(start) !== undefined;
945
+ }
946
+ /** The nearest live owner above `start` whose cancellation intent reaches it, if any; see `#belowCancelled`. */
947
+ #cancellingOwner(start) {
948
+ // Nothing cancels, the common case: no walk.
949
+ if (this.#intent.size === 0)
950
+ return undefined;
951
+ for (const step of this.#above(start)) {
952
+ if ("unknown" in step)
953
+ return undefined;
954
+ if (!("task" in step))
955
+ continue;
956
+ const live = this.#live.get(step.task);
957
+ if (live !== undefined && cancellationIntent(live))
958
+ return live;
959
+ if (step.node.background)
960
+ return undefined;
961
+ }
962
+ return undefined;
963
+ }
964
+ /** Fail the Session, which reports it, for a throw in one of the scheduler's own commits; ignored once closing. */
965
+ #failSession(error) {
966
+ if (!this.#closing)
967
+ this.#session.fail(error);
968
+ }
969
+ /** Close listener: runs synchronously once admission is sealed, before `join()`, or when the Session fails. */
970
+ #seal() {
971
+ this.#closing = true;
972
+ try {
973
+ this.#unsubscribeRegistry();
974
+ }
975
+ catch (error) {
976
+ this.#report(error);
977
+ }
978
+ const error = closedError(this.#session);
979
+ this.#taskWaiters.rejectAll(error);
980
+ this.#idleWaiters.rejectAll(error);
981
+ this.#contexts.clear();
982
+ if (this.#expiry !== undefined)
983
+ clearTimeout(this.#expiry.timer);
984
+ this.#expiry = undefined;
985
+ }
986
+ #kick() {
987
+ this.#dirty = true;
988
+ if (this.#draining || !this.#enabled || this.#closing)
989
+ return;
990
+ this.#draining = true;
991
+ // Never commit synchronously from a commit or registry listener.
992
+ queueMicrotask(() => void this.#drain());
993
+ }
994
+ async #drain() {
995
+ try {
996
+ while (this.#dirty && this.#enabled && !this.#closing) {
997
+ this.#dirty = false;
998
+ for (const reservation of await this.#reserve())
999
+ this.#start(reservation);
1000
+ }
1001
+ }
1002
+ catch (error) {
1003
+ // As in `#reconcile()`: the reservation commit runs no extension code.
1004
+ this.#failSession(error);
1005
+ }
1006
+ finally {
1007
+ this.#draining = false;
1008
+ // A wakeup that arrived after the loop's last check still needs its pass.
1009
+ if (this.#dirty)
1010
+ this.#kick();
1011
+ }
1012
+ }
1013
+ /**
1014
+ * Reserve every eligible task in one commit; orphan abort-marked tasks no definition can take, unless abandoned after
1015
+ * a restart. The first pass after open only abort-marks the abandoned tasks, so later passes see the marks.
1016
+ */
1017
+ async #reserve() {
1018
+ const reservations = [];
1019
+ let abandoned = [];
1020
+ try {
1021
+ await this.#session.commitWith(async (tx) => {
1022
+ if (!this.#enabled || this.#closing)
1023
+ return;
1024
+ for (const id of this.#abandoned) {
1025
+ const record = this.#live.get(id);
1026
+ if (record !== undefined && !record.abortRequested)
1027
+ tx.setTask(withAbortMark(record, "restart"));
1028
+ }
1029
+ if (this.#abandoned.size > 0) {
1030
+ abandoned = [...this.#abandoned];
1031
+ return;
1032
+ }
1033
+ await this.#loadScopes(false);
1034
+ // Taken once per pass, and only when some task is a candidate.
1035
+ let snapshot;
1036
+ for (const id of [...this.#runnable]) {
1037
+ const record = this.#live.get(id);
1038
+ if (record === undefined || record.state.status === "completing" || this.#invocations.has(id)) {
1039
+ this.#runnable.delete(id);
1040
+ continue;
1041
+ }
1042
+ // It returns once what it waits for ends, its record changes, or the work below it ends.
1043
+ if (this.#waitingOn(record, true).length > 0) {
1044
+ this.#runnable.delete(id);
1045
+ continue;
1046
+ }
1047
+ const runnable = record;
1048
+ const mode = record.abortRequested ? "abort" : "run";
1049
+ // Work below an owner with cancellation intent waits for its cascade mark instead of running a phase; schedule
1050
+ // the cascade, which may not have run yet.
1051
+ if (mode === "run" && !record.background && this.#belowCancelled(parentOf(record))) {
1052
+ this.#cascadePending = true;
1053
+ this.#scheduleReconcile();
1054
+ continue;
1055
+ }
1056
+ snapshot ??= this.#registry.snapshot();
1057
+ const resolution = this.#resolve(runnable, snapshot);
1058
+ if (resolution.kind === "blocked") {
1059
+ // An abandoned task waits for its definition, so its abort handler can clean up.
1060
+ if (mode === "abort" && record.abortReason === undefined) {
1061
+ await this.#terminate(tx, record, { status: "orphaned", reason: resolution.reason });
1062
+ }
1063
+ continue;
1064
+ }
1065
+ if (resolution.record !== runnable || record.state.status !== "running") {
1066
+ tx.setTask(withState(resolution.record, {
1067
+ status: "running",
1068
+ checkpoint: resolution.record.state.checkpoint,
1069
+ }));
1070
+ }
1071
+ // Registered on the line, so marks and later reservations see it and close joins it; `#end` makes it a
1072
+ // candidate again.
1073
+ this.#runnable.delete(id);
1074
+ const invocation = this.#createInvocation(record, mode);
1075
+ reservations.push({ invocation, task: resolution.task, snapshot });
1076
+ }
1077
+ }, this.#context);
1078
+ }
1079
+ catch (error) {
1080
+ for (const { invocation } of reservations) {
1081
+ this.#invocations.delete(invocation.taskId);
1082
+ this.#runnable.add(invocation.taskId);
1083
+ invocation.finish();
1084
+ }
1085
+ throw error;
1086
+ }
1087
+ // Marked, or already marked or gone: done with them. Reserve again, now seeing the marks.
1088
+ for (const id of abandoned)
1089
+ this.#abandoned.delete(id);
1090
+ if (abandoned.length > 0)
1091
+ this.#dirty = true;
1092
+ return reservations;
1093
+ }
1094
+ /**
1095
+ * Live tasks a task waits for before its next invocation: its live ordinary owned work when abort-marked, since
1096
+ * abort runs bottom-up, otherwise the live part of the `on` of a wait.
1097
+ */
1098
+ #waitingOn(record, first = false) {
1099
+ if (record.abortRequested)
1100
+ return this.#ownedLive(record.id, { first });
1101
+ if (record.state.status !== "waiting")
1102
+ return [];
1103
+ if (!first)
1104
+ return record.state.on.filter((id) => this.#live.has(id));
1105
+ const live = record.state.on.find((id) => this.#live.has(id));
1106
+ return live === undefined ? [] : [live];
1107
+ }
1108
+ /** Resolve the record's definition by kind, migrating an older stored version. */
1109
+ #resolve(record, snapshot) {
1110
+ const fit = this.#fit(record, snapshot.task(record.kind));
1111
+ if ("reason" in fit)
1112
+ return { kind: "blocked", reason: fit.reason };
1113
+ if (!fit.migrates)
1114
+ return { kind: "ready", task: fit.task, record };
1115
+ const definition = erased(fit.task);
1116
+ try {
1117
+ if (definition.migrate === undefined)
1118
+ throw missingMigration(record, definition);
1119
+ const migrated = definition.migrate(record.input, record.state.checkpoint, record.version);
1120
+ const state = { ...record.state, checkpoint: copyJson(migrated.checkpoint) };
1121
+ const migratedRecord = { ...record, version: definition.version, input: copyJson(migrated.input), state };
1122
+ return { kind: "ready", task: fit.task, record: migratedRecord };
1123
+ }
1124
+ catch (error) {
1125
+ this.#failedMigrations.set(record.id, { task: fit.task, error });
1126
+ this.#report(error);
1127
+ return { kind: "blocked", reason: "migration_failed" };
1128
+ }
1129
+ }
1130
+ #fit(record, task) {
1131
+ if (task === undefined)
1132
+ return { reason: "missing_task" };
1133
+ const version = task.definition.version;
1134
+ if (version === record.version)
1135
+ return { task, migrates: false };
1136
+ if (version < record.version)
1137
+ return { reason: "task_too_old" };
1138
+ const failed = this.#failedMigrations.get(record.id);
1139
+ if (failed?.task === task)
1140
+ return { reason: "migration_failed", error: failed.error };
1141
+ return { task, migrates: true };
1142
+ }
1143
+ /** Sizes of the ownership indexes, for tests that bound the scheduler's memory. */
1144
+ get indexSizes() {
1145
+ let below = 0;
1146
+ for (const children of this.#below.values())
1147
+ below += children.size;
1148
+ return {
1149
+ live: this.#live.size,
1150
+ settled: this.#settled.size,
1151
+ edges: this.#edges.size,
1152
+ below,
1153
+ roots: this.#roots.size,
1154
+ unloaded: this.#unloaded.size,
1155
+ dropQueue: this.#dropQueue.size,
1156
+ };
1157
+ }
1158
+ /**
1159
+ * Scheduling state and every live task with its derived state, read on the Session line. Runs no task code: a
1160
+ * pending migration shows as `ready` with `migrates`, and only a migration the scheduler already tried, or one that
1161
+ * cannot exist, shows as failed.
1162
+ */
1163
+ async inspect(snapshot) {
1164
+ await this.#loadScopes(false);
1165
+ const tasks = [];
1166
+ for (const record of this.#live.values())
1167
+ tasks.push({ record, state: this.#inspectTask(record, snapshot) });
1168
+ const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused";
1169
+ return { scheduling, tasks };
1170
+ }
1171
+ #inspectTask(record, snapshot) {
1172
+ if (this.#invocations.has(record.id))
1173
+ return { kind: "running" };
1174
+ if (record.state.status === "completing")
1175
+ return { kind: "completing" };
1176
+ const on = this.#waitingOn(record);
1177
+ if (on.length > 0)
1178
+ return { kind: "waiting", on };
1179
+ const fit = this.#fit(record, snapshot.task(record.kind));
1180
+ if ("reason" in fit)
1181
+ return { kind: "blocked", ...fit };
1182
+ if (fit.migrates && fit.task.definition.migrate === undefined) {
1183
+ return { kind: "blocked", reason: "migration_failed", error: missingMigration(record, erased(fit.task)) };
1184
+ }
1185
+ return { kind: "ready", migrates: fit.migrates };
1186
+ }
1187
+ #createInvocation(record, mode) {
1188
+ const controller = new AbortController();
1189
+ const { promise: done, resolve: finish } = Promise.withResolvers();
1190
+ const invocation = {
1191
+ taskId: record.id,
1192
+ conversationId: record.conversationId,
1193
+ mode,
1194
+ controller,
1195
+ context: withAbortSignal(controller.signal, this.#context),
1196
+ watches: new Set(),
1197
+ ended: false,
1198
+ done,
1199
+ finish: () => finish(),
1200
+ };
1201
+ this.#invocations.set(record.id, invocation);
1202
+ return invocation;
1203
+ }
1204
+ #start(reservation) {
1205
+ const invocation = reservation.invocation;
1206
+ void (async () => {
1207
+ try {
1208
+ if (invocation.mode === "run")
1209
+ await this.#run(reservation);
1210
+ else
1211
+ await this.#runAbort(reservation);
1212
+ }
1213
+ catch (error) {
1214
+ this.#report(error);
1215
+ }
1216
+ finally {
1217
+ this.#end(invocation);
1218
+ invocation.finish();
1219
+ this.#kick();
1220
+ }
1221
+ })();
1222
+ }
1223
+ /** Run phase handlers, each preceded by a step that decides on the line whether the invocation continues. */
1224
+ async #run(reservation) {
1225
+ const invocation = reservation.invocation;
1226
+ const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined };
1227
+ const phase = { snapshot: () => state.snapshot, task: () => state.task, agent: undefined };
1228
+ const runtime = this.#runtime(invocation, phase);
1229
+ let previous;
1230
+ for (;;) {
1231
+ const current = await this.#step(invocation, (tx, current) => this.#decide(tx, current, previous, state));
1232
+ // Close may seal between the decision and dispatch.
1233
+ if (current === undefined || this.#closing)
1234
+ return;
1235
+ const checkpoint = current.state.checkpoint;
1236
+ // Each phase handler resolves its agent afresh, at first use.
1237
+ phase.agent = undefined;
1238
+ try {
1239
+ await erased(state.task).phases[checkpoint.phase](current, runtime, invocation.context);
1240
+ previous = { checkpoint };
1241
+ }
1242
+ catch (error) {
1243
+ previous = { checkpoint, failure: { error } };
1244
+ }
1245
+ }
1246
+ }
1247
+ /**
1248
+ * Precedence rules for a run invocation, on the line. Rules 1 (terminal, `completing`, or `waiting`) and 2 (closing)
1249
+ * are applied by `#step`. Returns whether the invocation continues with the next phase.
1250
+ */
1251
+ #decide(tx, current, previous, state) {
1252
+ // 3. abort mark: end; a fresh abort invocation starts once the task's ordinary owned work is gone.
1253
+ if (current.abortRequested)
1254
+ return false;
1255
+ // An owner's cancellation intent ends it too, before its cascade marks it; reservation then holds it back.
1256
+ if (!current.background && this.#belowCancelled(parentOf(current))) {
1257
+ this.#cascadePending = true;
1258
+ this.#scheduleReconcile();
1259
+ return false;
1260
+ }
1261
+ if (previous === undefined)
1262
+ return true;
1263
+ // 4. uncaught error.
1264
+ if (previous.failure !== undefined)
1265
+ return { fault: previous.failure.error };
1266
+ // 6. no durable progress.
1267
+ if (jsonEqual(current.state.checkpoint, previous.checkpoint)) {
1268
+ const message = `Task ${current.kind} phase ${previous.checkpoint.phase} returned without durable progress`;
1269
+ return { fault: new Error(message) };
1270
+ }
1271
+ // 5. progress: refresh the snapshot; hand over to a replacement definition that can take the task.
1272
+ state.snapshot = this.#registry.snapshot();
1273
+ const next = state.snapshot.task(current.kind);
1274
+ if (next !== state.task) {
1275
+ if (next !== undefined && canReserve(next, current)) {
1276
+ tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));
1277
+ return false;
1278
+ }
1279
+ if (state.reported === undefined || state.reported.task !== next) {
1280
+ state.reported = { task: next };
1281
+ const cause = next === undefined ? "missing_task" : "incompatible_task";
1282
+ this.#report(new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`, { cause }));
1283
+ }
1284
+ }
1285
+ return true;
1286
+ }
1287
+ /** Run the abort handler once; rules 1, 2, and 4 apply, and returning without an outcome faults. */
1288
+ async #runAbort(reservation) {
1289
+ const invocation = reservation.invocation;
1290
+ const current = this.#live.get(invocation.taskId);
1291
+ if (current === undefined || this.#closing)
1292
+ return;
1293
+ let failure;
1294
+ try {
1295
+ const phase = { snapshot: () => reservation.snapshot, task: () => reservation.task, agent: undefined };
1296
+ const runtime = this.#runtime(invocation, phase);
1297
+ await erased(reservation.task).abort(current, runtime, invocation.context);
1298
+ }
1299
+ catch (error) {
1300
+ failure = { error };
1301
+ }
1302
+ const message = `Abort handler of task ${invocation.taskId} returned without a terminal outcome`;
1303
+ await this.#step(invocation, () => ({ fault: failure?.error ?? new Error(message) }));
1304
+ }
1305
+ /**
1306
+ * One synchronous decision on the Session line. A task that is no longer running (rule 1: terminal, `completing`, or
1307
+ * `waiting`) or a closing Harness (rule 2) ends the invocation without a write; otherwise `decide` may stage a write
1308
+ * and returns whether the invocation continues. Ending happens inside the callback, before a fault's Harness
1309
+ * cleanup. A rejected step, such as admission after close, also ends the invocation.
1310
+ */
1311
+ async #step(invocation, decide) {
1312
+ try {
1313
+ return await this.#session.commitWith(async (tx) => {
1314
+ const found = this.#live.get(invocation.taskId);
1315
+ const current = found?.state.status === "running" ? found : undefined;
1316
+ const decision = current !== undefined && !this.#closing ? decide(tx, current) : false;
1317
+ if (decision === true)
1318
+ return current;
1319
+ this.#end(invocation);
1320
+ if (decision !== false) {
1321
+ const message = errorMessage(decision.fault);
1322
+ await this.#terminate(tx, current, { status: "faulted", error: { message } });
1323
+ }
1324
+ return undefined;
1325
+ }, this.#context);
1326
+ }
1327
+ catch (error) {
1328
+ // The step writes the scheduler's own decision, a fault or a terminal record; a throw there would otherwise
1329
+ // leave the task running, to be reserved and run again.
1330
+ this.#end(invocation);
1331
+ this.#failSession(error);
1332
+ return undefined;
1333
+ }
1334
+ }
1335
+ /**
1336
+ * Write an outcome the scheduler decided. While the task's ordinary owned work is live it holds as `completing` and
1337
+ * its Harness cleanup waits for the final commit (spec §5.5, rule 4); otherwise it is terminal with its cleanup.
1338
+ */
1339
+ async #terminate(tx, record, outcome) {
1340
+ await this.#loadScopes(false);
1341
+ if (this.#hasOwnedLive(record.id, overlayOf(tx))) {
1342
+ tx.setTask(withState(record, { status: "completing", outcome }));
1343
+ return;
1344
+ }
1345
+ tx.setTask(withState(record, { status: "terminal", outcome }));
1346
+ await this.#settleOutcome(tx, record, outcome);
1347
+ }
1348
+ /**
1349
+ * Replace a running task's state with what it committed. A terminal state holds as `completing` while ordinary owned
1350
+ * work is live, judged on the commit's candidates, so work the same commit creates below the task counts. A wait is
1351
+ * validated first.
1352
+ */
1353
+ async #commitState(tx, invocation, current, next) {
1354
+ if (next.status === "waiting")
1355
+ await this.#validateWait(tx, invocation, current, next.on, next.policy);
1356
+ if (next.status === "terminal") {
1357
+ const overlay = overlayOf(tx);
1358
+ await this.#loadScopes(false);
1359
+ for (const record of overlay.tasks.values())
1360
+ await this.#loadChain(parentOf(record), overlay);
1361
+ if (this.#hasOwnedLive(current.id, overlay)) {
1362
+ tx.setTask(withState(current, { status: "completing", outcome: next.outcome }));
1363
+ return;
1364
+ }
1365
+ }
1366
+ tx.setTask(withState(current, next));
1367
+ }
1368
+ /**
1369
+ * A wait names existing tasks other than the waiter and its owners, which could never finish first; `failFast` only
1370
+ * tasks the waiter owns. An abort handler cannot wait.
1371
+ */
1372
+ async #validateWait(tx, invocation, current, on, policy) {
1373
+ if (invocation.mode === "abort")
1374
+ throw new Error(`Abort handler of task ${current.id} cannot wait`);
1375
+ const overlay = overlayOf(tx);
1376
+ await this.#loadChain(parentOf(current));
1377
+ const owners = new Set();
1378
+ for (const step of this.#above(parentOf(current)))
1379
+ if ("task" in step)
1380
+ owners.add(step.task);
1381
+ for (const id of on) {
1382
+ if (id === current.id || owners.has(id)) {
1383
+ throw new Error(`Task ${current.id} cannot wait on itself or its owner ${id}`);
1384
+ }
1385
+ const member = overlay.tasks.get(id) ?? this.#live.get(id) ?? (await this.#storage.task(id, this.#context));
1386
+ if (member === undefined)
1387
+ throw new Error(`Task ${id} does not exist`);
1388
+ if (policy === "failFast" && member.owner !== current.id) {
1389
+ throw new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`);
1390
+ }
1391
+ }
1392
+ }
1393
+ /** End an invocation: its runtime operations reject from now on, its signal aborts, its watches stop, and its task is free. */
1394
+ #end(invocation) {
1395
+ if (invocation.ended)
1396
+ return;
1397
+ invocation.ended = true;
1398
+ if (this.#invocations.get(invocation.taskId) === invocation) {
1399
+ this.#invocations.delete(invocation.taskId);
1400
+ if (this.#live.has(invocation.taskId))
1401
+ this.#runnable.add(invocation.taskId);
1402
+ }
1403
+ for (const watch of invocation.watches)
1404
+ void watch.stop();
1405
+ // Pending waits bound to the invocation, such as a tool's waitForTask(), reject with it.
1406
+ invocation.controller.abort(endedError(invocation));
1407
+ }
1408
+ /** No live non-background task in the scope; a task whose owner edges are not loaded yet counts as inside. */
1409
+ #idle(conversationId) {
1410
+ const scope = conversationId === undefined ? { roots: true } : { conversation: conversationId };
1411
+ // Tasks whose chain is not loaded are not listed below their scope yet; one counts as inside unless its walk up
1412
+ // decides otherwise.
1413
+ for (const id of this.#unloaded) {
1414
+ const record = this.#live.get(id);
1415
+ if (record !== undefined && !record.background && this.#inScope(parentOf(record), scope) !== false)
1416
+ return false;
1417
+ }
1418
+ const starts = conversationId === undefined ? this.#roots : [conversationId];
1419
+ for (const start of starts) {
1420
+ for (const { node, live } of this.#walkDown(conversationNode(start))) {
1421
+ if (live !== undefined && !node.background)
1422
+ return false;
1423
+ }
1424
+ }
1425
+ return true;
1426
+ }
1427
+ // ─── Invocation runtime ──────────────────────────────────────────────────
1428
+ #runtime(invocation, phase) {
1429
+ // Resolved at most once per phase handler, at first use, with the invocation's context; fixed for the phase.
1430
+ const agent = () => {
1431
+ if (invocation.ended)
1432
+ return Promise.reject(endedError(invocation));
1433
+ if (phase.agent === undefined) {
1434
+ phase.agent = this.#agent(invocation.conversationId, phase.snapshot(), invocation.context);
1435
+ // A caller that stops waiting must not leave the shared resolution's failure unobserved.
1436
+ phase.agent.catch(() => { });
1437
+ }
1438
+ return phase.agent;
1439
+ };
1440
+ const hooks = {
1441
+ each: async (name, invoke) => {
1442
+ for (const handlers of agentHooks(await agent(), phase.task().definition.name)) {
1443
+ const handler = handlers[name];
1444
+ if (typeof handler !== "function")
1445
+ continue;
1446
+ try {
1447
+ await invoke(handler.bind(handlers));
1448
+ }
1449
+ catch (error) {
1450
+ if (invocation.controller.signal.aborted)
1451
+ throw error;
1452
+ this.#report(error);
1453
+ }
1454
+ }
1455
+ },
1456
+ };
1457
+ const settings = this.#settings;
1458
+ return {
1459
+ taskId: invocation.taskId,
1460
+ conversationId: invocation.conversationId,
1461
+ signal: invocation.controller.signal,
1462
+ models: this.#models,
1463
+ agent: (context) => invocation.ended ? Promise.reject(endedError(invocation)) : awaitWithContext(agent(), context),
1464
+ get settings() {
1465
+ return settings();
1466
+ },
1467
+ env: (context) => this.#read(invocation, () => this.#env(invocation.conversationId, context)),
1468
+ hooks: hooks,
1469
+ get registry() {
1470
+ return phase.snapshot();
1471
+ },
1472
+ commit: (change, context) => this.#gated(invocation, async (tx, current) => {
1473
+ const next = await change(tx, current);
1474
+ if (next !== undefined)
1475
+ await this.#commitState(tx, invocation, current, next);
1476
+ }, context),
1477
+ memo: ((name, ...rest) => {
1478
+ if (rest.length === 1) {
1479
+ return this.#read(invocation, async () => memoOf(this.#live.get(invocation.taskId), name));
1480
+ }
1481
+ const candidate = rest[0];
1482
+ return this.#gated(invocation, (tx, current) => {
1483
+ const winner = memoOf(current, name);
1484
+ if (winner !== undefined)
1485
+ return winner;
1486
+ tx.setTask({ ...current, memos: { ...current.memos, [name]: candidate } });
1487
+ return candidate;
1488
+ }, rest[1]);
1489
+ }),
1490
+ sleep: (until, context) => this.#sleep(invocation, until, context),
1491
+ watchDoc: ((...args) => this.#watchDoc(invocation, args)),
1492
+ snapshot: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshot")(...args))),
1493
+ snapshotAsOf: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshotAsOf")(...args))),
1494
+ getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine(() => this.#storage.task(id, context)))),
1495
+ waitForTask: ((id, context) => this.#read(invocation, () => this.waitForTask(id, withAbortSignal(invocation.controller.signal, context)))),
1496
+ ownedTasks: (context) => this.#read(invocation, () => this.#session.readOnLine(async () => {
1497
+ // The step that ends the invocation may have run on the line before this.
1498
+ if (invocation.ended)
1499
+ throw endedError(invocation);
1500
+ context.abortSignal?.throwIfAborted();
1501
+ // Every live task is listed under its parent node, its owner task when it has one.
1502
+ const owned = [];
1503
+ for (const node of this.#below.get(invocation.taskId) ?? []) {
1504
+ const record = typeof node === "string" ? undefined : this.#live.get(node);
1505
+ if (record?.owner === invocation.taskId)
1506
+ owned.push(record);
1507
+ }
1508
+ // Copies, as from storage: the scheduler reads its own.
1509
+ return owned
1510
+ .sort((a, b) => a.id - b.id)
1511
+ .map((record) => copyJson(record));
1512
+ })),
1513
+ abortOwned: (id, context) => this.#read(invocation, async () => {
1514
+ const bound = withAbortSignal(invocation.controller.signal, context);
1515
+ const record = await this.#session.readOnLine(() => this.#storage.task(id, bound));
1516
+ if (record?.owner !== invocation.taskId) {
1517
+ throw new Error(`Task ${id} is not owned by task ${invocation.taskId}`);
1518
+ }
1519
+ if (record.state.status === "terminal")
1520
+ return;
1521
+ await this.abort(id, bound, true);
1522
+ await this.waitForTask(id, bound);
1523
+ }),
1524
+ outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async () => {
1525
+ const outcomes = [];
1526
+ for (const id of ids) {
1527
+ const state = (await this.#storage.task(id, context))?.state;
1528
+ if (state?.status !== "terminal")
1529
+ throw new Error(`Task ${id} is not terminal`);
1530
+ outcomes.push(state.outcome);
1531
+ }
1532
+ return outcomes;
1533
+ }))),
1534
+ conversation: (id, context) => this.#read(invocation, () => this.#conversation(id, {
1535
+ signal: invocation.controller.signal,
1536
+ check: () => {
1537
+ if (invocation.ended)
1538
+ throw endedError(invocation);
1539
+ },
1540
+ }, context)),
1541
+ entry: ((...args) => {
1542
+ const [token, id, context] = args.length === 2
1543
+ ? [undefined, args[0], args[1]]
1544
+ : [args[0], args[1], args[2]];
1545
+ return this.#read(invocation, async () => {
1546
+ const found = await this.#session.readOnLine(() => this.#storage.entry(invocation.conversationId, id, context));
1547
+ const entry = found?.entry;
1548
+ return token === undefined || entry?.kind === token.kind ? entry : undefined;
1549
+ });
1550
+ }),
1551
+ context: (conversationId, context, options) => this.#read(invocation, async () => {
1552
+ const { view, range } = await readContextFrom(this.#session, this.#storage, conversationId, context, options?.at, this.#contexts.get(conversationId)?.range);
1553
+ // Keep it unless the invocation ended or a concurrent read already kept a newer range.
1554
+ const kept = this.#contexts.get(conversationId);
1555
+ if (range !== undefined &&
1556
+ !this.#closing &&
1557
+ !invocation.ended &&
1558
+ (kept === undefined || kept.range.bounds.tail <= range.bounds.tail)) {
1559
+ // A read of another, idle conversation starts or continues its retention period.
1560
+ if (!this.#idle(conversationId)) {
1561
+ this.#contexts.set(conversationId, { range, idleSince: undefined });
1562
+ }
1563
+ else if (this.#contextRetentionMs() > 0) {
1564
+ this.#contexts.set(conversationId, { range, idleSince: kept?.idleSince ?? this.#now() });
1565
+ this.#scheduleExpiry();
1566
+ }
1567
+ }
1568
+ return view;
1569
+ }),
1570
+ now: () => {
1571
+ if (invocation.ended)
1572
+ throw endedError(invocation);
1573
+ return this.#now();
1574
+ },
1575
+ report: (error) => {
1576
+ if (invocation.ended)
1577
+ throw endedError(invocation);
1578
+ this.#report(error);
1579
+ },
1580
+ };
1581
+ }
1582
+ /** Run a committed-state read unless the invocation has ended. */
1583
+ async #read(invocation, read) {
1584
+ if (invocation.ended)
1585
+ throw endedError(invocation);
1586
+ return read();
1587
+ }
1588
+ /** Commit after rereading the task on the line and gating the invocation. */
1589
+ #gated(invocation, change, context) {
1590
+ if (invocation.ended)
1591
+ return Promise.reject(endedError(invocation));
1592
+ return this.#session.commitWith(async (tx) => {
1593
+ if (invocation.ended)
1594
+ throw endedError(invocation);
1595
+ if (this.#closing)
1596
+ throw closedError(this.#session);
1597
+ const found = this.#live.get(invocation.taskId);
1598
+ if (found === undefined)
1599
+ throw new Error(`Task ${invocation.taskId} is terminal`);
1600
+ if (found.state.status !== "running")
1601
+ throw new Error(`Task ${invocation.taskId} is ${found.state.status}`);
1602
+ const current = found;
1603
+ if (invocation.mode === "run" && current.abortRequested) {
1604
+ throw new Error(`Task ${invocation.taskId} has a durable abort mark`);
1605
+ }
1606
+ return change(tx, current);
1607
+ }, context, { conversationId: invocation.conversationId, taskId: invocation.taskId });
1608
+ }
1609
+ /** Wait until the Harness clock reaches `until`, rechecking it after every timer. */
1610
+ async #sleep(invocation, until, context) {
1611
+ if (invocation.ended)
1612
+ throw endedError(invocation);
1613
+ const signals = [invocation.controller.signal];
1614
+ if (context.abortSignal !== undefined)
1615
+ signals.push(context.abortSignal);
1616
+ const signal = AbortSignal.any(signals);
1617
+ for (;;) {
1618
+ signal.throwIfAborted();
1619
+ const remaining = until - this.#now();
1620
+ if (remaining <= 0)
1621
+ return;
1622
+ await delay(Math.min(remaining, MAX_TIMER_DELAY), signal);
1623
+ }
1624
+ }
1625
+ async #watchDoc(invocation, args) {
1626
+ if (invocation.ended)
1627
+ throw endedError(invocation);
1628
+ const watch = (await sessionMethod(this.#session, "watchDoc")(...args));
1629
+ if (watch === undefined)
1630
+ return undefined;
1631
+ if (invocation.ended) {
1632
+ void watch.stop();
1633
+ throw endedError(invocation);
1634
+ }
1635
+ invocation.watches.add(watch);
1636
+ void watch.closed.then(() => invocation.watches.delete(watch));
1637
+ return watch;
1638
+ }
1639
+ }
1640
+ /** The record with an abort mark: `restart` for an abandonment; without a reason, a request, which replaces `restart`. */
1641
+ function withAbortMark(record, reason) {
1642
+ const { abortReason: _reason, ...rest } = record;
1643
+ return { ...rest, abortRequested: true, ...(reason === undefined ? {} : { abortReason: reason }) };
1644
+ }
1645
+ /** A live owner's durable cancellation intent: its abort mark, or a held outcome other than `completed`. */
1646
+ function cancellationIntent(record) {
1647
+ return record.state.status !== "terminal" && (record.abortRequested || failedOutcome(record));
1648
+ }
1649
+ /** Whether the record holds or ends with an outcome other than `completed`. */
1650
+ function failedOutcome(record) {
1651
+ const state = record.state;
1652
+ return (state.status === "completing" || state.status === "terminal") && state.outcome.status !== "completed";
1653
+ }
1654
+ function parentOf(node) {
1655
+ return node.owner !== undefined ? { task: node.owner } : { conversation: node.conversationId };
1656
+ }
1657
+ function nodeOf(record) {
1658
+ return {
1659
+ conversationId: record.conversationId,
1660
+ ...(record.owner === undefined ? {} : { owner: record.owner }),
1661
+ background: record.background,
1662
+ };
1663
+ }
1664
+ function overlayOf(tx) {
1665
+ const tasks = new Map(tx.stagedTasks().map((record) => [record.id, record]));
1666
+ const edges = new Map(tx.stagedConversations().map((record) => [record.id, record.owner?.taskId ?? null]));
1667
+ return { tasks, edges };
1668
+ }
1669
+ function addTo(map, key, value) {
1670
+ const values = map.get(key);
1671
+ if (values === undefined)
1672
+ map.set(key, new Set([value]));
1673
+ else
1674
+ values.add(value);
1675
+ }
1676
+ function deleteFrom(map, key, value) {
1677
+ const values = map.get(key);
1678
+ if (values === undefined)
1679
+ return;
1680
+ values.delete(value);
1681
+ if (values.size === 0)
1682
+ map.delete(key);
1683
+ }
1684
+ /** Own memo entry only; memo names such as `toString` must not resolve to inherited properties. */
1685
+ function memoOf(record, name) {
1686
+ const memos = record?.memos;
1687
+ return memos !== undefined && Object.hasOwn(memos, name) ? memos[name] : undefined;
1688
+ }
1689
+ /** Overloaded Session method bound for forwarding an argument list unchanged. */
1690
+ function sessionMethod(session, name) {
1691
+ return session[name].bind(session);
1692
+ }
1693
+ function missingMigration(record, definition) {
1694
+ return new Error(`Task ${record.kind} version ${definition.version} has no migration from ${record.version}`);
1695
+ }
1696
+ function erased(task) {
1697
+ return task.definition;
1698
+ }
1699
+ function endedError(invocation) {
1700
+ return new Error(`Task ${invocation.taskId} invocation has ended`);
1701
+ }
1702
+ /** Replace a live record's state; memos disappear once an outcome is decided. */
1703
+ function withState(record, state) {
1704
+ if (state.status !== "terminal" && state.status !== "completing")
1705
+ return { ...record, state };
1706
+ const { memos: _memos, ...rest } = record;
1707
+ return { ...rest, state };
1708
+ }
1709
+ /** Whether a definition can take the task at reservation: same version, or newer with a migration. */
1710
+ function canReserve(task, record) {
1711
+ const definition = task.definition;
1712
+ return (definition.version === record.version || (definition.version > record.version && definition.migrate !== undefined));
1713
+ }
1714
+ function delay(ms, signal) {
1715
+ return new Promise((resolve, reject) => {
1716
+ const onAbort = () => {
1717
+ clearTimeout(timer);
1718
+ reject(signal.reason);
1719
+ };
1720
+ const timer = setTimeout(() => {
1721
+ signal.removeEventListener("abort", onAbort);
1722
+ resolve();
1723
+ }, ms);
1724
+ signal.addEventListener("abort", onAbort, { once: true });
1725
+ });
1726
+ }
1727
+ /** Structural equality of two JSON values; object key order is ignored. */
1728
+ function jsonEqual(left, right) {
1729
+ if (left === right)
1730
+ return true;
1731
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
1732
+ return false;
1733
+ if (Array.isArray(left) || Array.isArray(right)) {
1734
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
1735
+ return false;
1736
+ return left.every((value, index) => jsonEqual(value, right[index]));
1737
+ }
1738
+ const keys = Object.keys(left);
1739
+ if (keys.length !== Object.keys(right).length)
1740
+ return false;
1741
+ return keys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]));
1742
+ }
1743
+ //# sourceMappingURL=scheduler.js.map