@latysh-development/monolith 0.0.0-stage → 0.1.0-alpha.1

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 (345) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -2
  3. package/dist/brief/cli.d.ts +30 -0
  4. package/dist/brief/cli.d.ts.map +1 -0
  5. package/dist/brief/cli.js +85 -0
  6. package/dist/brief/collect.d.ts +17 -0
  7. package/dist/brief/collect.d.ts.map +1 -0
  8. package/dist/brief/collect.js +342 -0
  9. package/dist/brief/config.d.ts +40 -0
  10. package/dist/brief/config.d.ts.map +1 -0
  11. package/dist/brief/config.js +85 -0
  12. package/dist/brief/constants.d.ts +12 -0
  13. package/dist/brief/constants.d.ts.map +1 -0
  14. package/dist/brief/constants.js +11 -0
  15. package/dist/brief/docs.d.ts +53 -0
  16. package/dist/brief/docs.d.ts.map +1 -0
  17. package/dist/brief/docs.js +229 -0
  18. package/dist/brief/freshness.d.ts +104 -0
  19. package/dist/brief/freshness.d.ts.map +1 -0
  20. package/dist/brief/freshness.js +263 -0
  21. package/dist/brief/git-signals.d.ts +17 -0
  22. package/dist/brief/git-signals.d.ts.map +1 -0
  23. package/dist/brief/git-signals.js +101 -0
  24. package/dist/brief/index.d.ts +11 -0
  25. package/dist/brief/index.d.ts.map +1 -0
  26. package/dist/brief/index.js +5 -0
  27. package/dist/brief/reconcile.d.ts +75 -0
  28. package/dist/brief/reconcile.d.ts.map +1 -0
  29. package/dist/brief/reconcile.js +259 -0
  30. package/dist/brief/render.d.ts +67 -0
  31. package/dist/brief/render.d.ts.map +1 -0
  32. package/dist/brief/render.js +458 -0
  33. package/dist/brief/sanitize.d.ts +38 -0
  34. package/dist/brief/sanitize.d.ts.map +1 -0
  35. package/dist/brief/sanitize.js +48 -0
  36. package/dist/brief/selectors.d.ts +36 -0
  37. package/dist/brief/selectors.d.ts.map +1 -0
  38. package/dist/brief/selectors.js +75 -0
  39. package/dist/brief/story.d.ts +18 -0
  40. package/dist/brief/story.d.ts.map +1 -0
  41. package/dist/brief/story.js +59 -0
  42. package/dist/brief/types.d.ts +126 -0
  43. package/dist/brief/types.d.ts.map +1 -0
  44. package/dist/brief/types.js +11 -0
  45. package/dist/cli/index.d.ts +3 -0
  46. package/dist/cli/index.d.ts.map +1 -0
  47. package/dist/cli/index.js +199 -0
  48. package/dist/cli/init-args.d.ts +16 -0
  49. package/dist/cli/init-args.d.ts.map +1 -0
  50. package/dist/cli/init-args.js +24 -0
  51. package/dist/cli/ui-args.d.ts +23 -0
  52. package/dist/cli/ui-args.d.ts.map +1 -0
  53. package/dist/cli/ui-args.js +66 -0
  54. package/dist/cli/ui.d.ts +40 -0
  55. package/dist/cli/ui.d.ts.map +1 -0
  56. package/dist/cli/ui.js +180 -0
  57. package/dist/coordinatorium/checkpoint.d.ts +44 -0
  58. package/dist/coordinatorium/checkpoint.d.ts.map +1 -0
  59. package/dist/coordinatorium/checkpoint.js +124 -0
  60. package/dist/coordinatorium/cli.d.ts +39 -0
  61. package/dist/coordinatorium/cli.d.ts.map +1 -0
  62. package/dist/coordinatorium/cli.js +203 -0
  63. package/dist/coordinatorium/errors.d.ts +112 -0
  64. package/dist/coordinatorium/errors.d.ts.map +1 -0
  65. package/dist/coordinatorium/errors.js +145 -0
  66. package/dist/coordinatorium/index.d.ts +11 -0
  67. package/dist/coordinatorium/index.d.ts.map +1 -0
  68. package/dist/coordinatorium/index.js +5 -0
  69. package/dist/coordinatorium/lock.d.ts +187 -0
  70. package/dist/coordinatorium/lock.d.ts.map +1 -0
  71. package/dist/coordinatorium/lock.js +659 -0
  72. package/dist/coordinatorium/mission.d.ts +32 -0
  73. package/dist/coordinatorium/mission.d.ts.map +1 -0
  74. package/dist/coordinatorium/mission.js +87 -0
  75. package/dist/coordinatorium/state-keeper.d.ts +115 -0
  76. package/dist/coordinatorium/state-keeper.d.ts.map +1 -0
  77. package/dist/coordinatorium/state-keeper.js +396 -0
  78. package/dist/coordinatorium/types.d.ts +69 -0
  79. package/dist/coordinatorium/types.d.ts.map +1 -0
  80. package/dist/coordinatorium/types.js +27 -0
  81. package/dist/discovery/compose.d.ts +28 -0
  82. package/dist/discovery/compose.d.ts.map +1 -0
  83. package/dist/discovery/compose.js +159 -0
  84. package/dist/discovery/contracts.d.ts +211 -0
  85. package/dist/discovery/contracts.d.ts.map +1 -0
  86. package/dist/discovery/contracts.js +555 -0
  87. package/dist/discovery/docs.d.ts +278 -0
  88. package/dist/discovery/docs.d.ts.map +1 -0
  89. package/dist/discovery/docs.js +1182 -0
  90. package/dist/discovery/facts.d.ts +49 -0
  91. package/dist/discovery/facts.d.ts.map +1 -0
  92. package/dist/discovery/facts.js +541 -0
  93. package/dist/discovery/index.d.ts +8 -0
  94. package/dist/discovery/index.d.ts.map +1 -0
  95. package/dist/discovery/index.js +9 -0
  96. package/dist/discovery/orchestrate.d.ts +35 -0
  97. package/dist/discovery/orchestrate.d.ts.map +1 -0
  98. package/dist/discovery/orchestrate.js +32 -0
  99. package/dist/discovery/render.d.ts +36 -0
  100. package/dist/discovery/render.d.ts.map +1 -0
  101. package/dist/discovery/render.js +278 -0
  102. package/dist/discovery/traverse.d.ts +197 -0
  103. package/dist/discovery/traverse.d.ts.map +1 -0
  104. package/dist/discovery/traverse.js +510 -0
  105. package/dist/durable/assess.d.ts +105 -0
  106. package/dist/durable/assess.d.ts.map +1 -0
  107. package/dist/durable/assess.js +239 -0
  108. package/dist/durable/canonical.d.ts +25 -0
  109. package/dist/durable/canonical.d.ts.map +1 -0
  110. package/dist/durable/canonical.js +78 -0
  111. package/dist/durable/cli.d.ts +37 -0
  112. package/dist/durable/cli.d.ts.map +1 -0
  113. package/dist/durable/cli.js +141 -0
  114. package/dist/durable/errors.d.ts +192 -0
  115. package/dist/durable/errors.d.ts.map +1 -0
  116. package/dist/durable/errors.js +323 -0
  117. package/dist/durable/fs-store.d.ts +254 -0
  118. package/dist/durable/fs-store.d.ts.map +1 -0
  119. package/dist/durable/fs-store.js +1493 -0
  120. package/dist/durable/index.d.ts +20 -0
  121. package/dist/durable/index.d.ts.map +1 -0
  122. package/dist/durable/index.js +13 -0
  123. package/dist/durable/log.d.ts +33 -0
  124. package/dist/durable/log.d.ts.map +1 -0
  125. package/dist/durable/log.js +157 -0
  126. package/dist/durable/memory-store.d.ts +51 -0
  127. package/dist/durable/memory-store.d.ts.map +1 -0
  128. package/dist/durable/memory-store.js +302 -0
  129. package/dist/durable/projection.d.ts +35 -0
  130. package/dist/durable/projection.d.ts.map +1 -0
  131. package/dist/durable/projection.js +328 -0
  132. package/dist/durable/prune-check.d.ts +34 -0
  133. package/dist/durable/prune-check.d.ts.map +1 -0
  134. package/dist/durable/prune-check.js +72 -0
  135. package/dist/durable/register.d.ts +100 -0
  136. package/dist/durable/register.d.ts.map +1 -0
  137. package/dist/durable/register.js +532 -0
  138. package/dist/durable/retention.d.ts +49 -0
  139. package/dist/durable/retention.d.ts.map +1 -0
  140. package/dist/durable/retention.js +59 -0
  141. package/dist/durable/runtime.d.ts +499 -0
  142. package/dist/durable/runtime.d.ts.map +1 -0
  143. package/dist/durable/runtime.js +1288 -0
  144. package/dist/durable/seams.d.ts +44 -0
  145. package/dist/durable/seams.d.ts.map +1 -0
  146. package/dist/durable/seams.js +21 -0
  147. package/dist/durable/testing.d.ts +11 -0
  148. package/dist/durable/testing.d.ts.map +1 -0
  149. package/dist/durable/testing.js +25 -0
  150. package/dist/durable/types.d.ts +442 -0
  151. package/dist/durable/types.d.ts.map +1 -0
  152. package/dist/durable/types.js +75 -0
  153. package/dist/graph/builder.d.ts +11 -0
  154. package/dist/graph/builder.d.ts.map +1 -0
  155. package/dist/graph/builder.js +153 -0
  156. package/dist/graph/identity.d.ts +37 -0
  157. package/dist/graph/identity.d.ts.map +1 -0
  158. package/dist/graph/identity.js +46 -0
  159. package/dist/graph/index.d.ts +7 -0
  160. package/dist/graph/index.d.ts.map +1 -0
  161. package/dist/graph/index.js +3 -0
  162. package/dist/graph/resolver.d.ts +41 -0
  163. package/dist/graph/resolver.d.ts.map +1 -0
  164. package/dist/graph/resolver.js +36 -0
  165. package/dist/graph/types.d.ts +42 -0
  166. package/dist/graph/types.d.ts.map +1 -0
  167. package/dist/graph/types.js +1 -0
  168. package/dist/http/index.d.ts +5 -0
  169. package/dist/http/index.d.ts.map +1 -0
  170. package/dist/http/index.js +2 -0
  171. package/dist/http/server.d.ts +76 -0
  172. package/dist/http/server.d.ts.map +1 -0
  173. package/dist/http/server.js +195 -0
  174. package/dist/http/ui-assets.d.ts +22 -0
  175. package/dist/http/ui-assets.d.ts.map +1 -0
  176. package/dist/http/ui-assets.js +100 -0
  177. package/dist/ideas/cli.d.ts +33 -0
  178. package/dist/ideas/cli.d.ts.map +1 -0
  179. package/dist/ideas/cli.js +158 -0
  180. package/dist/ideas/format.d.ts +38 -0
  181. package/dist/ideas/format.d.ts.map +1 -0
  182. package/dist/ideas/format.js +194 -0
  183. package/dist/ideas/index.d.ts +6 -0
  184. package/dist/ideas/index.d.ts.map +1 -0
  185. package/dist/ideas/index.js +3 -0
  186. package/dist/ideas/store.d.ts +55 -0
  187. package/dist/ideas/store.d.ts.map +1 -0
  188. package/dist/ideas/store.js +262 -0
  189. package/dist/ideas/types.d.ts +54 -0
  190. package/dist/ideas/types.d.ts.map +1 -0
  191. package/dist/ideas/types.js +26 -0
  192. package/dist/index.d.ts +32 -0
  193. package/dist/index.d.ts.map +1 -0
  194. package/dist/index.js +28 -0
  195. package/dist/init/index.d.ts +12 -0
  196. package/dist/init/index.d.ts.map +1 -0
  197. package/dist/init/index.js +90 -0
  198. package/dist/init/prompts.d.ts +3 -0
  199. package/dist/init/prompts.d.ts.map +1 -0
  200. package/dist/init/prompts.js +47 -0
  201. package/dist/init/scaffold.d.ts +29 -0
  202. package/dist/init/scaffold.d.ts.map +1 -0
  203. package/dist/init/scaffold.js +208 -0
  204. package/dist/init/types.d.ts +14 -0
  205. package/dist/init/types.d.ts.map +1 -0
  206. package/dist/init/types.js +1 -0
  207. package/dist/managed/index.d.ts +73 -0
  208. package/dist/managed/index.d.ts.map +1 -0
  209. package/dist/managed/index.js +222 -0
  210. package/dist/readmodel/dashboard.d.ts +40 -0
  211. package/dist/readmodel/dashboard.d.ts.map +1 -0
  212. package/dist/readmodel/dashboard.js +236 -0
  213. package/dist/readmodel/index.d.ts +3 -0
  214. package/dist/readmodel/index.d.ts.map +1 -0
  215. package/dist/readmodel/index.js +1 -0
  216. package/dist/readmodel/types.d.ts +104 -0
  217. package/dist/readmodel/types.d.ts.map +1 -0
  218. package/dist/readmodel/types.js +15 -0
  219. package/dist/root/index.d.ts +186 -0
  220. package/dist/root/index.d.ts.map +1 -0
  221. package/dist/root/index.js +430 -0
  222. package/dist/root/safe-write.d.ts +268 -0
  223. package/dist/root/safe-write.d.ts.map +1 -0
  224. package/dist/root/safe-write.js +966 -0
  225. package/dist/root/write-artifacts.d.ts +20 -0
  226. package/dist/root/write-artifacts.d.ts.map +1 -0
  227. package/dist/root/write-artifacts.js +42 -0
  228. package/dist/scriptorium/compile.d.ts +4 -0
  229. package/dist/scriptorium/compile.d.ts.map +1 -0
  230. package/dist/scriptorium/compile.js +185 -0
  231. package/dist/scriptorium/frontmatter.d.ts +13 -0
  232. package/dist/scriptorium/frontmatter.d.ts.map +1 -0
  233. package/dist/scriptorium/frontmatter.js +90 -0
  234. package/dist/scriptorium/index.d.ts +3 -0
  235. package/dist/scriptorium/index.d.ts.map +1 -0
  236. package/dist/scriptorium/index.js +1 -0
  237. package/dist/scriptorium/markdown.d.ts +31 -0
  238. package/dist/scriptorium/markdown.d.ts.map +1 -0
  239. package/dist/scriptorium/markdown.js +88 -0
  240. package/dist/scriptorium/types.d.ts +78 -0
  241. package/dist/scriptorium/types.d.ts.map +1 -0
  242. package/dist/scriptorium/types.js +1 -0
  243. package/dist/types/enforcement.d.ts +11 -0
  244. package/dist/types/enforcement.d.ts.map +1 -0
  245. package/dist/types/enforcement.js +1 -0
  246. package/dist/types/integrity.d.ts +33 -0
  247. package/dist/types/integrity.d.ts.map +1 -0
  248. package/dist/types/integrity.js +1 -0
  249. package/dist/types/project-definition.d.ts +30 -0
  250. package/dist/types/project-definition.d.ts.map +1 -0
  251. package/dist/types/project-definition.js +1 -0
  252. package/dist/ui/assets/cosmic-glass-bg-Dwx47zPu.webp +0 -0
  253. package/dist/ui/assets/index-C4NhMaq7.js +28 -0
  254. package/dist/ui/assets/index-DMZ7ZvZY.css +1 -0
  255. package/dist/ui/index.html +13 -0
  256. package/dist/update/cli.d.ts +8 -0
  257. package/dist/update/cli.d.ts.map +1 -0
  258. package/dist/update/cli.js +115 -0
  259. package/dist/update/index.d.ts +78 -0
  260. package/dist/update/index.d.ts.map +1 -0
  261. package/dist/update/index.js +285 -0
  262. package/dist/validate/index.d.ts +6 -0
  263. package/dist/validate/index.d.ts.map +1 -0
  264. package/dist/validate/index.js +214 -0
  265. package/dist/version.d.ts +2 -0
  266. package/dist/version.d.ts.map +1 -0
  267. package/dist/version.js +1 -0
  268. package/package.json +67 -4
  269. package/templates/.gitkeep +0 -0
  270. package/templates/default-instance/instruction/.cursorrules +59 -0
  271. package/templates/default-instance/instruction/.windsurfrules +59 -0
  272. package/templates/default-instance/instruction/CLAUDE.md +57 -0
  273. package/templates/default-instance/managed/agent.md +20 -0
  274. package/templates/default-instance/managed/bootstrap.md +7 -0
  275. package/templates/default-instance/managed/index-idea.md +5 -0
  276. package/templates/default-instance/managed/routing-idea.md +14 -0
  277. package/templates/default-instance/monolith/AGENT.md +1 -0
  278. package/templates/default-instance/monolith/context/boot/HANDOFF.md +49 -0
  279. package/templates/default-instance/monolith/context/boot/WORK_SESSION.md +30 -0
  280. package/templates/default-instance/monolith/context/execution/current-state.md +30 -0
  281. package/templates/default-instance/monolith/context/maps/dependency-map.md +38 -0
  282. package/templates/default-instance/monolith/context/maps/project-map.md +85 -0
  283. package/templates/default-instance/monolith/context/maps/roadmap-index.md +41 -0
  284. package/templates/default-instance/monolith/context/maps/routing-map.md +151 -0
  285. package/templates/default-instance/monolith/context/maps/semantic-map.md +75 -0
  286. package/templates/default-instance/monolith/context/models/model-registry.md +37 -0
  287. package/templates/default-instance/monolith/context/models/privacy-classification.md +35 -0
  288. package/templates/default-instance/monolith/context/templates/plan-template.md +56 -0
  289. package/templates/default-instance/monolith/governance/README.md +29 -0
  290. package/templates/default-instance/monolith/governance/RULE_SCHEMA.md +58 -0
  291. package/templates/default-instance/monolith/learning/INDEX.md +48 -0
  292. package/templates/default-instance/monolith/learning/injection-map.md +49 -0
  293. package/templates/default-instance/monolith/learning/taxonomy.md +30 -0
  294. package/templates/default-instance/monolith/memory/errors/README.md +26 -0
  295. package/templates/default-instance/monolith/memory/errors/SCHEMA.md +47 -0
  296. package/templates/default-instance/monolith/memory/errors/TAXONOMY.md +27 -0
  297. package/templates/default-instance/monolith/memory/long-term/architecture-principles.md +31 -0
  298. package/templates/default-instance/monolith/memory/long-term/decisions.md +36 -0
  299. package/templates/default-instance/monolith/memory/long-term/execution-history.md +20 -0
  300. package/templates/default-instance/monolith/memory/long-term/tech-debt.md +25 -0
  301. package/templates/default-instance/monolith/memory/working/active.md +41 -0
  302. package/templates/default-instance/monolith/memory/working/governance-result.md +0 -0
  303. package/templates/default-instance/monolith/memory/working/integrity-result.md +0 -0
  304. package/templates/default-instance/monolith/memory/working/story-index.md +28 -0
  305. package/templates/default-instance/monolith/protocols/boot/boot-sequence.md +59 -0
  306. package/templates/default-instance/monolith/protocols/cognition/cognitive-modes.md +90 -0
  307. package/templates/default-instance/monolith/protocols/cognition/dry-run.md +56 -0
  308. package/templates/default-instance/monolith/protocols/cognition/execution-trace-format.md +52 -0
  309. package/templates/default-instance/monolith/protocols/cognition/semantic-planning.md +51 -0
  310. package/templates/default-instance/monolith/protocols/execution/hybrid-execution.md +37 -0
  311. package/templates/default-instance/monolith/protocols/execution/local-llm-governance.md +42 -0
  312. package/templates/default-instance/monolith/protocols/governance/command-policy.md +51 -0
  313. package/templates/default-instance/monolith/protocols/governance/core-invariants.md +40 -0
  314. package/templates/default-instance/monolith/protocols/governance/memory-governance.md +54 -0
  315. package/templates/default-instance/monolith/protocols/governance/metadata-schema.md +62 -0
  316. package/templates/default-instance/monolith/protocols/governance/skills.md +53 -0
  317. package/templates/default-instance/monolith/protocols/governance/work-session.md +46 -0
  318. package/templates/default-instance/monolith/skills/INDEX.md +32 -0
  319. package/templates/default-instance/monolith/skills/current-status/SKILL.md +47 -0
  320. package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +86 -0
  321. package/templates/default-instance/monolith/skills/global-status/SKILL.md +56 -0
  322. package/templates/default-instance/monolith/skills/idea-capture/SKILL.md +157 -0
  323. package/templates/default-instance/monolith/skills/self-improvement/SKILL.md +77 -0
  324. package/templates/default-instance/monolith/state.json +11 -0
  325. package/templates/default-instance/monolith/tests/INDEX.md +57 -0
  326. package/templates/default-instance/monolith/tests/scenarios/architecture-review.md +32 -0
  327. package/templates/default-instance/monolith/tests/scenarios/debug-failure.md +32 -0
  328. package/templates/default-instance/monolith/tests/scenarios/dry-run-activation.md +33 -0
  329. package/templates/default-instance/monolith/tests/scenarios/execution-trace-verification.md +31 -0
  330. package/templates/default-instance/monolith/tests/scenarios/finalize-story.md +35 -0
  331. package/templates/default-instance/monolith/tests/scenarios/implement-feature.md +36 -0
  332. package/templates/default-instance/monolith/tests/scenarios/local-llm-delegation.md +31 -0
  333. package/templates/default-instance/monolith/tests/scenarios/planning-request.md +32 -0
  334. package/templates/default-instance/monolith/tests/scenarios/sensitive-context-task.md +30 -0
  335. package/templates/default-instance/monolith/tests/scenarios/validator-verification.md +33 -0
  336. package/templates/default-instance/monolith/tests/validation/expected-behavior.md +70 -0
  337. package/templates/default-instance/monolith/tests/validators/INDEX.md +17 -0
  338. package/templates/default-instance/monolith/tests/validators/delegation-semantics.md +22 -0
  339. package/templates/default-instance/monolith/tests/validators/mode-consistency.md +25 -0
  340. package/templates/default-instance/monolith/tests/validators/privacy-routing.md +23 -0
  341. package/templates/default-instance/monolith/tests/validators/protocol-references.md +27 -0
  342. package/templates/default-instance/monolith/tests/validators/routing-integrity.md +30 -0
  343. package/templates/default-instance/projectroot/AGENTS.md +111 -0
  344. package/templates/default-instance/projectroot/monolith.config.json +32 -0
  345. package/templates/default-instance/projectroot/project.definition.mjs.template +41 -0
