@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,14 +1,18 @@
1
- import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-grammar";
1
+ import { PlurnkParser, PlurnkParseError, UNKNOWN_POSITION } from "@plurnk/plurnk-contracts";
2
+ import { RuntimeTag } from "@plurnk/plurnk-execs";
2
3
  import Owner from "./Owner.js";
4
+ const TERMINAL_SEND_SIGNALS = new Set([102, 200, 202, 300, 499]);
5
+ const comparePosition = (a, b) => a.line - b.line || a.column - b.column;
3
6
  import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
7
+ import Meta from "@plurnk/plurnk-meta";
4
8
  import EntryCrud from "../schemes/_entry-crud.js";
5
- import EntryManifest from "../schemes/_entry-manifest.js";
9
+ import SearchIndex from "../schemes/_search-index.js";
6
10
  import { markTerminal } from "../schemes/Worker.js";
7
11
  import GitMembership from "./git-membership.js";
8
12
  import GitState from "./git-state.js";
9
13
  import WorkspaceSettings from "./workspace-settings.js";
10
14
  import { editedSpan } from "../content/index.js";
11
- import { promptPathname, promptLoopPrefix } from "./plurnk-uri.js";
15
+ import { promptPathname, promptLoopPrefix, renderTarget } from "./plurnk-uri.js";
12
16
  import { rulerCount } from "./token-ruler.js";
13
17
  import SearchGate from "./search-gate.js";
14
18
  import LiveSubscriptions from "./LiveSubscriptions.js";
@@ -19,28 +23,61 @@ import { setTimeout as delay } from "node:timers/promises";
19
23
  // Shared module imported by both Engine and bin/digest.ts, so wire
20
24
  // projection and digest projection are structurally one function — no
21
25
  // drift between wire and digest possible.
22
- // Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
23
26
  import PacketWire from "./packet-wire.js";
27
+ import Results, { OperationFailureError } from "./results.js";
28
+ import BranchReceipt from "./BranchReceipt.js";
29
+ import BudgetOverflow from "./BudgetOverflow.js";
30
+ import WorkerControlAddress from "./WorkerControlAddress.js";
31
+ import JournalTurn from "./JournalTurn.js";
24
32
  // The engine's collaborators — each owns one machine; Engine owns the loop/turn
25
33
  // lifecycle and wires them together as the public facade.
26
- import TelemetryChannel from "./TelemetryChannel.js";
34
+ import NoticeChannel from "./NoticeChannel.js";
35
+ import ProblemLog from "./ProblemLog.js";
27
36
  import StrikeRail from "./StrikeRail.js";
28
37
  import PacketBuilder from "./PacketBuilder.js";
38
+ import StoredPacket from "./StoredPacket.js";
29
39
  import ProposalLifecycle from "./ProposalLifecycle.js";
30
40
  import Dispatcher from "./Dispatcher.js";
41
+ import { observed, observedSync } from "../observe/spans.js";
42
+ import { OPS_DISPATCHED, PROVIDER_CALLS, recordCounter } from "../observe/metrics.js";
31
43
  import { scheduleTurnOps } from "./turn-scheduler.js";
32
44
  const DEFAULT_MAX_STRIKES = 3;
33
- // The foisted prompt EDIT/READ target — prompt:///<loop>/<N>, self-only ({§prompt-self-only}):
45
+ const ENGINE_PROBLEMS = Object.freeze({
46
+ max_commands_exceeded: {
47
+ status: 429,
48
+ code: "max-commands-exceeded",
49
+ detail: "Later operations were not executed because the turn exceeded its operation limit.",
50
+ },
51
+ idle_turn: {
52
+ status: 409,
53
+ code: "idle-turn",
54
+ detail: "SEND[102] was emitted without an operation to continue from.",
55
+ },
56
+ });
57
+ // The prompt entry target - prompt:///<loop>/<N>, self-only ({§prompt-self-only}):
34
58
  // the owner rides the owner_id column, the address carries only the loop coordinate.
