@plurnk/plurnk-service 1.3.12 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (488) hide show
  1. package/.env.defaults +95 -91
  2. package/INSTALL.md +54 -28
  3. package/README.md +25 -10
  4. package/SPEC.md +2426 -942
  5. package/dist/Paths.d.ts +4 -1
  6. package/dist/Paths.d.ts.map +1 -1
  7. package/dist/Paths.js +24 -23
  8. package/dist/Paths.js.map +1 -1
  9. package/dist/build-info.json +1 -1
  10. package/dist/content/edit-receipt.d.ts +13 -28
  11. package/dist/content/edit-receipt.d.ts.map +1 -1
  12. package/dist/content/edit-receipt.js +285 -104
  13. package/dist/content/edit-receipt.js.map +1 -1
  14. package/dist/content/edited-span.d.ts.map +1 -1
  15. package/dist/content/edited-span.js +2 -5
  16. package/dist/content/edited-span.js.map +1 -1
  17. package/dist/content/index.d.ts +4 -3
  18. package/dist/content/index.d.ts.map +1 -1
  19. package/dist/content/index.js +2 -1
  20. package/dist/content/index.js.map +1 -1
  21. package/dist/content/line-marker.d.ts +10 -9
  22. package/dist/content/line-marker.d.ts.map +1 -1
  23. package/dist/content/line-marker.js +8 -11
  24. package/dist/content/line-marker.js.map +1 -1
  25. package/dist/content/matcher.d.ts +15 -26
  26. package/dist/content/matcher.d.ts.map +1 -1
  27. package/dist/content/matcher.js +77 -107
  28. package/dist/content/matcher.js.map +1 -1
  29. package/dist/content/mimetype-binary.d.ts +2 -2
  30. package/dist/content/mimetype-binary.d.ts.map +1 -1
  31. package/dist/content/mimetype-binary.js +10 -10
  32. package/dist/content/mimetype-binary.js.map +1 -1
  33. package/dist/content/path-mimetype.js +3 -3
  34. package/dist/content/path-mimetype.js.map +1 -1
  35. package/dist/content/read-projector.d.ts +14 -0
  36. package/dist/content/read-projector.d.ts.map +1 -0
  37. package/dist/content/read-projector.js +86 -0
  38. package/dist/content/read-projector.js.map +1 -0
  39. package/dist/content/read-resolve.d.ts +6 -7
  40. package/dist/content/read-resolve.d.ts.map +1 -1
  41. package/dist/content/read-resolve.js +36 -59
  42. package/dist/content/read-resolve.js.map +1 -1
  43. package/dist/core/BranchReceipt.d.ts +6 -0
  44. package/dist/core/BranchReceipt.d.ts.map +1 -0
  45. package/dist/core/BranchReceipt.js +24 -0
  46. package/dist/core/BranchReceipt.js.map +1 -0
  47. package/dist/core/BudgetOverflow.d.ts +20 -0
  48. package/dist/core/BudgetOverflow.d.ts.map +1 -0
  49. package/dist/core/BudgetOverflow.js +49 -0
  50. package/dist/core/BudgetOverflow.js.map +1 -0
  51. package/dist/core/BudgetReadout.d.ts +8 -0
  52. package/dist/core/BudgetReadout.d.ts.map +1 -0
  53. package/dist/core/BudgetReadout.js +79 -0
  54. package/dist/core/BudgetReadout.js.map +1 -0
  55. package/dist/core/ChannelWrite.d.ts +31 -9
  56. package/dist/core/ChannelWrite.d.ts.map +1 -1
  57. package/dist/core/ChannelWrite.js +30 -23
  58. package/dist/core/ChannelWrite.js.map +1 -1
  59. package/dist/core/ChannelWrite.sql +14 -12
  60. package/dist/core/CoreSchemeServices.d.ts +18 -4
  61. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  62. package/dist/core/CoreSchemeServices.js +7 -1
  63. package/dist/core/CoreSchemeServices.js.map +1 -1
  64. package/dist/core/Dispatcher.d.ts +26 -21
  65. package/dist/core/Dispatcher.d.ts.map +1 -1
  66. package/dist/core/Dispatcher.js +1626 -671
  67. package/dist/core/Dispatcher.js.map +1 -1
  68. package/dist/core/DurableStatement.d.ts +6 -0
  69. package/dist/core/DurableStatement.d.ts.map +1 -0
  70. package/dist/core/DurableStatement.js +51 -0
  71. package/dist/core/DurableStatement.js.map +1 -0
  72. package/dist/core/Engine.d.ts +64 -29
  73. package/dist/core/Engine.d.ts.map +1 -1
  74. package/dist/core/Engine.js +1348 -835
  75. package/dist/core/Engine.js.map +1 -1
  76. package/dist/core/Engine.sql +329 -234
  77. package/dist/core/EnvFlags.js +1 -1
  78. package/dist/core/EnvFlags.js.map +1 -1
  79. package/dist/core/ErrorDetail.d.ts +6 -0
  80. package/dist/core/ErrorDetail.d.ts.map +1 -0
  81. package/dist/core/ErrorDetail.js +20 -0
  82. package/dist/core/ErrorDetail.js.map +1 -0
  83. package/dist/core/ExecutorRegistry.d.ts +14 -3
  84. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  85. package/dist/core/ExecutorRegistry.js +56 -30
  86. package/dist/core/ExecutorRegistry.js.map +1 -1
  87. package/dist/core/GitBranch.d.ts +20 -0
  88. package/dist/core/GitBranch.d.ts.map +1 -0
  89. package/dist/core/GitBranch.js +110 -0
  90. package/dist/core/GitBranch.js.map +1 -0
  91. package/dist/core/JournalTurn.d.ts +9 -0
  92. package/dist/core/JournalTurn.d.ts.map +1 -0
  93. package/dist/core/JournalTurn.js +14 -0
  94. package/dist/core/JournalTurn.js.map +1 -0
  95. package/dist/core/JournalTurn.sql +10 -0
  96. package/dist/core/LogBody.d.ts +18 -0
  97. package/dist/core/LogBody.d.ts.map +1 -0
  98. package/dist/core/LogBody.js +180 -0
  99. package/dist/core/LogBody.js.map +1 -0
  100. package/dist/core/LoopFlagsReader.d.ts +7 -0
  101. package/dist/core/LoopFlagsReader.d.ts.map +1 -0
  102. package/dist/core/LoopFlagsReader.js +33 -0
  103. package/dist/core/LoopFlagsReader.js.map +1 -0
  104. package/dist/core/LoopLifecycle.d.ts +8 -2
  105. package/dist/core/LoopLifecycle.d.ts.map +1 -1
  106. package/dist/core/LoopLifecycle.js +54 -11
  107. package/dist/core/LoopLifecycle.js.map +1 -1
  108. package/dist/core/LoopLifecycle.sql +10 -7
  109. package/dist/core/NoticeChannel.d.ts +13 -0
  110. package/dist/core/NoticeChannel.d.ts.map +1 -0
  111. package/dist/core/NoticeChannel.js +40 -0
  112. package/dist/core/NoticeChannel.js.map +1 -0
  113. package/dist/core/Owner.d.ts +0 -1
  114. package/dist/core/Owner.d.ts.map +1 -1
  115. package/dist/core/Owner.js +2 -5
  116. package/dist/core/Owner.js.map +1 -1
  117. package/dist/core/PacketBuilder.d.ts +27 -25
  118. package/dist/core/PacketBuilder.d.ts.map +1 -1
  119. package/dist/core/PacketBuilder.js +321 -383
  120. package/dist/core/PacketBuilder.js.map +1 -1
  121. package/dist/core/ProblemLog.d.ts +26 -0
  122. package/dist/core/ProblemLog.d.ts.map +1 -0
  123. package/dist/core/ProblemLog.js +58 -0
  124. package/dist/core/ProblemLog.js.map +1 -0
  125. package/dist/core/ProposalLifecycle.d.ts +15 -21
  126. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  127. package/dist/core/ProposalLifecycle.js +291 -78
  128. package/dist/core/ProposalLifecycle.js.map +1 -1
  129. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  130. package/dist/core/ProviderInstantiate.js +51 -48
  131. package/dist/core/ProviderInstantiate.js.map +1 -1
  132. package/dist/core/SchemeRegistry.d.ts +14 -8
  133. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  134. package/dist/core/SchemeRegistry.js +189 -114
  135. package/dist/core/SchemeRegistry.js.map +1 -1
  136. package/dist/core/ServiceTeardown.d.ts +11 -0
  137. package/dist/core/ServiceTeardown.d.ts.map +1 -0
  138. package/dist/core/ServiceTeardown.js +70 -0
  139. package/dist/core/ServiceTeardown.js.map +1 -0
  140. package/dist/core/StoredPacket.d.ts +29 -0
  141. package/dist/core/StoredPacket.d.ts.map +1 -0
  142. package/dist/core/StoredPacket.js +147 -0
  143. package/dist/core/StoredPacket.js.map +1 -0
  144. package/dist/core/StrikeRail.d.ts +6 -3
  145. package/dist/core/StrikeRail.d.ts.map +1 -1
  146. package/dist/core/StrikeRail.js +27 -56
  147. package/dist/core/StrikeRail.js.map +1 -1
  148. package/dist/core/TerminalResult.d.ts +16 -0
  149. package/dist/core/TerminalResult.d.ts.map +1 -0
  150. package/dist/core/TerminalResult.js +49 -0
  151. package/dist/core/TerminalResult.js.map +1 -0
  152. package/dist/core/WorkerControlAddress.d.ts +17 -0
  153. package/dist/core/WorkerControlAddress.d.ts.map +1 -0
  154. package/dist/core/WorkerControlAddress.js +44 -0
  155. package/dist/core/WorkerControlAddress.js.map +1 -0
  156. package/dist/core/WorkerName.d.ts +31 -0
  157. package/dist/core/WorkerName.d.ts.map +1 -0
  158. package/dist/core/WorkerName.js +95 -0
  159. package/dist/core/WorkerName.js.map +1 -0
  160. package/dist/core/WorkerName.sql +36 -0
  161. package/dist/core/WorkspaceGate.d.ts +14 -0
  162. package/dist/core/WorkspaceGate.d.ts.map +1 -0
  163. package/dist/core/WorkspaceGate.js +155 -0
  164. package/dist/core/WorkspaceGate.js.map +1 -0
  165. package/dist/core/caps/CapsResolve.d.ts +5 -1
  166. package/dist/core/caps/CapsResolve.d.ts.map +1 -1
  167. package/dist/core/caps/CapsResolve.js +8 -4
  168. package/dist/core/caps/CapsResolve.js.map +1 -1
  169. package/dist/core/caps/DbChannelCaps.d.ts +5 -11
  170. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -1
  171. package/dist/core/caps/DbChannelCaps.js +30 -12
  172. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  173. package/dist/core/caps/DbEntryCaps.d.ts +5 -14
  174. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
  175. package/dist/core/caps/DbEntryCaps.js +27 -8
  176. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  177. package/dist/core/caps/DbNotifyCaps.d.ts +1 -1
  178. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -1
  179. package/dist/core/caps/DbNotifyCaps.js +18 -15
  180. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  181. package/dist/core/caps/DbProjectionCaps.d.ts +5 -5
  182. package/dist/core/caps/DbProjectionCaps.d.ts.map +1 -1
  183. package/dist/core/caps/DbProjectionCaps.js +21 -4
  184. package/dist/core/caps/DbProjectionCaps.js.map +1 -1
  185. package/dist/core/caps/DbSubscriptionCaps.d.ts +4 -6
  186. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
  187. package/dist/core/caps/DbSubscriptionCaps.js +75 -66
  188. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  189. package/dist/core/caps/DbTagCaps.d.ts +5 -12
  190. package/dist/core/caps/DbTagCaps.d.ts.map +1 -1
  191. package/dist/core/caps/DbTagCaps.js +20 -11
  192. package/dist/core/caps/DbTagCaps.js.map +1 -1
  193. package/dist/core/caps/SchemeCtxImpl.d.ts +6 -1
  194. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  195. package/dist/core/caps/SchemeCtxImpl.js +6 -6
  196. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  197. package/dist/core/content-hash.js +1 -1
  198. package/dist/core/content-hash.js.map +1 -1
  199. package/dist/core/env-defaults.d.ts.map +1 -1
  200. package/dist/core/env-defaults.js.map +1 -1
  201. package/dist/core/fork.d.ts.map +1 -1
  202. package/dist/core/fork.js +55 -30
  203. package/dist/core/fork.js.map +1 -1
  204. package/dist/core/fork.sql +43 -29
  205. package/dist/core/git-env.d.ts +2 -0
  206. package/dist/core/git-env.d.ts.map +1 -1
  207. package/dist/core/git-env.js +13 -18
  208. package/dist/core/git-env.js.map +1 -1
  209. package/dist/core/git-iso.d.ts +3 -2
  210. package/dist/core/git-iso.d.ts.map +1 -1
  211. package/dist/core/git-iso.js +36 -27
  212. package/dist/core/git-iso.js.map +1 -1
  213. package/dist/core/git-membership.d.ts +2 -1
  214. package/dist/core/git-membership.d.ts.map +1 -1
  215. package/dist/core/git-membership.js +276 -162
  216. package/dist/core/git-membership.js.map +1 -1
  217. package/dist/core/git-state.d.ts +8 -1
  218. package/dist/core/git-state.d.ts.map +1 -1
  219. package/dist/core/git-state.js +44 -26
  220. package/dist/core/git-state.js.map +1 -1
  221. package/dist/core/namespace.d.ts +1 -0
  222. package/dist/core/namespace.d.ts.map +1 -1
  223. package/dist/core/namespace.js +20 -0
  224. package/dist/core/namespace.js.map +1 -1
  225. package/dist/core/optimistic-settlement.d.ts +2 -0
  226. package/dist/core/optimistic-settlement.d.ts.map +1 -0
  227. package/dist/core/optimistic-settlement.js +14 -0
  228. package/dist/core/optimistic-settlement.js.map +1 -0
  229. package/dist/core/owner.sql +1 -1
  230. package/dist/core/packet-inject.d.ts.map +1 -1
  231. package/dist/core/packet-inject.js +7 -11
  232. package/dist/core/packet-inject.js.map +1 -1
  233. package/dist/core/packet-wire.d.ts +3 -21
  234. package/dist/core/packet-wire.d.ts.map +1 -1
  235. package/dist/core/packet-wire.js +305 -385
  236. package/dist/core/packet-wire.js.map +1 -1
  237. package/dist/core/plurnk-uri.d.ts +12 -1
  238. package/dist/core/plurnk-uri.d.ts.map +1 -1
  239. package/dist/core/plurnk-uri.js +49 -29
  240. package/dist/core/plurnk-uri.js.map +1 -1
  241. package/dist/core/results.d.ts +18 -5
  242. package/dist/core/results.d.ts.map +1 -1
  243. package/dist/core/results.js +33 -18
  244. package/dist/core/results.js.map +1 -1
  245. package/dist/core/ruler_count.d.ts +4 -0
  246. package/dist/core/ruler_count.d.ts.map +1 -0
  247. package/dist/core/ruler_count.js +7 -0
  248. package/dist/core/ruler_count.js.map +1 -0
  249. package/dist/core/scheme-types.d.ts +3 -17
  250. package/dist/core/scheme-types.d.ts.map +1 -1
  251. package/dist/core/scheme-types.js.map +1 -1
  252. package/dist/core/search-gate.d.ts.map +1 -1
  253. package/dist/core/search-gate.js +3 -11
  254. package/dist/core/search-gate.js.map +1 -1
  255. package/dist/core/teaching-corpus.d.ts +6 -0
  256. package/dist/core/teaching-corpus.d.ts.map +1 -0
  257. package/dist/core/teaching-corpus.js +23 -0
  258. package/dist/core/teaching-corpus.js.map +1 -0
  259. package/dist/core/teaching.d.ts +0 -1
  260. package/dist/core/teaching.d.ts.map +1 -1
  261. package/dist/core/teaching.js +2 -8
  262. package/dist/core/teaching.js.map +1 -1
  263. package/dist/core/token-ruler.d.ts.map +1 -1
  264. package/dist/core/token-ruler.js +3 -16
  265. package/dist/core/token-ruler.js.map +1 -1
  266. package/dist/core/turn-scheduler.d.ts +1 -1
  267. package/dist/core/turn-scheduler.d.ts.map +1 -1
  268. package/dist/core/turn-scheduler.js +1 -1
  269. package/dist/core/turn-scheduler.js.map +1 -1
  270. package/dist/core/worker-cap.d.ts +2 -4
  271. package/dist/core/worker-cap.d.ts.map +1 -1
  272. package/dist/core/worker-cap.js +13 -4
  273. package/dist/core/worker-cap.js.map +1 -1
  274. package/dist/core/{run-ops.sql → worker-ops.sql} +14 -14
  275. package/dist/core/workspace-settings.d.ts.map +1 -1
  276. package/dist/core/workspace-settings.js +17 -9
  277. package/dist/core/workspace-settings.js.map +1 -1
  278. package/dist/digest/Digest.d.ts +2 -1
  279. package/dist/digest/Digest.d.ts.map +1 -1
  280. package/dist/digest/Digest.js +510 -149
  281. package/dist/digest/Digest.js.map +1 -1
  282. package/dist/digest/digest.sql +23 -10
  283. package/dist/index.d.ts.map +1 -1
  284. package/dist/index.js +3 -5
  285. package/dist/index.js.map +1 -1
  286. package/dist/observe/api.d.ts +6 -0
  287. package/dist/observe/api.d.ts.map +1 -0
  288. package/dist/observe/api.js +11 -0
  289. package/dist/observe/api.js.map +1 -0
  290. package/dist/observe/init.d.ts +7 -0
  291. package/dist/observe/init.d.ts.map +1 -0
  292. package/dist/observe/init.js +159 -0
  293. package/dist/observe/init.js.map +1 -0
  294. package/dist/observe/metrics.d.ts +6 -0
  295. package/dist/observe/metrics.d.ts.map +1 -0
  296. package/dist/observe/metrics.js +15 -0
  297. package/dist/observe/metrics.js.map +1 -0
  298. package/dist/observe/spans.d.ts +4 -0
  299. package/dist/observe/spans.d.ts.map +1 -0
  300. package/dist/observe/spans.js +51 -0
  301. package/dist/observe/spans.js.map +1 -0
  302. package/dist/schemes/EffectPolicy.d.ts +1 -0
  303. package/dist/schemes/EffectPolicy.d.ts.map +1 -1
  304. package/dist/schemes/EffectPolicy.js +5 -2
  305. package/dist/schemes/EffectPolicy.js.map +1 -1
  306. package/dist/schemes/Exec.d.ts +9 -14
  307. package/dist/schemes/Exec.d.ts.map +1 -1
  308. package/dist/schemes/Exec.js +402 -262
  309. package/dist/schemes/Exec.js.map +1 -1
  310. package/dist/schemes/ExecOutputScheme.d.ts +9 -9
  311. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  312. package/dist/schemes/ExecOutputScheme.js +49 -18
  313. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  314. package/dist/schemes/File.d.ts +7 -13
  315. package/dist/schemes/File.d.ts.map +1 -1
  316. package/dist/schemes/File.js +280 -151
  317. package/dist/schemes/File.js.map +1 -1
  318. package/dist/schemes/Log.d.ts +8 -12
  319. package/dist/schemes/Log.d.ts.map +1 -1
  320. package/dist/schemes/Log.js +402 -233
  321. package/dist/schemes/Log.js.map +1 -1
  322. package/dist/schemes/Log.sql +71 -32
  323. package/dist/schemes/Prompt.d.ts +3 -3
  324. package/dist/schemes/Prompt.d.ts.map +1 -1
  325. package/dist/schemes/Prompt.js +10 -10
  326. package/dist/schemes/Prompt.js.map +1 -1
  327. package/dist/schemes/Skill.d.ts +2 -3
  328. package/dist/schemes/Skill.d.ts.map +1 -1
  329. package/dist/schemes/Skill.js +2 -6
  330. package/dist/schemes/Skill.js.map +1 -1
  331. package/dist/schemes/Worker.d.ts +11 -19
  332. package/dist/schemes/Worker.d.ts.map +1 -1
  333. package/dist/schemes/Worker.js +210 -89
  334. package/dist/schemes/Worker.js.map +1 -1
  335. package/dist/schemes/_entry-chunk.d.ts.map +1 -1
  336. package/dist/schemes/_entry-chunk.js +12 -5
  337. package/dist/schemes/_entry-chunk.js.map +1 -1
  338. package/dist/schemes/_entry-crud.d.ts +11 -9
  339. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  340. package/dist/schemes/_entry-crud.js +72 -13
  341. package/dist/schemes/_entry-crud.js.map +1 -1
  342. package/dist/schemes/_entry-crud.sql +28 -10
  343. package/dist/schemes/_entry-find.d.ts +49 -18
  344. package/dist/schemes/_entry-find.d.ts.map +1 -1
  345. package/dist/schemes/_entry-find.js +348 -195
  346. package/dist/schemes/_entry-find.js.map +1 -1
  347. package/dist/schemes/_entry-find.sql +20 -8
  348. package/dist/schemes/_entry-graph.d.ts +4 -2
  349. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  350. package/dist/schemes/_entry-graph.js +55 -31
  351. package/dist/schemes/_entry-graph.js.map +1 -1
  352. package/dist/schemes/_entry-graph.sql +37 -22
  353. package/dist/schemes/_entry-manifest.d.ts +10 -15
  354. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  355. package/dist/schemes/_entry-manifest.js +50 -268
  356. package/dist/schemes/_entry-manifest.js.map +1 -1
  357. package/dist/schemes/_entry-ops.d.ts +14 -15
  358. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  359. package/dist/schemes/_entry-ops.js +121 -110
  360. package/dist/schemes/_entry-ops.js.map +1 -1
  361. package/dist/schemes/_entry-ops.sql +2 -3
  362. package/dist/schemes/_entry-semantic.d.ts +22 -9
  363. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  364. package/dist/schemes/_entry-semantic.js +127 -67
  365. package/dist/schemes/_entry-semantic.js.map +1 -1
  366. package/dist/schemes/_entry-semantic.sql +63 -45
  367. package/dist/schemes/_entry-send.d.ts +3 -4
  368. package/dist/schemes/_entry-send.d.ts.map +1 -1
  369. package/dist/schemes/_entry-send.js +48 -23
  370. package/dist/schemes/_entry-send.js.map +1 -1
  371. package/dist/schemes/_path-scope.d.ts +23 -0
  372. package/dist/schemes/_path-scope.d.ts.map +1 -0
  373. package/dist/schemes/_path-scope.js +61 -0
  374. package/dist/schemes/_path-scope.js.map +1 -0
  375. package/dist/schemes/_search-candidate.d.ts +20 -0
  376. package/dist/schemes/_search-candidate.d.ts.map +1 -0
  377. package/dist/schemes/_search-candidate.js +19 -0
  378. package/dist/schemes/_search-candidate.js.map +1 -0
  379. package/dist/schemes/_search-exclusion.d.ts +7 -0
  380. package/dist/schemes/_search-exclusion.d.ts.map +1 -0
  381. package/dist/schemes/_search-exclusion.js +23 -0
  382. package/dist/schemes/_search-exclusion.js.map +1 -0
  383. package/dist/schemes/_search-index.d.ts +6 -0
  384. package/dist/schemes/_search-index.d.ts.map +1 -0
  385. package/dist/schemes/_search-index.js +328 -0
  386. package/dist/schemes/_search-index.js.map +1 -0
  387. package/dist/schemes/cosine.js +1 -1
  388. package/dist/schemes/cosine.js.map +1 -1
  389. package/dist/schemes/exec-abort.js +3 -3
  390. package/dist/schemes/exec-abort.js.map +1 -1
  391. package/dist/schemes/exec-env.js +3 -3
  392. package/dist/schemes/exec-env.js.map +1 -1
  393. package/dist/server/BranchBatches.d.ts +49 -0
  394. package/dist/server/BranchBatches.d.ts.map +1 -0
  395. package/dist/server/BranchBatches.js +592 -0
  396. package/dist/server/BranchBatches.js.map +1 -0
  397. package/dist/server/Daemon.d.ts +39 -71
  398. package/dist/server/Daemon.d.ts.map +1 -1
  399. package/dist/server/Daemon.js +1133 -491
  400. package/dist/server/Daemon.js.map +1 -1
  401. package/dist/server/DaemonModule.d.ts +31 -0
  402. package/dist/server/DaemonModule.d.ts.map +1 -0
  403. package/dist/server/DaemonModule.js +2 -0
  404. package/dist/server/DaemonModule.js.map +1 -0
  405. package/dist/server/branch-batch.sql +154 -0
  406. package/dist/server/client-input.d.ts +13 -0
  407. package/dist/server/client-input.d.ts.map +1 -1
  408. package/dist/server/client-input.js +268 -57
  409. package/dist/server/client-input.js.map +1 -1
  410. package/dist/server/dispatch-as-plurnk.d.ts +1 -1
  411. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
  412. package/dist/server/dispatch-as-plurnk.js +15 -7
  413. package/dist/server/dispatch-as-plurnk.js.map +1 -1
  414. package/dist/server/drain.sql +96 -65
  415. package/dist/server/envelope.d.ts +4 -7
  416. package/dist/server/envelope.d.ts.map +1 -1
  417. package/dist/server/envelope.js +118 -95
  418. package/dist/server/envelope.js.map +1 -1
  419. package/dist/server/envelope.sql +18 -23
  420. package/dist/server/exec-poll-backoff.js +1 -1
  421. package/dist/server/exec-poll-backoff.js.map +1 -1
  422. package/dist/server/lifecycle-recovery.sql +112 -2
  423. package/dist/server/logEntry.d.ts +4 -2
  424. package/dist/server/logEntry.d.ts.map +1 -1
  425. package/dist/server/logEntry.js +6 -3
  426. package/dist/server/logEntry.js.map +1 -1
  427. package/dist/server/logEntry.sql +1 -1
  428. package/dist/server/loop-model.d.ts.map +1 -1
  429. package/dist/server/loop-model.js +22 -9
  430. package/dist/server/loop-model.js.map +1 -1
  431. package/dist/server/loopDocs.d.ts.map +1 -1
  432. package/dist/server/loopDocs.js +5 -4
  433. package/dist/server/loopDocs.js.map +1 -1
  434. package/dist/server/seam-entry-read.sql +15 -10
  435. package/dist/server/seam-log-read.sql +2 -2
  436. package/dist/server/seam-loop.sql +1 -1
  437. package/dist/server/seam-proposal-list.sql +17 -7
  438. package/dist/service.d.ts.map +1 -1
  439. package/dist/service.js +79 -61
  440. package/dist/service.js.map +1 -1
  441. package/migrations/001_schema.sql +1010 -0
  442. package/package.json +65 -32
  443. package/dist/core/PluginLoader.d.ts +0 -20
  444. package/dist/core/PluginLoader.d.ts.map +0 -1
  445. package/dist/core/PluginLoader.js +0 -142
  446. package/dist/core/PluginLoader.js.map +0 -1
  447. package/dist/core/TelemetryChannel.d.ts +0 -37
  448. package/dist/core/TelemetryChannel.d.ts.map +0 -1
  449. package/dist/core/TelemetryChannel.js +0 -80
  450. package/dist/core/TelemetryChannel.js.map +0 -1
  451. package/dist/core/path-decode.d.ts +0 -3
  452. package/dist/core/path-decode.d.ts.map +0 -1
  453. package/dist/core/path-decode.js +0 -9
  454. package/dist/core/path-decode.js.map +0 -1
  455. package/dist/core/plugin-attribution.d.ts +0 -5
  456. package/dist/core/plugin-attribution.d.ts.map +0 -1
  457. package/dist/core/plugin-attribution.js +0 -39
  458. package/dist/core/plugin-attribution.js.map +0 -1
  459. package/dist/core/world-state.d.ts +0 -10
  460. package/dist/core/world-state.d.ts.map +0 -1
  461. package/dist/core/world-state.js +0 -35
  462. package/dist/core/world-state.js.map +0 -1
  463. package/dist/core/world-state.sql +0 -35
  464. package/dist/core/zero-pin.d.ts +0 -3
  465. package/dist/core/zero-pin.d.ts.map +0 -1
  466. package/dist/core/zero-pin.js +0 -16
  467. package/dist/core/zero-pin.js.map +0 -1
  468. package/dist/server/auto.d.ts +0 -6
  469. package/dist/server/auto.d.ts.map +0 -1
  470. package/dist/server/auto.js +0 -63
  471. package/dist/server/auto.js.map +0 -1
  472. package/dist/server/clientTurn.d.ts +0 -6
  473. package/dist/server/clientTurn.d.ts.map +0 -1
  474. package/dist/server/clientTurn.js +0 -22
  475. package/dist/server/clientTurn.js.map +0 -1
  476. package/dist/server/clientTurn.sql +0 -10
  477. package/dist/server/noProposals.d.ts +0 -6
  478. package/dist/server/noProposals.d.ts.map +0 -1
  479. package/dist/server/noProposals.js +0 -37
  480. package/dist/server/noProposals.js.map +0 -1
  481. package/dist/server/version-info.d.ts +0 -14
  482. package/dist/server/version-info.d.ts.map +0 -1
  483. package/dist/server/version-info.js +0 -69
  484. package/dist/server/version-info.js.map +0 -1
  485. package/migrations/0000-00-00.01_schema.sql +0 -536
  486. package/migrations/0004_loop-provider.sql +0 -3
  487. package/migrations/0005_subscription-published-channel.sql +0 -3
  488. package/migrations/0006_loop-max-turns.sql +0 -3