@@ -0,0 +1,1288 @@
1
+ /**
2
+ * DurableTasks — the application-facing API of Durable State.
3
+ *
4
+ * Every state change is one event appended to the task's log by compare-and-
5
+ * set on the next sequence number (see `EventStore.append`). There is no
6
+ * lock and no in-memory authority: any process can load a task from the store,
7
+ * decide, and append; if another process appended first the attempt re-reads
8
+ * and decides again against the new state. Consequences:
9
+ *
10
+ * - no lost updates, no stale locks, nothing to recover after a crash —
11
+ * a crash leaves either the event (complete) or nothing;
12
+ * - every operation is idempotent where a retry after a crash is plausible
13
+ * (same id + same content is a no-op; same id + different content is an
14
+ * error), so "run the step again" is always safe at the state level;
15
+ * - a worker acts under a *fence* (owner + epoch): once someone else claims
16
+ * the task, the old worker's next write fails with `StaleFenceError`
17
+ * instead of interleaving with the new one;
18
+ * - results from outside arrive through `deliver(token, ...)`, which needs no
19
+ * live process, tolerates duplicates and any order, and never loses a
20
+ * payload it declines to apply.
21
+ *
22
+ * Side effects *outside* the store cannot be made exactly-once by a store.
23
+ * `runEffect` records intent before and outcome after, so after a crash the
24
+ * state says precisely which effects completed, which are known not to have
25
+ * run, and which are *in doubt* (needs reconciliation with the external system).
26
+ */
27
+ import { randomBytes, timingSafeEqual } from 'crypto';
28
+ import { hostname } from 'os';
29
+ import { canonicalize, fingerprintOf, toJson } from './canonical.js';
30
+ import { assessTask, diffStates, leaseIsLive } from './assess.js';
31
+ import { ContentionError, DurableError, EffectConflictError, EffectFailedError, EffectInDoubtError, EffectNotExecutedError, LeaseHeldError, LogCorruptError, LogTooLargeError, RetentionRefusedError, StaleFenceError, TaskClosedError, TaskExistsError, TaskNotFoundError, TaskOrphanedError, RequiredTaskPendingError, TombstoneCommittedError, TaskPrunedError, ValidationError, } from './errors.js';
32
+ import { AppendOutcomeUnknownError } from './fs-store.js';
33
+ import { assertId, assertNonEmpty, stampEvent, verifyChain } from './log.js';
34
+ import { applyEvent, cloneState, deliveryKey, foldEvents, foldUntil, planProblem } from './projection.js';
35
+ import { planRetention } from './retention.js';
36
+ import { tombstoneFor } from './prune-check.js';
37
+ import { GENESIS_HASH, TERMINAL_EVENT_TYPES, adoptedIsSuccess, } from './types.js';
38
+ import { sha256Hex } from './canonical.js';
39
+ const KIND_MAX = 100;
40
+ const GOAL_MAX = 4000;
41
+ const TEXT_MAX = 4000;
42
+ const KEY_MAX = 200;
43
+ const MAX_TIMELINE = 500;
44
+ const COMPACT_CHUNK_BYTES = 4 * 1024 * 1024;
45
+ const DEFAULT_TERMINAL_RESERVE = 16;
46
+ /**
47
+ * How long `createTask` waits for a transient tombstone to settle before it refuses to create a task under that
48
+ * parent. Only a request is refused; it never decides that a required task is gone (see `requirementState`).
49
+ */
50
+ const SETTLE_ATTEMPTS = 40;
51
+ const SETTLE_STEP_MS = 25;
52
+ /** Conflicting deliveries kept in the log per await and per task; more are answered but not stored. */
53
+ const MAX_REJECTED_PER_AWAIT = 8;
54
+ const MAX_REJECTED_PER_TASK = 64;
55
+ /** A stored rejected payload larger than this is replaced by its size and digest. */
56
+ const REJECTED_PAYLOAD_MAX_BYTES = 4096;
57
+ function draft(type, actor, data) {
58
+ return { type, actor, data };
59
+ }
60
+ function sleep(ms) {
61
+ return new Promise((r) => setTimeout(r, ms));
62
+ }
63
+ /** A unique, human-readable worker identity: host, pid, random suffix. Use one per process incarnation. */
64
+ export function newOwnerId() {
65
+ return `${hostname().replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 24)}-${process.pid}-${randomBytes(3).toString('hex')}`;
66
+ }
67
+ /** The payload as stored with a rejected delivery: verbatim when small, else its size and SHA-256. */
68
+ function boundedPayload(body) {
69
+ const text = canonicalize(body);
70
+ const bytes = Buffer.byteLength(text, 'utf8');
71
+ return bytes <= REJECTED_PAYLOAD_MAX_BYTES ? body : { $truncated: true, bytes, sha256: sha256Hex(text) };
72
+ }
73
+ function defaultClassify(err) {
74
+ return err instanceof EffectNotExecutedError ? 'not-executed' : 'unknown';
75
+ }
76
+ /** What a compare-and-set on an effect compares: its state and the announcement it belongs to. */
77
+ function effectIdentity(e) {
78
+ return e ? `${e.status}@${e.intendedAtSeq}` : 'none';
79
+ }
80
+ const TOKEN_PREFIX = 'dsc1';
81
+ export function parseToken(token) {
82
+ if (typeof token !== 'string' || token.length > 400)
83
+ return null;
84
+ const parts = token.split('.');
85
+ if (parts.length !== 4 || parts[0] !== TOKEN_PREFIX)
86
+ return null;
87
+ const [, taskId, awaitId, nonce] = parts;
88
+ try {
89
+ assertId('taskId', taskId);
90
+ assertId('awaitId', awaitId);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ return /^[0-9a-f]{32}$/.test(nonce) ? { taskId, awaitId, nonce } : null;
96
+ }
97
+ export class DurableTasks {
98
+ store;
99
+ now;
100
+ newId;
101
+ defaultLeaseMs;
102
+ maxEvents;
103
+ maxAttempts;
104
+ maxCached;
105
+ terminalReserve;
106
+ referenceGraceMs;
107
+ /** Validated, incrementally extended view per task. A cache of the log, never an authority. */
108
+ cache = new Map();
109
+ /** Effect executions running in this runtime, so concurrent calls with one identity share one `fn` call. */
110
+ inflight = new Map();
111
+ constructor(opts) {
112
+ this.store = opts.store;
113
+ this.now = opts.now ?? (() => new Date());
114
+ this.newId = opts.newId ?? (() => randomBytes(8).toString('hex'));
115
+ this.defaultLeaseMs = opts.defaultLeaseMs ?? 5 * 60_000;
116
+ this.maxEvents = opts.maxEventsPerTask ?? 50_000;
117
+ this.maxAttempts = opts.maxAppendAttempts ?? 100;
118
+ this.maxCached = opts.maxCachedTasks ?? 256;
119
+ this.terminalReserve = Math.max(0, Math.floor(opts.terminalReserveEvents ?? DEFAULT_TERMINAL_RESERVE));
120
+ this.referenceGraceMs = opts.referenceGraceMs ?? 10 * 60_000;
121
+ }
122
+ remember(taskId, state) {
123
+ this.cache.delete(taskId); // re-insert: Map order is recency order
124
+ this.cache.set(taskId, state);
125
+ while (this.cache.size > this.maxCached) {
126
+ const oldest = this.cache.keys().next().value;
127
+ this.cache.delete(oldest);
128
+ }
129
+ }
130
+ // =========================================================================
131
+ // Core: load and compare-and-set append
132
+ // =========================================================================
133
+ /** Brings the cached state up to the head of the log. Throws if the task is missing or pruned. */
134
+ async refresh(taskId) {
135
+ for (let pass = 0;; pass++) {
136
+ const cached = this.cache.get(taskId);
137
+ const after = cached?.headSeq ?? 0;
138
+ const log = await this.store.readLog(taskId, { afterSeq: after });
139
+ if (log.tombstone) {
140
+ this.cache.delete(taskId);
141
+ throw new TaskPrunedError(taskId);
142
+ }
143
+ if (!log.exists || (log.events.length === 0 && !cached)) {
144
+ this.cache.delete(taskId);
145
+ throw new TaskNotFoundError(taskId);
146
+ }
147
+ if (log.events.length === 0)
148
+ return cached;
149
+ try {
150
+ verifyChain(taskId, log.events, after, cached?.headHash ?? GENESIS_HASH);
151
+ const next = foldEvents(log.events, cached ? cloneState(cached) : null);
152
+ this.remember(taskId, next);
153
+ return next;
154
+ }
155
+ catch (err) {
156
+ // A cached prefix that no longer matches the log is dropped and re-read once from scratch.
157
+ if (cached && pass === 0 && err instanceof LogCorruptError) {
158
+ this.cache.delete(taskId);
159
+ continue;
160
+ }
161
+ throw err;
162
+ }
163
+ }
164
+ }
165
+ async loadFull(taskId) {
166
+ const log = await this.store.readLog(taskId);
167
+ if (log.tombstone)
168
+ throw new TaskPrunedError(taskId);
169
+ if (!log.exists || log.events.length === 0)
170
+ throw new TaskNotFoundError(taskId);
171
+ verifyChain(taskId, log.events, 0, GENESIS_HASH);
172
+ const state = foldEvents(log.events);
173
+ this.remember(taskId, state);
174
+ return { state: cloneState(state), events: log.events, diagnostics: log.diagnostics, compactedThrough: log.compactedThrough };
175
+ }
176
+ /**
177
+ * Read state -> decide -> append, repeated until the append wins its slot.
178
+ * `plan` must be a pure function of the state it is given: it is re-run
179
+ * against fresh state after every lost race.
180
+ */
181
+ async mutate(taskId, plan) {
182
+ for (let attempt = 0; attempt < this.maxAttempts; attempt++) {
183
+ const state = await this.refresh(taskId);
184
+ if (state.status !== 'active') {
185
+ // A planner may still answer a read-only question about a closed task (e.g. duplicate delivery).
186
+ const probe = plan(state);
187
+ if (probe.draft === null)
188
+ return probe.result(null, state);
189
+ throw new TaskClosedError(taskId, state.status);
190
+ }
191
+ const p = plan(state);
192
+ if (p.draft === null)
193
+ return p.result(null, state);
194
+ if (state.eventCount >= this.eventLimitFor(p.draft.type))
195
+ throw new LogTooLargeError(taskId, this.maxEvents);
196
+ const event = stampEvent(taskId, state.headSeq + 1, this.now().toISOString(), state.headHash, p.draft);
197
+ const next = this.precheck(state, event);
198
+ let outcome;
199
+ try {
200
+ outcome = await this.store.append(event);
201
+ }
202
+ catch (err) {
203
+ if (!(err instanceof AppendOutcomeUnknownError))
204
+ throw err;
205
+ outcome = await this.resolveUnknown(event);
206
+ }
207
+ // Only 'appended' proves this call won the slot. 'adopted' is success solely for the closed list of state-only
208
+ // events; for every other type (the rights-bearing ones and any unknown type) it is a lost race: re-read, re-plan,
209
+ // and never act on the strength of the matching event (Amendment-004G, 2.11).
210
+ if (outcome === 'appended' || (outcome === 'adopted' && adoptedIsSuccess(event.type))) {
211
+ this.remember(taskId, next);
212
+ return p.result(event, next);
213
+ }
214
+ if (attempt >= 2)
215
+ await sleep(Math.floor(Math.random() * Math.min(2 ** attempt, 40)));
216
+ }
217
+ throw new ContentionError(taskId, this.maxAttempts);
218
+ }
219
+ /**
220
+ * How many events a task may hold before `type` is refused. Ordinary events stop at `maxEvents`; lease changes
221
+ * may use the reserve except its last slot; terminal events may use all of it. See `terminalReserveEvents`.
222
+ */
223
+ eventLimitFor(type) {
224
+ if (TERMINAL_EVENT_TYPES.includes(type))
225
+ return this.maxEvents + this.terminalReserve;
226
+ if (type === 'run.claimed' || type === 'run.released')
227
+ return this.maxEvents + Math.max(0, this.terminalReserve - 1);
228
+ return this.maxEvents;
229
+ }
230
+ /**
231
+ * Runs a proposed event through the fold before anything is stored. A violation here means the *caller's*
232
+ * request does not fit the task's current state (the stored log was just verified and is fine), so it is
233
+ * a `ValidationError`, never a `LogCorruptError`: corruption is reserved for stored data that fails verification.
234
+ */
235
+ precheck(state, event) {
236
+ try {
237
+ return applyEvent(cloneState(state), event);
238
+ }
239
+ catch (err) {
240
+ if (err instanceof LogCorruptError) {
241
+ throw new ValidationError(`${event.type} is not allowed in the current state of task "${event.taskId}": ${err.reason}.`);
242
+ }
243
+ throw err;
244
+ }
245
+ }
246
+ /**
247
+ * After an unknown publication outcome: is *our* event (same hash) the one in the slot? Matching bytes cannot prove
248
+ * that this call created the slot, so the answer is 'adopted', never 'appended' (Amendment-004G, F-G-3). A failing
249
+ * read propagates: the outcome stays unknown and no right is granted.
250
+ */
251
+ async resolveUnknown(event) {
252
+ const log = await this.store.readLog(event.taskId, { afterSeq: event.seq - 1 });
253
+ const there = log.events[0];
254
+ return there !== undefined && there.seq === event.seq && there.hash === event.hash ? 'adopted' : 'conflict';
255
+ }
256
+ // =========================================================================
257
+ // Tasks
258
+ // =========================================================================
259
+ async createTask(input) {
260
+ assertNonEmpty('kind', input.kind, KIND_MAX);
261
+ assertNonEmpty('goal', input.goal, GOAL_MAX);
262
+ const taskId = input.taskId ?? `task-${this.newId()}`;
263
+ assertId('taskId', taskId);
264
+ const refs = [...(input.parentTaskId ? [input.parentTaskId] : []), ...(input.dependsOn ?? [])];
265
+ for (const r of refs)
266
+ assertId('referenced task id', r);
267
+ const labels = input.labels ?? {};
268
+ if (Object.keys(labels).length > 20)
269
+ throw new ValidationError('at most 20 labels.');
270
+ for (const [k, v] of Object.entries(labels)) {
271
+ if (typeof v !== 'string' || k.length > 64 || v.length > 256)
272
+ throw new ValidationError('labels must be short string pairs.');
273
+ }
274
+ const data = {
275
+ kind: input.kind,
276
+ goal: input.goal,
277
+ input: input.input === undefined ? null : toJson(input.input, 'input'),
278
+ parentTaskId: input.parentTaskId ?? null,
279
+ dependsOn: [...(input.dependsOn ?? [])].sort(),
280
+ labels: { ...labels },
281
+ };
282
+ for (const r of refs) {
283
+ if (!(await this.referenceIsLive(r)))
284
+ throw new ValidationError(`referenced task "${r}" does not exist (or was pruned).`);
285
+ }
286
+ // Pin every reference BEFORE publishing the task, so a concurrent prune of a referenced task sees this task
287
+ // (or this call sees its tombstone). Stores without pins keep the old, weaker check above.
288
+ if (this.store.pinReference) {
289
+ for (const r of refs) {
290
+ const pinned = await this.store.pinReference(r, taskId, this.now().toISOString());
291
+ if (pinned !== 'pinned')
292
+ throw new ValidationError(`referenced task "${r}" does not exist (or was pruned).`);
293
+ }
294
+ }
295
+ const event = stampEvent(taskId, 1, this.now().toISOString(), GENESIS_HASH, draft('task.created', null, data));
296
+ let outcome;
297
+ try {
298
+ outcome = await this.store.append(event);
299
+ }
300
+ catch (err) {
301
+ if (!(err instanceof AppendOutcomeUnknownError))
302
+ throw err;
303
+ outcome = await this.resolveUnknown(event);
304
+ }
305
+ if (outcome === 'appended') {
306
+ const state = applyEvent(null, event);
307
+ this.remember(taskId, state);
308
+ await this.assertReferencesSurvived(taskId, refs);
309
+ return { taskId, created: true, state: cloneState(state) };
310
+ }
311
+ // The id is taken: the same request repeated is fine, a different one is an error.
312
+ const existing = await this.refresh(taskId);
313
+ const same = existing.kind === data.kind && existing.goal === data.goal && existing.parentTaskId === data.parentTaskId &&
314
+ canonicalize(existing.input) === canonicalize(data.input) &&
315
+ canonicalize(existing.dependsOn) === canonicalize(data.dependsOn) &&
316
+ canonicalize(existing.labels) === canonicalize(data.labels);
317
+ if (!same)
318
+ throw new TaskExistsError(taskId);
319
+ return { taskId, created: false, state: cloneState(existing) };
320
+ }
321
+ /**
322
+ * True when `taskId` exists and is not pruned. A tombstone may be transient (a prune that found a referrer
323
+ * withdraws it right away), so a pruned answer is re-checked for a short while before it is believed.
324
+ */
325
+ async referenceIsLive(taskId) {
326
+ for (let i = 0; i < SETTLE_ATTEMPTS; i++) {
327
+ try {
328
+ await this.refresh(taskId);
329
+ return true;
330
+ }
331
+ catch (err) {
332
+ if (err instanceof TaskNotFoundError)
333
+ return false;
334
+ if (!(err instanceof TaskPrunedError))
335
+ throw err;
336
+ }
337
+ await sleep(SETTLE_STEP_MS);
338
+ }
339
+ return false;
340
+ }
341
+ /**
342
+ * Immediate feedback for the creator, NOT the safety net: a creator that is killed before it gets here leaves an
343
+ * active task behind, and that is made harmless by `claim` / discovery (see `blockingRequirement`). It runs without
344
+ * looking at any clock: a clock-dependent skip is what let a skewed creator keep a task whose parent was pruned.
345
+ * Only a parent that is GONE cancels the new task. A parent whose prune is merely unfinished is rolled back on the
346
+ * spot (this task is published and active, so it is a referrer the prune must respect) or, if that cannot be
347
+ * done now, left alone: `claim` refuses to run the task until the question is settled, and cancels nothing.
348
+ */
349
+ async assertReferencesSurvived(taskId, refs) {
350
+ if (!this.store.pinReference)
351
+ return;
352
+ for (const r of refs) {
353
+ const st = await this.requirementState(r, true);
354
+ if (st === 'missing')
355
+ throw new ValidationError(`referenced task "${r}" was not found while task "${taskId}" was being created; the new task is not run and is not cancelled (ADR-018 D2).`);
356
+ if (st !== 'gone')
357
+ continue;
358
+ await this.quarantine(taskId, r);
359
+ throw new ValidationError(`referenced task "${r}" was pruned while task "${taskId}" was being created; the new task was cancelled.`);
360
+ }
361
+ }
362
+ /**
363
+ * What a required task is, as seen right now (a logical model; nothing of this is persisted):
364
+ * - `live` it exists and carries no tombstone.
365
+ * - `pending` a tombstone exists, but the prune is unfinished and may still be rolled back, or cannot be examined.
366
+ * This is a transient state. It is never a reason to cancel anybody.
367
+ * - `gone` its removal has started or finished: the prune is committed and irreversible.
368
+ * - `missing` its directory is not there and no prune accounts for it (ADR-018 D2). Absence is not proof of a
369
+ * committed prune, so, like `pending`, it blocks execution and is never a reason to cancel anybody.
370
+ *
371
+ * Why the tombstone alone proves nothing: a pruner publishes it BEFORE it looks for referrers, and a stopped
372
+ * (SIGSTOP) or killed pruner leaves it behind with all data in place. Only the data decides:
373
+ * - complete data + a referrer that is active ⇒ the pruner has not passed its referrer check (or never will);
374
+ * the prune is rolled back here (`withdrawTombstone`, idempotent, refused once any file was removed).
375
+ * - data gone or partly removed ⇒ the pruner passed its referrer check and committed.
376
+ * With `recover: false` nothing is written: an unfinished prune is reported as `pending`.
377
+ * No timeout takes part in this decision; `pending` is retried by the caller, never promoted to `gone`.
378
+ */
379
+ async requirementState(taskId, recover) {
380
+ for (let pass = 0; pass < 3; pass++) {
381
+ try {
382
+ await this.refresh(taskId);
383
+ return 'live';
384
+ }
385
+ catch (err) {
386
+ if (err instanceof TaskNotFoundError)
387
+ return 'missing';
388
+ if (!(err instanceof TaskPrunedError))
389
+ throw err;
390
+ }
391
+ const head = await this.store.readLog(taskId, { afterSeq: Number.MAX_SAFE_INTEGER });
392
+ if (!head.tombstone)
393
+ continue; // withdrawn (or never there) meanwhile: look again
394
+ if (!head.diagnostics.some((d) => d.code === 'PRUNE_INCOMPLETE'))
395
+ return 'gone'; // nothing left to roll back to
396
+ if (!recover || !this.store.withdrawTombstone)
397
+ return 'pending';
398
+ try {
399
+ await this.store.withdrawTombstone(head.tombstone);
400
+ }
401
+ catch (err) {
402
+ if (err instanceof TombstoneCommittedError)
403
+ return 'gone';
404
+ if (err instanceof DurableError)
405
+ return 'pending'; // could not roll back now (I/O error, ...): undetermined
406
+ throw err;
407
+ }
408
+ this.cache.delete(taskId);
409
+ }
410
+ return 'pending';
411
+ }
412
+ /**
413
+ * The first task an ACTIVE task requires (`parentTaskId`, `dependsOn`) that is gone, else the first that is missing,
414
+ * else the first whose prune is pending, else `null`. This — not the presence of `task.created` in the log — is what permits execution. Why it
415
+ * is safe without any clock: a pruner publishes its tombstone and only then looks for referrers; this check runs
416
+ * after the task's `task.created` is published. So either the tombstone is visible here, or the pruner sees the
417
+ * task and backs off. Once the check has passed, no prune of the required task can succeed while this task is active.
418
+ */
419
+ async blockingRequirement(s, recover) {
420
+ if (s.status !== 'active')
421
+ return null;
422
+ let missing = null;
423
+ let pending = null;
424
+ for (const r of [...(s.parentTaskId ? [s.parentTaskId] : []), ...s.dependsOn]) {
425
+ const st = await this.requirementState(r, recover);
426
+ if (st === 'gone')
427
+ return { id: r, state: 'gone' };
428
+ if (st === 'missing' && missing === null)
429
+ missing = { id: r, state: 'missing' };
430
+ if (st === 'pending' && pending === null)
431
+ pending = { id: r, state: 'pending' };
432
+ }
433
+ return missing ?? pending;
434
+ }
435
+ /**
436
+ * Durable, idempotent cleanup of an orphan whose required task is GONE (never for a pending prune):
437
+ * `task.cancelled` (a terminal event, which also fits in the reserve).
438
+ * Best effort by design: if the write fails (crash, ENOSPC) the task stays active in the log but
439
+ * `blockingRequirement` keeps refusing it on every call, and the next discovery tries the write again.
440
+ */
441
+ async quarantine(taskId, requiredId) {
442
+ try {
443
+ await this.cancel(taskId, `required task "${requiredId}" was pruned while this task was being created`, 'durable');
444
+ }
445
+ catch (err) {
446
+ if (err instanceof TaskClosedError || err instanceof TaskNotFoundError || err instanceof TaskPrunedError)
447
+ return;
448
+ if (err instanceof DurableError)
449
+ return; // the guard does not depend on this write
450
+ throw err;
451
+ }
452
+ }
453
+ /**
454
+ * An active task that refers to `taskId`: either published (`parentTaskId` / `dependsOn`) or being created
455
+ * (a recent pin with no task behind it yet). `null` if nothing needs `taskId` any more.
456
+ */
457
+ async activeReferrer(taskId) {
458
+ for (const other of await this.store.listTaskIds()) {
459
+ if (other === taskId)
460
+ continue;
461
+ let o;
462
+ try {
463
+ o = (await this.loadFull(other)).state;
464
+ }
465
+ catch (err) {
466
+ if (err instanceof TaskPrunedError || err instanceof TaskNotFoundError)
467
+ continue;
468
+ throw err;
469
+ }
470
+ if (o.status === 'active' && (o.parentTaskId === taskId || o.dependsOn.includes(taskId)))
471
+ return other;
472
+ }
473
+ for (const pin of (await this.store.listReferences?.(taskId)) ?? []) {
474
+ let child;
475
+ try {
476
+ child = await this.refresh(pin.childId);
477
+ }
478
+ catch (err) {
479
+ if (err instanceof TaskNotFoundError) {
480
+ if (this.now().getTime() - Date.parse(pin.at) < this.referenceGraceMs)
481
+ return pin.childId; // creation in progress
482
+ continue; // abandoned
483
+ }
484
+ if (err instanceof TaskPrunedError)
485
+ continue;
486
+ throw err;
487
+ }
488
+ if (child.status === 'active')
489
+ return pin.childId;
490
+ }
491
+ return null;
492
+ }
493
+ async getState(taskId) {
494
+ return cloneState(await this.refresh(taskId));
495
+ }
496
+ /** The state when the log had `seq` events. */
497
+ async getStateAt(taskId, seq) {
498
+ const { events } = await this.loadFull(taskId);
499
+ if (!Number.isInteger(seq) || seq < 1 || seq > events.length)
500
+ throw new ValidationError(`seq must be between 1 and ${events.length}.`);
501
+ return foldUntil(events, seq);
502
+ }
503
+ async getLog(taskId) {
504
+ const { state, events, diagnostics } = await this.loadFull(taskId);
505
+ return { state, events, diagnostics };
506
+ }
507
+ async listTasks(filter = {}) {
508
+ const out = [];
509
+ for (const id of await this.store.listTaskIds()) {
510
+ let s;
511
+ try {
512
+ s = await this.refresh(id);
513
+ }
514
+ catch (err) {
515
+ if (err instanceof TaskPrunedError || err instanceof TaskNotFoundError)
516
+ continue;
517
+ throw err;
518
+ }
519
+ if (filter.status && s.status !== filter.status)
520
+ continue;
521
+ if (filter.kind && s.kind !== filter.kind)
522
+ continue;
523
+ if (filter.label && s.labels[filter.label[0]] !== filter.label[1])
524
+ continue;
525
+ out.push({
526
+ taskId: s.taskId, kind: s.kind, goal: s.goal, status: s.status, createdAt: s.createdAt, updatedAt: s.updatedAt,
527
+ closedAt: s.closedAt, headSeq: s.headSeq, parentTaskId: s.parentTaskId,
528
+ pendingAwaits: Object.values(s.awaits).filter((a) => a.status === 'pending').length,
529
+ leaseOwner: s.lease && !s.lease.released ? s.lease.owner : null,
530
+ });
531
+ }
532
+ return out;
533
+ }
534
+ // =========================================================================
535
+ // Worker identity: lease + fencing
536
+ // =========================================================================
537
+ /**
538
+ * Become the task's current worker. Fails with `LeaseHeldError` while another
539
+ * owner's lease is live; an expired or released lease (a dead worker) can be
540
+ * taken over. Every claim increments the epoch, which fences out the previous holder.
541
+ */
542
+ async claim(taskId, opts) {
543
+ assertId('owner', opts.owner);
544
+ const leaseMs = opts.leaseMs ?? this.defaultLeaseMs;
545
+ if (!Number.isFinite(leaseMs) || leaseMs <= 0)
546
+ throw new ValidationError('leaseMs must be positive.');
547
+ // Execution permission is decided from the required tasks as they are now, not from the log of this task.
548
+ const blocked = await this.blockingRequirement(await this.refresh(taskId), true);
549
+ if (blocked?.state === 'gone') {
550
+ await this.quarantine(taskId, blocked.id);
551
+ throw new TaskOrphanedError(taskId, blocked.id);
552
+ }
553
+ if (blocked?.state === 'missing')
554
+ throw new TaskOrphanedError(taskId, blocked.id); // refused, but nothing is cancelled (ADR-018 D2)
555
+ if (blocked)
556
+ throw new RequiredTaskPendingError(taskId, blocked.id); // fail closed, but not final: nothing is cancelled
557
+ return this.mutate(taskId, (s) => {
558
+ if (s.status !== 'active')
559
+ throw new TaskClosedError(taskId, s.status);
560
+ const now = this.now();
561
+ if (s.lease && leaseIsLive(s, now) && s.lease.owner !== opts.owner) {
562
+ throw new LeaseHeldError(taskId, s.lease.owner, s.lease.leaseUntil);
563
+ }
564
+ const epoch = s.epoch + 1;
565
+ return {
566
+ draft: draft('run.claimed', opts.owner, {
567
+ owner: opts.owner, epoch, leaseUntil: new Date(now.getTime() + leaseMs).toISOString(),
568
+ takeover: s.lease !== null && !s.lease.released,
569
+ }),
570
+ result: () => ({ taskId, owner: opts.owner, epoch }),
571
+ };
572
+ });
573
+ }
574
+ assertFence(s, f) {
575
+ if (s.status !== 'active')
576
+ throw new TaskClosedError(s.taskId, s.status);
577
+ if (!s.lease || s.lease.released || s.lease.owner !== f.owner || s.lease.epoch !== f.epoch) {
578
+ throw new StaleFenceError(s.taskId, f.owner, f.epoch, s.epoch);
579
+ }
580
+ }
581
+ /** Mutation by the fenced worker. */
582
+ fenced(f, plan) {
583
+ return this.mutate(f.taskId, (s) => {
584
+ this.assertFence(s, f);
585
+ return plan(s);
586
+ });
587
+ }
588
+ async heartbeat(f, leaseMs) {
589
+ const ms = leaseMs ?? this.defaultLeaseMs;
590
+ await this.fenced(f, () => ({
591
+ draft: draft('run.heartbeat', f.owner, { owner: f.owner, epoch: f.epoch, leaseUntil: new Date(this.now().getTime() + ms).toISOString() }),
592
+ result: () => undefined,
593
+ }));
594
+ }
595
+ /** Give the task up. Idempotent; a closed task or an already-superseded fence is a quiet no-op. */
596
+ async release(f) {
597
+ try {
598
+ await this.fenced(f, (s) => ({
599
+ draft: s.lease?.released ? null : draft('run.released', f.owner, { owner: f.owner, epoch: f.epoch }),
600
+ result: () => undefined,
601
+ }));
602
+ }
603
+ catch (err) {
604
+ if (err instanceof TaskClosedError || err instanceof StaleFenceError)
605
+ return;
606
+ throw err;
607
+ }
608
+ }
609
+ // =========================================================================
610
+ // Plan, decisions, assumptions, observations
611
+ // =========================================================================
612
+ async declarePlan(f, steps, reason) {
613
+ const decl = steps.map((s) => {
614
+ assertId('step id', s.id);
615
+ assertNonEmpty('step title', s.title, 500);
616
+ return { id: s.id, title: s.title, dependsOn: [...(s.dependsOn ?? [])] };
617
+ });
618
+ const problem = planProblem(decl);
619
+ if (problem)
620
+ throw new ValidationError(`invalid plan: ${problem}.`);
621
+ await this.fenced(f, (s) => {
622
+ const same = s.plan !== null && s.plan.length === decl.length &&
623
+ decl.every((d, i) => {
624
+ const p = s.plan[i];
625
+ return p.id === d.id && p.title === d.title && canonicalize(p.dependsOn) === canonicalize(d.dependsOn);
626
+ });
627
+ return { draft: same ? null : draft('plan.declared', f.owner, { steps: decl, reason: reason ?? null }), result: () => undefined };
628
+ });
629
+ }
630
+ /** Marks a step done. Idempotent: completing a done step is a no-op. */
631
+ async completeStep(f, stepId, result) {
632
+ const res = result === undefined ? null : toJson(result, 'result');
633
+ await this.fenced(f, (s) => {
634
+ const step = s.plan?.find((p) => p.id === stepId);
635
+ if (!step)
636
+ throw new ValidationError(`unknown plan step "${stepId}".`);
637
+ if (step.status === 'done')
638
+ return { draft: null, result: () => undefined };
639
+ if (step.status === 'skipped')
640
+ throw new ValidationError(`step "${stepId}" was skipped.`);
641
+ const open = step.dependsOn.filter((d) => s.plan.find((p) => p.id === d)?.status === 'pending');
642
+ if (open.length > 0)
643
+ throw new ValidationError(`step "${stepId}" depends on unfinished step(s): ${open.join(', ')}.`);
644
+ const waiting = Object.values(s.awaits).filter((a) => a.stepId === stepId && a.status !== 'resolved');
645
+ if (waiting.length > 0)
646
+ throw new ValidationError(`step "${stepId}" still waits for: ${waiting.map((a) => a.awaitId).join(', ')} (skipStep to bypass).`);
647
+ return { draft: draft('plan.step_done', f.owner, { stepId, result: res }), result: () => undefined };
648
+ });
649
+ }
650
+ async skipStep(f, stepId, reason) {
651
+ assertNonEmpty('reason', reason, TEXT_MAX);
652
+ await this.fenced(f, (s) => {
653
+ const step = s.plan?.find((p) => p.id === stepId);
654
+ if (!step)
655
+ throw new ValidationError(`unknown plan step "${stepId}".`);
656
+ if (step.status === 'skipped')
657
+ return { draft: null, result: () => undefined };
658
+ if (step.status === 'done')
659
+ throw new ValidationError(`step "${stepId}" is already done.`);
660
+ return { draft: draft('plan.step_skipped', f.owner, { stepId, reason }), result: () => undefined };
661
+ });
662
+ }
663
+ /** Records why something was chosen. Same id + same content is a no-op. */
664
+ async recordDecision(f, d) {
665
+ assertId('decision id', d.id);
666
+ assertNonEmpty('summary', d.summary, TEXT_MAX);
667
+ const data = { id: d.id, summary: d.summary, rationale: d.rationale ?? null, basedOn: [...(d.basedOn ?? [])] };
668
+ await this.fenced(f, (s) => {
669
+ const old = s.decisions.find((x) => x.id === d.id);
670
+ if (old) {
671
+ if (old.summary === data.summary && old.rationale === data.rationale && canonicalize(old.basedOn) === canonicalize(data.basedOn)) {
672
+ return { draft: null, result: () => undefined };
673
+ }
674
+ throw new ValidationError(`decision "${d.id}" already exists with different content.`);
675
+ }
676
+ return { draft: draft('decision.recorded', f.owner, data), result: () => undefined };
677
+ });
678
+ }
679
+ /**
680
+ * Records something the plan relies on, together with the value it was
681
+ * based on. `assess` later compares it with the current value of the same
682
+ * key. `blocking` (default): if it changes, the plan is not to be trusted.
683
+ * `advisory`: if it changes, adapt.
684
+ */
685
+ async recordAssumption(f, a) {
686
+ assertNonEmpty('assumption key', a.key, KEY_MAX);
687
+ assertNonEmpty('statement', a.statement, TEXT_MAX);
688
+ const value = toJson(a.value, 'assumption value');
689
+ const evidence = (a.evidence ?? []).map((e) => {
690
+ assertNonEmpty('evidence ref', e.ref, 1000);
691
+ return { ref: e.ref, note: e.note ?? null };
692
+ });
693
+ const data = {
694
+ key: a.key, statement: a.statement, criticality: a.criticality ?? 'blocking', fingerprint: fingerprintOf(value), value, evidence,
695
+ };
696
+ await this.fenced(f, (s) => {
697
+ const old = s.assumptions[a.key];
698
+ if (old && old.status === 'active' && old.fingerprint === data.fingerprint && old.statement === data.statement &&
699
+ old.criticality === data.criticality && canonicalize(old.evidence) === canonicalize(evidence)) {
700
+ return { draft: null, result: () => undefined };
701
+ }
702
+ if (old && old.status === 'active' && old.fingerprint !== data.fingerprint && !a.supersede) {
703
+ throw new ValidationError(`assumption "${a.key}" is already recorded with a different value; it was made before the world may have changed. ` +
704
+ 'Compare it with the current value (assess) and pass supersede: true only for a deliberate re-assessment.');
705
+ }
706
+ return { draft: draft('assumption.recorded', f.owner, data), result: () => undefined };
707
+ });
708
+ }
709
+ async retractAssumption(f, key, reason) {
710
+ assertNonEmpty('reason', reason, TEXT_MAX);
711
+ await this.fenced(f, (s) => {
712
+ const a = s.assumptions[key];
713
+ if (!a)
714
+ throw new ValidationError(`no assumption "${key}".`);
715
+ return { draft: a.status === 'retracted' ? null : draft('assumption.retracted', f.owner, { key, reason }), result: () => undefined };
716
+ });
717
+ }
718
+ /**
719
+ * Another agent or system reports a fact about the world. No fence: observers
720
+ * are not the task's worker. An observation equal to the latest one for the key is a no-op.
721
+ */
722
+ async observe(taskId, o) {
723
+ assertNonEmpty('observation key', o.key, KEY_MAX);
724
+ const value = toJson(o.value, 'observation value');
725
+ const fingerprint = fingerprintOf(value);
726
+ await this.mutate(taskId, (s) => {
727
+ if (s.observations[o.key]?.fingerprint === fingerprint)
728
+ return { draft: null, result: () => undefined };
729
+ return {
730
+ draft: draft('fact.observed', o.source ?? null, { key: o.key, fingerprint, value, source: o.source ?? null }),
731
+ result: () => undefined,
732
+ };
733
+ });
734
+ }
735
+ // =========================================================================
736
+ // Effects
737
+ // =========================================================================
738
+ /**
739
+ * Runs `fn` at most once *as far as the log can tell*, and records what happened.
740
+ * `key` should also be the idempotency key sent to the external system.
741
+ *
742
+ * - completed before: returns the recorded result, `fn` is not called;
743
+ * - intent recorded but no outcome (crash in between): calls `reconcile` if
744
+ * given, else throws `EffectInDoubtError` — it will not guess;
745
+ * - failed before and PROVABLY not executed (`EffectNotExecutedError` / `classifyError`): runs again
746
+ * if retryable (default), else `EffectFailedError`;
747
+ * - `fn` threw anything else: an exception does not prove the remote side did nothing (a timeout after
748
+ * the request was sent is the typical case), so the outcome is recorded as unknown and treated like an
749
+ * announced effect without outcome: `reconcile` or `EffectInDoubtError`, never an automatic re-run.
750
+ * This is "at most once as far as the log can tell", not exactly-once: only an idempotency key honoured
751
+ * by the external system (use `key` for it) makes a repeated call harmless.
752
+ *
753
+ * Concurrency. Calls with the same (fence, key) inside one runtime share a single execution: the
754
+ * later callers wait for it and receive its result (`replayed: true`) or its error. Between runtimes
755
+ * and processes the guarantee comes from the log: the announcement (`effect.intended`) is appended
756
+ * only if the effect is still exactly as this call found it, so of N callers that decided on the same
757
+ * state only one announces and runs `fn`; the others get `EffectConflictError` and `fn` is not
758
+ * called. A different owner finds the unfinished effect in doubt (its epoch is no longer current).
759
+ * A fence belongs to one worker; sharing one fence between processes is outside the contract
760
+ * (the compare-and-set still keeps `fn` single, but `reconcile` cannot tell a live peer from a dead one).
761
+ */
762
+ async runEffect(f, key, fn, opts) {
763
+ assertNonEmpty('effect key', key, KEY_MAX);
764
+ assertNonEmpty('description', opts.description, TEXT_MAX);
765
+ const params = opts.params === undefined ? null : toJson(opts.params, 'effect params');
766
+ // No await between the lookup and the registration: two callers cannot both miss the map.
767
+ const slot = `${f.taskId}\u0000${f.owner}\u0000${f.epoch}\u0000${key}`;
768
+ const running = this.inflight.get(slot);
769
+ if (running) {
770
+ const shared = await running;
771
+ return { result: shared.result, replayed: true };
772
+ }
773
+ const run = this.executeEffect(f, key, fn, opts, params);
774
+ this.inflight.set(slot, run);
775
+ try {
776
+ return await run;
777
+ }
778
+ finally {
779
+ if (this.inflight.get(slot) === run)
780
+ this.inflight.delete(slot);
781
+ }
782
+ }
783
+ async executeEffect(f, key, fn, opts, params) {
784
+ const taskId = f.taskId;
785
+ const state = await this.getState(taskId);
786
+ this.assertFence(state, f);
787
+ const prior = state.effects[key];
788
+ if (prior?.status === 'completed')
789
+ return { result: prior.result, replayed: true };
790
+ if (prior?.status === 'failed' && !prior.uncertain && !prior.retryable)
791
+ throw new EffectFailedError(taskId, key, prior.error ?? 'unknown');
792
+ // Announced without a recorded outcome, or failed with an unknown outcome: the external operation may have happened.
793
+ if (prior?.status === 'intended' || (prior?.status === 'failed' && prior.uncertain)) {
794
+ if (!opts.reconcile)
795
+ throw new EffectInDoubtError(taskId, key);
796
+ const verdict = await opts.reconcile(prior);
797
+ if (verdict.status === 'completed') {
798
+ const res = toJson(verdict.result, 'reconciled result');
799
+ const recorded = await this.fenced(f, (s) => {
800
+ const cur = s.effects[key];
801
+ if (cur?.status === 'completed')
802
+ return { draft: null, result: () => cur.result };
803
+ if (effectIdentity(cur) !== effectIdentity(prior))
804
+ throw new EffectConflictError(taskId, key);
805
+ return { draft: draft('effect.completed', f.owner, { key, result: res }), result: () => res };
806
+ });
807
+ return { result: recorded, replayed: true };
808
+ }
809
+ // not executed: fall through and run it, re-announcing the intent under our epoch.
810
+ }
811
+ // Announce. The append succeeds only if the effect is still as we found it.
812
+ const announced = await this.fenced(f, (s) => {
813
+ const cur = s.effects[key];
814
+ if (cur?.status === 'completed')
815
+ return { draft: null, result: () => ({ replay: cur.result }) };
816
+ if (effectIdentity(cur) !== effectIdentity(prior))
817
+ throw new EffectConflictError(taskId, key);
818
+ return {
819
+ draft: draft('effect.intended', f.owner, { key, description: opts.description, params, epoch: f.epoch }),
820
+ result: () => ({ replay: undefined }),
821
+ };
822
+ });
823
+ if (announced.replay !== undefined)
824
+ return { result: announced.replay, replayed: true };
825
+ let result;
826
+ try {
827
+ result = await fn();
828
+ }
829
+ catch (err) {
830
+ const message = err instanceof Error ? err.message : String(err);
831
+ const certain = (opts.classifyError ?? defaultClassify)(err) === 'not-executed';
832
+ await this.fenced(f, () => ({
833
+ // An unknown outcome is also written as `retryable: false`, so a reader that predates `uncertain` stays safe.
834
+ draft: draft('effect.failed', f.owner, {
835
+ key, error: message.slice(0, TEXT_MAX), retryable: certain ? (opts.retryable ?? true) : false, ...(certain ? {} : { uncertain: true }),
836
+ }),
837
+ result: () => undefined,
838
+ }));
839
+ throw err;
840
+ }
841
+ const res = toJson(result, 'effect result');
842
+ await this.fenced(f, (s) => ({
843
+ draft: s.effects[key]?.status === 'completed' ? null : draft('effect.completed', f.owner, { key, result: res }),
844
+ result: () => undefined,
845
+ }));
846
+ return { result, replayed: false };
847
+ }
848
+ // =========================================================================
849
+ // Waiting for the outside world
850
+ // =========================================================================
851
+ /**
852
+ * Announces that an answer is expected and returns the token the answer must
853
+ * carry. Idempotent per `awaitId`: a restarted worker that registers the same
854
+ * await again gets the same token and nothing new is written.
855
+ * `basisSeq` records where the log stood at send time, so "what did the task
856
+ * look like then, and what changed since" is always answerable.
857
+ */
858
+ async registerAwait(f, a) {
859
+ const awaitId = a.awaitId ?? `await-${this.newId()}`;
860
+ assertId('awaitId', awaitId);
861
+ assertNonEmpty('purpose', a.purpose, 500);
862
+ const request = a.request === undefined ? null : toJson(a.request, 'request');
863
+ const deadlineMs = a.deadlineAt === undefined ? null : new Date(a.deadlineAt).getTime();
864
+ if (deadlineMs !== null && !Number.isFinite(deadlineMs))
865
+ throw new ValidationError('deadlineAt is not a valid date.');
866
+ const deadlineAt = deadlineMs === null ? null : new Date(deadlineMs).toISOString();
867
+ return this.fenced(f, (s) => {
868
+ const old = s.awaits[awaitId];
869
+ if (old) {
870
+ if (old.purpose !== a.purpose || old.stepId !== (a.stepId ?? null)) {
871
+ throw new ValidationError(`await "${awaitId}" already exists with a different purpose or step.`);
872
+ }
873
+ return { draft: null, result: () => ({ awaitId, token: old.token, created: false }) };
874
+ }
875
+ if (a.stepId !== undefined && !s.plan?.some((p) => p.id === a.stepId)) {
876
+ throw new ValidationError(`await refers to unknown plan step "${a.stepId}"; declare the plan first.`);
877
+ }
878
+ const token = `${TOKEN_PREFIX}.${f.taskId}.${awaitId}.${randomBytes(16).toString('hex')}`;
879
+ return {
880
+ draft: draft('await.registered', f.owner, {
881
+ awaitId, token, purpose: a.purpose, stepId: a.stepId ?? null, request, deadlineAt, basisSeq: s.headSeq,
882
+ }),
883
+ result: () => ({ awaitId, token, created: true }),
884
+ };
885
+ });
886
+ }
887
+ /**
888
+ * Hand an externally produced result to the task it belongs to. Needs no
889
+ * live process and no fence — only the token. Safe to call repeatedly and in any order:
890
+ * - same `idempotencyKey` again → `duplicate`, nothing written;
891
+ * - a different result for an already resolved await → `conflict`; the payload is kept
892
+ * in the log (`await.delivery_rejected`) and does not replace the first one;
893
+ * - a result after the await's deadline passed → accepted, flagged `late`;
894
+ * - a result for a finished task → `ignored` (its log is sealed).
895
+ */
896
+ async deliver(token, payload, opts) {
897
+ assertNonEmpty('idempotencyKey', opts.idempotencyKey, KEY_MAX);
898
+ const parsed = parseToken(token);
899
+ if (!parsed)
900
+ return { kind: 'rejected', reason: 'malformed-token' };
901
+ const body = toJson(payload, 'payload');
902
+ const { taskId, awaitId, nonce } = parsed;
903
+ try {
904
+ return await this.mutate(taskId, (s) => {
905
+ const known = s.deliveryKeys[deliveryKey(awaitId, opts.idempotencyKey)];
906
+ const a = s.awaits[awaitId];
907
+ if (!a)
908
+ return { draft: null, result: () => ({ kind: 'rejected', reason: 'unknown-await' }) };
909
+ const got = Buffer.from(nonce);
910
+ const want = Buffer.from(a.token.split('.')[3] ?? '');
911
+ if (got.length !== want.length || !timingSafeEqual(got, want)) {
912
+ return { draft: null, result: () => ({ kind: 'rejected', reason: 'bad-token' }) };
913
+ }
914
+ if (known !== undefined)
915
+ return { draft: null, result: () => ({ kind: 'duplicate', taskId, awaitId, seq: known }) };
916
+ if (s.status !== 'active')
917
+ return { draft: null, result: () => ({ kind: 'ignored', reason: 'task-closed', taskId }) };
918
+ if (a.status === 'resolved') {
919
+ // Every distinct idempotency key would otherwise cost one stored event with a full payload. Keep the
920
+ // first few (bounded in size), then answer without writing: the caller learns it conflicts, the log stays bounded.
921
+ const rejectedInTask = Object.values(s.awaits).reduce((n, x) => n + x.rejectedDeliveries, 0);
922
+ if (a.rejectedDeliveries >= MAX_REJECTED_PER_AWAIT || rejectedInTask >= MAX_REJECTED_PER_TASK) {
923
+ return { draft: null, result: () => ({ kind: 'conflict', taskId, awaitId, seq: a.resolvedAtSeq, recorded: false }) };
924
+ }
925
+ return {
926
+ draft: draft('await.delivery_rejected', opts.source ?? null, {
927
+ awaitId, idempotencyKey: opts.idempotencyKey, reason: 'already-resolved', payload: boundedPayload(body),
928
+ }),
929
+ result: (e) => ({ kind: 'conflict', taskId, awaitId, seq: e.seq, recorded: true }),
930
+ };
931
+ }
932
+ return {
933
+ draft: draft('await.resolved', opts.source ?? null, {
934
+ awaitId, idempotencyKey: opts.idempotencyKey, payload: body, source: opts.source ?? null, late: a.status === 'expired',
935
+ }),
936
+ result: (e) => ({ kind: 'accepted', taskId, awaitId, seq: e.seq }),
937
+ };
938
+ });
939
+ }
940
+ catch (err) {
941
+ if (err instanceof TaskNotFoundError)
942
+ return { kind: 'rejected', reason: 'unknown-task' };
943
+ if (err instanceof TaskPrunedError)
944
+ return { kind: 'ignored', reason: 'task-pruned', taskId };
945
+ throw err;
946
+ }
947
+ }
948
+ /** Marks pending awaits whose deadline has passed as expired. Returns the awaits it expired. */
949
+ async sweepDeadlines(taskId) {
950
+ const ids = taskId ? [taskId] : await this.store.listTaskIds();
951
+ const expired = [];
952
+ for (const id of ids) {
953
+ let s;
954
+ try {
955
+ s = await this.refresh(id);
956
+ }
957
+ catch (err) {
958
+ if (err instanceof TaskPrunedError || err instanceof TaskNotFoundError)
959
+ continue;
960
+ throw err;
961
+ }
962
+ if (s.status !== 'active')
963
+ continue;
964
+ for (const a of Object.values(s.awaits)) {
965
+ if (a.status !== 'pending' || a.deadlineAt === null || Date.parse(a.deadlineAt) > this.now().getTime())
966
+ continue;
967
+ await this.mutate(id, (cur) => {
968
+ const x = cur.awaits[a.awaitId];
969
+ return { draft: x && x.status === 'pending' ? draft('await.expired', null, { awaitId: a.awaitId }) : null, result: () => undefined };
970
+ });
971
+ expired.push({ taskId: id, awaitId: a.awaitId });
972
+ }
973
+ }
974
+ return expired;
975
+ }
976
+ // =========================================================================
977
+ // Closing
978
+ // =========================================================================
979
+ async complete(f, result) {
980
+ const res = result === undefined ? null : toJson(result, 'result');
981
+ await this.fenced(f, () => ({ draft: draft('task.completed', f.owner, { result: res }), result: () => undefined }));
982
+ }
983
+ async fail(f, error, detail) {
984
+ assertNonEmpty('error', error, TEXT_MAX);
985
+ const d = detail === undefined ? null : toJson(detail, 'detail');
986
+ await this.fenced(f, () => ({ draft: draft('task.failed', f.owner, { error, detail: d }), result: () => undefined }));
987
+ }
988
+ /** Administrative stop: no fence needed. Idempotent for an already cancelled task. */
989
+ async cancel(taskId, reason, actor) {
990
+ assertNonEmpty('reason', reason, TEXT_MAX);
991
+ await this.mutate(taskId, (s) => ({
992
+ draft: s.status === 'cancelled' ? null : draft('task.cancelled', actor ?? null, { reason }),
993
+ result: () => undefined,
994
+ }));
995
+ }
996
+ // =========================================================================
997
+ // Resuming
998
+ // =========================================================================
999
+ async assess(taskId, opts = {}) {
1000
+ const { state, events } = await this.loadFull(taskId);
1001
+ return this.withRequirements(state, assessTask(state, events, { ...opts, now: opts.now ?? this.now() }), false);
1002
+ }
1003
+ /**
1004
+ * An active task whose required task is gone is reported as not claimable, with the reason; `claim` enforces it.
1005
+ * A required task whose prune is pending also makes it not claimable, but the task is not cancelled.
1006
+ * `recover`: roll back an unfinished prune of a required task (a write); `assess` / `resumePacket` do not.
1007
+ */
1008
+ async withRequirements(state, a, recover) {
1009
+ const blocked = await this.blockingRequirement(state, recover);
1010
+ if (blocked === null)
1011
+ return a;
1012
+ if (blocked.state === 'pending') {
1013
+ return {
1014
+ ...a,
1015
+ claimable: false,
1016
+ reasons: [...a.reasons, { code: 'REQUIRED_TASK_PRUNE_PENDING', severity: 'recovery', detail: `the prune of required task "${blocked.id}" is not settled; the task is not run now and is not cancelled`, refs: [blocked.id] }],
1017
+ };
1018
+ }
1019
+ if (blocked.state === 'missing') {
1020
+ return {
1021
+ ...a,
1022
+ claimable: false,
1023
+ reasons: [...a.reasons, { code: 'REQUIRED_TASK_GONE', severity: 'recovery', detail: `required task "${blocked.id}" was not found; the task is not run now and is not cancelled`, refs: [blocked.id] }],
1024
+ };
1025
+ }
1026
+ await this.quarantine(state.taskId, blocked.id);
1027
+ return {
1028
+ ...a,
1029
+ claimable: false,
1030
+ reasons: [...a.reasons, { code: 'REQUIRED_TASK_GONE', severity: 'recovery', detail: `required task "${blocked.id}" was pruned or does not exist; the task will not be run`, refs: [blocked.id] }],
1031
+ };
1032
+ }
1033
+ /** Everything a resumed worker — or a reasoning component — needs, in one deterministic document. */
1034
+ async resumePacket(taskId, opts = {}) {
1035
+ const { state, events } = await this.loadFull(taskId);
1036
+ const assessment = await this.withRequirements(state, assessTask(state, events, { ...opts, now: opts.now ?? this.now() }), false);
1037
+ const bases = [...new Set(assessment.results.map((r) => r.basisSeq))].sort((a, b) => a - b);
1038
+ const sinceSend = bases.map((basisSeq) => ({
1039
+ awaitId: assessment.results.filter((r) => r.basisSeq === basisSeq).map((r) => r.awaitId).sort().join(','),
1040
+ basisSeq,
1041
+ changes: diffStates(foldUntil(events, basisSeq), state),
1042
+ }));
1043
+ const timelineAll = events.map((e) => ({ seq: e.seq, type: e.type, at: e.at, actor: e.actor, key: this.keyOf(e) }));
1044
+ const byKey = (xs) => xs.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
1045
+ return {
1046
+ formatVersion: 1,
1047
+ generatedFromSeq: state.headSeq,
1048
+ task: {
1049
+ taskId: state.taskId, kind: state.kind, goal: state.goal, input: state.input, parentTaskId: state.parentTaskId,
1050
+ dependsOn: state.dependsOn, labels: state.labels, status: state.status, createdAt: state.createdAt, headSeq: state.headSeq,
1051
+ },
1052
+ assessment,
1053
+ plan: state.plan,
1054
+ decisions: state.decisions,
1055
+ assumptions: byKey(Object.values(state.assumptions)),
1056
+ observations: byKey(Object.values(state.observations)),
1057
+ effects: byKey(Object.values(state.effects)),
1058
+ awaits: Object.values(state.awaits).sort((a, b) => a.registeredAtSeq - b.registeredAtSeq),
1059
+ sinceSend,
1060
+ timeline: timelineAll.slice(-MAX_TIMELINE),
1061
+ timelineTruncated: timelineAll.length > MAX_TIMELINE,
1062
+ };
1063
+ }
1064
+ keyOf(e) {
1065
+ switch (e.type) {
1066
+ case 'fact.observed':
1067
+ case 'assumption.recorded':
1068
+ case 'assumption.retracted':
1069
+ case 'effect.intended':
1070
+ case 'effect.completed':
1071
+ case 'effect.failed':
1072
+ return e.data.key;
1073
+ case 'decision.recorded':
1074
+ return e.data.id;
1075
+ case 'await.registered':
1076
+ case 'await.resolved':
1077
+ case 'await.expired':
1078
+ case 'await.delivery_rejected':
1079
+ return e.data.awaitId;
1080
+ case 'plan.step_done':
1081
+ case 'plan.step_skipped':
1082
+ return e.data.stepId;
1083
+ default:
1084
+ return null;
1085
+ }
1086
+ }
1087
+ /**
1088
+ * The wake-up query of a scheduler after a restart: active tasks nobody is
1089
+ * working on that have something to do (`direct`, `adaptive`, `recovery`).
1090
+ * Tasks that only wait for more results are not returned.
1091
+ */
1092
+ async findResumable(opts = {}) {
1093
+ const out = [];
1094
+ for (const id of await this.store.listTaskIds()) {
1095
+ let loaded;
1096
+ try {
1097
+ loaded = await this.loadFull(id);
1098
+ }
1099
+ catch (err) {
1100
+ if (err instanceof TaskPrunedError || err instanceof TaskNotFoundError)
1101
+ continue;
1102
+ throw err;
1103
+ }
1104
+ if (loaded.state.status !== 'active')
1105
+ continue;
1106
+ const a = assessTask(loaded.state, loaded.events, { now: this.now(), observations: opts.observations?.(loaded.state) });
1107
+ if (!a.claimable || a.mode === 'waiting' || a.mode === 'closed')
1108
+ continue;
1109
+ if ((await this.withRequirements(loaded.state, a, true)).claimable === false)
1110
+ continue; // orphan or unsettled prune: not offered now; a pending one is retried by the next call
1111
+ out.push({ taskId: id, mode: a.mode, reasons: a.reasons.filter((r) => r.severity !== 'info').map((r) => r.code) });
1112
+ }
1113
+ return out;
1114
+ }
1115
+ // =========================================================================
1116
+ // Integrity and lifecycle
1117
+ // =========================================================================
1118
+ /** Re-reads and re-verifies logs end to end (hash chain, sequence, fold). Never repairs. */
1119
+ async verify(taskId) {
1120
+ const ids = taskId ? [taskId] : await this.store.listTaskIds();
1121
+ const reports = [];
1122
+ for (const id of ids) {
1123
+ try {
1124
+ const log = await this.store.readLog(id);
1125
+ if (log.tombstone) {
1126
+ reports.push({ taskId: id, ok: true, headSeq: log.tombstone.headSeq, status: 'pruned', error: null, diagnostics: log.diagnostics });
1127
+ continue;
1128
+ }
1129
+ if (!log.exists || log.events.length === 0)
1130
+ throw new TaskNotFoundError(id);
1131
+ verifyChain(id, log.events, 0, GENESIS_HASH);
1132
+ const s = foldEvents(log.events);
1133
+ reports.push({ taskId: id, ok: true, headSeq: s.headSeq, status: s.status, error: null, diagnostics: log.diagnostics });
1134
+ }
1135
+ catch (err) {
1136
+ reports.push({ taskId: id, ok: false, headSeq: null, status: null, error: err instanceof Error ? err.message : String(err), diagnostics: [] });
1137
+ }
1138
+ }
1139
+ return reports;
1140
+ }
1141
+ /**
1142
+ * Decide what retention would do. Nothing is changed. Tasks whose log fails
1143
+ * verification are skipped (never pruned or compacted while corrupt).
1144
+ */
1145
+ async planRetention(policy) {
1146
+ const states = [];
1147
+ const compacted = new Map();
1148
+ const interrupted = { compactions: new Set(), prunes: [] };
1149
+ for (const id of await this.store.listTaskIds()) {
1150
+ try {
1151
+ const l = await this.loadFull(id);
1152
+ states.push(l.state);
1153
+ compacted.set(id, l.compactedThrough);
1154
+ if (l.diagnostics.some((d) => d.code === 'LOOSE_COPY_OF_COMPACTED_EVENT'))
1155
+ interrupted.compactions.add(id);
1156
+ }
1157
+ catch (err) {
1158
+ if (err instanceof TaskPrunedError) {
1159
+ // A tombstone with data still on disk is an interrupted prune; a finished one needs nothing.
1160
+ const head = await this.store.readLog(id, { afterSeq: Number.MAX_SAFE_INTEGER });
1161
+ if (head.diagnostics.some((d) => d.code === 'PRUNE_INCOMPLETE'))
1162
+ interrupted.prunes.push(id);
1163
+ continue;
1164
+ }
1165
+ if (err instanceof TaskNotFoundError)
1166
+ continue;
1167
+ if (err instanceof LogCorruptError)
1168
+ continue;
1169
+ throw err;
1170
+ }
1171
+ }
1172
+ return planRetention(states, compacted, policy, this.now(), interrupted);
1173
+ }
1174
+ /** Executes a plan. Every destructive step re-checks its precondition against the live log. */
1175
+ async applyRetention(policy) {
1176
+ const decisions = await this.planRetention(policy);
1177
+ const applied = [];
1178
+ for (const d of decisions) {
1179
+ if (d.action === 'compact') {
1180
+ const n = await this.compact(d.taskId, { throughSeq: d.throughSeq });
1181
+ applied.push({ taskId: d.taskId, action: 'compact', detail: `${n} events packed` });
1182
+ }
1183
+ else if (d.action === 'prune') {
1184
+ try {
1185
+ const t = await this.pruneTask(d.taskId, policy);
1186
+ applied.push({ taskId: d.taskId, action: 'prune', detail: `tombstone ${t.headHash.slice(0, 12)}` });
1187
+ }
1188
+ catch (err) {
1189
+ if (err instanceof RetentionRefusedError)
1190
+ continue; // state changed since planning: keep
1191
+ throw err;
1192
+ }
1193
+ }
1194
+ }
1195
+ return { decisions, applied };
1196
+ }
1197
+ /**
1198
+ * Packs events up to `throughSeq` into verified segments. Returns the number of events packed.
1199
+ * Also finishes an interrupted earlier compaction: loose copies that a published segment already covers
1200
+ * are removed (only if byte-identical to the segment), even when there is nothing new to pack.
1201
+ */
1202
+ async compact(taskId, opts) {
1203
+ const { state, events, diagnostics, compactedThrough } = await this.loadFull(taskId);
1204
+ if (diagnostics.some((d) => d.code === 'LOOSE_COPY_OF_COMPACTED_EVENT'))
1205
+ await this.store.sweepCompacted?.(taskId);
1206
+ const through = Math.min(opts.throughSeq, state.headSeq - 1);
1207
+ if (through <= compactedThrough)
1208
+ return 0;
1209
+ const todo = events.filter((e) => e.seq > compactedThrough && e.seq <= through);
1210
+ let packed = 0;
1211
+ let chunk = [];
1212
+ let bytes = 0;
1213
+ const flush = async () => {
1214
+ if (chunk.length === 0)
1215
+ return;
1216
+ await this.store.compact(taskId, chunk[chunk.length - 1].seq, chunk);
1217
+ packed += chunk.length;
1218
+ chunk = [];
1219
+ bytes = 0;
1220
+ };
1221
+ for (const e of todo) {
1222
+ const size = Buffer.byteLength(canonicalize(e), 'utf8') + 1;
1223
+ if (bytes + size > COMPACT_CHUNK_BYTES)
1224
+ await flush();
1225
+ chunk.push(e);
1226
+ bytes += size;
1227
+ }
1228
+ await flush();
1229
+ return packed;
1230
+ }
1231
+ /**
1232
+ * Removes a finished task's log, leaving a tombstone. Refuses unless the
1233
+ * task is terminal, past retention, and not referenced by an active task —
1234
+ * checked now, against the live log, not against an earlier plan.
1235
+ */
1236
+ async pruneTask(taskId, policy) {
1237
+ // A tombstone means an earlier call already decided (terminal, past retention, unreferenced) and published
1238
+ // that decision before removing anything. Finishing is therefore safe without deciding again, and it is
1239
+ // the only way to complete a prune that was interrupted. `store.prune` is idempotent and removes nothing
1240
+ // but event/segment/staging files of this task. One question is asked again, though: a task that started
1241
+ // referring to it in the meantime (see the pins) must not be left pointing at deleted data.
1242
+ const marker = await this.store.readLog(taskId, { afterSeq: Number.MAX_SAFE_INTEGER });
1243
+ if (marker.tombstone) {
1244
+ const holder = await this.activeReferrer(taskId);
1245
+ if (holder !== null)
1246
+ await this.refuseAfterTombstone(marker.tombstone, holder);
1247
+ await this.store.prune(marker.tombstone);
1248
+ this.cache.delete(taskId);
1249
+ return marker.tombstone;
1250
+ }
1251
+ const { state } = await this.loadFull(taskId);
1252
+ if (state.status === 'active')
1253
+ throw new RetentionRefusedError(taskId, 'the task is still active');
1254
+ const age = this.now().getTime() - Date.parse(state.closedAt);
1255
+ if (age < policy.terminalRetentionMs)
1256
+ throw new RetentionRefusedError(taskId, 'the task is within its retention period');
1257
+ const early = await this.activeReferrer(taskId);
1258
+ if (early !== null)
1259
+ throw new RetentionRefusedError(taskId, `active task "${early}" refers to it`);
1260
+ const tomb = tombstoneFor(state, this.now().toISOString());
1261
+ // The check above can be overtaken: another process may create a task that refers to this one between the
1262
+ // check and the removal. So publish the tombstone first (new creations now refuse), then look again
1263
+ // (creations that pinned before the tombstone are visible), and take the tombstone back if one is found.
1264
+ if (this.store.markPruned && this.store.withdrawTombstone) {
1265
+ await this.store.markPruned(tomb);
1266
+ const late = await this.activeReferrer(taskId);
1267
+ if (late !== null)
1268
+ await this.refuseAfterTombstone(tomb, late);
1269
+ }
1270
+ await this.store.prune(tomb);
1271
+ this.cache.delete(taskId);
1272
+ return tomb;
1273
+ }
1274
+ /** A referrer showed up after the tombstone was published: undo the prune if the data is still complete. */
1275
+ async refuseAfterTombstone(tomb, holder) {
1276
+ try {
1277
+ await this.store.withdrawTombstone?.(tomb);
1278
+ }
1279
+ catch (err) {
1280
+ if (!(err instanceof LogCorruptError))
1281
+ throw err;
1282
+ this.cache.delete(tomb.taskId);
1283
+ throw new RetentionRefusedError(tomb.taskId, `active task "${holder}" refers to it, but its data was already partly removed; restore it from a backup or cancel "${holder}"`);
1284
+ }
1285
+ this.cache.delete(tomb.taskId);
1286
+ throw new RetentionRefusedError(tomb.taskId, `active task "${holder}" refers to it`);
1287
+ }
1288
+ }