@plurnk/plurnk-service 1.3.12 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (488) hide show
  1. package/.env.defaults +95 -91
  2. package/INSTALL.md +54 -28
  3. package/README.md +25 -10
  4. package/SPEC.md +2426 -942
  5. package/dist/Paths.d.ts +4 -1
  6. package/dist/Paths.d.ts.map +1 -1
  7. package/dist/Paths.js +24 -23
  8. package/dist/Paths.js.map +1 -1
  9. package/dist/build-info.json +1 -1
  10. package/dist/content/edit-receipt.d.ts +13 -28
  11. package/dist/content/edit-receipt.d.ts.map +1 -1
  12. package/dist/content/edit-receipt.js +285 -104
  13. package/dist/content/edit-receipt.js.map +1 -1
  14. package/dist/content/edited-span.d.ts.map +1 -1
  15. package/dist/content/edited-span.js +2 -5
  16. package/dist/content/edited-span.js.map +1 -1
  17. package/dist/content/index.d.ts +4 -3
  18. package/dist/content/index.d.ts.map +1 -1
  19. package/dist/content/index.js +2 -1
  20. package/dist/content/index.js.map +1 -1
  21. package/dist/content/line-marker.d.ts +10 -9
  22. package/dist/content/line-marker.d.ts.map +1 -1
  23. package/dist/content/line-marker.js +8 -11
  24. package/dist/content/line-marker.js.map +1 -1
  25. package/dist/content/matcher.d.ts +15 -26
  26. package/dist/content/matcher.d.ts.map +1 -1
  27. package/dist/content/matcher.js +77 -107
  28. package/dist/content/matcher.js.map +1 -1
  29. package/dist/content/mimetype-binary.d.ts +2 -2
  30. package/dist/content/mimetype-binary.d.ts.map +1 -1
  31. package/dist/content/mimetype-binary.js +10 -10
  32. package/dist/content/mimetype-binary.js.map +1 -1
  33. package/dist/content/path-mimetype.js +3 -3
  34. package/dist/content/path-mimetype.js.map +1 -1
  35. package/dist/content/read-projector.d.ts +14 -0
  36. package/dist/content/read-projector.d.ts.map +1 -0
  37. package/dist/content/read-projector.js +86 -0
  38. package/dist/content/read-projector.js.map +1 -0
  39. package/dist/content/read-resolve.d.ts +6 -7
  40. package/dist/content/read-resolve.d.ts.map +1 -1
  41. package/dist/content/read-resolve.js +36 -59
  42. package/dist/content/read-resolve.js.map +1 -1
  43. package/dist/core/BranchReceipt.d.ts +6 -0
  44. package/dist/core/BranchReceipt.d.ts.map +1 -0
  45. package/dist/core/BranchReceipt.js +24 -0
  46. package/dist/core/BranchReceipt.js.map +1 -0
  47. package/dist/core/BudgetOverflow.d.ts +20 -0
  48. package/dist/core/BudgetOverflow.d.ts.map +1 -0
  49. package/dist/core/BudgetOverflow.js +49 -0
  50. package/dist/core/BudgetOverflow.js.map +1 -0
  51. package/dist/core/BudgetReadout.d.ts +8 -0
  52. package/dist/core/BudgetReadout.d.ts.map +1 -0
  53. package/dist/core/BudgetReadout.js +79 -0
  54. package/dist/core/BudgetReadout.js.map +1 -0
  55. package/dist/core/ChannelWrite.d.ts +31 -9
  56. package/dist/core/ChannelWrite.d.ts.map +1 -1
  57. package/dist/core/ChannelWrite.js +30 -23
  58. package/dist/core/ChannelWrite.js.map +1 -1
  59. package/dist/core/ChannelWrite.sql +14 -12
  60. package/dist/core/CoreSchemeServices.d.ts +18 -4
  61. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  62. package/dist/core/CoreSchemeServices.js +7 -1
  63. package/dist/core/CoreSchemeServices.js.map +1 -1
  64. package/dist/core/Dispatcher.d.ts +26 -21
  65. package/dist/core/Dispatcher.d.ts.map +1 -1
  66. package/dist/core/Dispatcher.js +1626 -671
  67. package/dist/core/Dispatcher.js.map +1 -1
  68. package/dist/core/DurableStatement.d.ts +6 -0
  69. package/dist/core/DurableStatement.d.ts.map +1 -0
  70. package/dist/core/DurableStatement.js +51 -0
  71. package/dist/core/DurableStatement.js.map +1 -0
  72. package/dist/core/Engine.d.ts +64 -29
  73. package/dist/core/Engine.d.ts.map +1 -1
  74. package/dist/core/Engine.js +1348 -835
  75. package/dist/core/Engine.js.map +1 -1
  76. package/dist/core/Engine.sql +329 -234
  77. package/dist/core/EnvFlags.js +1 -1
  78. package/dist/core/EnvFlags.js.map +1 -1
  79. package/dist/core/ErrorDetail.d.ts +6 -0
  80. package/dist/core/ErrorDetail.d.ts.map +1 -0
  81. package/dist/core/ErrorDetail.js +20 -0
  82. package/dist/core/ErrorDetail.js.map +1 -0
  83. package/dist/core/ExecutorRegistry.d.ts +14 -3
  84. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  85. package/dist/core/ExecutorRegistry.js +56 -30
  86. package/dist/core/ExecutorRegistry.js.map +1 -1
  87. package/dist/core/GitBranch.d.ts +20 -0
  88. package/dist/core/GitBranch.d.ts.map +1 -0
  89. package/dist/core/GitBranch.js +110 -0
  90. package/dist/core/GitBranch.js.map +1 -0
  91. package/dist/core/JournalTurn.d.ts +9 -0
  92. package/dist/core/JournalTurn.d.ts.map +1 -0
  93. package/dist/core/JournalTurn.js +14 -0
  94. package/dist/core/JournalTurn.js.map +1 -0
  95. package/dist/core/JournalTurn.sql +10 -0
  96. package/dist/core/LogBody.d.ts +18 -0
  97. package/dist/core/LogBody.d.ts.map +1 -0
  98. package/dist/core/LogBody.js +180 -0
  99. package/dist/core/LogBody.js.map +1 -0
  100. package/dist/core/LoopFlagsReader.d.ts +7 -0
  101. package/dist/core/LoopFlagsReader.d.ts.map +1 -0
  102. package/dist/core/LoopFlagsReader.js +33 -0
  103. package/dist/core/LoopFlagsReader.js.map +1 -0
  104. package/dist/core/LoopLifecycle.d.ts +8 -2
  105. package/dist/core/LoopLifecycle.d.ts.map +1 -1
  106. package/dist/core/LoopLifecycle.js +54 -11
  107. package/dist/core/LoopLifecycle.js.map +1 -1
  108. package/dist/core/LoopLifecycle.sql +10 -7
  109. package/dist/core/NoticeChannel.d.ts +13 -0
  110. package/dist/core/NoticeChannel.d.ts.map +1 -0
  111. package/dist/core/NoticeChannel.js +40 -0
  112. package/dist/core/NoticeChannel.js.map +1 -0
  113. package/dist/core/Owner.d.ts +0 -1
  114. package/dist/core/Owner.d.ts.map +1 -1
  115. package/dist/core/Owner.js +2 -5
  116. package/dist/core/Owner.js.map +1 -1
  117. package/dist/core/PacketBuilder.d.ts +27 -25
  118. package/dist/core/PacketBuilder.d.ts.map +1 -1
  119. package/dist/core/PacketBuilder.js +321 -383
  120. package/dist/core/PacketBuilder.js.map +1 -1
  121. package/dist/core/ProblemLog.d.ts +26 -0
  122. package/dist/core/ProblemLog.d.ts.map +1 -0
  123. package/dist/core/ProblemLog.js +58 -0
  124. package/dist/core/ProblemLog.js.map +1 -0
  125. package/dist/core/ProposalLifecycle.d.ts +15 -21
  126. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  127. package/dist/core/ProposalLifecycle.js +291 -78
  128. package/dist/core/ProposalLifecycle.js.map +1 -1
  129. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  130. package/dist/core/ProviderInstantiate.js +51 -48
  131. package/dist/core/ProviderInstantiate.js.map +1 -1
  132. package/dist/core/SchemeRegistry.d.ts +14 -8
  133. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  134. package/dist/core/SchemeRegistry.js +189 -114
  135. package/dist/core/SchemeRegistry.js.map +1 -1
  136. package/dist/core/ServiceTeardown.d.ts +11 -0
  137. package/dist/core/ServiceTeardown.d.ts.map +1 -0
  138. package/dist/core/ServiceTeardown.js +70 -0
  139. package/dist/core/ServiceTeardown.js.map +1 -0
  140. package/dist/core/StoredPacket.d.ts +29 -0
  141. package/dist/core/StoredPacket.d.ts.map +1 -0
  142. package/dist/core/StoredPacket.js +147 -0
  143. package/dist/core/StoredPacket.js.map +1 -0
  144. package/dist/core/StrikeRail.d.ts +6 -3
  145. package/dist/core/StrikeRail.d.ts.map +1 -1
  146. package/dist/core/StrikeRail.js +27 -56
  147. package/dist/core/StrikeRail.js.map +1 -1
  148. package/dist/core/TerminalResult.d.ts +16 -0
  149. package/dist/core/TerminalResult.d.ts.map +1 -0
  150. package/dist/core/TerminalResult.js +49 -0
  151. package/dist/core/TerminalResult.js.map +1 -0
  152. package/dist/core/WorkerControlAddress.d.ts +17 -0
  153. package/dist/core/WorkerControlAddress.d.ts.map +1 -0
  154. package/dist/core/WorkerControlAddress.js +44 -0
  155. package/dist/core/WorkerControlAddress.js.map +1 -0
  156. package/dist/core/WorkerName.d.ts +31 -0
  157. package/dist/core/WorkerName.d.ts.map +1 -0
  158. package/dist/core/WorkerName.js +95 -0
  159. package/dist/core/WorkerName.js.map +1 -0
  160. package/dist/core/WorkerName.sql +36 -0
  161. package/dist/core/WorkspaceGate.d.ts +14 -0
  162. package/dist/core/WorkspaceGate.d.ts.map +1 -0
  163. package/dist/core/WorkspaceGate.js +155 -0
  164. package/dist/core/WorkspaceGate.js.map +1 -0
  165. package/dist/core/caps/CapsResolve.d.ts +5 -1
  166. package/dist/core/caps/CapsResolve.d.ts.map +1 -1
  167. package/dist/core/caps/CapsResolve.js +8 -4
  168. package/dist/core/caps/CapsResolve.js.map +1 -1
  169. package/dist/core/caps/DbChannelCaps.d.ts +5 -11
  170. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -1
  171. package/dist/core/caps/DbChannelCaps.js +30 -12
  172. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  173. package/dist/core/caps/DbEntryCaps.d.ts +5 -14
  174. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
  175. package/dist/core/caps/DbEntryCaps.js +27 -8
  176. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  177. package/dist/core/caps/DbNotifyCaps.d.ts +1 -1
  178. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -1
  179. package/dist/core/caps/DbNotifyCaps.js +18 -15
  180. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  181. package/dist/core/caps/DbProjectionCaps.d.ts +5 -5
  182. package/dist/core/caps/DbProjectionCaps.d.ts.map +1 -1
  183. package/dist/core/caps/DbProjectionCaps.js +21 -4
  184. package/dist/core/caps/DbProjectionCaps.js.map +1 -1
  185. package/dist/core/caps/DbSubscriptionCaps.d.ts +4 -6
  186. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
  187. package/dist/core/caps/DbSubscriptionCaps.js +75 -66
  188. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  189. package/dist/core/caps/DbTagCaps.d.ts +5 -12
  190. package/dist/core/caps/DbTagCaps.d.ts.map +1 -1
  191. package/dist/core/caps/DbTagCaps.js +20 -11
  192. package/dist/core/caps/DbTagCaps.js.map +1 -1
  193. package/dist/core/caps/SchemeCtxImpl.d.ts +6 -1
  194. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  195. package/dist/core/caps/SchemeCtxImpl.js +6 -6
  196. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  197. package/dist/core/content-hash.js +1 -1
  198. package/dist/core/content-hash.js.map +1 -1
  199. package/dist/core/env-defaults.d.ts.map +1 -1
  200. package/dist/core/env-defaults.js.map +1 -1
  201. package/dist/core/fork.d.ts.map +1 -1
  202. package/dist/core/fork.js +55 -30
  203. package/dist/core/fork.js.map +1 -1
  204. package/dist/core/fork.sql +43 -29
  205. package/dist/core/git-env.d.ts +2 -0
  206. package/dist/core/git-env.d.ts.map +1 -1
  207. package/dist/core/git-env.js +13 -18
  208. package/dist/core/git-env.js.map +1 -1
  209. package/dist/core/git-iso.d.ts +3 -2
  210. package/dist/core/git-iso.d.ts.map +1 -1
  211. package/dist/core/git-iso.js +36 -27
  212. package/dist/core/git-iso.js.map +1 -1
  213. package/dist/core/git-membership.d.ts +2 -1
  214. package/dist/core/git-membership.d.ts.map +1 -1
  215. package/dist/core/git-membership.js +276 -162
  216. package/dist/core/git-membership.js.map +1 -1
  217. package/dist/core/git-state.d.ts +8 -1
  218. package/dist/core/git-state.d.ts.map +1 -1
  219. package/dist/core/git-state.js +44 -26
  220. package/dist/core/git-state.js.map +1 -1
  221. package/dist/core/namespace.d.ts +1 -0
  222. package/dist/core/namespace.d.ts.map +1 -1
  223. package/dist/core/namespace.js +20 -0
  224. package/dist/core/namespace.js.map +1 -1
  225. package/dist/core/optimistic-settlement.d.ts +2 -0
  226. package/dist/core/optimistic-settlement.d.ts.map +1 -0
  227. package/dist/core/optimistic-settlement.js +14 -0
  228. package/dist/core/optimistic-settlement.js.map +1 -0
  229. package/dist/core/owner.sql +1 -1
  230. package/dist/core/packet-inject.d.ts.map +1 -1
  231. package/dist/core/packet-inject.js +7 -11
  232. package/dist/core/packet-inject.js.map +1 -1
  233. package/dist/core/packet-wire.d.ts +3 -21
  234. package/dist/core/packet-wire.d.ts.map +1 -1
  235. package/dist/core/packet-wire.js +305 -385
  236. package/dist/core/packet-wire.js.map +1 -1
  237. package/dist/core/plurnk-uri.d.ts +12 -1
  238. package/dist/core/plurnk-uri.d.ts.map +1 -1
  239. package/dist/core/plurnk-uri.js +49 -29
  240. package/dist/core/plurnk-uri.js.map +1 -1
  241. package/dist/core/results.d.ts +18 -5
  242. package/dist/core/results.d.ts.map +1 -1
  243. package/dist/core/results.js +33 -18
  244. package/dist/core/results.js.map +1 -1
  245. package/dist/core/ruler_count.d.ts +4 -0
  246. package/dist/core/ruler_count.d.ts.map +1 -0
  247. package/dist/core/ruler_count.js +7 -0
  248. package/dist/core/ruler_count.js.map +1 -0
  249. package/dist/core/scheme-types.d.ts +3 -17
  250. package/dist/core/scheme-types.d.ts.map +1 -1
  251. package/dist/core/scheme-types.js.map +1 -1
  252. package/dist/core/search-gate.d.ts.map +1 -1
  253. package/dist/core/search-gate.js +3 -11
  254. package/dist/core/search-gate.js.map +1 -1
  255. package/dist/core/teaching-corpus.d.ts +6 -0
  256. package/dist/core/teaching-corpus.d.ts.map +1 -0
  257. package/dist/core/teaching-corpus.js +23 -0
  258. package/dist/core/teaching-corpus.js.map +1 -0
  259. package/dist/core/teaching.d.ts +0 -1
  260. package/dist/core/teaching.d.ts.map +1 -1
  261. package/dist/core/teaching.js +2 -8
  262. package/dist/core/teaching.js.map +1 -1
  263. package/dist/core/token-ruler.d.ts.map +1 -1
  264. package/dist/core/token-ruler.js +3 -16
  265. package/dist/core/token-ruler.js.map +1 -1
  266. package/dist/core/turn-scheduler.d.ts +1 -1
  267. package/dist/core/turn-scheduler.d.ts.map +1 -1
  268. package/dist/core/turn-scheduler.js +1 -1
  269. package/dist/core/turn-scheduler.js.map +1 -1
  270. package/dist/core/worker-cap.d.ts +2 -4
  271. package/dist/core/worker-cap.d.ts.map +1 -1
  272. package/dist/core/worker-cap.js +13 -4
  273. package/dist/core/worker-cap.js.map +1 -1
  274. package/dist/core/{run-ops.sql → worker-ops.sql} +14 -14
  275. package/dist/core/workspace-settings.d.ts.map +1 -1
  276. package/dist/core/workspace-settings.js +17 -9
  277. package/dist/core/workspace-settings.js.map +1 -1
  278. package/dist/digest/Digest.d.ts +2 -1
  279. package/dist/digest/Digest.d.ts.map +1 -1
  280. package/dist/digest/Digest.js +510 -149
  281. package/dist/digest/Digest.js.map +1 -1
  282. package/dist/digest/digest.sql +23 -10
  283. package/dist/index.d.ts.map +1 -1
  284. package/dist/index.js +3 -5
  285. package/dist/index.js.map +1 -1
  286. package/dist/observe/api.d.ts +6 -0
  287. package/dist/observe/api.d.ts.map +1 -0
  288. package/dist/observe/api.js +11 -0
  289. package/dist/observe/api.js.map +1 -0
  290. package/dist/observe/init.d.ts +7 -0
  291. package/dist/observe/init.d.ts.map +1 -0
  292. package/dist/observe/init.js +159 -0
  293. package/dist/observe/init.js.map +1 -0
  294. package/dist/observe/metrics.d.ts +6 -0
  295. package/dist/observe/metrics.d.ts.map +1 -0
  296. package/dist/observe/metrics.js +15 -0
  297. package/dist/observe/metrics.js.map +1 -0
  298. package/dist/observe/spans.d.ts +4 -0
  299. package/dist/observe/spans.d.ts.map +1 -0
  300. package/dist/observe/spans.js +51 -0
  301. package/dist/observe/spans.js.map +1 -0
  302. package/dist/schemes/EffectPolicy.d.ts +1 -0
  303. package/dist/schemes/EffectPolicy.d.ts.map +1 -1
  304. package/dist/schemes/EffectPolicy.js +5 -2
  305. package/dist/schemes/EffectPolicy.js.map +1 -1
  306. package/dist/schemes/Exec.d.ts +9 -14
  307. package/dist/schemes/Exec.d.ts.map +1 -1
  308. package/dist/schemes/Exec.js +402 -262
  309. package/dist/schemes/Exec.js.map +1 -1
  310. package/dist/schemes/ExecOutputScheme.d.ts +9 -9
  311. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  312. package/dist/schemes/ExecOutputScheme.js +49 -18
  313. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  314. package/dist/schemes/File.d.ts +7 -13
  315. package/dist/schemes/File.d.ts.map +1 -1
  316. package/dist/schemes/File.js +280 -151
  317. package/dist/schemes/File.js.map +1 -1
  318. package/dist/schemes/Log.d.ts +8 -12
  319. package/dist/schemes/Log.d.ts.map +1 -1
  320. package/dist/schemes/Log.js +402 -233
  321. package/dist/schemes/Log.js.map +1 -1
  322. package/dist/schemes/Log.sql +71 -32
  323. package/dist/schemes/Prompt.d.ts +3 -3
  324. package/dist/schemes/Prompt.d.ts.map +1 -1
  325. package/dist/schemes/Prompt.js +10 -10
  326. package/dist/schemes/Prompt.js.map +1 -1
  327. package/dist/schemes/Skill.d.ts +2 -3
  328. package/dist/schemes/Skill.d.ts.map +1 -1
  329. package/dist/schemes/Skill.js +2 -6
  330. package/dist/schemes/Skill.js.map +1 -1
  331. package/dist/schemes/Worker.d.ts +11 -19
  332. package/dist/schemes/Worker.d.ts.map +1 -1
  333. package/dist/schemes/Worker.js +210 -89
  334. package/dist/schemes/Worker.js.map +1 -1
  335. package/dist/schemes/_entry-chunk.d.ts.map +1 -1
  336. package/dist/schemes/_entry-chunk.js +12 -5
  337. package/dist/schemes/_entry-chunk.js.map +1 -1
  338. package/dist/schemes/_entry-crud.d.ts +11 -9
  339. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  340. package/dist/schemes/_entry-crud.js +72 -13
  341. package/dist/schemes/_entry-crud.js.map +1 -1
  342. package/dist/schemes/_entry-crud.sql +28 -10
  343. package/dist/schemes/_entry-find.d.ts +49 -18
  344. package/dist/schemes/_entry-find.d.ts.map +1 -1
  345. package/dist/schemes/_entry-find.js +348 -195
  346. package/dist/schemes/_entry-find.js.map +1 -1
  347. package/dist/schemes/_entry-find.sql +20 -8
  348. package/dist/schemes/_entry-graph.d.ts +4 -2
  349. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  350. package/dist/schemes/_entry-graph.js +55 -31
  351. package/dist/schemes/_entry-graph.js.map +1 -1
  352. package/dist/schemes/_entry-graph.sql +37 -22
  353. package/dist/schemes/_entry-manifest.d.ts +10 -15
  354. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  355. package/dist/schemes/_entry-manifest.js +50 -268
  356. package/dist/schemes/_entry-manifest.js.map +1 -1
  357. package/dist/schemes/_entry-ops.d.ts +14 -15
  358. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  359. package/dist/schemes/_entry-ops.js +121 -110
  360. package/dist/schemes/_entry-ops.js.map +1 -1
  361. package/dist/schemes/_entry-ops.sql +2 -3
  362. package/dist/schemes/_entry-semantic.d.ts +22 -9
  363. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  364. package/dist/schemes/_entry-semantic.js +127 -67
  365. package/dist/schemes/_entry-semantic.js.map +1 -1
  366. package/dist/schemes/_entry-semantic.sql +63 -45
  367. package/dist/schemes/_entry-send.d.ts +3 -4
  368. package/dist/schemes/_entry-send.d.ts.map +1 -1
  369. package/dist/schemes/_entry-send.js +48 -23
  370. package/dist/schemes/_entry-send.js.map +1 -1
  371. package/dist/schemes/_path-scope.d.ts +23 -0
  372. package/dist/schemes/_path-scope.d.ts.map +1 -0
  373. package/dist/schemes/_path-scope.js +61 -0
  374. package/dist/schemes/_path-scope.js.map +1 -0
  375. package/dist/schemes/_search-candidate.d.ts +20 -0
  376. package/dist/schemes/_search-candidate.d.ts.map +1 -0
  377. package/dist/schemes/_search-candidate.js +19 -0
  378. package/dist/schemes/_search-candidate.js.map +1 -0
  379. package/dist/schemes/_search-exclusion.d.ts +7 -0
  380. package/dist/schemes/_search-exclusion.d.ts.map +1 -0
  381. package/dist/schemes/_search-exclusion.js +23 -0
  382. package/dist/schemes/_search-exclusion.js.map +1 -0
  383. package/dist/schemes/_search-index.d.ts +6 -0
  384. package/dist/schemes/_search-index.d.ts.map +1 -0
  385. package/dist/schemes/_search-index.js +328 -0
  386. package/dist/schemes/_search-index.js.map +1 -0
  387. package/dist/schemes/cosine.js +1 -1
  388. package/dist/schemes/cosine.js.map +1 -1
  389. package/dist/schemes/exec-abort.js +3 -3
  390. package/dist/schemes/exec-abort.js.map +1 -1
  391. package/dist/schemes/exec-env.js +3 -3
  392. package/dist/schemes/exec-env.js.map +1 -1
  393. package/dist/server/BranchBatches.d.ts +49 -0
  394. package/dist/server/BranchBatches.d.ts.map +1 -0
  395. package/dist/server/BranchBatches.js +592 -0
  396. package/dist/server/BranchBatches.js.map +1 -0
  397. package/dist/server/Daemon.d.ts +39 -71
  398. package/dist/server/Daemon.d.ts.map +1 -1
  399. package/dist/server/Daemon.js +1133 -491
  400. package/dist/server/Daemon.js.map +1 -1
  401. package/dist/server/DaemonModule.d.ts +31 -0
  402. package/dist/server/DaemonModule.d.ts.map +1 -0
  403. package/dist/server/DaemonModule.js +2 -0
  404. package/dist/server/DaemonModule.js.map +1 -0
  405. package/dist/server/branch-batch.sql +154 -0
  406. package/dist/server/client-input.d.ts +13 -0
  407. package/dist/server/client-input.d.ts.map +1 -1
  408. package/dist/server/client-input.js +268 -57
  409. package/dist/server/client-input.js.map +1 -1
  410. package/dist/server/dispatch-as-plurnk.d.ts +1 -1
  411. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
  412. package/dist/server/dispatch-as-plurnk.js +15 -7
  413. package/dist/server/dispatch-as-plurnk.js.map +1 -1
  414. package/dist/server/drain.sql +96 -65
  415. package/dist/server/envelope.d.ts +4 -7
  416. package/dist/server/envelope.d.ts.map +1 -1
  417. package/dist/server/envelope.js +118 -95
  418. package/dist/server/envelope.js.map +1 -1
  419. package/dist/server/envelope.sql +18 -23
  420. package/dist/server/exec-poll-backoff.js +1 -1
  421. package/dist/server/exec-poll-backoff.js.map +1 -1
  422. package/dist/server/lifecycle-recovery.sql +112 -2
  423. package/dist/server/logEntry.d.ts +4 -2
  424. package/dist/server/logEntry.d.ts.map +1 -1
  425. package/dist/server/logEntry.js +6 -3
  426. package/dist/server/logEntry.js.map +1 -1
  427. package/dist/server/logEntry.sql +1 -1
  428. package/dist/server/loop-model.d.ts.map +1 -1
  429. package/dist/server/loop-model.js +22 -9
  430. package/dist/server/loop-model.js.map +1 -1
  431. package/dist/server/loopDocs.d.ts.map +1 -1
  432. package/dist/server/loopDocs.js +5 -4
  433. package/dist/server/loopDocs.js.map +1 -1
  434. package/dist/server/seam-entry-read.sql +15 -10
  435. package/dist/server/seam-log-read.sql +2 -2
  436. package/dist/server/seam-loop.sql +1 -1
  437. package/dist/server/seam-proposal-list.sql +17 -7
  438. package/dist/service.d.ts.map +1 -1
  439. package/dist/service.js +79 -61
  440. package/dist/service.js.map +1 -1
  441. package/migrations/001_schema.sql +1010 -0
  442. package/package.json +65 -32
  443. package/dist/core/PluginLoader.d.ts +0 -20
  444. package/dist/core/PluginLoader.d.ts.map +0 -1
  445. package/dist/core/PluginLoader.js +0 -142
  446. package/dist/core/PluginLoader.js.map +0 -1
  447. package/dist/core/TelemetryChannel.d.ts +0 -37
  448. package/dist/core/TelemetryChannel.d.ts.map +0 -1
  449. package/dist/core/TelemetryChannel.js +0 -80
  450. package/dist/core/TelemetryChannel.js.map +0 -1
  451. package/dist/core/path-decode.d.ts +0 -3
  452. package/dist/core/path-decode.d.ts.map +0 -1
  453. package/dist/core/path-decode.js +0 -9
  454. package/dist/core/path-decode.js.map +0 -1
  455. package/dist/core/plugin-attribution.d.ts +0 -5
  456. package/dist/core/plugin-attribution.d.ts.map +0 -1
  457. package/dist/core/plugin-attribution.js +0 -39
  458. package/dist/core/plugin-attribution.js.map +0 -1
  459. package/dist/core/world-state.d.ts +0 -10
  460. package/dist/core/world-state.d.ts.map +0 -1
  461. package/dist/core/world-state.js +0 -35
  462. package/dist/core/world-state.js.map +0 -1
  463. package/dist/core/world-state.sql +0 -35
  464. package/dist/core/zero-pin.d.ts +0 -3
  465. package/dist/core/zero-pin.d.ts.map +0 -1
  466. package/dist/core/zero-pin.js +0 -16
  467. package/dist/core/zero-pin.js.map +0 -1
  468. package/dist/server/auto.d.ts +0 -6
  469. package/dist/server/auto.d.ts.map +0 -1
  470. package/dist/server/auto.js +0 -63
  471. package/dist/server/auto.js.map +0 -1
  472. package/dist/server/clientTurn.d.ts +0 -6
  473. package/dist/server/clientTurn.d.ts.map +0 -1
  474. package/dist/server/clientTurn.js +0 -22
  475. package/dist/server/clientTurn.js.map +0 -1
  476. package/dist/server/clientTurn.sql +0 -10
  477. package/dist/server/noProposals.d.ts +0 -6
  478. package/dist/server/noProposals.d.ts.map +0 -1
  479. package/dist/server/noProposals.js +0 -37
  480. package/dist/server/noProposals.js.map +0 -1
  481. package/dist/server/version-info.d.ts +0 -14
  482. package/dist/server/version-info.d.ts.map +0 -1
  483. package/dist/server/version-info.js +0 -69
  484. package/dist/server/version-info.js.map +0 -1
  485. package/migrations/0000-00-00.01_schema.sql +0 -536
  486. package/migrations/0004_loop-provider.sql +0 -3
  487. package/migrations/0005_subscription-published-channel.sql +0 -3
  488. package/migrations/0006_loop-max-turns.sql +0 -3
