@plurnk/plurnk-service 1.3.12 → 1.4.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 (469) hide show
  1. package/.env.defaults +84 -77
  2. package/INSTALL.md +50 -25
  3. package/README.md +24 -10
  4. package/SPEC.md +2311 -928
  5. package/dist/Paths.d.ts +4 -1
  6. package/dist/Paths.d.ts.map +1 -1
  7. package/dist/Paths.js +23 -21
  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 +3 -3
  18. package/dist/content/index.d.ts.map +1 -1
  19. package/dist/content/index.js +1 -1
  20. package/dist/content/index.js.map +1 -1
  21. package/dist/content/line-marker.d.ts +6 -9
  22. package/dist/content/line-marker.d.ts.map +1 -1
  23. package/dist/content/line-marker.js +7 -11
  24. package/dist/content/line-marker.js.map +1 -1
  25. package/dist/content/matcher.d.ts +14 -25
  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-resolve.d.ts +8 -4
  36. package/dist/content/read-resolve.d.ts.map +1 -1
  37. package/dist/content/read-resolve.js +101 -49
  38. package/dist/content/read-resolve.js.map +1 -1
  39. package/dist/core/BranchReceipt.d.ts +7 -0
  40. package/dist/core/BranchReceipt.d.ts.map +1 -0
  41. package/dist/core/BranchReceipt.js +27 -0
  42. package/dist/core/BranchReceipt.js.map +1 -0
  43. package/dist/core/BudgetOverflow.d.ts +23 -0
  44. package/dist/core/BudgetOverflow.d.ts.map +1 -0
  45. package/dist/core/BudgetOverflow.js +61 -0
  46. package/dist/core/BudgetOverflow.js.map +1 -0
  47. package/dist/core/BudgetReadout.d.ts +8 -0
  48. package/dist/core/BudgetReadout.d.ts.map +1 -0
  49. package/dist/core/BudgetReadout.js +79 -0
  50. package/dist/core/BudgetReadout.js.map +1 -0
  51. package/dist/core/ChannelWrite.d.ts +28 -9
  52. package/dist/core/ChannelWrite.d.ts.map +1 -1
  53. package/dist/core/ChannelWrite.js +28 -21
  54. package/dist/core/ChannelWrite.js.map +1 -1
  55. package/dist/core/ChannelWrite.sql +11 -10
  56. package/dist/core/CoreSchemeServices.d.ts +10 -4
  57. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  58. package/dist/core/CoreSchemeServices.js +7 -1
  59. package/dist/core/CoreSchemeServices.js.map +1 -1
  60. package/dist/core/Dispatcher.d.ts +29 -20
  61. package/dist/core/Dispatcher.d.ts.map +1 -1
  62. package/dist/core/Dispatcher.js +1488 -599
  63. package/dist/core/Dispatcher.js.map +1 -1
  64. package/dist/core/DurableStatement.d.ts +6 -0
  65. package/dist/core/DurableStatement.d.ts.map +1 -0
  66. package/dist/core/DurableStatement.js +51 -0
  67. package/dist/core/DurableStatement.js.map +1 -0
  68. package/dist/core/Engine.d.ts +48 -20
  69. package/dist/core/Engine.d.ts.map +1 -1
  70. package/dist/core/Engine.js +1208 -789
  71. package/dist/core/Engine.js.map +1 -1
  72. package/dist/core/Engine.sql +309 -217
  73. package/dist/core/EnvFlags.js +1 -1
  74. package/dist/core/EnvFlags.js.map +1 -1
  75. package/dist/core/ErrorDetail.d.ts +6 -0
  76. package/dist/core/ErrorDetail.d.ts.map +1 -0
  77. package/dist/core/ErrorDetail.js +20 -0
  78. package/dist/core/ErrorDetail.js.map +1 -0
  79. package/dist/core/ExecutorRegistry.d.ts +14 -3
  80. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  81. package/dist/core/ExecutorRegistry.js +56 -30
  82. package/dist/core/ExecutorRegistry.js.map +1 -1
  83. package/dist/core/GitBranch.d.ts +20 -0
  84. package/dist/core/GitBranch.d.ts.map +1 -0
  85. package/dist/core/GitBranch.js +110 -0
  86. package/dist/core/GitBranch.js.map +1 -0
  87. package/dist/core/JournalTurn.d.ts +9 -0
  88. package/dist/core/JournalTurn.d.ts.map +1 -0
  89. package/dist/core/JournalTurn.js +14 -0
  90. package/dist/core/JournalTurn.js.map +1 -0
  91. package/dist/core/JournalTurn.sql +10 -0
  92. package/dist/core/LogBody.d.ts +17 -0
  93. package/dist/core/LogBody.d.ts.map +1 -0
  94. package/dist/core/LogBody.js +148 -0
  95. package/dist/core/LogBody.js.map +1 -0
  96. package/dist/core/LoopFlagsReader.d.ts +7 -0
  97. package/dist/core/LoopFlagsReader.d.ts.map +1 -0
  98. package/dist/core/LoopFlagsReader.js +33 -0
  99. package/dist/core/LoopFlagsReader.js.map +1 -0
  100. package/dist/core/LoopLifecycle.d.ts +8 -1
  101. package/dist/core/LoopLifecycle.d.ts.map +1 -1
  102. package/dist/core/LoopLifecycle.js +55 -9
  103. package/dist/core/LoopLifecycle.js.map +1 -1
  104. package/dist/core/LoopLifecycle.sql +9 -3
  105. package/dist/core/NoticeChannel.d.ts +13 -0
  106. package/dist/core/NoticeChannel.d.ts.map +1 -0
  107. package/dist/core/NoticeChannel.js +40 -0
  108. package/dist/core/NoticeChannel.js.map +1 -0
  109. package/dist/core/Owner.d.ts +0 -1
  110. package/dist/core/Owner.d.ts.map +1 -1
  111. package/dist/core/Owner.js +2 -5
  112. package/dist/core/Owner.js.map +1 -1
  113. package/dist/core/PacketBuilder.d.ts +25 -24
  114. package/dist/core/PacketBuilder.d.ts.map +1 -1
  115. package/dist/core/PacketBuilder.js +313 -375
  116. package/dist/core/PacketBuilder.js.map +1 -1
  117. package/dist/core/ProblemLog.d.ts +26 -0
  118. package/dist/core/ProblemLog.d.ts.map +1 -0
  119. package/dist/core/ProblemLog.js +58 -0
  120. package/dist/core/ProblemLog.js.map +1 -0
  121. package/dist/core/ProposalLifecycle.d.ts +15 -21
  122. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  123. package/dist/core/ProposalLifecycle.js +291 -78
  124. package/dist/core/ProposalLifecycle.js.map +1 -1
  125. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  126. package/dist/core/ProviderInstantiate.js +51 -48
  127. package/dist/core/ProviderInstantiate.js.map +1 -1
  128. package/dist/core/SchemeRegistry.d.ts +14 -8
  129. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  130. package/dist/core/SchemeRegistry.js +189 -114
  131. package/dist/core/SchemeRegistry.js.map +1 -1
  132. package/dist/core/ServiceTeardown.d.ts +11 -0
  133. package/dist/core/ServiceTeardown.d.ts.map +1 -0
  134. package/dist/core/ServiceTeardown.js +70 -0
  135. package/dist/core/ServiceTeardown.js.map +1 -0
  136. package/dist/core/StoredPacket.d.ts +29 -0
  137. package/dist/core/StoredPacket.d.ts.map +1 -0
  138. package/dist/core/StoredPacket.js +147 -0
  139. package/dist/core/StoredPacket.js.map +1 -0
  140. package/dist/core/StrikeRail.d.ts +1 -2
  141. package/dist/core/StrikeRail.d.ts.map +1 -1
  142. package/dist/core/StrikeRail.js +25 -55
  143. package/dist/core/StrikeRail.js.map +1 -1
  144. package/dist/core/WorkerControlAddress.d.ts +17 -0
  145. package/dist/core/WorkerControlAddress.d.ts.map +1 -0
  146. package/dist/core/WorkerControlAddress.js +44 -0
  147. package/dist/core/WorkerControlAddress.js.map +1 -0
  148. package/dist/core/WorkerName.d.ts +31 -0
  149. package/dist/core/WorkerName.d.ts.map +1 -0
  150. package/dist/core/WorkerName.js +95 -0
  151. package/dist/core/WorkerName.js.map +1 -0
  152. package/dist/core/WorkerName.sql +36 -0
  153. package/dist/core/WorkspaceGate.d.ts +14 -0
  154. package/dist/core/WorkspaceGate.d.ts.map +1 -0
  155. package/dist/core/WorkspaceGate.js +155 -0
  156. package/dist/core/WorkspaceGate.js.map +1 -0
  157. package/dist/core/caps/CapsResolve.d.ts +1 -1
  158. package/dist/core/caps/CapsResolve.d.ts.map +1 -1
  159. package/dist/core/caps/CapsResolve.js.map +1 -1
  160. package/dist/core/caps/DbChannelCaps.d.ts +5 -11
  161. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -1
  162. package/dist/core/caps/DbChannelCaps.js +24 -8
  163. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  164. package/dist/core/caps/DbEntryCaps.d.ts +4 -13
  165. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
  166. package/dist/core/caps/DbEntryCaps.js +15 -6
  167. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  168. package/dist/core/caps/DbNotifyCaps.d.ts +1 -1
  169. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -1
  170. package/dist/core/caps/DbNotifyCaps.js +12 -11
  171. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  172. package/dist/core/caps/DbProjectionCaps.d.ts +5 -5
  173. package/dist/core/caps/DbProjectionCaps.d.ts.map +1 -1
  174. package/dist/core/caps/DbProjectionCaps.js +21 -4
  175. package/dist/core/caps/DbProjectionCaps.js.map +1 -1
  176. package/dist/core/caps/DbSubscriptionCaps.d.ts +4 -4
  177. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
  178. package/dist/core/caps/DbSubscriptionCaps.js +57 -62
  179. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  180. package/dist/core/caps/DbTagCaps.d.ts +5 -12
  181. package/dist/core/caps/DbTagCaps.d.ts.map +1 -1
  182. package/dist/core/caps/DbTagCaps.js +14 -7
  183. package/dist/core/caps/DbTagCaps.js.map +1 -1
  184. package/dist/core/content-hash.js +1 -1
  185. package/dist/core/content-hash.js.map +1 -1
  186. package/dist/core/env-defaults.d.ts.map +1 -1
  187. package/dist/core/env-defaults.js.map +1 -1
  188. package/dist/core/fork.d.ts.map +1 -1
  189. package/dist/core/fork.js +55 -30
  190. package/dist/core/fork.js.map +1 -1
  191. package/dist/core/fork.sql +41 -27
  192. package/dist/core/git-env.d.ts +2 -0
  193. package/dist/core/git-env.d.ts.map +1 -1
  194. package/dist/core/git-env.js +13 -18
  195. package/dist/core/git-env.js.map +1 -1
  196. package/dist/core/git-iso.d.ts.map +1 -1
  197. package/dist/core/git-iso.js +9 -15
  198. package/dist/core/git-iso.js.map +1 -1
  199. package/dist/core/git-membership.d.ts +1 -1
  200. package/dist/core/git-membership.d.ts.map +1 -1
  201. package/dist/core/git-membership.js +198 -146
  202. package/dist/core/git-membership.js.map +1 -1
  203. package/dist/core/git-state.d.ts.map +1 -1
  204. package/dist/core/git-state.js +10 -11
  205. package/dist/core/git-state.js.map +1 -1
  206. package/dist/core/owner.sql +1 -1
  207. package/dist/core/packet-inject.d.ts.map +1 -1
  208. package/dist/core/packet-inject.js +7 -11
  209. package/dist/core/packet-inject.js.map +1 -1
  210. package/dist/core/packet-wire.d.ts +2 -20
  211. package/dist/core/packet-wire.d.ts.map +1 -1
  212. package/dist/core/packet-wire.js +242 -368
  213. package/dist/core/packet-wire.js.map +1 -1
  214. package/dist/core/plurnk-uri.d.ts +12 -1
  215. package/dist/core/plurnk-uri.d.ts.map +1 -1
  216. package/dist/core/plurnk-uri.js +49 -29
  217. package/dist/core/plurnk-uri.js.map +1 -1
  218. package/dist/core/results.d.ts +16 -5
  219. package/dist/core/results.d.ts.map +1 -1
  220. package/dist/core/results.js +27 -18
  221. package/dist/core/results.js.map +1 -1
  222. package/dist/core/ruler_count.d.ts +4 -0
  223. package/dist/core/ruler_count.d.ts.map +1 -0
  224. package/dist/core/ruler_count.js +7 -0
  225. package/dist/core/ruler_count.js.map +1 -0
  226. package/dist/core/scheme-types.d.ts +9 -11
  227. package/dist/core/scheme-types.d.ts.map +1 -1
  228. package/dist/core/scheme-types.js.map +1 -1
  229. package/dist/core/search-gate.d.ts.map +1 -1
  230. package/dist/core/search-gate.js +3 -11
  231. package/dist/core/search-gate.js.map +1 -1
  232. package/dist/core/teaching-corpus.d.ts +6 -0
  233. package/dist/core/teaching-corpus.d.ts.map +1 -0
  234. package/dist/core/teaching-corpus.js +23 -0
  235. package/dist/core/teaching-corpus.js.map +1 -0
  236. package/dist/core/teaching.d.ts +0 -1
  237. package/dist/core/teaching.d.ts.map +1 -1
  238. package/dist/core/teaching.js +2 -8
  239. package/dist/core/teaching.js.map +1 -1
  240. package/dist/core/token-ruler.d.ts.map +1 -1
  241. package/dist/core/token-ruler.js +3 -16
  242. package/dist/core/token-ruler.js.map +1 -1
  243. package/dist/core/turn-scheduler.d.ts +1 -1
  244. package/dist/core/turn-scheduler.d.ts.map +1 -1
  245. package/dist/core/turn-scheduler.js +1 -1
  246. package/dist/core/turn-scheduler.js.map +1 -1
  247. package/dist/core/worker-cap.d.ts +2 -4
  248. package/dist/core/worker-cap.d.ts.map +1 -1
  249. package/dist/core/worker-cap.js +13 -4
  250. package/dist/core/worker-cap.js.map +1 -1
  251. package/dist/core/{run-ops.sql → worker-ops.sql} +11 -11
  252. package/dist/core/workspace-settings.d.ts.map +1 -1
  253. package/dist/core/workspace-settings.js +17 -9
  254. package/dist/core/workspace-settings.js.map +1 -1
  255. package/dist/digest/Digest.d.ts +2 -1
  256. package/dist/digest/Digest.d.ts.map +1 -1
  257. package/dist/digest/Digest.js +337 -138
  258. package/dist/digest/Digest.js.map +1 -1
  259. package/dist/digest/digest.sql +19 -10
  260. package/dist/index.d.ts.map +1 -1
  261. package/dist/index.js +3 -5
  262. package/dist/index.js.map +1 -1
  263. package/dist/matrix/live-specimen.d.ts +9 -0
  264. package/dist/matrix/live-specimen.d.ts.map +1 -0
  265. package/dist/matrix/live-specimen.js +37 -0
  266. package/dist/matrix/live-specimen.js.map +1 -0
  267. package/dist/observe/api.d.ts +6 -0
  268. package/dist/observe/api.d.ts.map +1 -0
  269. package/dist/observe/api.js +11 -0
  270. package/dist/observe/api.js.map +1 -0
  271. package/dist/observe/init.d.ts +7 -0
  272. package/dist/observe/init.d.ts.map +1 -0
  273. package/dist/observe/init.js +159 -0
  274. package/dist/observe/init.js.map +1 -0
  275. package/dist/observe/metrics.d.ts +6 -0
  276. package/dist/observe/metrics.d.ts.map +1 -0
  277. package/dist/observe/metrics.js +15 -0
  278. package/dist/observe/metrics.js.map +1 -0
  279. package/dist/observe/spans.d.ts +4 -0
  280. package/dist/observe/spans.d.ts.map +1 -0
  281. package/dist/observe/spans.js +51 -0
  282. package/dist/observe/spans.js.map +1 -0
  283. package/dist/schemes/EffectPolicy.d.ts +1 -0
  284. package/dist/schemes/EffectPolicy.d.ts.map +1 -1
  285. package/dist/schemes/EffectPolicy.js +5 -2
  286. package/dist/schemes/EffectPolicy.js.map +1 -1
  287. package/dist/schemes/Exec.d.ts +6 -11
  288. package/dist/schemes/Exec.d.ts.map +1 -1
  289. package/dist/schemes/Exec.js +364 -247
  290. package/dist/schemes/Exec.js.map +1 -1
  291. package/dist/schemes/ExecOutputScheme.d.ts +8 -7
  292. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  293. package/dist/schemes/ExecOutputScheme.js +48 -9
  294. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  295. package/dist/schemes/File.d.ts +7 -11
  296. package/dist/schemes/File.d.ts.map +1 -1
  297. package/dist/schemes/File.js +279 -142
  298. package/dist/schemes/File.js.map +1 -1
  299. package/dist/schemes/Log.d.ts +4 -8
  300. package/dist/schemes/Log.d.ts.map +1 -1
  301. package/dist/schemes/Log.js +475 -208
  302. package/dist/schemes/Log.js.map +1 -1
  303. package/dist/schemes/Log.sql +62 -30
  304. package/dist/schemes/Prompt.d.ts +3 -2
  305. package/dist/schemes/Prompt.d.ts.map +1 -1
  306. package/dist/schemes/Prompt.js +9 -6
  307. package/dist/schemes/Prompt.js.map +1 -1
  308. package/dist/schemes/Skill.d.ts +1 -1
  309. package/dist/schemes/Skill.d.ts.map +1 -1
  310. package/dist/schemes/Skill.js +2 -3
  311. package/dist/schemes/Skill.js.map +1 -1
  312. package/dist/schemes/Worker.d.ts +8 -16
  313. package/dist/schemes/Worker.d.ts.map +1 -1
  314. package/dist/schemes/Worker.js +179 -69
  315. package/dist/schemes/Worker.js.map +1 -1
  316. package/dist/schemes/_entry-chunk.d.ts.map +1 -1
  317. package/dist/schemes/_entry-chunk.js +12 -5
  318. package/dist/schemes/_entry-chunk.js.map +1 -1
  319. package/dist/schemes/_entry-crud.d.ts +10 -9
  320. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  321. package/dist/schemes/_entry-crud.js +59 -13
  322. package/dist/schemes/_entry-crud.js.map +1 -1
  323. package/dist/schemes/_entry-crud.sql +20 -8
  324. package/dist/schemes/_entry-find.d.ts +20 -15
  325. package/dist/schemes/_entry-find.d.ts.map +1 -1
  326. package/dist/schemes/_entry-find.js +339 -174
  327. package/dist/schemes/_entry-find.js.map +1 -1
  328. package/dist/schemes/_entry-find.sql +20 -8
  329. package/dist/schemes/_entry-graph.d.ts +4 -2
  330. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  331. package/dist/schemes/_entry-graph.js +55 -31
  332. package/dist/schemes/_entry-graph.js.map +1 -1
  333. package/dist/schemes/_entry-graph.sql +37 -22
  334. package/dist/schemes/_entry-manifest.d.ts +10 -15
  335. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  336. package/dist/schemes/_entry-manifest.js +50 -268
  337. package/dist/schemes/_entry-manifest.js.map +1 -1
  338. package/dist/schemes/_entry-ops.d.ts +9 -14
  339. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  340. package/dist/schemes/_entry-ops.js +149 -77
  341. package/dist/schemes/_entry-ops.js.map +1 -1
  342. package/dist/schemes/_entry-ops.sql +1 -1
  343. package/dist/schemes/_entry-semantic.d.ts +23 -8
  344. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  345. package/dist/schemes/_entry-semantic.js +141 -60
  346. package/dist/schemes/_entry-semantic.js.map +1 -1
  347. package/dist/schemes/_entry-semantic.sql +63 -45
  348. package/dist/schemes/_entry-send.d.ts +3 -4
  349. package/dist/schemes/_entry-send.d.ts.map +1 -1
  350. package/dist/schemes/_entry-send.js +48 -23
  351. package/dist/schemes/_entry-send.js.map +1 -1
  352. package/dist/schemes/_path-scope.d.ts +23 -0
  353. package/dist/schemes/_path-scope.d.ts.map +1 -0
  354. package/dist/schemes/_path-scope.js +61 -0
  355. package/dist/schemes/_path-scope.js.map +1 -0
  356. package/dist/schemes/_search-candidate.d.ts +20 -0
  357. package/dist/schemes/_search-candidate.d.ts.map +1 -0
  358. package/dist/schemes/_search-candidate.js +19 -0
  359. package/dist/schemes/_search-candidate.js.map +1 -0
  360. package/dist/schemes/_search-exclusion.d.ts +7 -0
  361. package/dist/schemes/_search-exclusion.d.ts.map +1 -0
  362. package/dist/schemes/_search-exclusion.js +23 -0
  363. package/dist/schemes/_search-exclusion.js.map +1 -0
  364. package/dist/schemes/_search-index.d.ts +6 -0
  365. package/dist/schemes/_search-index.d.ts.map +1 -0
  366. package/dist/schemes/_search-index.js +326 -0
  367. package/dist/schemes/_search-index.js.map +1 -0
  368. package/dist/schemes/cosine.js +1 -1
  369. package/dist/schemes/cosine.js.map +1 -1
  370. package/dist/schemes/exec-abort.js +3 -3
  371. package/dist/schemes/exec-abort.js.map +1 -1
  372. package/dist/schemes/exec-env.js +3 -3
  373. package/dist/schemes/exec-env.js.map +1 -1
  374. package/dist/server/BranchBatches.d.ts +48 -0
  375. package/dist/server/BranchBatches.d.ts.map +1 -0
  376. package/dist/server/BranchBatches.js +591 -0
  377. package/dist/server/BranchBatches.js.map +1 -0
  378. package/dist/server/Daemon.d.ts +36 -66
  379. package/dist/server/Daemon.d.ts.map +1 -1
  380. package/dist/server/Daemon.js +920 -446
  381. package/dist/server/Daemon.js.map +1 -1
  382. package/dist/server/DaemonModule.d.ts +31 -0
  383. package/dist/server/DaemonModule.d.ts.map +1 -0
  384. package/dist/server/DaemonModule.js +2 -0
  385. package/dist/server/DaemonModule.js.map +1 -0
  386. package/dist/server/branch-batch.sql +152 -0
  387. package/dist/server/client-input.d.ts +12 -0
  388. package/dist/server/client-input.d.ts.map +1 -1
  389. package/dist/server/client-input.js +262 -57
  390. package/dist/server/client-input.js.map +1 -1
  391. package/dist/server/dispatch-as-plurnk.d.ts +1 -1
  392. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
  393. package/dist/server/dispatch-as-plurnk.js +15 -7
  394. package/dist/server/dispatch-as-plurnk.js.map +1 -1
  395. package/dist/server/drain.sql +92 -62
  396. package/dist/server/envelope.d.ts +2 -5
  397. package/dist/server/envelope.d.ts.map +1 -1
  398. package/dist/server/envelope.js +119 -95
  399. package/dist/server/envelope.js.map +1 -1
  400. package/dist/server/envelope.sql +19 -23
  401. package/dist/server/exec-poll-backoff.js +1 -1
  402. package/dist/server/exec-poll-backoff.js.map +1 -1
  403. package/dist/server/lifecycle-recovery.sql +79 -2
  404. package/dist/server/logEntry.d.ts +3 -1
  405. package/dist/server/logEntry.d.ts.map +1 -1
  406. package/dist/server/logEntry.js +4 -2
  407. package/dist/server/logEntry.js.map +1 -1
  408. package/dist/server/logEntry.sql +1 -1
  409. package/dist/server/loop-model.d.ts.map +1 -1
  410. package/dist/server/loop-model.js +22 -9
  411. package/dist/server/loop-model.js.map +1 -1
  412. package/dist/server/loopDocs.d.ts.map +1 -1
  413. package/dist/server/loopDocs.js +5 -4
  414. package/dist/server/loopDocs.js.map +1 -1
  415. package/dist/server/seam-entry-read.sql +15 -10
  416. package/dist/server/seam-log-read.sql +2 -2
  417. package/dist/server/seam-loop.sql +1 -1
  418. package/dist/server/seam-proposal-list.sql +17 -7
  419. package/dist/service.d.ts.map +1 -1
  420. package/dist/service.js +66 -60
  421. package/dist/service.js.map +1 -1
  422. package/migrations/001_schema.sql +897 -0
  423. package/package.json +57 -26
  424. package/dist/core/PluginLoader.d.ts +0 -20
  425. package/dist/core/PluginLoader.d.ts.map +0 -1
  426. package/dist/core/PluginLoader.js +0 -142
  427. package/dist/core/PluginLoader.js.map +0 -1
  428. package/dist/core/TelemetryChannel.d.ts +0 -37
  429. package/dist/core/TelemetryChannel.d.ts.map +0 -1
  430. package/dist/core/TelemetryChannel.js +0 -80
  431. package/dist/core/TelemetryChannel.js.map +0 -1
  432. package/dist/core/path-decode.d.ts +0 -3
  433. package/dist/core/path-decode.d.ts.map +0 -1
  434. package/dist/core/path-decode.js +0 -9
  435. package/dist/core/path-decode.js.map +0 -1
  436. package/dist/core/plugin-attribution.d.ts +0 -5
  437. package/dist/core/plugin-attribution.d.ts.map +0 -1
  438. package/dist/core/plugin-attribution.js +0 -39
  439. package/dist/core/plugin-attribution.js.map +0 -1
  440. package/dist/core/world-state.d.ts +0 -10
  441. package/dist/core/world-state.d.ts.map +0 -1
  442. package/dist/core/world-state.js +0 -35
  443. package/dist/core/world-state.js.map +0 -1
  444. package/dist/core/world-state.sql +0 -35
  445. package/dist/core/zero-pin.d.ts +0 -3
  446. package/dist/core/zero-pin.d.ts.map +0 -1
  447. package/dist/core/zero-pin.js +0 -16
  448. package/dist/core/zero-pin.js.map +0 -1
  449. package/dist/server/auto.d.ts +0 -6
  450. package/dist/server/auto.d.ts.map +0 -1
  451. package/dist/server/auto.js +0 -63
  452. package/dist/server/auto.js.map +0 -1
  453. package/dist/server/clientTurn.d.ts +0 -6
  454. package/dist/server/clientTurn.d.ts.map +0 -1
  455. package/dist/server/clientTurn.js +0 -22
  456. package/dist/server/clientTurn.js.map +0 -1
  457. package/dist/server/clientTurn.sql +0 -10
  458. package/dist/server/noProposals.d.ts +0 -6
  459. package/dist/server/noProposals.d.ts.map +0 -1
  460. package/dist/server/noProposals.js +0 -37
  461. package/dist/server/noProposals.js.map +0 -1
  462. package/dist/server/version-info.d.ts +0 -14
  463. package/dist/server/version-info.d.ts.map +0 -1
  464. package/dist/server/version-info.js +0 -69
  465. package/dist/server/version-info.js.map +0 -1
  466. package/migrations/0000-00-00.01_schema.sql +0 -536
  467. package/migrations/0004_loop-provider.sql +0 -3
  468. package/migrations/0005_subscription-published-channel.sql +0 -3
  469. package/migrations/0006_loop-max-turns.sql +0 -3