@@ -1,8 +1,8 @@
1
1
  // Top-level daemon orchestrator. Owns the DB connection, engine, registries,
2
- // the plugin-module seam (#364: the daemon owns no transport).
3
- // SPEC §rpc.
2
+ // the transport-free plugin-module seam ({§rpc}).
4
3
  import { readFile } from "node:fs/promises";
5
4
  import { resolve, dirname } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
6
  import { execPollBackoffMs } from "./exec-poll-backoff.js";
7
7
  import ChannelWrite from "../core/ChannelWrite.js";
8
8
  import { Paths } from "../index.js";
@@ -10,37 +10,63 @@ import Engine from "../core/Engine.js";
10
10
  import ExecutorRegistry from "../core/ExecutorRegistry.js";
11
11
  import SchemeRegistry from "../core/SchemeRegistry.js";
12
12
  import { Mimetypes } from "@plurnk/plurnk-mimetypes";
13
+ import { parsePath, Validator, } from "@plurnk/plurnk-contracts";
13
14
  import LogEntry from "./logEntry.js";
14
15
  import Envelope from "./envelope.js";
15
16
  import ClientInput from "./client-input.js";
16
- import ClientTurn from "./clientTurn.js";
17
+ import JournalTurn from "../core/JournalTurn.js";
17
18
  import LoopDocs from "./loopDocs.js";
18
19
  import GitMembership from "../core/git-membership.js";
19
20
  import Fork from "../core/fork.js";
21
+ import WorkerName from "../core/WorkerName.js";
20
22
  import LoopLifecycle from "../core/LoopLifecycle.js";
21
23
  import { promptLoopPrefix } from "../core/plurnk-uri.js";
22
24
  import { rulerCount } from "../core/token-ruler.js";
23
25
  import { parseAliasesFromEnv, resolveActiveAlias } from "@plurnk/plurnk-providers";
24
26
  import ProviderInstantiate from "../core/ProviderInstantiate.js";
25
27
  import { resolveLoopAlias } from "./loop-model.js";
26
- import Auto from "./auto.js";
27
- import NoProposals from "./noProposals.js";
28
28
  import { DEFAULT_LOOP_FLAGS } from "../core/scheme-types.js";
29
+ import LoopFlagsReader from "../core/LoopFlagsReader.js";
30
+ import Results, { OperationFailureError } from "../core/results.js";
31
+ import WorkspaceGate from "../core/WorkspaceGate.js";
32
+ import BranchBatches from "./BranchBatches.js";
33
+ import ErrorDetail from "../core/ErrorDetail.js";
34
+ import { observed, observedSync } from "../observe/spans.js";
35
+ import { LOOP_TERMINALS, recordCounter } from "../observe/metrics.js";
36
+ import { readOptimisticSettlementMs } from "../core/optimistic-settlement.js";
37
+ const clientActionFailure = (error) => {
38
+ if (error instanceof OperationFailureError)
39
+ return error.result;
40
+ console.error("Client action failed outside its operation result contract:", error);
41
+ return Results.failure("daemon:client", "action-threw", 500, "The client action failed outside its operation result contract.", {}, {
42
+ stage: "client-action",
43
+ retryable: false,
44
+ });
45
+ };
46
+ const daemonFailure = (owner, code, status, detail, extensions = {}) => new OperationFailureError(Results.failure(owner, code, status, detail, {}, extensions));
47
+ const entryReadResult = (result) => Validator.assertEntryReadResult(result);
29
48
  export default class Daemon {
30
49
  #db;
31
50
  #engine;
51
+ #workspaceGate;
52
+ #branchBatches;
32
53
  #lifecycle;
33
54
  #schemes;
34
55
  #mimetypes;
56
+ #ownsMimetypes;
35
57
  #provider;
36
58
  #nodeModulesPath;
37
59
  #discoveryCwd;
38
- #started = false; // start() runs once — boots discovery + plugin modules (#364: no listener, ever)
39
- // The emit half of the broadcast, exposed as an in-process event source (#355). A transport
40
- // module (plurnk-agui) subscribes and fans out to its OWN clients; core emits, never fans out
41
- // for it. The WS fan-out below is legacy scaffolding that retires at the AG-UI+ cutover.
60
+ #started = false; // {§module-lifecycle}: one discovery/module boot; no listener
61
+ #capabilitiesPublished = false;
62
+ #modules = [];
63
+ #moduleClosers = [];
64
+ #moduleActions = new Map();
65
+ // {§methods-event-subscribe} — the broadcast's in-process event source. A transport
66
+ // module (plurnk-agui) subscribes and fans out to its OWN clients; core emits, never owns
67
+ // client transport or connection state.
42
68
  #eventSubscribers = new Set();
43
- // Run-level drain registry. At most one drain per worker. The stored object
69
+ // Worker-level drain registry. At most one drain per worker. The stored object
44
70
  // is the drain's identity handle: start/exit compare it by reference so a
45
71
  // drain exiting never clobbers a successor that raced in, and a loop
46
72
  // enqueued during teardown is never stranded. A drain is a pure queue
@@ -48,27 +74,32 @@ export default class Daemon {
48
74
  // (subscriptions + Exec.idle), and a concluding stream routes through
49
75
  // inject() like any other loop source.
50
76
  #activeDrains = new Map();
51
- // Per-run cancellation scope. Loops AND the streams they spawn (execs)
77
+ #drainExitTasks = new Set();
78
+ // Per-worker cancellation scope. Loops AND the streams they spawn (execs)
52
79
  // share this signal, so loop.cancel / shutdown abort it once and every
53
80
  // in-flight subscription tears down — even a spawn that registers AFTER the
54
81
  // cancel self-aborts against the already-aborted signal (no race). Outlives
55
82
  // any single (ephemeral) drain; replaced with a fresh controller once
56
- // aborted so a later loop.run isn't born cancelled.
83
+ // aborted so a later runLoop request isn't born cancelled.
57
84
  #workerAborts = new Map();
58
85
  // grammar 0.74.20 EXEC `<T,P>` — per-worker hibernation poll-wake timer. When a loop parks at
59
- // a park with a polled stream, a timer fires every P seconds to resume it (§exec-poll). One
86
+ // a park with a polled stream, a timer fires every P seconds to resume it ({§exec-poll}). One
60
87
  // per worker (the tightest cadence); cleared/replaced on each park and on cancel.
61
88
  #parkTimers = new Map();
62
89
  #pollTimers = new Map();
63
- #pollBackoff = new Map(); // #521 — the exec-poll backoff step per worker (nth wake)
64
- // Per-run drain-transition lock — see #withDrainLock (R4 / §worker-lifecycle-single-drain).
90
+ #pollBackoff = new Map(); // {§exec-poll} — backoff step per worker
91
+ // Per-worker drain-transition lock — see #withDrainLock (R4 / {§worker-lifecycle-single-drain}).
65
92
  #drainLocks = new Map();
66
- // §worker-lifecycle-child-wake — runs OWED a wake: a child/stream conclusion fired while the worker was
67
- // mid-turn (not yet slept), so #wakeParkedWorker could not resume it. A worker-run conclusion is a
93
+ // {§worker-lifecycle-child-wake} — workers owed a wake: a child/stream conclusion fired while the worker was
94
+ // mid-turn (not yet slept), so #wakeParkedWorker could not resume it. A child-worker conclusion is a
68
95
  // BOUNDED, lossless wake (a worker always concludes), so a hibernation awaiting one MUST return —
69
96
  // never deadlock. The drain honors the owed wake at the worker's next park, closing the conclude-
70
97
  // before-park race. (Only a live exec stream, unbounded absent a timeout, may hold a park open.)
71
98
  #owedWakes = new Set();
99
+ // {§worker-optimistic-settlement} — one non-sliding completion-wake batch
100
+ // per worker. Durable conclusions and client events land before this gate;
101
+ // only the parked loop's provider-dispatch requeue waits.
102
+ #completionWakeGates = new Map();
72
103
  constructor({ db, schemes, mimetypes, provider, nodeModulesPath, }) {
73
104
  this.#db = db;
74
105
  this.#lifecycle = new LoopLifecycle(db);
@@ -81,7 +112,10 @@ export default class Daemon {
81
112
  this.#nodeModulesPath = nodeModulesPath ?? resolve(process.cwd(), "node_modules");
82
113
  this.#discoveryCwd = dirname(this.#nodeModulesPath);
83
114
  // Mimetypes owns discovery + detection; default mimetype text/markdown. (Token counting
84
- // is NOT wired here — the engine's ruler below is §tokenomics-agnostic-ruler.)
115
+ // is NOT wired here — the engine's ruler below is {§tokenomics-agnostic-ruler}.)
116
+ // Constructor ownership is the lifecycle boundary
117
+ // ({§mimetype-owned-lifecycle}).
118
+ this.#ownsMimetypes = mimetypes === undefined;
85
119
  this.#mimetypes = mimetypes ?? new Mimetypes({
86
120
  defaultMimetype: "text/markdown",
87
121
  discoverOptions: { cwd: this.#discoveryCwd },
@@ -90,9 +124,60 @@ export default class Daemon {
90
124
  if (this.#provider !== null && bootSpec !== null) {
91
125
  ProviderInstantiate.registerInstance(this.#provider, bootSpec);
92
126
  }
127
+ this.#workspaceGate = new WorkspaceGate(async (workerId, rootWorkerId) => {
128
+ const row = await this.#db.branch_batch_worker_lineage.get({
129
+ worker_id: workerId,
130
+ root_worker_id: rootWorkerId,
131
+ });
132
+ return row !== undefined;
133
+ });
134
+ this.#branchBatches = new BranchBatches(db, this.#workspaceGate, {
135
+ settleWorkspace: async (workspaceId) => this.#engine.drainWorkspaceDerivations(workspaceId),
136
+ createChild: async ({ workspaceId, parentWorkerId, parentLoopId, op, name, prompt, flags, origin }) => {
137
+ const parentPolicy = await this.#providerPolicyForLoop(parentLoopId);
138
+ const providerSpec = parentPolicy.childProviderSpec ?? parentPolicy.providerSpec;
139
+ const workerName = WorkerName.assert(name);
140
+ const workerId = op === "FORK"
141
+ ? await Fork.fork(this.#db, parentWorkerId, workerName)
142
+ : (await this.#db.fork_insert_worker.get({
143
+ workspace_id: workspaceId,
144
+ name: workerName,
145
+ parent_worker_id: parentWorkerId,
146
+ origin,
147
+ }))?.id;
148
+ if (workerId === undefined)
149
+ throw new Error("Branch worker insert returned no row");
150
+ const loopId = await this.#enqueueFreshLoop({
151
+ workerId,
152
+ prompt,
153
+ providerSpec,
154
+ childProviderSpec: parentPolicy.childProviderSpec,
155
+ flags,
156
+ });
157
+ return { workerId, loopId };
158
+ },
159
+ startChild: async (workspaceId, workerId, loopId) => {
160
+ const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
161
+ const started = await this.#ensureDrain({ workspaceId, workerId, systemPrompt });
162
+ if (started === null)
163
+ throw new Error(`Branch worker ${workerId} already has a live drain`);
164
+ const result = await started.firstLoopPromise;
165
+ if (result.loopId !== loopId) {
166
+ throw new Error(`Branch worker ${workerId} drained loop ${result.loopId}, expected ${loopId}`);
167
+ }
168
+ return result.result;
169
+ },
170
+ wakeParent: async (workspaceId, workerId) => {
171
+ const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
172
+ await this.#settleCompletionWake(workspaceId, workerId, systemPrompt, false);
173
+ },
174
+ notify: (workspaceId, payload) => {
175
+ this.#broadcast({ workspaceId }, "workspace/branch-batch", payload);
176
+ },
177
+ });
93
178
  this.#engine = new Engine({
94
179
  db, schemes: this.#schemes, mimetypes: this.#mimetypes,
95
- // §tokenomics-agnostic-ruler — the ONE model-facing token ruler (chars/2), NOT the
180
+ // {§tokenomics-agnostic-ruler} — the ONE model-facing token ruler (chars/2), NOT the
96
181
  // boot provider: token accounting is workspace-wide across many concurrent models, so
97
182
  // the write-time + catalog counts must be model-independent. Exact per-model counting
98
183
  // lives only at the packet-materialization fit-gate.