@@ -1,14 +1,17 @@
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";
6
- import { markTerminal } from "../schemes/Worker.js";
9
+ import SearchIndex from "../schemes/_search-index.js";
7
10
  import GitMembership from "./git-membership.js";
8
11
  import GitState from "./git-state.js";
9
12
  import WorkspaceSettings from "./workspace-settings.js";
10
13
  import { editedSpan } from "../content/index.js";
11
- import { promptPathname, promptLoopPrefix } from "./plurnk-uri.js";
14
+ import { promptPathname, promptLoopPrefix, renderTarget } from "./plurnk-uri.js";
12
15
  import { rulerCount } from "./token-ruler.js";
13
16
  import SearchGate from "./search-gate.js";
14
17
  import LiveSubscriptions from "./LiveSubscriptions.js";
@@ -19,28 +22,75 @@ import { setTimeout as delay } from "node:timers/promises";
19
22
  // Shared module imported by both Engine and bin/digest.ts, so wire
20
23
  // projection and digest projection are structurally one function — no
21
24
  // drift between wire and digest possible.
22
- // Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
23
25
  import PacketWire from "./packet-wire.js";
26
+ import Results, { OperationFailureError } from "./results.js";
27
+ import BranchReceipt from "./BranchReceipt.js";
28
+ import TerminalResult from "./TerminalResult.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";
44
+ import { readOptimisticSettlementMs } from "./optimistic-settlement.js";
32
45
  const DEFAULT_MAX_STRIKES = 3;
33
- // The foisted prompt EDIT/READ target — prompt:///<loop>/<N>, self-only ({§prompt-self-only}):
46
+ const RECORD_STREAM_MIMETYPES = new Set(["application/jsonl", "application/x-ndjson"]);
47
+ const baseMimetype = (mimetype) => mimetype.split(";", 1)[0].trim().toLowerCase();
48
+ // {§exec-stream} Active streams publish only independently meaningful units.
49
+ // Atomic documents wait for close; JSONL stops after its last complete record.
50
+ const streamPublicationEnd = (content, mimetype, cursor, closed) => {
51
+ const type = baseMimetype(mimetype);
52
+ if (closed || type.startsWith("text/"))
53
+ return content.length;
54
+ if (!RECORD_STREAM_MIMETYPES.has(type))
55
+ return cursor;
56
+ return Math.max(cursor, content.lastIndexOf("\n") + 1);
57
+ };
58
+ const ENGINE_PROBLEMS = Object.freeze({
59
+ max_commands_exceeded: {
60
+ status: 429,
61
+ code: "max-commands-exceeded",
62
+ detail: "Later operations were not executed because the turn exceeded its operation limit.",
63
+ },
64
+ idle_turn: {
65
+ status: 409,
66
+ code: "idle-turn",
67
+ detail: "SEND[102] was emitted without an operation to continue from.",
68
+ },
69
+ });
70
+ // The prompt entry target - prompt:///<loop>/<N>, self-only ({§prompt-self-only}):
34
71
  // the owner rides the owner_id column, the address carries only the loop coordinate.