@@ -1,6 +1,5 @@
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";
6
5
  import { execPollBackoffMs } from "./exec-poll-backoff.js";
@@ -10,37 +9,62 @@ import Engine from "../core/Engine.js";
10
9
  import ExecutorRegistry from "../core/ExecutorRegistry.js";
11
10
  import SchemeRegistry from "../core/SchemeRegistry.js";
12
11
  import { Mimetypes } from "@plurnk/plurnk-mimetypes";
12
+ import { parsePath, Validator, } from "@plurnk/plurnk-contracts";
13
13
  import LogEntry from "./logEntry.js";
14
14
  import Envelope from "./envelope.js";
15
15
  import ClientInput from "./client-input.js";
16
- import ClientTurn from "./clientTurn.js";
16
+ import JournalTurn from "../core/JournalTurn.js";
17
17
  import LoopDocs from "./loopDocs.js";
18
18
  import GitMembership from "../core/git-membership.js";
19
19
  import Fork from "../core/fork.js";
20
+ import WorkerName from "../core/WorkerName.js";
20
21
  import LoopLifecycle from "../core/LoopLifecycle.js";
21
22
  import { promptLoopPrefix } from "../core/plurnk-uri.js";
22
23
  import { rulerCount } from "../core/token-ruler.js";
23
24
  import { parseAliasesFromEnv, resolveActiveAlias } from "@plurnk/plurnk-providers";
24
25
  import ProviderInstantiate from "../core/ProviderInstantiate.js";
25
26
  import { resolveLoopAlias } from "./loop-model.js";
26
- import Auto from "./auto.js";
27
- import NoProposals from "./noProposals.js";
28
27
  import { DEFAULT_LOOP_FLAGS } from "../core/scheme-types.js";