@@ -103,50 +188,46 @@ export default class Daemon {
103
188
  // Daemon.inject (active sister → fold; idle → enqueue + drain). The
104
189
  // daemon owns provider + the law-file system prompt; the worker scheme
105
190
  // handler carries neither. Fire-and-forget: the returned drain runs
106
- // independently (the sister is its own worker). §machine-processes
107
- injectWorker: async ({ workspaceId, workerId, prompt, flags }) => {
108
- if (this.#provider === null)
109
- throw new Error("injectWorker: no provider configured");
191
+ // independently (the sister is its own worker). {§machine-processes}
192
+ injectWorker: async ({ workspaceId, workerId, prompt, flags, parentLoopId }) => {
110
193
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
111
- const providerSpec = resolveActiveAlias();
194
+ const parentPolicy = parentLoopId === undefined
195
+ ? null
196
+ : await this.#providerPolicyForLoop(parentLoopId);
197
+ const providerSpec = parentPolicy === null
198
+ ? resolveActiveAlias()
199
+ : parentPolicy.childProviderSpec ?? parentPolicy.providerSpec;
112
200
  if (providerSpec === null)
113
201
  throw new Error("injectWorker: active provider has no resolvable alias");
114
- const { action, loopId } = await this.inject({ workspaceId, workerId, prompt, providerSpec, systemPrompt, ...(flags === undefined ? {} : { flags }) });
202
+ const { action, loopId } = await this.inject({
203
+ workspaceId,
204
+ workerId,
205
+ prompt,
206
+ providerSpec,
207
+ ...(parentPolicy === null ? {} : { childProviderSpec: parentPolicy.childProviderSpec }),
208
+ systemPrompt,
209
+ ...(flags === undefined ? {} : { flags }),
210
+ });
115
211
  return { action, loopId };
116
212
  },
213
+ branchWorker: async (args) => this.#branchBatches.enqueue(args),
214
+ branchCompletionGate: async (workerId) => this.#branchBatches.completionGate(workerId),
215
+ acquireWorkspaceTurn: async (workspaceId, workerId) => this.#workspaceGate.acquireTurn(workspaceId, workerId),
216
+ workspaceTurnCompleted: async ({ turnId }) => this.#branchBatches.sealTurn(turnId),
117
217
  // worker:// KILL (terminate) — cancel the addressed worker subtree and
118
218
  // tear down its held streams before the operation completes.
119
219
  cancelWorker: async (workerId, reason) => this.#cancelWorkerTree(workerId, reason),
120
220
  cancelDescendants: async (workerId, reason) => this.#cancelTree(workerId, reason, false),
121
- telemetryEventNotify: (workspaceId, payload) => this.notifyTelemetryEvent(workspaceId, payload),
221
+ noticeNotify: (workspaceId, payload) => this.notifyNotice(workspaceId, payload),
122
222
  });
123
223
  // Wire proposal-pending events to the loop/proposal WS notification.
124
224
  // Sessionid scopes the broadcast to clients on the same workspace.
125
225
  this.#engine.onProposalPending((event) => {
126
- this.#broadcast({ workspaceId: event.workspaceId }, "loop/proposal", {
127
- logEntryId: event.logEntryId,
128
- workerId: event.workerId,
129
- loopId: event.loopId,
130
- turnId: event.turnId,
131
- op: event.op,
132
- target: event.target,
133
- body: event.body,
134
- attrs: event.attrs,
135
- // event.flags is carried for discoverability — a client in
136
- // loop-auto mode (event.flags.auto=true) knows to skip
137
- // rendering review UI because the entry will resolve in-
138
- // process before any human can react.
139
- flags: event.flags,
140
- });
226
+ const { workspaceId, ...proposal } = event;
227
+ this.#broadcast({ workspaceId }, "loop/proposal", proposal);
141
228
  });
142
- // In-tree auto listener resolves proposals when persisted flags.auto is true.
143
- Auto.attach(this.#engine, this.#db);
144
- // Inverse policy: auto-REJECT proposals in-process when the loop's
145
- // persisted flags.noProposals === true (client has no review channel).
146
- // The model sees an ordinary 400, never the orchestration reason.
147
- NoProposals.attachNoProposals(this.#engine, this.#db);
148
- }
149
- // The client-interface seam (#355). A transport module subscribes to the daemon's in-process
229
+ }
230
+ // {§methods-event-subscribe}. A transport module subscribes to the daemon's in-process
150
231
  // event source: it receives every workspace-scoped engine event as `(workspaceId, method, params)`
151
232
  // and fans out to its OWN clients — core emits, it never fans out for the module. Returns an
152
233
  // unsubscribe. `workspaceId` is the event's workspace, or null for a global event (e.g. workspace/created).