35
- const promptTarget = (workerId, loopSeq, turnSeq) => {
72
+ const promptTarget = (loopSeq, turnSeq) => {
36
73
  const storage = promptPathname(loopSeq, turnSeq);
37
74
  return {
38
75
  kind: "url", raw: `prompt://${storage}`,
39
76
  scheme: "prompt", username: null, password: null,
40
77
  hostname: null, port: null,
41
- pathname: storage, params: {}, fragment: null,
78
+ pathname: storage, query: null, fragment: null,
42
79
  };
43
80
  };
81
+ const assertOpenPaths = (value, source) => {
82
+ if (!Array.isArray(value) || value.some((path) => typeof path !== "string" || path.length === 0)) {
83
+ throw new TypeError(`${source}: expected an array of non-empty strings`);
84
+ }
85
+ return value;
86
+ };
87
+ const parsePromptAttributes = (encoded, source) => {
88
+ const attributes = JSON.parse(encoded);
89
+ if (attributes === null || typeof attributes !== "object" || Array.isArray(attributes)) {
90
+ throw new TypeError(`${source}: expected a JSON object`);
91
+ }
92
+ return attributes;
93
+ };
44
94
  const readMaxStrikes = () => {
45
95
  const raw = process.env.PLURNK_SERVICE_MAX_STRIKES;
46
96
  if (raw === undefined || raw.length === 0)
@@ -50,10 +100,11 @@ const readMaxStrikes = () => {
50
100
  return DEFAULT_MAX_STRIKES;
51
101
  return n;
52
102
  };
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),
103
+ // Per-emission action ceiling — OFF by default. `-1` (or unset/non-positive) = no cap:
104
+ // every generated op dispatches. Runaway degeneration is a sampler concern (repetition penalty),
55
105
  // 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).
106
+ // workspace's maxCommands may tighten (min wins), never widen
107
+ // ({§operator-config-workspace-max-commands}).
57
108
  const readMaxCommands = () => {
58
109
  const raw = process.env.PLURNK_SERVICE_MAX_COMMANDS;
59
110
  if (raw === undefined || raw.length === 0)
@@ -63,8 +114,8 @@ const readMaxCommands = () => {
63
114
  return Number.POSITIVE_INFINITY;
64
115
  return n;
65
116
  };
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.
117
+ // PLURNK_SERVICE_FILES_ITEMS — the turn-0 catalog preview. null = off;
118
+ // -1 = the ordinary markerless page; positive N explicitly caps file rows. 0 / unset = off.
68
119
  const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
69
120
  const readFilesItems = () => {
70
121
  const raw = process.env.PLURNK_SERVICE_FILES_ITEMS;
@@ -72,16 +123,14 @@ const readFilesItems = () => {
72
123
  return null;
73
124
  return normalizeFilesItems(Number.parseInt(raw, 10));
74
125
  };
75
- import { ProviderError, scopeEnvToAlias, resolveActiveAlias } from "@plurnk/plurnk-providers";
126
+ import { ProviderError, providerCostFor, providerCostUsd, validateProviderCost, scopeEnvToAlias, resolveActiveAlias } from "@plurnk/plurnk-providers";
76
127
  import { validateGbnf } from "@plurnk/gbnf";
77
128
  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.
129
+ // Runtime normalization for a disposition the engine refuses or resolves as a
130
+ // continue after dispatch ({§send}). Every admitted emission itself ends in an
131
+ // explicit disposition SEND ({§emission-admission}).
81
132
  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;
133
+ const INVALID_EMISSION_RECOVERY_MESSAGE = "Your previous response contained an unrecoverable syntax error. No operations were performed. Try again.";
85
134
  const DEFAULT_MIN_CYCLES = 3;
86
135
  const DEFAULT_MAX_CYCLE_PERIOD = 4;
87
136
  const readPositiveInt = (envVar, fallback) => {
@@ -93,7 +142,15 @@ const readPositiveInt = (envVar, fallback) => {
93
142
  return fallback;
94
143
  return n;
95
144
  };
96
- // §operator-config-loop-timeout — the loop's wall-clock budget (PLURNK_SERVICE_LOOP_TIMEOUT).
145
+ const readEmissionAttempts = () => {
146
+ const raw = process.env.PLURNK_SERVICE_EMISSION_ATTEMPTS;
147
+ const value = Number.parseInt(raw ?? "", 10);
148
+ if (!Number.isInteger(value) || value < 1) {
149
+ throw new Error(`PLURNK_SERVICE_EMISSION_ATTEMPTS must be a positive integer; got ${raw}`);
150
+ }
151
+ return value;
152
+ };
153
+ // {§operator-config-loop-timeout} — the loop's wall-clock budget (PLURNK_SERVICE_LOOP_TIMEOUT).
97
154
  const DEFAULT_LOOP_TIMEOUT_MS = 86400000;
98
155
  const readLoopTimeoutMs = () => readPositiveInt("PLURNK_SERVICE_LOOP_TIMEOUT", DEFAULT_LOOP_TIMEOUT_MS);
99
156
  // The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
@@ -109,30 +166,26 @@ export default class Engine {
109
166
  #lifecycle;
110
167
  #schemes;
111
168
  #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.
169
+ // {§tokenomics-agnostic-ruler} — the stable model-independent ruler used
170
+ // for write-time, catalog, receipt, and packet weights.
117
171
  #tokenize;
118
172
  // Boot-discovered runtime executors. Daemon builds + sets via
119
173
  // setExecutors at start(); undefined until then (and in bare tests).
120
174
  #executors;
121
- // §send-premature-terminate/[102]<T> — park deadlines by loopId, written at dispatch (the
175
+ // {§send-premature-terminate}/SEND[202]<T> — park deadlines by loopId, written at dispatch (the
122
176
  // marker's seconds; -1 = indefinite), consumed by the daemon's drain park-exit to schedule
123
177
  // the deadline wake. In-memory: a daemon restart drops pending deadlines (documented).
124
178
  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.
179
+ // Per-turn running-worker READ obligations. {§join-blocking-collect}
128
180
  joinTargets = new Set();
129
181
  // The collaborators. Engine constructs them (they share its deps via
130
182
  // thunks where the value is late-injected — executors, loop signals)
131
183
  // and fronts their public surface.
132
- #telemetry;
184
+ #notices;
185
+ #problems;
133
186
  #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.
187
+ // {§grinder-hard-413-recovery} - loops granted their one over-ceiling recovery turn. Cleared on a
188
+ // fitting turn so a later independent overflow can earn a fresh recovery, and at loop cleanup.
136
189
  #hardOverflowRecovery = new Set();
137
190
  #packets;
138
191
  searchGate = new SearchGate();
@@ -145,9 +198,10 @@ export default class Engine {
145
198
  // Streaming schemes (exec) chain their per-spawn controllers off
146
199
  // ctx.signal so cancelled loops tear down their background spawns.
147
200
  #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();
201
+ // {§prompt-loop-containment}: one worker's prompt-frame allocation and
202
+ // persistence is a serial critical section. A completed later frame can
203
+ // therefore never overtake or replace an earlier concurrent arrival.
204
+ #promptWriteLocks = new Map();
151
205
  // One coalesced warm per workspace. Creation/membership changes start it as soon
152
206
  // as content exists; the first model turn joins it, so no operation observes
153
207
  // partial graph/vector coverage. A request arriving mid-pass marks the workspace
@@ -168,14 +222,20 @@ export default class Engine {
168
222
  if (!invalidate && this.#workspaceWarmStatus.get(workspaceId)?.phase === "complete") {
169
223
  return Promise.resolve();
170
224
  }
171
- const state = { dirty: false, materialize, ctx, promise: Promise.resolve() };
172
- // Register before publishing the first synchronous telemetry event. A
225
+ const state = {
226
+ dirty: false,
227
+ materialize,
228
+ ctx,
229
+ abort: new AbortController(),
230
+ promise: Promise.resolve(),
231
+ };
232
+ // Register before publishing the first synchronous Notice. A
173
233
  // listener may request another warm from that callback; it must join
174
234
  // this state rather than opening a second pump in the re-entrant gap.
175
235
  this.#workspaceWarms.set(workspaceId, state);
176
236
  const publish = (current, status) => {
177
237
  this.#workspaceWarmStatus.set(workspaceId, status);
178
- current.pushTelemetry?.({
238
+ current.pushNotice?.({
179
239
  source: "engine:derivation", kind: "embed_progress", ...status,
180
240
  });
181
241
  };
@@ -191,25 +251,29 @@ export default class Engine {
191
251
  completed: 0, total: 1, percent: 0, level: "info",
192
252
  });
193
253
  try {
254
+ const signal = current.signal === undefined
255
+ ? state.abort.signal
256
+ : AbortSignal.any([current.signal, state.abort.signal]);
257
+ const cancellable = { ...current, signal };
194
258
  if (shouldMaterialize)
195
- await GitMembership.indexGitMembership(current);
196
- await EntryManifest.maintainDerivations({
197
- ...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") {
259
+ await GitMembership.indexGitMembership(cancellable);
260
+ await SearchIndex.maintain({
261
+ ...cancellable,
262
+ pushNotice: (notice) => {
263
+ if (notice.kind === "embed_progress"
264
+ && typeof notice.completed === "number"
265
+ && typeof notice.total === "number"
266
+ && typeof notice.percent === "number") {
203
267
  this.#workspaceWarmStatus.set(workspaceId, {
204
268
  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",
269
+ completed: notice.completed,
270
+ total: notice.total,
271
+ percent: notice.percent,
272
+ message: notice.message ?? "Indexing repository semantics",
273
+ level: notice.level === "error" ? "error" : "info",
210
274
  });
211
275
  }
212
- current.pushTelemetry?.(event);
276
+ current.pushNotice?.(notice);
213
277
  },
214
278
  });
215
279
  }
@@ -237,14 +301,36 @@ export default class Engine {
237
301
  workspaceDerivationStatus(workspaceId) {
238
302
  return this.#workspaceWarmStatus.get(workspaceId) ?? null;
239
303
  }
240
- // Awaited by Daemon.stop before the db closes.
241
- async drainDerivations() {
242
- await Promise.all([...this.#workspaceWarms.values()].map((state) => state.promise));
304
+ cancelDerivations(reason = new DOMException("derivations cancelled", "AbortError")) {
305
+ for (const state of this.#workspaceWarms.values()) {
306
+ if (!state.abort.signal.aborted)
307
+ state.abort.abort(reason);
308
+ }
309
+ }
310
+ // Awaited by Daemon.stop before the db closes. Shutdown supplies the exact
311
+ // cancellation reason it owns; every unrelated failure remains visible.
312
+ async drainDerivations(ignoredReason) {
313
+ const results = await Promise.allSettled([...this.#workspaceWarms.values()].map((state) => state.promise));
314
+ const errors = results
315
+ .filter((result) => result.status === "rejected")
316
+ .flatMap((result) => result.reason instanceof AggregateError
317
+ ? [...result.reason.errors]
318
+ : [result.reason])
319
+ .filter((error) => error !== ignoredReason);
320
+ if (errors.length === 1)
321
+ throw errors[0];
322
+ if (errors.length > 1)
323
+ throw new AggregateError(errors, "derivation drain failed");
324
+ }
325
+ async drainWorkspaceDerivations(workspaceId) {
326
+ await this.#workspaceWarms.get(workspaceId)?.promise;
243
327
  }
244
328
  #streamEventNotify;
245
329
  #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)
330
+ #acquireWorkspaceTurn;
331
+ #workspaceTurnCompleted;
332
+ // Configured grammar text is cached by variant after its first load.
333
+ #gbnfCache = new Map();
248
334
  // {§rail-truth-engine-verdict} — the verify GAP (a configured grammar @plurnk/gbnf can't
249
335
  // parse): warn once per message, never per turn; the turn records railsVerdict "unverifiable".
250
336
  static #railGapWarned = new Set();
@@ -254,42 +340,52 @@ export default class Engine {
254
340
  Engine.#railGapWarned.add(message);
255
341
  process.stderr.write(`plurnk-engine: rail verdict unavailable — the configured grammar did not parse in @plurnk/gbnf (${message})\n`);
256
342
  }
257
- constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker, cancelDescendants, telemetryEventNotify, tokenize }) {
343
+ static #requireGrammarEvidence(response) {
344
+ const evidence = response.grammarEvidence;
345
+ if (evidence === undefined) {
346
+ throw new Error("provider contract violation: configured GBNF response omitted grammar evidence");
347
+ }
348
+ const input = [...evidence.input];
349
+ if (!Number.isInteger(evidence.contentStart)
350
+ || evidence.contentStart < 0
351
+ || evidence.contentStart > input.length
352
+ || typeof evidence.transported !== "boolean"
353
+ || input.slice(evidence.contentStart).join("") !== response.assistant.content) {
354
+ throw new Error("provider contract violation: grammar evidence does not map exactly to assistant.content");
355
+ }
356
+ return evidence;
357
+ }
358
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants, acquireWorkspaceTurn, workspaceTurnCompleted, noticeNotify, tokenize }) {
258
359
  this.#db = db;
259
360
  this.#lifecycle = new LoopLifecycle(db);
260
361
  this.#schemes = schemes;
261
362
  this.#streamEventNotify = streamEventNotify;
262
363
  this.#wakeWorkerNotify = wakeWorkerNotify;
364
+ this.#acquireWorkspaceTurn = acquireWorkspaceTurn ?? (async () => () => { });
365
+ this.#workspaceTurnCompleted = workspaceTurnCompleted;
263
366
  // Default to empty discovery — standalone Engine construction (in
264
367
  // tests) gets no handlers, and content flows through the framework's
265
368
  // raw-content fitContent fallback. Daemon-managed Engine receives a
266
369
  // production-configured Mimetypes via the constructor arg.
267
370
  this.#mimetypes = mimetypes ?? new Mimetypes({
268
- discovery: { registry: emptyRegistry(), handlers: new Map() },
371
+ discovery: { registry: emptyRegistry(), handlers: new Map(), skipped: [] },
269
372
  });
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.
373
+ // {§tokenomics-agnostic-ruler} — standalone construction and the daemon
374
+ // use the same default; provider counting is confined to physical admission.
273
375
  this.#tokenize = tokenize ?? rulerCount;
274
376
  const executors = () => this.#executors;
275
377
  const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
276
- this.#telemetry = new TelemetryChannel({ db, notify: telemetryEventNotify });
277
- schemes.bindCore({
378
+ this.#notices = new NoticeChannel({ notify: noticeNotify });
379
+ this.#problems = new ProblemLog(db);
380
+ this.#strikes = new StrikeRail();
381
+ this.#packets = new PacketBuilder({
278
382
  db,
279
- mimetypes: this.#mimetypes,
383
+ schemes,
384
+ problems: this.#problems,
280
385
  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
386
  });
289
- this.#strikes = new StrikeRail();
290
- this.#packets = new PacketBuilder({ db, schemes, telemetry: this.#telemetry, executors });
291
387
  this.#proposals = new ProposalLifecycle({
292
- db, schemes, telemetry: this.#telemetry,
388
+ db, schemes, notices: this.#notices,
293
389
  streamEventNotify, wakeWorkerNotify,
294
390
  tokenize: this.#tokenize, mimetypes: this.#mimetypes, executors, loopSignal,
295
391
  liveSubscriptions: this.#liveSubscriptions,
@@ -297,52 +393,52 @@ export default class Engine {
297
393
  this.#dispatcher = new Dispatcher({ searchGate: this.searchGate,
298
394
  db, schemes, mimetypes: this.#mimetypes,
299
395
  tokenize: this.#tokenize,
300
- telemetry: this.#telemetry, proposals: this.#proposals,
396
+ notices: this.#notices, proposals: this.#proposals,
301
397
  executors, loopSignal,
302
- streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker, cancelDescendants,
398
+ streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants,
303
399
  parkDeadlines: this.parkDeadlines,
304
400
  joinTargets: this.joinTargets,
305
401
  liveSubscriptions: this.#liveSubscriptions,
306
402
  });
403
+ schemes.bindCore({
404
+ db,
405
+ mimetypes: this.#mimetypes,
406
+ executors,
407
+ tokenize: this.#tokenize,
408
+ streamEventNotify,
409
+ wakeWorkerNotify,
410
+ injectWorker,
411
+ pushNotice: (workspaceId, loopId, notice) => this.#notices.push(workspaceId, loopId, notice),
412
+ defaultChannelFor: (scheme) => schemes.defaultChannelFor(scheme),
413
+ readExecSource: (statement, ctx) => this.#dispatcher.readExecSource(statement, ctx),
414
+ liveSubscriptions: this.#liveSubscriptions,
415
+ });
307
416
  }
308
417
  // Late injection: the executor registry is async-built at daemon start()
309
418
  // (discover + probe), after Engine construction.
310
419
  setExecutors(executors) {
311
420
  this.#executors = executors;
312
421
  }
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) {
422
+ // Register a module-owned runtime on the same two registries as boot discovery.
423
+ // An optional same-name scheme handler lets one capability own both execution
424
+ // and addressable state without teaching core its protocol.
425
+ registerRuntime(tag, entry, scheme) {
320
426
  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);
427
+ throw new Error("registerRuntime: executor registry not wired yet");
428
+ RuntimeTag.assert(tag, "module runtime");
429
+ // Preflight both owners before either write; synchronous registration
430
+ // then cannot leave a half-claimed namespace. {§plugin-namespace-arbitration}
431
+ this.#executors.assertCanRegister(tag, entry.namespaceOwner);
432
+ this.#schemes.assertRuntimeClaim(tag, entry.namespaceOwner);
433
+ this.#schemes.registerRuntimeScheme(tag, entry.executor, entry.namespaceOwner, scheme);
326
434
  this.#executors.register(tag, entry);
327
435
  }
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.
436
+ // Supply an explicitly configured local constraint; ANTLR remains the
437
+ // language authority. {§grammar-enforcement-verified-at-boot}
331
438
  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).
439
+ // Resolve through the registered or active alias; ambiguity and load
440
+ // failures never degrade to unconstrained generation.
441
+ // {§grammar-enforcement-verified-at-boot}
346
442
  const registered = ProviderInstantiate.aliasOf(provider);
347
443
  const fallback = registered === undefined ? resolveActiveAlias(process.env)?.alias : undefined;
348
444
  if (registered === undefined && fallback === undefined && Object.keys(process.env).some((k) => k.startsWith("PLURNK_PROVIDERS_GBNF_"))) {
@@ -357,52 +453,66 @@ export default class Engine {
357
453
  return hit;
358
454
  const path = variant.startsWith("/") || variant.startsWith(".")
359
455
  ? variant
360
- : fileURLToPath(import.meta.resolve(`@plurnk/plurnk-grammar/${variant}`));
456
+ : fileURLToPath(import.meta.resolve(`@plurnk/plurnk-contracts/${variant}`));
361
457
  const text = await readFile(path, "utf8"); // unresolvable/unreadable throws — a configured rail never silently degrades
362
458
  this.#gbnfCache.set(variant, text);
363
459
  process.stderr.write(`plurnk-engine: GBNF constraint: ${alias || "(bare)"} → ${variant} (${text.length} chars)\n`);
364
460
  return text;
365
461
  }
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."
462
+ // A lineage's no-parent root; a root worker resolves to itself. Fail hard
463
+ // when corruption leaves a worker without one. {§worker-primary}
379
464
  async resolveWorkerPrimary(workerId) {
380
465
  const root = await this.#db.engine_worker_lineage_root.get({ worker_id: workerId });
381
466
  if (root === undefined)
382
- throw new Error(`resolveWorkerPrimary: worker ${workerId} has no lineage root — corrupt parent chain (#522)`);
467
+ throw new Error(`resolveWorkerPrimary: worker ${workerId} has no lineage root — corrupt parent chain`);
383
468
  return root.id;
384
469
  }
385
470
  promptBudgetFor(provider) {
386
471
  return this.#packets.promptBudgetFor(provider);
387
472
  }
473
+ async #attemptAttributions(provider, context) {
474
+ const tags = Meta.composeAttributions(this.#schemes.attributions(context), this.#executors?.attributions(context) ?? [], await this.#mimetypes.attributions(context), provider.attributions?.(context) ?? []);
475
+ return [...tags];
476
+ }
477
+ // {§attribution} — reporting derives from exact provider-request evidence;
478
+ // malformed durable tags fail here instead of being silently filtered.
479
+ async loopAttributions(loopId) {
480
+ const rows = await this.#db.engine_loop_attributions.all({ loop_id: loopId });
481
+ const tags = rows.map(({ attribution }, index) => {
482
+ if (typeof attribution !== "string" || attribution.length === 0) {
483
+ throw new TypeError(`loop ${loopId} attribution row ${index} is not a non-empty string`);
484
+ }
485
+ return attribution;
486
+ });
487
+ return [...Meta.composeAttributions(tags)];
488
+ }
489
+ // Loop totals are billing evidence; the latest-turn pair is the client
490
+ // occupancy gauge. {§tokenomics-client-gauge}, {§notifications-loop-terminated}
388
491
  async loopUsage(loopId) {
389
492
  const row = await this.#db.engine_loop_usage.get({ loop_id: loopId });
493
+ if (row === undefined)
494
+ throw new Error(`loopUsage: loop ${loopId} does not exist`);
495
+ const parsedCosts = JSON.parse(row?.costs ?? "[]");
496
+ if (!Array.isArray(parsedCosts))
497
+ throw new TypeError(`loop ${loopId} monetary evidence is not an array`);
498
+ const costs = parsedCosts.map((cost) => validateProviderCost(cost));
390
499
  return {
391
500
  promptTokens: row?.prompt ?? 0,
392
501
  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.
502
+ reasoningTokens: row?.reasoning ?? 0,
503
+ cachedTokens: row?.cached ?? 0,
504
+ costUsd: row?.cost_usd ?? null,
505
+ costs,
506
+ // Latest provider attempt on the latest turn, not the billed total.
396
507
  contextTokens: row?.context ?? 0,
397
- // #274 — the last turn's model window (denominator); null when the provider reports none.
508
+ // Latest effective packet allowance; null when uncapped or unknown.
398
509
  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.
510
+ // Latest turn's opaque provider metadata. {§meta-passthrough}
401
511
  meta: JSON.parse(row?.meta ?? "{}"),
402
512
  };
403
513
  }
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.
514
+ // A mapped rail divergence is a CODE-POINT offset into the model's content;
515
+ // the snippet/notices surface speaks the parser-point convention. {§parser-position}
406
516
  // Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
407
517
  // clamping out-of-range offsets to the content's end.
408
518
  #offsetToLineColumn(content, offset) {
@@ -426,7 +536,7 @@ export default class Engine {
426
536
  // Its ceiling therefore counts every prior turn, not merely this process-local
427
537
  // execution segment.
428
538
  const turnIds = await this.#lifecycle.turnIds(loopId);
429
- const suddenDeathThreshold = maxTurns - maxStrikes;
539
+ let invalidEmissionRecoveryEntryId = null;
430
540
  // Per-loop AbortController for scheme-side cancellation propagation.
431
541
  // Chained from the caller's `signal` so an external abort cascades.
432
542
  const loopAbort = new AbortController();
@@ -437,23 +547,24 @@ export default class Engine {
437
547
  signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
438
548
  }
439
549
  this.#loopAborts.set(loopId, loopAbort);
440
- // §operator-config-loop-timeout — the wall-clock budget. Expiry aborts the loop signal, so a
550
+ // {§operator-config-loop-timeout} — the wall-clock budget. Expiry aborts the loop signal, so a
441
551
  // 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
552
+ // loop terminates 504 (kin to the exec <T> reap's 504, {§exec-timeout}) — a legible engine
443
553
  // terminal, never an outside kill. unref'd: the wall never holds the process open.
444
554
  const wall = setTimeout(() => loopAbort.abort(LOOP_TIMEOUT_REASON), readLoopTimeoutMs());
445
555
  wall.unref();
446
556
  const timedOut = () => loopAbort.signal.aborted && loopAbort.signal.reason === LOOP_TIMEOUT_REASON;
447
557
  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`,
558
+ const failure = Results.failure("engine:rails", "loop-timeout", 504, `The loop exceeded its wall-clock deadline after ${turnIds.length} turns.`, {}, {
559
+ turns: turnIds.length,
560
+ stage: "loop",
561
+ retryable: false,
453
562
  });
454
- await this.#lifecycle.finish(loopId, 504, "loop_timeout");
563
+ const result = await this.#lifecycle.finish(loopId, failure);
564
+ if (result === null)
565
+ throw new Error(`loop ${loopId} became terminal before timeout settlement`);
455
566
  cleanup("forceful", "loop_timeout");
456
- return { turnIds, finalStatus: 504, hitMaxTurns: false, reason: "loop_timeout" };
567
+ return { turnIds, result, hitMaxTurns: false, reason: "loop_timeout" };
457
568
  };
458
569
  // Cleanup splits by termination kind:
459
570
  // - "graceful" (SEND[202] Accepted): in-flight streaming-scheme spawns
@@ -471,23 +582,19 @@ export default class Engine {
471
582
  this.#strikes.delete(loopId);
472
583
  this.searchGate.cleanup(loopId);
473
584
  this.#hardOverflowRecovery.delete(loopId);
474
- this.#telemetry.delete(loopId);
585
+ this.#notices.delete(loopId);
475
586
  };
476
587
  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
588
  const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
482
589
  if (row === undefined)
483
590
  throw new Error(`Engine.runLoop: loop ${loopId} not found`);
484
591
  if (row.status === 100) {
485
592
  // NOT a terminal — a wake re-queued this loop while its own live drain was
486
593
  // 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
594
+ // check, {§worker-lifecycle-wake-requeue-not-terminal}). The wake's intent is KEEP
488
595
  // RUNNING: re-claim atomically and continue — the injected prompt is already
489
596
  // this loop's next turn. Returning it as "external" broadcast a QUEUED loop
490
- // as loop/terminated {finalStatus: 100} — the delegation-flags flake.
597
+ // as a terminal result with status 100 — the delegation-flags race.
491
598
  await this.#db.engine_reclaim_queued_loop.run({ loop_id: loopId });
492
599
  continue; // claimed (or a racer flipped it first — the re-read decides)
493
600
  }
@@ -496,34 +603,38 @@ export default class Engine {
496
603
  // contract (E.4). Every other terminal, 200 included, reaps: "done"
497
604
  // must not leak running execs. Trust the code's declared intent.
498
605
  cleanup(row.status === 202 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
499
- return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
606
+ if (row.status === 202) {
607
+ return { turnIds, result: { status: 202 }, hitMaxTurns: false, reason: "external" };
608
+ }
609
+ const result = await this.#lifecycle.result(loopId);
610
+ if (result === null) {
611
+ throw new Error(`terminal loop ${loopId} status ${row.status} has no operation result`);
612
+ }
613
+ return { turnIds, result, hitMaxTurns: false, reason: "external" };
500
614
  }
615
+ // Durable disposition outranks a later process-local cancellation observation.
616
+ // SEND may commit 202 immediately before daemon shutdown aborts this drain; reading
617
+ // the abort first launders that lawful park into 499 under load. Only a still-running
618
+ // 102 loop can be cancelled or time out at this boundary.
619
+ if (timedOut())
620
+ return await ruleTimeout();
621
+ signal?.throwIfAborted();
501
622
  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`,
623
+ const failure = Results.failure("engine:rails", "max-turns", 429, `The configured turn ceiling (${maxTurns}) is exhausted.`, {}, {
624
+ maximumTurns: maxTurns,
625
+ stage: "loop",
626
+ retryable: false,
509
627
  });
510
- await this.#lifecycle.finish(loopId, 429, "max_turns");
628
+ const result = await this.#lifecycle.finish(loopId, failure);
629
+ if (result === null)
630
+ throw new Error(`loop ${loopId} became terminal before max-turn settlement`);
511
631
  cleanup("forceful", "max_turns");
512
- return { turnIds, finalStatus: 429, hitMaxTurns: true, reason: "max_turns" };
632
+ return { turnIds, result, hitMaxTurns: true, reason: "max_turns" };
513
633
  }
514
- // PLURNK_SERVICE_EXEC_WAIT_MS — a post-EXEC breath: if a spawn from the prior turn
515
- // is still in flight, give it a tunable beat to land in THIS turn's packet
516
- // before we assemble it. A fixed grace beat, never a wait-for-completion;
517
- // 0/unset = off. Abortable with the loop signal.
518
634
  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.
635
+ // {§exec-hold-until-concluded} — hold matching runtime/effect
636
+ // streams until conclusion or the fail-open cap, then resume the
637
+ // ordinary cycle without altering stream state.
527
638
  const holdSet = new Set((process.env.PLURNK_SERVICE_EXEC_HOLD ?? "").split(",").map((x) => x.trim()).filter((x) => x.length > 0));
528
639
  const holdCapMs = Number(process.env.PLURNK_SERVICE_EXEC_HOLD_MS ?? "300000");
529
640
  if (holdSet.size > 0 && holdCapMs > 0 && execHandler?.hasActiveHoldSpawns !== undefined) {
@@ -532,16 +643,24 @@ export default class Engine {
532
643
  await delay(150, undefined, { signal });
533
644
  }
534
645
  }
535
- const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
536
- if (execWaitMs > 0) {
537
- if (execHandler?.hasActiveSpawns?.(workerId) === true)
538
- await delay(execWaitMs, undefined, { signal });
539
- }
540
646
  let turn;
647
+ const releaseWorkspace = await this.#acquireWorkspaceTurn(workspaceId, workerId);
541
648
  try {
542
- turn = await this.runTurn({
543
- provider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
544
- turnNumber: turnIds.length + 1, maxTurns,
649
+ turn = await observed(// {§observability-boundary}
650
+ "loop.turn", { workerId, "loop.id": loopId }, async (span) => {
651
+ const t = await this.runTurn({
652
+ provider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
653
+ turnNumber: turnIds.length + 1, maxTurns,
654
+ invalidEmissionRecoveryEntryId,
655
+ });
656
+ span.setAttribute("turn.id", t.turnId);
657
+ return t;
658
+ });
659
+ await this.#workspaceTurnCompleted?.({
660
+ workspaceId,
661
+ workerId,
662
+ loopId,
663
+ turnId: turn.turnId,
545
664
  });
546
665
  }
547
666
  catch (err) {
@@ -551,121 +670,162 @@ export default class Engine {
551
670
  return await ruleTimeout();
552
671
  throw err;
553
672
  }
673
+ finally {
674
+ releaseWorkspace();
675
+ }
554
676
  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`,
677
+ // {§invalid-emission-attempts} Invalid provider emissions are retried beneath this turn and never
678
+ // reach the strike rail. The first consecutive exhaustion has already
679
+ // exposed its bounded lifeline through ordinary next-turn state. A
680
+ // second exhaustion is terminal; an admitted turn clears the sequence.
681
+ if (turn.emissionExhausted) {
682
+ if (invalidEmissionRecoveryEntryId === null) {
683
+ if (turn.rejectedModelEntryId === undefined) {
684
+ throw new Error("an admitted invalid-emission recovery requires its rejected model-entry identity");
685
+ }
686
+ invalidEmissionRecoveryEntryId = turn.rejectedModelEntryId;
687
+ continue;
688
+ }
689
+ const failure = Results.failure("engine:generation", "invalid-emission-exhausted", 500, `No valid PLAN...SEND turn was received after ${turn.emissionAttempts} emission attempts.`, {}, {
690
+ attempts: turn.emissionAttempts,
691
+ stage: "emission-validation",
692
+ retryable: false,
562
693
  });
563
- await this.#lifecycle.finish(loopId, 413, "budget_overflow");
694
+ const result = await this.#lifecycle.finish(loopId, failure);
695
+ if (result === null)
696
+ throw new Error(`loop ${loopId} became terminal before invalid-emission settlement`);
697
+ cleanup("forceful", "invalid_emission");
698
+ return { turnIds, result, hitMaxTurns: false, reason: "invalid_emission" };
699
+ }
700
+ invalidEmissionRecoveryEntryId = null;
701
+ // SPEC {§grinder}: budget hard-stop — packet won't fit even collapsed → abandon.
702
+ if (turn.budgetHardStop) {
703
+ if (turn.budget === undefined) {
704
+ throw new Error("a budget hard-stop requires its measured overflow");
705
+ }
706
+ const failure = BudgetOverflow.result(turn.budget.usage, turn.budget.ceiling, false);
707
+ const result = await this.#lifecycle.finish(loopId, failure);
708
+ if (result === null)
709
+ throw new Error(`loop ${loopId} became terminal before budget settlement`);
564
710
  cleanup("forceful", "budget_overflow");
565
- return { turnIds, finalStatus: 413, hitMaxTurns: false, reason: "budget_overflow" };
711
+ return { turnIds, result, hitMaxTurns: false, reason: "budget_overflow" };
566
712
  }
567
- // Rails #38/#39 — per-turn strike accounting (cycle detection, the
568
- // grinder/steer coupling, hard-failure statuses). StrikeRail owns the
713
+ // {§engine-rails} — per-turn strike accounting (cycle detection, the
714
+ // grinder/steer coupling, hard operation outcomes). StrikeRail owns the
569
715
  // bookkeeping; runLoop owns abandonment.
570
716
  const verdict = this.#strikes.assess(loopId, {
571
717
  fingerprint: turn.fingerprint,
572
- statuses: turn.statuses,
573
- noOps: turn.status === TURN_STATUS_NO_OPS,
718
+ outcomes: turn.outcomes,
574
719
  budgetStruck: turn.budgetStruck,
575
720
  steerStruck: turn.steerStruck,
576
721
  minCycles, maxCyclePeriod, maxStrikes,
577
722
  });
578
723
  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.
724
+ // {§engine-rails} — the source on the crossing turn classifies
725
+ // the engine verdict: cycle-driven is 508; every other strike is 500.
582
726
  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`,
727
+ const failure = Results.failure("engine:rails", "strike-threshold", status, verdict.cycleDetected
728
+ ? `The loop reached its strike threshold after ${turnIds.length} turns because its operation pattern repeated.`
729
+ : `The loop reached its strike threshold after ${turnIds.length} turns because consecutive turns failed.`, {}, {
730
+ turns: turnIds.length,
731
+ stage: "loop",
732
+ retryable: false,
592
733
  });
593
- await this.#lifecycle.finish(loopId, status, "strike_threshold");
734
+ const result = await this.#lifecycle.finish(loopId, failure);
735
+ if (result === null)
736
+ throw new Error(`loop ${loopId} became terminal before strike settlement`);
594
737
  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.
738
+ return { turnIds, result, hitMaxTurns: false, reason: "strike_threshold" };
602
739
  }
603
740
  }
604
741
  }
605
- async runTurn({ provider, messages, requirements = "", workspaceId, workerId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
742
+ async runTurn({ provider, messages, requirements = "", workspaceId, workerId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, invalidEmissionRecoveryEntryId, }) {
743
+ const allowInvalidEmissionRecovery = invalidEmissionRecoveryEntryId === null;
744
+ const transientOpenLogEntryId = typeof invalidEmissionRecoveryEntryId === "number"
745
+ ? invalidEmissionRecoveryEntryId
746
+ : null;
606
747
  // === Turn-as-container model ===
607
748
  //
608
749
  // Turn rows are created at runTurn OPEN (status=102, placeholder
609
750
  // packet) so things can be written into the turn before the model
610
751
  // is called: the user prompt on turn 1; later, system signals or
611
- // injected telemetry events on any turn. The turn is CLOSED at
752
+ // injected Notices on any turn. The turn is CLOSED at
612
753
  // the end with the final packet + status + usage stats.
613
754
  //
614
755
  // sequence is "ordinal of stuff in this turn." Pre-model
615
756
  // writes consume low indices; model ops continue from there.
616
757
  const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
617
758
  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.
759
+ // loops.sequence is the loop's ordinal within the worker. Turn-0 foists that belong to the
760
+ // WORKER (manifest preview, AGENTS, operator docs) gate on the worker's first loop, not every loop's
761
+ // first turn ({§actor-boundary-catalog-preview}); per-loop foists such as
762
+ // {§prompt-entry} still fire each loop. Read once, turn-1 only.
621
763
  const loopRow = seq === 1
622
764
  ? await this.#db.engine_get_loop_prompt.get({ loop_id: loopId })
623
765
  : undefined;
624
- const runFirstLoop = (loopRow?.sequence ?? 0) === 1;
766
+ const workerFirstLoop = (loopRow?.sequence ?? 0) === 1;
625
767
  const openRow = await this.#db.engine_open_turn.get({
626
768
  loop_id: loopId, sequence: seq,
627
769
  });
628
770
  if (openRow === undefined)
629
771
  throw new Error("Engine.runTurn: turn open returned no row");
630
772
  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:
773
+ // {§env-delta-log-pull} — establish a fresh worker's observation
774
+ // baseline immediately after its first turn opens. A fork already has
775
+ // its parent's cursor, so the NULL-guarded statement leaves it intact.
776
+ // Events committed after this statement belong to this or a later
777
+ // closed pull window; pre-existing state is read through the ordinary
778
+ // shared-world projections in this first packet.
779
+ await this.#db.engine_initialize_ambient_cursor.get({ workspace_id: workspaceId, worker_id: workerId });
780
+ // Threaded per turn, never engine state, so concurrent loops on
781
+ // different providers each read their own honest tokenizer values.
782
+ const systemCtx = {
783
+ db: this.#db, workspaceId, workerId, loopId, turnId,
784
+ writer: "plurnk",
785
+ signal: this.#loopAborts.get(loopId)?.signal,
786
+ streamEventNotify: this.#streamEventNotify,
787
+ wakeWorkerNotify: this.#wakeWorkerNotify,
788
+ tokenize: this.#tokenize,
789
+ mimetypes: this.#mimetypes,
790
+ defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
791
+ pushNotice: (notice) => this.#notices.push(workspaceId, loopId, notice),
792
+ };
793
+ // Pre-model writes. Each prompt the model has not seen yet becomes an
794
+ // actionless `prompt` log row whose target is its durable prompt:// entry:
634
795
  // - Turn 1: loop.prompt is the initial user prompt.
635
796
  // - Turn N>1: only if Engine.inject (or wake-on-completion via
636
797
  // daemon.inject) wrote a prompt entry for this turn slot
637
798
  // between turn N-1 and N. Inject writes directly to entries;
638
799
  // 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.
800
+ // Model ops dispatch after these pre-model rows.
642
801
  let nextActionIndex = 1;
643
- // §model-entry — the worker's first turn opens with the model's own turn-0, mirrored OPEN: a
802
+ const turnOpenPaths = [];
803
+ // {§model-entry} — the worker's first turn opens with the model's own turn-0, mirrored OPEN: a
644
804
  // worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
645
805
  // from the real ops below (not a static print — we lean into the genuine echo paradigm) and
646
806
  // written at sequence 1, so it reads first as the emission with the foisted results following.
647
807
  const turnZeroMoves = [];
648
808
  if (seq === 1) {
649
- if (runFirstLoop)
809
+ if (workerFirstLoop)
650
810
  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
811
+ // Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, {§actor-boundary-doc-injection}). The docs were materialized
812
+ // as worker://plurnk/<entry> entries by the plurnk worker (LoopDocs, via the
813
+ // {§actor-boundary} keystone); foist a READ of each into THIS turn-0 so the model
654
814
  // reads them inline. It sees only the READ — the materializing EDIT
655
815
  // 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).
816
+ // {§operator-config-workspace-md-docs} — env docs union the workspace's client docs; foist a READ of
817
+ // each materialized worker://plurnk/<alias>.md (LoopDocs materialized the same set).
658
818
  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) : []) {
819
+ // {§actor-boundary-doc-injection} — operator docs appear on the worker's first loop.
820
+ for (const doc of workerFirstLoop ? await WorkspaceSettings.resolveDocs(mdDocs) : []) {
661
821
  const docTarget = {
662
822
  kind: "url", raw: `worker://plurnk/${doc.entryName}`, scheme: "worker",
663
823
  username: null, password: null, hostname: "plurnk", port: null,
664
- pathname: `/${doc.entryName}`, params: {}, fragment: null,
824
+ pathname: `/${doc.entryName}`, query: null, fragment: null,
665
825
  };
666
826
  const docRead = {
667
827
  op: "READ", suffix: "", signal: null, target: docTarget,
668
- lineMarker: null, body: null, position: { line: 1, column: 1 },
828
+ lineMarker: null, body: null, position: UNKNOWN_POSITION,
669
829
  };
670
830
  await this.dispatch({
671
831
  statement: docRead, workspaceId, workerId, loopId, turnId,
@@ -673,95 +833,72 @@ export default class Engine {
673
833
  });
674
834
  nextActionIndex++;
675
835
  }
676
- const promptRow = loopRow; // #269 — already read above (per-loop; fires every loop's turn 1)
836
+ const promptRow = loopRow; // {§prompt-entry} — per-loop; fires every loop's turn 1
677
837
  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 },
838
+ const openPaths = assertOpenPaths(JSON.parse(promptRow.open_paths), `Loop ${loopId} open_paths`);
839
+ const promptLoopSeq = promptRow.sequence; // the loop's per-worker sequence — model-facing, matching log coordinates (owner: the db id read as prompt/2/1)
840
+ const promptPath = promptTarget(promptLoopSeq, seq);
841
+ const entry = {
842
+ channels: { body: { content: promptRow.prompt, mimetype: "text/markdown" } },
843
+ tags: [],
844
+ attributes: { openPaths },
684
845
  };
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 },
711
- };
712
- await this.dispatch({
713
- statement: promptRead, workspaceId, workerId, loopId, turnId,
714
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
846
+ await EntryCrud.writeEntry(promptPath.pathname, entry, systemCtx, "prompt", workerId);
847
+ turnOpenPaths.push(...openPaths);
848
+ const promptLogId = await this.#writePromptLog({
849
+ workerId,
850
+ loopId,
851
+ turnId,
852
+ sequence: nextActionIndex,
853
+ target: promptPath,
854
+ content: promptRow.prompt,
715
855
  });
856
+ onDispatch?.(promptLogId);
716
857
  nextActionIndex++;
717
858
  }
718
859
  }
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) {
860
+ // {§prompt-loop-containment}: the loop contains every prompt that arrived
861
+ // while it ran. Publish each undelivered frame as a prompt row, oldest
862
+ // first, so rapid arrivals reach the model together exactly once.
863
+ {
724
864
  const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
725
865
  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 }))