28
+ import LoopFlagsReader from "../core/LoopFlagsReader.js";
29
+ import Results, { OperationFailureError } from "../core/results.js";
30
+ import WorkspaceGate from "../core/WorkspaceGate.js";
31
+ import BranchBatches from "./BranchBatches.js";
32
+ import ErrorDetail from "../core/ErrorDetail.js";
33
+ import { observed, observedSync } from "../observe/spans.js";
34
+ import { LOOP_TERMINALS, recordCounter } from "../observe/metrics.js";
35
+ const clientActionFailure = (error) => {
36
+ if (error instanceof OperationFailureError)
37
+ return error.result;
38
+ console.error("Client action failed outside its operation result contract:", error);
39
+ return Results.failure("daemon:client", "action-threw", 500, "The client action failed outside its operation result contract.", {}, {
40
+ stage: "client-action",
41
+ retryable: false,
42
+ });
43
+ };
44
+ const daemonFailure = (owner, code, status, detail, extensions = {}) => new OperationFailureError(Results.failure(owner, code, status, detail, {}, extensions));
45
+ const entryReadResult = (result) => Validator.assertEntryReadResult(result);
29
46
  export default class Daemon {
30
47
  #db;
31
48
  #engine;
49
+ #workspaceGate;
50
+ #branchBatches;
32
51
  #lifecycle;
33
52
  #schemes;
34
53
  #mimetypes;
54
+ #ownsMimetypes;
35
55
  #provider;
36
56
  #nodeModulesPath;
37
57
  #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.
58
+ #started = false; // {§module-lifecycle}: one discovery/module boot; no listener
59
+ #capabilitiesPublished = false;
60
+ #modules = [];
61
+ #moduleClosers = [];
62
+ #moduleActions = new Map();
63
+ // {§methods-event-subscribe} — the broadcast's in-process event source. A transport
64
+ // module (plurnk-agui) subscribes and fans out to its OWN clients; core emits, never owns
65
+ // client transport or connection state.
42
66
  #eventSubscribers = new Set();
43
- // Run-level drain registry. At most one drain per worker. The stored object
67
+ // Worker-level drain registry. At most one drain per worker. The stored object
44
68
  // is the drain's identity handle: start/exit compare it by reference so a
45
69
  // drain exiting never clobbers a successor that raced in, and a loop
46
70
  // enqueued during teardown is never stranded. A drain is a pure queue
@@ -48,23 +72,23 @@ export default class Daemon {
48
72
  // (subscriptions + Exec.idle), and a concluding stream routes through
49
73
  // inject() like any other loop source.
50
74
  #activeDrains = new Map();
51
- // Per-run cancellation scope. Loops AND the streams they spawn (execs)
75
+ // Per-worker cancellation scope. Loops AND the streams they spawn (execs)
52
76
  // share this signal, so loop.cancel / shutdown abort it once and every
53
77
  // in-flight subscription tears down — even a spawn that registers AFTER the
54
78
  // cancel self-aborts against the already-aborted signal (no race). Outlives
55
79
  // any single (ephemeral) drain; replaced with a fresh controller once
56
- // aborted so a later loop.run isn't born cancelled.
80
+ // aborted so a later runLoop request isn't born cancelled.
57
81
  #workerAborts = new Map();
58
82
  // 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
83
+ // a park with a polled stream, a timer fires every P seconds to resume it ({§exec-poll}). One
60
84
  // per worker (the tightest cadence); cleared/replaced on each park and on cancel.
61
85
  #parkTimers = new Map();
62
86
  #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).
87
+ #pollBackoff = new Map(); // {§exec-poll} — backoff step per worker
88
+ // Per-worker drain-transition lock — see #withDrainLock (R4 / {§worker-lifecycle-single-drain}).
65
89
  #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
90
+ // {§worker-lifecycle-child-wake} — workers owed a wake: a child/stream conclusion fired while the worker was
91
+ // mid-turn (not yet slept), so #wakeParkedWorker could not resume it. A child-worker conclusion is a
68
92
  // BOUNDED, lossless wake (a worker always concludes), so a hibernation awaiting one MUST return —
69
93
  // never deadlock. The drain honors the owed wake at the worker's next park, closing the conclude-
70
94
  // before-park race. (Only a live exec stream, unbounded absent a timeout, may hold a park open.)
@@ -81,7 +105,10 @@ export default class Daemon {
81
105
  this.#nodeModulesPath = nodeModulesPath ?? resolve(process.cwd(), "node_modules");
82
106
  this.#discoveryCwd = dirname(this.#nodeModulesPath);
83
107
  // Mimetypes owns discovery + detection; default mimetype text/markdown. (Token counting
84
- // is NOT wired here — the engine's ruler below is §tokenomics-agnostic-ruler.)
108
+ // is NOT wired here — the engine's ruler below is {§tokenomics-agnostic-ruler}.)
109
+ // Constructor ownership is the lifecycle boundary
110
+ // ({§mimetype-owned-lifecycle}).
111
+ this.#ownsMimetypes = mimetypes === undefined;
85
112
  this.#mimetypes = mimetypes ?? new Mimetypes({
86
113
  defaultMimetype: "text/markdown",
87
114
  discoverOptions: { cwd: this.#discoveryCwd },
@@ -90,9 +117,60 @@ export default class Daemon {
90
117
  if (this.#provider !== null && bootSpec !== null) {
91
118
  ProviderInstantiate.registerInstance(this.#provider, bootSpec);
92
119
  }
120
+ this.#workspaceGate = new WorkspaceGate(async (workerId, rootWorkerId) => {
121
+ const row = await this.#db.branch_batch_worker_lineage.get({
122
+ worker_id: workerId,
123
+ root_worker_id: rootWorkerId,
124
+ });
125
+ return row !== undefined;
126
+ });
127
+ this.#branchBatches = new BranchBatches(db, this.#workspaceGate, {
128
+ settleWorkspace: async (workspaceId) => this.#engine.drainWorkspaceDerivations(workspaceId),
129
+ createChild: async ({ workspaceId, parentWorkerId, op, name, prompt, flags, origin }) => {
130
+ const providerSpec = resolveActiveAlias();
131
+ if (providerSpec === null)
132
+ throw new Error("Branch worker: active provider has no resolvable alias");
133
+ const workerName = WorkerName.assert(name);
134
+ const workerId = op === "FORK"
135
+ ? await Fork.fork(this.#db, parentWorkerId, workerName)
136
+ : (await this.#db.fork_insert_worker.get({
137
+ workspace_id: workspaceId,
138
+ name: workerName,
139
+ parent_worker_id: parentWorkerId,
140
+ origin,
141
+ }))?.id;
142
+ if (workerId === undefined)
143
+ throw new Error("Branch worker insert returned no row");
144
+ const loopId = await this.#enqueueFreshLoop({
145
+ workerId,
146
+ prompt,
147
+ providerSpec,
148
+ flags,
149
+ });
150
+ return { workerId, loopId };
151
+ },
152
+ startChild: async (workspaceId, workerId, loopId) => {
153
+ const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
154
+ const started = await this.#ensureDrain({ workspaceId, workerId, systemPrompt });
155
+ if (started === null)
156
+ throw new Error(`Branch worker ${workerId} already has a live drain`);
157
+ const result = await started.firstLoopPromise;
158
+ if (result.loopId !== loopId) {
159
+ throw new Error(`Branch worker ${workerId} drained loop ${result.loopId}, expected ${loopId}`);
160
+ }
161
+ return result.result;
162
+ },
163
+ wakeParent: async (workspaceId, workerId) => {
164
+ const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
165
+ await this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
166
+ },
167
+ notify: (workspaceId, payload) => {
168
+ this.#broadcast({ workspaceId }, "workspace/branch-batch", payload);
169
+ },
170
+ });
93
171
  this.#engine = new Engine({
94
172
  db, schemes: this.#schemes, mimetypes: this.#mimetypes,
95
- // §tokenomics-agnostic-ruler — the ONE model-facing token ruler (chars/2), NOT the
173
+ // {§tokenomics-agnostic-ruler} — the ONE model-facing token ruler (chars/2), NOT the
96
174
  // boot provider: token accounting is workspace-wide across many concurrent models, so
97
175
  // the write-time + catalog counts must be model-independent. Exact per-model counting
98
176
  // lives only at the packet-materialization fit-gate.
@@ -103,7 +181,7 @@ export default class Daemon {
103
181
  // Daemon.inject (active sister → fold; idle → enqueue + drain). The
104
182
  // daemon owns provider + the law-file system prompt; the worker scheme
105
183
  // handler carries neither. Fire-and-forget: the returned drain runs
106
- // independently (the sister is its own worker). §machine-processes
184
+ // independently (the sister is its own worker). {§machine-processes}
107
185
  injectWorker: async ({ workspaceId, workerId, prompt, flags }) => {
108
186
  if (this.#provider === null)
109
187
  throw new Error("injectWorker: no provider configured");
@@ -114,39 +192,24 @@ export default class Daemon {
114
192
  const { action, loopId } = await this.inject({ workspaceId, workerId, prompt, providerSpec, systemPrompt, ...(flags === undefined ? {} : { flags }) });
115
193
  return { action, loopId };
116
194
  },
195
+ branchWorker: async (args) => this.#branchBatches.enqueue(args),
196
+ branchCompletionGate: async (workerId) => this.#branchBatches.completionGate(workerId),
197
+ acquireWorkspaceTurn: async (workspaceId, workerId) => this.#workspaceGate.acquireTurn(workspaceId, workerId),
198
+ workspaceTurnCompleted: async ({ turnId }) => this.#branchBatches.sealTurn(turnId),
117
199
  // worker:// KILL (terminate) — cancel the addressed worker subtree and
118
200
  // tear down its held streams before the operation completes.
119
201
  cancelWorker: async (workerId, reason) => this.#cancelWorkerTree(workerId, reason),
120
202
  cancelDescendants: async (workerId, reason) => this.#cancelTree(workerId, reason, false),
121
- telemetryEventNotify: (workspaceId, payload) => this.notifyTelemetryEvent(workspaceId, payload),
203
+ noticeNotify: (workspaceId, payload) => this.notifyNotice(workspaceId, payload),
122
204
  });
123
205
  // Wire proposal-pending events to the loop/proposal WS notification.
124
206
  // Sessionid scopes the broadcast to clients on the same workspace.
125
207
  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
- });
208
+ const { workspaceId, ...proposal } = event;
209
+ this.#broadcast({ workspaceId }, "loop/proposal", proposal);
141
210
  });
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
211
  }
149
- // The client-interface seam (#355). A transport module subscribes to the daemon's in-process
212
+ // {§methods-event-subscribe}. A transport module subscribes to the daemon's in-process
150
213
  // event source: it receives every workspace-scoped engine event as `(workspaceId, method, params)`
151
214
  // and fans out to its OWN clients — core emits, it never fans out for the module. Returns an
152
215
  // unsubscribe. `workspaceId` is the event's workspace, or null for a global event (e.g. workspace/created).
@@ -155,55 +218,90 @@ export default class Daemon {
155
218
  this.#eventSubscribers.add(handler);
156
219
  return () => { this.#eventSubscribers.delete(handler); };
157
220
  }
158
- // The client-interface seam (#355) — proposal HITL. A transport module reads the stopped-world
221
+ // {§methods-proposal-resolve} — proposal HITL. A transport module reads the stopped-world
159
222
  // proposals for a workspace (rendering each as a TOOL_CALL) and feeds back the human's decision. The
160
223
  // gate, validation, and applyResolution stay core (Engine.resolveProposal); the seam is the read +
161
224
  // the resolve, never the mechanism. `resolveProposal` throws for an unknown/already-resolved id.
162
225
  async pendingProposals(workspaceId) {
163
- return this.#db.proposal_list_pending.all({ workspace_id: workspaceId });
226
+ const checkedWorkspaceId = ClientInput.assertId("pendingProposals", "workspaceId", workspaceId);
227
+ return this.#engine.pendingProposals(checkedWorkspaceId);
164
228
  }
165
229
  resolveProposal(logEntryId, resolution) {
166
- this.#engine.resolveProposal(logEntryId, resolution);
230
+ const checkedLogEntryId = ClientInput.assertId("resolveProposal", "logEntryId", logEntryId);
231
+ const checkedResolution = ClientInput.assertProposalResolution("resolveProposal", resolution);
232
+ this.#engine.resolveProposal(checkedLogEntryId, checkedResolution);
167
233
  }
168
- // The client-interface seam (#355) — drive/steer a loop. The module supplies only workspace/run/prompt;
234
+ // {§methods-loop-run} — drive/steer a loop. The module supplies only workspace/worker/prompt;
169
235
  // the provider and the law-file system prompt are core's and stay inside. Returns immediately — the
170
236
  // loop runs async and its outcome arrives on the event source (loop/terminated). `cancelDrain` (public)
171
237
  // is the cancel hook. Both funnel through the unified `inject`, which owns the drain lifecycle.
172
238
  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
239
+ const workspaceId = ClientInput.assertId("runLoop", "workspaceId", args.workspaceId);
240
+ const workerId = ClientInput.assertId("runLoop", "workerId", args.workerId);
241
+ const prompt = ClientInput.assertPrompt("runLoop", args.prompt);
242
+ const requestedMaxTurns = ClientInput.assertMaxTurns("runLoop", args.maxTurns);
243
+ const openPaths = ClientInput.assertOpenPaths("runLoop", args.openPaths);
244
+ const alias = ClientInput.assertOptionalSelector("runLoop", "alias", args.alias);
245
+ const model = ClientInput.assertOptionalSelector("runLoop", "model", args.model);
246
+ const flags = ClientInput.normalizeLoopFlags("runLoop", args.flags);
247
+ // {§methods-loop-run-model} — a client sends alias/model on every loop, so a
248
+ // switch takes effect turn-to-turn. `model` (client-resolved <provider>/<model>) wins
176
249
  // over `alias`; neither → the boot default. Instantiation is cached, so ping-ponging
177
250
  // 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");
251
+ const selection = await this.#resolveLoopProvider(alias, model);
252
+ if (selection === null) {
253
+ throw new OperationFailureError(Results.failure("daemon:provider", "not-configured", 501, "No provider is configured for this loop.", {}, {
254
+ stage: "provider-selection",
255
+ recovery: "Select a configured model provider.",
256
+ retryable: false,
257
+ }));
258
+ }
181
259
  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
260
+ // {§machine-processes} — the model NEVER runs in a client-origin worker (its packets would carry
261
+ // client-action rows). The module resolves the model worker via ensureModelWorker and passes it (or a
184
262
  // 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
263
+ const target = await this.#db.envelope_get_worker_by_id.get({ id: workerId });
264
+ if (target === undefined) {
265
+ throw daemonFailure("daemon:worker", "worker-not-found", 404, `Worker ${workerId} does not exist.`, { workerId });
266
+ }
267
+ if (target.workspace_id !== workspaceId) {
268
+ throw daemonFailure("daemon:worker", "workspace-mismatch", 409, `Worker ${workerId} does not belong to workspace ${workspaceId}.`, {
269
+ workerId,
270
+ workspaceId,
271
+ actualWorkspaceId: target.workspace_id,
272
+ retryable: false,
273
+ });
274
+ }
275
+ if (target.origin === "client") {
276
+ throw daemonFailure("daemon:worker", "model-worker-required", 409, `Worker ${workerId} is not a model worker.`, {
277
+ workerId,
278
+ recovery: "Select or create a model worker for this loop.",
279
+ retryable: false,
280
+ });
281
+ }
282
+ // {§operator-config-max-turns-ceiling} — the operator ceiling clamps a per-call maxTurns; a
191
283
  // seam caller must not bypass operator policy (inject only DEFAULTS from env, never clamps).
192
284
  const ceiling = Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "-1");
193
- const requested = args.maxTurns ?? ceiling;
285
+ const requested = requestedMaxTurns ?? ceiling;
194
286
  const maxTurns = ceiling < 0 ? requested : (requested < 0 ? ceiling : Math.min(requested, ceiling));
195
- const { flags: _inputFlags, ...rest } = args;
287
+ const turnCeiling = {
288
+ effective: maxTurns,
289
+ source: requestedMaxTurns === undefined ? "implicit" : "explicit",
290
+ };
196
291
  const { action, loopId, turnSeq } = await this.inject({
197
- ...rest,
292
+ workspaceId,
293
+ workerId,
294
+ prompt,
198
295
  ...(flags !== undefined ? { flags } : {}),
199
- maxTurns,
296
+ ...(openPaths !== undefined ? { openPaths } : {}),
297
+ turnCeiling,
200
298
  providerSpec: selection,
201
299
  systemPrompt,
202
300
  });