@@ -155,55 +236,104 @@ export default class Daemon {
155
236
  this.#eventSubscribers.add(handler);
156
237
  return () => { this.#eventSubscribers.delete(handler); };
157
238
  }
158
- // The client-interface seam (#355) — proposal HITL. A transport module reads the stopped-world
239
+ // {§methods-proposal-resolve} — proposal HITL. A transport module reads the stopped-world
159
240
  // proposals for a workspace (rendering each as a TOOL_CALL) and feeds back the human's decision. The
160
241
  // gate, validation, and applyResolution stay core (Engine.resolveProposal); the seam is the read +
161
242
  // the resolve, never the mechanism. `resolveProposal` throws for an unknown/already-resolved id.
162
243
  async pendingProposals(workspaceId) {
163
- return this.#db.proposal_list_pending.all({ workspace_id: workspaceId });
244
+ const checkedWorkspaceId = ClientInput.assertId("pendingProposals", "workspaceId", workspaceId);
245
+ return this.#engine.pendingProposals(checkedWorkspaceId);
164
246
  }
165
247
  resolveProposal(logEntryId, resolution) {
166
- this.#engine.resolveProposal(logEntryId, resolution);
248
+ const checkedLogEntryId = ClientInput.assertId("resolveProposal", "logEntryId", logEntryId);
249
+ const checkedResolution = ClientInput.assertProposalResolution("resolveProposal", resolution);
250
+ this.#engine.resolveProposal(checkedLogEntryId, checkedResolution);
167
251
  }
168
- // The client-interface seam (#355) — drive/steer a loop. The module supplies only workspace/run/prompt;
252
+ // {§methods-loop-run} — drive/steer a loop. The module supplies only workspace/worker/prompt;
169
253
  // the provider and the law-file system prompt are core's and stay inside. Returns immediately — the
170
254
  // loop runs async and its outcome arrives on the event source (loop/terminated). `cancelDrain` (public)
171
255
  // is the cancel hook. Both funnel through the unified `inject`, which owns the drain lifecycle.
172
256
  async runLoop(args) {
173
- const flags = ClientInput.normalizeLoopFlags("loop.run", args.flags);
174
- // #414 — per-loop model selection: a client sends its alias/model on every loop, so a
175
- // switch takes effect turn-to-turn. `model` (client-resolved <provider>/<model>, #90) wins
257
+ const workspaceId = ClientInput.assertId("runLoop", "workspaceId", args.workspaceId);
258
+ const workerId = ClientInput.assertId("runLoop", "workerId", args.workerId);
259
+ const prompt = ClientInput.assertPrompt("runLoop", args.prompt);
260
+ const requestedMaxTurns = ClientInput.assertMaxTurns("runLoop", args.maxTurns);
261
+ const openPaths = ClientInput.assertOpenPaths("runLoop", args.openPaths);
262
+ const alias = ClientInput.assertOptionalSelector("runLoop", "alias", args.alias);
263
+ const model = ClientInput.assertOptionalSelector("runLoop", "model", args.model);
264
+ const childAlias = ClientInput.assertOptionalChildAlias("runLoop", args.childAlias);
265
+ const childModel = ClientInput.assertOptionalSelector("runLoop", "childModel", args.childModel);
266
+ if (childAlias === null && childModel !== undefined) {
267
+ throw daemonFailure("daemon:input", "child-provider-conflict", 400, "childModel cannot accompany an inherited child provider policy.", { field: "childModel", recovery: "Omit childModel or select a child alias.", retryable: false });
268
+ }
269
+ const flags = ClientInput.normalizeLoopFlags("runLoop", args.flags);
270
+ // {§methods-loop-run-model} — a client sends alias/model on every loop, so a
271
+ // switch takes effect turn-to-turn. `model` (client-resolved <provider>/<model>) wins
176
272
  // over `alias`; neither → the boot default. Instantiation is cached, so ping-ponging
177
273
  // between two models is cheap, and an unresolvable alias/model fails loud here.
178
- const selection = await this.#resolveLoopProvider(args.alias, args.model);
179
- if (selection === null)
180
- throw new Error("runLoop: no provider configured");
274
+ const selection = await this.#resolveLoopProvider(alias, model);
275
+ if (selection === null) {
276
+ throw new OperationFailureError(Results.failure("daemon:provider", "not-configured", 501, "No provider is configured for this loop.", {}, {
277
+ stage: "provider-selection",
278
+ recovery: "Select a configured model provider.",
279
+ retryable: false,
280
+ }));
281
+ }
282
+ // {§methods-loop-run-child-provider}
283
+ const configuredChildAlias = childAlias === undefined && childModel === undefined
284
+ ? process.env.PLURNK_MODEL_CHILD
285
+ : childAlias;
286
+ const childSelection = configuredChildAlias === null
287
+ || (configuredChildAlias === undefined && childModel === undefined)
288
+ ? null
289
+ : await this.#resolveLoopProvider(configuredChildAlias, childModel);
181
290
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
182
- // §machine-processes — the model NEVER runs in a client-origin run (its packets would carry
183
- // client op.* rows). The module resolves the model worker via ensureModelWorker and passes it (or a
291
+ // {§machine-processes} — the model NEVER runs in a client-origin worker (its packets would carry
292
+ // client-action rows). The module resolves the model worker via ensureModelWorker and passes it (or a
184
293
  // fork); a client worker here is a caller error, refused loudly rather than silently rehomed.
185
- const target = await this.#db.envelope_get_worker_by_id.get({ id: args.workerId });
186
- if (target === undefined)
187
- throw new Error(`runLoop: run ${args.workerId} not found`);
188
- if (target.origin === "client")
189
- throw new Error(`runLoop: run ${args.workerId} is a client worker — loops run in model workers (§machine-processes); resolve one with ensureModelWorker(workspaceId)`);
190
- // §operator-config-max-turns-ceiling — the operator ceiling clamps a per-call maxTurns; a
294
+ const target = await this.#db.envelope_get_worker_by_id.get({ id: workerId });
295
+ if (target === undefined) {
296
+ throw daemonFailure("daemon:worker", "worker-not-found", 404, `Worker ${workerId} does not exist.`, { workerId });
297
+ }
298
+ if (target.workspace_id !== workspaceId) {
299
+ throw daemonFailure("daemon:worker", "workspace-mismatch", 409, `Worker ${workerId} does not belong to workspace ${workspaceId}.`, {
300
+ workerId,
301
+ workspaceId,
302
+ actualWorkspaceId: target.workspace_id,
303
+ retryable: false,
304
+ });
305
+ }
306
+ if (target.origin === "client") {
307
+ throw daemonFailure("daemon:worker", "model-worker-required", 409, `Worker ${workerId} is not a model worker.`, {
308
+ workerId,
309
+ recovery: "Select or create a model worker for this loop.",
310
+ retryable: false,
311
+ });
312
+ }
313
+ // {§operator-config-max-turns-ceiling} — the operator ceiling clamps a per-call maxTurns; a
191
314
  // seam caller must not bypass operator policy (inject only DEFAULTS from env, never clamps).
192
315
  const ceiling = Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "-1");
193
- const requested = args.maxTurns ?? ceiling;
316
+ const requested = requestedMaxTurns ?? ceiling;
194
317
  const maxTurns = ceiling < 0 ? requested : (requested < 0 ? ceiling : Math.min(requested, ceiling));
195
- const { flags: _inputFlags, ...rest } = args;
318
+ const turnCeiling = {
319
+ effective: maxTurns,
320
+ source: requestedMaxTurns === undefined ? "implicit" : "explicit",
321
+ };
196
322
  const { action, loopId, turnSeq } = await this.inject({
197
- ...rest,
323
+ workspaceId,
324
+ workerId,
325
+ prompt,
198
326
  ...(flags !== undefined ? { flags } : {}),
199
- maxTurns,
327
+ ...(openPaths !== undefined ? { openPaths } : {}),
328
+ turnCeiling,
200
329
  providerSpec: selection,
330
+ childProviderSpec: childSelection,
201
331
  systemPrompt,
202
332
  });
203
- return { action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
333
+ return { status: 100, action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
204
334
  }
205
- // #414 — resolve a per-loop model override to a Provider (cached instances). `model`
206
- // (<provider>/<model>, client-resolved #90) wins over a named `alias`; absent both, the
335
+ // {§methods-loop-run-model} — resolve a per-loop model override to a cached Provider. `model`
336
+ // (<provider>/<model>, client-resolved) wins over a named `alias`; absent both, the
207
337
  // boot default. A named alias missing from the env cascade, or a malformed model spec, throws
208
338
  // legibly rather than silently running the wrong model.
209
339
  async #resolveLoopProvider(alias, model) {
@@ -211,43 +341,92 @@ export default class Daemon {
211
341
  if (requested === null && this.#provider === null)
212
342
  return null;
213
343
  const spec = requested ?? resolveActiveAlias();
214
- if (spec === null)
215
- throw new Error("runLoop: boot provider has no resolvable alias");
216
- // Resolve eagerly so loop.run fails before enqueue when the provider
344
+ if (spec === null) {
345
+ throw daemonFailure("daemon:provider", "active-alias-unresolved", 500, "The active provider has no resolvable alias.", { stage: "provider-selection", retryable: false });
346
+ }
347
+ // Resolve eagerly so runLoop fails before enqueue when the provider
217
348
  // cannot be constructed. The drain later retrieves this cached handle
218
349
  // from the loop's durable spec at the claim boundary.
219
- await ProviderInstantiate.instantiateProvider(spec);
350
+ try {
351
+ await ProviderInstantiate.instantiateProvider(spec);
352
+ }
353
+ catch (cause) {
354
+ if (cause instanceof OperationFailureError)
355
+ throw cause;
356
+ console.error(`Provider alias '${spec.alias}' could not be instantiated:`, cause);
357
+ throw daemonFailure("daemon:provider", "provider-unavailable", 503, `Provider alias '${spec.alias}' is unavailable.`, {
358
+ alias: spec.alias,
359
+ provider: spec.provider,
360
+ model: spec.model,
361
+ stage: "provider-selection",
362
+ retryable: false,
363
+ });
364
+ }
220
365
  return spec;
221
366
  }
222
- async #providerSpecForLoop(loopId) {
223
- const row = await this.#db.drain_loop_provider_spec.get({ loop_id: loopId });
224
- if (row === undefined)
225
- throw new Error(`loop ${loopId}: provider selection row is missing`);
367
+ #parseProviderSpec(loopId, field, encoded) {
226
368
  let parsed;
227
369
  try {
228
- parsed = JSON.parse(row.provider_spec);
370
+ parsed = JSON.parse(encoded);
229
371
  }
230
372
  catch {
231
- throw new Error(`loop ${loopId}: persisted provider selection is malformed — refusing boot-default substitution`);
373
+ throw new Error(`loop ${loopId}: persisted ${field} is malformed`);
232
374
  }
233
- if (parsed === null
234
- || typeof parsed.alias !== "string" || parsed.alias.length === 0
375
+ if (parsed === null)
376
+ return null;
377
+ if (typeof parsed.alias !== "string" || parsed.alias.length === 0
235
378
  || typeof parsed.provider !== "string" || parsed.provider.length === 0
236
379
  || typeof parsed.model !== "string" || parsed.model.length === 0
237
380
  || (parsed.baseUrl !== undefined && typeof parsed.baseUrl !== "string")) {
238
- throw new Error(`loop ${loopId}: persisted provider selection is missing or invalid — refusing boot-default substitution`);
381
+ throw new Error(`loop ${loopId}: persisted ${field} is invalid`);
239
382
  }
240
383
  return parsed;
241
384
  }
385
+ async #providerPolicyForLoop(loopId) {
386
+ const row = await this.#db.drain_loop_provider_spec.get({ loop_id: loopId });
387
+ if (row === undefined)
388
+ throw new Error(`loop ${loopId}: provider selection row is missing`);
389
+ const providerSpec = this.#parseProviderSpec(loopId, "provider_spec", row.provider_spec);
390
+ if (providerSpec === null) {
391
+ throw new Error(`loop ${loopId}: persisted provider selection is missing or invalid — refusing boot-default substitution`);
392
+ }
393
+ return {
394
+ providerSpec,
395
+ childProviderSpec: this.#parseProviderSpec(loopId, "child_provider_spec", row.child_provider_spec),
396
+ };
397
+ }
398
+ async #providerSpecForLoop(loopId) {
399
+ return (await this.#providerPolicyForLoop(loopId)).providerSpec;
400
+ }
242
401
  async #providerForLoop(loopId) {
243
402
  return ProviderInstantiate.instantiateProvider(await this.#providerSpecForLoop(loopId));
244
403
  }
245
404
  async #assertLoopProvider(loopId, requested) {
246
405
  const selected = await this.#providerSpecForLoop(loopId);
247
406
  if (JSON.stringify(selected) !== JSON.stringify(requested)) {
248
- throw new Error(`loop ${loopId}: provider selection is frozen at '${selected.alias}' (${selected.provider}/${selected.model}); `
249
- + `requested '${requested.alias}' (${requested.provider}/${requested.model}). `
250
- + "Cancel or conclude the loop before hot-swapping models.");
407
+ throw daemonFailure("daemon:provider", "loop-provider-conflict", 409, `Loop ${loopId} uses provider alias '${selected.alias}', not '${requested.alias}'.`, {
408
+ loopId,
409
+ selectedAlias: selected.alias,
410
+ selectedModel: `${selected.provider}/${selected.model}`,
411
+ requestedAlias: requested.alias,
412
+ requestedModel: `${requested.provider}/${requested.model}`,
413
+ stage: "loop-injection",
414
+ recovery: "Cancel or conclude the loop before selecting another provider.",
415
+ retryable: false,
416
+ });
417
+ }
418
+ }
419
+ async #assertLoopChildProvider(loopId, requested) {
420
+ const selected = (await this.#providerPolicyForLoop(loopId)).childProviderSpec;
421
+ if (JSON.stringify(selected) !== JSON.stringify(requested)) {
422
+ throw daemonFailure("daemon:provider", "loop-child-provider-conflict", 409, `Loop ${loopId} already has a different child provider policy.`, {
423
+ loopId,
424
+ selectedChildAlias: selected?.alias ?? null,
425
+ requestedChildAlias: requested?.alias ?? null,
426
+ stage: "loop-injection",
427
+ recovery: "Cancel or conclude the loop before changing its child provider policy.",
428
+ retryable: false,
429
+ });
251
430
  }
252
431
  }
253
432
  async #assertLoopMaxTurns(loopId, requested) {
@@ -257,29 +436,38 @@ export default class Daemon {
257
436
  if (durable === undefined)
258
437
  throw new Error(`inject: loop ${loopId} has no durable turn ceiling`);
259
438
  if (durable.max_turns !== requested) {
260
- throw new Error(`inject: the prompt would fold into loop ${loopId} with maxTurns ${durable.max_turns}, not requested ${requested} — maxTurns is loop-scoped and immutable; cancel or conclude the loop before opening one with a different ceiling`);
439
+ throw daemonFailure("daemon:loop", "turn-ceiling-conflict", 409, `Loop ${loopId} has turn ceiling ${durable.max_turns}, not ${requested}.`, {
440
+ loopId,
441
+ selectedMaximumTurns: durable.max_turns,
442
+ requestedMaximumTurns: requested,
443
+ stage: "loop-injection",
444
+ recovery: "Cancel or conclude the loop before selecting another turn ceiling.",
445
+ retryable: false,
446
+ });
261
447
  }
262
448
  }
263
- // §machine-processes — the workspace's model worker (created on first use), distinct from the client
264
- // run so the model's packets never carry client op.* rows. The module binds its threads to this.
449
+ // {§methods-model-worker} — the workspace's model worker (created on first use), distinct from the client
450
+ // worker so the model's packets never carry client-action rows. The module binds its threads to this.
265
451
  ensureModelWorker(workspaceId) {
266
- return Envelope.ensureModelWorker(this.#db, workspaceId);
452
+ return Envelope.ensureModelWorker(this.#db, ClientInput.assertId("worker.ensure-model", "workspaceId", workspaceId));
267
453
  }
268
- // The op-dispatch hook (#355) — execute one parsed op on behalf of a client: journaled as a
454
+ // {§methods-op-mirror} — execute parsed ops on behalf of a client, journaled as a
269
455
  // client-origin turn (the log is core's, a client op is a first-class citizen), dispatched through
270
456
  // the engine, then emitted as log/entry on the event source. One seam op backs the whole op_*
271
457
  // family (read/edit/copy/find/fold/look/move/open/send/exec); the module parses at its edge with the
272
458
  // grammar package and hands over the statement, then fans the emitted entry out to its own clients.
273
459
  async dispatchAsClient(args) {
274
- const { workspaceId, workerId, statement } = args;
460
+ const workspaceId = ClientInput.assertId("operation.dispatch", "workspaceId", args.workspaceId);
461
+ const workerId = ClientInput.assertId("operation.dispatch", "workerId", args.workerId);
462
+ const { statement } = args;
275
463
  const clientLoopId = await Envelope.ensureClientLoop(this.#db, workerId);
276
464
  try {
277
465
  const result = await this.#dispatchClientStatement({ workspaceId, workerId, loopId: clientLoopId, statement });
278
- await Envelope.closeClientLoop(this.#db, clientLoopId, 200);
466
+ await Envelope.closeClientLoop(this.#db, clientLoopId, { status: 200 });
279
467
  return result;
280
468
  }
281
469
  catch (error) {
282
- await Envelope.closeClientLoop(this.#db, clientLoopId, 499);
470
+ await Envelope.closeClientLoop(this.#db, clientLoopId, clientActionFailure(error));
283
471
  throw error;
284
472
  }
285
473
  }
@@ -288,7 +476,9 @@ export default class Daemon {
288
476
  // keep this promise (and segment) open across interrupt/resume; settlement closes
289
477
  // it. The journal is durable evidence for the action, not a second client lifecycle.
290
478
  async dispatchClientAction(args) {
291
- const { workspaceId, workerId, statements } = args;
479
+ const workspaceId = ClientInput.assertId("operation.dispatch-batch", "workspaceId", args.workspaceId);
480
+ const workerId = ClientInput.assertId("operation.dispatch-batch", "workerId", args.workerId);
481
+ const { statements } = args;
292
482
  if (statements.length === 0)
293
483
  return [];
294
484
  const clientLoopId = await Envelope.ensureClientLoop(this.#db, workerId);
@@ -297,58 +487,107 @@ export default class Daemon {
297
487
  for (const statement of statements) {
298
488
  results.push(await this.#dispatchClientStatement({ workspaceId, workerId, loopId: clientLoopId, statement }));
299
489
  }
300
- await Envelope.closeClientLoop(this.#db, clientLoopId, 200);
490
+ await Envelope.closeClientLoop(this.#db, clientLoopId, { status: 200 });
301
491
  return results;
302
492
  }
303
493
  catch (error) {
304
- await Envelope.closeClientLoop(this.#db, clientLoopId, 499);
494
+ await Envelope.closeClientLoop(this.#db, clientLoopId, clientActionFailure(error));
305
495
  throw error;
306
496
  }
307
497
  }
308
498
  async #dispatchClientStatement(args) {
309
499
  const { workspaceId, workerId, loopId, statement } = args;
310
- const turnId = await ClientTurn.insertClientTurn(this.#db, loopId);
311
- const entryIds = [];
312
- const result = await this.#engine.dispatch({
313
- statement, workspaceId, workerId, loopId, turnId, sequence: 1,
314
- origin: "client", onDispatch: (logEntryId) => { entryIds.push(logEntryId); },
315
- });
316
- for (const logEntryId of entryIds) {
317
- const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
318
- this.#broadcast({ workspaceId }, "log/entry", { entry });
500
+ const release = await this.#workspaceGate.acquireTurn(workspaceId, workerId);
501
+ try {
502
+ const { id: turnId } = await JournalTurn.insert(this.#db, loopId);
503
+ const entryIds = [];
504
+ const result = await this.#engine.dispatch({
505
+ statement, workspaceId, workerId, loopId, turnId, sequence: 1,
506
+ origin: "client", onDispatch: (logEntryId) => { entryIds.push(logEntryId); },
507
+ });
508
+ await this.#branchBatches.sealTurn(turnId);
509
+ for (const logEntryId of entryIds) {
510
+ const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
511
+ this.#broadcast({ workspaceId }, "log/entry", { entry });
512
+ }
513
+ return result;
514
+ }
515
+ finally {
516
+ release();
319
517
  }
320
- return result;
321
518
  }
322
- // op.look (#283/#358) — the pure READ-projection query on the seam: resolve a READ through the
323
- // full scheme resolver and return its content, writing NO log row — the client's off-run
519
+ // {§op-look} — the pure READ-projection query on the seam: resolve a READ through the
520
+ // full scheme resolver and return its content, writing NO log row — the client's out-of-band
324
521
  // inspection primitive (the module rewrites LOOK→READ and parses at its edge, exactly like
325
522
  // dispatchClientAction). Its closed observation segment supplies the numeric loop coordinate
326
523
  // required by plugin context and relative log:/// addresses without impersonating an active
327
524
  // client lifecycle. It creates no turn or log row. Engine.look enforces READ-only.
328
525
  async look(args) {
329
- const { workspaceId, workerId, statement } = args;
526
+ const workspaceId = ClientInput.assertId("operation.look", "workspaceId", args.workspaceId);
527
+ const workerId = ClientInput.assertId("operation.look", "workerId", args.workerId);
528
+ const { statement } = args;
529
+ const release = await this.#workspaceGate.acquireTurn(workspaceId, workerId);
330
530
  const clientLoopId = await Envelope.ensureClientLoop(this.#db, workerId);
331
531
  try {
332
532
  const result = await this.#engine.look({ statement, workspaceId, workerId, loopId: clientLoopId });
333
- await Envelope.closeClientLoop(this.#db, clientLoopId, 200);
533
+ await Envelope.closeClientLoop(this.#db, clientLoopId, { status: 200 });
334
534
  return result;
335
535
  }
336
536
  catch (error) {
337
- await Envelope.closeClientLoop(this.#db, clientLoopId, 499);
537
+ await Envelope.closeClientLoop(this.#db, clientLoopId, clientActionFailure(error));
338
538
  throw error;
339
539
  }
540
+ finally {
541
+ release();
542
+ }
340
543
  }
341
- // The log-read hook (#355) — a workspace's journal, the module's primary render input. The worker is
342
- // ownership-verified against the workspace (a workspace reads only its own runs — the model worker included,
343
- // #214); entries filter by loop/turn/since-id or the full L/T/S display coordinate. Core owns the
544
+ // {§methods-log-read} — a workspace's journal, the module's primary render input. The worker is
545
+ // ownership-verified against the workspace (a workspace reads only its own workers — the model worker included,
546
+ // {§methods-log-coordinate}); entries filter by loop/turn/since-id or the full L/T/S display coordinate. Core owns the
344
547
  // journal + the invariant; the module shapes the entries into AG-UI messages at its edge.
345
548
  async readLog(args) {
346
- const { workspaceId, workerId } = args;
549
+ const workspaceId = ClientInput.assertId("log.read", "workspaceId", args.workspaceId);
550
+ const workerId = ClientInput.assertId("log.read", "workerId", args.workerId);
347
551
  const target = await this.#db.envelope_get_worker_by_id.get({ id: workerId });
348
- if (target === undefined)
349
- throw new Error(`run ${workerId} not found`);
350
- if (target.workspace_id !== workspaceId)
351
- throw new Error(`run ${workerId} is not in this workspace (${workspaceId})`);
552
+ if (target === undefined) {
553
+ throw daemonFailure("daemon:worker", "worker-not-found", 404, `Worker ${workerId} does not exist.`, { workerId });
554
+ }
555
+ if (target.workspace_id !== workspaceId) {
556
+ throw daemonFailure("daemon:worker", "workspace-mismatch", 409, `Worker ${workerId} does not belong to workspace ${workspaceId}.`, {
557
+ workerId,
558
+ workspaceId,
559
+ actualWorkspaceId: target.workspace_id,
560
+ retryable: false,
561
+ });
562
+ }
563
+ const coordinateFields = {
564
+ loopId: args.loopId,
565
+ turnId: args.turnId,
566
+ sinceId: args.sinceId,
567
+ loopSeq: args.loopSeq,
568
+ turnSeq: args.turnSeq,
569
+ sequence: args.sequence,
570
+ };
571
+ for (const [field, value] of Object.entries(coordinateFields)) {
572
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
573
+ throw daemonFailure("daemon:log", "coordinate-invalid", 400, `Log coordinate field '${field}' is not a non-negative safe integer.`, {
574
+ field,
575
+ value,
576
+ stage: "log-read",
577
+ recovery: "Use a non-negative integer coordinate.",
578
+ retryable: false,
579
+ });
580
+ }
581
+ }
582
+ if (args.limit !== undefined && (!Number.isSafeInteger(args.limit) || args.limit < 1)) {
583
+ throw daemonFailure("daemon:log", "limit-invalid", 400, `Log limit ${args.limit} is not a positive safe integer.`, {
584
+ field: "limit",
585
+ value: args.limit,
586
+ stage: "log-read",
587
+ recovery: "Use a positive integer log limit.",
588
+ retryable: false,
589
+ });
590
+ }
352
591
  const rows = await this.#db.log_read_recent_ids.all({
353
592
  worker_id: workerId,
354
593
  loop_id: args.loopId ?? null, turn_id: args.turnId ?? null, since_id: args.sinceId ?? null,
@@ -360,7 +599,7 @@ export default class Daemon {
360
599
  entries.push(await LogEntry.fetchLogEntry(this.#db, r.id));
361
600
  return entries;
362
601
  }
363
- // The metadata-read hooks (#355) — the module's render surface beyond the journal. Thin delegations
602
+ // {§methods} — the module's render surface beyond the journal. Thin delegations
364
603
  // into core's envelope / membership / provider machinery; the module fans the results into its own views.
365
604
  listProviders() {
366
605
  const active = resolveActiveAlias();
@@ -369,186 +608,368 @@ export default class Daemon {
369
608
  const isActive = active !== null && active.alias === a.alias;
370
609
  return {
371
610
  alias: a.alias, provider: a.provider, model: a.model, active: isActive,
372
- // promptBudget = the EFFECTIVE prompt budget (window minus reserves, #345; named honestly #481) — the same
373
- // denominator loop-usage reports; known for the active alias, null elsewhere.
611
+ // The same effective model-facing budget loop usage reports, including
612
+ // optional virtual pressure; known for the active alias, null elsewhere.
374
613
  promptBudget: isActive && this.#provider !== null ? this.#engine.promptBudgetFor(this.#provider) : null,
375
614
  };
376
615
  }),
377
616
  };
378
617
  }
618
+ // {§client-display-capabilities} Core composes the installed family
619
+ // declarations; interface modules expose this contracts-owned wire without
620
+ // inventing presentation policy. `exec` is operation machinery, not an
621
+ // addressable URI scheme; its runtime-tag scheme faces remain discoverable.
622
+ async listClientDisplayCapabilities() {
623
+ const schemes = this.#schemes.list()
624
+ .filter((scheme) => scheme !== "exec")
625
+ .map((scheme) => {
626
+ const glyph = this.#schemes.manifestFor(scheme)?.glyph;
627
+ return {
628
+ kind: "scheme",
629
+ scheme,
630
+ display: glyph === undefined ? {} : { glyph },
631
+ };
632
+ });
633
+ const mimetypes = (await this.#mimetypes.displayMetadata())
634
+ .map(({ mimetype, glyph }) => ({
635
+ kind: "mimetype",
636
+ mimetype,
637
+ display: glyph.length === 0 ? {} : { glyph },
638
+ }));
639
+ return Validator.assertClientDisplayCapabilities([...schemes, ...mimetypes]);
640
+ }
379
641
  listWorkspaces() { return Envelope.listWorkspaces(this.#db); }
380
- listWorkers(workspaceId) { return Envelope.listWorkersForWorkspace(this.#db, workspaceId); }
381
- listPrompts(workspaceId, limit = 100) { return Envelope.listPromptsForWorkspace(this.#db, workspaceId, limit); }
382
- listMembers(workspaceId) { return GitMembership.resolveMembershipEffects(this.#db, workspaceId, undefined); }
642
+ listWorkers(workspaceId) {
643
+ return Envelope.listWorkersForWorkspace(this.#db, ClientInput.assertId("workspace.workers", "workspaceId", workspaceId));
644
+ }
645
+ // {§methods-workspace-prompts}: root-conversation loop seeds, newest-first.
646
+ listPrompts(workspaceId, limit) {
647
+ const checkedWorkspaceId = ClientInput.assertId("workspace.prompts", "workspaceId", workspaceId);
648
+ const checkedLimit = ClientInput.assertLimit("workspace.prompts", limit);
649
+ return Envelope.listPromptsForWorkspace(this.#db, checkedWorkspaceId, checkedLimit ?? 100);
650
+ }
651
+ async listMembers(workspaceId) {
652
+ const checkedWorkspaceId = ClientInput.assertId("workspace.members", "workspaceId", workspaceId);
653
+ const release = await this.#workspaceGate.acquireTurn(checkedWorkspaceId, 0);
654
+ try {
655
+ return await GitMembership.resolveMembershipEffects(this.#db, checkedWorkspaceId, undefined);
656
+ }
657
+ finally {
658
+ release();
659
+ }
660
+ }
383
661
  listConstraints(workspaceId) {
384
- return this.#db.crud_list_workspace_constraints.all({ workspace_id: workspaceId });
662
+ const checkedWorkspaceId = ClientInput.assertId("workspace.constraints", "workspaceId", workspaceId);
663
+ return this.#db.crud_list_workspace_constraints.all({ workspace_id: checkedWorkspaceId });
385
664
  }
386
665
  workspaceDerivationStatus(workspaceId) {
387
- return this.#engine.workspaceDerivationStatus(workspaceId);
666
+ return this.#engine.workspaceDerivationStatus(ClientInput.assertId("workspace.derivation", "workspaceId", workspaceId));
388
667
  }
389
- // Workspace lifecycle (#355): the module's workspace-management surface. Inputs arrive already validated
390
- // at the module's edge ("I am the wall" — settings as the stored JSON string, constraints as a typed
391
- // array, roots absolute); core owns the envelope, its reserved-name + name-uniqueness invariants,
668
+ // {§methods-workspace-create}: the module owns protocol decoding; core validates the typed seam
669
+ // inputs and owns the envelope, its reserved-name + name-uniqueness invariants,
392
670
  // membership resolution, warmWorkspaceDerivations, and the workspace/created emit. No connection state
393
671
  // (which client is on which workspace) lives here — that's the module's.
394
672
  async createWorkspace(args) {
395
- // The SEAM fail-hards on malformed client input (#364 — validation flushed out of the
396
- // retired WS handlers so every module inherits it): settings bag (#231/#232/#249/#328),
397
- // constraints (#200), absolute projectRoot.
673
+ // The seam fails hard on malformed semantic input so every module inherits one wall:
674
+ // the settings bag
675
+ // ({§operator-config-workspace-settings}),
676
+ // constraints, and absolute projectRoot.
677
+ const name = ClientInput.assertOptionalName("workspace.create", "name", args.name);
398
678
  const projectRoot = ClientInput.assertProjectRoot("workspace.create", args.projectRoot);
399
679
  const settings = ClientInput.parseSettings(args.settings);
400
680
  const constraints = ClientInput.parseConstraints(args.constraints);
401
- const envelope = await Envelope.createClientEnvelope(this.#db, { name: args.name, projectRoot, settings });
402
- for (const { effect, glob } of constraints) {
403
- await this.#db.crud_insert_workspace_constraint.run({ workspace_id: envelope.workspaceId, effect, glob });
404
- }
405
- if (constraints.length > 0)
406
- await GitMembership.resolveGitMembership(this.#db, envelope.workspaceId, undefined);
407
- await LoopDocs.materialize(this.#engine, this.#db, envelope.workspaceId);
408
- void this.#engine.warmWorkspaceDerivations(envelope.workspaceId).catch(() => { });
409
- this.#broadcast("all", "workspace/created", { id: envelope.workspaceId, name: envelope.workspaceName, projectRoot: envelope.projectRoot });
410
- return envelope;
681
+ return observed(// {§observability-boundary}
682
+ "workspace.create", {}, async (span) => {
683
+ const envelope = await Envelope.createClientEnvelope(this.#db, { name, projectRoot, settings });
684
+ span.setAttribute("workspace.id", envelope.workspaceId);
685
+ for (const { effect, glob } of constraints) {
686
+ await this.#db.crud_insert_workspace_constraint.run({ workspace_id: envelope.workspaceId, effect, glob });
687
+ }
688
+ if (constraints.length > 0)
689
+ await GitMembership.resolveGitMembership(this.#db, envelope.workspaceId, undefined);
690
+ await LoopDocs.materialize(this.#engine, this.#db, envelope.workspaceId);
691
+ void this.#engine.warmWorkspaceDerivations(envelope.workspaceId).catch(() => { });
692
+ this.#broadcast("all", "workspace/created", { id: envelope.workspaceId, name: envelope.workspaceName, projectRoot: envelope.projectRoot });
693
+ return envelope;
694
+ });
411
695
  }
412
696
  async attachWorkspace(args) {
413
- // attachToWorkspace owns the reserved-name + run-ownership invariants; the seam just delegates + warms.
414
- const envelope = await Envelope.attachToWorkspace(this.#db, args.workspaceId, { workerId: args.workerId, workerName: args.workerName });
697
+ // attachToWorkspace owns the reserved-name + worker-ownership invariants; the seam just delegates + warms.
698
+ const workspaceId = ClientInput.assertId("workspace.attach", "workspaceId", args.workspaceId);
699
+ const workerId = args.workerId === undefined
700
+ ? undefined
701
+ : ClientInput.assertId("workspace.attach", "workerId", args.workerId);
702
+ const workerName = ClientInput.assertOptionalWorkerName("workspace.attach", "workerName", args.workerName);
703
+ const envelope = await Envelope.attachToWorkspace(this.#db, workspaceId, { workerId, workerName });
415
704
  void this.#engine.warmWorkspaceDerivations(envelope.workspaceId).catch(() => { });
416
705
  return envelope;
417
706
  }
418
707
  async renameWorkspace(workspaceId, name) {
419
- if (typeof name !== "string" || name.length === 0)
420
- throw new Error("workspace.rename: name must be a non-empty string"); // seam fail-hard (#364)
421
- const taken = await this.#db.envelope_get_workspace_by_name.get({ name });
422
- if (taken !== undefined && taken.id !== workspaceId)
423
- throw new Error(`a workspace named "${name}" already exists — pick another`);
424
- return { id: workspaceId, name: await Envelope.updateWorkspaceName(this.#db, workspaceId, name) };
708
+ const checkedWorkspaceId = ClientInput.assertId("workspace.rename", "workspaceId", workspaceId);
709
+ const checkedName = ClientInput.assertOptionalName("workspace.rename", "name", name);
710
+ if (checkedName === undefined)
711
+ throw new Error("ClientInput.assertOptionalName accepted a required name as undefined");
712
+ return { id: checkedWorkspaceId, name: await Envelope.updateWorkspaceName(this.#db, checkedWorkspaceId, checkedName) };
425
713
  }
426
714
  async constrain(workspaceId, effect, glob) {
427
- ClientInput.assertConstraint("workspace.constrain", effect, glob);
428
- // Headless is FOREVER (owner ruling, 2026-07-11, matching the client SPEC): a workspace is
429
- // born with its workspace pointer or never has one — so a 'repo' constraint on a headless
430
- // workspace can never resolve. Refuse legibly instead of recording a forever-pending lie.
431
- if (effect === "repo") {
432
- const s = await this.#db.envelope_get_workspace.get({ id: workspaceId });
433
- if (s?.project_root == null)
434
- throw new Error("workspace.constrain: this workspace is headless — and headless is forever (a workspace pointer is set at workspace.create or never). A 'repo' overlay needs a workspace created with projectRoot.");
435
- }
436
- await this.#db.crud_insert_workspace_constraint.run({ workspace_id: workspaceId, effect, glob });
437
- await GitMembership.resolveGitMembership(this.#db, workspaceId, undefined);
438
- // Members may have just landed — begin warming now, but return the constraint response
439
- // immediately. Awaiting the whole corpus here kept `/repo **` at the head of the client's
440
- // command queue, so prompts appeared accepted while no turn could start until 100%.
441
- void this.#engine.warmWorkspaceDerivations(workspaceId).catch(() => { });
442
- return { effect, glob };
715
+ const checkedWorkspaceId = ClientInput.assertId("workspace.constrain", "workspaceId", workspaceId);
716
+ const release = await this.#workspaceGate.acquireTurn(checkedWorkspaceId, 0);
717
+ try {
718
+ ClientInput.assertConstraint("workspace.constrain", effect, glob);
719
+ await this.#db.crud_insert_workspace_constraint.run({ workspace_id: checkedWorkspaceId, effect, glob });
720
+ await GitMembership.resolveGitMembership(this.#db, checkedWorkspaceId, undefined);
721
+ // Members may have just landed — begin warming now, but return the constraint response
722
+ // immediately so prompts do not wait for the complete derivation corpus.
723
+ void this.#engine.warmWorkspaceDerivations(checkedWorkspaceId).catch(() => { });
724
+ return { effect, glob };
725
+ }
726
+ finally {
727
+ release();
728
+ }
443
729
  }
444
730
  async unconstrain(workspaceId, effect, glob) {
445
- ClientInput.assertConstraint("workspace.unconstrain", effect, glob);
446
- await this.#db.crud_delete_workspace_constraint.run({ workspace_id: workspaceId, effect, glob });
447
- await GitMembership.resolveGitMembership(this.#db, workspaceId, undefined);
448
- void this.#engine.warmWorkspaceDerivations(workspaceId).catch(() => { });
449
- return { effect, glob };
450
- }
451
- // The entry-shape hook (#355) — one entry's channels + tags + metadata at a path. With channel+offset,
452
- // returns just that channel's content sliced from the offset: the incremental streaming read (#192,
453
- // the delta leaves storage, not the whole channel). The module renders growing output by re-polling.
731
+ const checkedWorkspaceId = ClientInput.assertId("workspace.unconstrain", "workspaceId", workspaceId);
732
+ const release = await this.#workspaceGate.acquireTurn(checkedWorkspaceId, 0);
733
+ try {
734
+ ClientInput.assertConstraint("workspace.unconstrain", effect, glob);
735
+ await this.#db.crud_delete_workspace_constraint.run({ workspace_id: checkedWorkspaceId, effect, glob });
736
+ await GitMembership.resolveGitMembership(this.#db, checkedWorkspaceId, undefined);
737
+ void this.#engine.warmWorkspaceDerivations(checkedWorkspaceId).catch(() => { });
738
+ return { effect, glob };
739
+ }
740
+ finally {
741
+ release();
742
+ }
743
+ }
744
+ // Contracts {§entry-read-result}: resolve through the scheme's address law,
745
+ // then project one owner-scoped entry without exposing persistence columns.
454
746
  async readEntry(args) {
455
- const m = args.target.match(/^([a-z][a-z0-9+.-]*):\/\/(.*)$/);
456
- if (m === null)
457
- throw new Error(`readEntry: target must be URL-shaped (scheme://pathname); got: ${args.target}`);
458
- if (args.offset !== undefined && args.channel === undefined)
459
- throw new Error("readEntry: offset requires channel (which channel to slice)");
460
- const scheme = m[1];
461
- const pathname = m[2].split("#")[0];
462
- const row = await this.#db.entry_read_lookup.get({ workspace_id: args.workspaceId, scheme, pathname });
463
- if (row === undefined)
464
- return { status: 404, entry: null };
465
- let channelRows;
466
- if (args.channel === undefined) {
467
- channelRows = await this.#db.entry_read_channels.all({ entry_id: row.id });
747
+ const workspaceId = ClientInput.assertId("entry.read", "workspaceId", args.workspaceId);
748
+ const workerId = ClientInput.assertId("entry.read", "workerId", args.workerId);
749
+ if (typeof args.target !== "string" || args.target.length === 0) {
750
+ throw daemonFailure("daemon:input", "target-invalid", 400, "target is not a non-empty string.", {
751
+ context: "entry.read",
752
+ field: "target",
753
+ stage: "input-validation",
754
+ recovery: "Provide an entry URI.",
755
+ retryable: false,
756
+ });
468
757
  }
469
- else {
470
- const r = await this.#db.entry_read_channel_slice.get({ entry_id: row.id, channel: args.channel, offset: args.offset ?? 0 });
471
- channelRows = r === undefined ? [] : [r];
472
- }
473
- const channels = {};
474
- for (const c of channelRows)
475
- channels[c.name] = { content: c.content, contentLength: c.contentLength, mimetype: c.mimetype, tokens: c.tokens, state: c.state };
476
- const tagRows = await this.#db.crud_read_tags.all({ entry_id: row.id });
477
- return { status: 200, entry: { id: row.id, scope: row.scope, workspaceId: row.workspace_id, scheme: row.scheme, pathname: row.pathname, channels, tags: tagRows.map((t) => t.tag) } };
478
- }
479
- // The fork hook (#355) — branch a worker's log into a new worker in the same workspace (#228), sharing the
480
- // workspace's world (entries + overlay), copying nothing of it. The module resolves the default (the
481
- // workspace's model worker) from its own connection state and passes the concrete workerId; the seam owns the
482
- // #366 — a fresh conversation worker: AG-UI threads map to RUNS (§machine-processes — the workspace
483
- // is the workspace, the worker is the conversation). ensureModelWorker is the stable DEFAULT door,
484
- // forkWorker the branching door (copies history); this is the fresh door — a named, empty-log,
485
- // model-origin root that runLoop accepts. New chat = new conversation, same workspace.
758
+ const channel = ClientInput.assertOptionalChannel("entry.read", args.channel);
759
+ const worker = await this.#db.envelope_get_worker_by_id.get({ id: workerId });
760
+ if (worker === undefined) {
761
+ throw daemonFailure("daemon:worker", "worker-not-found", 404, `Worker ${workerId} does not exist.`, { workerId });
762
+ }
763
+ if (worker.workspace_id !== workspaceId) {
764
+ throw daemonFailure("daemon:worker", "workspace-mismatch", 409, `Worker ${workerId} does not belong to workspace ${workspaceId}.`, {
765
+ workerId,
766
+ workspaceId,
767
+ actualWorkspaceId: worker.workspace_id,
768
+ retryable: false,
769
+ });
770
+ }
771
+ const release = await this.#workspaceGate.acquireTurn(workspaceId, workerId);
772
+ try {
773
+ let parsed;
774
+ try {
775
+ parsed = parsePath(args.target);
776
+ }
777
+ catch {
778
+ parsed = null;
779
+ }
780
+ if (parsed === null || parsed.kind !== "url") {
781
+ return entryReadResult(Results.failure("daemon:entry", "target-invalid", 400, `The entry target '${args.target}' is not URL-shaped.`, { entry: null }, {
782
+ target: args.target,
783
+ stage: "entry-read",
784
+ recovery: "Use a scheme://path target.",
785
+ retryable: false,
786
+ }));
787
+ }
788
+ if (args.offset !== undefined && channel === undefined) {
789
+ return entryReadResult(Results.failure("daemon:entry", "offset-channel-required", 400, "An entry offset requires a channel.", { entry: null }, {
790
+ offset: args.offset,
791
+ stage: "entry-read",
792
+ recovery: "Select the channel to read from the offset.",
793
+ retryable: false,
794
+ }));
795
+ }
796
+ if (args.offset !== undefined && (!Number.isSafeInteger(args.offset) || args.offset < 0)) {
797
+ return entryReadResult(Results.failure("daemon:entry", "offset-invalid", 400, `Entry offset ${args.offset} is not a non-negative safe integer.`, { entry: null }, {
798
+ offset: args.offset,
799
+ stage: "entry-read",
800
+ recovery: "Use a non-negative integer offset.",
801
+ retryable: false,
802
+ }));
803
+ }
804
+ if (parsed.username !== null || parsed.password !== null) {
805
+ return entryReadResult(Results.failure("daemon:entry", "userinfo-not-allowed", 400, "Entry target URL userinfo is not allowed.", { entry: null }, {
806
+ stage: "entry-read",
807
+ recovery: "Remove credentials from the entry URL.",
808
+ retryable: false,
809
+ }));
810
+ }
811
+ const location = await this.#engine.resolveEntryAddress({
812
+ workspaceId,
813
+ workerId,
814
+ target: parsed,
815
+ });
816
+ if (location === null) {
817
+ return entryReadResult(Results.failure("daemon:entry", "entry-not-found", 404, "No visible entry exists at the requested target.", { entry: null }, { target: args.target }));
818
+ }
819
+ const row = await this.#db.entry_read_lookup.get({
820
+ workspace_id: workspaceId,
821
+ owner_id: location.ownerId,
822
+ scheme: location.scheme,
823
+ pathname: location.pathname,
824
+ });
825
+ if (row === undefined) {
826
+ return entryReadResult(Results.failure("daemon:entry", "entry-not-found", 404, `No visible entry exists at ${location.target}.`, { entry: null }, { target: location.target }));
827
+ }
828
+ let channelRows;
829
+ if (channel === undefined) {
830
+ channelRows = await this.#db.entry_read_channels.all({ entry_id: row.id });
831
+ }
832
+ else {
833
+ const r = await this.#db.entry_read_channel_slice.get({ entry_id: row.id, channel, offset: args.offset ?? 0 });
834
+ if (r === undefined) {
835
+ const availableChannels = (await this.#db.entry_read_channels.all({ entry_id: row.id }))
836
+ .map(({ name }) => name);
837
+ return entryReadResult(Results.failure("daemon:entry", "channel-not-found", 404, `Channel #${channel} does not exist at ${location.target}.`, { entry: null }, {
838
+ target: location.target,
839
+ requestedChannel: channel,
840
+ availableChannels,
841
+ ...(availableChannels.length === 0
842
+ ? {}
843
+ : { recovery: `Use one of the available channels: ${availableChannels.map((channel) => `#${channel}`).join(", ")}.` }),
844
+ retryable: false,
845
+ }));
846
+ }
847
+ channelRows = [r];
848
+ }
849
+ const channels = {};
850
+ for (const c of channelRows) {
851
+ channels[c.name] = {
852
+ content: c.content,
853
+ contentOffset: c.contentOffset,
854
+ contentLength: c.contentLength,
855
+ mimetype: c.mimetype,
856
+ tokens: c.tokens,
857
+ state: c.state,
858
+ };
859
+ }
860
+ const tagRows = await this.#db.crud_read_tags.all({ entry_id: row.id });
861
+ return entryReadResult({
862
+ status: 200,
863
+ entry: {
864
+ entryId: row.id,
865
+ target: location.target,
866
+ channels,
867
+ tags: tagRows.map((tag) => tag.tag),
868
+ },
869
+ });
870
+ }
871
+ finally {
872
+ release();
873
+ }
874
+ }
875
+ // {§methods-conversation-worker}: a fresh conversation is a model-origin root worker with an empty private log.
876
+ // AG-UI threads map to these workers while the workspace world remains shared ({§machine-processes}).
486
877
  async createConversationWorker(args) {
487
- const { workspaceId, name } = args;
488
- if (name !== undefined && (typeof name !== "string" || name.length === 0))
489
- throw new Error("run.create: name must be a non-empty string");
878
+ const workspaceId = ClientInput.assertId("worker.create", "workspaceId", args.workspaceId);
879
+ const name = ClientInput.assertOptionalWorkerName("worker.create", "name", args.name);
490
880
  const workspace = await this.#db.envelope_get_workspace.get({ id: workspaceId });
491
- if (workspace === undefined)
492
- throw new Error(`run.create: workspace ${workspaceId} not found`);
881
+ if (workspace === undefined) {
882
+ throw daemonFailure("daemon:workspace", "workspace-not-found", 404, `Workspace ${workspaceId} does not exist.`, { workspaceId });
883
+ }
493
884
  if (name !== undefined) {
494
- if (Envelope.RESERVED_RUN_NAMES.has(name.toLowerCase()))
495
- throw new Error(`run.create: name "${name}" is reserved for a non-client actor`);
496
885
  const taken = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name });
497
- if (taken !== undefined)
498
- throw new Error(`run.create: a worker named "${name}" already exists — worker names are immutable, pick another`);
886
+ if (taken !== undefined) {
887
+ throw daemonFailure("daemon:worker", "name-conflict", 409, `Worker name '${name}' is already in use in workspace ${workspaceId}.`, { workspaceId, name, recovery: "Choose another worker name.", retryable: false });
888
+ }
499
889
  }
500
- const run = await Envelope.createModelWorker(this.#db, workspaceId, name);
501
- return { workerId: run.id, workerName: run.name };
890
+ const worker = await Envelope.createModelWorker(this.#db, workspaceId, name);
891
+ return { workerId: worker.id, workerName: worker.name };
502
892
  }
503
- // ownership check and the run-name namespace + uniqueness invariants (names are immutable — no rename).
893
+ // {§worker-scheme-fork} — branch a worker's log while sharing the workspace world.
894
+ // Core owns the workspace check and immutable worker-name admission.
504
895
  async forkWorker(args) {
505
- if (args.name !== undefined && (typeof args.name !== "string" || args.name.length === 0))
506
- throw new Error("run.fork: name must be a non-empty string"); // seam fail-hard (#364)
507
- const { workspaceId, workerId, name } = args;
896
+ const workspaceId = ClientInput.assertId("worker.fork", "workspaceId", args.workspaceId);
897
+ const workerId = ClientInput.assertId("worker.fork", "workerId", args.workerId);
898
+ const name = ClientInput.assertOptionalWorkerName("worker.fork", "name", args.name);
508
899
  const owner = await this.#db.envelope_get_worker_by_id.get({ id: workerId });
509
- if (owner === undefined)
510
- throw new Error(`forkWorker: run ${workerId} not found`);
511
- if (owner.workspace_id !== workspaceId)
512
- throw new Error(`forkWorker: run ${workerId} is not in workspace ${workspaceId}`);
900
+ if (owner === undefined) {
901
+ throw daemonFailure("daemon:worker", "worker-not-found", 404, `Worker ${workerId} does not exist.`, { workerId });
902
+ }
903
+ if (owner.workspace_id !== workspaceId) {
904
+ throw daemonFailure("daemon:worker", "workspace-mismatch", 409, `Worker ${workerId} does not belong to workspace ${workspaceId}.`, {
905
+ workerId,
906
+ workspaceId,
907
+ actualWorkspaceId: owner.workspace_id,
908
+ retryable: false,
909
+ });
910
+ }
513
911
  if (name !== undefined) {
514
- if (Envelope.RESERVED_RUN_NAMES.has(name.toLowerCase()))
515
- throw new Error(`forkWorker: name "${name}" is reserved for a non-client actor`);
516
912
  const taken = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name });
517
- if (taken !== undefined)
518
- throw new Error(`forkWorker: a worker named "${name}" already exists — worker names are immutable, pick another`);
913
+ if (taken !== undefined) {
914
+ throw daemonFailure("daemon:worker", "name-conflict", 409, `Worker name '${name}' is already in use in workspace ${workspaceId}.`, { workspaceId, name, recovery: "Choose another worker name.", retryable: false });
915
+ }
519
916
  }
520
917
  const branchWorkerId = await Fork.fork(this.#db, workerId, name);
521
918
  const branch = await this.#db.envelope_get_worker_by_id.get({ id: branchWorkerId });
522
919
  return { workerId: branchWorkerId, workerName: branch?.name ?? null, parentWorkerId: workerId };
523
920
  }
524
- // The module-load hook (#355 / #289) — register a runtime into the live registry, driver-agnostic:
525
- // the kernel knows nothing about MCP or any specific driver. The struct is the booth window agreed
526
- // with the execs agent (execs-mcp installServer's hotload callback): framework types only — the decl
527
- // (tag + glyph/example/documentation), the executor, the driver's probe result. RegistryEntry never
528
- // leaves the kernel; it's wrapped here, mirroring boot. The engine's scheme-face arbitration
529
- // (reserved / cross-family collision, #240) gates the tag before registering.
530
- hotloadRuntime(reg) {
531
- const { decl, executor, availability } = reg;
532
- this.#engine.hotloadRuntime(decl.name, {
921
+ async registerRuntime({ namespaceOwner, decl, executor, availability, scheme }) {
922
+ if (typeof namespaceOwner !== "string" || namespaceOwner.trim().length === 0) {
923
+ throw new Error("registerRuntime: namespaceOwner must be a non-empty string");
924
+ }
925
+ this.#engine.registerRuntime(decl.name, {
533
926
  executor,
927
+ namespaceOwner: { kind: "module", name: namespaceOwner },
534
928
  glyph: decl.glyph ?? "",
535
929
  example: decl.example ?? "",
536
930
  documentation: decl.documentation ?? "",
537
931
  available: availability.available,
538
932
  detail: availability.detail,
539
- });
933
+ }, scheme);
934
+ if (this.#capabilitiesPublished) {
935
+ for (const workspace of await Envelope.listWorkspaces(this.#db)) {
936
+ await LoopDocs.materialize(this.#engine, this.#db, workspace.id);
937
+ }
938
+ }
939
+ }
940
+ async registerScheme(name, handler) {
941
+ this.#schemes.register(name, handler);
942
+ if (this.#capabilitiesPublished) {
943
+ await this.#schemes.ready();
944
+ for (const workspace of await Envelope.listWorkspaces(this.#db)) {
945
+ await LoopDocs.materialize(this.#engine, this.#db, workspace.id);
946
+ }
947
+ }
948
+ }
949
+ registerModuleAction(name, handler) {
950
+ if (name.length === 0)
951
+ throw new Error("registerModuleAction: action name must not be empty");
952
+ if (this.#moduleActions.has(name))
953
+ throw new Error(`module action '${name}' is already registered`);
954
+ this.#moduleActions.set(name, handler);
955
+ }
956
+ listModuleActions() {
957
+ return [...this.#moduleActions.keys()].toSorted();
958
+ }
959
+ async invokeModuleAction(name, params) {
960
+ const handler = this.#moduleActions.get(name);
961
+ if (handler === undefined)
962
+ throw new Error(`module action '${name}' is not registered`);
963
+ return handler(params);
540
964
  }
541
965
  get engine() { return this.#engine; }
542
966
  get provider() { return this.#provider; }
543
967
  get schemes() { return this.#schemes; }
544
968
  get mimetypes() { return this.#mimetypes; }
545
- // The boot plug-point (#355 hook D) — register a plugin module before start(); its init runs at
546
- // boot with the curated CoreSeam handle, where it opens its own transport/listener. Direct wiring, no
547
- // plugin-kind abstraction: a second transport earns one if it ever appears. "Here's your handle."
548
- // The init's return value is ignored — a module may hand back its instance (or nothing).
549
- #moduleInits = [];
550
- registerModule(init) {
551
- this.#moduleInits.push(init);
969
+ registerModule(module) {
970
+ if (this.#started)
971
+ throw new Error("registerModule: modules must be registered before daemon start");
972
+ this.#modules.push(module);
552
973
  }
553
974
  async start() {
554
975
  if (this.#started)
@@ -557,18 +978,27 @@ export default class Daemon {
557
978
  // Mimetypes owns its own discovery scan over @plurnk/plurnk-mimetypes-*
558
979
  // packages; pre-warm it so first index render doesn't pay the cost.
559
980
  await this.#mimetypes.ready();
981
+ for (const name of await this.#mimetypes.skippedPackages()) {
982
+ console.warn(`mimetype discovery: '${name}' is discovered but untrusted (PLURNK_PLUGINS_TRUSTED_ONLY); not registered`);
983
+ }
560
984
  // Discover + probe the installed executor siblings, then hand the
561
- // registry to the engine for exec dispatch (plurnk-service#181). The
985
+ // registry to the engine for exec dispatch ({§exec-registry-resolves}). The
562
986
  // shell is the default runtime, so its executor must boot usable.
563
987
  const executors = await ExecutorRegistry.build({ defaultRuntime: "sh", cwd: this.#discoveryCwd });
564
988
  this.#engine.setExecutors(executors);
565
- // §exec — mint a scheme per runtime tag so exec output entries address by tag
989
+ // {§exec} — mint a scheme per runtime tag so exec output entries address by tag
566
990
  // authority (sh:///l/t/s). The "exec" scheme stays for the EXEC op dispatch.
567
991
  this.#schemes.registerRuntimeSchemes(executors);
568
992
  // Discover external @plurnk/plurnk-schemes-* siblings + register them
569
993
  // (agnostic, by plurnk.kind:"scheme"). They light up http://, etc. with
570
- // no further engine change — #run wraps their ctx in SchemeCtxImpl (#195).
994
+ // no further engine change — #run wraps their context in SchemeCtxImpl ({§plugin-discovery}).
571
995
  await this.#schemes.discoverExternal(this.#discoveryCwd);
996
+ const setupSeam = this;
997
+ for (const module of this.#modules) {
998
+ if (module.close !== undefined)
999
+ this.#moduleClosers.push(module);
1000
+ await module.setup?.(setupSeam);
1001
+ }
572
1002
  await this.#schemes.ready();
573
1003
  // Reconcile the kernel-published documentation surface once per existing workspace.
574
1004
  // Installed capabilities and operator configuration are now fully known; model loops
@@ -576,16 +1006,29 @@ export default class Daemon {
576
1006
  for (const workspace of await Envelope.listWorkspaces(this.#db)) {
577
1007
  await LoopDocs.materialize(this.#engine, this.#db, workspace.id);
578
1008
  }
1009
+ this.#capabilitiesPublished = true;
579
1010
  await this.#recoverLifecycle();
580
- // #364 — the daemon opens NO transport, ever: plugin modules open theirs via the seam.
581
- for (const init of this.#moduleInits)
582
- await init(this);
1011
+ // {§module-lifecycle} — the daemon opens no transport. Modules start their listeners only
1012
+ // after capability publication and durable lifecycle recovery are complete.
1013
+ for (const module of this.#modules) {
1014
+ const started = await module.start?.(this);
1015
+ if (started !== undefined && !this.#moduleClosers.includes(started)) {
1016
+ this.#moduleClosers.push(started);
1017
+ }
1018
+ }
583
1019
  }
584
1020
  async #recoverLifecycle() {
585
1021
  await this.#db.recovery_fail_active_loops.run({});
1022
+ await this.#db.recovery_fail_open_provider_attempts.run({});
1023
+ await this.#db.recovery_fail_ownerless_proposals.run({});
586
1024
  await this.#db.recovery_error_orphan_subscription_channels.run({});
587
1025
  await this.#db.recovery_fail_orphan_subscriptions.run({});
588
1026
  await this.#db.recovery_resume_unblocked_parks.run({});
1027
+ await this.#branchBatches.recover();
1028
+ const orphanSources = await this.#db.recovery_orphan_prompt_sources.all({});
1029
+ for (const source of orphanSources) {
1030
+ await this.#reconcileOrphanedPrompts(source.worker_id, source.loop_id);
1031
+ }
589
1032
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
590
1033
  const queued = await this.#db.recovery_queued_workers.all({});
591
1034
  for (const row of queued) {
@@ -607,6 +1050,13 @@ export default class Daemon {
607
1050
  if (!this.#started)
608
1051
  return;
609
1052
  this.#started = false;
1053
+ // Stop accepting external work immediately, but do not await listener
1054
+ // closure before cancelling active workers: an SSE connection may itself be
1055
+ // waiting for the worker cancellation that follows.
1056
+ const moduleClose = Promise.allSettled(this.#moduleClosers
1057
+ .toReversed()
1058
+ .map((module) => Promise.resolve().then(() => module.close())));
1059
+ this.#moduleClosers = [];
610
1060
  // Drain order: (1) abort in-flight loops via #activeDrains so
611
1061
  // strike paths don't keep going, (2) await each drain's promise
612
1062
  // to completion, (3) drain streaming schemes' background work
@@ -614,12 +1064,15 @@ export default class Daemon {
614
1064
  // upstream — drain queries hit the DB right up until they exit.
615
1065
  // Abort every worker's cancellation scope — stops in-flight loops AND the
616
1066
  // streams (background execs) linked to them, so idle() doesn't block on
617
- // a long-running command. Covers runs whose drain already exited but
1067
+ // a long-running command. Covers workers whose drain already exited but
618
1068
  // whose exec is still in flight.
619
1069
  // Settle the stopped world FIRST: a drain paused at a pending proposal awaits a resolution
620
1070
  // that will never arrive once clients are gone — allSettled(drains) below would deadlock
621
1071
  // the stop forever (a daemon with a pending HITL proposal could not shut down).
1072
+ const derivationAbort = new DOMException("daemon stopping", "AbortError");
622
1073
  this.#engine.cancelAllProposals("daemon_stopping");
1074
+ this.#engine.cancelDerivations(derivationAbort);
1075
+ this.#branchBatches.beginStop();
623
1076
  for (const scope of this.#workerAborts.values()) {
624
1077
  if (!scope.signal.aborted)
625
1078
  scope.abort("daemon_stopping");
@@ -628,17 +1081,32 @@ export default class Daemon {
628
1081
  clearTimeout(t); // drop pending hibernation poll-wakes
629
1082
  this.#pollBackoff.clear();
630
1083
  this.#pollTimers.clear();
631
- // …and the park-DEADLINE timers (#432): a bounded park's timer fires #wakeParkedWorker after
1084
+ // Cancel park-deadline timers before DB close; otherwise a late #wakeParkedWorker would run after
632
1085
  // stop/db-close if left pending — an unhandled rejection (SqlRite closed) that abnormally
633
1086
  // exits the worker under load. Symmetric with the poll-wakes above; both must be reaped.
634
1087
  for (const t of this.#parkTimers.values())
635
1088
  clearTimeout(t);
636
1089
  this.#parkTimers.clear();
1090
+ await this.#branchBatches.idle();
637
1091
  const drainPromises = [...this.#activeDrains.values()].map((d) => d.promise);
638
1092
  await Promise.allSettled(drainPromises);
639
- await this.#drainStreamingSchemes();
640
- await this.#engine.drainDerivations(); // active workspace warms finish before the db closes upstream
641
- await this.#schemes.close();
1093
+ await Promise.allSettled([...this.#drainExitTasks]);
1094
+ const closeResults = await moduleClose;
1095
+ const [streamingResult] = await Promise.allSettled([this.#drainStreamingSchemes()]);
1096
+ const [derivationResult] = await Promise.allSettled([
1097
+ this.#engine.drainDerivations(derivationAbort), // active workspace warms settle before the db closes upstream
1098
+ ]);
1099
+ const mimetypeResults = this.#ownsMimetypes
1100
+ ? await Promise.allSettled([this.#mimetypes.dispose()])
1101
+ : [];
1102
+ const [schemeResult] = await Promise.allSettled([this.#schemes.close()]);
1103
+ const closeErrors = [...closeResults, streamingResult, derivationResult, ...mimetypeResults, schemeResult]
1104
+ .filter((result) => result.status === "rejected")
1105
+ .flatMap((result) => result.reason instanceof AggregateError
1106
+ ? [...result.reason.errors]
1107
+ : [result.reason]);
1108
+ if (closeErrors.length > 0)
1109
+ throw new AggregateError(closeErrors, "daemon shutdown failed");
642
1110
  }
643
1111
  // Per-scheme idle awaits for clean shutdown. New streaming schemes
644
1112
  // (SSE, WS) add themselves here as they land.
@@ -650,48 +1118,47 @@ export default class Daemon {
650
1118
  /**
651
1119
  * Emit a stream/event notification scoped to the workspace containing the
652
1120
  * entry. ChannelWrite helpers (src/core/ChannelWrite.ts) invoke this when
653
- * they update channel content or state. SPEC §notifications.
1121
+ * they update channel content or state. SPEC {§notifications}.
654
1122
  */
655
1123
  notifyStreamEvent(workspaceId, event) {
656
1124
  this.#broadcast({ workspaceId }, "stream/event", event);
657
1125
  }
658
1126
  /**
659
- * Emit a telemetry/event notification scoped to the workspace containing
660
- * the loop. TelemetryChannel.push invokes this for every TelemetryEvent
661
- * (parse_error, strike, cycle, sudden_death, no_ops, max_commands_exceeded,
662
- * action_failure) the moment it lands in the loop's telemetry buffer.
663
- * SPEC §telemetry.
1127
+ * Emit a transient notice scoped to the workspace containing the loop.
664
1128
  */
665
- notifyTelemetryEvent(workspaceId, payload) {
666
- this.#broadcast({ workspaceId }, "telemetry/event", payload);
1129
+ notifyNotice(workspaceId, payload) {
1130
+ this.#broadcast({ workspaceId }, "notice/event", payload);
667
1131
  }
668
1132
  /**
669
1133
  * Inject a prompt into a worker. Two paths:
670
- * - Active drain: writes a plurnk://prompt/<run>/<loop>/<next-turn> entry
671
- * via Engine.inject. Current loop sees the new prompt at its next
1134
+ * - Active drain: writes the next prompt:///<loop>/<N> entry via
1135
+ * Engine.inject. The current loop publishes it at its next
672
1136
  * turn. Returns immediately with {action: "injected_next_turn"}.
673
1137
  * - No active drain: enqueues a fresh loop with the prompt at
674
1138
  * status=100, starts a drain. Returns the drain promise so the
675
1139
  * caller can await full completion.
676
1140
  *
677
- * Rummy parallel: AgentLoop.inject(). Unified surface — both `loop.run`
678
- * and wake-on-completion go through this method. §actor-boundary-passive-wake
1141
+ * Both `runLoop` and wake-on-completion go through this method
1142
+ * ({§actor-boundary-passive-wake}).
679
1143
  */
680
- // #368 — flags are LOOP-scoped (persisted per loop row; the packet's teaching follows them), so a
681
- // prompt folding into a live/parked loop cannot re-flag it mid-flight — and it must never PRETEND
682
- // to: an inject carrying flags that DIFFER from the target loop's effective flags is refused
683
- // legibly (cancel the loop or omit the flags), never a silent posture discard. Identical or
684
- // absent flags fold clean.
1144
+ // {§methods-loop-run-fold-consistency} — a folded prompt cannot reconfigure its loop.
685
1145
  async #assertFoldPosture(workerId, flags, loopId) {
686
1146
  if (flags === undefined || Object.keys(flags).length === 0)
687
1147
  return;
688
- const row = loopId !== undefined
689
- ? await this.#db.engine_get_loop_flags.get({ loop_id: loopId })
690
- : await this.#db.drain_active_loop_flags.get({ worker_id: workerId });
691
- const effective = { ...DEFAULT_LOOP_FLAGS, ...JSON.parse(row?.flags ?? "{}") };
692
- const conflicts = Object.entries(flags).filter(([k, v]) => v !== undefined && effective[k] !== v).map(([k, v]) => `${k}: ${JSON.stringify(effective[k])} → ${JSON.stringify(v)}`);
1148
+ const effective = await LoopFlagsReader.read(this.#db, loopId);
1149
+ const requested = Object.entries(flags);
1150
+ const conflicts = requested
1151
+ .filter(([key, value]) => value !== undefined && effective[key] !== value)
1152
+ .map(([key, value]) => `${key}: ${JSON.stringify(effective[key])} -> ${JSON.stringify(value)}`);
693
1153
  if (conflicts.length > 0) {
694
- throw new Error(`inject: the prompt would fold into a live loop whose flags differ (${conflicts.join(", ")}) — flags are loop-scoped and never change mid-flight. Cancel the loop (loop.cancel) and re-run with the new flags, or send the prompt without flags to adopt the loop's posture.`);
1154
+ throw daemonFailure("daemon:loop", "loop-flags-conflict", 409, "The requested loop flags differ from the active loop flags.", {
1155
+ workerId,
1156
+ loopId,
1157
+ conflicts,
1158
+ stage: "loop-injection",
1159
+ recovery: "Cancel the active loop before changing flags, or omit flags to keep its current posture.",
1160
+ retryable: false,
1161
+ });
695
1162
  }
696
1163
  }
697
1164
  async inject(args) {
@@ -700,29 +1167,33 @@ export default class Daemon {
700
1167
  // engine.inject returns null when no loop is currently executing, so
701
1168
  // we enqueue a fresh loop below and ensure a drain claims it.
702
1169
  if (this.#activeDrains.has(workerId)) {
703
- await this.#assertFoldPosture(workerId, args.flags); // #368 — a fold never silently discards intent
704
1170
  const active = await this.#db.drain_current_loop_for_worker.get({ worker_id: workerId });
705
1171
  if (active !== undefined) {
1172
+ await this.#assertFoldPosture(workerId, args.flags, active.id); // compare with the exact durable loop
706
1173
  await this.#assertLoopProvider(active.id, args.providerSpec);
707
- await this.#assertLoopMaxTurns(active.id, args.maxTurns);
1174
+ if (args.childProviderSpec !== undefined)
1175
+ await this.#assertLoopChildProvider(active.id, args.childProviderSpec);
1176
+ await this.#assertLoopMaxTurns(active.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
708
1177
  }
709
- const result = await this.#engine.inject(workerId, prompt);
1178
+ const result = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
710
1179
  if (result !== null) {
711
1180
  return { action: "injected_next_turn", loopId: result.loopId, turnSeq: result.turnSeq };
712
1181
  }
713
1182
  }
714
- // #55 — a worker PARKED at 202 RESUMES that slept loop in place: the voice door (irc / loop.inject)
1183
+ // {§worker-lifecycle-wake-requeue-not-terminal} — a worker parked at 202 resumes that loop in place:
715
1184
  // is a wake edge like a stream/child conclusion, not a fresh loop that orphans the parked one
716
1185
  // (which would leave the worker non-quiescent forever). engine.inject writes the message as the
717
1186
  // slept loop's next-turn prompt (the directed message — distinct from the env door, which
718
- // resumes promptless); then re-queue + drain it. §worker-lifecycle-wake-liveness.
1187
+ // resumes promptless); then re-queue + drain it. {§worker-lifecycle-wake-liveness}.
719
1188
  if (!this.#activeDrains.has(workerId)) {
720
1189
  const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
721
1190
  if (slept !== undefined) {
722
- await this.#assertFoldPosture(workerId, args.flags, slept.id); // #368 — the resume path drops nothing silently either
1191
+ await this.#assertFoldPosture(workerId, args.flags, slept.id); // resume drops nothing silently
723
1192
  await this.#assertLoopProvider(slept.id, args.providerSpec);
724
- await this.#assertLoopMaxTurns(slept.id, args.maxTurns);
725
- const injected = await this.#engine.inject(workerId, prompt);
1193
+ if (args.childProviderSpec !== undefined)
1194
+ await this.#assertLoopChildProvider(slept.id, args.childProviderSpec);
1195
+ await this.#assertLoopMaxTurns(slept.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
1196
+ const injected = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
726
1197
  await this.#lifecycle.wake(slept.id);
727
1198
  const started = await this.#ensureDrain({
728
1199
  workspaceId, workerId, systemPrompt: args.systemPrompt,
@@ -730,44 +1201,63 @@ export default class Daemon {
730
1201
  return { action: "injected_next_turn", loopId: slept.id, ...(injected?.turnSeq !== undefined ? { turnSeq: injected.turnSeq } : {}), ...(started ?? {}) };
731
1202
  }
732
1203
  }
733
- // Enqueue a fresh loop. Persist flags on the row.
734
- const seqRow = await this.#db.loop_run_next_sequence.get({ worker_id: workerId });
735
- if (seqRow === undefined)
736
- throw new Error("inject: next-sequence query returned no row");
737
- const loopRow = await this.#db.drain_enqueue_loop.get({
738
- worker_id: workerId, sequence: seqRow.next, prompt,
739
- provider_spec: JSON.stringify(args.providerSpec),
740
- max_turns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
1204
+ const loopId = await this.#enqueueFreshLoop({
1205
+ workerId,
1206
+ prompt,
1207
+ providerSpec: args.providerSpec,
1208
+ childProviderSpec: args.childProviderSpec ?? null,
1209
+ maxTurns: args.turnCeiling?.effective,
1210
+ flags: args.flags,
1211
+ openPaths: args.openPaths,
741
1212
  });
742
- if (loopRow === undefined)
743
- throw new Error("inject: loop enqueue returned no row");
744
- const loopId = loopRow.id;
745
- if (args.flags !== undefined) {
746
- const merged = { ...DEFAULT_LOOP_FLAGS, ...args.flags };
747
- await this.#db.engine_set_loop_flags.run({
748
- loop_id: loopId, flags: JSON.stringify(merged),
749
- });
750
- }
751
- // #260 — persist client-passed @file paths before the drain claims the loop, so turn 0 foists them.
752
- if (args.openPaths !== undefined && args.openPaths.length > 0) {
753
- await this.#db.engine_set_loop_open_paths.run({
754
- loop_id: loopId, open_paths: JSON.stringify(args.openPaths),
755
- });
756
- }
757
1213
  // Guarantee a drain claims the loop we just enqueued. #ensureDrain runs its
758
- // check-and-start UNDER the per-worker drain lock (§worker-lifecycle-single-drain),
1214
+ // check-and-start UNDER the per-worker drain lock ({§worker-lifecycle-single-drain}),
759
1215
  // serialized against a draining sibling's teardown relinquish so the two can't
760
1216
  // both register a drain (R4). A live drain re-claims the loop in its own
761
1217
  // iteration or its lock-held exit re-claim, so it's never stranded.
762
- // firstLoopPromise is present only when THIS call started the drain — loop.run
1218
+ // firstLoopPromise is present only when THIS call started the drain — runLoop
763
1219
  // keys its fast-path response on that.
764
1220
  const started = await this.#ensureDrain({
765
1221
  workspaceId, workerId, systemPrompt: args.systemPrompt,
766
1222
  });
767
1223
  return { action: "enqueued_new_loop", loopId, ...(started ?? {}) };
768
1224
  }
1225
+ async #enqueueFreshLoop(args) {
1226
+ // {§worker-lifecycle-single-drain}: sequence allocation and insertion
1227
+ // are one queue mutation; another accepted prompt cannot claim the gap.
1228
+ return this.#withDrainLock(args.workerId, async () => {
1229
+ const seqRow = await this.#db.loop_run_next_sequence.get({
1230
+ worker_id: args.workerId,
1231
+ });
1232
+ if (seqRow === undefined)
1233
+ throw new Error("enqueueFreshLoop: next-sequence query returned no row");
1234
+ const loopRow = await this.#db.drain_enqueue_loop.get({
1235
+ worker_id: args.workerId,
1236
+ sequence: seqRow.next,
1237
+ prompt: args.prompt,
1238
+ provider_spec: JSON.stringify(args.providerSpec),
1239
+ child_provider_spec: JSON.stringify(args.childProviderSpec),
1240
+ max_turns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
1241
+ });
1242
+ if (loopRow === undefined)
1243
+ throw new Error("enqueueFreshLoop: loop enqueue returned no row");
1244
+ if (args.flags !== undefined) {
1245
+ await this.#db.engine_set_loop_flags.run({
1246
+ loop_id: loopRow.id,
1247
+ flags: JSON.stringify({ ...DEFAULT_LOOP_FLAGS, ...args.flags }),
1248
+ });
1249
+ }
1250
+ if (args.openPaths !== undefined && args.openPaths.length > 0) {
1251
+ await this.#db.engine_set_loop_open_paths.run({
1252
+ loop_id: loopRow.id,
1253
+ open_paths: JSON.stringify(args.openPaths),
1254
+ });
1255
+ }
1256
+ return loopRow.id;
1257
+ });
1258
+ }
769
1259
  /**
770
- * Start a drain for the given run. The drain claims queued loops via
1260
+ * Start a drain for the given worker. The drain claims queued loops via
771
1261
  * drain_claim_next_loop (atomic 100→102 flip), executes each via
772
1262
  * Engine.runLoop, and re-checks. Stream-aware: when the queue is empty
773
1263
  * but the worker has active subscriptions, the drain parks on a
@@ -775,7 +1265,7 @@ export default class Daemon {
775
1265
  * exits when queue is empty AND no active subscriptions remain.
776
1266
  *
777
1267
  * Returns both `firstLoopPromise` (resolves once the first loop the
778
- * drain processes completes — used by loop.run to give the caller a
1268
+ * drain processes completes — used by runLoop to give the caller a
779
1269
  * fast response containing their loop's result) and `drainPromise`
780
1270
  * (resolves only when the whole drain finishes, queue+subs settled).
781
1271
  */
@@ -798,7 +1288,7 @@ export default class Daemon {
798
1288
  const drainPromise = (async () => {
799
1289
  let loopsDrained = 0;
800
1290
  let lastResult = null;
801
- let currentLoopId = null; // the loop being drained — for the #204 abort→499 resolution below
1291
+ let currentLoopId = null; // the loop being drained — for abort→499 settlement
802
1292
  try {
803
1293
  while (true) {
804
1294
  controller.signal.throwIfAborted();
@@ -821,35 +1311,40 @@ export default class Daemon {
821
1311
  break;
822
1312
  }
823
1313
  currentLoopId = loopRow.id;
824
- // #598 — provider identity belongs to the claimed loop, not the
1314
+ // {§methods-loop-run-model} — provider identity belongs to the claimed loop, not the
825
1315
  // drain that happened to claim it. A drain can consume multiple
826
1316
  // queued loops; resolve each durable selection at this boundary.
827
1317
  const provider = await this.#providerForLoop(loopRow.id);
828
1318
  const onDispatch = (logEntryId) => {
829
- // #506 — a rejection here was a silent process-death vector (unhandled in a
830
- // fire-and-forget void); a log-broadcast failure must never crash the drain.
1319
+ // {§methods-event-subscribe} — a log-broadcast failure must never crash the drain.
831
1320
  void (async () => {
832
1321
  const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
833
1322
  this.#broadcast({ workspaceId }, "log/entry", { entry });
834
1323
  })().catch((e) => console.error("log/entry broadcast failed:", e instanceof Error ? e.message : String(e)));
835
1324
  };
836
- const result = await this.#engine.runLoop({
837
- provider, workspaceId, workerId, loopId: loopRow.id, maxTurns: loopRow.max_turns,
838
- messages: [
839
- { role: "system", content: systemPrompt },
840
- { role: "user", content: loopRow.prompt },
841
- ],
842
- origin: "model",
843
- onDispatch,
844
- signal: controller.signal,
1325
+ const result = await observed(// {§observability-boundary}
1326
+ "loop.run", { workspaceId, workerId, "loop.id": loopRow.id }, async (span) => {
1327
+ const loopResult = await this.#engine.runLoop({
1328
+ provider, workspaceId, workerId, loopId: loopRow.id, maxTurns: loopRow.max_turns,
1329
+ messages: [
1330
+ { role: "system", content: systemPrompt },
1331
+ { role: "user", content: loopRow.prompt },
1332
+ ],
1333
+ origin: "model",
1334
+ onDispatch,
1335
+ signal: controller.signal,
1336
+ });
1337
+ span.setAttribute("status", loopResult.result.status);
1338
+ recordCounter(LOOP_TERMINALS, { status: loopResult.result.status });
1339
+ return loopResult;
845
1340
  });
846
- if (result.finalStatus === 202) {
847
- // The loop SLEPT (parked via [102]<T>/<-1>) — suspended, not terminated. Leave it at 202
1341
+ if (result.result.status === 202) {
1342
+ // The loop slept via SEND[202] — suspended, not terminated. Leave it at 202
848
1343
  // (resumable); no loop/terminated, no orphan-reconcile. A stream conclusion
849
1344
  // (#handleWakeWorker) re-queues it; and if it holds a polled stream, a poll timer
850
- // wakes it every P to inspect (§exec-poll). §worker-lifecycle-wake-liveness.
1345
+ // wakes it every P to inspect ({§exec-poll}). {§worker-lifecycle-wake-liveness}.
851
1346
  void this.#schedulePollWake(workspaceId, workerId, systemPrompt).catch((err) => console.error("poll-wake scheduling failed:", err instanceof Error ? err.message : String(err)));
852
- // §send-premature-terminate/[102]<T> — the park DEADLINE (grammar 0.75.0): the
1347
+ // {§send-premature-terminate}/SEND[202]<T> — the park deadline:
853
1348
  // dispatcher recorded the marker's seconds; a bounded park is woken at T
854
1349
  // regardless of arrivals, so a park always has a next turn. -1 (indefinite:
855
1350
  // the butler, a [300] ask) schedules nothing — irc/inject/conclusions wake it.
@@ -871,37 +1366,44 @@ export default class Daemon {
871
1366
  this.#parkTimers.set(workerId, t);
872
1367
  }
873
1368
  }
874
- // Honor an OWED wake (§worker-lifecycle-child-wake): a child/stream concluded while
1369
+ // Honor an OWED wake ({§worker-lifecycle-child-wake}): a child/stream concluded while
875
1370
  // this worker was mid-turn, before it slept — resume in place rather than park blind,
876
- // so a worker-run hibernation always returns. The loop is 202 here; reset to
1371
+ // so a worker hibernation always returns. The loop is 202 here; reset to
877
1372
  // claimable and the drain re-runs it on the next claim below.
878
1373
  if (this.#owedWakes.delete(workerId)) {
879
1374
  await this.#lifecycle.wake(loopRow.id);
1375
+ currentLoopId = null;
880
1376
  continue;
881
1377
  }
882
- // The loop is blocked at 202 on a live obligation (§wait-obligation-matrix);
1378
+ // The loop is blocked at 202 on a live obligation ({§wait-obligation-matrix});
883
1379
  // that obligation's conclusion is its wake edge (the owed-wake above covers the
884
1380
  // conclude-before-block race). An idle wait never reaches here — it concluded at dispatch.
1381
+ currentLoopId = null;
885
1382
  continue;
886
1383
  }
887
1384
  this.#owedWakes.delete(workerId); // the loop concluded (non-202) — no park to honor a held wake at
888
- const usage = await this.#engine.loopUsage(loopRow.id);
889
- const turnIds = await this.#lifecycle.turnIds(loopRow.id);
1385
+ const [usage, attributions, turnIds] = await Promise.all([
1386
+ this.#engine.loopUsage(loopRow.id),
1387
+ this.#engine.loopAttributions(loopRow.id),
1388
+ this.#lifecycle.turnIds(loopRow.id),
1389
+ ]);
890
1390
  this.#broadcast({ workspaceId }, "loop/terminated", {
891
1391
  workerId,
892
1392
  loopId: loopRow.id,
893
- finalStatus: result.finalStatus,
1393
+ result: result.result,
894
1394
  hitMaxTurns: result.hitMaxTurns,
895
1395
  turnIds,
896
1396
  usage,
1397
+ attributions,
897
1398
  });
898
1399
  loopsDrained++;
899
1400
  const loopResult = {
900
1401
  loopId: loopRow.id,
901
1402
  turnIds,
902
- finalStatus: result.finalStatus,
1403
+ result: result.result,
903
1404
  hitMaxTurns: result.hitMaxTurns,
904
1405
  usage,
1406
+ attributions,
905
1407
  };
906
1408
  lastResult = loopResult;
907
1409
  if (!firstSettled) {
@@ -909,67 +1411,106 @@ export default class Daemon {
909
1411
  resolveFirst(loopResult);
910
1412
  }
911
1413
  // A next-turn prompt this loop ended before consuming (a
912
- // wake conclusion or a loop.run-while-active) is promoted to
1414
+ // wake conclusion or a runLoop-while-active prompt) is promoted to
913
1415
  // a fresh queued loop so it's never silently dropped.
914
- await this.#reconcileOrphanedWake(workerId, loopRow.id);
1416
+ await this.#reconcileOrphanedPrompts(workerId, loopRow.id);
1417
+ currentLoopId = null;
915
1418
  }
916
1419
  }
917
1420
  catch (err) {
918
1421
  if (controller.signal.aborted) {
919
- // #204 / Model 3 — loop.cancel / shutdown aborted the live drain. A cancellation
920
- // is the loop's TERMINAL state (499), delivered via loop/terminated (loop.run no
1422
+ // {§methods-loop-cancel} — loop.cancel / shutdown aborted the live drain. A cancellation
1423
+ // is the loop's TERMINAL state (499), delivered via loop/terminated (runLoop no
921
1424
  // longer blocks to return it). A genuine error rejects firstLoopPromise.
922
- const usage = currentLoopId === null
923
- ? { promptTokens: 0, completionTokens: 0, costUsd: 0, contextTokens: 0, promptBudget: null, meta: {} }
924
- : await this.#engine.loopUsage(currentLoopId);
1425
+ let usage = {
1426
+ promptTokens: 0,
1427
+ completionTokens: 0,
1428
+ reasoningTokens: 0,
1429
+ cachedTokens: 0,
1430
+ costUsd: 0,
1431
+ costs: [],
1432
+ contextTokens: 0,
1433
+ promptBudget: null,
1434
+ meta: {},
1435
+ };
1436
+ let attributions = [];
1437
+ const message = ErrorDetail.preview(controller.signal.reason ?? "user_cancelled")
1438
+ || "no reason was supplied";
925
1439
  if (currentLoopId !== null) {
926
- // #380 (owner ruling) — the cancel is allowed but provenanced: the ROW goes
927
- // terminal 499 (a dead loop must never read as live 102, #311) carrying
928
- // terminated_by='cancel' + the abort reason as the abandonment message, and
929
- // the broadcast carries the same message. The abort reason is the client's
930
- // loop.cancel reason (cancelDrain threads it through scope.abort).
931
- const message = String(controller.signal.reason ?? "user_cancelled").slice(0, 500);
932
- const cancelled = await this.#lifecycle.finish(currentLoopId, 499, message, "cancel");
933
- if (cancelled) {
1440
+ // {§methods-loop-cancel}/{§worker-lifecycle-terminal-result} —
1441
+ // persist the exact 499 cancellation result before broadcasting it.
1442
+ const cancelled = await this.#lifecycle.finish(currentLoopId, Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
1443
+ reason: message,
1444
+ stage: "loop",
1445
+ retryable: false,
1446
+ }), { terminatedBy: "cancel" });
1447
+ [usage, attributions] = await Promise.all([
1448
+ this.#engine.loopUsage(currentLoopId),
1449
+ this.#engine.loopAttributions(currentLoopId),
1450
+ ]);
1451
+ if (cancelled !== null) {
934
1452
  this.#broadcast({ workspaceId }, "loop/terminated", {
935
1453
  workerId,
936
1454
  loopId: currentLoopId,
937
- finalStatus: 499,
1455
+ result: cancelled,
938
1456
  hitMaxTurns: false,
939
1457
  turnIds: await this.#lifecycle.turnIds(currentLoopId),
940
1458
  usage,
941
- message,
1459
+ attributions,
942
1460
  });
943
1461
  }
944
1462
  }
945
1463
  if (!firstSettled) {
946
1464
  firstSettled = true;
947
- resolveFirst({ loopId: currentLoopId ?? 0, turnIds: [], finalStatus: 499, hitMaxTurns: false, usage });
1465
+ resolveFirst({
1466
+ loopId: currentLoopId ?? 0,
1467
+ turnIds: [],
1468
+ result: currentLoopId === null
1469
+ ? Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
1470
+ reason: message,
1471
+ stage: "loop",
1472
+ retryable: false,
1473
+ })
1474
+ : await this.#lifecycle.result(currentLoopId)
1475
+ ?? Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
1476
+ reason: message,
1477
+ stage: "loop",
1478
+ retryable: false,
1479
+ }),
1480
+ hitMaxTurns: false,
1481
+ usage,
1482
+ });
948
1483
  }
949
1484
  }
950
1485
  else {
951
- // #265 — a genuine (non-abort) loop error must still reach the client. loop.run only
952
- // acked finalStatus:100, so loop/terminated is the sole outcome channel; the rejection
953
- // alone reaches no one (firstLoopPromise/drainPromise are .catch()'d). Broadcast 500
954
- // (failed) — distinct from an abort's 499 — for every error, not just the pre-first one.
955
- // #506 — the WHY must reach every forensic channel, not one. The old handler
956
- // fed only the loop row + broadcast; run54 died with the daemon log silent, zero
957
- // error telemetry, and a bare 500 — the cause (a stack) reachable nowhere. The
958
- // daemon-log line + the error telemetry event fire even when currentLoopId is null
959
- // or the row-write itself is what failed, so a death is never traceless again.
1486
+ // {§worker-lifecycle-terminal-result} — a non-abort drain
1487
+ // failure becomes an exact durable 500 and terminal notification;
1488
+ // daemon diagnostics retain the complete caught error.
960
1489
  console.error(`drain error (workspace ${workspaceId}, worker ${workerId}, loop ${currentLoopId ?? "?"}):`, err);
961
1490
  if (currentLoopId !== null) {
962
- this.notifyTelemetryEvent(workspaceId, { loopId: currentLoopId, event: { source: "daemon:drain", kind: "loop_error", level: "error", message: err instanceof Error ? err.message : String(err) } });
963
- // #311 — the failure must be first-class on BOTH surfaces: the loop row goes
964
- // terminal 500 carrying the cause (a dead loop must never read as live 102 —
965
- // the premature-terminate gate counts live loops), and the broadcast carries
966
- // the same message so a backend 400 (context overflow, auth, …) reaches the
967
- // client as text, never a contentless 500.
968
- const message = (err instanceof Error ? err.message : String(err)).slice(0, 500);
969
- await this.#lifecycle.finish(currentLoopId, 500, message);
970
- const usage = await this.#engine.loopUsage(currentLoopId);
1491
+ const failure = err instanceof OperationFailureError
1492
+ ? err.result
1493
+ : Results.failure("daemon:drain", "loop-threw", 500, "The loop failed outside its operation result contract.", {}, {
1494
+ stage: "loop",
1495
+ retryable: false,
1496
+ });
1497
+ const settled = await this.#lifecycle.finish(currentLoopId, failure)
1498
+ ?? await this.#lifecycle.result(currentLoopId);
1499
+ if (settled === null) {
1500
+ throw new Error(`drain could not settle loop ${currentLoopId}`, { cause: err });
1501
+ }
1502
+ const [usage, attributions] = await Promise.all([
1503
+ this.#engine.loopUsage(currentLoopId),
1504
+ this.#engine.loopAttributions(currentLoopId),
1505
+ ]);
971
1506
  this.#broadcast({ workspaceId }, "loop/terminated", {
972
- workerId, loopId: currentLoopId, finalStatus: 500, hitMaxTurns: false, turnIds: [], usage, message,
1507
+ workerId,
1508
+ loopId: currentLoopId,
1509
+ result: settled,
1510
+ hitMaxTurns: false,
1511
+ turnIds: await this.#lifecycle.turnIds(currentLoopId),
1512
+ usage,
1513
+ attributions,
973
1514
  });
974
1515
  }
975
1516
  if (!firstSettled) {
@@ -991,11 +1532,15 @@ export default class Daemon {
991
1532
  })();
992
1533
  handle.promise = drainPromise;
993
1534
  this.#activeDrains.set(workerId, handle);
994
- // Topology join (§run-lifecycle): when this drain exits having CONCLUDED the worker, wake its parent
1535
+ // Topology join ({§worker-loop-lifecycle}): when this drain exits having CONCLUDED the worker, wake its parent
995
1536
  // if parked. Runs after the drain fully tears down (settled promise) so the quiescence check sees
996
1537
  // final state; speculative (#onDrainExit no-ops unless the worker concluded AND the parent is parked).
997
- void drainPromise.then(() => this.#onDrainExit(workspaceId, workerId, systemPrompt), () => this.#onDrainExit(workspaceId, workerId, systemPrompt)).catch((err) => {
1538
+ const drainExitTask = drainPromise.then(() => this.#onDrainExit(workspaceId, workerId, systemPrompt), () => this.#onDrainExit(workspaceId, workerId, systemPrompt));
1539
+ this.#drainExitTasks.add(drainExitTask);
1540
+ void drainExitTask.catch((err) => {
998
1541
  console.error(`parent wake after worker ${workerId} settlement failed:`, err);
1542
+ }).finally(() => {
1543
+ this.#drainExitTasks.delete(drainExitTask);
999
1544
  });
1000
1545
  // Swallow unhandled rejections (drain aborts with no awaiter); the
1001
1546
  // error already surfaced via firstLoopPromise or was logged inside.
@@ -1003,12 +1548,12 @@ export default class Daemon {
1003
1548
  firstLoopPromise.catch(() => { });
1004
1549
  return { firstLoopPromise, drainPromise };
1005
1550
  }
1006
- // Per-run drain-transition lock (R4 / §worker-lifecycle-single-drain). #ensureDrain's
1551
+ // Per-worker drain-transition lock (R4 / {§worker-lifecycle-single-drain}). #ensureDrain's
1007
1552
  // start and a drain's teardown relinquish both run under it, serialized, so the two
1008
1553
  // can't interleave and register two drains for one worker. The critical section is the
1009
1554
  // registry decision only (never a loop's work) — a sub-ms hop at drain boundaries.
1010
1555
  // A promise-chain mutex: each caller awaits the prior holder; the tail self-prunes
1011
- // when idle so the Map stays bounded to runs mid-transition.
1556
+ // when idle so the Map stays bounded to workers mid-transition.
1012
1557
  #withDrainLock(workerId, fn) {
1013
1558
  const prev = this.#drainLocks.get(workerId) ?? Promise.resolve();
1014
1559
  const run = prev.then(fn, fn);
@@ -1032,34 +1577,50 @@ export default class Daemon {
1032
1577
  return this.#startDrain(opts);
1033
1578
  });
1034
1579
  }
1035
- // After a loop terminates, promote any next-turn prompt it never consumed —
1036
- // an injected wake (stream conclusion) or a loop.run-while-active prompt
1037
- // that landed on a turn the loop didn't reach — into a fresh queued loop.
1038
- // The drain claims it on its next iteration, so a conclusion or client
1039
- // prompt is never silently dropped. Inherits the ended loop's flags.
1040
- async #reconcileOrphanedWake(workerId, endedLoopId) {
1041
- const endedSeq = (await this.#db.engine_loop_sequence.get({ loop_id: endedLoopId }))?.sequence ?? endedLoopId;
1042
- const prefix = promptLoopPrefix(endedSeq);
1043
- const orphan = await this.#db.drain_orphaned_prompt_for_loop.get({ loop_id: endedLoopId, owner_id: workerId, pattern: `${prefix}%` });
1044
- if (orphan === undefined)
1045
- return;
1046
- const seqRow = await this.#db.loop_run_next_sequence.get({ worker_id: workerId });
1047
- if (seqRow === undefined)
1048
- throw new Error("reconcileOrphanedWake: next-sequence query returned no row");
1049
- const fresh = await this.#db.drain_enqueue_loop.get({
1050
- worker_id: workerId, sequence: seqRow.next, prompt: orphan.body,
1051
- provider_spec: orphan.provider_spec,
1052
- max_turns: (await this.#db.drain_get_loop_max_turns.get({ loop_id: endedLoopId }))?.max_turns
1053
- ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
1580
+ // After a loop terminates, promote every next-turn frame it never consumed
1581
+ // into one source-keyed queued loop. The first frame occupies the loop seed;
1582
+ // later frames retain separate prompt entries and publish in the same turn.
1583
+ // Re-entry and boot recovery complete that same queued identity.
1584
+ async #reconcileOrphanedPrompts(workerId, endedLoopId) {
1585
+ await this.#withDrainLock(workerId, async () => {
1586
+ const endedSeq = (await this.#db.engine_loop_sequence.get({ loop_id: endedLoopId }))?.sequence ?? endedLoopId;
1587
+ const prefix = promptLoopPrefix(endedSeq);
1588
+ const frames = await this.#db.drain_orphaned_prompts_for_loop.all({ loop_id: endedLoopId, owner_id: workerId, pattern: `${prefix}%`, prefix_len: prefix.length });
1589
+ const first = frames[0];
1590
+ if (first === undefined)
1591
+ return;
1592
+ const seqRow = await this.#db.loop_run_next_sequence.get({ worker_id: workerId });
1593
+ if (seqRow === undefined)
1594
+ throw new Error("reconcileOrphanedPrompts: next-sequence query returned no row");
1595
+ const recovery = await this.#db.drain_enqueue_orphan_recovery_loop.get({
1596
+ worker_id: workerId,
1597
+ sequence: seqRow.next,
1598
+ prompt: first.body,
1599
+ flags: first.flags,
1600
+ provider_spec: first.provider_spec,
1601
+ child_provider_spec: first.child_provider_spec,
1602
+ max_turns: first.max_turns,
1603
+ open_paths: first.open_paths ?? "[]",
1604
+ orphan_source_loop_id: endedLoopId,
1605
+ });
1606
+ if (recovery === undefined)
1607
+ throw new Error("reconcileOrphanedPrompts: enqueue returned no row");
1608
+ if (recovery.status !== 100)
1609
+ return;
1610
+ const moved = await this.#db.drain_rehome_orphaned_prompt_frames.all({
1611
+ owner_id: workerId,
1612
+ source_loop_id: endedLoopId,
1613
+ source_pattern: `${prefix}%`,
1614
+ source_prefix_len: prefix.length,
1615
+ target_prefix: promptLoopPrefix(recovery.sequence),
1616
+ });
1617
+ if (moved.length !== frames.length) {
1618
+ throw new Error(`reconcileOrphanedPrompts: expected to re-home ${frames.length} frames, moved ${moved.length}`);
1619
+ }
1054
1620
  });
1055
- if (fresh === undefined)
1056
- throw new Error("reconcileOrphanedWake: enqueue returned no row");
1057
- if (orphan.flags !== null) {
1058
- await this.#db.engine_set_loop_flags.run({ loop_id: fresh.id, flags: orphan.flags });
1059
- }
1060
1621
  }
1061
1622
  // The worker's cancellation scope — lazily created, and replaced once aborted
1062
- // so a later loop.run gets a live signal. The drain and the execs its loops
1623
+ // so a later runLoop gets a live signal. The drain and the execs its loops
1063
1624
  // spawn all run under it.
1064
1625
  #workerSignal(workerId) {
1065
1626
  const existing = this.#workerAborts.get(workerId);
@@ -1089,19 +1650,22 @@ export default class Daemon {
1089
1650
  scope.abort(reason);
1090
1651
  }
1091
1652
  await Promise.all(cancelled.workerIds.map(async (targetWorkerId) => this.#reapWorkerStreams(targetWorkerId)));
1092
- for (const { loopId, workerId: targetWorkerId } of cancelled.loops) {
1653
+ for (const { loopId, workerId: targetWorkerId, result } of cancelled.loops) {
1093
1654
  const row = await this.#db.drain_get_worker_workspace.get({ worker_id: targetWorkerId });
1094
1655
  if (row === undefined)
1095
1656
  continue;
1096
- const usage = await this.#engine.loopUsage(loopId);
1657
+ const [usage, attributions] = await Promise.all([
1658
+ this.#engine.loopUsage(loopId),
1659
+ this.#engine.loopAttributions(loopId),
1660
+ ]);
1097
1661
  this.#broadcast({ workspaceId: row.workspace_id }, "loop/terminated", {
1098
1662
  workerId: targetWorkerId,
1099
1663
  loopId,
1100
- finalStatus: 499,
1664
+ result,
1101
1665
  hitMaxTurns: false,
1102
1666
  turnIds: await this.#lifecycle.turnIds(loopId),
1103
1667
  usage,
1104
- message: reason.slice(0, 500),
1668
+ attributions,
1105
1669
  });
1106
1670
  }
1107
1671
  }
@@ -1110,10 +1674,11 @@ export default class Daemon {
1110
1674
  }
1111
1675
  /**
1112
1676
  * Cancel the worker's in-flight work (loop.cancel). One abort, one scope: the
1113
- * run signal stops the running loop's turn generation AND tears down every
1677
+ * worker signal stops the running loop's turn generation AND tears down every
1114
1678
  * stream linked to it — a background exec that outlived its loop, or even a
1115
1679
  * spawn that registers after this abort (it self-aborts against the aborted
1116
- * signal). Returns cancelled iff there was work. Queued loops stay enqueued.
1680
+ * signal). Returns cancelled iff there was process-local work; durable
1681
+ * unresolved loops in the worker tree are terminalized independently.
1117
1682
  */
1118
1683
  cancelDrain(workerId, reason = "user_cancelled") {
1119
1684
  const hadDrain = this.#activeDrains.has(workerId);
@@ -1135,12 +1700,12 @@ export default class Daemon {
1135
1700
  }
1136
1701
  // Does the worker have an in-flight stream (a background exec)? Used only for
1137
1702
  // loop.cancel's cancelled=true/false answer; the teardown itself rides the
1138
- // run signal. Duck-typed like #drainStreamingSchemes.
1703
+ // worker signal. Duck-typed like #drainStreamingSchemes.
1139
1704
  #workerHasActiveStreams(workerId) {
1140
1705
  const exec = this.#schemes.get("exec");
1141
1706
  return exec?.hasActiveSpawns?.(workerId) ?? false;
1142
1707
  }
1143
- // The contract-routed reap (§worker-lifecycle-total-reap): durable rows enumerate
1708
+ // The contract-routed reap ({§worker-lifecycle-total-reap}): durable rows enumerate
1144
1709
  // every open subscription; the live registry invokes its exact callable owner.
1145
1710
  // The worker signal is only the fast path. An exec mid-spawn or a background exec
1146
1711
  // from a past loop is caught regardless of listener timing. Idempotent — a stream
@@ -1151,79 +1716,76 @@ export default class Daemon {
1151
1716
  }
1152
1717
  /**
1153
1718
  * Wake-on-completion handler. Streaming schemes call this when a
1154
- * subscription closes. If the worker has an active loop, the channel
1155
- * transition will surface at that loop's next turn boundary — no new
1156
- * loop needed. Otherwise we open a fresh loop with the synthetic
1157
- * summary as the user prompt so the model gets a chance to react.
1158
- *
1159
- * Skipped on closeStatus=499 (aborted): the model already knows about
1160
- * its own SEND[499], and a forcefully-cancelled loop's spawn-abort
1161
- * shouldn't resurrect into a wake loop (defeats the cancel).
1719
+ * subscription closes. A parked loop resumes in place; an active loop
1720
+ * observes the channel transition at its next turn boundary. No synthetic
1721
+ * prompt or replacement loop is created.
1162
1722
  *
1163
- * Rummy parallel: plugins/stream/stream.js stream/completed wake:true.
1723
+ * Skipped on result.status=499 (aborted): the model already knows about
1724
+ * its own SEND[499], and a forcefully-cancelled loop's spawn-abort must
1725
+ * not resurrect the worker.
1164
1726
  */
1165
1727
  async #handleWakeWorker(payload) {
1166
- // §search-gate — settle the dedup registration: promote on a 200 conclusion, drop on
1728
+ const { entryOwnerId, ...wake } = payload;
1729
+ const conclusion = { ...wake, workerId: entryOwnerId };
1730
+ // {§search-gate} — settle the dedup registration: promote on a 200 conclusion, drop on
1167
1731
  // failure (a dead search must never serve as a duplicate). No-op for non-search streams.
1168
- this.#engine.searchGate.settle(payload.target.replace(/^[a-z+.-]+:\/\//, "/").replace(/^\/+/, "/"), payload.closeStatus);
1732
+ this.#engine.searchGate.settle(payload.target.replace(/^[a-z+.-]+:\/\//, "/").replace(/^\/+/, "/"), payload.result.status);
1169
1733
  // Aborted streams don't wake — the abort was deliberate.
1170
- if (payload.closeStatus === 499) {
1734
+ if (payload.result.status === 499) {
1171
1735
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1172
- ...payload, wakeAction: "skipped-aborted",
1736
+ ...conclusion, wakeAction: "skipped-aborted",
1173
1737
  });
1174
1738
  return;
1175
1739
  }
1176
- // No resurrection (§worker-lifecycle-no-resurrection): a non-499 completion whose
1177
- // run was CANCELLED (idle + its scope aborted) must not start a fresh drain —
1740
+ // No resurrection ({§worker-lifecycle-no-resurrection}): a non-499 completion whose
1741
+ // worker was cancelled (idle + its scope aborted) must not start a fresh drain —
1178
1742
  // the cancel was deliberate. The deliverable is already in the channel/log and
1179
- // surfaces as a `collect` environment delta (§env-delta) if the worker is read or
1180
- // resumed; we just don't inject a turn. (An active run folds the wake into its
1181
- // next turn via inject below; a resumed run is active, never aborted, so it is
1743
+ // surfaces as a `collect` environment delta ({§env-delta}) if the worker is read or
1744
+ // resumed; we just don't inject a turn. (An active worker folds the wake into its
1745
+ // next turn via inject below; a resumed worker is active, never aborted, so it is
1182
1746
  // unaffected.)
1183
1747
  const scope = this.#workerAborts.get(payload.workerId);
1184
1748
  if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.workerId)) {
1185
1749
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1186
- ...payload, wakeAction: "skipped-cancelled",
1750
+ ...conclusion, wakeAction: "skipped-cancelled",
1187
1751
  });
1188
1752
  return;
1189
1753
  }
1190
1754
  try {
1191
1755
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
1192
- // A slept (202) loop means the worker PARKED ([102]<T>/<-1>) → RESUME it IN PLACE: re-queue
1756
+ // A slept (202) loop means the worker parked via SEND[202] → resume it in place: re-queue
1193
1757
  // it (202→100) so the drain re-claims and CONTINUES it (seq>1 → no re-foist). Checked
1194
1758
  // FIRST: the slept status is the worker's true disposition regardless of a draining
1195
1759
  // sibling mid-teardown (the #ensureDrain lock serializes the re-claim). No fresh loop,
1196
1760
  // no summary-as-prompt — the resumed loop reads the concluded stream's own state from
1197
- // the manifest. §worker-lifecycle-wake-liveness.
1761
+ // the manifest. {§worker-lifecycle-wake-liveness}.
1198
1762
  const slept = await this.#db.drain_find_slept_loop.get({ worker_id: payload.workerId });
1199
1763
  if (slept !== undefined) {
1200
- await this.#lifecycle.wake(slept.id);
1201
- const started = await this.#ensureDrain({
1202
- workspaceId: payload.workspaceId, workerId: payload.workerId,
1203
- systemPrompt,
1764
+ // {§worker-optimistic-settlement} — publish this conclusion now,
1765
+ // then let the worker-local gate coalesce only the provider
1766
+ // dispatch. Concurrent stream/child callbacks join that one gate.
1767
+ void this.#settleCompletionWake(payload.workspaceId, payload.workerId, systemPrompt, false).catch((err) => {
1768
+ console.error("completion wake settlement failed:", err instanceof Error ? err.message : String(err));
1204
1769
  });
1205
1770
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1206
- ...payload, wakeAction: "resumed-loop", wakeLoopId: slept.id,
1207
- });
1208
- started?.drainPromise?.catch((err) => {
1209
- console.error("wake resume drain failed:", err instanceof Error ? err.message : String(err));
1771
+ ...conclusion, wakeAction: "resumed-loop", wakeLoopId: slept.id,
1210
1772
  });
1211
1773
  return;
1212
1774
  }
1213
1775
  // No slept loop. A live loop surfaces the concluded stream ambiently via the
1214
- // environment-observation injector (§exec-stream) on its next turn — there is no prompt
1776
+ // environment-observation injector ({§exec-stream}) on its next turn — there is no prompt
1215
1777
  // to inject and NO task to overwrite. The obsolete "automated environment update"
1216
1778
  // synthesis (which clobbered the model's actual goal) is retired; just tell the client.
1217
1779
  if (this.#activeDrains.has(payload.workerId)) {
1218
1780
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1219
- ...payload, wakeAction: "no-op-active-loop",
1781
+ ...conclusion, wakeAction: "no-op-active-loop",
1220
1782
  });
1221
1783
  return;
1222
1784
  }
1223
- // No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done run whose
1785
+ // No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done worker whose
1224
1786
  // streams were swept). Surface the conclusion without opening a loop.
1225
1787
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1226
- ...payload, wakeAction: "no-loop",
1788
+ ...conclusion, wakeAction: "no-loop",
1227
1789
  });
1228
1790
  }
1229
1791
  catch (err) {
@@ -1234,8 +1796,8 @@ export default class Daemon {
1234
1796
  * grammar 0.74.20 EXEC `<T,P>` — schedule a hibernation poll-wake. Called when a loop parks at
1235
1797
  * a park; if the worker holds an open polled stream, arm a timer for its tightest cadence P that
1236
1798
  * resumes the slept loop so the model inspects progress. While the loop is ACTIVE there is no
1237
- * poll work — ambient folded stream deltas already surface progress (§exec-stream); the wake
1238
- * matters only across hibernation. A wake-edge-less 202 (no polled stream) gets no timer. §exec-poll
1799
+ * poll work — ambient folded stream deltas already surface progress ({§exec-stream}); the wake
1800
+ * matters only across hibernation. A wake-edge-less 202 (no polled stream) gets no timer. {§exec-poll}
1239
1801
  */
1240
1802
  async #schedulePollWake(workspaceId, workerId, systemPrompt) {
1241
1803
  const existing = this.#pollTimers.get(workerId);
@@ -1249,14 +1811,8 @@ export default class Daemon {
1249
1811
  return;
1250
1812
  }
1251
1813
  const pollSec = row?.poll_seconds ?? null;
1252
- // #521 (§exec-poll, owner-ruled) — the poll cadence for a parked exec stream:
1253
- // explicit <,P> (P>0) → fixed cadence P, reset the backoff (today's behavior).
1254
- // explicit <,0> → poll_seconds=0 stored → blind opt-out (an exec a model wants unwatched).
1255
- // absent <,P> + a LIVE stream → EXPONENTIAL BACKOFF (base*2^min(step,turns-1)), so a hung
1256
- // exec is no longer park-blind-forever: the model regains a turn every tick to read
1257
- // partial output and re-park a slow long-runner or KILL a stuck one (no auto-kill — only
1258
- // the model tells a silent deadlock from a silent `cargo build`).
1259
- // no open stream at all → nothing to poll (a child-join park is woken by the child terminal).
1814
+ // {§exec-poll} — a positive explicit cadence wins, zero opts out,
1815
+ // and an absent cadence uses the worker's exponential-backoff step.
1260
1816
  let delayMs;
1261
1817
  if (pollSec !== null && pollSec > 0) {
1262
1818
  this.#pollBackoff.delete(workerId);
@@ -1275,21 +1831,23 @@ export default class Daemon {
1275
1831
  delayMs = execPollBackoffMs(step, base, turns);
1276
1832
  this.#pollBackoff.set(workerId, step + 1);
1277
1833
  }
1278
- // Floored by the post-EXEC breath (PLURNK_SERVICE_EXEC_WAIT_MS) so a `<…,1>` can't wake the loop
1279
- // faster than a turn settles — §exec-poll.
1280
- const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
1834
+ // Floored by the optimistic settlement cap so a `<…,1>` cannot wake a
1835
+ // parked loop faster than the preceding turn's settlement scale.
1836
+ const optimisticWaitMs = readOptimisticSettlementMs();
1281
1837
  const timer = setTimeout(() => {
1282
1838
  this.#pollTimers.delete(workerId);
1283
1839
  void this.#wakeParkedWorker(workspaceId, workerId, systemPrompt);
1284
- }, Math.max(delayMs, execWaitMs));
1840
+ }, Math.max(delayMs, optimisticWaitMs));
1285
1841
  timer.unref();
1286
1842
  this.#pollTimers.set(workerId, timer);
1287
1843
  }
1288
1844
  /** Resume `workerId`'s slept (202) loop in place — the same 202→100 resume #handleWakeWorker uses, minus a
1289
- * wake payload. The shared wake primitive: a poll cadence (§exec-poll), a watched stream concluding,
1290
- * or a child worker finishing (§run-lifecycle topology join) all call this. A no-op if the worker was
1845
+ * wake payload. The shared wake primitive: a poll cadence ({§exec-poll}), a watched stream concluding,
1846
+ * or a child worker finishing ({§worker-loop-lifecycle} topology join) all call this. A no-op if the worker was
1291
1847
  * cancelled or isn't actually parked (no slept loop) — so calling it speculatively is safe. */
1292
- async #wakeParkedWorker(workspaceId, workerId, systemPrompt) {
1848
+ async #wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive = true) {
1849
+ if (!this.#started)
1850
+ return;
1293
1851
  const scope = this.#workerAborts.get(workerId);
1294
1852
  if (scope?.signal.aborted === true && !this.#activeDrains.has(workerId))
1295
1853
  return; // cancelled — no resurrection
@@ -1297,25 +1855,108 @@ export default class Daemon {
1297
1855
  if (slept === undefined) {
1298
1856
  // Not parked. If a drain is still ACTIVE, the worker is mid-turn and about to park — the
1299
1857
  // conclusion that fired this wake arrived before the 202 committed (the conclude-before-park
1300
- // race). OWE the wake: the drain honors it at park so a worker-run hibernation never deadlocks.
1858
+ // race). Owe the wake: the drain honors it at park so a worker hibernation never deadlocks.
1301
1859
  // (No active drain → already concluded/running; nothing to wake.)
1302
- if (this.#activeDrains.has(workerId))
1303
- this.#owedWakes.add(workerId);
1860
+ if (this.#activeDrains.has(workerId)) {
1861
+ if (oweIfActive)
1862
+ this.#owedWakes.add(workerId);
1863
+ }
1304
1864
  return;
1305
1865
  }
1306
- await this.#lifecycle.wake(slept.id);
1866
+ const woke = await this.#lifecycle.wake(slept.id);
1867
+ if (!woke)
1868
+ return;
1307
1869
  const started = await this.#ensureDrain({
1308
1870
  workspaceId, workerId, systemPrompt,
1309
1871
  });
1310
1872
  started?.drainPromise?.catch((err) => {
1311
- console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
1873
+ if (this.#started) {
1874
+ console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
1875
+ }
1312
1876
  });
1313
1877
  }
1878
+ async #workerHasLiveObligation(workerId) {
1879
+ const [openSubscriptions, liveChild] = await Promise.all([
1880
+ this.#db.find_open_subscriptions_for_worker.all({ worker_id: workerId }),
1881
+ this.#db.engine_worker_has_live_child.get({ worker_id: workerId }),
1882
+ ]);
1883
+ return openSubscriptions.length > 0 || liveChild !== undefined;
1884
+ }
1885
+ #settleCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive = true) {
1886
+ const existing = this.#completionWakeGates.get(workerId);
1887
+ if (existing !== undefined) {
1888
+ existing.conclusions++;
1889
+ existing.poke.resolve();
1890
+ return existing.promise;
1891
+ }
1892
+ const completed = Promise.withResolvers();
1893
+ const gate = {
1894
+ conclusions: 1,
1895
+ poke: Promise.withResolvers(),
1896
+ promise: completed.promise,
1897
+ };
1898
+ this.#completionWakeGates.set(workerId, gate);
1899
+ void this.#runCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive, gate).then(completed.resolve, completed.reject).finally(() => {
1900
+ if (this.#completionWakeGates.get(workerId) === gate) {
1901
+ this.#completionWakeGates.delete(workerId);
1902
+ }
1903
+ });
1904
+ return gate.promise;
1905
+ }
1906
+ async #runCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive, gate) {
1907
+ const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
1908
+ if (slept === undefined) {
1909
+ this.#releaseCompletionWake(workerId, gate);
1910
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive);
1911
+ }
1912
+ const timeoutMs = readOptimisticSettlementMs();
1913
+ if (timeoutMs === 0 || !(await this.#workerHasLiveObligation(workerId))) {
1914
+ this.#releaseCompletionWake(workerId, gate);
1915
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
1916
+ }
1917
+ return observed("worker.wake.settlement", { "window.ms": timeoutMs }, async (span) => {
1918
+ const startedAt = performance.now();
1919
+ const signal = this.#workerAborts.get(workerId)?.signal;
1920
+ const deadline = delay(timeoutMs, "deadline", { signal, ref: false })
1921
+ .catch((cause) => {
1922
+ if (signal?.aborted === true)
1923
+ return "cancelled";
1924
+ throw cause;
1925
+ });
1926
+ let release = "quiescent";
1927
+ while (await this.#workerHasLiveObligation(workerId)) {
1928
+ const poke = gate.poke;
1929
+ const outcome = await Promise.race([
1930
+ poke.promise.then(() => "arrival"),
1931
+ deadline,
1932
+ ]);
1933
+ if (outcome === "arrival") {
1934
+ if (gate.poke === poke)
1935
+ gate.poke = Promise.withResolvers();
1936
+ continue;
1937
+ }
1938
+ release = outcome;
1939
+ break;
1940
+ }
1941
+ span.setAttribute("release", release);
1942
+ span.setAttribute("conclusions", gate.conclusions);
1943
+ span.setAttribute("elapsed.ms", Math.round(performance.now() - startedAt));
1944
+ this.#releaseCompletionWake(workerId, gate);
1945
+ if (release === "cancelled")
1946
+ return;
1947
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
1948
+ });
1949
+ }
1950
+ #releaseCompletionWake(workerId, gate) {
1951
+ if (this.#completionWakeGates.get(workerId) === gate) {
1952
+ this.#completionWakeGates.delete(workerId);
1953
+ }
1954
+ }
1314
1955
  /** A worker's drain exited. If the worker truly CONCLUDED — no 202-blocked loop, no open stream — then
1315
1956
  * wake its PARENT in place if the parent is blocked on the join (the structured-concurrency join — a
1316
- * child finishing is the wake edge for a parent that waited on it, §worker-lifecycle-child-wake). A worker
1957
+ * child finishing is the wake edge for a parent that waited on it, {§worker-lifecycle-child-wake}). A worker
1317
1958
  * blocked at 202, or still holding a stream, is NOT concluded — its own wake edges drive it, not this.
1318
- * The parent reads the child's deliverable from its own log (the §worker-scheme-collect delta) on
1959
+ * The parent reads the child's deliverable from its own log (the {§worker-scheme-collect} delta) on
1319
1960
  * resume — control edge here, never an injected prompt. Recurses up via the parent's own drain-exit. */
1320
1961
  async #onDrainExit(workspaceId, workerId, systemPrompt) {
1321
1962
  const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
@@ -1326,13 +1967,11 @@ export default class Daemon {
1326
1967
  return; // a stream still runs — its conclusion re-evaluates, not this exit
1327
1968
  const parent = await this.#db.worker_parent_id.get({ worker_id: workerId });
1328
1969
  if (parent?.parent_worker_id == null)
1329
- return; // a root run — nobody to wake
1330
- await this.#wakeParkedWorker(workspaceId, parent.parent_worker_id, systemPrompt);
1970
+ return; // a root worker — nobody to wake
1971
+ await this.#settleCompletionWake(workspaceId, parent.parent_worker_id, systemPrompt);
1331
1972
  }
1332
- // #506 — a SUBSCRIBER throw must never propagate into engine control flow: a transport
1333
- // module's bad socket rethrowing through the emitter was the run54/55 death class (an
1334
- // unhandled rejection in the one then-uncaught dispatch void). The transport's failure is
1335
- // its own — logged loudly per event, never the engine's crash.
1973
+ // {§methods-event-subscribe} — subscriber failures are transport-local:
1974
+ // log them at this boundary and never re-enter engine control flow.
1336
1975
  #emitTo(workspaceId, method, params) {
1337
1976
  for (const sub of this.#eventSubscribers) {
1338
1977
  try {
@@ -1344,16 +1983,19 @@ export default class Daemon {
1344
1983
  }
1345
1984
  }
1346
1985
  #broadcast(target, method, params) {
1347
- if (target === "all") {
1348
- // A global engine event (e.g. workspace/created) — emitted to the seam with workspaceId null (#355).
1349
- this.#emitTo(null, method, params);
1350
- return;
1351
- }
1352
- // Publish the raw event to the in-process source first (#355) — transport modules subscribe
1353
- // here (plurnk-agui renders to AG-UI+). Each subscriber owns its own fan-out; core just emits.
1354
- // Scope-stamping onto the notification envelope (§notifications-envelope-carries-workspaceid)
1355
- // is each subscriber's edge concern now — the seam hands (workspaceId, method, params) raw.
1356
- this.#emitTo(target.workspaceId, method, params);
1986
+ observedSync(// {§observability-boundary}
1987
+ "stream.broadcast", { method, ...(target === "all" ? {} : { "workspace.id": target.workspaceId }) }, () => {
1988
+ if (target === "all") {
1989
+ // {§notifications-envelope-carries-workspaceid}: global events carry workspaceId null.
1990
+ this.#emitTo(null, method, params);
1991
+ return;
1992
+ }
1993
+ // {§methods-event-subscribe}: publish to the in-process source; transport modules subscribe
1994
+ // here (plurnk-agui renders to AG-UI+). Each subscriber owns its own fan-out; core just emits.
1995
+ // Scope-stamping onto the notification envelope ({§notifications-envelope-carries-workspaceid})
1996
+ // is each subscriber's edge concern now — the seam hands (workspaceId, method, params) raw.
1997
+ this.#emitTo(target.workspaceId, method, params);
1998
+ });
1357
1999
  }
1358
2000
  }
1359
2001
  //# sourceMappingURL=Daemon.js.map