866
+ const prefix = promptLoopPrefix(loopSeq);
867
+ const undelivered = (await this.#db.drain_undelivered_prompts_for_loop.all({
868
+ owner_id: workerId,
869
+ pattern: `${prefix}%`,
870
+ prefix_len: prefix.length,
871
+ loop_id: loopId,
872
+ }))
727
873
  .filter((r) => typeof r.content === "string" && r.content.length > 0);
728
874
  for (const injectedRow of undelivered) {
729
- const lineCount = injectedRow.content.split("\n").length;
875
+ const attributes = parsePromptAttributes(injectedRow.attributes, `Prompt ${injectedRow.pathname} attributes`);
876
+ const encodedOpenPaths = attributes.openPaths;
877
+ if (encodedOpenPaths !== undefined) {
878
+ turnOpenPaths.push(...assertOpenPaths(encodedOpenPaths, `Prompt ${injectedRow.pathname} openPaths`));
879
+ }
730
880
  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,
881
+ const injTarget = promptTarget(loopSeq, ordinal);
882
+ const promptLogId = await this.#writePromptLog({
883
+ workerId,
884
+ loopId,
885
+ turnId,
886
+ sequence: nextActionIndex,
887
+ target: injTarget,
888
+ content: injectedRow.content,
740
889
  });