35
- const promptTarget = (workerId, loopSeq, turnSeq) => {
59
+ const promptTarget = (loopSeq, turnSeq) => {
36
60
  const storage = promptPathname(loopSeq, turnSeq);
37
61
  return {
38
62
  kind: "url", raw: `prompt://${storage}`,
39
63
  scheme: "prompt", username: null, password: null,
40
64
  hostname: null, port: null,
41
- pathname: storage, params: {}, fragment: null,
65
+ pathname: storage, query: null, fragment: null,
42
66
  };
43
67
  };
68
+ const assertOpenPaths = (value, source) => {
69
+ if (!Array.isArray(value) || value.some((path) => typeof path !== "string" || path.length === 0)) {
70
+ throw new TypeError(`${source}: expected an array of non-empty strings`);
71
+ }
72
+ return value;
73
+ };
74
+ const parsePromptAttributes = (encoded, source) => {
75
+ const attributes = JSON.parse(encoded);
76
+ if (attributes === null || typeof attributes !== "object" || Array.isArray(attributes)) {
77
+ throw new TypeError(`${source}: expected a JSON object`);
78
+ }
79
+ return attributes;
80
+ };
44
81
  const readMaxStrikes = () => {
45
82
  const raw = process.env.PLURNK_SERVICE_MAX_STRIKES;
46
83
  if (raw === undefined || raw.length === 0)
@@ -50,10 +87,11 @@ const readMaxStrikes = () => {
50
87
  return DEFAULT_MAX_STRIKES;
51
88
  return n;
52
89
  };
53
- // Per-emission op ceiling — OFF by default. `-1` (or unset/non-positive) = no cap: every
54
- // generated op dispatches. Runaway degeneration is a sampler concern (repetition penalty),
90
+ // Per-emission action ceiling — OFF by default. `-1` (or unset/non-positive) = no cap:
91
+ // every generated op dispatches. Runaway degeneration is a sampler concern (repetition penalty),
55
92
  // not grounds to drop already-generated work. A positive value is an operator ceiling a
56
- // workspace's maxCommands may tighten (min wins), never widen (#232).
93
+ // workspace's maxCommands may tighten (min wins), never widen
94
+ // ({§operator-config-workspace-max-commands}).
57
95
  const readMaxCommands = () => {
58
96
  const raw = process.env.PLURNK_SERVICE_MAX_COMMANDS;
59
97
  if (raw === undefined || raw.length === 0)
@@ -63,8 +101,8 @@ const readMaxCommands = () => {
63
101
  return Number.POSITIVE_INFINITY;
64
102
  return n;
65
103
  };
66
- // PLURNK_SERVICE_FILES_ITEMS — the turn-0 manifest preview. null = off (no foist);
67
- // -1 = the full manifest; positive N = the first N items. 0 / unset = off.
104
+ // PLURNK_SERVICE_FILES_ITEMS — the turn-0 catalog preview. null = off;
105
+ // -1 = the complete one-level map; positive N caps its file rows. 0 / unset = off.
68
106
  const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
69
107
  const readFilesItems = () => {
70
108
  const raw = process.env.PLURNK_SERVICE_FILES_ITEMS;
@@ -72,16 +110,13 @@ const readFilesItems = () => {
72
110
  return null;
73
111
  return normalizeFilesItems(Number.parseInt(raw, 10));
74
112
  };
75
- import { ProviderError, scopeEnvToAlias, resolveActiveAlias } from "@plurnk/plurnk-providers";
113
+ import { ProviderError, providerCostFor, providerCostUsd, validateProviderCost, scopeEnvToAlias, resolveActiveAlias } from "@plurnk/plurnk-providers";
76
114
  import { validateGbnf } from "@plurnk/gbnf";
77
115
  import ProviderInstantiate from "./ProviderInstantiate.js";
78
- // Default turn.status when ops were emitted but no SEND. Model is implicitly
79
- // continuing; loop.status stays 102 either way (only SEND broadcast advances
80
- // loop terminal). No strike, no telemetry.
116
+ // Runtime normalization for a disposition the engine refuses or resolves as a
117
+ // continue after dispatch ({§send}). Every admitted emission itself ends in an
118
+ // explicit disposition SEND ({§emission-admission}).
81
119
  const TURN_STATUS_IMPLICIT_CONTINUE = 102;
82
- // Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
83
- // action routes through telemetry.errors[] (§telemetry, §telemetry-no-error-scheme — never an error:// scheme).
84
- const TURN_STATUS_NO_OPS = 422;
85
120
  const DEFAULT_MIN_CYCLES = 3;
86
121
  const DEFAULT_MAX_CYCLE_PERIOD = 4;
87
122
  const readPositiveInt = (envVar, fallback) => {
@@ -93,7 +128,15 @@ const readPositiveInt = (envVar, fallback) => {
93
128
  return fallback;
94
129
  return n;
95
130
  };
96
- // §operator-config-loop-timeout — the loop's wall-clock budget (PLURNK_SERVICE_LOOP_TIMEOUT).
131
+ const readEmissionAttempts = () => {
132
+ const raw = process.env.PLURNK_SERVICE_EMISSION_ATTEMPTS;
133
+ const value = Number.parseInt(raw ?? "", 10);
134
+ if (!Number.isInteger(value) || value < 1) {
135
+ throw new Error(`PLURNK_SERVICE_EMISSION_ATTEMPTS must be a positive integer; got ${raw}`);
136
+ }
137
+ return value;
138
+ };
139
+ // {§operator-config-loop-timeout} — the loop's wall-clock budget (PLURNK_SERVICE_LOOP_TIMEOUT).
97
140
  const DEFAULT_LOOP_TIMEOUT_MS = 86400000;
98
141
  const readLoopTimeoutMs = () => readPositiveInt("PLURNK_SERVICE_LOOP_TIMEOUT", DEFAULT_LOOP_TIMEOUT_MS);
99
142
  // The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
@@ -109,30 +152,26 @@ export default class Engine {
109
152
  #lifecycle;
110
153
  #schemes;
111
154
  #mimetypes;
112
- // Write-time tokenizer (SPEC §tokenomics). Synchronous per the provider
113
- // contract (§provider-surface). Populated from the active provider's countTokens via
114
- // the Daemon; a divisor tripwire stands in only for bare/standalone
115
- // construction before a provider is wired (same boot affordance as
116
- // Mimetypes, §mimetype-surface). Real counts come from provider.countTokens.
155
+ // {§tokenomics-agnostic-ruler} — the stable model-independent ruler used
156
+ // for write-time, catalog, receipt, and packet weights.
117
157
  #tokenize;
118
158
  // Boot-discovered runtime executors. Daemon builds + sets via
119
159
  // setExecutors at start(); undefined until then (and in bare tests).
120
160
  #executors;
121
- // §send-premature-terminate/[102]<T> — park deadlines by loopId, written at dispatch (the
161
+ // {§send-premature-terminate}/SEND[202]<T> — park deadlines by loopId, written at dispatch (the
122
162
  // marker's seconds; -1 = indefinite), consumed by the daemon's drain park-exit to schedule
123
163
  // the deadline wake. In-memory: a daemon restart drops pending deadlines (documented).
124
164
  parkDeadlines = new Map();
125
- // §join-blocking-collect (#354) — loops whose turn issued a READ(worker://running-child): the
126
- // blocking join. The dispatcher arms this on the READ; the turn's bare SEND[102] parks on it
127
- // (indefinite) instead of continuing, and any SEND clears it. Twin of parkDeadlines.
165
+ // Per-turn running-worker READ obligations. {§join-blocking-collect}
128
166
  joinTargets = new Set();
129
167
  // The collaborators. Engine constructs them (they share its deps via
130
168
  // thunks where the value is late-injected — executors, loop signals)
131
169
  // and fronts their public surface.
132
- #telemetry;
170
+ #notices;
171
+ #problems;
133
172
  #strikes;
134
- // §grinder-hard-413-recovery — loops granted their ONE over-ceiling recovery turn. Cleared on a
135
- // fitting turn (the model curated; a LATER overflow earns a fresh recovery) and at loop cleanup.
173
+ // {§grinder-hard-413-recovery} - loops granted their one over-ceiling recovery turn. Cleared on a
174
+ // fitting turn so a later independent overflow can earn a fresh recovery, and at loop cleanup.
136
175
  #hardOverflowRecovery = new Set();
137
176
  #packets;
138
177
  searchGate = new SearchGate();
@@ -145,9 +184,10 @@ export default class Engine {
145
184
  // Streaming schemes (exec) chain their per-spawn controllers off
146
185
  // ctx.signal so cancelled loops tear down their background spawns.
147
186
  #loopAborts = new Map();
148
- // §send-premature-terminate — loops owed one idle-grace turn after a retrieval-only 409
149
- // (the steer's own advice is to wait; in-memory, fail-open on restart).
150
- #retrievalRefusalGrace = new Set();
187
+ // {§prompt-loop-containment}: one worker's prompt-frame allocation and
188
+ // persistence is a serial critical section. A completed later frame can
189
+ // therefore never overtake or replace an earlier concurrent arrival.
190
+ #promptWriteLocks = new Map();
151
191
  // One coalesced warm per workspace. Creation/membership changes start it as soon
152
192
  // as content exists; the first model turn joins it, so no operation observes
153
193
  // partial graph/vector coverage. A request arriving mid-pass marks the workspace
@@ -169,13 +209,13 @@ export default class Engine {
169
209
  return Promise.resolve();
170
210
  }
171
211
  const state = { dirty: false, materialize, ctx, promise: Promise.resolve() };
172
- // Register before publishing the first synchronous telemetry event. A
212
+ // Register before publishing the first synchronous Notice. A
173
213
  // listener may request another warm from that callback; it must join
174
214
  // this state rather than opening a second pump in the re-entrant gap.
175
215
  this.#workspaceWarms.set(workspaceId, state);
176
216
  const publish = (current, status) => {
177
217
  this.#workspaceWarmStatus.set(workspaceId, status);
178
- current.pushTelemetry?.({
218
+ current.pushNotice?.({
179
219
  source: "engine:derivation", kind: "embed_progress", ...status,
180
220
  });
181
221
  };
@@ -193,23 +233,23 @@ export default class Engine {
193
233
  try {
194
234
  if (shouldMaterialize)
195
235
  await GitMembership.indexGitMembership(current);
196
- await EntryManifest.maintainDerivations({
236
+ await SearchIndex.maintain({
197
237
  ...current,
198
- pushTelemetry: (event) => {
199
- if (event.kind === "embed_progress"
200
- && typeof event.completed === "number"
201
- && typeof event.total === "number"
202
- && typeof event.percent === "number") {
238
+ pushNotice: (notice) => {
239
+ if (notice.kind === "embed_progress"
240
+ && typeof notice.completed === "number"
241
+ && typeof notice.total === "number"
242
+ && typeof notice.percent === "number") {
203
243
  this.#workspaceWarmStatus.set(workspaceId, {
204
244
  phase: "indexing",
205
- completed: event.completed,
206
- total: event.total,
207
- percent: event.percent,
208
- message: event.message ?? "Indexing repository semantics",
209
- level: event.level === "error" ? "error" : "info",
245
+ completed: notice.completed,
246
+ total: notice.total,
247
+ percent: notice.percent,
248
+ message: notice.message ?? "Indexing repository semantics",
249
+ level: notice.level === "error" ? "error" : "info",
210
250
  });
211
251
  }
212
- current.pushTelemetry?.(event);
252
+ current.pushNotice?.(notice);
213
253
  },
214
254
  });
215
255
  }
@@ -241,10 +281,15 @@ export default class Engine {
241
281
  async drainDerivations() {
242
282
  await Promise.all([...this.#workspaceWarms.values()].map((state) => state.promise));
243
283
  }
284
+ async drainWorkspaceDerivations(workspaceId) {
285
+ await this.#workspaceWarms.get(workspaceId)?.promise;
286
+ }
244
287
  #streamEventNotify;
245
288
  #wakeWorkerNotify;
246
- // Cached plurnk GBNF — read once on the first constrained generate (#189).
247
- #gbnfCache = new Map(); // variant name -> GBNF text (per-alias selection, #353)
289
+ #acquireWorkspaceTurn;
290
+ #workspaceTurnCompleted;
291
+ // Configured grammar text is cached by variant after its first load.
292
+ #gbnfCache = new Map();
248
293
  // {§rail-truth-engine-verdict} — the verify GAP (a configured grammar @plurnk/gbnf can't
249
294
  // parse): warn once per message, never per turn; the turn records railsVerdict "unverifiable".
250
295
  static #railGapWarned = new Set();
@@ -254,42 +299,52 @@ export default class Engine {
254
299
  Engine.#railGapWarned.add(message);
255
300
  process.stderr.write(`plurnk-engine: rail verdict unavailable — the configured grammar did not parse in @plurnk/gbnf (${message})\n`);
256
301
  }
257
- constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker, cancelDescendants, telemetryEventNotify, tokenize }) {
302
+ static #requireGrammarEvidence(response) {
303
+ const evidence = response.grammarEvidence;
304
+ if (evidence === undefined) {
305
+ throw new Error("provider contract violation: configured GBNF response omitted grammar evidence");
306
+ }
307
+ const input = [...evidence.input];
308
+ if (!Number.isInteger(evidence.contentStart)
309
+ || evidence.contentStart < 0
310
+ || evidence.contentStart > input.length
311
+ || typeof evidence.transported !== "boolean"
312
+ || input.slice(evidence.contentStart).join("") !== response.assistant.content) {
313
+ throw new Error("provider contract violation: grammar evidence does not map exactly to assistant.content");
314
+ }
315
+ return evidence;
316
+ }
317
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants, acquireWorkspaceTurn, workspaceTurnCompleted, noticeNotify, tokenize }) {
258
318
  this.#db = db;
259
319
  this.#lifecycle = new LoopLifecycle(db);
260
320
  this.#schemes = schemes;
261
321
  this.#streamEventNotify = streamEventNotify;
262
322
  this.#wakeWorkerNotify = wakeWorkerNotify;
323
+ this.#acquireWorkspaceTurn = acquireWorkspaceTurn ?? (async () => () => { });
324
+ this.#workspaceTurnCompleted = workspaceTurnCompleted;
263
325
  // Default to empty discovery — standalone Engine construction (in
264
326
  // tests) gets no handlers, and content flows through the framework's
265
327
  // raw-content fitContent fallback. Daemon-managed Engine receives a
266
328
  // production-configured Mimetypes via the constructor arg.
267
329
  this.#mimetypes = mimetypes ?? new Mimetypes({
268
- discovery: { registry: emptyRegistry(), handlers: new Map() },
330
+ discovery: { registry: emptyRegistry(), handlers: new Map(), skipped: [] },
269
331
  });
270
- // Tripwire default matches the Mimetypes boot affordance (SPEC §mimetype-surface):
271
- // the divisor stands in only until the provider-backed tokenizer is
272
- // wired by the Daemon. Real counts come from provider.countTokens.
332
+ // {§tokenomics-agnostic-ruler} — standalone construction and the daemon
333
+ // use the same default; provider counting is confined to physical admission.
273
334
  this.#tokenize = tokenize ?? rulerCount;
274
335
  const executors = () => this.#executors;
275
336
  const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
276
- this.#telemetry = new TelemetryChannel({ db, notify: telemetryEventNotify });
277
- schemes.bindCore({
337
+ this.#notices = new NoticeChannel({ notify: noticeNotify });
338
+ this.#problems = new ProblemLog(db);
339
+ this.#strikes = new StrikeRail();
340
+ this.#packets = new PacketBuilder({
278
341
  db,
279
- mimetypes: this.#mimetypes,
342
+ schemes,
343
+ problems: this.#problems,
280
344
  executors,
281
- tokenize: this.#tokenize,
282
- streamEventNotify,
283
- wakeWorkerNotify,
284
- injectWorker,
285
- pushTelemetry: (workspaceId, loopId, event) => this.#telemetry.push(workspaceId, loopId, event),
286
- defaultChannelFor: (scheme) => schemes.defaultChannelFor(scheme),
287
- liveSubscriptions: this.#liveSubscriptions,
288
345
  });
289
- this.#strikes = new StrikeRail();
290
- this.#packets = new PacketBuilder({ db, schemes, telemetry: this.#telemetry, executors });
291
346
  this.#proposals = new ProposalLifecycle({
292
- db, schemes, telemetry: this.#telemetry,
347
+ db, schemes, notices: this.#notices,
293
348
  streamEventNotify, wakeWorkerNotify,
294
349
  tokenize: this.#tokenize, mimetypes: this.#mimetypes, executors, loopSignal,
295
350
  liveSubscriptions: this.#liveSubscriptions,
@@ -297,52 +352,52 @@ export default class Engine {
297
352
  this.#dispatcher = new Dispatcher({ searchGate: this.searchGate,
298
353
  db, schemes, mimetypes: this.#mimetypes,
299
354
  tokenize: this.#tokenize,
300
- telemetry: this.#telemetry, proposals: this.#proposals,
355
+ notices: this.#notices, proposals: this.#proposals,
301
356
  executors, loopSignal,
302
- streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker, cancelDescendants,
357
+ streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants,
303
358
  parkDeadlines: this.parkDeadlines,
304
359
  joinTargets: this.joinTargets,
305
360
  liveSubscriptions: this.#liveSubscriptions,
306
361
  });
362
+ schemes.bindCore({
363
+ db,
364
+ mimetypes: this.#mimetypes,
365
+ executors,
366
+ tokenize: this.#tokenize,
367
+ streamEventNotify,
368
+ wakeWorkerNotify,
369
+ injectWorker,
370
+ pushNotice: (workspaceId, loopId, notice) => this.#notices.push(workspaceId, loopId, notice),
371
+ defaultChannelFor: (scheme) => schemes.defaultChannelFor(scheme),
372
+ readExecSource: (statement, ctx) => this.#dispatcher.readExecSource(statement, ctx),
373
+ liveSubscriptions: this.#liveSubscriptions,
374
+ });
307
375
  }
308
376
  // Late injection: the executor registry is async-built at daemon start()
309
377
  // (discover + probe), after Engine construction.
310
378
  setExecutors(executors) {
311
379
  this.#executors = executors;
312
380
  }
313
- // Runtime hotload (#289) — register an executor TAG live, after boot (the /mcp route: an MCP
314
- // server connected at runtime becomes EXEC[<server>]). Registers on BOTH registries the boot path
315
- // wires: the ExecutorRegistry (dispatch resolves the tag; the tools sheet, rebuilt per packet, then
316
- // offers it to the model) and the SchemeRegistry face (the tag's READ/FIND/KILL scheme), sharing the
317
- // same reserved/cross-family arbitration boot uses. Fail-hard if the registry isn't wired yet — a
318
- // hotload before daemon start() is a caller bug, not a silent no-op.
319
- hotloadRuntime(tag, entry) {
381
+ // Register a module-owned runtime on the same two registries as boot discovery.
382
+ // An optional same-name scheme handler lets one capability own both execution
383
+ // and addressable state without teaching core its protocol.
384
+ registerRuntime(tag, entry, scheme) {
320
385
  if (this.#executors === undefined)
321
- throw new Error("hotloadRuntime: executor registry not wired yet (call after daemon start)");
322
- // Scheme face FIRST — it is the arbitration gate (reserved / cross-family collision, #240) and
323
- // throws before we mutate the executor registry, so a rejected tag leaves neither registry
324
- // half-written. A brand-new tag registers on both; a reserved/claimed tag throws here untouched.
325
- this.#schemes.registerRuntimeScheme(tag, entry.executor);
386
+ throw new Error("registerRuntime: executor registry not wired yet");
387
+ RuntimeTag.assert(tag, "module runtime");
388
+ // Preflight both owners before either write; synchronous registration
389
+ // then cannot leave a half-claimed namespace. {§plugin-namespace-arbitration}
390
+ this.#executors.assertCanRegister(tag, entry.namespaceOwner);
391
+ this.#schemes.assertRuntimeClaim(tag, entry.namespaceOwner);
392
+ this.#schemes.registerRuntimeScheme(tag, entry.executor, entry.namespaceOwner, scheme);
326
393
  this.#executors.register(tag, entry);
327
394
  }
328
- // Optional local-model constrained sampling (#189). The PLURNK language is
329
- // always parsed by ANTLR; this separately supplies a GBNF artifact to a
330
- // backend that explicitly supports constrained sampling.
395
+ // Supply an explicitly configured local constraint; ANTLR remains the
396
+ // language authority. {§grammar-enforcement-verified-at-boot}
331
397
  async #grammarConstraint(provider) {
332
- // PLURNK_PROVIDERS_GBNF SELECTS the GBNF variant to constrain sampling to (#225):
333
- // a bare name (`plurnk-strict.gbnf` | `plurnk.gbnf`) is a variant shipped by
334
- // @plurnk/plurnk-grammar; an absolute/relative path is a BYO grammar. Empty or "0"
335
- // disables the optional constraint.
336
- //
337
- // PER ALIAS (#353): resolved PLURNK_PROVIDERS_GBNF_<alias> over the bare fallback (providers'
338
- // scopeEnvToAlias), scoped by the alias that built this provider. GBNF only helps backends
339
- // that constrain sampling (llama-server). The bare default is unset and
340
- // local-model aliases opt in via a PLURNK_PROVIDERS_GBNF_<alias> suffix.
341
- // #488 — the rail must be VERIFIABLE, never silently off. Two guards:
342
- // (1) the alias fallback is only trusted when NO per-alias GBNF opt-ins exist: an
343
- // unregistered provider in a process that configured suffixed rails could fall back
344
- // to a DIFFERENT active alias, miss the suffix, and run unconstrained — the silent
345
- // severance class run78 demonstrated (free decode, fabricated logs, CLEAN telemetry).
398
+ // Resolve through the registered or active alias; ambiguity and load
399
+ // failures never degrade to unconstrained generation.
400
+ // {§grammar-enforcement-verified-at-boot}
346
401
  const registered = ProviderInstantiate.aliasOf(provider);
347
402
  const fallback = registered === undefined ? resolveActiveAlias(process.env)?.alias : undefined;
348
403
  if (registered === undefined && fallback === undefined && Object.keys(process.env).some((k) => k.startsWith("PLURNK_PROVIDERS_GBNF_"))) {
@@ -357,52 +412,61 @@ export default class Engine {
357
412
  return hit;
358
413
  const path = variant.startsWith("/") || variant.startsWith(".")
359
414
  ? variant
360
- : fileURLToPath(import.meta.resolve(`@plurnk/plurnk-grammar/${variant}`));
415
+ : fileURLToPath(import.meta.resolve(`@plurnk/plurnk-contracts/${variant}`));
361
416
  const text = await readFile(path, "utf8"); // unresolvable/unreadable throws — a configured rail never silently degrades
362
417
  this.#gbnfCache.set(variant, text);
363
418
  process.stderr.write(`plurnk-engine: GBNF constraint: ${alias || "(bare)"} → ${variant} (${text.length} chars)\n`);
364
419
  return text;
365
420
  }
366
- // Per-loop usage totals (#197): SUM the loop's turns (usage is stored per
367
- // turn, §tokenomics). Surfaced on loop.run + loop/terminated so clients render real
368
- // token/cost numbers. costUsd is the stored USD unit.
369
- // #345 — the client-facing budget denominator, ONE meaning on every surface: the prompt
370
- // budget the packet actually lives under (effective window minus the partition reserves),
371
- // the same number loop-usage stores per turn. providers.list advertised the raw KV and the
372
- // client's gauge rendered a window the model can never fill.
373
- // #522 — the PRIMARY worker of a turn's lineage: the no-parent root reached by walking
374
- // parent_worker_id up. A no-parent worker is its OWN primary (stamped on the primary's own
375
- // turns). Supplied on the first-party metadata channel alongside Worker-Id; the endpoint routes
376
- // primary→strong / spawned→cheap by `Worker-Primary == Worker-Id` equality. Fail-hard if a
377
- // worker resolves to no root — that is a corrupt lineage (a cycle the parent!=id CHECK forbids),
378
- // never a silent "assume primary."
421
+ // A lineage's no-parent root; a root worker resolves to itself. Fail hard
422
+ // when corruption leaves a worker without one. {§worker-primary}
379
423
  async resolveWorkerPrimary(workerId) {
380
424
  const root = await this.#db.engine_worker_lineage_root.get({ worker_id: workerId });
381
425
  if (root === undefined)
382
- throw new Error(`resolveWorkerPrimary: worker ${workerId} has no lineage root — corrupt parent chain (#522)`);
426
+ throw new Error(`resolveWorkerPrimary: worker ${workerId} has no lineage root — corrupt parent chain`);
383
427
  return root.id;
384
428
  }
385
429
  promptBudgetFor(provider) {
386
430
  return this.#packets.promptBudgetFor(provider);
387
431
  }
432
+ async #attemptAttributions(provider, context) {
433
+ const tags = Meta.composeAttributions(this.#schemes.attributions(context), this.#executors?.attributions(context) ?? [], await this.#mimetypes.attributions(context), provider.attributions?.(context) ?? []);
434
+ return [...tags];
435
+ }
436
+ // {§attribution} — reporting derives from exact provider-request evidence;
437
+ // malformed durable tags fail here instead of being silently filtered.
438
+ async loopAttributions(loopId) {
439
+ const rows = await this.#db.engine_loop_attributions.all({ loop_id: loopId });
440
+ const tags = rows.map(({ attribution }, index) => {
441
+ if (typeof attribution !== "string" || attribution.length === 0) {
442
+ throw new TypeError(`loop ${loopId} attribution row ${index} is not a non-empty string`);
443
+ }
444
+ return attribution;
445
+ });
446
+ return [...Meta.composeAttributions(tags)];
447
+ }
448
+ // Loop totals are billing evidence; the latest-turn pair is the client
449
+ // occupancy gauge. {§tokenomics-client-gauge}, {§notifications-loop-terminated}
388
450
  async loopUsage(loopId) {
389
451
  const row = await this.#db.engine_loop_usage.get({ loop_id: loopId });
452
+ const parsedCosts = JSON.parse(row?.costs ?? "[]");
453
+ if (!Array.isArray(parsedCosts))
454
+ throw new TypeError(`loop ${loopId} monetary evidence is not an array`);
390
455
  return {
391
456
  promptTokens: row?.prompt ?? 0,
392
457
  completionTokens: row?.completion ?? 0,
393
- costUsd: row?.cost_usd ?? 0,
394
- // #263 — the last turn's prompt tokens = current window occupancy (gauge numerator), NOT the
395
- // summed promptTokens above, which overcounts a context that grows across turns.
458
+ costUsd: row?.cost_usd ?? null,
459
+ costs: parsedCosts.map((cost) => validateProviderCost(cost)),
460
+ // Latest provider attempt on the latest turn, not the billed total.
396
461
  contextTokens: row?.context ?? 0,
397
- // #274 — the last turn's model window (denominator); null when the provider reports none.
462
+ // Latest effective packet allowance; null when uncapped or unknown.
398
463
  promptBudget: row?.context_size ?? null,
399
- // #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
400
- // provider returned no meta. The service forwards it; it never reads a field within.
464
+ // Latest turn's opaque provider metadata. {§meta-passthrough}
401
465
  meta: JSON.parse(row?.meta ?? "{}"),
402
466
  };
403
467
  }
404
- // A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
405
- // model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
468
+ // A mapped rail divergence is a CODE-POINT offset into the model's content;
469
+ // the snippet/notices surface speaks the parser-point convention. {§parser-position}
406
470
  // Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
407
471
  // clamping out-of-range offsets to the content's end.
408
472
  #offsetToLineColumn(content, offset) {
@@ -426,7 +490,6 @@ export default class Engine {
426
490
  // Its ceiling therefore counts every prior turn, not merely this process-local
427
491
  // execution segment.
428
492
  const turnIds = await this.#lifecycle.turnIds(loopId);
429
- const suddenDeathThreshold = maxTurns - maxStrikes;
430
493
  // Per-loop AbortController for scheme-side cancellation propagation.
431
494
  // Chained from the caller's `signal` so an external abort cascades.
432
495
  const loopAbort = new AbortController();
@@ -437,23 +500,24 @@ export default class Engine {
437
500
  signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
438
501
  }
439
502
  this.#loopAborts.set(loopId, loopAbort);
440
- // §operator-config-loop-timeout — the wall-clock budget. Expiry aborts the loop signal, so a
503
+ // {§operator-config-loop-timeout} — the wall-clock budget. Expiry aborts the loop signal, so a
441
504
  // mid-flight provider call (generate rides this signal) and in-flight spawns tear down; the
442
- // loop terminates 504 (kin to the exec <T> reap's 504, §exec-timeout) — a legible engine
505
+ // loop terminates 504 (kin to the exec <T> reap's 504, {§exec-timeout}) — a legible engine
443
506
  // terminal, never an outside kill. unref'd: the wall never holds the process open.
444
507
  const wall = setTimeout(() => loopAbort.abort(LOOP_TIMEOUT_REASON), readLoopTimeoutMs());
445
508
  wall.unref();
446
509
  const timedOut = () => loopAbort.signal.aborted && loopAbort.signal.reason === LOOP_TIMEOUT_REASON;
447
510
  const ruleTimeout = async () => {
448
- // {§loop-terminals} — EVERY abandonment names itself (#555; the full terminal
449
- // enumeration, not the two first found): live fan-out before cleanup wipes the buffer.
450
- this.#telemetry.push(workspaceId, loopId, {
451
- source: "engine:rails", kind: "loop_timeout", level: "error",
452
- message: `loop abandoned 504: the loop wall expired after ${turnIds.length} turns`,
511
+ const failure = Results.failure("engine:rails", "loop-timeout", 504, `The loop exceeded its wall-clock deadline after ${turnIds.length} turns.`, {}, {
512
+ turns: turnIds.length,
513
+ stage: "loop",
514
+ retryable: false,
453
515
  });
454
- await this.#lifecycle.finish(loopId, 504, "loop_timeout");
516
+ const result = await this.#lifecycle.finish(loopId, failure);
517
+ if (result === null)
518
+ throw new Error(`loop ${loopId} became terminal before timeout settlement`);
455
519
  cleanup("forceful", "loop_timeout");
456
- return { turnIds, finalStatus: 504, hitMaxTurns: false, reason: "loop_timeout" };
520
+ return { turnIds, result, hitMaxTurns: false, reason: "loop_timeout" };
457
521
  };
458
522
  // Cleanup splits by termination kind:
459
523
  // - "graceful" (SEND[202] Accepted): in-flight streaming-scheme spawns
@@ -471,23 +535,19 @@ export default class Engine {
471
535
  this.#strikes.delete(loopId);
472
536
  this.searchGate.cleanup(loopId);
473
537
  this.#hardOverflowRecovery.delete(loopId);
474
- this.#telemetry.delete(loopId);
538
+ this.#notices.delete(loopId);
475
539
  };
476
540
  while (true) {
477
- // The wall fired between turns — rule 504 before anything else reads the loop.
478
- if (timedOut())
479
- return await ruleTimeout();
480
- signal?.throwIfAborted();
481
541
  const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
482
542
  if (row === undefined)
483
543
  throw new Error(`Engine.runLoop: loop ${loopId} not found`);
484
544
  if (row.status === 100) {
485
545
  // NOT a terminal — a wake re-queued this loop while its own live drain was
486
546
  // between turns (a child concluded in the gap between our 202 write and this
487
- // check, §worker-lifecycle-wake-requeue-not-terminal). The wake's intent is KEEP
547
+ // check, {§worker-lifecycle-wake-requeue-not-terminal}). The wake's intent is KEEP
488
548
  // RUNNING: re-claim atomically and continue — the injected prompt is already
489
549
  // this loop's next turn. Returning it as "external" broadcast a QUEUED loop
490
- // as loop/terminated {finalStatus: 100} — the delegation-flags flake.
550
+ // as a terminal result with status 100 — the delegation-flags flake.
491
551
  await this.#db.engine_reclaim_queued_loop.run({ loop_id: loopId });
492
552
  continue; // claimed (or a racer flipped it first — the re-read decides)
493
553
  }
@@ -496,34 +556,42 @@ export default class Engine {
496
556
  // contract (E.4). Every other terminal, 200 included, reaps: "done"
497
557
  // must not leak running execs. Trust the code's declared intent.
498
558
  cleanup(row.status === 202 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
499
- return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
559
+ if (row.status === 202) {
560
+ return { turnIds, result: { status: 202 }, hitMaxTurns: false, reason: "external" };
561
+ }
562
+ const result = await this.#lifecycle.result(loopId);
563
+ if (result === null) {
564
+ throw new Error(`terminal loop ${loopId} status ${row.status} has no operation result`);
565
+ }
566
+ return { turnIds, result, hitMaxTurns: false, reason: "external" };
500
567
  }
568
+ // Durable disposition outranks a later process-local cancellation observation.
569
+ // SEND may commit 202 immediately before daemon shutdown aborts this drain; reading
570
+ // the abort first launders that lawful park into 499 under load. Only a still-running
571
+ // 102 loop can be cancelled or time out at this boundary.
572
+ if (timedOut())
573
+ return await ruleTimeout();
574
+ signal?.throwIfAborted();
501
575
  if (maxTurns >= 0 && turnIds.length >= maxTurns) {
502
- // §loop-terminals — the turn ceiling is exhausted: 429 Too Many Requests
503
- // (kin to the soft sudden-death 429 warnings that precede it).
504
- // {§loop-terminals} — even the EXPECTED ceiling names itself (warn, not error:
505
- // the client configured maxTurns; hitting it is a bound, not a malfunction).
506
- this.#telemetry.push(workspaceId, loopId, {
507
- source: "engine:rails", kind: "max_turns", level: "warn",
508
- message: `loop ended 429: the configured turn ceiling (${maxTurns}) is exhausted`,
576
+ const failure = Results.failure("engine:rails", "max-turns", 429, `The configured turn ceiling (${maxTurns}) is exhausted.`, {}, {
577
+ maximumTurns: maxTurns,
578
+ stage: "loop",
579
+ retryable: false,
509
580
  });
510
- await this.#lifecycle.finish(loopId, 429, "max_turns");
581
+ const result = await this.#lifecycle.finish(loopId, failure);
582
+ if (result === null)
583
+ throw new Error(`loop ${loopId} became terminal before max-turn settlement`);
511
584
  cleanup("forceful", "max_turns");
512
- return { turnIds, finalStatus: 429, hitMaxTurns: true, reason: "max_turns" };
585
+ return { turnIds, result, hitMaxTurns: true, reason: "max_turns" };
513
586
  }
514
587
  // PLURNK_SERVICE_EXEC_WAIT_MS — a post-EXEC breath: if a spawn from the prior turn
515
588
  // is still in flight, give it a tunable beat to land in THIS turn's packet
516
589
  // before we assemble it. A fixed grace beat, never a wait-for-completion;
517
590
  // 0/unset = off. Abortable with the loop signal.
518
591
  const execHandler = this.#schemes.get("exec");
519
- // §exec-hold-until-concluded — the turn-hold exception (owner ruling): for runtimes in
520
- // the operator's HOLD set (the search family — streams we know and control: one final
521
- // JSON digest, seconds-bounded), the cycle PAUSES here until the stream concludes, so
522
- // the model never gets a turn it can only waste asking "are we there yet". Bounded by
523
- // PLURNK_SERVICE_EXEC_HOLD_MS and FAIL-OPEN: at the cap the standard cycle resumes
524
- // (the stream stays live; parks/wakes/polls all still apply). Zero grammar or teaching
525
- // change — the model emits EXEC + SEND[102] as ever; the next packet simply contains
526
- // the finished digest, open and final.
592
+ // {§exec-hold-until-concluded} — hold matching runtime/effect
593
+ // streams until conclusion or the fail-open cap, then resume the
594
+ // ordinary cycle without altering stream state.
527
595
  const holdSet = new Set((process.env.PLURNK_SERVICE_EXEC_HOLD ?? "").split(",").map((x) => x.trim()).filter((x) => x.length > 0));
528
596
  const holdCapMs = Number(process.env.PLURNK_SERVICE_EXEC_HOLD_MS ?? "300000");
529
597
  if (holdSet.size > 0 && holdCapMs > 0 && execHandler?.hasActiveHoldSpawns !== undefined) {
@@ -538,10 +606,22 @@ export default class Engine {
538
606
  await delay(execWaitMs, undefined, { signal });
539
607
  }
540
608
  let turn;
609
+ const releaseWorkspace = await this.#acquireWorkspaceTurn(workspaceId, workerId);
541
610
  try {
542
- turn = await this.runTurn({
543
- provider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
544
- turnNumber: turnIds.length + 1, maxTurns,
611
+ turn = await observed(// {§observability-boundary}
612
+ "loop.turn", { workerId, "loop.id": loopId }, async (span) => {
613
+ const t = await this.runTurn({
614
+ provider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
615
+ turnNumber: turnIds.length + 1, maxTurns,
616
+ });
617
+ span.setAttribute("turn.id", t.turnId);
618
+ return t;
619
+ });
620
+ await this.#workspaceTurnCompleted?.({
621
+ workspaceId,
622
+ workerId,
623
+ loopId,
624
+ turnId: turn.turnId,
545
625
  });
546
626
  }
547
627
  catch (err) {
@@ -551,54 +631,63 @@ export default class Engine {
551
631
  return await ruleTimeout();
552
632
  throw err;
553
633
  }
634
+ finally {
635
+ releaseWorkspace();
636
+ }
554
637
  turnIds.push(turn.turnId);
555
- // SPEC §grinder: budget hard-stop — packet won't fit even collapsed → abandon.
556
- if (turn.budgetHardStop) {
557
- // §loop-terminals — the packet won't fit even collapsed: 413 Content Too Large.
558
- // Same silent-terminal class as strike_threshold ({§loop-terminals}, #555): name it.
559
- this.#telemetry.push(workspaceId, loopId, {
560
- source: "engine:rails", kind: "budget_overflow", level: "error",
561
- message: `loop abandoned 413: the packet exceeds the budget even after the newest turn folded — unrecoverable overflow after ${turnIds.length} turns`,
638
+ // Invalid provider emissions are retried beneath this turn and never
639
+ // reach the strike rail. Exhausting that inner attempt budget is a
640
+ // terminal generation failure, not one of the engine's three strikes.
641
+ if (turn.emissionExhausted) {
642
+ const failure = Results.failure("engine:generation", "invalid-emission-exhausted", 500, `No valid PLAN...SEND turn was received after ${turn.emissionAttempts} emission attempts.`, {}, {
643
+ attempts: turn.emissionAttempts,
644
+ stage: "emission-validation",
645
+ retryable: false,
562
646
  });
563
- await this.#lifecycle.finish(loopId, 413, "budget_overflow");
647
+ const result = await this.#lifecycle.finish(loopId, failure);
648
+ if (result === null)
649
+ throw new Error(`loop ${loopId} became terminal before invalid-emission settlement`);
650
+ cleanup("forceful", "invalid_emission");
651
+ return { turnIds, result, hitMaxTurns: false, reason: "invalid_emission" };
652
+ }
653
+ // SPEC {§grinder}: budget hard-stop — packet won't fit even collapsed → abandon.
654
+ if (turn.budgetHardStop) {
655
+ if (turn.budget === undefined) {
656
+ throw new Error("a budget hard-stop requires its measured overflow");
657
+ }
658
+ const failure = BudgetOverflow.result(turn.budget.usage, turn.budget.ceiling, false);
659
+ const result = await this.#lifecycle.finish(loopId, failure);
660
+ if (result === null)
661
+ throw new Error(`loop ${loopId} became terminal before budget settlement`);
564
662
  cleanup("forceful", "budget_overflow");
565
- return { turnIds, finalStatus: 413, hitMaxTurns: false, reason: "budget_overflow" };
663
+ return { turnIds, result, hitMaxTurns: false, reason: "budget_overflow" };
566
664
  }
567
- // Rails #38/#39 — per-turn strike accounting (cycle detection, the
665
+ // {§engine-rails} — per-turn strike accounting (cycle detection, the
568
666
  // grinder/steer coupling, hard-failure statuses). StrikeRail owns the
569
667
  // bookkeeping; runLoop owns abandonment.
570
668
  const verdict = this.#strikes.assess(loopId, {
571
669
  fingerprint: turn.fingerprint,
572
670
  statuses: turn.statuses,
573
- noOps: turn.status === TURN_STATUS_NO_OPS,
574
671
  budgetStruck: turn.budgetStruck,
575
672
  steerStruck: turn.steerStruck,
576
673
  minCycles, maxCyclePeriod, maxStrikes,
577
674
  });
578
675
  if (verdict.thresholdCrossed) {
579
- // §loop-terminals — a cycle-driven strike is the model spinning in place
580
- // (508 Loop Detected); a failure/no-op strike is the model failing (500
581
- // Internal Server Error). The straw that crossed the threshold picks it.
676
+ // {§engine-rails} — the source on the crossing turn classifies
677
+ // the engine verdict: cycle-driven is 508; every other strike is 500.
582
678
  const status = verdict.cycleDetected ? 508 : 500;
583
- // {§loop-terminals} — the abandonment NAMES ITSELF (#506 promise; run60/#555): a
584
- // deliberate strike terminal returns a clean finalStatus, so the drain's loop_error
585
- // catch never sees it and the death would be silent on every channel. Emit a legible
586
- // error event — live fan-out fires here, BEFORE cleanup deletes the loop's buffer.
587
- this.#telemetry.push(workspaceId, loopId, {
588
- source: "engine:rails", kind: "strike_threshold", level: "error",
589
- message: verdict.cycleDetected
590
- ? `loop abandoned ${status}: strike threshold crossed after ${turnIds.length} turns — the model was spinning in place (cycle detected)`
591
- : `loop abandoned ${status}: strike threshold crossed after ${turnIds.length} turns — repeated failed or no-op turns`,
679
+ const failure = Results.failure("engine:rails", "strike-threshold", status, verdict.cycleDetected
680
+ ? `The loop reached its strike threshold after ${turnIds.length} turns because its operation pattern repeated.`
681
+ : `The loop reached its strike threshold after ${turnIds.length} turns because consecutive turns failed.`, {}, {
682
+ turns: turnIds.length,
683
+ stage: "loop",
684
+ retryable: false,
592
685
  });
593
- await this.#lifecycle.finish(loopId, status, "strike_threshold");
686
+ const result = await this.#lifecycle.finish(loopId, failure);
687
+ if (result === null)
688
+ throw new Error(`loop ${loopId} became terminal before strike settlement`);
594
689
  cleanup("forceful", "strike_threshold");
595
- return { turnIds, finalStatus: status, hitMaxTurns: false, reason: "strike_threshold" };
596
- }
597
- // Sudden-death threshold is engine-internal — abandonment
598
- // happens at maxTurns regardless. Per gamification policy:
599
- // we don't warn the model that it's nearing our limit.
600
- if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
601
- // Threshold tripped; engine bookkeeping only.
690
+ return { turnIds, result, hitMaxTurns: false, reason: "strike_threshold" };
602
691
  }
603
692
  }
604
693
  }
@@ -608,64 +697,83 @@ export default class Engine {
608
697
  // Turn rows are created at runTurn OPEN (status=102, placeholder
609
698
  // packet) so things can be written into the turn before the model
610
699
  // is called: the user prompt on turn 1; later, system signals or
611
- // injected telemetry events on any turn. The turn is CLOSED at
700
+ // injected Notices on any turn. The turn is CLOSED at
612
701
  // the end with the final packet + status + usage stats.
613
702
  //
614
703
  // sequence is "ordinal of stuff in this turn." Pre-model
615
704
  // writes consume low indices; model ops continue from there.
616
705
  const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
617
706
  const seq = seqRow.next;
618
- // #269 — loops.sequence is the loop's ordinal WITHIN the worker. Turn-0 foists that belong to the
619
- // RUN (manifest preview, AGENTS, operator docs) gate on the worker's FIRST loop, not every loop's
620
- // first turn; per-loop foists (the prompt, @file) still fire each loop. Read once, turn-1 only.
707
+ // loops.sequence is the loop's ordinal within the worker. Turn-0 foists that belong to the
708
+ // WORKER (manifest preview, AGENTS, operator docs) gate on the worker's first loop, not every loop's
709
+ // first turn ({§actor-boundary-catalog-preview}); per-loop foists such as
710
+ // {§prompt-entry} still fire each loop. Read once, turn-1 only.
621
711
  const loopRow = seq === 1
622
712
  ? await this.#db.engine_get_loop_prompt.get({ loop_id: loopId })
623
713
  : undefined;
624
- const runFirstLoop = (loopRow?.sequence ?? 0) === 1;
714
+ const workerFirstLoop = (loopRow?.sequence ?? 0) === 1;
625
715
  const openRow = await this.#db.engine_open_turn.get({
626
716
  loop_id: loopId, sequence: seq,
627
717
  });
628
718
  if (openRow === undefined)
629
719
  throw new Error("Engine.runTurn: turn open returned no row");
630
720
  const turnId = openRow.id;
631
- // Pre-model writes. Each turn opens with a system-origin EDIT
632
- // against `plurnk://prompt/<run>/<loop>/<seq>` IF there's a prompt
633
- // for THIS turn the model hasn't seen yet:
721
+ // {§env-delta-log-pull} — establish a fresh worker's observation
722
+ // baseline immediately after its first turn opens. A fork already has
723
+ // its parent's cursor, so the NULL-guarded statement leaves it intact.
724
+ // Events committed after this statement belong to this or a later
725
+ // closed pull window; pre-existing state is read through the ordinary
726
+ // shared-world projections in this first packet.
727
+ await this.#db.engine_initialize_ambient_cursor.get({ workspace_id: workspaceId, worker_id: workerId });
728
+ // Threaded per turn, never engine state, so concurrent loops on
729
+ // different providers each read their own honest tokenizer values.
730
+ const systemCtx = {
731
+ db: this.#db, workspaceId, workerId, loopId, turnId,
732
+ writer: "plurnk",
733
+ signal: this.#loopAborts.get(loopId)?.signal,
734
+ streamEventNotify: this.#streamEventNotify,
735
+ wakeWorkerNotify: this.#wakeWorkerNotify,
736
+ tokenize: this.#tokenize,
737
+ mimetypes: this.#mimetypes,
738
+ defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
739
+ pushNotice: (notice) => this.#notices.push(workspaceId, loopId, notice),
740
+ };
741
+ // Pre-model writes. Each prompt the model has not seen yet becomes an
742
+ // actionless `prompt` log row whose target is its durable prompt:// entry:
634
743
  // - Turn 1: loop.prompt is the initial user prompt.
635
744
  // - Turn N>1: only if Engine.inject (or wake-on-completion via
636
745
  // daemon.inject) wrote a prompt entry for this turn slot
637
746
  // between turn N-1 and N. Inject writes directly to entries;
638
747
  // we DON'T re-foist here for N>1.
639
- // The log records the EDIT for forensics. Model ops dispatch
640
- // from sequence=2 onward on prompt-foisted turns; 1 onward
641
- // otherwise.
748
+ // Model ops dispatch after these pre-model rows.
642
749
  let nextActionIndex = 1;
643
- // §model-entry — the worker's first turn opens with the model's own turn-0, mirrored OPEN: a
750
+ const turnOpenPaths = [];
751
+ // {§model-entry} — the worker's first turn opens with the model's own turn-0, mirrored OPEN: a
644
752
  // worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
645
753
  // from the real ops below (not a static print — we lean into the genuine echo paradigm) and
646
754
  // written at sequence 1, so it reads first as the emission with the foisted results following.
647
755
  const turnZeroMoves = [];
648
756
  if (seq === 1) {
649
- if (runFirstLoop)
757
+ if (workerFirstLoop)
650
758
  nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
651
- // Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
652
- // as plurnk:///<entry> entries by the plurnk worker (loop_run, via the
653
- // §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
759
+ // Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, {§actor-boundary-doc-injection}). The docs were materialized
760
+ // as worker://plurnk/<entry> entries by the plurnk worker (LoopDocs, via the
761
+ // {§actor-boundary} keystone); foist a READ of each into THIS turn-0 so the model
654
762
  // reads them inline. It sees only the READ — the materializing EDIT
655
763
  // lives in the plurnk worker's log, never the model's.
656
- // #231 — env docs (PLURNK_SERVICE_MD_*) UNION the workspace's client docs; foist a READ of
657
- // each materialized plurnk:///<alias>.md (loop_run materialized the same set).
764
+ // {§operator-config-workspace-md-docs} — env docs union the workspace's client docs; foist a READ of
765
+ // each materialized worker://plurnk/<alias>.md (LoopDocs materialized the same set).
658
766
  const { mdDocs } = await WorkspaceSettings.read(this.#db, workspaceId);
659
- // #269 — operator docs are run-once; foist them only on the worker's first loop.
660
- for (const doc of runFirstLoop ? await WorkspaceSettings.resolveDocs(mdDocs) : []) {
767
+ // {§actor-boundary-doc-injection} — operator docs appear on the worker's first loop.
768
+ for (const doc of workerFirstLoop ? await WorkspaceSettings.resolveDocs(mdDocs) : []) {
661
769
  const docTarget = {
662
770
  kind: "url", raw: `worker://plurnk/${doc.entryName}`, scheme: "worker",
663
771
  username: null, password: null, hostname: "plurnk", port: null,
664
- pathname: `/${doc.entryName}`, params: {}, fragment: null,
772
+ pathname: `/${doc.entryName}`, query: null, fragment: null,
665
773
  };
666
774
  const docRead = {
667
775
  op: "READ", suffix: "", signal: null, target: docTarget,
668
- lineMarker: null, body: null, position: { line: 1, column: 1 },
776
+ lineMarker: null, body: null, position: UNKNOWN_POSITION,
669
777
  };
670
778
  await this.dispatch({
671
779
  statement: docRead, workspaceId, workerId, loopId, turnId,
@@ -673,95 +781,72 @@ export default class Engine {
673
781
  });
674
782
  nextActionIndex++;
675
783
  }
676
- const promptRow = loopRow; // #269 — already read above (per-loop; fires every loop's turn 1)
784
+ const promptRow = loopRow; // {§prompt-entry} — per-loop; fires every loop's turn 1
677
785
  if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
678
- const promptLoopSeq = promptRow.sequence; // the loop's PER-RUN sequence — model-facing, matching log coordinates (owner: the db id read as prompt/2/1)
679
- const promptPath = promptTarget(workerId, promptLoopSeq, seq);
680
- const promptStmt = {
681
- op: "EDIT", suffix: "", signal: null,
682
- target: promptPath, lineMarker: null,
683
- body: promptRow.prompt, position: { line: 1, column: 1 },
684
- };
685
- let promptLogId;
686
- await this.dispatch({
687
- statement: promptStmt, workspaceId, workerId, loopId, turnId,
688
- sequence: nextActionIndex, origin: "plurnk",
689
- onDispatch: (id) => { promptLogId = id; onDispatch?.(id); },
690
- });
691
- // §prompt-fold: the prompt EDIT's row is folded — the body reaches the model
692
- // through the auto-READ below; the EDIT stays forensic, re-OPENable.
693
- if (promptLogId !== undefined)
694
- await this.#db.engine_fold_log_entry.run({ id: promptLogId });
695
- nextActionIndex++;
696
- // §prompt-auto-read (owner): the prompt's body reaches the model as a foisted
697
- // READ of its own entry — first 12 lines (<1,12>), or the whole prompt (<1,-1>)
698
- // when it runs fewer than 12 (whole-read form doubles as teaching). Prior prompts
699
- // stay listed by path in the system packet's User Prompts section — reachable,
700
- // never silently lost.
701
- // §arrival-law (#499) — the preview bound is the knob (was a hardcoded 12). The
702
- // line-slice is an EFFICIENCY (do not materialize hundreds of lines into rx); the
703
- // RENDER is the law — packet-wire's arrival preview cuts the char dimension there,
704
- // which a line marker structurally cannot (a 1-line slice of a 1-line bomb is 416).
705
- const previewLines = Number(process.env.PLURNK_SERVICE_ARRIVAL_PREVIEW_LINES ?? "16");
706
- const promptLineCount = promptRow.prompt.split("\n").length;
707
- const promptRead = {
708
- op: "READ", suffix: "", signal: null, target: promptPath,
709
- lineMarker: { marks: promptLineCount >= previewLines ? [1, previewLines] : [1, -1] },
710
- body: null, position: { line: 1, column: 1 },
786
+ const openPaths = assertOpenPaths(JSON.parse(promptRow.open_paths), `Loop ${loopId} open_paths`);
787
+ const promptLoopSeq = promptRow.sequence; // the loop's per-worker sequence — model-facing, matching log coordinates (owner: the db id read as prompt/2/1)
788
+ const promptPath = promptTarget(promptLoopSeq, seq);
789
+ const entry = {
790
+ channels: { body: { content: promptRow.prompt, mimetype: "text/markdown" } },
791
+ tags: [],
792
+ attributes: { openPaths },
711
793
  };
712
- await this.dispatch({
713
- statement: promptRead, workspaceId, workerId, loopId, turnId,
714
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
794
+ await EntryCrud.writeEntry(promptPath.pathname, entry, systemCtx, "prompt", workerId);
795
+ turnOpenPaths.push(...openPaths);
796
+ const promptLogId = await this.#writePromptLog({
797
+ workerId,
798
+ loopId,
799
+ turnId,
800
+ sequence: nextActionIndex,
801
+ target: promptPath,
802
+ content: promptRow.prompt,
715
803
  });
804
+ onDispatch?.(promptLogId);
716
805
  nextActionIndex++;
717
806
  }
718
807
  }
719
- // §prompt-auto-read, the mid-loop half + {§prompt-loop-containment}: the loop CONTAINS
720
- // every prompt that arrived while it ran — this boundary foists an auto-READ for each
721
- // frame not yet delivered (no prior auto-READ in the loop), oldest first, so rapid
722
- // arrivals reach the model together, none lost, each exactly once.
723
- if (seq > 1) {
808
+ // {§prompt-loop-containment}: the loop contains every prompt that arrived
809
+ // while it ran. Publish each undelivered frame as a prompt row, oldest
810
+ // first, so rapid arrivals reach the model together exactly once.
811
+ {
724
812
  const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
725
813
  const loopSeq = loopSeqRow?.sequence ?? loopId;
726
- const undelivered = (await this.#db.drain_undelivered_prompts_for_loop.all({ owner_id: workerId, pattern: `${promptLoopPrefix(loopSeq)}%`, loop_id: loopId }))
814
+ const prefix = promptLoopPrefix(loopSeq);
815
+ const undelivered = (await this.#db.drain_undelivered_prompts_for_loop.all({
816
+ owner_id: workerId,
817
+ pattern: `${prefix}%`,
818
+ prefix_len: prefix.length,
819
+ loop_id: loopId,
820
+ }))
727
821
  .filter((r) => typeof r.content === "string" && r.content.length > 0);
728
822
  for (const injectedRow of undelivered) {
729
- const lineCount = injectedRow.content.split("\n").length;
823
+ const attributes = parsePromptAttributes(injectedRow.attributes, `Prompt ${injectedRow.pathname} attributes`);
824
+ const encodedOpenPaths = attributes.openPaths;
825
+ if (encodedOpenPaths !== undefined) {
826
+ turnOpenPaths.push(...assertOpenPaths(encodedOpenPaths, `Prompt ${injectedRow.pathname} openPaths`));
827
+ }
730
828
  const ordinal = Number(injectedRow.pathname.split("/").filter(Boolean).at(-1));
731
- const injTarget = promptTarget(workerId, loopSeq, ordinal);
732
- const injRead = {
733
- op: "READ", suffix: "", signal: null, target: injTarget,
734
- lineMarker: { marks: lineCount >= 12 ? [1, 12] : [1, -1] },
735
- body: null, position: { line: 1, column: 1 },
736
- };
737
- await this.dispatch({
738
- statement: injRead, workspaceId, workerId, loopId, turnId,
739
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
829
+ const injTarget = promptTarget(loopSeq, ordinal);
830
+ const promptLogId = await this.#writePromptLog({
831
+ workerId,
832
+ loopId,
833
+ turnId,
834
+ sequence: nextActionIndex,
835
+ target: injTarget,
836
+ content: injectedRow.content,
740
837
  });
838
+ onDispatch?.(promptLogId);
741
839
  nextActionIndex++;
742
840
  }
743
841
  }
744
- // The per-turn derivation pump (_entry-manifest.maintainDerivations) — refreshes
745
- // every entry's deep channels (symbols/refs/embeddings/FTS, deep_hash-gated) so the
746
- // catalog and FIND read current data. NOT an action: no log entry, no sequence slot,
747
- // not dispatched. There is no plurnk:///manifest.json entry — the catalog is served
748
- // on demand by FIND(scheme:///**), foisted into the worker's first turn below.
749
- // #312 — the turn's token gauge: the ACTIVE provider's tokenizer identity + exact counter
750
- // (mimetypes seam; provider upper bound surfaced as tokenizer_unavailable when inexact).
751
- // Threaded per turn — never engine state — so concurrent loops on different providers
752
- // each read their own honest numbers.
753
- const systemCtx = {
754
- db: this.#db, workspaceId, workerId, loopId, turnId,
755
- writer: "plurnk",
756
- signal: this.#loopAborts.get(loopId)?.signal,
757
- streamEventNotify: this.#streamEventNotify,
758
- wakeWorkerNotify: this.#wakeWorkerNotify,
759
- tokenize: this.#tokenize,
760
- mimetypes: this.#mimetypes,
761
- defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
762
- pushTelemetry: (event) => this.#telemetry.push(workspaceId, loopId, event),
763
- };
764
- // SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
842
+ // The persistent search-index pass (_search-index.maintain) attaches
843
+ // every readable entry/log projection to complete graph/FTS/vector derivations.
844
+ // NOT an action: no log entry, no sequence slot,
845
+ // not dispatched. There is no materialized manifest entry — the catalog
846
+ // is served on demand by FIND: recursive when asked, shallow-mapped below.
847
+ // {§semantic-embed-dedup} — one pass-wide semantic plan binds every
848
+ // chunk counter to the derivation identity it produces.
849
+ // SPEC {§membership} D4/D5 — git-ls-files workspace membership, resolved at
765
850
  // prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
766
851
  // workspace's project_root is a git working tree, tracked files are
767
852
  // members without a client `add`; active members are materialized
@@ -780,425 +865,594 @@ export default class Engine {
780
865
  // construction. Membership is already current, so this pass does not
781
866
  // consume the filesystem divergences a second time.
782
867
  await this.#queueWorkspaceWarm(systemCtx, true, false);
783
- // Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, §actor-boundary-catalog-preview):
784
- // one FIND(scheme:///**) per scheme that holds entries, foisted into the worker's first
785
- // model turn so it opens with its catalog (the per-scheme arrays that replaced the
786
- // single manifest.json). -1 → each scheme's whole catalog; N → its first N rows
787
- // (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
868
+ // Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, {§actor-boundary-catalog-preview}):
869
+ // FIND surveys foisted into the worker's first model turn so it opens with its catalog.
870
+ // Folder-capable surfaces reveal one level with `*`; each deeper directory is an
871
+ // actionable `dir/**` aggregate. The curated kernel docs remain recursive, so the
872
+ // opening packet demonstrates both navigation forms. Empty results are orientation.
788
873
  if (seq === 1) {
789
- // #231 — a workspace's client-chosen filesItems REPLACES the env default outright.
874
+ // {§operator-config-workspace-files-items} — workspace filesItems replaces the env default.
790
875
  const { filesItems: workspaceMI } = await WorkspaceSettings.read(this.#db, workspaceId);
791
876
  const filesItems = workspaceMI !== null ? normalizeFilesItems(workspaceMI) : readFilesItems();
792
- if (filesItems !== null && runFirstLoop) { // #269 — catalog preview is run-once
793
- // engine_scheme_catalog_summary is the scheme source: workspace-scoped, ordered,
794
- // one row per scheme that has entries (scheme=null → file). log:// is absent —
877
+ if (filesItems !== null && workerFirstLoop) { // {§actor-boundary-catalog-preview} — once per worker
878
+ // engine_scheme_catalog_summary is the workspace-bounded scheme source: ordered,
879
+ // one row per stored entry scheme. log:// is absent —
795
880
  // it lives in log_entries, not the catalog (present-mode, the # Log section).
796
881
  const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ workspace_id: workspaceId });
797
- // known:/// + unknown:/// + file ALWAYS foist, even at zero entries — else the
798
- // model burns a turn running the FIND itself, assuming the catalog is merely
799
- // being withheld. An empty FIND(**) is orienting, not noise (owner): it tells
800
- // the model NOT to look there. Other schemes keep the with-entries default.
801
- const foistSchemes = [...catalogSchemes].filter((c) => c.scheme !== "prompt" && c.scheme !== "worker");
802
- // The COMMONS (worker:///**) + the file tree always foist — even at zero entries the
803
- // empty survey is orienting ("don't look here"), per the #527 re-home of the old
804
- // known/unknown always-foist role onto the shared blackboard.
805
- foistSchemes.push({ scheme: "worker", entries: catalogSchemes.find((c) => c.scheme === "worker")?.entries ?? 0 });
882
+ // Entry-bearing plugin schemes foist alongside the four structural surveys below.
883
+ const foistSchemes = catalogSchemes
884
+ .filter((catalog) => catalog.scheme !== "prompt" && catalog.scheme !== "worker")
885
+ .map(({ scheme, shallow_items }) => ({ scheme, shallow_items }));
886
+ // Commons + project files always foist. An empty result establishes that the
887
+ // surface exists and currently contains nothing.
888
+ foistSchemes.push({
889
+ scheme: "worker",
890
+ shallow_items: catalogSchemes.find((c) => c.scheme === "worker")?.shallow_items ?? 0,
891
+ });
806
892
  if (!foistSchemes.some((c) => c.scheme === "file"))
807
- foistSchemes.push({ scheme: "file", entries: 0 }); // {§entry-identity-no-null} — file rows persist under the reserved scheme now; a null key would double-foist the tree
808
- for (const { scheme, entries } of foistSchemes) {
809
- const schemeName = scheme ?? "file";
810
- const isFile = schemeName === "file";
811
- // Only the FILE list is cappable (PLURNK_SERVICE_FILES_ITEMS first-N): the tracked-file
812
- // tree is external and arbitrarily large. Every other scheme — known/unknown
813
- // (memory), run (scratch), plurnk (docs) — foists FULL, never truncated: a partial
814
- // view of the model's own memory reads as withheld. file at -1, or any non-file
815
- // scheme → no cap. (file in this loop always has entries>0, so no degenerate <1,0>.)
816
- const cap = isFile && filesItems > 0 ? Math.min(filesItems, entries) : null;
893
+ foistSchemes.push({ scheme: "file", shallow_items: 0 }); // Empty project surface still receives its orienting FIND.
894
+ for (const { scheme, shallow_items: shallowItems } of foistSchemes) {
895
+ const isFile = scheme === "file";
896
+ const pattern = this.#schemes.manifestFor(scheme)?.folderScopes === true ? "*" : "**";
897
+ // Only the file map takes PLURNK_SERVICE_FILES_ITEMS as a first-N cap;
898
+ // other schemes always render their complete one-level map. An empty file
899
+ // survey has no range: `<1,0>` would turn useful orientation into an error.
900
+ const cap = isFile && filesItems > 0 && shallowItems > 0 ? Math.min(filesItems, shallowItems) : null;
817
901
  // The file survey foists as the BARE relative glob — the path shape plurnk.md
818
- // teaches (`src/**`, `**/notes.md`; bare = project-relative) — so the turn-0
902
+ // teaches (`*`, `src/**`, `**/notes.md`; bare = project-relative) — so the turn-0
819
903
  // exemplar and the log rows the model reads never train a leading-slash or
820
904
  // file:/// habit the rest of the teaching contradicts.
821
905
  const catalogFind = {
822
906
  op: "FIND", suffix: "", signal: null,
823
- target: isFile ? { kind: "local", raw: "**" } : {
907
+ target: isFile ? { kind: "local", raw: pattern } : {
824
908
  kind: "url",
825
- raw: `${schemeName}:///**`,
826
- scheme: schemeName,
909
+ raw: `${scheme}:///${pattern}`,
910
+ scheme,
827
911
  username: null, password: null, hostname: null, port: null,
828
- pathname: "/**",
829
- params: {}, fragment: null,
912
+ pathname: `/${pattern}`,
913
+ query: null, fragment: null,
830
914
  },
831
915
  body: null,
832
916
  lineMarker: cap === null ? null : { marks: [1, cap] },
833
- position: { line: 1, column: 1 },
917
+ position: UNKNOWN_POSITION,
834
918
  };
835
919
  await this.dispatch({
836
920
  statement: catalogFind, workspaceId, workerId, loopId, turnId,
837
921
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
838
922
  });
839
923
  nextActionIndex++;
840
- // §model-entry — the same FIND, rendered back to DSL for the turn-0 echo (the model's
924
+ // {§model-entry} — the same FIND, rendered back to DSL for the turn-0 echo (the model's
841
925
  // own survey, mirrored OPEN). The <L> cap rides as `<1,N>`, exactly as the model would type it.
842
- turnZeroMoves.push(`<<FIND(${isFile ? "**" : `${schemeName}:///**`})${cap === null ? "" : `<1,${cap}>`}::FIND`);
926
+ turnZeroMoves.push(`<<FIND(${isFile ? pattern : `${scheme}:///${pattern}`})${cap === null ? "" : `<1,${cap}>`}::FIND`);
843
927
  }
844
928
  // The kernel's self-documenting surface — FIND(worker://plurnk/docs/**), uncapped,
845
- // always (the law materializes the docs): the #270 discovery foist, re-homed (#527).
929
+ // always ({§schemes-directory}, published under {§entry-owner}).
846
930
  await Owner.kernelId(this.#db, workspaceId); // the row exists even before docs materialize — the empty survey is orienting, never 404
847
931
  const kernelDocsFind = {
848
932
  op: "FIND", suffix: "", signal: null,
849
- target: { kind: "url", raw: "worker://plurnk/docs/**", scheme: "worker", username: null, password: null, hostname: "plurnk", port: null, pathname: "/docs/**", params: {}, fragment: null },
850
- body: null, lineMarker: null, position: { line: 1, column: 1 },
933
+ target: { kind: "url", raw: "worker://plurnk/docs/**", scheme: "worker", username: null, password: null, hostname: "plurnk", port: null, pathname: "/docs/**", query: null, fragment: null },
934
+ body: null, lineMarker: null, position: UNKNOWN_POSITION,
851
935
  };
852
936
  await this.dispatch({ statement: kernelDocsFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
853
937
  nextActionIndex++;
854
938
  turnZeroMoves.push("<<FIND(worker://plurnk/docs/**)::FIND");
855
- // §worker-scheme — Manifest(run) = workspace-scope ∪ THIS worker's worker-scope. Foist the
856
- // building worker's OWN scratch (worker://self/**, uncapped — a worker needs the full view to
857
- // manage its private workspace) so it's catalogued in ITS perspective alone; other
858
- // runs reach it only via explicit FIND(worker://<name>/**). A worker with no scratch foists nothing.
859
- const scratch = (await this.#db.engine_worker_scratch_count.get({ workspace_id: workspaceId, owner_id: workerId }))?.entries ?? 0;
860
- if (scratch > 0) {
861
- const runFind = {
862
- op: "FIND", suffix: "", signal: null,
863
- target: { kind: "url", raw: "worker://~/**", scheme: "worker", username: null, password: null, hostname: "~", port: null, pathname: "/**", params: {}, fragment: null },
864
- body: null, lineMarker: null, position: { line: 1, column: 1 },
865
- };
866
- await this.dispatch({ statement: runFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
867
- nextActionIndex++;
868
- turnZeroMoves.push("<<FIND(worker://~/**)::FIND"); // §model-entry — the own-space survey, into the turn-0 echo
869
- }
870
- }
871
- // #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
872
- // of the model. Daemon owns the workspace → a normal file:/// member READ; a missing or
873
- // non-member path surfaces its READ outcome (4xx) in the log, visible to the model.
874
- const openPathsRow = await this.#db.engine_get_loop_open_paths.get({ loop_id: loopId });
875
- for (const raw of JSON.parse(openPathsRow?.open_paths ?? "[]")) {
876
- const pathname = raw.startsWith("/") ? raw : `/${raw}`;
877
- const fileRead = {
878
- op: "READ", suffix: "", signal: null, lineMarker: null,
879
- target: {
880
- kind: "url", raw: `file://${pathname}`, scheme: "file",
881
- username: null, password: null, hostname: null, port: null,
882
- pathname, params: {}, fragment: null,
883
- },
884
- body: null, position: { line: 1, column: 1 },
939
+ // {§worker-scheme} — the building worker's own scratch gets the same complete
940
+ // one-level map in its perspective alone. It always executes: an empty private
941
+ // space is useful orientation, not grounds to hide the surface.
942
+ const ownFind = {
943
+ op: "FIND", suffix: "", signal: null,
944
+ target: { kind: "url", raw: "worker://~/*", scheme: "worker", username: null, password: null, hostname: "~", port: null, pathname: "/*", query: null, fragment: null },
945
+ body: null, lineMarker: null, position: UNKNOWN_POSITION,
885
946
  };
886
- await this.dispatch({
887
- statement: fileRead, workspaceId, workerId, loopId, turnId,
888
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
889
- });
947
+ await this.dispatch({ statement: ownFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
890
948
  nextActionIndex++;
949
+ turnZeroMoves.push("<<FIND(worker://~/*)::FIND"); // {§model-entry} — the own-space survey, into the turn-0 echo
891
950
  }
892
- // §model-entry — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
951
+ // {§model-entry} — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
893
952
  // foisted above (real, their results already in the log) → SEND[102]. Dynamic — it reflects
894
953
  // the true survey, never a frozen print — and OPEN: the worked example the model orients on,
895
954
  // so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
896
- if (runFirstLoop) {
897
- const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
955
+ if (workerFirstLoop) {
956
+ const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Next, address the prompt from the initialized context.:SEND"].join("\n");
898
957
  await this.#dispatcher.writeModelEntry({ verbatim: emission, workerId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
899
958
  }
900
959
  }
901
- // §env-delta — pre-seed the worker's ambient observations (what changed since
902
- // it last looked) as foisted rows before the packet composes; advance the action index
903
- // past them so model ops continue after. Two instances of one machine: env-delta (sibling
904
- // edits · timestamp cursor · always folded) and exec streams (channel bytes · byte cursor ·
905
- // terminal delta opens). §env-delta §exec-stream
906
- // §exec-poll — EXEC `<0>` is turn-scoped: reap the worker's open turn-scoped streams (necessarily
960
+ // {§methods-loop-run-open-paths}: selected workspace paths belong to
961
+ // the prompt frame. Publish the frame, then dispatch ordinary core READs
962
+ // in that same turn; missing/non-member paths retain their normal 4xx.
963
+ for (const raw of turnOpenPaths) {
964
+ const pathname = raw.startsWith("/") ? raw : `/${raw}`;
965
+ const fileRead = {
966
+ op: "READ", suffix: "", signal: null, lineMarker: null,
967
+ target: {
968
+ kind: "url", raw: `file://${pathname}`, scheme: "file",
969
+ username: null, password: null, hostname: null, port: null,
970
+ pathname, query: null, fragment: null,
971
+ },
972
+ body: null, position: UNKNOWN_POSITION,
973
+ };
974
+ await this.dispatch({
975
+ statement: fileRead, workspaceId, workerId, loopId, turnId,
976
+ sequence: nextActionIndex, origin: "plurnk", onDispatch,
977
+ });
978
+ nextActionIndex++;
979
+ }
980
+ // {§env-delta-log-pull} — materialize ambient observations before packet
981
+ // composition and reserve their action indices. {§exec-stream} owns the
982
+ // distinct byte-cursor path for this worker's streams.
983
+ // {§exec-poll} — EXEC `<0>` is turn-scoped: reap the worker's open turn-scoped streams (necessarily
907
984
  // from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
908
985
  // the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
909
986
  await this.#reapTurnScopedStreams(workerId);
910
987
  nextActionIndex += await this.#materializeEnvironmentDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
911
988
  nextActionIndex += await this.#materializeStreamDeltas({ workerId, loopId, turnId, fromSequence: nextActionIndex });
912
- // SPEC §telemetry — git working-tree state for the telemetry section, read once
989
+ // Git working-tree state is read once for the packet and threaded
913
990
  // (a service-side `git status` shell-out) and threaded into the budget
914
991
  // rebuild too so it isn't re-shelled on overflow.
915
992
  const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopAborts.get(loopId)?.signal);
916
- // Build the spec'd packet (Packet.json) request half. The log build
993
+ // Notices are non-terminal observations, never operation-failure truth.
994
+ // Drain once and thread the same set through every grinder rebuild.
995
+ const notices = this.#notices.drain(loopId)
996
+ .filter((event) => event.level !== "info");
997
+ // Build the model request packet ({§packet-stored-shape}). The log build
917
998
  // queries log_entries scoped to the worker — the prompt entry just
918
999
  // written (if turn 1) is part of that query result.
919
1000
  let requestPacket = await this.#packets.buildRequestPacket({
920
1001
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
921
- currentTurnSeq: seq, provider, gitStatus,
1002
+ currentTurnSeq: seq, provider, gitStatus, notices,
922
1003
  });
923
- // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
1004
+ // SPEC {§grinder} — budget grinder, pre-LLM: reclaim window on actual overflow.
924
1005
  const enforced = await this.#packets.enforceBudget({
925
1006
  packet: requestPacket, provider, workerId, loopId, turnId,
926
1007
  // The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
927
1008
  // runTurn advances the counter past it below so the post-generate dispatch rows never collide.
928
1009
  mintSequence: nextActionIndex,
929
- // No preset telemetry — the rebuild RE-DERIVES the errors section from log≥400 so the
930
- // overflow row just minted surfaces THIS turn (§grinder-overflow-error-row). Safe: the
931
- // ephemeral buffer is empty pre-generate (events drain on the next turn's build).
1010
+ // Rebuilds re-derive durable errors and retain the one drained notice
1011
+ // set; neither path can duplicate or swallow a product failure.
932
1012
  rebuild: () => this.#packets.buildRequestPacket({
933
1013
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
934
- currentTurnSeq: seq, provider, gitStatus,
1014
+ currentTurnSeq: seq, provider, gitStatus, notices,
935
1015
  }),
936
1016
  });
937
- if (enforced.struck)
938
- nextActionIndex += 1; // the budget-overflow error row consumed a sequence
1017
+ if (enforced.recorded)
1018
+ nextActionIndex += 1; // a fold-to-fit Problem consumed the reserved sequence
939
1019
  requestPacket = enforced.packet;
1020
+ let operationConstraint;
940
1021
  if (!enforced.fit) {
941
- // §grinder-hard-413-recovery (Q4, owner ruling: recoverable strike, NO margin) — the
942
- // overflow lives in foldable HISTORY the model owns, and the grinder won't touch
943
- // history (§grinder-layer1-rollback doctrine). So the first hard overflow is a
944
- // RECOVERY TURN, not death: the packet is over the POLICY ceiling but usually well
945
- // within PHYSICS (the jumbo pin: ceiling 13k, real window 49k) — send it once, with a
946
- // minted steer naming the remedy, and a strike (budgetStruck). The model curates →
947
- // next turn fits → the grant clears. It declines → the second consecutive hard
948
- // overflow terminates 413 — death only after the model was told. Physically
949
- // unsendable (over the provider's real window too) → 413 immediately; physics
950
- // doesn't negotiate. The pointer stays at 100% of budget — a margin would mask it.
951
- const physicallySendable = provider.contextWindow === null
952
- ? true
953
- : this.#packets.exactPacketTokens(requestPacket, provider) <= provider.contextWindow - (this.#packets.maxTokensFor(provider) ?? 0);
954
- if (physicallySendable && !this.#hardOverflowRecovery.has(loopId)) {
955
- this.#hardOverflowRecovery.add(loopId);
956
- await this.#db.engine_insert_log_entry.get({
957
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: nextActionIndex++,
958
- origin: "model", source: "engine", op: "error", suffix: "", signal: null,
959
- scheme: null, username: null, password: null, hostname: null, port: null,
960
- pathname: null, params: null, fragment: null, lineMarker: null,
961
- tx: "", mimetype_tx: "text/plain",
962
- rx: JSON.stringify({ status: 413, kind: "budget_overflow", message: "the packet exceeds the budget even after the newest turn folded — this is your ONE recovery turn: KILL or FOLD history items now (the budget table lists the heaviest) to reclaim room; a second consecutive overflow terminates the loop" }),
963
- mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "budget_overflow",
964
- attrs: "{}",
1022
+ // {§grinder-hard-413-recovery}/{§grinder-hard-413-abort} — admit
1023
+ // one physically sendable constrained recovery turn; a physical
1024
+ // overflow or consecutive policy overflow terminates immediately.
1025
+ let physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
1026
+ if (physicalAdmission.admitted && !this.#hardOverflowRecovery.has(loopId)) {
1027
+ const ceiling = this.#packets.ceilingFor(provider);
1028
+ if (ceiling === null) {
1029
+ throw new Error("an unbounded prompt budget cannot enter budget recovery");
1030
+ }
1031
+ await this.#problems.record({
1032
+ workerId,
1033
+ loopId,
1034
+ turnId,
1035
+ sequence: nextActionIndex++,
1036
+ origin: "model",
1037
+ source: "engine",
1038
+ result: BudgetOverflow.result(requestPacket.tokens, ceiling, true),
965
1039
  });
966
1040
  // Rebuild so the recovery-steer row just minted renders in THIS packet's log +
967
1041
  // errors sections (the same re-derive contract the soft grind uses).
968
- nextActionIndex += 1;
969
1042
  requestPacket = await this.#packets.buildRequestPacket({
970
1043
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
971
- currentTurnSeq: seq, provider, gitStatus,
1044
+ currentTurnSeq: seq, provider, gitStatus, notices,
972
1045
  });
1046
+ physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
1047
+ if (physicalAdmission.admitted) {
1048
+ this.#hardOverflowRecovery.add(loopId);
1049
+ operationConstraint = {
1050
+ code: "budget-recovery",
1051
+ detail: "budget recovery is active",
1052
+ allowedOperations: BudgetOverflow.recoveryOperations,
1053
+ };
1054
+ }
973
1055
  }
974
- else {
975
- // Hard 413: physically unsendable, or the model already declined its recovery turn.
1056
+ if (operationConstraint === undefined) {
1057
+ // Hard 413: physically unsendable, or still over after the constrained recovery turn.
1058
+ const ceiling = this.#packets.ceilingFor(provider);
1059
+ if (ceiling === null) {
1060
+ throw new Error("an unbounded prompt budget cannot hard-stop");
1061
+ }
1062
+ if (!enforced.recorded || !physicalAdmission.admitted) {
1063
+ await this.#problems.record({
1064
+ workerId,
1065
+ loopId,
1066
+ turnId,
1067
+ sequence: nextActionIndex++,
1068
+ origin: "plurnk",
1069
+ source: "engine",
1070
+ result: BudgetOverflow.result(requestPacket.tokens, ceiling, false, physicalAdmission.admitted
1071
+ ? undefined
1072
+ : {
1073
+ reason: physicalAdmission.reason,
1074
+ detail: physicalAdmission.detail,
1075
+ capacity: physicalAdmission.capacity,
1076
+ tokens: physicalAdmission.measurement?.tokens,
1077
+ tokenKind: physicalAdmission.measurement?.kind,
1078
+ tokenSource: physicalAdmission.measurement?.source,
1079
+ }),
1080
+ });
1081
+ requestPacket = await this.#packets.buildRequestPacket({
1082
+ initialMessages: messages, requirements, workspaceId, workerId, loopId,
1083
+ currentTurnSeq: seq, provider, gitStatus, notices,
1084
+ });
1085
+ }
976
1086
  // Skip the LLM, close the turn, and let runLoop abandon.
977
- const hardPacket = this.#packets.completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
978
1087
  await this.#db.engine_close_turn.run({
979
- id: turnId, status: 413, packet: JSON.stringify(hardPacket),
980
- usage_prompt: 0, usage_completion: 0, usage_reasoning: 0, usage_cached: 0, usage_cost_usd: 0,
981
- usage_prompt_budget: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves), even on a hard-413 turn: the gauge denominator the packet actually lives under
1088
+ id: turnId, status: 413, packet: StoredPacket.stringify(requestPacket),
1089
+ usage_prompt: 0, usage_completion: 0, usage_reasoning: 0, usage_cached: 0,
1090
+ usage_cost: "[]", usage_cost_usd: 0,
1091
+ // The attempted turn retains its effective allowance even
1092
+ // when no provider exchange completed. {§tokenomics-client-gauge}
1093
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider),
982
1094
  finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
983
1095
  });
984
- return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
1096
+ return {
1097
+ turnId,
1098
+ status: 413,
1099
+ statuses: [],
1100
+ fingerprint: "",
1101
+ budgetStruck: enforced.struck,
1102
+ budgetHardStop: true,
1103
+ steerStruck: false,
1104
+ emissionAttempts: 0,
1105
+ emissionExhausted: false,
1106
+ budget: BudgetOverflow.measure(requestPacket.tokens, ceiling),
1107
+ };
985
1108
  }
986
1109
  }
987
1110
  else {
988
- // A fitting turn clears the recovery grant — the model curated; a later overflow
989
- // earns a fresh recovery turn (chronic overflow still strikes out via the rail).
1111
+ // A fitting turn clears the recovery grant; a later independent overflow can earn
1112
+ // a fresh recovery turn (chronic overflow still strikes out via the rail).
990
1113
  this.#hardOverflowRecovery.delete(loopId);
991
1114
  }
992
1115
  const modelMessages = PacketWire.packetToWireMessages(requestPacket);
993
- // No decode cap. Our budget governs the TRANSMISSION packet (the grinder folds
994
- // the input under the ceiling); the model's decode — reasoning + emission — is
995
- // out of band, owned by the provider's own context window. Deriving a maxTokens
996
- // from our budget conflated the two and guillotined a reasoning model's
997
- // out-of-band reasoning as the packet filled (`ceiling - packet` → near-zero
998
- // decode → finish=length mid-reasoning → no emission → strike spiral). The
999
- // provider enforces its physical wall on its own.
1116
+ // Packet pressure and provider generation are independent. The grinder governs
1117
+ // only the request packet; maxTokens comes only from the provider envelope and
1118
+ // never shrinks as the virtual prompt budget fills.
1000
1119
  let response;
1120
+ let splitResponse;
1001
1121
  let railGrammar;
1002
- // #249 — plugin attribution tags onto the per-turn generate() wire. Value is the
1003
- // active-plugin set (placeholder); real per-turn grounding is deferred.
1004
- const attributions = [...new Set([...this.#schemes.attributions(), ...(this.#executors?.attributions() ?? [])])].toSorted();
1005
- // #249 — tag the loop (the activity) with its active plugins' attribution tags, write-once.
1006
- if (attributions.length > 0)
1007
- await this.#db.engine_tag_loop_attributions.run({ loop_id: loopId, attributions: JSON.stringify(attributions) });
1008
- // #249 — workspace-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
1122
+ let railEvidence;
1123
+ let emissionAttempts = 0;
1124
+ const usage = { prompt: 0, completion: 0, reasoning: 0, cached: 0 };
1125
+ let usageCostUsd = 0;
1126
+ const providerCosts = [];
1127
+ let providerCallInFlight = false;
1128
+ let providerAttemptSequence = 0;
1129
+ let providerAttemptAttributions = [];
1130
+ const providerSignal = this.#loopAborts.get(loopId)?.signal ?? signal;
1131
+ // {§client-metadata}
1009
1132
  const { client } = await WorkspaceSettings.read(this.#db, workspaceId);
1133
+ const recordProviderAttempt = async (attemptResponse, attemptSplit, sequence, accepted, attributions) => {
1134
+ const attemptUsage = attemptSplit.callMetadata.usage;
1135
+ const attemptCost = providerCostFor(provider, attemptUsage, attemptResponse.charge);
1136
+ const attemptCostUsd = providerCostUsd(attemptCost);
1137
+ await this.#db.engine_record_turn_attempt.run({
1138
+ turn_id: turnId,
1139
+ sequence,
1140
+ accepted: accepted ? 1 : 0,
1141
+ response: JSON.stringify(attemptResponse),
1142
+ parse_errors: JSON.stringify(attemptSplit.parseErrors),
1143
+ attributions: JSON.stringify(attributions),
1144
+ usage_prompt: attemptUsage.prompt,
1145
+ usage_completion: attemptUsage.completion,
1146
+ usage_reasoning: attemptUsage.reasoning,
1147
+ usage_cached: attemptUsage.cached,
1148
+ usage_cost: JSON.stringify(attemptCost),
1149
+ usage_cost_usd: attemptCostUsd,
1150
+ finish_reason: attemptSplit.callMetadata.finishReason,
1151
+ model: attemptSplit.callMetadata.model,
1152
+ });
1153
+ usage.prompt += attemptUsage.prompt;
1154
+ usage.completion += attemptUsage.completion;
1155
+ usage.reasoning += attemptUsage.reasoning;
1156
+ usage.cached += attemptUsage.cached;
1157
+ providerCosts.push(attemptCost);
1158
+ usageCostUsd = usageCostUsd === null || attemptCostUsd === null
1159
+ ? null
1160
+ : usageCostUsd + attemptCostUsd;
1161
+ emissionAttempts = sequence;
1162
+ };
1010
1163
  try {
1011
- // §turn-lifecycle (#301) — the provider call is the long, opaque window (submit → first
1012
- // committed op is provider latency + a full first-turn generation, ~70s local): a static
1013
- // screen there is indistinguishable from a hang. Bracket generate() with two telemetry beats
1014
- // so a client can show "awaiting model" the instant the turn starts and flip to "parsing" when
1015
- // ops are about to land. Base telemetry/event channel (the embed_progress precedent, §tokenomics
1016
- // clients already render it unconditionally); the abort guard keeps a cancelled loop silent.
1164
+ // {§turn-lifecycle}: bracket the complete provider-attempt window with liveness notices.
1017
1165
  if (!signal?.aborted)
1018
- this.#telemetry.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
1019
- // generate rides the LOOP signal (already chained from the caller's), so a loop-level
1020
- // abort — the §operator-config-loop-timeout wall — cancels a stuck provider call, not
1021
- // just the schemes. Bare runTurn (no runLoop) has no loop entry → the caller's signal.
1022
- // #391 — the turn COORDINATE (workspace + loop-seq/turn-seq of the turn being generated)
1023
- // rides as first-party metadata (Plurnk-Workspace-Id/Loop/Turn) so the endpoint flywheel keys
1024
- // on an AUTHORITATIVE hierarchy, not a scraped context-log approximation. The daemon owns
1025
- // the value; providers stamps the header (same split as Worker-Id, #26). loopSeq (the 1-based
1026
- // coordinate, not the DB id) resolves the same way the prompt-slot path does (§log coords).
1166
+ this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
1027
1167
  const loopSeq = (await this.#db.engine_loop_sequence.get({ loop_id: loopId }))?.sequence ?? loopId;
1028
1168
  railGrammar = await this.#grammarConstraint(provider);
1029
- response = await provider.generate({ messages: modelMessages, workerId: String(workerId), primaryWorkerId: String(await this.resolveWorkerPrimary(workerId)), signal: this.#loopAborts.get(loopId)?.signal ?? signal, grammar: railGrammar, maxTokens: this.#packets.maxTokensFor(provider) ?? undefined, strikes: this.#strikes.streak(loopId), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined, workspaceId: String(workspaceId), loop: loopSeq, turn: seq }); // strikes: first-party routing signal, 0 sent explicitly (#313) // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
1169
+ const primaryWorkerId = String(await this.resolveWorkerPrimary(workerId));
1170
+ const attemptLimit = readEmissionAttempts();
1171
+ const maxTokens = this.#packets.maxTokensFor(provider) ?? undefined;
1172
+ const strikeStreak = this.#strikes.streak(loopId);
1173
+ for (let attempt = 1; attempt <= attemptLimit; attempt++) {
1174
+ // Every attempt carries the exact same model packet, coordinates,
1175
+ // limits, and engine-strike state. Plugin-authored tags are pulled
1176
+ // for the attempt and do not alter the model messages. No failed
1177
+ // emission is appended and no new engine turn opens between calls.
1178
+ providerAttemptSequence = attempt;
1179
+ const attributionContext = Object.freeze({
1180
+ workspaceId: String(workspaceId),
1181
+ workerId: String(workerId),
1182
+ primaryWorkerId,
1183
+ loop: loopSeq,
1184
+ turn: seq,
1185
+ attempt,
1186
+ });
1187
+ providerAttemptAttributions = await this.#attemptAttributions(provider, attributionContext);
1188
+ requestPacket = { ...requestPacket, attributions: providerAttemptAttributions };
1189
+ providerCallInFlight = true;
1190
+ const completedResponse = await observed(// {§observability-boundary}
1191
+ "provider.generate", { model: provider.model, attempt }, async (span) => {
1192
+ const generated = await provider.generate({
1193
+ messages: modelMessages,
1194
+ workerId: String(workerId),
1195
+ primaryWorkerId,
1196
+ signal: providerSignal,
1197
+ grammar: railGrammar,
1198
+ maxTokens,
1199
+ strikes: strikeStreak,
1200
+ attributions: providerAttemptAttributions.length > 0
1201
+ ? providerAttemptAttributions
1202
+ : undefined,
1203
+ client: client ?? undefined,
1204
+ workspaceId: String(workspaceId),
1205
+ loop: loopSeq,
1206
+ turn: seq,
1207
+ }); // {§provider-surface-generate} {§provider-guarantees-signal-wired} {§provider-guarantees-serial-attempts} {§attribution} {§client-metadata}
1208
+ recordCounter(PROVIDER_CALLS, {
1209
+ model: provider.model,
1210
+ attempt,
1211
+ status: "resolved",
1212
+ });
1213
+ span.setAttribute("status", "resolved");
1214
+ return generated;
1215
+ });
1216
+ response = completedResponse;
1217
+ providerCallInFlight = false;
1218
+ railEvidence = railGrammar === undefined
1219
+ ? undefined
1220
+ : Engine.#requireGrammarEvidence(completedResponse);
1221
+ splitResponse = this.#splitResponse(completedResponse);
1222
+ await recordProviderAttempt(completedResponse, splitResponse, attempt, splitResponse.emissionValid, providerAttemptAttributions);
1223
+ if (splitResponse.emissionValid)
1224
+ break;
1225
+ }
1030
1226
  if (!signal?.aborted)
1031
- this.#telemetry.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
1227
+ this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
1032
1228
  }
1033
1229
  catch (err) {
1034
- // §turn-never-blank — a ProviderError is an INFRASTRUCTURE failure (auth, network
1035
- // beyond retries, rate limit): no completed exchange exists, so no turn exists —
1036
- // telemetry the cause and DIE legibly (the drain writes the loop terminal 500 with
1037
- // the message). Grammar conformance never arrives here: providers 0.32 retired the
1038
- // constrained-path throw — a completed exchange ALWAYS returns, bytes in assistant,
1039
- // the conformance verdict riding response.telemetry as an OBSERVATION (the engine's
1040
- // ANTLR parse is the judge; the provider transports and observes, never adjudicates).
1041
- // The old fallback fabricated an empty emission here and laundered a provider
1042
- // adjudication into a model-behavior 422 — a state the system otherwise forbids,
1043
- // a record that lied, and days of forensics pointed at the wrong suspect.
1044
- if (err instanceof ProviderError) {
1045
- this.#telemetry.push(workspaceId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
1230
+ // This handler owns only provider-call failures. Parser, cost, SQL,
1231
+ // and engine-contract failures retain their original source.
1232
+ if (!providerCallInFlight)
1233
+ throw err;
1234
+ // {§provider-interrupted-attempt} — a provider-declared interruption
1235
+ // carries response evidence without becoming a completed exchange.
1236
+ // Persist it as an unaccepted attempt before settling the failure.
1237
+ if (err instanceof ProviderError && err.attempt !== undefined) {
1238
+ response = err.attempt;
1239
+ splitResponse = this.#splitResponse(response);
1240
+ await recordProviderAttempt(response, splitResponse, providerAttemptSequence, false, providerAttemptAttributions);
1241
+ }
1242
+ else {
1243
+ providerCosts.push({
1244
+ kind: "unknown",
1245
+ reason: "provider call failed without response-bearing charge evidence",
1246
+ });
1247
+ usageCostUsd = null;
1248
+ }
1249
+ // {§turn-never-blank} — a ProviderError means no completed exchange exists.
1250
+ // Persist its exact RFC 9457 result before propagating it. Grammar evidence
1251
+ // and its engine-owned verdict exist only on completed responses
1252
+ // ({§rail-truth-engine-verdict}). Cancellation is lifecycle truth, not a
1253
+ // provider failure. Close the
1254
+ // attempted turn without inventing an assistant response, then let
1255
+ // runLoop/Daemon settle the exact 504/499 loop result.
1256
+ if (providerSignal?.aborted) {
1257
+ await this.#db.engine_close_turn.run({
1258
+ id: turnId,
1259
+ status: providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499,
1260
+ packet: StoredPacket.stringify(requestPacket),
1261
+ usage_prompt: usage.prompt,
1262
+ usage_completion: usage.completion,
1263
+ usage_reasoning: usage.reasoning,
1264
+ usage_cached: usage.cached,
1265
+ usage_cost: JSON.stringify(providerCosts),
1266
+ usage_cost_usd: usageCostUsd,
1267
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1268
+ finish_reason: splitResponse?.callMetadata.finishReason ?? null,
1269
+ model: splitResponse?.callMetadata.model ?? provider.model,
1270
+ meta: JSON.stringify(response?.meta ?? {}),
1271
+ });
1272
+ throw err;
1046
1273
  }
1047
- throw err;
1274
+ const failure = err instanceof ProviderError
1275
+ ? { status: err.problem.status, problem: err.problem }
1276
+ : (() => {
1277
+ console.error("Provider failed outside its Problem Details contract:", err);
1278
+ return Results.failure("engine:provider", "provider-contract-violation", 502, "The provider failed without returning its required Problem Details.", {}, {
1279
+ stage: "provider-request",
1280
+ retryable: false,
1281
+ });
1282
+ })();
1283
+ const recorded = await this.#problems.record({
1284
+ workerId,
1285
+ loopId,
1286
+ turnId,
1287
+ sequence: nextActionIndex,
1288
+ origin: "plurnk",
1289
+ source: "provider",
1290
+ result: failure,
1291
+ });
1292
+ // The provider call was attempted, but no completed exchange exists.
1293
+ // Persist the exact request half and failure status; omitting assistant
1294
+ // is materially different from fabricating an empty model turn.
1295
+ await this.#db.engine_close_turn.run({
1296
+ id: turnId,
1297
+ status: recorded.result.status,
1298
+ packet: StoredPacket.stringify(requestPacket),
1299
+ usage_prompt: usage.prompt,
1300
+ usage_completion: usage.completion,
1301
+ usage_reasoning: usage.reasoning,
1302
+ usage_cached: usage.cached,
1303
+ usage_cost: JSON.stringify(providerCosts),
1304
+ usage_cost_usd: usageCostUsd,
1305
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1306
+ finish_reason: splitResponse?.callMetadata.finishReason ?? null,
1307
+ model: splitResponse?.callMetadata.model ?? provider.model,
1308
+ meta: JSON.stringify(response?.meta ?? {}),
1309
+ });
1310
+ throw new OperationFailureError(recorded.result, { cause: err });
1048
1311
  }
1049
- // Engine splits wire-level response: emission (content, reasoning,
1050
- // parsed ops) → packet.assistant per Packet.json assistant section;
1051
- // call-metadata (usage, finishReason, model) → Turn columns per
1052
- // Turn.json. Mixing the two on packet.assistant was the wrong layer.
1053
- const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response); // raw assistant content is opaque — split, never interpreted — §provider-guarantees-assistantraw-opaque
1054
- // Surface parse errors to the model's NEXT packet so it can self-
1055
- // correct. Without this, malformed emissions (e.g. a READ matcher
1056
- // body starting with `//` being interpreted as xpath) silently
1057
- // drop, the model sees zero ops dispatched, strike-rail fires,
1058
- // model has no feedback on WHY its emission didn't take effect.
1059
- //
1060
- // Envelope per @plurnk/plurnk-grammar 0.17.0 TelemetryEvent:
1061
- // { source, kind, message, position: { type: "content-offset", line, column } }
1062
- // Plus a `snippet` field (additionalProperties) carrying ±N lines
1063
- // of the assistant's own content around the error line. Without
1064
- // the snippet, the model sees "invalid xpath at 1:0" but can't
1065
- // connect that to what IT wrote — and tends to regenerate the
1066
- // same broken emission. See edit-todo demo for the canonical case.
1067
- // Parse errors are LOG ITEMS now (§telemetry — one budget surface): each failed-to-parse
1068
- // emission records an actionless `error` row below, after the turn's dispatched ops are
1069
- // sequenced (see the parse-error log write past the dispatch loop). The errors section
1070
- // derives a pointer to it from log≥400, uniform with action_failure.
1071
- // providers#24 / #275: non-fatal provider telemetry on a SUCCESSFUL turn. In GBNF-filter
1072
- // mode the provider no longer THROWS grammar_unenforced — it returns the model's bytes
1073
- // (here, packetAssistant.content) and attaches the conflict as a telemetry event carrying
1074
- // the divergence code-point position. Forward each event with a content-offset `line:col`;
1312
+ if (response === undefined || splitResponse === undefined) {
1313
+ throw new Error("provider attempt loop completed without a response");
1314
+ }
1315
+ if (!splitResponse.emissionValid) {
1316
+ await this.#db.engine_close_turn.run({
1317
+ id: turnId,
1318
+ status: 500,
1319
+ packet: StoredPacket.stringify(requestPacket),
1320
+ usage_prompt: usage.prompt,
1321
+ usage_completion: usage.completion,
1322
+ usage_reasoning: usage.reasoning,
1323
+ usage_cached: usage.cached,
1324
+ usage_cost: JSON.stringify(providerCosts),
1325
+ usage_cost_usd: usageCostUsd,
1326
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1327
+ finish_reason: splitResponse.callMetadata.finishReason,
1328
+ model: splitResponse.callMetadata.model,
1329
+ meta: JSON.stringify(response.meta ?? {}),
1330
+ });
1331
+ return {
1332
+ turnId,
1333
+ status: 500,
1334
+ statuses: [],
1335
+ fingerprint: "",
1336
+ budgetStruck: enforced.struck,
1337
+ budgetHardStop: false,
1338
+ steerStruck: false,
1339
+ emissionAttempts,
1340
+ emissionExhausted: true,
1341
+ };
1342
+ }
1343
+ // {§packet-stored-shape} — admitted emission data extends the packet;
1344
+ // provider-call metadata remains on the Turn row.
1345
+ const { packetAssistant, callMetadata, parseNotices, recoverableParseErrors, } = splitResponse; // raw assistant content is opaque — split, never interpreted — {§provider-guarantees-assistantraw-opaque}
1346
+ for (const notice of parseNotices) {
1347
+ this.#notices.push(workspaceId, loopId, notice);
1348
+ }
1349
+ // Non-fatal provider transport notices on an accepted turn. Forward each
1350
+ // Notice with a content-offset `line:col`;
1075
1351
  // the model resolves it against its own emission — READ the folded `model` mirror row at the
1076
- // cited lines (§model-entry) — not an embedded snippet that would duplicate the emission.
1077
- for (const event of response.telemetry ?? []) {
1078
- const located = typeof event.position === "number"
1079
- ? this.#offsetToLineColumn(packetAssistant.content, event.position)
1352
+ // cited lines ({§model-entry}) — not an embedded snippet that would duplicate the emission.
1353
+ for (const notice of response.notices ?? []) {
1354
+ const located = typeof notice.position === "number"
1355
+ ? this.#offsetToLineColumn(packetAssistant.content, notice.position)
1080
1356
  : null;
1081
- this.#telemetry.push(workspaceId, loopId, {
1082
- source: event.source,
1083
- kind: event.kind,
1084
- message: event.message ?? "",
1085
- level: event.level ?? "warn", // forward the producer's severity; default for a producer predating the field
1357
+ this.#notices.push(workspaceId, loopId, {
1358
+ source: notice.source,
1359
+ kind: notice.kind,
1360
+ message: notice.message ?? "",
1361
+ level: notice.level,
1086
1362
  ...(located !== null
1087
1363
  ? { position: { type: "content-offset", line: located.line, column: located.column } }
1088
1364
  : {}),
1089
1365
  });
1090
1366
  }
1091
- // {§rail-truth-engine-verdict} (#534) — a configured local GBNF constraint is
1092
- // independently graded by the engine. Endpoint-owned constraints arrive only
1093
- // through provider metadata; absence of local configuration says nothing about them.
1367
+ // Grade configured local evidence independently. Endpoint-owned
1368
+ // constraints remain provider observations. {§rail-truth-engine-verdict}
1094
1369
  let railKeys;
1095
1370
  if (railGrammar !== undefined) {
1371
+ if (railEvidence === undefined)
1372
+ throw new Error("configured GBNF response has no final grammar evidence");
1096
1373
  let verdict = null;
1097
1374
  try {
1098
- verdict = validateGbnf(railGrammar, packetAssistant.content);
1375
+ verdict = validateGbnf(railGrammar, railEvidence.input);
1099
1376
  }
1100
1377
  catch (cause) {
1101
1378
  Engine.#warnRailVerdictGapOnce(cause.message);
1102
1379
  }
1103
- railKeys = { railsAttached: "client", railsVerdict: verdict?.status ?? "unverifiable" };
1104
- const providerGraded = (response.telemetry ?? []).some((e) => e.kind === "grammar_unenforced");
1105
- if (verdict !== null && verdict.status !== "accept" && !providerGraded) {
1106
- const located = this.#offsetToLineColumn(packetAssistant.content, verdict.pos);
1107
- this.#telemetry.push(workspaceId, loopId, {
1380
+ railKeys = {
1381
+ railsAttached: railEvidence.transported ? "client" : "withheld",
1382
+ railsVerdict: verdict?.status ?? "unverifiable",
1383
+ };
1384
+ if (verdict !== null && verdict.status !== "accept") {
1385
+ const contentPosition = verdict.pos >= railEvidence.contentStart
1386
+ ? verdict.pos - railEvidence.contentStart
1387
+ : null;
1388
+ const located = contentPosition === null
1389
+ ? null
1390
+ : this.#offsetToLineColumn(packetAssistant.content, contentPosition);
1391
+ this.#notices.push(workspaceId, loopId, {
1108
1392
  source: "engine:rails",
1109
1393
  kind: "grammar_unenforced",
1110
1394
  message: verdict.status === "reject"
1111
- ? `emission rejects the grammar at code point ${verdict.pos}`
1112
- : `emission is an incomplete grammar sentence (ends mid-match at ${verdict.pos})`,
1395
+ ? `emission rejects the grammar at raw code point ${verdict.pos}`
1396
+ : `emission is an incomplete grammar sentence (ends at raw code point ${verdict.pos})`,
1113
1397
  level: "warn",
1114
- position: { type: "content-offset", line: located.line, column: located.column },
1398
+ ...(located === null
1399
+ ? {}
1400
+ : { position: { type: "content-offset", line: located.line, column: located.column } }),
1115
1401
  });
1116
1402
  }
1117
1403
  }
1118
1404
  const opsCount = packetAssistant.ops.length;
1119
- // PLAN (reasoning) and informational SEND[103] are no-ops, not actions: both are
1120
- // excluded from the real-op count so a PLAN-only or prose-only turn still strikes
1121
- // as no-ops, and the terminal scan ignores 1xx so they never set turnStatus.
1122
- const realOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && !(op.op === "SEND" && op.signal === 103 && op.target === null)).length;
1123
- const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200);
1124
- // §send the terminal contract — two engine error states verify a terminal claim against run
1125
- // state, never trusting the model's code. Both strike via turn.steerStruck (turnErrors,
1126
- // §grinder-strike-coupling): the loop continues, the model sees the steering hint not the strike
1405
+ const finalOp = packetAssistant.ops.at(-1);
1406
+ if (finalOp?.op !== "SEND") {
1407
+ // Text emissions cannot reach this point without a disposition;
1408
+ // this fail-hard guard also keeps Mock's trusted pre-parsed seam
1409
+ // from creating runtime states that production admission forbids.
1410
+ throw new Error("an admitted emission must end in a disposition SEND");
1411
+ }
1412
+ const dispositionSignal = finalOp.signal;
1413
+ if (typeof dispositionSignal !== "number" || !TERMINAL_SEND_SIGNALS.has(dispositionSignal)) {
1414
+ throw new Error("an admitted emission must end in a disposition SEND");
1415
+ }
1416
+ const sendOp = finalOp;
1417
+ // {§send} the terminal contract — engine error states verify a terminal claim against loop
1418
+ // state, never trusting the model's code. They strike via turn.steerStruck
1419
+ // ({§engine-rails}): the loop continues, the model sees the steering hint not the strike
1127
1420
  // count, and a non-resolver spins out to the engine's 500.
1128
1421
  let steerStruck = false;
1129
1422
  // Engine errors raised this turn, minted as op='error' log rows after dispatch (they share the
1130
- // post-dispatch sequence counter). §telemetry-uniform-error-channel
1423
+ // post-dispatch sequence counter). {§operation-result-uniform-error-channel}
1131
1424
  const pendingEngineErrors = [];
1132
- // Terminal adjudication moved to the DISPATCHER (§send-premature-terminate, the unified
1425
+ // Terminal adjudication moved to the DISPATCHER ({§send-premature-terminate}, the unified
1133
1426
  // pending set): the terminal SEND is judged AT ITS OWN DISPATCH — after the emission's
1134
1427
  // earlier ops executed — so a same-turn KILL+[200] repairs in one turn and a same-turn
1135
- // WORK+[200] is caught. A refused terminal (409) strikes via the dispatch-loop check below.
1136
- // Rail #41 (revised): the per-turn requirement is "emit at least one op," not "emit a terminal
1137
- // SEND." SEND is purely a signal verb; many turns pass without one. An empty op list strikes.
1138
- // Provisional here — a terminal REFUSED at dispatch (the pending-set 409, only knowable
1139
- // post-dispatch) demotes the turn back to a continue below: the SEND's signal stays on the
1140
- // row (the un-erased record), but the loop never went terminal, so the turn didn't either.
1141
- // §broken-packet-no-dispatch (#566) — a BROKEN PACKET is structurally incomplete: the
1142
- // provider GUILLOTINED the emission at the completion cap (finish=length) and it failed the
1143
- // parse (empty, or cut mid-op). Its parsed "ops" are a severed frame — garbage (run42: an
1144
- // unclosed `<<PLAN` swallowed a 57k-char runaway into one bogus status-200 PLAN). Nothing
1145
- // dispatches and the turn is a NO-OPS strike, so a repeated runaway can't spin as a 102
1146
- // "continue". The turn still records — the folded `model` mirror + the output_truncated 413 +
1147
- // the parse-error rows — so the model sees WHY next turn and re-emits through the existing
1148
- // error channel (no same-turn re-generate). A "flubbed op" is DIFFERENT: a well-framed turn
1149
- // (finish=stop) whose single op erred dispatches its valid ops and steers on the bad one (the
1150
- // recovery rail); only a severed FRAME is refused wholesale. The formal framing-vs-op-local
1151
- // error split is grammar's to tag.
1152
- const brokenPacket = callMetadata.finishReason === "length"
1153
- && (packetAssistant.content.trim().length === 0 || (parseErrors?.length ?? 0) > 0);
1154
- let turnStatus = brokenPacket
1155
- ? TURN_STATUS_NO_OPS
1156
- : sendOp !== undefined
1157
- ? sendOp.signal
1158
- : realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
1428
+ // WORK+[200] is caught. A refused final disposition (409) strikes via
1429
+ // the dispatch-loop check below.
1430
+ let turnStatus = dispositionSignal;
1159
1431
  // Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
1160
- // The model continued with nothing to do. (Skipped when premature already steered this turn.)
1161
- // #467 (owner criterion) — a turn whose op ATTEMPTS died at the parser is a FAILED-RETRIEVAL
1162
- // turn, not an idle one: the model performed an op; the op died. The root parse-400 speaks
1163
- // alone; stacking the idle-409 on it calls the turn something it factually wasn't (run65's
1164
- // one malformed FIND minted two error rows for one accident). A dispatched-but-failed op is
1165
- // already a mid op, so only the parse-emptied class needs the deference. GBNF makes the
1166
- // emitted-idle shape unemittable (grammar SPEC, no-idle-102); this 409 stays the truly-idle backstop.
1167
- const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length;
1168
- if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0 && parseErrors.length === 0) {
1169
- // One grace turn after a retrieval-only 409 (admins specimen): the refusal steer says
1170
- // "continuing in order to receive results" — a model that obediently waits one bare
1171
- // [102] turn is following OUR advice, and the idle rail was executing it for that.
1172
- // The grace is exactly one turn; a second consecutive idle strikes as ever.
1173
- if (this.#retrievalRefusalGrace.delete(loopId)) {
1174
- // graced — the wait the steer asked for
1175
- }
1176
- else {
1177
- steerStruck = true;
1178
- pendingEngineErrors.push("idle_turn");
1179
- }
1180
- }
1181
- else {
1182
- this.#retrievalRefusalGrace.delete(loopId); // a working turn consumes any pending grace
1432
+ // The model continued with nothing to do.
1433
+ const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length
1434
+ + recoverableParseErrors.length;
1435
+ if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
1436
+ steerStruck = true;
1437
+ pendingEngineErrors.push("idle_turn");
1183
1438
  }
1184
1439
  // Close the turn with the final packet, status, and usage stats.
1185
- const packet = this.#packets.completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
1186
- const { usage, finishReason, model } = callMetadata;
1440
+ const packet = StoredPacket.admit(requestPacket, packetAssistant, response.assistantRaw);
1187
1441
  await this.#db.engine_close_turn.run({
1188
1442
  id: turnId,
1189
1443
  status: turnStatus,
1190
- packet: JSON.stringify(packet),
1444
+ packet: StoredPacket.stringify(packet),
1191
1445
  usage_prompt: usage.prompt,
1192
1446
  usage_completion: usage.completion,
1193
1447
  usage_reasoning: usage.reasoning,
1194
1448
  usage_cached: usage.cached,
1195
- usage_cost_usd: provider.calculateCost(usage), // §provider-surface-calculate-cost
1196
- usage_prompt_budget: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves): the raw n_ctx overstated usable room by the reserve total
1197
- finish_reason: finishReason,
1198
- model,
1199
- // #252 — opaque provider→client metadata passthrough (for example, the
1200
- // provider normalized), plus the ONE service-authored carve-out: the engine's rail
1201
- // keys ({§rail-truth-engine-verdict}) merge over any transitional provider railsMeta.
1449
+ usage_cost: JSON.stringify(providerCosts),
1450
+ usage_cost_usd: usageCostUsd,
1451
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider), // {§tokenomics-client-gauge}
1452
+ finish_reason: callMetadata.finishReason,
1453
+ model: callMetadata.model,
1454
+ // Opaque provider metadata plus engine-authored rail keys.
1455
+ // {§meta-passthrough}, {§rail-truth-engine-verdict}
1202
1456
  meta: JSON.stringify({ ...(response.meta ?? {}), ...(railKeys ?? {}) }),
1203
1457
  });