203
- return { action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
301
+ return { status: 100, action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
204
302
  }
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
303
+ // {§methods-loop-run-model} — resolve a per-loop model override to a cached Provider. `model`
304
+ // (<provider>/<model>, client-resolved) wins over a named `alias`; absent both, the
207
305
  // boot default. A named alias missing from the env cascade, or a malformed model spec, throws
208
306
  // legibly rather than silently running the wrong model.
209
307
  async #resolveLoopProvider(alias, model) {
@@ -211,12 +309,27 @@ export default class Daemon {
211
309
  if (requested === null && this.#provider === null)
212
310
  return null;
213
311
  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
312
+ if (spec === null) {
313
+ throw daemonFailure("daemon:provider", "active-alias-unresolved", 500, "The active provider has no resolvable alias.", { stage: "provider-selection", retryable: false });
314
+ }
315
+ // Resolve eagerly so runLoop fails before enqueue when the provider
217
316
  // cannot be constructed. The drain later retrieves this cached handle
218
317
  // from the loop's durable spec at the claim boundary.
219
- await ProviderInstantiate.instantiateProvider(spec);
318
+ try {
319
+ await ProviderInstantiate.instantiateProvider(spec);
320
+ }
321
+ catch (cause) {
322
+ if (cause instanceof OperationFailureError)
323
+ throw cause;
324
+ console.error(`Provider alias '${spec.alias}' could not be instantiated:`, cause);
325
+ throw daemonFailure("daemon:provider", "provider-unavailable", 503, `Provider alias '${spec.alias}' is unavailable.`, {
326
+ alias: spec.alias,
327
+ provider: spec.provider,
328
+ model: spec.model,
329
+ stage: "provider-selection",
330
+ retryable: false,
331
+ });
332
+ }
220
333
  return spec;
221
334
  }
222
335
  async #providerSpecForLoop(loopId) {
@@ -245,9 +358,16 @@ export default class Daemon {
245
358
  async #assertLoopProvider(loopId, requested) {
246
359
  const selected = await this.#providerSpecForLoop(loopId);
247
360
  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.");
361
+ throw daemonFailure("daemon:provider", "loop-provider-conflict", 409, `Loop ${loopId} uses provider alias '${selected.alias}', not '${requested.alias}'.`, {
362
+ loopId,
363
+ selectedAlias: selected.alias,
364
+ selectedModel: `${selected.provider}/${selected.model}`,
365
+ requestedAlias: requested.alias,
366
+ requestedModel: `${requested.provider}/${requested.model}`,
367
+ stage: "loop-injection",
368
+ recovery: "Cancel or conclude the loop before selecting another provider.",
369
+ retryable: false,
370
+ });
251
371
  }
252
372
  }
253
373
  async #assertLoopMaxTurns(loopId, requested) {
@@ -257,29 +377,38 @@ export default class Daemon {
257
377
  if (durable === undefined)
258
378
  throw new Error(`inject: loop ${loopId} has no durable turn ceiling`);
259
379
  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`);
380
+ throw daemonFailure("daemon:loop", "turn-ceiling-conflict", 409, `Loop ${loopId} has turn ceiling ${durable.max_turns}, not ${requested}.`, {
381
+ loopId,
382
+ selectedMaximumTurns: durable.max_turns,
383
+ requestedMaximumTurns: requested,
384
+ stage: "loop-injection",
385
+ recovery: "Cancel or conclude the loop before selecting another turn ceiling.",
386
+ retryable: false,
387
+ });
261
388
  }
262
389
  }
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.
390
+ // {§methods-model-worker} — the workspace's model worker (created on first use), distinct from the client
391
+ // worker so the model's packets never carry client-action rows. The module binds its threads to this.
265
392
  ensureModelWorker(workspaceId) {
266
- return Envelope.ensureModelWorker(this.#db, workspaceId);
393
+ return Envelope.ensureModelWorker(this.#db, ClientInput.assertId("worker.ensure-model", "workspaceId", workspaceId));
267
394
  }
268
- // The op-dispatch hook (#355) — execute one parsed op on behalf of a client: journaled as a
395
+ // {§methods-op-mirror} — execute parsed ops on behalf of a client, journaled as a
269
396
  // client-origin turn (the log is core's, a client op is a first-class citizen), dispatched through
270
397
  // the engine, then emitted as log/entry on the event source. One seam op backs the whole op_*
271
398
  // family (read/edit/copy/find/fold/look/move/open/send/exec); the module parses at its edge with the
272
399
  // grammar package and hands over the statement, then fans the emitted entry out to its own clients.
273
400
  async dispatchAsClient(args) {
274
- const { workspaceId, workerId, statement } = args;
401
+ const workspaceId = ClientInput.assertId("operation.dispatch", "workspaceId", args.workspaceId);
402
+ const workerId = ClientInput.assertId("operation.dispatch", "workerId", args.workerId);
403
+ const { statement } = args;
275
404
  const clientLoopId = await Envelope.ensureClientLoop(this.#db, workerId);
276
405
  try {
277
406
  const result = await this.#dispatchClientStatement({ workspaceId, workerId, loopId: clientLoopId, statement });
278
- await Envelope.closeClientLoop(this.#db, clientLoopId, 200);
407
+ await Envelope.closeClientLoop(this.#db, clientLoopId, { status: 200 });
279
408
  return result;
280
409
  }
281
410
  catch (error) {
282
- await Envelope.closeClientLoop(this.#db, clientLoopId, 499);
411
+ await Envelope.closeClientLoop(this.#db, clientLoopId, clientActionFailure(error));
283
412
  throw error;
284
413
  }
285
414
  }
@@ -288,7 +417,9 @@ export default class Daemon {
288
417
  // keep this promise (and segment) open across interrupt/resume; settlement closes
289
418
  // it. The journal is durable evidence for the action, not a second client lifecycle.
290
419
  async dispatchClientAction(args) {
291
- const { workspaceId, workerId, statements } = args;
420
+ const workspaceId = ClientInput.assertId("operation.dispatch-batch", "workspaceId", args.workspaceId);
421
+ const workerId = ClientInput.assertId("operation.dispatch-batch", "workerId", args.workerId);
422
+ const { statements } = args;
292
423
  if (statements.length === 0)
293
424
  return [];
294
425
  const clientLoopId = await Envelope.ensureClientLoop(this.#db, workerId);
@@ -297,58 +428,107 @@ export default class Daemon {
297
428
  for (const statement of statements) {
298
429
  results.push(await this.#dispatchClientStatement({ workspaceId, workerId, loopId: clientLoopId, statement }));
299
430
  }
300
- await Envelope.closeClientLoop(this.#db, clientLoopId, 200);
431
+ await Envelope.closeClientLoop(this.#db, clientLoopId, { status: 200 });
301
432
  return results;
302
433
  }
303
434
  catch (error) {
304
- await Envelope.closeClientLoop(this.#db, clientLoopId, 499);
435
+ await Envelope.closeClientLoop(this.#db, clientLoopId, clientActionFailure(error));
305
436
  throw error;
306
437
  }
307
438
  }
308
439
  async #dispatchClientStatement(args) {
309
440
  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 });
441
+ const release = await this.#workspaceGate.acquireTurn(workspaceId, workerId);
442
+ try {
443
+ const { id: turnId } = await JournalTurn.insert(this.#db, loopId);
444
+ const entryIds = [];
445
+ const result = await this.#engine.dispatch({
446
+ statement, workspaceId, workerId, loopId, turnId, sequence: 1,
447
+ origin: "client", onDispatch: (logEntryId) => { entryIds.push(logEntryId); },
448
+ });
449
+ await this.#branchBatches.sealTurn(turnId);
450
+ for (const logEntryId of entryIds) {
451
+ const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
452
+ this.#broadcast({ workspaceId }, "log/entry", { entry });
453
+ }
454
+ return result;
455
+ }
456
+ finally {
457
+ release();
319
458
  }
320
- return result;
321
459
  }
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
460
+ // {§op-look} — the pure READ-projection query on the seam: resolve a READ through the
461
+ // full scheme resolver and return its content, writing NO log row — the client's out-of-band
324
462
  // inspection primitive (the module rewrites LOOK→READ and parses at its edge, exactly like
325
463
  // dispatchClientAction). Its closed observation segment supplies the numeric loop coordinate
326
464
  // required by plugin context and relative log:/// addresses without impersonating an active
327
465
  // client lifecycle. It creates no turn or log row. Engine.look enforces READ-only.
328
466
  async look(args) {
329
- const { workspaceId, workerId, statement } = args;
467
+ const workspaceId = ClientInput.assertId("operation.look", "workspaceId", args.workspaceId);
468
+ const workerId = ClientInput.assertId("operation.look", "workerId", args.workerId);
469
+ const { statement } = args;
470
+ const release = await this.#workspaceGate.acquireTurn(workspaceId, workerId);
330
471
  const clientLoopId = await Envelope.ensureClientLoop(this.#db, workerId);
331
472
  try {
332
473
  const result = await this.#engine.look({ statement, workspaceId, workerId, loopId: clientLoopId });
333
- await Envelope.closeClientLoop(this.#db, clientLoopId, 200);
474
+ await Envelope.closeClientLoop(this.#db, clientLoopId, { status: 200 });
334
475
  return result;
335
476
  }
336
477
  catch (error) {
337
- await Envelope.closeClientLoop(this.#db, clientLoopId, 499);
478
+ await Envelope.closeClientLoop(this.#db, clientLoopId, clientActionFailure(error));
338
479
  throw error;
339
480
  }
481
+ finally {
482
+ release();
483
+ }
340
484
  }
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
485
+ // {§methods-log-read} — a workspace's journal, the module's primary render input. The worker is
486
+ // ownership-verified against the workspace (a workspace reads only its own workers — the model worker included,
487
+ // {§methods-log-coordinate}); entries filter by loop/turn/since-id or the full L/T/S display coordinate. Core owns the
344
488
  // journal + the invariant; the module shapes the entries into AG-UI messages at its edge.
345
489
  async readLog(args) {
346
- const { workspaceId, workerId } = args;
490
+ const workspaceId = ClientInput.assertId("log.read", "workspaceId", args.workspaceId);
491
+ const workerId = ClientInput.assertId("log.read", "workerId", args.workerId);
347
492
  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})`);
493
+ if (target === undefined) {
494
+ throw daemonFailure("daemon:worker", "worker-not-found", 404, `Worker ${workerId} does not exist.`, { workerId });
495
+ }
496
+ if (target.workspace_id !== workspaceId) {
497
+ throw daemonFailure("daemon:worker", "workspace-mismatch", 409, `Worker ${workerId} does not belong to workspace ${workspaceId}.`, {
498
+ workerId,
499
+ workspaceId,
500
+ actualWorkspaceId: target.workspace_id,
501
+ retryable: false,
502
+ });
503
+ }
504
+ const coordinateFields = {
505
+ loopId: args.loopId,
506
+ turnId: args.turnId,
507
+ sinceId: args.sinceId,
508
+ loopSeq: args.loopSeq,
509
+ turnSeq: args.turnSeq,
510
+ sequence: args.sequence,
511
+ };
512
+ for (const [field, value] of Object.entries(coordinateFields)) {
513
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
514
+ throw daemonFailure("daemon:log", "coordinate-invalid", 400, `Log coordinate field '${field}' is not a non-negative safe integer.`, {
515
+ field,
516
+ value,
517
+ stage: "log-read",
518
+ recovery: "Use a non-negative integer coordinate.",
519
+ retryable: false,
520
+ });
521
+ }
522
+ }
523
+ if (args.limit !== undefined && (!Number.isSafeInteger(args.limit) || args.limit < 1)) {
524
+ throw daemonFailure("daemon:log", "limit-invalid", 400, `Log limit ${args.limit} is not a positive safe integer.`, {
525
+ field: "limit",
526
+ value: args.limit,
527
+ stage: "log-read",
528
+ recovery: "Use a positive integer log limit.",
529
+ retryable: false,
530
+ });
531
+ }
352
532
  const rows = await this.#db.log_read_recent_ids.all({
353
533
  worker_id: workerId,
354
534
  loop_id: args.loopId ?? null, turn_id: args.turnId ?? null, since_id: args.sinceId ?? null,
@@ -360,7 +540,7 @@ export default class Daemon {
360
540
  entries.push(await LogEntry.fetchLogEntry(this.#db, r.id));
361
541
  return entries;
362
542
  }
363
- // The metadata-read hooks (#355) — the module's render surface beyond the journal. Thin delegations
543
+ // {§methods} — the module's render surface beyond the journal. Thin delegations
364
544
  // into core's envelope / membership / provider machinery; the module fans the results into its own views.
365
545
  listProviders() {
366
546
  const active = resolveActiveAlias();
@@ -369,186 +549,368 @@ export default class Daemon {
369
549
  const isActive = active !== null && active.alias === a.alias;
370
550
  return {
371
551
  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.
552
+ // The same effective model-facing budget loop usage reports, including
553
+ // optional virtual pressure; known for the active alias, null elsewhere.
374
554
  promptBudget: isActive && this.#provider !== null ? this.#engine.promptBudgetFor(this.#provider) : null,
375
555
  };
376
556
  }),
377
557
  };
378
558
  }
559
+ // {§client-display-capabilities} Core composes the installed family
560
+ // declarations; interface modules expose this contracts-owned wire without
561
+ // inventing presentation policy. `exec` is operation machinery, not an
562
+ // addressable URI scheme; its runtime-tag scheme faces remain discoverable.
563
+ async listClientDisplayCapabilities() {
564
+ const schemes = this.#schemes.list()
565
+ .filter((scheme) => scheme !== "exec")
566
+ .map((scheme) => {
567
+ const glyph = this.#schemes.manifestFor(scheme)?.glyph;
568
+ return {
569
+ kind: "scheme",
570
+ scheme,
571
+ display: glyph === undefined ? {} : { glyph },
572
+ };
573
+ });
574
+ const mimetypes = (await this.#mimetypes.displayMetadata())
575
+ .map(({ mimetype, glyph }) => ({
576
+ kind: "mimetype",
577
+ mimetype,
578
+ display: glyph.length === 0 ? {} : { glyph },
579
+ }));
580
+ return Validator.assertClientDisplayCapabilities([...schemes, ...mimetypes]);
581
+ }
379
582
  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); }