890
+ onDispatch?.(promptLogId);
741
891
  nextActionIndex++;
742
892
  }
743
893
  }
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
894
+ // The persistent search-index pass (_search-index.maintain) attaches
895
+ // every readable entry/log projection to complete graph/FTS/vector derivations.
896
+ // NOT an action: no log entry, no sequence slot,
897
+ // not dispatched. There is no materialized manifest entry — the catalog
898
+ // is served on demand by FIND: recursive when asked, shallow-mapped below.
899
+ // {§semantic-embed-dedup} — one pass-wide semantic plan binds every
900
+ // chunk counter to the derivation identity it produces.
901
+ // SPEC {§membership} D4/D5 — git-ls-files workspace membership, resolved at
765
902
  // prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
766
903
  // workspace's project_root is a git working tree, tracked files are
767
904
  // members without a client `add`; active members are materialized
@@ -774,431 +911,618 @@ export default class Engine {
774
911
  // The warm materialized membership before deriving. This second pass is the
775
912
  // ordinary cheap change detector and captures any drift that landed meanwhile.
776
913
  const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
777
- await this.#logFsFictions(workspaceId, fsDivergences);
914
+ // {§packet-git-status} — one post-reconciliation snapshot supplies both
915
+ // the compact packet summary and each causal file event's exact XY state.
916
+ const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopAborts.get(loopId)?.signal);
917
+ await this.#logFsFictions(workspaceId, fsDivergences, gitStatus);
778
918
  // The refresh above may have changed bodies (including model/client edits
779
919
  // since the startup warm). Re-derive to completion before packet/model
780
920
  // construction. Membership is already current, so this pass does not
781
921
  // consume the filesystem divergences a second time.
782
922
  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.
923
+ // Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, {§actor-boundary-catalog-preview}):
924
+ // FIND surveys foisted into the worker's first model turn so it opens with its catalog.
925
+ // Folder-capable surfaces reveal one level with `*`; each deeper directory is an
926
+ // actionable `dir/**` aggregate. The curated kernel docs remain recursive, so the
927
+ // opening packet demonstrates both navigation forms. Empty results are orientation.
788
928
  if (seq === 1) {
789
- // #231 — a workspace's client-chosen filesItems REPLACES the env default outright.
929
+ // {§operator-config-workspace-files-items} — workspace filesItems replaces the env default.
790
930
  const { filesItems: workspaceMI } = await WorkspaceSettings.read(this.#db, workspaceId);
791
931
  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 —
932
+ if (filesItems !== null && workerFirstLoop) { // {§actor-boundary-catalog-preview} — once per worker
933
+ // engine_scheme_catalog_summary is the workspace-bounded scheme source: ordered,
934
+ // one row per stored entry scheme. log:// is absent —
795
935
  // it lives in log_entries, not the catalog (present-mode, the # Log section).
796
936
  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 });
937
+ // Entry-bearing plugin schemes foist alongside the four structural surveys below.
938
+ const foistSchemes = catalogSchemes
939
+ .filter((catalog) => catalog.scheme !== "prompt" && catalog.scheme !== "worker")
940
+ .map(({ scheme, shallow_items }) => ({ scheme, shallow_items }));
941
+ // Commons + project files always foist. An empty result establishes that the
942
+ // surface exists and currently contains nothing.
943
+ foistSchemes.push({
944
+ scheme: "worker",
945
+ shallow_items: catalogSchemes.find((c) => c.scheme === "worker")?.shallow_items ?? 0,
946
+ });
806
947
  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;
948
+ foistSchemes.push({ scheme: "file", shallow_items: 0 }); // Empty project surface still receives its orienting FIND.
949
+ for (const { scheme, shallow_items: shallowItems } of foistSchemes) {
950
+ const isFile = scheme === "file";
951
+ const pattern = this.#schemes.manifestFor(scheme)?.folderScopes === true ? "*" : "**";
952
+ // Only the file map takes PLURNK_SERVICE_FILES_ITEMS as an explicit first-N
953
+ // cap. Every other ordinary survey uses FIND's markerless first page.
954
+ const cap = isFile && filesItems > 0 && shallowItems > 0 ? Math.min(filesItems, shallowItems) : null;
955
+ const catalogMarker = cap === null ? null : { marks: [1, cap] };
817
956
  // 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
957
+ // teaches (`*`, `src/**`, `**/notes.md`; bare = project-relative) — so the turn-0
819
958
  // exemplar and the log rows the model reads never train a leading-slash or
820
959
  // file:/// habit the rest of the teaching contradicts.
821
960
  const catalogFind = {
822
961
  op: "FIND", suffix: "", signal: null,
823
- target: isFile ? { kind: "local", raw: "**" } : {
962
+ target: isFile ? { kind: "local", raw: pattern } : {
824
963
  kind: "url",
825
- raw: `${schemeName}:///**`,
826
- scheme: schemeName,
964
+ raw: `${scheme}:///${pattern}`,
965
+ scheme,
827
966
  username: null, password: null, hostname: null, port: null,
828
- pathname: "/**",
829
- params: {}, fragment: null,
967
+ pathname: `/${pattern}`,
968
+ query: null, fragment: null,
830
969
  },
831
970
  body: null,
832
- lineMarker: cap === null ? null : { marks: [1, cap] },
833
- position: { line: 1, column: 1 },
971
+ lineMarker: catalogMarker,
972
+ position: UNKNOWN_POSITION,
834
973
  };
835
974
  await this.dispatch({
836
975
  statement: catalogFind, workspaceId, workerId, loopId, turnId,
837
976
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
838
977
  });
839
978
  nextActionIndex++;
840
- // §model-entry — the same FIND, rendered back to DSL for the turn-0 echo (the model's
841
- // 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`);
979
+ // {§model-entry} — the same FIND, rendered back to DSL for the turn-0 echo
980
+ // (the model's own survey, mirrored OPEN). An explicit file cap rides as
981
+ // `<1,N>`; ordinary surveys teach the safe markerless default by example.
982
+ const target = isFile ? pattern : `${scheme}:///${pattern}`;
983
+ turnZeroMoves.push(`<<FIND(${target})${cap === null ? "" : `<1,${cap}>`}::FIND`);
843
984
  }
844
985
  // 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).
986
+ // always ({§schemes-directory}, published under {§entry-owner}).
846
987
  await Owner.kernelId(this.#db, workspaceId); // the row exists even before docs materialize — the empty survey is orienting, never 404
847
988
  const kernelDocsFind = {
848
989
  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 },
990
+ target: { kind: "url", raw: "worker://plurnk/docs/**", scheme: "worker", username: null, password: null, hostname: "plurnk", port: null, pathname: "/docs/**", query: null, fragment: null },
991
+ body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
851
992
  };
852
993
  await this.dispatch({ statement: kernelDocsFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
853
994
  nextActionIndex++;
854
- 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 },
995
+ turnZeroMoves.push("<<FIND(worker://plurnk/docs/**)<1,-1>::FIND");
996
+ // {§worker-scheme} — the building worker's own scratch gets the same markerless
997
+ // one-level survey in its perspective alone. It always executes: an empty private
998
+ // space is useful orientation, not grounds to hide the surface.
999
+ const ownFind = {
1000
+ op: "FIND", suffix: "", signal: null,
1001
+ target: { kind: "url", raw: "worker://~/*", scheme: "worker", username: null, password: null, hostname: "~", port: null, pathname: "/*", query: null, fragment: null },
1002
+ body: null, lineMarker: null, position: UNKNOWN_POSITION,
885
1003
  };
886
- await this.dispatch({
887
- statement: fileRead, workspaceId, workerId, loopId, turnId,
888
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
889
- });
1004
+ await this.dispatch({ statement: ownFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
890
1005
  nextActionIndex++;
1006
+ turnZeroMoves.push("<<FIND(worker://~/*)::FIND"); // {§model-entry} — the own-space survey, into the turn-0 echo
891
1007
  }
892
- // §model-entry — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
1008
+ // {§model-entry} — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
893
1009
  // foisted above (real, their results already in the log) → SEND[102]. Dynamic — it reflects
894
1010
  // the true survey, never a frozen print — and OPEN: the worked example the model orients on,
895
1011
  // 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");
1012
+ if (workerFirstLoop) {
1013
+ const emission = ["<<PLAN:Survey context, then address the prompt.:PLAN", ...turnZeroMoves, "<<SEND[102]:Next, address the prompt using the survey.:SEND"].join("\n");
898
1014
  await this.#dispatcher.writeModelEntry({ verbatim: emission, workerId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
899
1015
  }
900
1016
  }
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
1017
+ // {§methods-loop-run-open-paths}: selected workspace paths belong to
1018
+ // the prompt frame. Publish the frame, then dispatch ordinary core READs
1019
+ // in that same turn; missing/non-member paths retain their normal 4xx.
1020
+ for (const raw of turnOpenPaths) {
1021
+ const pathname = raw.startsWith("/") ? raw : `/${raw}`;
1022
+ const fileRead = {
1023
+ op: "READ", suffix: "", signal: null, lineMarker: null,
1024
+ target: {
1025
+ kind: "url", raw: `file://${pathname}`, scheme: "file",
1026
+ username: null, password: null, hostname: null, port: null,
1027
+ pathname, query: null, fragment: null,
1028
+ },
1029
+ body: null, position: UNKNOWN_POSITION,
1030
+ };
1031
+ await this.dispatch({
1032
+ statement: fileRead, workspaceId, workerId, loopId, turnId,
1033
+ sequence: nextActionIndex, origin: "plurnk", onDispatch,
1034
+ });
1035
+ nextActionIndex++;
1036
+ }
1037
+ // {§env-delta-log-pull} — materialize ambient observations before packet
1038
+ // composition and reserve their action indices. {§exec-stream} owns the
1039
+ // distinct byte-cursor path for this worker's streams.
1040
+ // {§exec-poll} — EXEC `<0>` is turn-scoped: reap the worker's open turn-scoped streams (necessarily
907
1041
  // from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
908
1042
  // the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
909
1043
  await this.#reapTurnScopedStreams(workerId);
910
1044
  nextActionIndex += await this.#materializeEnvironmentDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
911
1045
  nextActionIndex += await this.#materializeStreamDeltas({ workerId, loopId, turnId, fromSequence: nextActionIndex });
912
- // SPEC §telemetry — git working-tree state for the telemetry section, read once
913
- // (a service-side `git status` shell-out) and threaded into the budget
914
- // rebuild too so it isn't re-shelled on overflow.
915
- 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
1046
+ // The post-reconciliation Git snapshot above is threaded into the packet
1047
+ // and every budget rebuild; overflow never shells again.
1048
+ // Notices are non-terminal observations, never operation-failure truth.
1049
+ // Drain once and thread the same set through every grinder rebuild.
1050
+ const notices = this.#notices.drain(loopId)
1051
+ .filter((event) => event.level !== "info");
1052
+ // Build the model request packet ({§packet-stored-shape}). The log build
917
1053
  // queries log_entries scoped to the worker — the prompt entry just
918
1054
  // written (if turn 1) is part of that query result.
919
1055
  let requestPacket = await this.#packets.buildRequestPacket({
920
1056
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
921
- currentTurnSeq: seq, provider, gitStatus,
1057
+ currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
922
1058
  });
923
- // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
1059
+ // SPEC {§grinder} — budget grinder, pre-LLM: reclaim window on actual overflow.
924
1060
  const enforced = await this.#packets.enforceBudget({
925
1061
  packet: requestPacket, provider, workerId, loopId, turnId,
926
1062
  // The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
927
1063
  // runTurn advances the counter past it below so the post-generate dispatch rows never collide.
928
1064
  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).
1065
+ // Rebuilds re-derive durable errors and retain the one drained notice
1066
+ // set; neither path can duplicate or swallow a product failure.
932
1067
  rebuild: () => this.#packets.buildRequestPacket({
933
1068
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
934
- currentTurnSeq: seq, provider, gitStatus,
1069
+ currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
935
1070
  }),
936
1071
  });
937
- if (enforced.struck)
938
- nextActionIndex += 1; // the budget-overflow error row consumed a sequence
1072
+ if (enforced.recorded)
1073
+ nextActionIndex += 1; // a fold-to-fit Problem consumed the reserved sequence
939
1074
  requestPacket = enforced.packet;
940
1075
  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: "{}",
1076
+ // {§grinder-hard-413-recovery}/{§grinder-hard-413-abort} — admit
1077
+ // one physically sendable informed recovery turn; a physical
1078
+ // overflow or consecutive policy overflow terminates immediately.
1079
+ let physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
1080
+ let recoveryAdmitted = false;
1081
+ if (physicalAdmission.admitted && !this.#hardOverflowRecovery.has(loopId)) {
1082
+ const ceiling = this.#packets.ceilingFor(provider);
1083
+ if (ceiling === null) {
1084
+ throw new Error("an unbounded prompt budget cannot enter budget recovery");
1085
+ }
1086
+ await this.#problems.record({
1087
+ workerId,
1088
+ loopId,
1089
+ turnId,
1090
+ sequence: nextActionIndex++,
1091
+ origin: "model",
1092
+ source: "engine",
1093
+ result: BudgetOverflow.result(requestPacket.tokens, ceiling, true),
965
1094
  });
966
1095
  // Rebuild so the recovery-steer row just minted renders in THIS packet's log +
967
1096
  // errors sections (the same re-derive contract the soft grind uses).
968
- nextActionIndex += 1;
969
1097
  requestPacket = await this.#packets.buildRequestPacket({
970
1098
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
971
- currentTurnSeq: seq, provider, gitStatus,
1099
+ currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
972
1100
  });
1101
+ physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
1102
+ if (physicalAdmission.admitted) {
1103
+ this.#hardOverflowRecovery.add(loopId);
1104
+ recoveryAdmitted = true;
1105
+ }
973
1106
  }
974
- else {
975
- // Hard 413: physically unsendable, or the model already declined its recovery turn.
1107
+ if (!recoveryAdmitted) {
1108
+ // Hard 413: physically unsendable, or still over after the informed recovery turn.
1109
+ const ceiling = this.#packets.ceilingFor(provider);
1110
+ if (ceiling === null) {
1111
+ throw new Error("an unbounded prompt budget cannot hard-stop");
1112
+ }
1113
+ if (!enforced.recorded || !physicalAdmission.admitted) {
1114
+ await this.#problems.record({
1115
+ workerId,
1116
+ loopId,
1117
+ turnId,
1118
+ sequence: nextActionIndex++,
1119
+ origin: "plurnk",
1120
+ source: "engine",
1121
+ result: BudgetOverflow.result(requestPacket.tokens, ceiling, false, physicalAdmission.admitted
1122
+ ? undefined
1123
+ : {
1124
+ reason: physicalAdmission.reason,
1125
+ detail: physicalAdmission.detail,
1126
+ capacity: physicalAdmission.capacity,
1127
+ tokens: physicalAdmission.measurement?.tokens,
1128
+ tokenKind: physicalAdmission.measurement?.kind,
1129
+ tokenSource: physicalAdmission.measurement?.source,
1130
+ }),
1131
+ });
1132
+ requestPacket = await this.#packets.buildRequestPacket({
1133
+ initialMessages: messages, requirements, workspaceId, workerId, loopId,
1134
+ currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
1135
+ });
1136
+ }
976
1137
  // Skip the LLM, close the turn, and let runLoop abandon.
977
- const hardPacket = this.#packets.completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
978
1138
  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
1139
+ id: turnId, status: 413, packet: StoredPacket.stringify(requestPacket),
1140
+ // The attempted turn retains its effective allowance even
1141
+ // when no provider exchange completed. {§tokenomics-client-gauge}
1142
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider),
982
1143
  finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