1204
1458
  // Dispatch model ops starting at nextActionIndex (continues the
@@ -1208,149 +1462,179 @@ export default class Engine {
1208
1462
  // A degenerate op-loop is a sampler failure guarded at generation, not by dropping
1209
1463
  // already-generated work post-hoc. The ceiling is an OPT-IN operator/client bound:
1210
1464
  // when set, overflow ops drop without per-op log entries (no forensics flood) and the
1211
- // model gets one telemetry signal next packet.
1212
- // #232 — a workspace's maxCommands is a tighten-only ceiling: min() the env ceiling.
1465
+ // model gets one notices signal next packet.
1466
+ // {§operator-config-workspace-max-commands} — workspace maxCommands min()s the env ceiling.
1213
1467
  const maxCommands = Math.min(readMaxCommands(), (await WorkspaceSettings.read(this.#db, workspaceId)).maxCommands ?? Number.POSITIVE_INFINITY);
1214
- // PLAN (reasoning) and a terminal SEND (signal ≥ 200, the conclusion) are not
1215
- // actions — they always dispatch and never count against the cap. maxCommands
1216
- // bounds real actions only; maxCommands:0 still admits a plan and a conclusion
1468
+ // PLAN (intended goals) and the final disposition SEND are not actions —
1469
+ // they always dispatch and never count against the cap. maxCommands
1470
+ // bounds real actions only; maxCommands:0 still admits a plan and a disposition
1217
1471
  // (the PLAN/SEND ops, zero actions), which is its only coherent meaning.
1218
- // §broken-packet-no-dispatch (#566) — the severed frame dispatches NOTHING (rationale +
1219
- // status handling at the brokenPacket definition above).
1220
1472
  let realCommands = 0;
1221
- const admittedOps = brokenPacket ? [] : packetAssistant.ops.filter((op) => op.op === "PLAN"
1222
- || (op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200)
1223
- || realCommands++ < maxCommands);
1473
+ const admittedOps = packetAssistant.ops.filter((op) => {
1474
+ const constrained = operationConstraint !== undefined
1475
+ && !operationConstraint.allowedOperations.includes(op.op);
1476
+ return op.op === "PLAN"
1477
+ || op === sendOp
1478
+ || constrained
1479
+ || realCommands++ < maxCommands;
1480
+ });
1224
1481
  const opsToDispatch = scheduleTurnOps(admittedOps);
1225
- await this.#dispatcher.prepareEditBatches(opsToDispatch.filter((statement) => statement.op === "EDIT"), {
1482
+ await this.#dispatcher.prepareEditBatches(opsToDispatch.filter((statement) => statement.op === "EDIT"
1483
+ && (operationConstraint === undefined
1484
+ || operationConstraint.allowedOperations.includes(statement.op))), {
1226
1485
  workspaceId, workerId, loopId, turnId,
1227
1486
  origin, onDispatch,
1228
- turnParseErrors: parseErrors?.length ?? 0,
1229
1487
  });
1230
- // A broken packet's ops aren't max_commands drops — they're refused wholesale, and the
1231
- // output_truncated 413 already tells the model why; don't also mint max_commands_exceeded.
1232
- const droppedCount = brokenPacket ? 0 : opsCount - opsToDispatch.length;
1488
+ const droppedCount = opsCount - opsToDispatch.length;
1233
1489
  const statuses = [];
1234
1490
  // Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
1235
1491
  // so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
1236
1492
  // every op writes one row (the common case).
1237
1493
  let rowSeq = nextActionIndex;
1494
+ let parseErrorsRecorded = false;
1495
+ const recordRecoverableParseErrors = async () => {
1496
+ if (parseErrorsRecorded)
1497
+ return;
1498
+ parseErrorsRecorded = true;
1499
+ for (const error of recoverableParseErrors) {
1500
+ const recorded = await this.#problems.record({
1501
+ workerId,
1502
+ loopId,
1503
+ turnId,
1504
+ sequence: rowSeq++,
1505
+ origin: "model",
1506
+ source: "grammar",
1507
+ result: Results.failure("grammar:parser", "invalid-operation-syntax", 400, error.message, {}, {
1508
+ line: error.line,
1509
+ column: error.column,
1510
+ source: error.source,
1511
+ stage: "parse",
1512
+ recovery: "Correct only the failed operation; sibling operations were retained.",
1513
+ retryable: false,
1514
+ }),
1515
+ });
1516
+ statuses.push(recorded.result.status);
1517
+ onDispatch?.(recorded.id);
1518
+ }
1519
+ };
1238
1520
  for (const statement of opsToDispatch) {
1239
- const result = await this.#dispatcher.dispatch({
1240
- statement, workspaceId, workerId, loopId, turnId,
1241
- sequence: rowSeq,
1242
- origin, onDispatch,
1243
- // §send-200-failed-ops — parse errors mint as rows AFTER this loop; the terminal
1244
- // gate needs them NOW, so the count rides the dispatch context.
1245
- turnParseErrors: parseErrors?.length ?? 0,
1521
+ if (statement.op === "SEND"
1522
+ && typeof statement.signal === "number"
1523
+ && TERMINAL_SEND_SIGNALS.has(statement.signal)) {
1524
+ await recordRecoverableParseErrors();
1525
+ }
1526
+ const result = await observed(// {§observability-boundary}
1527
+ "op.dispatch", { op: statement.op }, async (span) => {
1528
+ const dispatchResult = await this.#dispatcher.dispatch({
1529
+ statement, workspaceId, workerId, loopId, turnId,
1530
+ sequence: rowSeq,
1531
+ ...(operationConstraint === undefined ? {} : { operationConstraint }),
1532
+ origin, onDispatch,
1533
+ });
1534
+ span.setAttribute("status", dispatchResult.status);
1535
+ recordCounter(OPS_DISPATCHED, { op: statement.op, status: dispatchResult.status });
1536
+ return dispatchResult;
1246
1537
  });