583
+ listWorkers(workspaceId) {
584
+ return Envelope.listWorkersForWorkspace(this.#db, ClientInput.assertId("workspace.workers", "workspaceId", workspaceId));
585
+ }
586
+ // {§methods-workspace-prompts}: root-conversation loop seeds, newest-first.
587
+ listPrompts(workspaceId, limit) {
588
+ const checkedWorkspaceId = ClientInput.assertId("workspace.prompts", "workspaceId", workspaceId);
589
+ const checkedLimit = ClientInput.assertLimit("workspace.prompts", limit);
590
+ return Envelope.listPromptsForWorkspace(this.#db, checkedWorkspaceId, checkedLimit ?? 100);
591
+ }
592
+ async listMembers(workspaceId) {
593
+ const checkedWorkspaceId = ClientInput.assertId("workspace.members", "workspaceId", workspaceId);
594
+ const release = await this.#workspaceGate.acquireTurn(checkedWorkspaceId, 0);
595
+ try {
596
+ return await GitMembership.resolveMembershipEffects(this.#db, checkedWorkspaceId, undefined);
597
+ }
598
+ finally {
599
+ release();
600
+ }
601
+ }
383
602
  listConstraints(workspaceId) {
384
- return this.#db.crud_list_workspace_constraints.all({ workspace_id: workspaceId });
603
+ const checkedWorkspaceId = ClientInput.assertId("workspace.constraints", "workspaceId", workspaceId);
604
+ return this.#db.crud_list_workspace_constraints.all({ workspace_id: checkedWorkspaceId });
385
605
  }
386
606
  workspaceDerivationStatus(workspaceId) {
387
- return this.#engine.workspaceDerivationStatus(workspaceId);
607
+ return this.#engine.workspaceDerivationStatus(ClientInput.assertId("workspace.derivation", "workspaceId", workspaceId));
388
608
  }
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,
609
+ // {§methods-workspace-create}: the module owns protocol decoding; core validates the typed seam
610
+ // inputs and owns the envelope, its reserved-name + name-uniqueness invariants,
392
611
  // membership resolution, warmWorkspaceDerivations, and the workspace/created emit. No connection state
393
612
  // (which client is on which workspace) lives here — that's the module's.
394
613
  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.
614
+ // The seam fails hard on malformed semantic input so every module inherits one wall:
615
+ // the settings bag
616
+ // ({§operator-config-workspace-settings}),
617
+ // constraints, and absolute projectRoot.
618
+ const name = ClientInput.assertOptionalName("workspace.create", "name", args.name);
398
619
  const projectRoot = ClientInput.assertProjectRoot("workspace.create", args.projectRoot);
399
620
  const settings = ClientInput.parseSettings(args.settings);
400
621
  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;
622
+ return observed(// {§observability-boundary}
623
+ "workspace.create", {}, async (span) => {
624
+ const envelope = await Envelope.createClientEnvelope(this.#db, { name, projectRoot, settings });
625
+ span.setAttribute("workspace.id", envelope.workspaceId);
626
+ for (const { effect, glob } of constraints) {
627
+ await this.#db.crud_insert_workspace_constraint.run({ workspace_id: envelope.workspaceId, effect, glob });
628
+ }
629
+ if (constraints.length > 0)
630
+ await GitMembership.resolveGitMembership(this.#db, envelope.workspaceId, undefined);
631
+ await LoopDocs.materialize(this.#engine, this.#db, envelope.workspaceId);
632
+ void this.#engine.warmWorkspaceDerivations(envelope.workspaceId).catch(() => { });
633
+ this.#broadcast("all", "workspace/created", { id: envelope.workspaceId, name: envelope.workspaceName, projectRoot: envelope.projectRoot });
634
+ return envelope;
635
+ });
411
636
  }
412
637
  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 });
638
+ // attachToWorkspace owns the reserved-name + worker-ownership invariants; the seam just delegates + warms.
639
+ const workspaceId = ClientInput.assertId("workspace.attach", "workspaceId", args.workspaceId);
640
+ const workerId = args.workerId === undefined
641
+ ? undefined
642
+ : ClientInput.assertId("workspace.attach", "workerId", args.workerId);
643
+ const workerName = ClientInput.assertOptionalWorkerName("workspace.attach", "workerName", args.workerName);
644
+ const envelope = await Envelope.attachToWorkspace(this.#db, workspaceId, { workerId, workerName });
415
645
  void this.#engine.warmWorkspaceDerivations(envelope.workspaceId).catch(() => { });
416
646
  return envelope;
417
647
  }
418
648
  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) };
649
+ const checkedWorkspaceId = ClientInput.assertId("workspace.rename", "workspaceId", workspaceId);
650
+ const checkedName = ClientInput.assertOptionalName("workspace.rename", "name", name);
651
+ if (checkedName === undefined)
652
+ throw new Error("ClientInput.assertOptionalName accepted a required name as undefined");
653
+ return { id: checkedWorkspaceId, name: await Envelope.updateWorkspaceName(this.#db, checkedWorkspaceId, checkedName) };
425
654
  }
426
655
  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 };
656
+ const checkedWorkspaceId = ClientInput.assertId("workspace.constrain", "workspaceId", workspaceId);
657
+ const release = await this.#workspaceGate.acquireTurn(checkedWorkspaceId, 0);
658
+ try {
659
+ ClientInput.assertConstraint("workspace.constrain", effect, glob);
660
+ await this.#db.crud_insert_workspace_constraint.run({ workspace_id: checkedWorkspaceId, effect, glob });
661
+ await GitMembership.resolveGitMembership(this.#db, checkedWorkspaceId, undefined);
662
+ // Members may have just landed — begin warming now, but return the constraint response
663
+ // immediately so prompts do not wait for the complete derivation corpus.
664
+ void this.#engine.warmWorkspaceDerivations(checkedWorkspaceId).catch(() => { });
665
+ return { effect, glob };
666
+ }
667
+ finally {
668
+ release();
669
+ }
443
670
  }
444
671
  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 };
672
+ const checkedWorkspaceId = ClientInput.assertId("workspace.unconstrain", "workspaceId", workspaceId);
673
+ const release = await this.#workspaceGate.acquireTurn(checkedWorkspaceId, 0);
674
+ try {
675
+ ClientInput.assertConstraint("workspace.unconstrain", effect, glob);
676
+ await this.#db.crud_delete_workspace_constraint.run({ workspace_id: checkedWorkspaceId, effect, glob });
677
+ await GitMembership.resolveGitMembership(this.#db, checkedWorkspaceId, undefined);
678
+ void this.#engine.warmWorkspaceDerivations(checkedWorkspaceId).catch(() => { });
679
+ return { effect, glob };
680
+ }
681
+ finally {
682
+ release();
683
+ }
450
684
  }
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.
685
+ // Contracts {§entry-read-result}: resolve through the scheme's address law,
686
+ // then project one owner-scoped entry without exposing persistence columns.
454
687
  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 });
688
+ const workspaceId = ClientInput.assertId("entry.read", "workspaceId", args.workspaceId);
689
+ const workerId = ClientInput.assertId("entry.read", "workerId", args.workerId);
690
+ if (typeof args.target !== "string" || args.target.length === 0) {
691
+ throw daemonFailure("daemon:input", "target-invalid", 400, "target is not a non-empty string.", {
692
+ context: "entry.read",
693
+ field: "target",
694
+ stage: "input-validation",
695
+ recovery: "Provide an entry URI.",
696
+ retryable: false,
697
+ });
698
+ }
699
+ const channel = ClientInput.assertOptionalChannel("entry.read", args.channel);
700
+ const worker = await this.#db.envelope_get_worker_by_id.get({ id: workerId });
701
+ if (worker === undefined) {
702
+ throw daemonFailure("daemon:worker", "worker-not-found", 404, `Worker ${workerId} does not exist.`, { workerId });
703
+ }
704
+ if (worker.workspace_id !== workspaceId) {
705
+ throw daemonFailure("daemon:worker", "workspace-mismatch", 409, `Worker ${workerId} does not belong to workspace ${workspaceId}.`, {
706
+ workerId,
707
+ workspaceId,
708
+ actualWorkspaceId: worker.workspace_id,
709
+ retryable: false,
710
+ });
711
+ }
712
+ const release = await this.#workspaceGate.acquireTurn(workspaceId, workerId);
713
+ try {
714
+ let parsed;
715
+ try {
716
+ parsed = parsePath(args.target);
717
+ }
718
+ catch {
719
+ parsed = null;
720
+ }
721
+ if (parsed === null || parsed.kind !== "url") {
722
+ return entryReadResult(Results.failure("daemon:entry", "target-invalid", 400, `The entry target '${args.target}' is not URL-shaped.`, { entry: null }, {
723
+ target: args.target,
724
+ stage: "entry-read",
725
+ recovery: "Use a scheme://path target.",
726
+ retryable: false,
727
+ }));
728
+ }
729
+ if (args.offset !== undefined && channel === undefined) {
730
+ return entryReadResult(Results.failure("daemon:entry", "offset-channel-required", 400, "An entry offset requires a channel.", { entry: null }, {
731
+ offset: args.offset,
732
+ stage: "entry-read",
733
+ recovery: "Select the channel to read from the offset.",
734
+ retryable: false,
735
+ }));
736
+ }
737
+ if (args.offset !== undefined && (!Number.isSafeInteger(args.offset) || args.offset < 0)) {
738
+ return entryReadResult(Results.failure("daemon:entry", "offset-invalid", 400, `Entry offset ${args.offset} is not a non-negative safe integer.`, { entry: null }, {
739
+ offset: args.offset,
740
+ stage: "entry-read",
741
+ recovery: "Use a non-negative integer offset.",
742
+ retryable: false,
743
+ }));
744
+ }
745
+ if (parsed.username !== null || parsed.password !== null) {
746
+ return entryReadResult(Results.failure("daemon:entry", "userinfo-not-allowed", 400, "Entry target URL userinfo is not allowed.", { entry: null }, {
747
+ stage: "entry-read",
748
+ recovery: "Remove credentials from the entry URL.",
749
+ retryable: false,
750
+ }));
751
+ }
752
+ const location = await this.#engine.resolveEntryAddress({
753
+ workspaceId,
754
+ workerId,
755
+ target: parsed,
756
+ });
757
+ if (location === null) {
758
+ return entryReadResult(Results.failure("daemon:entry", "entry-not-found", 404, "No visible entry exists at the requested target.", { entry: null }, { target: args.target }));
759
+ }
760
+ const row = await this.#db.entry_read_lookup.get({
761
+ workspace_id: workspaceId,
762
+ owner_id: location.ownerId,
763
+ scheme: location.scheme,
764
+ pathname: location.pathname,
765
+ });
766
+ if (row === undefined) {
767
+ return entryReadResult(Results.failure("daemon:entry", "entry-not-found", 404, `No visible entry exists at ${location.target}.`, { entry: null }, { target: location.target }));
768
+ }
769
+ let channelRows;
770
+ if (channel === undefined) {
771
+ channelRows = await this.#db.entry_read_channels.all({ entry_id: row.id });
772
+ }
773
+ else {
774
+ const r = await this.#db.entry_read_channel_slice.get({ entry_id: row.id, channel, offset: args.offset ?? 0 });
775
+ if (r === undefined) {
776
+ const availableChannels = (await this.#db.entry_read_channels.all({ entry_id: row.id }))
777
+ .map(({ name }) => name);
778
+ return entryReadResult(Results.failure("daemon:entry", "channel-not-found", 404, `Channel #${channel} does not exist at ${location.target}.`, { entry: null }, {
779
+ target: location.target,
780
+ requestedChannel: channel,
781
+ availableChannels,
782
+ ...(availableChannels.length === 0
783
+ ? {}
784
+ : { recovery: `Use one of the available channels: ${availableChannels.map((channel) => `#${channel}`).join(", ")}.` }),
785
+ retryable: false,
786
+ }));
787
+ }
788
+ channelRows = [r];
789
+ }
790
+ const channels = {};
791
+ for (const c of channelRows) {
792
+ channels[c.name] = {
793
+ content: c.content,
794
+ contentOffset: c.contentOffset,
795
+ contentLength: c.contentLength,
796
+ mimetype: c.mimetype,
797
+ tokens: c.tokens,
798
+ state: c.state,
799
+ };
800
+ }
801
+ const tagRows = await this.#db.crud_read_tags.all({ entry_id: row.id });
802
+ return entryReadResult({
803
+ status: 200,
804
+ entry: {
805
+ entryId: row.id,
806
+ target: location.target,
807
+ channels,
808
+ tags: tagRows.map((tag) => tag.tag),
809
+ },
810
+ });
811
+ }
812
+ finally {
813
+ release();
468
814
  }
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
815
  }
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.
816
+ // {§methods-conversation-worker}: a fresh conversation is a model-origin root worker with an empty private log.
817
+ // AG-UI threads map to these workers while the workspace world remains shared ({§machine-processes}).
486
818
  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");
819
+ const workspaceId = ClientInput.assertId("worker.create", "workspaceId", args.workspaceId);
820
+ const name = ClientInput.assertOptionalWorkerName("worker.create", "name", args.name);
490
821
  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`);
822
+ if (workspace === undefined) {
823
+ throw daemonFailure("daemon:workspace", "workspace-not-found", 404, `Workspace ${workspaceId} does not exist.`, { workspaceId });
824
+ }
493
825
  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
826
  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`);
827
+ if (taken !== undefined) {
828
+ 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 });
829
+ }
499
830
  }
500
- const run = await Envelope.createModelWorker(this.#db, workspaceId, name);
501
- return { workerId: run.id, workerName: run.name };
831
+ const worker = await Envelope.createModelWorker(this.#db, workspaceId, name);
832
+ return { workerId: worker.id, workerName: worker.name };
502
833
  }
503
- // ownership check and the run-name namespace + uniqueness invariants (names are immutable — no rename).
834
+ // {§worker-scheme-fork} — branch a worker's log while sharing the workspace world.
835
+ // Core owns the workspace check and immutable worker-name admission.
504
836
  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;
837
+ const workspaceId = ClientInput.assertId("worker.fork", "workspaceId", args.workspaceId);
838
+ const workerId = ClientInput.assertId("worker.fork", "workerId", args.workerId);
839
+ const name = ClientInput.assertOptionalWorkerName("worker.fork", "name", args.name);
508
840
  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}`);
841
+ if (owner === undefined) {
842
+ throw daemonFailure("daemon:worker", "worker-not-found", 404, `Worker ${workerId} does not exist.`, { workerId });
843
+ }
844
+ if (owner.workspace_id !== workspaceId) {
845
+ throw daemonFailure("daemon:worker", "workspace-mismatch", 409, `Worker ${workerId} does not belong to workspace ${workspaceId}.`, {
846
+ workerId,
847
+ workspaceId,
848
+ actualWorkspaceId: owner.workspace_id,
849
+ retryable: false,
850
+ });
851
+ }
513
852
  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
853
  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`);
854
+ if (taken !== undefined) {
855
+ 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 });
856
+ }
519
857
  }