983
1144
  });
984
- return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
1145
+ return {
1146
+ turnId,
1147
+ status: 413,
1148
+ outcomes: [],
1149
+ fingerprint: "",
1150
+ budgetStruck: enforced.struck,
1151
+ budgetHardStop: true,
1152
+ steerStruck: false,
1153
+ emissionAttempts: 0,
1154
+ emissionExhausted: false,
1155
+ budget: BudgetOverflow.measure(requestPacket.tokens, ceiling),
1156
+ };
985
1157
  }
986
1158
  }
987
1159
  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).
1160
+ // A fitting turn clears the recovery grant; a later independent overflow can earn
1161
+ // a fresh recovery turn (chronic overflow still strikes out via the rail).
990
1162
  this.#hardOverflowRecovery.delete(loopId);
991
1163
  }
992
1164
  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.
1165
+ // Packet pressure and provider generation are independent. The grinder governs
1166
+ // only the request packet; maxTokens comes only from the provider envelope and
1167
+ // never shrinks as the virtual prompt budget fills.
1000
1168
  let response;
1169
+ let splitResponse;
1001
1170
  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.
1171
+ let railEvidence;
1172
+ let emissionAttempts = 0;
1173
+ let providerCallInFlight = false;
1174
+ let providerAttemptSequence = 0;
1175
+ let providerAttemptId = null;
1176
+ let providerAttemptAttributions = [];
1177
+ const providerSignal = this.#loopAborts.get(loopId)?.signal ?? signal;
1178
+ // {§client-metadata}
1009
1179
  const { client } = await WorkspaceSettings.read(this.#db, workspaceId);
1180
+ const observeProviderAttempt = async (id, attemptResponse) => {
1181
+ const attemptUsage = attemptResponse.assistant.usage;
1182
+ await this.#db.engine_observe_turn_attempt_response.run({
1183
+ id,
1184
+ response: JSON.stringify(attemptResponse),
1185
+ usage_prompt: attemptUsage.prompt,
1186
+ usage_completion: attemptUsage.completion,
1187
+ usage_reasoning: attemptUsage.reasoning,
1188
+ usage_cached: attemptUsage.cached,
1189
+ usage_cost: JSON.stringify({
1190
+ kind: "unknown",
1191
+ reason: "provider response retained before monetary classification",
1192
+ }),
1193
+ finish_reason: attemptResponse.assistant.finishReason,
1194
+ model: attemptResponse.assistant.model,
1195
+ });
1196
+ };
1197
+ const classifyProviderAttempt = async (id, attemptResponse, attemptSplit, sequence, accepted, failure = null) => {
1198
+ const attemptUsage = attemptSplit.callMetadata.usage;
1199
+ const attemptCost = providerCostFor(provider, attemptUsage, attemptResponse.charge);
1200
+ const attemptCostUsd = providerCostUsd(attemptCost);
1201
+ await this.#db.engine_classify_turn_attempt_response.run({
1202
+ id,
1203
+ accepted: accepted ? 1 : 0,
1204
+ parse_errors: JSON.stringify(attemptSplit.parseErrors),
1205
+ failure: failure === null ? null : JSON.stringify(failure),
1206
+ usage_cost: JSON.stringify(attemptCost),
1207
+ usage_cost_usd: attemptCostUsd,
1208
+ });
1209
+ emissionAttempts = sequence;
1210
+ };
1010
1211
  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.
1212
+ // {§turn-lifecycle}: bracket the complete provider-attempt window with liveness notices.
1017
1213
  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).
1214
+ this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
1027
1215
  const loopSeq = (await this.#db.engine_loop_sequence.get({ loop_id: loopId }))?.sequence ?? loopId;
1028
1216
  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
1217
+ const primaryWorkerId = String(await this.resolveWorkerPrimary(workerId));
1218
+ const attemptLimit = readEmissionAttempts();
1219
+ const maxTokens = this.#packets.maxTokensFor(provider) ?? undefined;
1220
+ const strikeStreak = this.#strikes.streak(loopId);
1221
+ for (let attempt = 1; attempt <= attemptLimit; attempt++) {
1222
+ // Every attempt carries the exact same model packet, coordinates,
1223
+ // limits, and engine-strike state. Plugin-authored tags are pulled
1224
+ // for the attempt and do not alter the model messages. No failed
1225
+ // emission is appended and no new engine turn opens between calls.
1226
+ providerAttemptSequence = attempt;
1227
+ const attributionContext = Object.freeze({
1228
+ workspaceId: String(workspaceId),
1229
+ workerId: String(workerId),
1230
+ primaryWorkerId,
1231
+ loop: loopSeq,
1232
+ turn: seq,
1233
+ attempt,
1234
+ });
1235
+ providerAttemptAttributions = await this.#attemptAttributions(provider, attributionContext);
1236
+ requestPacket = { ...requestPacket, attributions: providerAttemptAttributions };
1237
+ const attemptRow = await this.#db.engine_open_turn_attempt.get({
1238
+ turn_id: turnId,
1239
+ sequence: attempt,
1240
+ attributions: JSON.stringify(providerAttemptAttributions),
1241
+ model: provider.model,
1242
+ });
1243
+ if (attemptRow === undefined) {
1244
+ throw new Error(`Engine.runTurn: provider attempt ${attempt} did not open`);
1245
+ }
1246
+ providerAttemptId = attemptRow.id;
1247
+ providerCallInFlight = true;
1248
+ const completedResponse = await observed(// {§observability-boundary}
1249
+ "provider.generate", { model: provider.model, attempt }, async (span) => {
1250
+ const generated = await provider.generate({
1251
+ messages: modelMessages,
1252
+ workerId: String(workerId),
1253
+ primaryWorkerId,
1254
+ signal: providerSignal,
1255
+ grammar: railGrammar,
1256
+ maxTokens,
1257
+ strikes: strikeStreak,
1258
+ attributions: providerAttemptAttributions.length > 0
1259
+ ? providerAttemptAttributions
1260
+ : undefined,
1261
+ client: client ?? undefined,
1262
+ workspaceId: String(workspaceId),
1263
+ loop: loopSeq,
1264
+ turn: seq,
1265
+ }); // {§provider-surface-generate} {§provider-guarantees-signal-wired} {§provider-guarantees-serial-attempts} {§attribution} {§client-metadata}
1266
+ providerCallInFlight = false;
1267
+ recordCounter(PROVIDER_CALLS, {
1268
+ model: provider.model,
1269
+ attempt,
1270
+ status: "resolved",
1271
+ });
1272
+ span.setAttribute("status", "resolved");
1273
+ return generated;
1274
+ });
1275
+ response = completedResponse;
1276
+ await observeProviderAttempt(attemptRow.id, completedResponse);
1277
+ railEvidence = railGrammar === undefined
1278
+ ? undefined
1279
+ : Engine.#requireGrammarEvidence(completedResponse);
1280
+ splitResponse = this.#splitResponse(completedResponse);
1281
+ await classifyProviderAttempt(attemptRow.id, completedResponse, splitResponse, attempt, splitResponse.emissionValid);
1282
+ if (splitResponse.emissionValid)
1283
+ break;
1284
+ }
1030
1285
  if (!signal?.aborted)
1031
- this.#telemetry.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
1286
+ this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
1032
1287
  }
1033
1288
  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" });
1289
+ // This handler owns only provider-call failures. Parser, cost, SQL,
1290
+ // and engine-contract failures retain their original source.
1291
+ if (!providerCallInFlight)
1292
+ throw err;
1293
+ providerCallInFlight = false;
1294
+ if (providerAttemptId === null) {
1295
+ throw new Error("provider call failed without a durable attempt identity", { cause: err });
1296
+ }
1297
+ const failure = err instanceof ProviderError
1298
+ ? { status: err.problem.status, problem: err.problem }
1299
+ : providerSignal?.aborted === true
1300
+ ? Results.failure("engine:provider", providerSignal.reason === LOOP_TIMEOUT_REASON ? "provider-call-timeout" : "provider-call-cancelled", providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499, providerSignal.reason === LOOP_TIMEOUT_REASON
1301
+ ? "The provider call was interrupted by the loop deadline."
1302
+ : "The provider call was interrupted by loop cancellation.", {}, { stage: "provider-request", retryable: false })
1303
+ : (() => {
1304
+ console.error("Provider failed outside its Problem Details contract:", err);
1305
+ return Results.failure("engine:provider", "provider-contract-violation", 502, "The provider failed without returning its required Problem Details.", {}, {
1306
+ stage: "provider-request",
1307
+ retryable: false,
1308
+ });
1309
+ })();
1310
+ // {§provider-interrupted-attempt} — a provider-declared interruption
1311
+ // carries response evidence without becoming a completed exchange.
1312
+ // Persist it as an unaccepted attempt before settling the failure.
1313
+ if (err instanceof ProviderError && err.attempt !== undefined) {
1314
+ response = err.attempt;
1315
+ await observeProviderAttempt(providerAttemptId, response);
1316
+ splitResponse = this.#splitResponse(response);
1317
+ await classifyProviderAttempt(providerAttemptId, response, splitResponse, providerAttemptSequence, false, failure);
1318
+ }
1319
+ else {
1320
+ const unknownCost = {
1321
+ kind: "unknown",
1322
+ reason: "provider call failed without response-bearing charge evidence",
1323
+ };
1324
+ await this.#db.engine_fail_turn_attempt.run({
1325
+ id: providerAttemptId,
1326
+ failure: JSON.stringify(failure),
1327
+ usage_cost: JSON.stringify(unknownCost),
1328
+ });
1329
+ emissionAttempts = providerAttemptSequence;
1330
+ }
1331
+ // {§turn-never-blank} — a ProviderError means no completed exchange exists.
1332
+ // Persist its exact RFC 9457 result before propagating it. Grammar evidence
1333
+ // and its engine-owned verdict exist only on completed responses
1334
+ // ({§rail-truth-engine-verdict}). Cancellation is lifecycle truth, not a
1335
+ // provider failure. Close the
1336
+ // attempted turn without inventing an assistant response, then let
1337
+ // runLoop/Daemon settle the exact 504/499 loop result.
1338
+ if (providerSignal?.aborted) {
1339
+ await this.#db.engine_close_turn.run({
1340
+ id: turnId,
1341
+ status: providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499,
1342
+ packet: StoredPacket.stringify(requestPacket),
1343
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1344
+ finish_reason: splitResponse?.callMetadata.finishReason ?? null,
1345
+ model: splitResponse?.callMetadata.model ?? provider.model,
1346
+ meta: JSON.stringify(response?.meta ?? {}),
1347
+ });
1348
+ throw err;
1046
1349
  }
1047
- throw err;
1350
+ const recorded = await this.#problems.record({
1351
+ workerId,
1352
+ loopId,
1353
+ turnId,
1354
+ sequence: nextActionIndex,
1355
+ origin: "plurnk",
1356
+ source: "provider",
1357
+ result: failure,
1358
+ });
1359
+ // The provider call was attempted, but no completed exchange exists.
1360
+ // Persist the exact request half and failure status; omitting assistant
1361
+ // is materially different from fabricating an empty model turn.
1362
+ await this.#db.engine_close_turn.run({
1363
+ id: turnId,
1364
+ status: recorded.result.status,
1365
+ packet: StoredPacket.stringify(requestPacket),
1366
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1367
+ finish_reason: splitResponse?.callMetadata.finishReason ?? null,
1368
+ model: splitResponse?.callMetadata.model ?? provider.model,
1369
+ meta: JSON.stringify(response?.meta ?? {}),
1370
+ });
1371
+ throw new OperationFailureError(recorded.result, { cause: err });
1048
1372
  }
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`;
1075
- // 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)
1373
+ if (response === undefined || splitResponse === undefined) {
1374
+ throw new Error("provider attempt loop completed without a response");
1375
+ }
1376
+ if (!splitResponse.emissionValid) {
1377
+ // {§invalid-emission-attempts} The first consecutive exhaustion
1378
+ // publishes only the raw final response and generic recovery fact.
1379
+ let rejectedModelEntryId;
1380
+ if (allowInvalidEmissionRecovery) {
1381
+ rejectedModelEntryId = await this.#dispatcher.writeModelEntry({
1382
+ verbatim: splitResponse.packetAssistant.content,
1383
+ workerId,
1384
+ loopId,
1385
+ turnId,
1386
+ sequence: nextActionIndex,
1387
+ folded: true,
1388
+ admission: "rejected",
1389
+ });
1390
+ this.#notices.push(workspaceId, loopId, {
1391
+ source: "engine:grammar",
1392
+ kind: "invalid_emission",
1393
+ level: "error",
1394
+ message: INVALID_EMISSION_RECOVERY_MESSAGE,
1395
+ });
1396
+ }
1397
+ await this.#db.engine_close_turn.run({
1398
+ id: turnId,
1399
+ status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
1400
+ packet: StoredPacket.stringify(requestPacket),
1401
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1402
+ finish_reason: splitResponse.callMetadata.finishReason,
1403
+ model: splitResponse.callMetadata.model,
1404
+ meta: JSON.stringify(response.meta ?? {}),
1405
+ });
1406
+ return {
1407
+ turnId,
1408
+ status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
1409
+ outcomes: [],
1410
+ fingerprint: "",
1411
+ budgetStruck: enforced.struck,
1412
+ budgetHardStop: false,
1413
+ steerStruck: false,
1414
+ emissionAttempts,
1415
+ emissionExhausted: true,
1416
+ ...(rejectedModelEntryId === undefined ? {} : { rejectedModelEntryId }),
1417
+ };
1418
+ }
1419
+ // {§packet-stored-shape} — admitted emission data extends the packet;
1420
+ // provider-call metadata remains on the Turn row.
1421
+ const { packetAssistant, callMetadata, parseNotices, recoverableParseErrors, } = splitResponse; // raw assistant content is opaque — split, never interpreted — {§provider-guarantees-assistantraw-opaque}
1422
+ for (const notice of parseNotices) {
1423
+ this.#notices.push(workspaceId, loopId, notice);
1424
+ }
1425
+ // Non-fatal provider transport notices on an accepted turn. Forward each
1426
+ // Notice with a content-offset `line:col`;
1427
+ // the model resolves it against its own emission — READ the folded model-emission row at the
1428
+ // cited lines ({§model-entry}) — not an embedded snippet that would duplicate the emission.
1429
+ for (const notice of response.notices ?? []) {
1430
+ const located = typeof notice.position === "number"
1431
+ ? this.#offsetToLineColumn(packetAssistant.content, notice.position)
1080
1432
  : 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
1433
+ this.#notices.push(workspaceId, loopId, {
1434
+ source: notice.source,
1435
+ kind: notice.kind,
1436
+ message: notice.message ?? "",
1437
+ level: notice.level,
1086
1438
  ...(located !== null
1087
1439
  ? { position: { type: "content-offset", line: located.line, column: located.column } }
1088
1440
  : {}),
1089
1441
  });
1090
1442
  }
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.
1443
+ // Grade configured local evidence independently. Endpoint-owned
1444
+ // constraints remain provider observations. {§rail-truth-engine-verdict}
1094
1445
  let railKeys;
1095
1446
  if (railGrammar !== undefined) {
1447
+ if (railEvidence === undefined)
1448
+ throw new Error("configured GBNF response has no final grammar evidence");
1096
1449
  let verdict = null;
1097
1450
  try {
1098
- verdict = validateGbnf(railGrammar, packetAssistant.content);
1451
+ verdict = validateGbnf(railGrammar, railEvidence.input);
1099
1452
  }
1100
1453
  catch (cause) {
1101
1454
  Engine.#warnRailVerdictGapOnce(cause.message);
1102
1455
  }
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, {
1456
+ railKeys = {
1457
+ railsAttached: railEvidence.transported ? "client" : "withheld",
1458
+ railsVerdict: verdict?.status ?? "unverifiable",
1459
+ };
1460
+ if (verdict !== null && verdict.status !== "accept") {
1461
+ const contentPosition = verdict.pos >= railEvidence.contentStart
1462
+ ? verdict.pos - railEvidence.contentStart
1463
+ : null;
1464
+ const located = contentPosition === null
1465
+ ? null
1466
+ : this.#offsetToLineColumn(packetAssistant.content, contentPosition);
1467
+ this.#notices.push(workspaceId, loopId, {
1108
1468
  source: "engine:rails",
1109
1469
  kind: "grammar_unenforced",
1110
1470
  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})`,
1471
+ ? `emission rejects the grammar at raw code point ${verdict.pos}`
1472
+ : `emission is an incomplete grammar sentence (ends at raw code point ${verdict.pos})`,
1113
1473
  level: "warn",
1114
- position: { type: "content-offset", line: located.line, column: located.column },
1474
+ ...(located === null
1475
+ ? {}
1476
+ : { position: { type: "content-offset", line: located.line, column: located.column } }),
1115
1477
  });
1116
1478
  }
1117
1479
  }
1118
1480
  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
1481
+ const finalOp = packetAssistant.ops.at(-1);
1482
+ if (finalOp?.op !== "SEND") {
1483
+ // Text emissions cannot reach this point without a disposition;
1484
+ // this fail-hard guard also keeps Mock's trusted pre-parsed seam
1485
+ // from creating runtime states that production admission forbids.
1486
+ throw new Error("an admitted emission must end in a disposition SEND");
1487
+ }
1488
+ const dispositionSignal = finalOp.signal;
1489
+ if (typeof dispositionSignal !== "number" || !TERMINAL_SEND_SIGNALS.has(dispositionSignal)) {
1490
+ throw new Error("an admitted emission must end in a disposition SEND");
1491
+ }
1492
+ const sendOp = finalOp;
1493
+ // {§send} the terminal contract — engine error states verify a terminal claim against loop
1494
+ // state, never trusting the model's code. They strike via turn.steerStruck
1495
+ // ({§engine-rails}): the loop continues, the model sees the steering hint not the strike
1127
1496
  // count, and a non-resolver spins out to the engine's 500.
1128
1497
  let steerStruck = false;
1129
1498
  // 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
1499
+ // post-dispatch sequence counter). {§operation-result-uniform-error-channel}
1131
1500
  const pendingEngineErrors = [];
1132
- // Terminal adjudication moved to the DISPATCHER (§send-premature-terminate, the unified
1501
+ // Terminal adjudication moved to the DISPATCHER ({§send-premature-terminate}, the unified
1133
1502
  // pending set): the terminal SEND is judged AT ITS OWN DISPATCH — after the emission's