1247
1538
  statuses.push(result.status);
1248
- // A refused terminal (the pending-set 409) demotes the turn to a continue: the loop
1249
- // never went terminal, so the turn didn't either (the close persisted the provisional
1250
- // status BEFORE dispatch — run20's T3 stored 200 with a 409-refused SEND). Whether it
1251
- // ALSO strikes is kind-specific (owner ruling): a retrievals-only refusal teaches
1252
- // without striking — atomic-turn-pretrained models pair fetch-and-answer by habit,
1253
- // the refusal is correct each time, and maxTurns bounds the walk; striking executed
1254
- // converging behavior (jumbo/admins specimens: 3 correct refusals → 500 mid-adapt).
1255
- // Streams/children refusals keep the strike — discarding live work stays serious.
1539
+ for (const normalization of result.scopeNormalizations ?? []) {
1540
+ this.#notices.push(workspaceId, loopId, {
1541
+ source: "engine:slicer",
1542
+ kind: "scope_normalized",
1543
+ level: "warn",
1544
+ message: `Scope <${normalization.requested.join(",")}> was normalized to <${normalization.canonical.join(",")}>.`,
1545
+ });
1546
+ }
1547
+ // {§engine-rails} — a refused final disposition leaves both loop
1548
+ // and turn continuing, and its 409 steering ruling strikes once.
1256
1549
  if (statement === sendOp && result.status === 409) {
1257
- if (result.attrs?.retrievalOnly !== true)
1258
- steerStruck = true;
1259
- else
1260
- this.#retrievalRefusalGrace.add(loopId); // the steer says "continuing to receive" — the NEXT turn's obedient wait must not idle-strike
1550
+ steerStruck = true;
1261
1551
  turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
1262
- await this.#db.engine_demote_turn_status.run({ id: turnId, status: turnStatus });
1552
+ await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
1263
1553
  }