520
858
  const branchWorkerId = await Fork.fork(this.#db, workerId, name);
521
859
  const branch = await this.#db.envelope_get_worker_by_id.get({ id: branchWorkerId });
522
860
  return { workerId: branchWorkerId, workerName: branch?.name ?? null, parentWorkerId: workerId };
523
861
  }
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, {
862
+ async registerRuntime({ namespaceOwner, decl, executor, availability, scheme }) {
863
+ if (typeof namespaceOwner !== "string" || namespaceOwner.trim().length === 0) {
864
+ throw new Error("registerRuntime: namespaceOwner must be a non-empty string");
865
+ }
866
+ this.#engine.registerRuntime(decl.name, {
533
867
  executor,
868
+ namespaceOwner: { kind: "module", name: namespaceOwner },
534
869
  glyph: decl.glyph ?? "",
535
870
  example: decl.example ?? "",
536
871
  documentation: decl.documentation ?? "",
537
872
  available: availability.available,
538
873
  detail: availability.detail,
539
- });
874
+ }, scheme);
875
+ if (this.#capabilitiesPublished) {
876
+ for (const workspace of await Envelope.listWorkspaces(this.#db)) {
877
+ await LoopDocs.materialize(this.#engine, this.#db, workspace.id);
878
+ }
879
+ }
880
+ }
881
+ async registerScheme(name, handler) {
882
+ this.#schemes.register(name, handler);
883
+ if (this.#capabilitiesPublished) {
884
+ await this.#schemes.ready();
885
+ for (const workspace of await Envelope.listWorkspaces(this.#db)) {
886
+ await LoopDocs.materialize(this.#engine, this.#db, workspace.id);
887
+ }
888
+ }
889
+ }
890
+ registerModuleAction(name, handler) {
891
+ if (name.length === 0)
892
+ throw new Error("registerModuleAction: action name must not be empty");
893
+ if (this.#moduleActions.has(name))
894
+ throw new Error(`module action '${name}' is already registered`);
895
+ this.#moduleActions.set(name, handler);
896
+ }
897
+ listModuleActions() {
898
+ return [...this.#moduleActions.keys()].toSorted();
899
+ }
900
+ async invokeModuleAction(name, params) {
901
+ const handler = this.#moduleActions.get(name);
902
+ if (handler === undefined)
903
+ throw new Error(`module action '${name}' is not registered`);
904
+ return handler(params);
540
905
  }
541
906
  get engine() { return this.#engine; }
542
907
  get provider() { return this.#provider; }
543
908
  get schemes() { return this.#schemes; }
544
909
  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);
910
+ registerModule(module) {
911
+ if (this.#started)
912
+ throw new Error("registerModule: modules must be registered before daemon start");
913
+ this.#modules.push(module);
552
914
  }
553
915
  async start() {
554
916
  if (this.#started)
@@ -557,18 +919,27 @@ export default class Daemon {
557
919
  // Mimetypes owns its own discovery scan over @plurnk/plurnk-mimetypes-*
558
920
  // packages; pre-warm it so first index render doesn't pay the cost.
559
921
  await this.#mimetypes.ready();
922
+ for (const name of await this.#mimetypes.skippedPackages()) {
923
+ console.warn(`mimetype discovery: '${name}' is discovered but untrusted (PLURNK_PLUGINS_TRUSTED_ONLY); not registered`);
924
+ }
560
925
  // Discover + probe the installed executor siblings, then hand the
561
- // registry to the engine for exec dispatch (plurnk-service#181). The
926
+ // registry to the engine for exec dispatch ({§exec-registry-resolves}). The
562
927
  // shell is the default runtime, so its executor must boot usable.
563
928
  const executors = await ExecutorRegistry.build({ defaultRuntime: "sh", cwd: this.#discoveryCwd });
564
929
  this.#engine.setExecutors(executors);
565
- // §exec — mint a scheme per runtime tag so exec output entries address by tag
930
+ // {§exec} — mint a scheme per runtime tag so exec output entries address by tag
566
931
  // authority (sh:///l/t/s). The "exec" scheme stays for the EXEC op dispatch.
567
932
  this.#schemes.registerRuntimeSchemes(executors);
568
933
  // Discover external @plurnk/plurnk-schemes-* siblings + register them
569
934
  // (agnostic, by plurnk.kind:"scheme"). They light up http://, etc. with
570
- // no further engine change — #run wraps their ctx in SchemeCtxImpl (#195).
935
+ // no further engine change — #run wraps their context in SchemeCtxImpl ({§plugin-discovery}).
571
936
  await this.#schemes.discoverExternal(this.#discoveryCwd);
937
+ const setupSeam = this;
938
+ for (const module of this.#modules) {
939
+ if (module.close !== undefined)
940
+ this.#moduleClosers.push(module);
941
+ await module.setup?.(setupSeam);
942
+ }
572
943
  await this.#schemes.ready();
573
944
  // Reconcile the kernel-published documentation surface once per existing workspace.
574
945
  // Installed capabilities and operator configuration are now fully known; model loops
@@ -576,16 +947,28 @@ export default class Daemon {
576
947
  for (const workspace of await Envelope.listWorkspaces(this.#db)) {
577
948
  await LoopDocs.materialize(this.#engine, this.#db, workspace.id);
578
949
  }
950
+ this.#capabilitiesPublished = true;
579
951
  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);
952
+ // {§module-lifecycle} — the daemon opens no transport. Modules start their listeners only
953
+ // after capability publication and durable lifecycle recovery are complete.
954
+ for (const module of this.#modules) {
955
+ const started = await module.start?.(this);
956
+ if (started !== undefined && !this.#moduleClosers.includes(started)) {
957
+ this.#moduleClosers.push(started);
958
+ }
959
+ }
583
960
  }
584
961
  async #recoverLifecycle() {
585
962
  await this.#db.recovery_fail_active_loops.run({});
963
+ await this.#db.recovery_fail_ownerless_proposals.run({});
586
964
  await this.#db.recovery_error_orphan_subscription_channels.run({});
587
965
  await this.#db.recovery_fail_orphan_subscriptions.run({});
588
966
  await this.#db.recovery_resume_unblocked_parks.run({});
967
+ await this.#branchBatches.recover();
968
+ const orphanSources = await this.#db.recovery_orphan_prompt_sources.all({});
969
+ for (const source of orphanSources) {
970
+ await this.#reconcileOrphanedPrompts(source.worker_id, source.loop_id);
971
+ }
589
972
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
590
973
  const queued = await this.#db.recovery_queued_workers.all({});
591
974
  for (const row of queued) {
@@ -607,6 +990,13 @@ export default class Daemon {
607
990
  if (!this.#started)
608
991
  return;
609
992
  this.#started = false;
993
+ // Stop accepting external work immediately, but do not await listener
994
+ // closure before cancelling active workers: an SSE connection may itself be
995
+ // waiting for the worker cancellation that follows.
996
+ const moduleClose = Promise.allSettled(this.#moduleClosers
997
+ .toReversed()
998
+ .map((module) => Promise.resolve().then(() => module.close())));
999
+ this.#moduleClosers = [];
610
1000
  // Drain order: (1) abort in-flight loops via #activeDrains so
611
1001
  // strike paths don't keep going, (2) await each drain's promise
612
1002
  // to completion, (3) drain streaming schemes' background work
@@ -614,12 +1004,13 @@ export default class Daemon {
614
1004
  // upstream — drain queries hit the DB right up until they exit.
615
1005
  // Abort every worker's cancellation scope — stops in-flight loops AND the
616
1006
  // streams (background execs) linked to them, so idle() doesn't block on
617
- // a long-running command. Covers runs whose drain already exited but
1007
+ // a long-running command. Covers workers whose drain already exited but
618
1008
  // whose exec is still in flight.
619
1009
  // Settle the stopped world FIRST: a drain paused at a pending proposal awaits a resolution
620
1010
  // that will never arrive once clients are gone — allSettled(drains) below would deadlock
621
1011
  // the stop forever (a daemon with a pending HITL proposal could not shut down).
622
1012
  this.#engine.cancelAllProposals("daemon_stopping");
1013
+ this.#branchBatches.beginStop();
623
1014
  for (const scope of this.#workerAborts.values()) {
624
1015
  if (!scope.signal.aborted)
625
1016
  scope.abort("daemon_stopping");
@@ -628,17 +1019,31 @@ export default class Daemon {
628
1019
  clearTimeout(t); // drop pending hibernation poll-wakes
629
1020
  this.#pollBackoff.clear();
630
1021
  this.#pollTimers.clear();
631
- // …and the park-DEADLINE timers (#432): a bounded park's timer fires #wakeParkedWorker after
1022
+ // Cancel park-deadline timers before DB close; otherwise a late #wakeParkedWorker would run after
632
1023
  // stop/db-close if left pending — an unhandled rejection (SqlRite closed) that abnormally
633
1024
  // exits the worker under load. Symmetric with the poll-wakes above; both must be reaped.
634
1025
  for (const t of this.#parkTimers.values())
635
1026
  clearTimeout(t);
636
1027
  this.#parkTimers.clear();
1028
+ await this.#branchBatches.idle();
637
1029
  const drainPromises = [...this.#activeDrains.values()].map((d) => d.promise);
638
1030
  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();
1031
+ const closeResults = await moduleClose;
1032
+ const [streamingResult] = await Promise.allSettled([this.#drainStreamingSchemes()]);
1033
+ const [derivationResult] = await Promise.allSettled([
1034
+ this.#engine.drainDerivations(), // active workspace warms finish before the db closes upstream
1035
+ ]);
1036
+ const mimetypeResults = this.#ownsMimetypes
1037
+ ? await Promise.allSettled([this.#mimetypes.dispose()])
1038
+ : [];
1039
+ const [schemeResult] = await Promise.allSettled([this.#schemes.close()]);
1040
+ const closeErrors = [...closeResults, streamingResult, derivationResult, ...mimetypeResults, schemeResult]
1041
+ .filter((result) => result.status === "rejected")
1042
+ .flatMap((result) => result.reason instanceof AggregateError
1043
+ ? [...result.reason.errors]
1044
+ : [result.reason]);
1045
+ if (closeErrors.length > 0)
1046
+ throw new AggregateError(closeErrors, "daemon shutdown failed");
642
1047
  }
643
1048
  // Per-scheme idle awaits for clean shutdown. New streaming schemes
644
1049
  // (SSE, WS) add themselves here as they land.
@@ -650,48 +1055,47 @@ export default class Daemon {
650
1055
  /**
651
1056
  * Emit a stream/event notification scoped to the workspace containing the
652
1057
  * entry. ChannelWrite helpers (src/core/ChannelWrite.ts) invoke this when
653
- * they update channel content or state. SPEC §notifications.
1058
+ * they update channel content or state. SPEC {§notifications}.
654
1059
  */
655
1060
  notifyStreamEvent(workspaceId, event) {
656
1061
  this.#broadcast({ workspaceId }, "stream/event", event);
657
1062
  }
658
1063
  /**
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.
1064
+ * Emit a transient notice scoped to the workspace containing the loop.
664
1065
  */
665
- notifyTelemetryEvent(workspaceId, payload) {
666
- this.#broadcast({ workspaceId }, "telemetry/event", payload);
1066
+ notifyNotice(workspaceId, payload) {
1067
+ this.#broadcast({ workspaceId }, "notice/event", payload);
667
1068
  }
668
1069
  /**
669
1070
  * 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
1071
+ * - Active drain: writes the next prompt:///<loop>/<N> entry via
1072
+ * Engine.inject. The current loop publishes it at its next
672
1073
  * turn. Returns immediately with {action: "injected_next_turn"}.
673
1074
  * - No active drain: enqueues a fresh loop with the prompt at
674
1075
  * status=100, starts a drain. Returns the drain promise so the
675
1076
  * caller can await full completion.
676
1077
  *
677
- * Rummy parallel: AgentLoop.inject(). Unified surface — both `loop.run`
678
- * and wake-on-completion go through this method. §actor-boundary-passive-wake
1078
+ * Both `runLoop` and wake-on-completion go through this method
1079
+ * ({§actor-boundary-passive-wake}).
679
1080
  */
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.
1081
+ // {§methods-loop-run-fold-consistency} — a folded prompt cannot reconfigure its loop.
685
1082
  async #assertFoldPosture(workerId, flags, loopId) {
686
1083
  if (flags === undefined || Object.keys(flags).length === 0)
687
1084
  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)}`);
1085
+ const effective = await LoopFlagsReader.read(this.#db, loopId);
1086
+ const requested = Object.entries(flags);
1087
+ const conflicts = requested
1088
+ .filter(([key, value]) => value !== undefined && effective[key] !== value)
1089
+ .map(([key, value]) => `${key}: ${JSON.stringify(effective[key])} -> ${JSON.stringify(value)}`);
693
1090
  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.`);
1091
+ throw daemonFailure("daemon:loop", "loop-flags-conflict", 409, "The requested loop flags differ from the active loop flags.", {
1092
+ workerId,
1093
+ loopId,
1094
+ conflicts,
1095
+ stage: "loop-injection",
1096
+ recovery: "Cancel the active loop before changing flags, or omit flags to keep its current posture.",
1097
+ retryable: false,
1098
+ });
695
1099
  }
696
1100
  }
697
1101
  async inject(args) {
@@ -700,29 +1104,29 @@ export default class Daemon {
700
1104
  // engine.inject returns null when no loop is currently executing, so
701
1105
  // we enqueue a fresh loop below and ensure a drain claims it.
702
1106
  if (this.#activeDrains.has(workerId)) {
703
- await this.#assertFoldPosture(workerId, args.flags); // #368 — a fold never silently discards intent
704
1107
  const active = await this.#db.drain_current_loop_for_worker.get({ worker_id: workerId });
705
1108
  if (active !== undefined) {
1109
+ await this.#assertFoldPosture(workerId, args.flags, active.id); // compare with the exact durable loop
706
1110
  await this.#assertLoopProvider(active.id, args.providerSpec);
707
- await this.#assertLoopMaxTurns(active.id, args.maxTurns);
1111
+ await this.#assertLoopMaxTurns(active.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
708
1112
  }
709
- const result = await this.#engine.inject(workerId, prompt);
1113
+ const result = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
710
1114
  if (result !== null) {
711
1115
  return { action: "injected_next_turn", loopId: result.loopId, turnSeq: result.turnSeq };
712
1116
  }
713
1117
  }
714
- // #55 — a worker PARKED at 202 RESUMES that slept loop in place: the voice door (irc / loop.inject)
1118
+ // {§worker-lifecycle-wake-requeue-not-terminal} — a worker parked at 202 resumes that loop in place:
715
1119
  // is a wake edge like a stream/child conclusion, not a fresh loop that orphans the parked one
716
1120
  // (which would leave the worker non-quiescent forever). engine.inject writes the message as the
717
1121
  // 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.
1122
+ // resumes promptless); then re-queue + drain it. {§worker-lifecycle-wake-liveness}.
719
1123
  if (!this.#activeDrains.has(workerId)) {
720
1124
  const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
721
1125
  if (slept !== undefined) {
722
- await this.#assertFoldPosture(workerId, args.flags, slept.id); // #368 — the resume path drops nothing silently either
1126
+ await this.#assertFoldPosture(workerId, args.flags, slept.id); // resume drops nothing silently
723
1127
  await this.#assertLoopProvider(slept.id, args.providerSpec);
724
- await this.#assertLoopMaxTurns(slept.id, args.maxTurns);
725
- const injected = await this.#engine.inject(workerId, prompt);
1128
+ await this.#assertLoopMaxTurns(slept.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
1129
+ const injected = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
726
1130
  await this.#lifecycle.wake(slept.id);
727
1131
  const started = await this.#ensureDrain({
728
1132
  workspaceId, workerId, systemPrompt: args.systemPrompt,
@@ -730,44 +1134,61 @@ export default class Daemon {
730
1134
  return { action: "injected_next_turn", loopId: slept.id, ...(injected?.turnSeq !== undefined ? { turnSeq: injected.turnSeq } : {}), ...(started ?? {}) };
731
1135
  }
732
1136
  }
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"),
1137
+ const loopId = await this.#enqueueFreshLoop({
1138
+ workerId,
1139
+ prompt,
1140
+ providerSpec: args.providerSpec,
1141
+ maxTurns: args.turnCeiling?.effective,
1142
+ flags: args.flags,
1143
+ openPaths: args.openPaths,
741
1144
  });
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
1145
  // 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),
1146
+ // check-and-start UNDER the per-worker drain lock ({§worker-lifecycle-single-drain}),
759
1147
  // serialized against a draining sibling's teardown relinquish so the two can't
760
1148
  // both register a drain (R4). A live drain re-claims the loop in its own
761
1149
  // 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
1150
+ // firstLoopPromise is present only when THIS call started the drain — runLoop
763
1151
  // keys its fast-path response on that.
764
1152
  const started = await this.#ensureDrain({
765
1153
  workspaceId, workerId, systemPrompt: args.systemPrompt,
766
1154
  });
767
1155
  return { action: "enqueued_new_loop", loopId, ...(started ?? {}) };
768
1156
  }
1157
+ async #enqueueFreshLoop(args) {
1158
+ // {§worker-lifecycle-single-drain}: sequence allocation and insertion
1159
+ // are one queue mutation; another accepted prompt cannot claim the gap.
1160
+ return this.#withDrainLock(args.workerId, async () => {
1161
+ const seqRow = await this.#db.loop_run_next_sequence.get({
1162
+ worker_id: args.workerId,
1163
+ });
1164
+ if (seqRow === undefined)
1165
+ throw new Error("enqueueFreshLoop: next-sequence query returned no row");
1166
+ const loopRow = await this.#db.drain_enqueue_loop.get({
1167
+ worker_id: args.workerId,
1168
+ sequence: seqRow.next,
1169
+ prompt: args.prompt,
1170
+ provider_spec: JSON.stringify(args.providerSpec),
1171
+ max_turns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
1172
+ });
1173
+ if (loopRow === undefined)
1174
+ throw new Error("enqueueFreshLoop: loop enqueue returned no row");
1175
+ if (args.flags !== undefined) {
1176
+ await this.#db.engine_set_loop_flags.run({
1177
+ loop_id: loopRow.id,
1178
+ flags: JSON.stringify({ ...DEFAULT_LOOP_FLAGS, ...args.flags }),
1179
+ });
1180
+ }
1181
+ if (args.openPaths !== undefined && args.openPaths.length > 0) {
1182
+ await this.#db.engine_set_loop_open_paths.run({
1183
+ loop_id: loopRow.id,
1184
+ open_paths: JSON.stringify(args.openPaths),
1185
+ });
1186
+ }
1187
+ return loopRow.id;
1188
+ });
1189
+ }
769
1190
  /**
770
- * Start a drain for the given run. The drain claims queued loops via
1191
+ * Start a drain for the given worker. The drain claims queued loops via
771
1192
  * drain_claim_next_loop (atomic 100→102 flip), executes each via
772
1193
  * Engine.runLoop, and re-checks. Stream-aware: when the queue is empty
773
1194
  * but the worker has active subscriptions, the drain parks on a
@@ -775,7 +1196,7 @@ export default class Daemon {
775
1196
  * exits when queue is empty AND no active subscriptions remain.
776
1197
  *
777
1198
  * Returns both `firstLoopPromise` (resolves once the first loop the
778
- * drain processes completes — used by loop.run to give the caller a
1199
+ * drain processes completes — used by runLoop to give the caller a
779
1200
  * fast response containing their loop's result) and `drainPromise`
780
1201
  * (resolves only when the whole drain finishes, queue+subs settled).
781
1202
  */
@@ -798,7 +1219,7 @@ export default class Daemon {
798
1219
  const drainPromise = (async () => {
799
1220
  let loopsDrained = 0;
800
1221
  let lastResult = null;
801
- let currentLoopId = null; // the loop being drained — for the #204 abort→499 resolution below
1222
+ let currentLoopId = null; // the loop being drained — for abort→499 settlement
802
1223
  try {
803
1224
  while (true) {
804
1225
  controller.signal.throwIfAborted();
@@ -821,35 +1242,40 @@ export default class Daemon {
821
1242
  break;
822
1243
  }
823
1244
  currentLoopId = loopRow.id;
824
- // #598 — provider identity belongs to the claimed loop, not the
1245
+ // {§methods-loop-run-model} — provider identity belongs to the claimed loop, not the
825
1246
  // drain that happened to claim it. A drain can consume multiple
826
1247
  // queued loops; resolve each durable selection at this boundary.
827
1248
  const provider = await this.#providerForLoop(loopRow.id);
828
1249
  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.
1250
+ // {§methods-event-subscribe} — a log-broadcast failure must never crash the drain.
831
1251
  void (async () => {
832
1252
  const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
833
1253
  this.#broadcast({ workspaceId }, "log/entry", { entry });
834
1254
  })().catch((e) => console.error("log/entry broadcast failed:", e instanceof Error ? e.message : String(e)));
835
1255
  };
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,
1256
+ const result = await observed(// {§observability-boundary}
1257
+ "loop.run", { workspaceId, workerId, "loop.id": loopRow.id }, async (span) => {
1258
+ const loopResult = await this.#engine.runLoop({
1259
+ provider, workspaceId, workerId, loopId: loopRow.id, maxTurns: loopRow.max_turns,
1260
+ messages: [
1261
+ { role: "system", content: systemPrompt },
1262
+ { role: "user", content: loopRow.prompt },
1263
+ ],
1264
+ origin: "model",
1265
+ onDispatch,
1266
+ signal: controller.signal,
1267
+ });
1268
+ span.setAttribute("status", loopResult.result.status);
1269
+ recordCounter(LOOP_TERMINALS, { status: loopResult.result.status });
1270
+ return loopResult;
845
1271
  });
846
- if (result.finalStatus === 202) {
847
- // The loop SLEPT (parked via [102]<T>/<-1>) — suspended, not terminated. Leave it at 202
1272
+ if (result.result.status === 202) {
1273
+ // The loop slept via SEND[202] — suspended, not terminated. Leave it at 202
848
1274
  // (resumable); no loop/terminated, no orphan-reconcile. A stream conclusion
849
1275
  // (#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.
1276
+ // wakes it every P to inspect ({§exec-poll}). {§worker-lifecycle-wake-liveness}.
851
1277
  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
1278
+ // {§send-premature-terminate}/SEND[202]<T> — the park deadline:
853
1279
  // dispatcher recorded the marker's seconds; a bounded park is woken at T
854
1280
  // regardless of arrivals, so a park always has a next turn. -1 (indefinite:
855
1281
  // the butler, a [300] ask) schedules nothing — irc/inject/conclusions wake it.
@@ -871,37 +1297,44 @@ export default class Daemon {
871
1297
  this.#parkTimers.set(workerId, t);
872
1298
  }
873
1299
  }
874
- // Honor an OWED wake (§worker-lifecycle-child-wake): a child/stream concluded while
1300
+ // Honor an OWED wake ({§worker-lifecycle-child-wake}): a child/stream concluded while
875
1301
  // 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
1302
+ // so a worker hibernation always returns. The loop is 202 here; reset to
877
1303
  // claimable and the drain re-runs it on the next claim below.
878
1304
  if (this.#owedWakes.delete(workerId)) {
879
1305
  await this.#lifecycle.wake(loopRow.id);
1306
+ currentLoopId = null;
880
1307
  continue;
881
1308
  }
882
- // The loop is blocked at 202 on a live obligation (§wait-obligation-matrix);
1309
+ // The loop is blocked at 202 on a live obligation ({§wait-obligation-matrix});
883
1310
  // that obligation's conclusion is its wake edge (the owed-wake above covers the
884
1311
  // conclude-before-block race). An idle wait never reaches here — it concluded at dispatch.
1312
+ currentLoopId = null;
885
1313
  continue;
886
1314
  }
887
1315
  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);
1316
+ const [usage, attributions, turnIds] = await Promise.all([
1317
+ this.#engine.loopUsage(loopRow.id),
1318
+ this.#engine.loopAttributions(loopRow.id),
1319
+ this.#lifecycle.turnIds(loopRow.id),
1320
+ ]);
890
1321
  this.#broadcast({ workspaceId }, "loop/terminated", {
891
1322
  workerId,
892
1323
  loopId: loopRow.id,
893
- finalStatus: result.finalStatus,
1324
+ result: result.result,
894
1325
  hitMaxTurns: result.hitMaxTurns,
895
1326
  turnIds,
896
1327
  usage,
1328
+ attributions,
897
1329
  });
898
1330
  loopsDrained++;
899
1331
  const loopResult = {
900
1332
  loopId: loopRow.id,
901
1333
  turnIds,
902
- finalStatus: result.finalStatus,
1334
+ result: result.result,
903
1335
  hitMaxTurns: result.hitMaxTurns,
904
1336
  usage,
1337
+ attributions,
905
1338
  };
906
1339
  lastResult = loopResult;
907
1340
  if (!firstSettled) {
@@ -909,67 +1342,96 @@ export default class Daemon {
909
1342
  resolveFirst(loopResult);
910
1343
  }
911
1344
  // A next-turn prompt this loop ended before consuming (a
912
- // wake conclusion or a loop.run-while-active) is promoted to
1345
+ // wake conclusion or a runLoop-while-active prompt) is promoted to
913
1346
  // a fresh queued loop so it's never silently dropped.
914
- await this.#reconcileOrphanedWake(workerId, loopRow.id);
1347
+ await this.#reconcileOrphanedPrompts(workerId, loopRow.id);
1348
+ currentLoopId = null;
915
1349
  }
916
1350
  }
917
1351
  catch (err) {
918
1352
  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
1353
+ // {§methods-loop-cancel} — loop.cancel / shutdown aborted the live drain. A cancellation
1354
+ // is the loop's TERMINAL state (499), delivered via loop/terminated (runLoop no
921
1355
  // longer blocks to return it). A genuine error rejects firstLoopPromise.
922
1356
  const usage = currentLoopId === null
923
- ? { promptTokens: 0, completionTokens: 0, costUsd: 0, contextTokens: 0, promptBudget: null, meta: {} }
1357
+ ? { promptTokens: 0, completionTokens: 0, costUsd: 0, costs: [], contextTokens: 0, promptBudget: null, meta: {} }
924
1358
  : await this.#engine.loopUsage(currentLoopId);
1359
+ const attributions = currentLoopId === null
1360
+ ? []
1361
+ : await this.#engine.loopAttributions(currentLoopId);
1362
+ const message = ErrorDetail.preview(controller.signal.reason ?? "user_cancelled")
1363
+ || "no reason was supplied";
925
1364
  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) {
1365
+ // {§methods-loop-cancel}/{§worker-lifecycle-terminal-result} —
1366
+ // persist the exact 499 cancellation result before broadcasting it.
1367
+ const cancelled = await this.#lifecycle.finish(currentLoopId, Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
1368
+ reason: message,
1369
+ stage: "loop",
1370
+ retryable: false,
1371
+ }), { terminatedBy: "cancel" });
1372
+ if (cancelled !== null) {
934
1373
  this.#broadcast({ workspaceId }, "loop/terminated", {
935
1374
  workerId,
936
1375
  loopId: currentLoopId,
937
- finalStatus: 499,
1376
+ result: cancelled,
938
1377
  hitMaxTurns: false,
939
1378
  turnIds: await this.#lifecycle.turnIds(currentLoopId),
940
1379
  usage,
941
- message,
1380
+ attributions,
942
1381
  });
943
1382
  }
944
1383
  }
945
1384
  if (!firstSettled) {
946
1385
  firstSettled = true;
947
- resolveFirst({ loopId: currentLoopId ?? 0, turnIds: [], finalStatus: 499, hitMaxTurns: false, usage });
1386
+ resolveFirst({
1387
+ loopId: currentLoopId ?? 0,
1388
+ turnIds: [],
1389
+ result: currentLoopId === null
1390
+ ? Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
1391
+ reason: message,
1392
+ stage: "loop",
1393
+ retryable: false,
1394
+ })
1395
+ : await this.#lifecycle.result(currentLoopId)
1396
+ ?? Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
1397
+ reason: message,
1398
+ stage: "loop",
1399
+ retryable: false,
1400
+ }),
1401
+ hitMaxTurns: false,
1402
+ usage,
1403
+ });
948
1404
  }