1134
1503
  // 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;
1504
+ // WORK+[200] is caught. A refused final disposition (409) strikes via
1505
+ // the dispatch-loop check below.
1506
+ let turnStatus = dispositionSignal;
1159
1507
  // 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
1508
+ // The model continued with nothing to do.
1509
+ const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length
1510
+ + recoverableParseErrors.length;
1511
+ if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
1512
+ steerStruck = true;
1513
+ pendingEngineErrors.push("idle_turn");
1183
1514
  }
1184
1515
  // 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;
1516
+ const packet = StoredPacket.admit(requestPacket, packetAssistant, response.assistantRaw);
1187
1517
  await this.#db.engine_close_turn.run({
1188
1518
  id: turnId,
1189
1519
  status: turnStatus,
1190
- packet: JSON.stringify(packet),
1191
- usage_prompt: usage.prompt,
1192
- usage_completion: usage.completion,
1193
- usage_reasoning: usage.reasoning,
1194
- 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.
1520
+ packet: StoredPacket.stringify(packet),
1521
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider), // {§tokenomics-client-gauge}
1522
+ finish_reason: callMetadata.finishReason,
1523
+ model: callMetadata.model,
1524
+ // Opaque provider metadata plus engine-authored rail keys.
1525
+ // {§meta-passthrough}, {§rail-truth-engine-verdict}
1202
1526
  meta: JSON.stringify({ ...(response.meta ?? {}), ...(railKeys ?? {}) }),
1203
1527
  });
1204
1528
  // Dispatch model ops starting at nextActionIndex (continues the
@@ -1208,149 +1532,179 @@ export default class Engine {
1208
1532
  // A degenerate op-loop is a sampler failure guarded at generation, not by dropping
1209
1533
  // already-generated work post-hoc. The ceiling is an OPT-IN operator/client bound:
1210
1534
  // 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.
1535
+ // model gets one notices signal next packet.
1536
+ // {§operator-config-workspace-max-commands} — workspace maxCommands min()s the env ceiling.
1213
1537
  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
1538
+ // PLAN (intended goals) and the final disposition SEND are not actions —
1539
+ // they always dispatch and never count against the cap. maxCommands
1540
+ // bounds real actions only; maxCommands:0 still admits a plan and a disposition
1217
1541
  // (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
1542
  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);
1543
+ const admittedOps = packetAssistant.ops.filter((op) => {
1544
+ return op.op === "PLAN"
1545
+ || op === sendOp
1546
+ || realCommands++ < maxCommands;
1547
+ });
1224
1548
  const opsToDispatch = scheduleTurnOps(admittedOps);
1225
1549
  await this.#dispatcher.prepareEditBatches(opsToDispatch.filter((statement) => statement.op === "EDIT"), {
1226
1550
  workspaceId, workerId, loopId, turnId,
1227
1551
  origin, onDispatch,
1228
- turnParseErrors: parseErrors?.length ?? 0,
1229
1552
  });
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;
1233
- const statuses = [];
1553
+ const droppedCount = opsCount - opsToDispatch.length;
1554
+ const outcomes = [];
1234
1555
  // Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
1235
1556
  // so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
1236
1557
  // every op writes one row (the common case).
1237
1558
  let rowSeq = nextActionIndex;
1559
+ let parseErrorsRecorded = false;
1560
+ const recordRecoverableParseErrors = async () => {
1561
+ if (parseErrorsRecorded)
1562
+ return;
1563
+ parseErrorsRecorded = true;
1564
+ for (const error of recoverableParseErrors) {
1565
+ const recorded = await this.#problems.record({
1566
+ workerId,
1567
+ loopId,
1568
+ turnId,
1569
+ sequence: rowSeq++,
1570
+ origin: "model",
1571
+ source: "grammar",
1572
+ result: Results.failure("grammar:parser", "invalid-operation-syntax", 400, error.message, {}, {
1573
+ line: error.line,
1574
+ column: error.column,
1575
+ source: error.source,
1576
+ stage: "parse",
1577
+ recovery: "Correct only the failed operation; sibling operations were retained.",
1578
+ retryable: false,
1579
+ }),
1580
+ });
1581
+ outcomes.push({ op: null, status: recorded.result.status });
1582
+ onDispatch?.(recorded.id);
1583
+ }
1584
+ };
1238
1585
  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,
1586
+ if (statement.op === "SEND"
1587
+ && typeof statement.signal === "number"
1588
+ && TERMINAL_SEND_SIGNALS.has(statement.signal)) {
1589
+ await recordRecoverableParseErrors();
1590
+ // {§worker-optimistic-settlement} — SEND judges the refreshed
1591
+ // lifecycle after this turn's own fast streams receive one
1592
+ // bounded opportunity to conclude. This is not a sleep and
1593
+ // does not delay a later turn for older monitored streams.
1594
+ const execHandler = this.#schemes.get("exec");
1595
+ await execHandler?.settleTurnSpawns?.(workerId, turnId, readOptimisticSettlementMs(), providerSignal);
1596
+ }
1597
+ const result = await observed(// {§observability-boundary}
1598
+ "op.dispatch", { op: statement.op }, async (span) => {
1599
+ const dispatchResult = await this.#dispatcher.dispatch({
1600
+ statement, workspaceId, workerId, loopId, turnId,
1601
+ sequence: rowSeq,
1602
+ origin, onDispatch,
1603
+ });
1604
+ span.setAttribute("status", dispatchResult.status);
1605
+ recordCounter(OPS_DISPATCHED, { op: statement.op, status: dispatchResult.status });
1606
+ return dispatchResult;
1246
1607
  });
1247
- 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.
1608
+ outcomes.push({ op: statement.op, status: result.status });
1609
+ for (const normalization of result.scopeNormalizations ?? []) {
1610
+ this.#notices.push(workspaceId, loopId, {
1611
+ source: "engine:slicer",
1612
+ kind: "scope_normalized",
1613
+ level: "warn",
1614
+ message: `Scope <${normalization.requested.join(",")}> was normalized to <${normalization.canonical.join(",")}>.`,
1615
+ });
1616
+ }
1617
+ // {§engine-rails} — a refused final disposition leaves both loop
1618
+ // and turn continuing, and its 409 steering ruling strikes once.
1256
1619
  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
1620
+ steerStruck = true;
1261
1621
  turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
1262
- await this.#db.engine_demote_turn_status.run({ id: turnId, status: turnStatus });
1622
+ await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
1263
1623
  }
1264
- // A [300] question resolves through the proposal system (#346) — whatever the
1624
+ // {§send-300-choices}: a question resolves through the proposal system; whatever the
1265
1625
  // resolution (answer/reject/timeout), the LOOP continues to the turn where the model
1266
1626
  // reads it; the turn record is a continue, never a 300 terminal.
1267
1627
  if (statement === sendOp && sendOp.signal === 300 && result.status !== 409) {
1268
1628
  turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
1269
- await this.#db.engine_demote_turn_status.run({ id: turnId, status: turnStatus });
1629
+ await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
1630
+ }
1631
+ // A broadcast [202] is a conditional wait, not an unconditional turn status:
1632
+ // live work parks at 202; completed-but-unobserved work continues at 102; an
1633
+ // empty join completes at 200. Persist and return the dispatcher's actual ruling.
1634
+ if (statement === sendOp
1635
+ && result.status !== 409
1636
+ && sendOp.target === null
1637
+ && sendOp.signal === 202
1638
+ && result.status !== 202) {
1639
+ turnStatus = result.status;
1640
+ await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
1270
1641
  }
1271
1642
  rowSeq += result.rowsWritten ?? 1;
1272
1643
  }
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.
1644
+ await recordRecoverableParseErrors();
1645
+ // Engine rail failures mint as op='error' log rows at the turn's next
1646
+ // free sequence. Bounded syntax failures were recorded in their
1647
+ // authored turn before its terminal disposition.
1276
1648
  let errSeq = rowSeq;
1277
1649
  // max_commands_exceeded IS model-facing: dropped ops the model emitted that didn't run.
1278
1650
  if (droppedCount > 0)
1279
1651
  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: "{}",
1652
+ for (const kind of pendingEngineErrors) {
1653
+ const problem = ENGINE_PROBLEMS[kind];
1654
+ const extensions = kind === "max_commands_exceeded"
1655
+ ? {
1656
+ operationLimit: maxCommands,
1657
+ omittedOperations: droppedCount,
1658
+ stage: "dispatch-admission",
1659
+ recovery: "Continue with no more than the configured operation limit.",
1660
+ retryable: false,
1661
+ }
1662
+ : {
1663
+ stage: "turn",
1664
+ recovery: "Perform an operation before continuing with SEND[102].",
1665
+ retryable: false,
1666
+ };
1667
+ await this.#problems.record({
1668
+ workerId,
1669
+ loopId,
1670
+ turnId,
1671
+ sequence: errSeq++,
1672
+ origin: "plurnk",
1673
+ source: "rail",
1674
+ result: Results.failure("engine:rail", problem.code, problem.status, problem.detail, {}, extensions),
1327
1675
  });
1328
1676
  }
1329
- // §model-entry — mirror this turn's verbatim emission back as a `model` row, so the NEXT
1677
+ // {§log-row-self-explains} — model-operation failures remain on their
1678
+ // own rows; genuine engine faults fail hard and mint no substitute row.
1679
+ // {§model-entry} — mirror this turn's verbatim emission back as a `model` row, so the NEXT
1330
1680
  // packet shows the model exactly what it last produced. ALWAYS born FOLDED — the old
1331
1681
  // born-OPEN-on-error auto-trigger was conditional helpfulness that bred its own hazards
1332
1682
  // (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;
1683
+ // contamination, pressure feedback).
1684
+ // {§encrypted-reasoning-carrier} — core relays every provider-normalized
1685
+ // item unchanged.
1686
+ const reasoningItems = response.assistant.reasoningEncrypted?.length
1687
+ ? response.assistant.reasoningEncrypted
1688
+ : undefined;
1341
1689
  if (packetAssistant.content.trim().length > 0 || reasoningItems !== undefined) {
1342
1690
  await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, workerId, loopId, turnId, sequence: errSeq++, folded: true, ...(reasoningItems !== undefined ? { reasoningItems } : {}) });
1343
1691
  }
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 };
1692
+ return {
1693
+ turnId,
1694
+ status: turnStatus,
1695
+ outcomes,
1696
+ fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops),
1697
+ budgetStruck: enforced.struck,
1698
+ budgetHardStop: false,
1699
+ steerStruck,
1700
+ emissionAttempts,
1701
+ emissionExhausted: false,
1702
+ };
1349
1703
  }
1350
1704
  // Split the wire-level ProviderResponse into the two destinations:
1351
1705
  // packet.assistant gets the model's emission (content, ops, reasoning);
1352
1706
  // Turn columns get the call-metadata (usage, finishReason, model).
1353
- // SPEC §provider-surface / plurnk-providers#1: text-fragment scraping policy lives
1707
+ // {§provider-surface} Text-fragment scraping policy lives
1354
1708
  // here — engine owns the parse and the scraping rule, providers stay
1355
1709
  // grammar-unaware.
1356
1710
  //
@@ -1363,29 +1717,48 @@ export default class Engine {
1363
1717
  const preParsedOps = assistant.ops;
1364
1718
  const ops = [];
1365
1719
  // PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
1366
- // 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
1368
- // #free-text-capture synthesis of SEND[103] log ops was retired as tech debt
1369
- // (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.
1720
+ // client as a log entry. No special hoisting into the reasoning field. Only
1721
+ // structured operations are executable; interstitial text is not an operation.
1722
+ // Full PlurnkParseError context is preserved on rejected attempt evidence;
1723
+ // warnings remain admissible Notices. {§parse-diagnostics}
1374
1724
  const parseErrors = [];
1725
+ let hasUnparsedTail = false;
1726
+ const parseNotices = [];
1375
1727
  if (preParsedOps !== undefined) {
1376
1728
  ops.push(...preParsedOps);
1377
1729
  }
1378
1730
  else {
1379
- const parsed = PlurnkParser.parse(assistant.content);
1731
+ // {§observability-boundary} — the parse is observed without its input;
1732
+ // only the resulting statement count is attributable.
1733
+ const parsed = observedSync("contracts.parse", {}, (span) => {
1734
+ const result = PlurnkParser.parse(assistant.content);
1735
+ span.setAttribute("statements", result.items.filter((item) => item.kind === "statement").length);
1736
+ return result;
1737
+ });
1380
1738
  for (const item of parsed.items) {
1381
1739
  if (item.kind === "statement") {
1382
1740
  ops.push(item.statement);
1383
1741
  }
1384
- // Free text (kind "text") is dropped — #free-text-capture retired (above).
1385
1742
  else if (item.kind === "error") {
1386
1743
  const err = item.error;
1387
1744
  if (err instanceof PlurnkParseError) {
1388
- parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
1745
+ if (err.severity === "warning") {
1746
+ parseNotices.push({
1747
+ source: "grammar",
1748
+ kind: "parse_advisory",
1749
+ level: "warn",
1750
+ message: err.message,
1751
+ position: {
1752
+ type: "content-offset",
1753
+ line: err.line,
1754
+ column: err.column,
1755
+ },
1756
+ parserSource: err.source,
1757
+ });
1758
+ }
1759
+ else {
1760
+ parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
1761
+ }
1389
1762
  }
1390
1763
  else {
1391
1764
  const msg = err?.message ?? "parse error";
@@ -1393,72 +1766,117 @@ export default class Engine {
1393
1766
  }
1394
1767
  }
1395
1768
  }
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.
1769
+ // Boundary loss is the parser's one public fact from `unparsedTail.from` onward;
1770
+ // preserve it with the rejected forensic attempt. {§unparsed-tail-boundary}
1402
1771
  const tail = parsed.unparsedTail;
1403
1772
  if (tail !== undefined) {
1773
+ hasUnparsedTail = true;
1404
1774
  parseErrors.push({ message: tail.reason, line: tail.from.line, column: tail.from.column, source: "grammar" });
1405
1775
  }
1406
1776
  }
1777
+ const plan = ops[0]?.op === "PLAN" ? ops[0] : undefined;
1778
+ const finalOp = ops.at(-1);
1779
+ const terminalSend = finalOp?.op === "SEND"
1780
+ && typeof finalOp.signal === "number"
1781
+ && TERMINAL_SEND_SIGNALS.has(finalOp.signal)
1782
+ ? finalOp
1783
+ : undefined;
1784
+ const recoverableParseErrors = plan !== undefined && terminalSend !== undefined && !hasUnparsedTail
1785
+ ? parseErrors.filter((error) => comparePosition(error, plan.position) > 0
1786
+ && comparePosition(error, terminalSend.position) < 0).toSorted(comparePosition)
1787
+ : [];
1788
+ const emissionValid = preParsedOps !== undefined
1789
+ || (plan !== undefined
1790
+ && terminalSend !== undefined
1791
+ && !hasUnparsedTail
1792
+ && recoverableParseErrors.length === parseErrors.length);
1407
1793
  const reasoning = assistant.reasoning ?? null;
1408
1794
  return {
1409
1795
  packetAssistant: { content: assistant.content, ops, reasoning },
1410
1796
  callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
1411
1797
  parseErrors,
1798
+ recoverableParseErrors: emissionValid ? recoverableParseErrors : [],
1799
+ parseNotices,
1800
+ // The ANTLR model-turn parser is authoritative. A trustworthy
1801
+ // PLAN...SEND frame admits bounded interior statement failures so
1802
+ // they become durable operation results. Missing boundaries,
1803
+ // errors outside the frame, and an unparsed tail reject wholesale.
1804
+ // Pre-parsed ops are Mock's trusted test seam.
1805
+ emissionValid,
1412
1806
  };
1413
1807
  }
1414
1808
  // #note12 — the plugin-provided reference docs (schemes' + execs' `documentation`),
1415
- // materialized at plurnk:///docs/<name>.md by loop_run (like operator docs).
1809
+ // materialized at worker://plurnk/docs/<name>.md by LoopDocs (like operator docs).
1416
1810
  docEntries(workspaceId) {
1417
1811
  return this.#packets.docEntries(workspaceId);
1418
1812
  }
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.
1813
+ // {§env-delta-log-pull} — materialize one closed interval of the ambient
1814
+ // occurrence journal into this worker's self-contained log. #67 owns only
1815
+ // the remaining model-facing actor-name projection.
1425
1816
  async #materializeEnvironmentDeltas(args) {
1426
1817
  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 });
1818
+ const rows = await this.#db.engine_pull_ambient_events.all({ workspace_id: workspaceId, worker_id: workerId });
1819
+ const window = rows[0];
1820
+ if (window === undefined)
1821
+ throw new Error(`ambient pull: worker ${workerId} has no observation window`);
1432
1822
  let written = 0;
1433
1823
  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({
1824
+ if (r.event_id === null || r.producer_worker_id === null || r.producer_worker_name === null || r.kind === null
1825
+ || r.op === null || r.status_rx === null)
1826
+ continue;
1827
+ const termination = r.kind === "loop_termination";
1828
+ if (r.rx === null)
1829
+ throw new Error(`ambient event ${r.event_id} has no materializable result`);
1830
+ const terminal = termination
1831
+ ? TerminalResult.parse(r.rx, `ambient loop-termination event ${r.event_id}`)
1832
+ : null;
1833
+ if (terminal !== null && terminal.status !== r.status_rx) {
1834
+ throw new Error(`ambient loop-termination event ${r.event_id} status ${r.status_rx} does not match its terminal result status ${terminal.status}`);
1835
+ }
1836
+ let attrs = r.attrs ?? "{}";
1837
+ if (terminal !== null) {
1838
+ const inherited = JSON.parse(attrs);
1839
+ if (inherited === null || typeof inherited !== "object" || Array.isArray(inherited)) {
1840
+ throw new TypeError(`ambient loop-termination event ${r.event_id} attrs must be an object`);
1841
+ }
1842
+ const receipt = await BranchReceipt.render(this.#db, r.producer_worker_id);
1843
+ attrs = JSON.stringify({
1844
+ ...inherited,
1845
+ kind: "loop_termination",
1846
+ ...(r.terminated_by === null ? {} : { terminatedBy: r.terminated_by }),
1847
+ ...(receipt === null ? {} : { receipt }),
1848
+ });
1849
+ }
1850
+ const inserted = await this.#db.engine_insert_ambient_delta.get({
1448
1851
  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,
1852
+ event_id: r.event_id,
1853
+ source: r.source ?? WorkerControlAddress.render(r.producer_worker_name),
1854
+ op: r.op,
1855
+ scheme: r.scheme,
1856
+ hostname: r.hostname,
1857
+ pathname: r.pathname,
1858
+ rx: r.rx,
1859
+ mimetype_rx: "application/json",
1860
+ status: r.status_rx,
1861
+ expanded: terminal !== null && terminal.status >= 200 && terminal.status < 300 ? 1 : 0,
1862
+ attrs,
1452
1863
  });
1453
- written++;
1864
+ if (inserted !== undefined)
1865
+ written++;
1454
1866
  }