1264
- // A [300] question resolves through the proposal system (#346) — whatever the
1554
+ // {§send-300-choices}: a question resolves through the proposal system; whatever the
1265
1555
  // resolution (answer/reject/timeout), the LOOP continues to the turn where the model
1266
1556
  // reads it; the turn record is a continue, never a 300 terminal.
1267
1557
  if (statement === sendOp && sendOp.signal === 300 && result.status !== 409) {
1268
1558
  turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
1269
- await this.#db.engine_demote_turn_status.run({ id: turnId, status: turnStatus });
1559
+ await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
1560
+ }
1561
+ // A broadcast [202] is a conditional wait, not an unconditional turn status:
1562
+ // live work parks at 202; completed-but-unobserved work continues at 102; an
1563
+ // empty join completes at 200. Persist and return the dispatcher's actual ruling.
1564
+ if (statement === sendOp
1565
+ && result.status !== 409
1566
+ && sendOp.target === null
1567
+ && sendOp.signal === 202
1568
+ && result.status !== 202) {
1569
+ turnStatus = result.status;
1570
+ await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
1270
1571
  }
1271
1572
  rowSeq += result.rowsWritten ?? 1;
1272
1573
  }
1273
- // §telemetry-uniform-error-channel — every engine + parse failure mints as an op='error'
1274
- // log row at the turn's next free sequence (after every dispatched row, incl. a multi-file
1275
- // READ's fan-out). One channel: the errors section derives a LogCoordinate pointer from log≥400.
1574
+ await recordRecoverableParseErrors();
1575
+ // Engine rail failures mint as op='error' log rows at the turn's next
1576
+ // free sequence. Bounded syntax failures were recorded in their
1577
+ // authored turn before its terminal disposition.
1276
1578
  let errSeq = rowSeq;