949
1405
  }
950
1406
  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.
1407
+ // {§worker-lifecycle-terminal-result} — a non-abort drain
1408
+ // failure becomes an exact durable 500 and terminal notification;
1409
+ // daemon diagnostics retain the complete caught error.
960
1410
  console.error(`drain error (workspace ${workspaceId}, worker ${workerId}, loop ${currentLoopId ?? "?"}):`, err);
961
1411
  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);
1412
+ const failure = err instanceof OperationFailureError
1413
+ ? err.result
1414
+ : Results.failure("daemon:drain", "loop-threw", 500, "The loop failed outside its operation result contract.", {}, {
1415
+ stage: "loop",
1416
+ retryable: false,
1417
+ });
1418
+ const settled = await this.#lifecycle.finish(currentLoopId, failure)
1419
+ ?? await this.#lifecycle.result(currentLoopId);
1420
+ if (settled === null) {
1421
+ throw new Error(`drain could not settle loop ${currentLoopId}`, { cause: err });
1422
+ }
1423
+ const [usage, attributions] = await Promise.all([
1424
+ this.#engine.loopUsage(currentLoopId),
1425
+ this.#engine.loopAttributions(currentLoopId),
1426
+ ]);
971
1427
  this.#broadcast({ workspaceId }, "loop/terminated", {
972
- workerId, loopId: currentLoopId, finalStatus: 500, hitMaxTurns: false, turnIds: [], usage, message,
1428
+ workerId,
1429
+ loopId: currentLoopId,
1430
+ result: settled,
1431
+ hitMaxTurns: false,
1432
+ turnIds: await this.#lifecycle.turnIds(currentLoopId),
1433
+ usage,
1434
+ attributions,
973
1435
  });
974
1436
  }
975
1437
  if (!firstSettled) {
@@ -991,7 +1453,7 @@ export default class Daemon {
991
1453
  })();
992
1454
  handle.promise = drainPromise;
993
1455
  this.#activeDrains.set(workerId, handle);
994
- // Topology join (§run-lifecycle): when this drain exits having CONCLUDED the worker, wake its parent
1456
+ // Topology join ({§worker-loop-lifecycle}): when this drain exits having CONCLUDED the worker, wake its parent
995
1457
  // if parked. Runs after the drain fully tears down (settled promise) so the quiescence check sees
996
1458
  // final state; speculative (#onDrainExit no-ops unless the worker concluded AND the parent is parked).
997
1459
  void drainPromise.then(() => this.#onDrainExit(workspaceId, workerId, systemPrompt), () => this.#onDrainExit(workspaceId, workerId, systemPrompt)).catch((err) => {
@@ -1003,12 +1465,12 @@ export default class Daemon {
1003
1465
  firstLoopPromise.catch(() => { });
1004
1466
  return { firstLoopPromise, drainPromise };
1005
1467
  }
1006
- // Per-run drain-transition lock (R4 / §worker-lifecycle-single-drain). #ensureDrain's
1468
+ // Per-worker drain-transition lock (R4 / {§worker-lifecycle-single-drain}). #ensureDrain's
1007
1469
  // start and a drain's teardown relinquish both run under it, serialized, so the two
1008
1470
  // can't interleave and register two drains for one worker. The critical section is the
1009
1471
  // registry decision only (never a loop's work) — a sub-ms hop at drain boundaries.
1010
1472
  // 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.
1473
+ // when idle so the Map stays bounded to workers mid-transition.
1012
1474
  #withDrainLock(workerId, fn) {
1013
1475
  const prev = this.#drainLocks.get(workerId) ?? Promise.resolve();
1014
1476
  const run = prev.then(fn, fn);
@@ -1032,34 +1494,49 @@ export default class Daemon {
1032
1494
  return this.#startDrain(opts);
1033
1495
  });