1867
+ await this.#db.engine_advance_ambient_cursor.get({
1868
+ workspace_id: workspaceId,
1869
+ worker_id: workerId,
1870
+ cursor: window.cursor,
1871
+ boundary: window.boundary,
1872
+ });
1455
1873
  return written;
1456
1874
  }
1457
- // §exec-poll — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
1875
+ // {§exec-poll} — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
1458
1876
  // owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
1459
1877
  // before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
1460
1878
  // 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).
1879
+ // terminal output surfaces born-OPEN through the stream-delta path ({§exec-stream}).
1462
1880
  async #reapTurnScopedStreams(workerId) {
1463
1881
  const open = await this.#db.find_open_turn_scoped_subscriptions_for_worker.all({ worker_id: workerId });
1464
1882
  await Promise.all(open.map(({ id }) => this.#liveSubscriptions.cancel(id)));
@@ -1466,11 +1884,11 @@ export default class Engine {
1466
1884
  cancelSubscription(subscriptionId) {
1467
1885
  return this.#liveSubscriptions.cancel(subscriptionId);
1468
1886
  }
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
1472
- // 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
1887
+ // {§env-delta} — exec streams as an instance of the ambient-observe machine:
1888
+ // each turn, emit each owned channel's next publishable content as a foisted READ row. It is
1889
+ // 200 while the channel streams and preserves the exact terminal result when closed. Ongoing
1890
+ // observations fold; the terminal observation auto-OPENs. The cursor is the streamEnd recorded
1891
+ // on the channel's prior observation. {§exec-stream}
1474
1892
  async #materializeStreamDeltas(args) {
1475
1893
  const { workerId, loopId, turnId, fromSequence } = args;
1476
1894
  const channels = await this.#db.engine_worker_stream_channels.all({ worker_id: workerId });
@@ -1490,26 +1908,60 @@ export default class Engine {
1490
1908
  const priorAttrs = prior !== undefined ? JSON.parse(prior.attrs) : {};
1491
1909
  const cursor = priorAttrs.streamEnd ?? 0;
1492
1910
  const closed = ch.state === "closed" || ch.state === "errored";
1493
- if (ch.content.length <= cursor) {
1494
- // The cursor-terminal race (owner's dogfood find): a channel written in one final
1911
+ const terminal = closed
1912
+ ? Results.assert(JSON.parse(ch.close_result ?? "null"))
1913
+ : null;
1914
+ const terminalResult = async (fields, sequence) => {
1915
+ if (terminal === null)
1916
+ throw new Error(`closed subscription ${ch.subscription_id} has no terminal result`);
1917
+ const result = Results.assert({
1918
+ ...terminal,
1919
+ ...(terminal.problem === undefined ? {} : { problem: { ...terminal.problem } }),
1920
+ ...fields,
1921
+ });
1922
+ if (result.problem !== undefined) {
1923
+ const seqs = await this.#db.engine_loop_turn_seqs.get({
1924
+ loop_id: loopId,
1925
+ turn_id: turnId,
1926
+ });
1927
+ if (seqs === undefined)
1928
+ throw new Error(`stream delta has no log coordinate for loop=${loopId} turn=${turnId}`);
1929
+ Results.attachInstance(result, `log:///${seqs.loop_seq}/${seqs.turn_seq}/${sequence}/READ`);
1930
+ }
1931
+ return result;
1932
+ };
1933
+ const publishEnd = streamPublicationEnd(ch.content, ch.mimetype, cursor, closed);
1934
+ if (publishEnd <= cursor) {
1935
+ // The cursor-terminal race: a channel written in one final
1495
1936
  // burst gets fully shown FOLDED while still active; the close then has zero new
1496
- // bytes and the auto-OPEN terminal delta never fired — the model was never shown
1937
+ // content and the auto-OPEN terminal observation never fired — the model was never shown
1497
1938
  // the conclusion of a stream whose result it already holds folded. The same
1498
- // observation is required when the stream produced zero bytes: completion is
1499
- // information independently of payload. Emit the terminal marker ONCE: open,
1500
- // terse, carrying the close status (§tokenomics-fetch-fits-free).
1939
+ // observation is required when the channel produced no publishable content:
1940
+ // completion is information independently of payload. Text streams retain their
1941
+ // terse marker; structured channels emit a bodyless typed conclusion.
1501
1942
  if (closed && priorAttrs.terminal !== true) {
1943
+ const streamTarget = renderTarget({
1944
+ scheme: ch.runtime,
1945
+ pathname: ch.coord,
1946
+ fragment: visibleFragment,
1947
+ });
1948
+ if (streamTarget === null)
1949
+ throw new Error(`stream ${ch.subscription_id} has no renderable address`);
1502
1950
  const pointer = cursor > 0
1503
- ? `full output already delivered above; READ ${ch.runtime}://${ch.coord}${visibleFragment === null ? "" : `#${visibleFragment}`} to revisit`
1951
+ ? `full output already delivered above; READ ${streamTarget} to revisit`
1504
1952
  : "stream produced no output";
1953
+ const sequence = fromSequence + written;
1954
+ const content = baseMimetype(ch.mimetype).startsWith("text/")
1955
+ ? `[ stream closed (${ch.close_status ?? 200}) - ${pointer} ]`
1956
+ : "";
1505
1957
  await this.#db.engine_insert_stream_delta.run({
1506
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1958
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
1507
1959
  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} ]`,
1511
- mimetype: "text/stream",
1512
- }),
1960
+ rx: JSON.stringify(await terminalResult({
1961
+ content,
1962
+ mimetype: ch.mimetype,
1963
+ }, sequence)),
1964
+ status: terminal?.status ?? 200,
1513
1965
  attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
1514
1966
  expanded: 1,
1515
1967
  });
@@ -1518,91 +1970,136 @@ export default class Engine {
1518
1970
  continue;
1519
1971
  }
1520
1972
  // 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
1973
+ // into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. {§exec-stream}
1522
1974
  const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
1975
+ const sequence = fromSequence + written;
1976
+ const content = ch.content.slice(cursor, publishEnd);
1977
+ const terminalDelivery = closed && publishEnd === ch.content.length;
1978
+ const fields = { content, mimetype: ch.mimetype, startLine };
1979
+ const result = terminalDelivery
1980
+ ? await terminalResult(fields, sequence)
1981
+ : { status: 200, ...fields };
1523
1982
  await this.#db.engine_insert_stream_delta.run({
1524
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1983
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
1525
1984
  scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
1526
- rx: JSON.stringify({ status: 200, content: ch.content.slice(cursor), mimetype: "text/stream", startLine }),
1527
- attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: closed }),
1528
- expanded: closed ? 1 : 0, // §exec-stream — terminal delta auto-OPENs; ongoing folds
1985
+ rx: JSON.stringify(result),
1986
+ status: result.status,
1987
+ attrs: JSON.stringify({ streamEnd: publishEnd, terminal: terminalDelivery }),
1988
+ expanded: terminalDelivery ? 1 : 0, // {§exec-stream} — terminal observation auto-OPENs; ongoing folds
1529
1989
  });
1530
1990
  written++;
1531
1991
  }
1532
1992
  return written;
1533
1993
  }
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
1541
- async #logFsFictions(workspaceId, divergences) {
1994
+ // {§env-delta-filesystem-narration} {§membership-emi-divergence-signal}
1995
+ // — journal project-file divergence once through the reserved actor.
1996
+ async #logFsFictions(workspaceId, divergences, gitStatus) {
1542
1997
  if (divergences.length === 0)
1543
1998
  return;
1544
- const run = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
1999
+ const gitByPath = new Map(gitStatus?.files.map(({ path, status }) => [path, status]) ?? []);
2000
+ const worker = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
1545
2001
  ?? await this.#db.envelope_insert_worker.get({ workspace_id: workspaceId, name: "plurnk", origin: "plurnk" });
1546
- if (run === undefined)
2002
+ if (worker === undefined)
1547
2003
  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 });
2004
+ const loop = await this.#db.envelope_insert_client_loop.get({ worker_id: worker.id });
1549
2005
  if (loop === undefined)
1550
2006
  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");
2007
+ const turn = await JournalTurn.insert(this.#db, loop.id);
1555
2008
  let sequence = 1;
1556
2009
  for (const d of divergences) {
1557
2010
  const span = editedSpan(d.before, d.after);
1558
2011
  await this.#db.engine_insert_log_entry.get({
1559
- worker_id: run.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
2012
+ worker_id: worker.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
1560
2013
  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,
2014
+ // Match Dispatcher.#extractTarget: a bare file address has NULL scheme
2015
+ // only in log target metadata; its entry identity remains `file`.
2016
+ scheme: null, username: null, password: null, hostname: null, port: null,
2017
+ pathname: d.pathname, query: null, fragment: null, lineMarker: null,
1566
2018
  tx: "", mimetype_tx: "text/plain",
1567
2019
  rx: JSON.stringify({ status: 200, entryId: d.entryId, channel: d.channel, span }), mimetype_rx: "application/json",
1568
- status_rx: 200, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
2020
+ status_rx: 200, tokens: 0, state: "resolved", outcome: null,
2021
+ attrs: gitByPath.has(d.pathname)
2022
+ ? JSON.stringify({ git: gitByPath.get(d.pathname) })
2023
+ : "{}",
1569
2024
  });
1570
2025
  }
1571
2026
  }
1572
2027
  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);
2028
+ return observed(// {§observability-boundary}
2029
+ "op.dispatch", { op: context.statement.op }, async (span) => {
2030
+ if (context.statement.op === "EDIT") {
2031
+ const { statement, sequence: _sequence, ...batchContext } = context;
2032
+ await this.#dispatcher.prepareEditBatches([statement], batchContext);
2033
+ }
2034
+ const result = await this.#dispatcher.dispatch(context);
2035
+ span.setAttribute("status", result.status);
2036
+ return result;
2037
+ });
1578
2038
  }
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}
2039
+ // {§op-look}: resolve a READ without writing a log_entries row.
1581
2040
  async look(context) {
1582
2041
  return this.#dispatcher.look(context);
1583
2042
  }
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).
2043
+ async resolveEntryAddress(context) {
2044
+ return this.#dispatcher.resolveEntryAddress(context);
2045
+ }
2046
+ async #writePromptLog({ workerId, loopId, turnId, sequence, target, content, }) {
2047
+ const row = await this.#db.engine_insert_log_entry.get({
2048
+ worker_id: workerId,
2049
+ loop_id: loopId,
2050
+ turn_id: turnId,
2051
+ sequence,
2052
+ origin: "plurnk",
2053
+ source: null,
2054
+ op: "prompt",
2055
+ suffix: "",
2056
+ signal: null,
2057
+ scheme: target.scheme,
2058
+ username: target.username,
2059
+ password: target.password,
2060
+ hostname: target.hostname,
2061
+ port: target.port,
2062
+ pathname: target.pathname,
2063
+ query: target.query,
2064
+ fragment: target.fragment,
2065
+ lineMarker: null,
2066
+ tx: "",
2067
+ mimetype_tx: "text/plain",
2068
+ rx: JSON.stringify({ content, mimetype: "text/markdown" }),
2069
+ mimetype_rx: "application/json",
2070
+ status_rx: 200,
2071
+ tokens: this.#tokenize(content),
2072
+ state: "resolved",
2073
+ outcome: null,
2074
+ attrs: "{}",
2075
+ });
2076
+ if (row === undefined)
2077
+ throw new Error("Engine.#writePromptLog: INSERT ... RETURNING produced no row");
2078
+ return row.id;
2079
+ }
2080
+ // External API to feed a resolution into a pending proposal — the client-interface
2081
+ // seam, core-owned disposition, or the timeout watcher.
2082
+ // {§worker-lifecycle-total-reap}: release every stopped-world waiter before joining drains.
1588
2083
  cancelAllProposals(outcome) {
1589
2084
  this.#proposals.cancelAll(outcome);
1590
2085
  }
1591
2086
  resolveProposal(logEntryId, resolution) {
1592
2087
  this.#proposals.resolve(logEntryId, resolution);
1593
2088
  }
1594
- // Snapshot of pending proposals (for diagnostic / RPC listings).
2089
+ // Snapshot of pending proposals for client-interface discovery.
1595
2090
  pendingProposalIds() {
1596
2091
  return this.#proposals.pendingIds();
1597
2092
  }
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.
2093
+ // Subscribe to proposal-pending observations. Automatic settlement is
2094
+ // core-owned and happens before observers run.
1601
2095
  onProposalPending(listener) {
1602
2096
  this.#proposals.onPending(listener);
1603
2097
  }
2098
+ async pendingProposals(workspaceId) {
2099
+ return this.#proposals.list(workspaceId);
2100
+ }
1604
2101
  // 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
2102
+ // worker still accepting turns?" If yes, skip the wake — the active loop
1606
2103
  // will pick up the channel transition at its next turn boundary. If no,
1607
2104
  // the daemon opens a fresh loop with the wake prompt.
1608
2105
  async hasActiveLoopForWorker(workerId) {
@@ -1610,7 +2107,7 @@ export default class Engine {
1610
2107
  return (row?.n ?? 0) > 0;
1611
2108
  }
1612
2109
  // Workspace-scope eager warm: creation and membership changes start the
1613
- // exhaustive graph/FTS/vector derivation immediately. The RPC returns while
2110
+ // exhaustive graph/FTS/vector derivation immediately. The seam call returns while
1614
2111
  // progress live-fans-out at loopId 0; a model turn joins this same coalesced
1615
2112
  // promise and cannot reach its provider until coverage is complete.
1616
2113
  async warmWorkspaceDerivations(workspaceId) {
@@ -1623,23 +2120,33 @@ export default class Engine {
1623
2120
  tokenize: this.#tokenize,
1624
2121
  mimetypes: this.#mimetypes,
1625
2122
  defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
1626
- pushTelemetry: (event) => this.#telemetry.notify(workspaceId, 0, event),
2123
+ pushNotice: (notice) => this.#notices.notify(workspaceId, 0, notice),
1627
2124
  };
1628
2125
  await this.#queueWorkspaceWarm(ctx); // materialize first; overlapping requests coalesce and rescan
1629
2126
  }
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.
2127
+ // Inject a prompt into the worker's current non-terminal loop. Writes the
2128
+ // next owner-keyed prompt:///<loop>/<N> entry; the next turn publishes it
2129
+ // as one actionless prompt row. Prompt-frame writes serialize per worker,
2130
+ // so concurrent arrivals retain distinct ordered ordinals.
1635
2131
  //
1636
- // Returns null when no loop in the worker is currently active (status=102).
2132
+ // Returns null when no loop in the worker is active or parked (102/202).
1637
2133
  // The daemon-side inject path then enqueues a fresh loop with this
1638
2134
  // 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) {
2135
+ inject(workerId, prompt, openPaths = []) {
2136
+ return this.#withPromptWriteLock(workerId, () => this.#injectPrompt(workerId, prompt, openPaths));
2137
+ }
2138
+ #withPromptWriteLock(workerId, write) {
2139
+ const previous = this.#promptWriteLocks.get(workerId) ?? Promise.resolve();
2140
+ const run = previous.then(write, write);
2141
+ const tail = run.catch(() => { });
2142
+ this.#promptWriteLocks.set(workerId, tail);
2143
+ void tail.then(() => {
2144
+ if (this.#promptWriteLocks.get(workerId) === tail)
2145
+ this.#promptWriteLocks.delete(workerId);
2146
+ });
2147
+ return run;
2148
+ }
2149
+ async #injectPrompt(workerId, prompt, openPaths) {
1643
2150
  const loopRow = await this.#db.drain_current_loop_for_worker.get({ worker_id: workerId });
1644
2151
  if (loopRow === undefined)
1645
2152
  return null;
@@ -1648,11 +2155,16 @@ export default class Engine {
1648
2155
  const turnSeq = turnRow?.next ?? 1;
1649
2156
  const workspaceRow = await this.#db.drain_get_worker_workspace.get({ worker_id: workerId });
1650
2157
  if (workspaceRow === undefined)
1651
- throw new Error(`Engine.inject: run ${workerId} not found`);
2158
+ throw new Error(`Engine.inject: worker ${workerId} not found`);
1652
2159
  // {§prompt-loop-containment} — the frame is the loop's NEXT prompt ordinal, never a turn
1653
2160
  // 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);
2161
+ const prefix = promptLoopPrefix(loopRow.sequence);
2162
+ const ordinalRow = await this.#db.drain_next_prompt_ordinal_for_loop.get({
2163
+ owner_id: workerId,
2164
+ pattern: `${prefix}%`,
2165
+ prefix_len: prefix.length,
2166
+ });
2167
+ const pathname = promptPathname(loopRow.sequence, ordinalRow?.next ?? 2);
1656
2168
  const ctx = {
1657
2169
  db: this.#db, workspaceId: workspaceRow.workspace_id, workerId, loopId,
1658
2170
  turnId: 0, // no turn open at inject time; entries don't pin turnId
@@ -1661,11 +2173,12 @@ export default class Engine {
1661
2173
  streamEventNotify: this.#streamEventNotify,
1662
2174
  wakeWorkerNotify: this.#wakeWorkerNotify,
1663
2175
  tokenize: this.#tokenize,
1664
- pushTelemetry: (event) => this.#telemetry.push(workspaceRow.workspace_id, loopId, event),
2176
+ pushNotice: (notice) => this.#notices.push(workspaceRow.workspace_id, loopId, notice),
1665
2177
  };
1666
2178
  const entry = {
1667
2179
  channels: { body: { content: prompt, mimetype: "text/markdown" } },
1668
2180
  tags: [],
2181
+ attributes: { openPaths },
1669
2182
  };
1670
2183
  await EntryCrud.writeEntry(pathname, entry, ctx, "prompt", workerId);
1671
2184
  return { loopId, turnSeq };