1277
1579
  // max_commands_exceeded IS model-facing: dropped ops the model emitted that didn't run.
1278
1580
  if (droppedCount > 0)
1279
1581
  pendingEngineErrors.push("max_commands_exceeded");
1280
- for (const kind of pendingEngineErrors)
1281
- await this.#telemetry.mintEngineError(kind, { workerId, loopId, turnId, sequence: errSeq++ });
1282
- // §log-row-self-explains (Q2, owner-clarified) — a model-op failure is the MODEL'S OWN op
1283
- // result: the op row carries its failure message on its meta line (packet-wire), and the
1284
- // errors section points at the row. No separate minted item (the retired action_failure
1285
- // mint dressed op results as source:"engine" faults — the jumbo model chased a phantom
1286
- // "engine run 400 error" off a message-less item). Genuine engine-internal faults CRASH
1287
- // (fail-hard, §turn-never-blank) and never mint model-facing rows.
1288
- // §tokenomics-output-truncated — packet honesty at the completion cap: a finish=length turn was
1289
- // GUILLOTINED at the decode pool, and this actionless row MINTS to LEAD the artifact rows it
1290
- // explains (the cause must lead, or the model fixes syntax forever). Two shapes, honestly
1291
- // distinguished: content cut MID-OP (a valid prefix dispatched; the parse errors are the
1292
- // severed tail) vs the pool consumed with NOTHING emitted (reasoning ran away — the parse
1293
- // 'must begin with PLAN' is an artifact of the empty emission, not a malformed turn). The parse
1294
- // rows below STAY (the record never hides); the 413 leads and frames them for what they are.
1295
- const truncatedEmpty = finishReason === "length" && packetAssistant.content.trim().length === 0;
1296
- if (finishReason === "length" && (truncatedEmpty || (parseErrors?.length ?? 0) > 0)) {
1297
- const cap = this.#packets.maxTokensFor(provider);
1298
- const message = truncatedEmpty
1299
- ? `output truncated at the completion cap (${cap} tokens): nothing was emitted before the pool was consumed — the parse error below is an artifact of the empty emission, not a malformed turn`
1300
- : `output truncated at the completion cap (${cap} tokens): the emission was cut mid-op — the parse errors below are truncation artifacts`;
1301
- await this.#db.engine_insert_log_entry.get({
1302
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1303
- origin: "model", source: "engine", op: "error", suffix: "", signal: null,
1304
- scheme: null, username: null, password: null, hostname: null, port: null,
1305
- pathname: null, params: null, fragment: null, lineMarker: null,
1306
- tx: "", mimetype_tx: "text/plain",
1307
- rx: JSON.stringify({ status: 413, kind: "output_truncated", message }),
1308
- mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "output_truncated",
1309
- attrs: "{}",
1310
- });
1311
- }
1312
- // Parse errors carry the parser message + a content-offset line:col (a ContentOffset position),
1313
- // resolved against the model's folded mirror row (§model-entry) — origin 'model', not engine.
1314
- for (const { message, line, column, source } of parseErrors ?? []) {
1315
- await this.#db.engine_insert_log_entry.get({
1316
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1317
- origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
1318
- scheme: null, username: null, password: null, hostname: null, port: null,
1319
- pathname: null, params: null, fragment: null, lineMarker: null,
1320
- tx: "", mimetype_tx: "text/plain",
1321
- // The error carries the parser message + a content-offset `line:col`; the model READs
1322
- // its own folded mirror row (§model-entry) at the cited lines, so no snippet is
1323
- // embedded. The derived errors-section pointer stays minimal (status + coordinate).
1324
- rx: JSON.stringify({ message, position: { type: "content-offset", line, column }, parserSource: source }),
1325
- mimetype_rx: "application/json",
1326
- status_rx: 400, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
1582
+ for (const kind of pendingEngineErrors) {
1583
+ const problem = ENGINE_PROBLEMS[kind];
1584
+ const extensions = kind === "max_commands_exceeded"
1585
+ ? {
1586
+ operationLimit: maxCommands,
1587
+ omittedOperations: droppedCount,
1588
+ stage: "dispatch-admission",
1589
+ recovery: "Continue with no more than the configured operation limit.",
1590
+ retryable: false,
1591
+ }
1592
+ : {
1593
+ stage: "turn",
1594
+ recovery: "Perform an operation before continuing with SEND[102].",
1595
+ retryable: false,
1596
+ };
1597
+ await this.#problems.record({
1598
+ workerId,
1599
+ loopId,
1600
+ turnId,
1601
+ sequence: errSeq++,
1602
+ origin: "plurnk",
1603
+ source: "rail",
1604
+ result: Results.failure("engine:rail", problem.code, problem.status, problem.detail, {}, extensions),
1327
1605
  });