1034
1496
  }
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"),
1497
+ // After a loop terminates, promote every next-turn frame it never consumed
1498
+ // into one source-keyed queued loop. The first frame occupies the loop seed;
1499
+ // later frames retain separate prompt entries and publish in the same turn.
1500
+ // Re-entry and boot recovery complete that same queued identity.
1501
+ async #reconcileOrphanedPrompts(workerId, endedLoopId) {
1502
+ await this.#withDrainLock(workerId, async () => {
1503
+ const endedSeq = (await this.#db.engine_loop_sequence.get({ loop_id: endedLoopId }))?.sequence ?? endedLoopId;
1504
+ const prefix = promptLoopPrefix(endedSeq);
1505
+ const frames = await this.#db.drain_orphaned_prompts_for_loop.all({ loop_id: endedLoopId, owner_id: workerId, pattern: `${prefix}%`, prefix_len: prefix.length });
1506
+ const first = frames[0];
1507
+ if (first === undefined)
1508
+ return;
1509
+ const seqRow = await this.#db.loop_run_next_sequence.get({ worker_id: workerId });
1510
+ if (seqRow === undefined)
1511
+ throw new Error("reconcileOrphanedPrompts: next-sequence query returned no row");
1512
+ const recovery = await this.#db.drain_enqueue_orphan_recovery_loop.get({
1513
+ worker_id: workerId,
1514
+ sequence: seqRow.next,
1515
+ prompt: first.body,
1516
+ flags: first.flags,
1517
+ provider_spec: first.provider_spec,
1518
+ max_turns: first.max_turns,
1519
+ open_paths: first.open_paths ?? "[]",
1520
+ orphan_source_loop_id: endedLoopId,
1521
+ });
1522
+ if (recovery === undefined)
1523
+ throw new Error("reconcileOrphanedPrompts: enqueue returned no row");
1524
+ if (recovery.status !== 100)
1525
+ return;
1526
+ const moved = await this.#db.drain_rehome_orphaned_prompt_frames.all({
1527
+ owner_id: workerId,
1528
+ source_loop_id: endedLoopId,
1529
+ source_pattern: `${prefix}%`,
1530
+ source_prefix_len: prefix.length,
1531
+ target_prefix: promptLoopPrefix(recovery.sequence),
1532
+ });
1533
+ if (moved.length !== frames.length) {
1534
+ throw new Error(`reconcileOrphanedPrompts: expected to re-home ${frames.length} frames, moved ${moved.length}`);
1535
+ }
1054
1536
  });
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
1537
  }
1061
1538
  // 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
1539
+ // so a later runLoop gets a live signal. The drain and the execs its loops
1063
1540
  // spawn all run under it.
1064
1541
  #workerSignal(workerId) {
1065
1542
  const existing = this.#workerAborts.get(workerId);
@@ -1089,19 +1566,22 @@ export default class Daemon {
1089
1566
  scope.abort(reason);
1090
1567
  }
1091
1568
  await Promise.all(cancelled.workerIds.map(async (targetWorkerId) => this.#reapWorkerStreams(targetWorkerId)));
1092
- for (const { loopId, workerId: targetWorkerId } of cancelled.loops) {
1569
+ for (const { loopId, workerId: targetWorkerId, result } of cancelled.loops) {
1093
1570
  const row = await this.#db.drain_get_worker_workspace.get({ worker_id: targetWorkerId });
1094
1571
  if (row === undefined)
1095
1572
  continue;
1096
- const usage = await this.#engine.loopUsage(loopId);
1573
+ const [usage, attributions] = await Promise.all([
1574
+ this.#engine.loopUsage(loopId),
1575
+ this.#engine.loopAttributions(loopId),
1576
+ ]);
1097
1577
  this.#broadcast({ workspaceId: row.workspace_id }, "loop/terminated", {
1098
1578
  workerId: targetWorkerId,
1099
1579
  loopId,
1100
- finalStatus: 499,
1580
+ result,
1101
1581
  hitMaxTurns: false,
1102
1582
  turnIds: await this.#lifecycle.turnIds(loopId),
1103
1583
  usage,
1104
- message: reason.slice(0, 500),
1584
+ attributions,
1105
1585
  });
1106
1586
  }
1107
1587
  }
@@ -1110,10 +1590,11 @@ export default class Daemon {
1110
1590
  }
1111
1591
  /**
1112
1592
  * 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
1593
+ * worker signal stops the running loop's turn generation AND tears down every
1114
1594
  * stream linked to it — a background exec that outlived its loop, or even a
1115
1595
  * spawn that registers after this abort (it self-aborts against the aborted
1116
- * signal). Returns cancelled iff there was work. Queued loops stay enqueued.
1596
+ * signal). Returns cancelled iff there was process-local work; durable
1597
+ * unresolved loops in the worker tree are terminalized independently.
1117
1598
  */
1118
1599
  cancelDrain(workerId, reason = "user_cancelled") {
1119
1600
  const hadDrain = this.#activeDrains.has(workerId);
@@ -1135,12 +1616,12 @@ export default class Daemon {
1135
1616
  }
1136
1617
  // Does the worker have an in-flight stream (a background exec)? Used only for
1137
1618
  // loop.cancel's cancelled=true/false answer; the teardown itself rides the
1138
- // run signal. Duck-typed like #drainStreamingSchemes.
1619
+ // worker signal. Duck-typed like #drainStreamingSchemes.
1139
1620
  #workerHasActiveStreams(workerId) {
1140
1621
  const exec = this.#schemes.get("exec");
1141
1622
  return exec?.hasActiveSpawns?.(workerId) ?? false;
1142
1623
  }
1143
- // The contract-routed reap (§worker-lifecycle-total-reap): durable rows enumerate
1624
+ // The contract-routed reap ({§worker-lifecycle-total-reap}): durable rows enumerate
1144
1625
  // every open subscription; the live registry invokes its exact callable owner.
1145
1626
  // The worker signal is only the fast path. An exec mid-spawn or a background exec
1146
1627
  // from a past loop is caught regardless of listener timing. Idempotent — a stream
@@ -1156,29 +1637,27 @@ export default class Daemon {
1156
1637
  * loop needed. Otherwise we open a fresh loop with the synthetic
1157
1638
  * summary as the user prompt so the model gets a chance to react.
1158
1639
  *
1159
- * Skipped on closeStatus=499 (aborted): the model already knows about
1640
+ * Skipped on result.status=499 (aborted): the model already knows about
1160
1641
  * its own SEND[499], and a forcefully-cancelled loop's spawn-abort
1161
1642
  * shouldn't resurrect into a wake loop (defeats the cancel).
1162
- *
1163
- * Rummy parallel: plugins/stream/stream.js stream/completed wake:true.
1164
1643
  */
1165
1644
  async #handleWakeWorker(payload) {
1166
- // §search-gate — settle the dedup registration: promote on a 200 conclusion, drop on
1645
+ // {§search-gate} — settle the dedup registration: promote on a 200 conclusion, drop on
1167
1646
  // 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);
1647
+ this.#engine.searchGate.settle(payload.target.replace(/^[a-z+.-]+:\/\//, "/").replace(/^\/+/, "/"), payload.result.status);
1169
1648
  // Aborted streams don't wake — the abort was deliberate.
1170
- if (payload.closeStatus === 499) {
1649
+ if (payload.result.status === 499) {
1171
1650
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1172
1651
  ...payload, wakeAction: "skipped-aborted",
1173
1652
  });
1174
1653
  return;
1175
1654
  }
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 —
1655
+ // No resurrection ({§worker-lifecycle-no-resurrection}): a non-499 completion whose
1656
+ // worker was cancelled (idle + its scope aborted) must not start a fresh drain —
1178
1657
  // 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
1658
+ // surfaces as a `collect` environment delta ({§env-delta}) if the worker is read or
1659
+ // resumed; we just don't inject a turn. (An active worker folds the wake into its
1660
+ // next turn via inject below; a resumed worker is active, never aborted, so it is
1182
1661
  // unaffected.)
1183
1662
  const scope = this.#workerAborts.get(payload.workerId);
1184
1663
  if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.workerId)) {
@@ -1189,12 +1668,12 @@ export default class Daemon {
1189
1668
  }
1190
1669
  try {
1191
1670
  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
1671
+ // A slept (202) loop means the worker parked via SEND[202] → resume it in place: re-queue
1193
1672
  // it (202→100) so the drain re-claims and CONTINUES it (seq>1 → no re-foist). Checked
1194
1673
  // FIRST: the slept status is the worker's true disposition regardless of a draining
1195
1674
  // sibling mid-teardown (the #ensureDrain lock serializes the re-claim). No fresh loop,
1196
1675
  // no summary-as-prompt — the resumed loop reads the concluded stream's own state from
1197
- // the manifest. §worker-lifecycle-wake-liveness.
1676
+ // the manifest. {§worker-lifecycle-wake-liveness}.
1198
1677
  const slept = await this.#db.drain_find_slept_loop.get({ worker_id: payload.workerId });
1199
1678
  if (slept !== undefined) {
1200
1679
  await this.#lifecycle.wake(slept.id);
@@ -1211,7 +1690,7 @@ export default class Daemon {
1211
1690
  return;
1212
1691
  }
1213
1692
  // 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
1693
+ // environment-observation injector ({§exec-stream}) on its next turn — there is no prompt
1215
1694
  // to inject and NO task to overwrite. The obsolete "automated environment update"
1216
1695
  // synthesis (which clobbered the model's actual goal) is retired; just tell the client.
1217
1696
  if (this.#activeDrains.has(payload.workerId)) {
@@ -1220,7 +1699,7 @@ export default class Daemon {
1220
1699
  });
1221
1700
  return;
1222
1701
  }
1223
- // No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done run whose
1702
+ // No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done worker whose
1224
1703
  // streams were swept). Surface the conclusion without opening a loop.
1225
1704
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1226
1705
  ...payload, wakeAction: "no-loop",
@@ -1234,8 +1713,8 @@ export default class Daemon {
1234
1713
  * grammar 0.74.20 EXEC `<T,P>` — schedule a hibernation poll-wake. Called when a loop parks at
1235
1714
  * a park; if the worker holds an open polled stream, arm a timer for its tightest cadence P that
1236
1715
  * 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
1716
+ * poll work — ambient folded stream deltas already surface progress ({§exec-stream}); the wake
1717
+ * matters only across hibernation. A wake-edge-less 202 (no polled stream) gets no timer. {§exec-poll}
1239
1718
  */
1240
1719
  async #schedulePollWake(workspaceId, workerId, systemPrompt) {
1241
1720
  const existing = this.#pollTimers.get(workerId);
@@ -1249,14 +1728,8 @@ export default class Daemon {
1249
1728
  return;
1250
1729
  }
1251
1730
  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).
1731
+ // {§exec-poll} — a positive explicit cadence wins, zero opts out,
1732
+ // and an absent cadence uses the worker's exponential-backoff step.
1260
1733
  let delayMs;
1261
1734
  if (pollSec !== null && pollSec > 0) {
1262
1735
  this.#pollBackoff.delete(workerId);
@@ -1276,7 +1749,7 @@ export default class Daemon {
1276
1749
  this.#pollBackoff.set(workerId, step + 1);
1277
1750
  }
1278
1751
  // 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.
1752
+ // faster than a turn settles — {§exec-poll}.
1280
1753
  const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
1281
1754
  const timer = setTimeout(() => {
1282
1755
  this.#pollTimers.delete(workerId);
@@ -1286,10 +1759,10 @@ export default class Daemon {
1286
1759
  this.#pollTimers.set(workerId, timer);
1287
1760
  }
1288
1761
  /** 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
1762
+ * wake payload. The shared wake primitive: a poll cadence ({§exec-poll}), a watched stream concluding,
1763
+ * or a child worker finishing ({§worker-loop-lifecycle} topology join) all call this. A no-op if the worker was
1291
1764
  * cancelled or isn't actually parked (no slept loop) — so calling it speculatively is safe. */
1292
- async #wakeParkedWorker(workspaceId, workerId, systemPrompt) {
1765
+ async #wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive = true) {
1293
1766
  const scope = this.#workerAborts.get(workerId);
1294
1767
  if (scope?.signal.aborted === true && !this.#activeDrains.has(workerId))
1295
1768
  return; // cancelled — no resurrection
@@ -1297,9 +1770,9 @@ export default class Daemon {
1297
1770
  if (slept === undefined) {
1298
1771
  // Not parked. If a drain is still ACTIVE, the worker is mid-turn and about to park — the
1299
1772
  // 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.
1773
+ // race). Owe the wake: the drain honors it at park so a worker hibernation never deadlocks.
1301
1774
  // (No active drain → already concluded/running; nothing to wake.)
1302
- if (this.#activeDrains.has(workerId))
1775
+ if (oweIfActive && this.#activeDrains.has(workerId))
1303
1776
  this.#owedWakes.add(workerId);
1304
1777
  return;
1305
1778
  }
@@ -1313,9 +1786,9 @@ export default class Daemon {
1313
1786
  }
1314
1787
  /** A worker's drain exited. If the worker truly CONCLUDED — no 202-blocked loop, no open stream — then
1315
1788
  * 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
1789
+ * child finishing is the wake edge for a parent that waited on it, {§worker-lifecycle-child-wake}). A worker
1317
1790
  * 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
1791
+ * The parent reads the child's deliverable from its own log (the {§worker-scheme-collect} delta) on
1319
1792
  * resume — control edge here, never an injected prompt. Recurses up via the parent's own drain-exit. */
1320
1793
  async #onDrainExit(workspaceId, workerId, systemPrompt) {
1321
1794
  const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
@@ -1326,13 +1799,11 @@ export default class Daemon {
1326
1799
  return; // a stream still runs — its conclusion re-evaluates, not this exit
1327
1800
  const parent = await this.#db.worker_parent_id.get({ worker_id: workerId });
1328
1801
  if (parent?.parent_worker_id == null)
1329
- return; // a root run — nobody to wake
1802
+ return; // a root worker — nobody to wake
1330
1803
  await this.#wakeParkedWorker(workspaceId, parent.parent_worker_id, systemPrompt);
1331
1804
  }
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.
1805
+ // {§methods-event-subscribe} — subscriber failures are transport-local:
1806
+ // log them at this boundary and never re-enter engine control flow.
1336
1807
  #emitTo(workspaceId, method, params) {
1337
1808
  for (const sub of this.#eventSubscribers) {
1338
1809
  try {
@@ -1344,16 +1815,19 @@ export default class Daemon {
1344
1815
  }
1345
1816
  }
1346
1817
  #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);
1818
+ observedSync(// {§observability-boundary}
1819
+ "stream.broadcast", { method, ...(target === "all" ? {} : { "workspace.id": target.workspaceId }) }, () => {
1820
+ if (target === "all") {
1821
+ // {§notifications-envelope-carries-workspaceid}: global events carry workspaceId null.
1822
+ this.#emitTo(null, method, params);
1823
+ return;
1824
+ }
1825
+ // {§methods-event-subscribe}: publish to the in-process source; transport modules subscribe
1826
+ // here (plurnk-agui renders to AG-UI+). Each subscriber owns its own fan-out; core just emits.
1827
+ // Scope-stamping onto the notification envelope ({§notifications-envelope-carries-workspaceid})
1828
+ // is each subscriber's edge concern now — the seam hands (workspaceId, method, params) raw.
1829
+ this.#emitTo(target.workspaceId, method, params);
1830
+ });
1357
1831
  }
1358
1832
  }
1359
1833
  //# sourceMappingURL=Daemon.js.map