1328
1606
  }
1329
- // §model-entry — mirror this turn's verbatim emission back as a `model` row, so the NEXT
1607
+ // {§log-row-self-explains} — model-operation failures remain on their
1608
+ // own rows; genuine engine faults fail hard and mint no substitute row.
1609
+ // {§model-entry} — mirror this turn's verbatim emission back as a `model` row, so the NEXT
1330
1610
  // packet shows the model exactly what it last produced. ALWAYS born FOLDED — the old
1331
1611
  // born-OPEN-on-error auto-trigger was conditional helpfulness that bred its own hazards
1332
1612
  // (a 24k-char ramble mirrored open re-injects itself into the next packet: cost,
1333
- // contamination, pressure feedback). An error's line:col resolves the same way anything
1334
- // else does: the model that cares READs the folded row at the lines it wants — and can
1335
- // introspect any prior emission of its own the same way. Empty emissions (a struck/
1336
- // silent turn) write nothing — no prior output to mirror.
1337
- const sealed = response.assistant.reasoningEncrypted;
1338
- // #482 — relay the FULL item ARRAY verbatim (agui projects one correlated span per item), so a
1339
- // multi-id turn serves N entities, not a collapsed first. Providers already expose the array.
1340
- const reasoningItems = sealed !== undefined && sealed.length > 0 ? sealed : undefined;
1613
+ // contamination, pressure feedback).
1614
+ // {§encrypted-reasoning-carrier} — core relays every provider-normalized
1615
+ // item unchanged.
1616
+ const reasoningItems = response.assistant.reasoningEncrypted?.length
1617
+ ? response.assistant.reasoningEncrypted
1618
+ : undefined;
1341
1619
  if (packetAssistant.content.trim().length > 0 || reasoningItems !== undefined) {
1342
1620
  await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, workerId, loopId, turnId, sequence: errSeq++, folded: true, ...(reasoningItems !== undefined ? { reasoningItems } : {}) });
1343
1621
  }
1344
- // Zero ops is NOT an error to report — the model knows it emitted
1345
- // nothing. Strike accounting (engine-internal) treats it as a
1346
- // struck turn; the model just sees an empty packet next turn.
1347
- // Per SPEC §telemetry gamification policy.
1348
- return { turnId, status: turnStatus, statuses, fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops), budgetStruck: enforced.struck, budgetHardStop: false, steerStruck };
1622
+ return {
1623
+ turnId,
1624
+ status: turnStatus,
1625
+ statuses,
1626
+ fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops),
1627
+ budgetStruck: enforced.struck,
1628
+ budgetHardStop: false,
1629
+ steerStruck,
1630
+ emissionAttempts,
1631
+ emissionExhausted: false,
1632
+ };
1349
1633
  }
1350
1634
  // Split the wire-level ProviderResponse into the two destinations:
1351
1635
  // packet.assistant gets the model's emission (content, ops, reasoning);
1352
1636
  // Turn columns get the call-metadata (usage, finishReason, model).
1353
- // SPEC §provider-surface / plurnk-providers#1: text-fragment scraping policy lives
1637
+ // {§provider-surface} Text-fragment scraping policy lives
1354
1638
  // here — engine owns the parse and the scraping rule, providers stay
1355
1639
  // grammar-unaware.
1356
1640
  //
@@ -1364,19 +1648,25 @@ export default class Engine {
1364
1648
  const ops = [];
1365
1649
  // PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
1366
1650
  // client as a log entry. No special hoisting into the reasoning field (that
1367
- // legacy paradigm is abandoned). Interstitial free text is DROPPED — the prior
1651
+ // only structured operations are executable). Interstitial free text is DROPPED — the prior
1368
1652
  // #free-text-capture synthesis of SEND[103] log ops was retired as tech debt
1369
1653
  // (grammar 0.70 forbids free text between ops, so a prose-only turn strikes 422).
1370
- // Full PlurnkParseError context (line/column/source) is preserved
1371
- // here so runTurn can build TelemetryEvent envelopes per the
1372
- // grammar 0.17.0 protocol — model needs position info to locate
1373
- // its own offending content on the next turn.
1654
+ // Full PlurnkParseError context is preserved on rejected attempt evidence;
1655
+ // warnings remain admissible Notices. {§parse-diagnostics}
1374
1656
  const parseErrors = [];
1657
+ let hasUnparsedTail = false;
1658
+ const parseNotices = [];
1375
1659
  if (preParsedOps !== undefined) {
1376
1660
  ops.push(...preParsedOps);
1377
1661
  }
1378
1662
  else {
1379
- const parsed = PlurnkParser.parse(assistant.content);
1663
+ // {§observability-boundary} — the parse is observed without its input;
1664
+ // only the resulting statement count is attributable.
1665
+ const parsed = observedSync("contracts.parse", {}, (span) => {
1666
+ const result = PlurnkParser.parse(assistant.content);
1667
+ span.setAttribute("statements", result.items.filter((item) => item.kind === "statement").length);
1668
+ return result;
1669
+ });
1380
1670
  for (const item of parsed.items) {
1381
1671
  if (item.kind === "statement") {
1382
1672
  ops.push(item.statement);
@@ -1385,7 +1675,23 @@ export default class Engine {
1385
1675
  else if (item.kind === "error") {
1386
1676
  const err = item.error;
1387
1677
  if (err instanceof PlurnkParseError) {
1388
- parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
1678
+ if (err.severity === "warning") {
1679
+ parseNotices.push({
1680
+ source: "grammar",
1681
+ kind: "parse_advisory",
1682
+ level: "warn",
1683
+ message: err.message,
1684
+ position: {
1685
+ type: "content-offset",
1686
+ line: err.line,
1687
+ column: err.column,
1688
+ },
1689
+ parserSource: err.source,
1690
+ });
1691
+ }
1692
+ else {
1693
+ parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
1694
+ }
1389
1695
  }
1390
1696
  else {
1391
1697
  const msg = err?.message ?? "parse error";
@@ -1393,72 +1699,100 @@ export default class Engine {
1393
1699
  }
1394
1700
  }
1395
1701
  }
1396
- // The grammar also reports an `unparsedTail` when input ends
1397
- // mid-statement (a body opened but never closed): its `reason`
1398
- // names the op AND the fix ("…never closed — add `:READ`"), where
1399
- // the item-level error only says "expected close tag" for a tag the
1400
- // model thinks it already wrote. Surface it — phenomenal messages
1401
- // the model can self-correct from are the whole point of the DSL.
1702
+ // Boundary loss is the parser's one public fact from `unparsedTail.from` onward;
1703
+ // preserve it with the rejected forensic attempt. {§unparsed-tail-boundary}
1402
1704
  const tail = parsed.unparsedTail;
1403
1705
  if (tail !== undefined) {
1706
+ hasUnparsedTail = true;
1404
1707
  parseErrors.push({ message: tail.reason, line: tail.from.line, column: tail.from.column, source: "grammar" });
1405
1708
  }
1406
1709
  }
1710
+ const plan = ops[0]?.op === "PLAN" ? ops[0] : undefined;
1711
+ const finalOp = ops.at(-1);
1712
+ const terminalSend = finalOp?.op === "SEND"
1713
+ && typeof finalOp.signal === "number"
1714
+ && TERMINAL_SEND_SIGNALS.has(finalOp.signal)
1715
+ ? finalOp
1716
+ : undefined;
1717
+ const recoverableParseErrors = plan !== undefined && terminalSend !== undefined && !hasUnparsedTail
1718
+ ? parseErrors.filter((error) => comparePosition(error, plan.position) > 0
1719
+ && comparePosition(error, terminalSend.position) < 0).toSorted(comparePosition)
1720
+ : [];
1721
+ const emissionValid = preParsedOps !== undefined
1722
+ || (plan !== undefined
1723
+ && terminalSend !== undefined
1724
+ && !hasUnparsedTail
1725
+ && recoverableParseErrors.length === parseErrors.length);
1407
1726
  const reasoning = assistant.reasoning ?? null;
1408
1727
  return {
1409
1728
  packetAssistant: { content: assistant.content, ops, reasoning },
1410
1729
  callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
1411
1730
  parseErrors,
1731
+ recoverableParseErrors: emissionValid ? recoverableParseErrors : [],
1732
+ parseNotices,
1733
+ // The ANTLR model-turn parser is authoritative. A trustworthy
1734
+ // PLAN...SEND frame admits bounded interior statement failures so
1735
+ // they become durable operation results. Missing boundaries,
1736
+ // errors outside the frame, and an unparsed tail reject wholesale.
1737
+ // Pre-parsed ops are Mock's trusted test seam.
1738
+ emissionValid,
1412
1739
  };
1413
1740
  }
1414
1741
  // #note12 — the plugin-provided reference docs (schemes' + execs' `documentation`),
1415
- // materialized at plurnk:///docs/<name>.md by loop_run (like operator docs).
1742
+ // materialized at worker://plurnk/docs/<name>.md by LoopDocs (like operator docs).
1416
1743
  docEntries(workspaceId) {
1417
1744
  return this.#packets.docEntries(workspaceId);
1418
1745
  }
1419
- // §env-delta (§actor-boundary-no-mutex: runs share without locks; a conflict surfaces as a delta, never prevented) — at pre-turn build, surface what changed in the shared world since this
1420
- // run last looked. No per-worker snapshot (§machine-processes "a worker is its log"): every
1421
- // edit is already a span-carrying log row, so PULL other actors' EDITs on shared
1422
- // entries since this worker's prior turn — real cross-worker edits and the plurnk worker's
1423
- // fs-sync fictions — and materialize each as a FOLDED delta reusing the row's span +
1424
- // cause. Returns the count so the caller advances nextActionIndex past the deltas.
1746
+ // {§env-delta-log-pull} — materialize one closed interval of the ambient
1747
+ // occurrence journal into this worker's self-contained log. #67 owns only
1748
+ // the remaining model-facing actor-name projection.
1425
1749
  async #materializeEnvironmentDeltas(args) {
1426
1750
  const { workspaceId, workerId, loopId, turnId, fromSequence } = args;
1427
- const boundary = await this.#db.engine_worker_prior_turn_time.get({ worker_id: workerId, turn_id: turnId });
1428
- const since = boundary?.since ?? null;
1429
- if (since === null)
1430
- return 0; // first turn — nothing prior; the model reads current state fresh
1431
- const rows = await this.#db.engine_pull_env_deltas.all({ workspace_id: workspaceId, worker_id: workerId, since });
1751
+ const rows = await this.#db.engine_pull_ambient_events.all({ workspace_id: workspaceId, worker_id: workerId });
1752
+ const window = rows[0];
1753
+ if (window === undefined)
1754
+ throw new Error(`ambient pull: worker ${workerId} has no observation window`);
1432
1755
  let written = 0;
1433
1756
  for (const r of rows) {
1434
- // source: the originating run (a real cross-worker edit) or 'file' (an fs fiction);
1435
- // rx reuses the originating row's result span — the edit as it looked then.
1436
- await this.#db.engine_insert_env_delta.run({
1437
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1438
- source: r.source ?? String(r.worker_id), scheme: r.scheme, pathname: r.pathname, rx: r.rx, attrs: r.attrs,
1439
- });
1440
- written++;
1441
- }
1442
- // §worker-scheme — loop-terminations: a sibling's loop reaching terminal surfaces the
1443
- // same way an entry-change does, carrying its deliverable (the SEND body) or the
1444
- // abandonment reason. Folded, attributed to the terminated run.
1445
- const terms = await this.#db.engine_pull_loop_terminations.all({ workspace_id: workspaceId, worker_id: workerId, since });
1446
- for (const t of terms) {
1447
- await this.#db.engine_insert_loop_termination_delta.run({
1757
+ if (r.event_id === null || r.producer_worker_id === null || r.producer_worker_name === null || r.kind === null
1758
+ || r.op === null || r.status_rx === null)
1759
+ continue;
1760
+ const termination = r.kind === "loop_termination";
1761
+ const rx = termination
1762
+ ? BranchReceipt.append(markTerminal(r.terminated_by, r.rx) ?? `loop "${r.prompt ?? ""}" ended (${r.status_rx})`, await BranchReceipt.render(this.#db, r.producer_worker_id))
1763
+ : r.rx;
1764
+ if (rx === null)
1765
+ throw new Error(`ambient event ${r.event_id} has no materializable result`);
1766
+ const inserted = await this.#db.engine_insert_ambient_delta.get({
1448
1767
  worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1449
- source: String(t.worker_id), pathname: `/${t.worker_name}`,
1450
- rx: markTerminal(t.terminated_by, t.terminal_message) ?? `loop "${t.prompt}" ended (${t.status})`,
1451
- status: t.status,
1768
+ event_id: r.event_id,
1769
+ source: r.source ?? WorkerControlAddress.render(r.producer_worker_name),
1770
+ op: r.op,
1771
+ scheme: r.scheme,
1772
+ hostname: r.hostname,
1773
+ pathname: r.pathname,
1774
+ rx,
1775
+ mimetype_rx: termination ? "text/markdown" : "application/json",
1776
+ status: r.status_rx,
1777
+ expanded: termination && r.status_rx >= 200 && r.status_rx < 300 ? 1 : 0,
1778
+ attrs: r.attrs ?? "{}",
1452
1779
  });
1453
- written++;
1780
+ if (inserted !== undefined)
1781
+ written++;
1454
1782
  }
1783
+ await this.#db.engine_advance_ambient_cursor.get({
1784
+ workspace_id: workspaceId,
1785
+ worker_id: workerId,
1786
+ cursor: window.cursor,
1787
+ boundary: window.boundary,
1788
+ });
1455
1789
  return written;
1456
1790
  }
1457
- // §exec-poll — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
1791
+ // {§exec-poll} — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
1458
1792
  // owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
1459
1793
  // before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
1460
1794
  // never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
1461
- // terminal output surfaces born-OPEN through the stream-delta path (§exec-stream).
1795
+ // terminal output surfaces born-OPEN through the stream-delta path ({§exec-stream}).
1462
1796
  async #reapTurnScopedStreams(workerId) {
1463
1797
  const open = await this.#db.find_open_turn_scoped_subscriptions_for_worker.all({ worker_id: workerId });
1464
1798
  await Promise.all(open.map(({ id }) => this.#liveSubscriptions.cancel(id)));
@@ -1466,11 +1800,12 @@ export default class Engine {
1466
1800
  cancelSubscription(subscriptionId) {
1467
1801
  return this.#liveSubscriptions.cancel(subscriptionId);
1468
1802
  }
1469
- // §env-delta — exec streams as an instance of the ambient-observe machine:
1470
- // each turn, emit each owned channel's unshown byte-delta as a foisted READ@200 row. Folded
1471
- // while the channel streams; the terminal delta (channel closed) auto-OPENs. The cursor is the
1803
+ // {§env-delta} — exec streams as an instance of the ambient-observe machine:
1804
+ // each turn, emit each owned channel's unshown byte-delta as a foisted READ row. It is 200
1805
+ // while the channel streams and preserves the exact terminal result when closed. Ongoing
1806
+ // deltas fold; the terminal delta auto-OPENs. The cursor is the
1472
1807
  // streamEnd recorded on the channel's prior delta — no exec-specific surfacing, just the
1473
- // env-observe loop with a byte cursor where env-delta uses a timestamp. §exec-stream
1808
+ // env-observe loop with a byte cursor where env-delta uses a timestamp. {§exec-stream}
1474
1809
  async #materializeStreamDeltas(args) {
1475
1810
  const { workerId, loopId, turnId, fromSequence } = args;
1476
1811
  const channels = await this.#db.engine_worker_stream_channels.all({ worker_id: workerId });
@@ -1490,6 +1825,28 @@ export default class Engine {
1490
1825
  const priorAttrs = prior !== undefined ? JSON.parse(prior.attrs) : {};
1491
1826
  const cursor = priorAttrs.streamEnd ?? 0;
1492
1827
  const closed = ch.state === "closed" || ch.state === "errored";
1828
+ const terminal = closed
1829
+ ? Results.assert(JSON.parse(ch.close_result ?? "null"))
1830
+ : null;
1831
+ const terminalResult = async (fields, sequence) => {
1832
+ if (terminal === null)
1833
+ throw new Error(`closed subscription ${ch.subscription_id} has no terminal result`);
1834
+ const result = Results.assert({
1835
+ ...terminal,
1836
+ ...(terminal.problem === undefined ? {} : { problem: { ...terminal.problem } }),
1837
+ ...fields,
1838
+ });
1839
+ if (result.problem !== undefined) {
1840
+ const seqs = await this.#db.engine_loop_turn_seqs.get({
1841
+ loop_id: loopId,
1842
+ turn_id: turnId,
1843
+ });
1844
+ if (seqs === undefined)
1845
+ throw new Error(`stream delta has no log coordinate for loop=${loopId} turn=${turnId}`);
1846
+ Results.attachInstance(result, `log:///${seqs.loop_seq}/${seqs.turn_seq}/${sequence}/READ`);
1847
+ }
1848
+ return result;
1849
+ };
1493
1850
  if (ch.content.length <= cursor) {
1494
1851
  // The cursor-terminal race (owner's dogfood find): a channel written in one final
1495
1852
  // burst gets fully shown FOLDED while still active; the close then has zero new
@@ -1497,19 +1854,27 @@ export default class Engine {
1497
1854
  // the conclusion of a stream whose result it already holds folded. The same
1498
1855
  // observation is required when the stream produced zero bytes: completion is
1499
1856
  // information independently of payload. Emit the terminal marker ONCE: open,
1500
- // terse, carrying the close status (§tokenomics-fetch-fits-free).
1857
+ // terse, carrying the close status ({§tokenomics-fetch-fits-free}).
1501
1858
  if (closed && priorAttrs.terminal !== true) {
1859
+ const streamTarget = renderTarget({
1860
+ scheme: ch.runtime,
1861
+ pathname: ch.coord,
1862
+ fragment: visibleFragment,
1863
+ });
1864
+ if (streamTarget === null)
1865
+ throw new Error(`stream ${ch.subscription_id} has no renderable address`);
1502
1866
  const pointer = cursor > 0
1503
- ? `full output already delivered above; READ ${ch.runtime}://${ch.coord}${visibleFragment === null ? "" : `#${visibleFragment}`} to revisit`
1867
+ ? `full output already delivered above; READ ${streamTarget} to revisit`
1504
1868
  : "stream produced no output";
1869
+ const sequence = fromSequence + written;
1505
1870
  await this.#db.engine_insert_stream_delta.run({
1506
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1871
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
1507
1872
  scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
1508
- rx: JSON.stringify({
1509
- status: ch.close_status ?? 200,
1510
- content: `[ stream closed (${ch.close_status ?? 200}) — ${pointer} ]`,
1873
+ rx: JSON.stringify(await terminalResult({
1874
+ content: `[ stream closed (${ch.close_status ?? 200}) - ${pointer} ]`,
1511
1875
  mimetype: "text/stream",
1512
- }),
1876
+ }, sequence)),
1877
+ status: terminal?.status ?? 200,
1513
1878
  attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
1514
1879
  expanded: 1,
1515
1880
  });
@@ -1518,51 +1883,47 @@ export default class Engine {
1518
1883
  continue;
1519
1884
  }
1520
1885
  // startLine continues the line count across turns: a multi-turn stream's deltas number
1521
- // into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. §exec-stream
1886
+ // into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. {§exec-stream}
1522
1887
  const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
1888
+ const sequence = fromSequence + written;
1889
+ const result = closed
1890
+ ? await terminalResult({ content: ch.content.slice(cursor), mimetype: "text/stream", startLine }, sequence)
1891
+ : { status: 200, content: ch.content.slice(cursor), mimetype: "text/stream", startLine };
1523
1892
  await this.#db.engine_insert_stream_delta.run({
1524
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1893
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
1525
1894
  scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
1526
- rx: JSON.stringify({ status: 200, content: ch.content.slice(cursor), mimetype: "text/stream", startLine }),
1895
+ rx: JSON.stringify(result),
1896
+ status: result.status,
1527
1897
  attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: closed }),
1528
- expanded: closed ? 1 : 0, // §exec-stream — terminal delta auto-OPENs; ongoing folds
1898
+ expanded: closed ? 1 : 0, // {§exec-stream} — terminal delta auto-OPENs; ongoing folds
1529
1899
  });
1530
1900
  written++;
1531
1901
  }
1532
1902
  return written;
1533
1903
  }
1534
- // §env-delta — the filesystem as an actor. Ambient disk divergences detected at
1535
- // pre-turn (git membership re-read) are logged as the plurnk worker's source=file EDIT
1536
- // "fictions": no op happened, but EDIT is the only grammar the model has for "your
1537
- // world changed," so the fiction keeps its perspective aligned with what its tooling
1538
- // would show. The fiction lives in the plurnk worker's log; every other run pulls it
1539
- // through the one delta path, exactly like a sibling's real edit.
1540
- // §membership-emi-divergence-signal — disk divergences logged as the plurnk worker's source=file EDIT fictions
1904
+ // {§env-delta-filesystem-narration} {§membership-emi-divergence-signal}
1905
+ // — journal project-file divergence once through the reserved actor.
1541
1906
  async #logFsFictions(workspaceId, divergences) {
1542
1907
  if (divergences.length === 0)
1543
1908
  return;
1544
- const run = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
1909
+ const worker = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
1545
1910
  ?? await this.#db.envelope_insert_worker.get({ workspace_id: workspaceId, name: "plurnk", origin: "plurnk" });
1546
- if (run === undefined)
1911
+ if (worker === undefined)
1547
1912
  throw new Error("logFsFictions: plurnk worker resolution returned no row");
1548
- const loop = await this.#db.envelope_insert_client_loop.get({ worker_id: run.id });
1913
+ const loop = await this.#db.envelope_insert_client_loop.get({ worker_id: worker.id });
1549
1914
  if (loop === undefined)
1550
1915
  throw new Error("logFsFictions: loop insert returned no row");
1551
- const seq = await this.#db.client_turn_next_sequence.get({ loop_id: loop.id });
1552
- const turn = await this.#db.client_turn_insert.get({ loop_id: loop.id, sequence: seq?.next ?? 1, packet: "{}" });
1553
- if (turn === undefined)
1554
- throw new Error("logFsFictions: turn insert returned no row");
1916
+ const turn = await JournalTurn.insert(this.#db, loop.id);
1555
1917
  let sequence = 1;
1556
1918
  for (const d of divergences) {
1557
1919
  const span = editedSpan(d.before, d.after);
1558
1920
  await this.#db.engine_insert_log_entry.get({
1559
- worker_id: run.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
1921
+ worker_id: worker.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
1560
1922
  origin: "plurnk", source: "file", op: "EDIT", suffix: "", signal: null,
1561
- // `file` is an entry-routing scheme, never a stored log scheme. Match
1562
- // Dispatcher.#extractTarget so the fiction and a model's file EDIT
1563
- // address the same nullable log key.
1564
- scheme: d.scheme === "file" ? null : d.scheme, username: null, password: null, hostname: null, port: null,
1565
- pathname: d.pathname, params: null, fragment: null, lineMarker: null,
1923
+ // Match Dispatcher.#extractTarget: a bare file address has NULL scheme
1924
+ // only in log target metadata; its entry identity remains `file`.
1925
+ scheme: null, username: null, password: null, hostname: null, port: null,
1926
+ pathname: d.pathname, query: null, fragment: null, lineMarker: null,
1566
1927
  tx: "", mimetype_tx: "text/plain",
1567
1928
  rx: JSON.stringify({ status: 200, entryId: d.entryId, channel: d.channel, span }), mimetype_rx: "application/json",
1568
1929
  status_rx: 200, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
@@ -1570,39 +1931,81 @@ export default class Engine {
1570
1931
  }
1571
1932
  }
1572
1933
  async dispatch(context) {
1573
- if (context.statement.op === "EDIT") {
1574
- const { statement, sequence: _sequence, ...batchContext } = context;
1575
- await this.#dispatcher.prepareEditBatches([statement], batchContext);
1576
- }
1577
- return this.#dispatcher.dispatch(context);
1934
+ return observed(// {§observability-boundary}
1935
+ "op.dispatch", { op: context.statement.op }, async (span) => {
1936
+ if (context.statement.op === "EDIT") {
1937
+ const { statement, sequence: _sequence, ...batchContext } = context;
1938
+ await this.#dispatcher.prepareEditBatches([statement], batchContext);
1939
+ }
1940
+ const result = await this.#dispatcher.dispatch(context);
1941
+ span.setAttribute("status", result.status);
1942
+ return result;
1943
+ });
1578
1944
  }
1579
- // op.look (#283) — resolve a READ and return its content WITHOUT writing a
1580
- // log_entries row: the client's off-run inspection primitive. {§op-look}
1945
+ // {§op-look}: resolve a READ without writing a log_entries row.
1581
1946
  async look(context) {
1582
1947
  return this.#dispatcher.look(context);
1583
1948
  }
1584
- // External API to feed a resolution into a pending proposal — the loop/resolve
1585
- // RPC handler, the in-tree auto listener, or the timeout watcher.
1586
- // Shutdown lane: settle every pending proposal with a cancel so a stopped world can never
1587
- // deadlock the stop (§proposal-cancel-aborts; the #344 wedge class).
1949
+ async resolveEntryAddress(context) {
1950
+ return this.#dispatcher.resolveEntryAddress(context);
1951
+ }
1952
+ async #writePromptLog({ workerId, loopId, turnId, sequence, target, content, }) {
1953
+ const row = await this.#db.engine_insert_log_entry.get({
1954
+ worker_id: workerId,
1955
+ loop_id: loopId,
1956
+ turn_id: turnId,
1957
+ sequence,
1958
+ origin: "plurnk",
1959
+ source: null,
1960
+ op: "prompt",
1961
+ suffix: "",
1962
+ signal: null,
1963
+ scheme: target.scheme,
1964
+ username: target.username,
1965
+ password: target.password,
1966
+ hostname: target.hostname,
1967
+ port: target.port,
1968
+ pathname: target.pathname,
1969
+ query: target.query,
1970
+ fragment: target.fragment,
1971
+ lineMarker: null,
1972
+ tx: "",
1973
+ mimetype_tx: "text/plain",
1974
+ rx: JSON.stringify({ content, mimetype: "text/markdown" }),
1975
+ mimetype_rx: "application/json",
1976
+ status_rx: 200,
1977
+ tokens: this.#tokenize(content),
1978
+ state: "resolved",
1979
+ outcome: null,
1980
+ attrs: "{}",
1981
+ });
1982
+ if (row === undefined)
1983
+ throw new Error("Engine.#writePromptLog: INSERT ... RETURNING produced no row");
1984
+ return row.id;
1985
+ }
1986
+ // External API to feed a resolution into a pending proposal — the client-interface
1987
+ // seam, core-owned disposition, or the timeout watcher.
1988
+ // {§worker-lifecycle-total-reap}: release every stopped-world waiter before joining drains.
1588
1989
  cancelAllProposals(outcome) {
1589
1990
  this.#proposals.cancelAll(outcome);
1590
1991
  }
1591
1992
  resolveProposal(logEntryId, resolution) {
1592
1993
  this.#proposals.resolve(logEntryId, resolution);
1593
1994
  }
1594
- // Snapshot of pending proposals (for diagnostic / RPC listings).
1995
+ // Snapshot of pending proposals for client-interface discovery.
1595
1996
  pendingProposalIds() {
1596
1997
  return this.#proposals.pendingIds();
1597
1998
  }
1598
- // Subscribe to proposal-pending events. Daemon registers a listener
1599
- // that broadcasts the loop/proposal WS notification; auto listener
1600
- // registers one that auto-resolves.
1999
+ // Subscribe to proposal-pending observations. Automatic settlement is
2000
+ // core-owned and happens before observers run.
1601
2001
  onProposalPending(listener) {
1602
2002
  this.#proposals.onPending(listener);
1603
2003
  }
2004
+ async pendingProposals(workspaceId) {
2005
+ return this.#proposals.list(workspaceId);
2006
+ }
1604
2007
  // Used by wake-on-completion (daemon side): "is there any loop in this
1605
- // run still accepting turns?" If yes, skip the wake — the active loop
2008
+ // worker still accepting turns?" If yes, skip the wake — the active loop
1606
2009
  // will pick up the channel transition at its next turn boundary. If no,
1607
2010
  // the daemon opens a fresh loop with the wake prompt.
1608
2011
  async hasActiveLoopForWorker(workerId) {
@@ -1610,7 +2013,7 @@ export default class Engine {
1610
2013
  return (row?.n ?? 0) > 0;
1611
2014
  }
1612
2015
  // Workspace-scope eager warm: creation and membership changes start the
1613
- // exhaustive graph/FTS/vector derivation immediately. The RPC returns while
2016
+ // exhaustive graph/FTS/vector derivation immediately. The seam call returns while
1614
2017
  // progress live-fans-out at loopId 0; a model turn joins this same coalesced
1615
2018
  // promise and cannot reach its provider until coverage is complete.
1616
2019
  async warmWorkspaceDerivations(workspaceId) {
@@ -1623,23 +2026,33 @@ export default class Engine {
1623
2026
  tokenize: this.#tokenize,
1624
2027
  mimetypes: this.#mimetypes,
1625
2028
  defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
1626
- pushTelemetry: (event) => this.#telemetry.notify(workspaceId, 0, event),
2029
+ pushNotice: (notice) => this.#notices.notify(workspaceId, 0, notice),
1627
2030
  };
1628
2031
  await this.#queueWorkspaceWarm(ctx); // materialize first; overlapping requests coalesce and rescan
1629
2032
  }
1630
- // Inject a prompt into the worker's currently-executing loop. Writes a
1631
- // plurnk://prompt/<run>/<loop>/<next-turn> entry whose body becomes the
1632
- // prompt section at the next turn boundary. Last-wins: if two
1633
- // injects target the same next-turn slot, the second overwrites the
1634
- // first.
2033
+ // Inject a prompt into the worker's current non-terminal loop. Writes the
2034
+ // next owner-keyed prompt:///<loop>/<N> entry; the next turn publishes it
2035
+ // as one actionless prompt row. Prompt-frame writes serialize per worker,
2036
+ // so concurrent arrivals retain distinct ordered ordinals.
1635
2037
  //
1636
- // Returns null when no loop in the worker is currently active (status=102).
2038
+ // Returns null when no loop in the worker is active or parked (102/202).
1637
2039
  // The daemon-side inject path then enqueues a fresh loop with this
1638
2040
  // prompt; engine doesn't open loops itself.
1639
- //
1640
- // Rummy parallel: AgentLoop.inject(). The "active drain → write
1641
- // prompt entry, return immediately" branch.
1642
- async inject(workerId, prompt) {
2041
+ inject(workerId, prompt, openPaths = []) {
2042
+ return this.#withPromptWriteLock(workerId, () => this.#injectPrompt(workerId, prompt, openPaths));
2043
+ }
2044
+ #withPromptWriteLock(workerId, write) {
2045
+ const previous = this.#promptWriteLocks.get(workerId) ?? Promise.resolve();
2046
+ const run = previous.then(write, write);
2047
+ const tail = run.catch(() => { });
2048
+ this.#promptWriteLocks.set(workerId, tail);
2049
+ void tail.then(() => {
2050
+ if (this.#promptWriteLocks.get(workerId) === tail)
2051
+ this.#promptWriteLocks.delete(workerId);
2052
+ });
2053
+ return run;
2054
+ }
2055
+ async #injectPrompt(workerId, prompt, openPaths) {
1643
2056
  const loopRow = await this.#db.drain_current_loop_for_worker.get({ worker_id: workerId });
1644
2057
  if (loopRow === undefined)
1645
2058
  return null;
@@ -1648,11 +2061,16 @@ export default class Engine {
1648
2061
  const turnSeq = turnRow?.next ?? 1;
1649
2062
  const workspaceRow = await this.#db.drain_get_worker_workspace.get({ worker_id: workerId });
1650
2063
  if (workspaceRow === undefined)
1651
- throw new Error(`Engine.inject: run ${workerId} not found`);
2064
+ throw new Error(`Engine.inject: worker ${workerId} not found`);
1652
2065
  // {§prompt-loop-containment} — the frame is the loop's NEXT prompt ordinal, never a turn
1653
2066
  // slot: rapid arrivals land as N and N+1, both contained, nothing superseded.
1654
- const countRow = await this.#db.drain_count_prompts_for_loop.get({ owner_id: workerId, pattern: `${promptLoopPrefix(loopRow.sequence)}%` });
1655
- const pathname = promptPathname(loopRow.sequence, (countRow?.n ?? 0) + 1);
2067
+ const prefix = promptLoopPrefix(loopRow.sequence);
2068
+ const ordinalRow = await this.#db.drain_next_prompt_ordinal_for_loop.get({
2069
+ owner_id: workerId,
2070
+ pattern: `${prefix}%`,
2071
+ prefix_len: prefix.length,
2072
+ });
2073
+ const pathname = promptPathname(loopRow.sequence, ordinalRow?.next ?? 2);
1656
2074
  const ctx = {
1657
2075
  db: this.#db, workspaceId: workspaceRow.workspace_id, workerId, loopId,
1658
2076
  turnId: 0, // no turn open at inject time; entries don't pin turnId
@@ -1661,11 +2079,12 @@ export default class Engine {
1661
2079
  streamEventNotify: this.#streamEventNotify,
1662
2080
  wakeWorkerNotify: this.#wakeWorkerNotify,
1663
2081
  tokenize: this.#tokenize,
1664
- pushTelemetry: (event) => this.#telemetry.push(workspaceRow.workspace_id, loopId, event),
2082
+ pushNotice: (notice) => this.#notices.push(workspaceRow.workspace_id, loopId, notice),
1665
2083
  };
1666
2084
  const entry = {
1667
2085
  channels: { body: { content: prompt, mimetype: "text/markdown" } },
1668
2086
  tags: [],
2087
+ attributes: { openPaths },
1669
2088
  };
1670
2089
  await EntryCrud.writeEntry(pathname, entry, ctx, "prompt", workerId);
1671
2090
  return { loopId, turnSeq };