@animalabs/agent-framework 0.32.0 → 0.34.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 (368) hide show
  1. package/README.md +340 -22
  2. package/dist/src/admission/admit.d.ts +18 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -1
  4. package/dist/src/admission/admit.js +16 -3
  5. package/dist/src/admission/admit.js.map +1 -1
  6. package/dist/src/agent.d.ts +208 -80
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +345 -113
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +50 -12
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/api/server.d.ts.map +1 -1
  14. package/dist/src/api/server.js +4 -20
  15. package/dist/src/api/server.js.map +1 -1
  16. package/dist/src/append-all.d.ts +5 -0
  17. package/dist/src/append-all.d.ts.map +1 -0
  18. package/dist/src/append-all.js +8 -0
  19. package/dist/src/append-all.js.map +1 -0
  20. package/dist/src/code-execution/isolate/scripts.d.ts +12 -8
  21. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  22. package/dist/src/code-execution/isolate/scripts.js +24 -0
  23. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  24. package/dist/src/config-checks.d.ts +7 -0
  25. package/dist/src/config-checks.d.ts.map +1 -1
  26. package/dist/src/config-checks.js +14 -0
  27. package/dist/src/config-checks.js.map +1 -1
  28. package/dist/src/context-manager-errors.d.ts +25 -0
  29. package/dist/src/context-manager-errors.d.ts.map +1 -0
  30. package/dist/src/context-manager-errors.js +31 -0
  31. package/dist/src/context-manager-errors.js.map +1 -0
  32. package/dist/src/context-options.d.ts +70 -0
  33. package/dist/src/context-options.d.ts.map +1 -0
  34. package/dist/src/context-options.js +90 -0
  35. package/dist/src/context-options.js.map +1 -0
  36. package/dist/src/cut-off-round.js +4 -4
  37. package/dist/src/deferred-import.d.ts +93 -0
  38. package/dist/src/deferred-import.d.ts.map +1 -0
  39. package/dist/src/deferred-import.js +177 -0
  40. package/dist/src/deferred-import.js.map +1 -0
  41. package/dist/src/dendrite/audience.d.ts +223 -0
  42. package/dist/src/dendrite/audience.d.ts.map +1 -0
  43. package/dist/src/dendrite/audience.js +202 -0
  44. package/dist/src/dendrite/audience.js.map +1 -0
  45. package/dist/src/dendrite/index.d.ts +5 -3
  46. package/dist/src/dendrite/index.d.ts.map +1 -1
  47. package/dist/src/dendrite/index.js +2 -1
  48. package/dist/src/dendrite/index.js.map +1 -1
  49. package/dist/src/dendrite/presets.d.ts +10 -5
  50. package/dist/src/dendrite/presets.d.ts.map +1 -1
  51. package/dist/src/dendrite/presets.js +17 -5
  52. package/dist/src/dendrite/presets.js.map +1 -1
  53. package/dist/src/dendrite/registry.d.ts +62 -7
  54. package/dist/src/dendrite/registry.d.ts.map +1 -1
  55. package/dist/src/dendrite/registry.js +183 -12
  56. package/dist/src/dendrite/registry.js.map +1 -1
  57. package/dist/src/dendrite/types.d.ts +91 -8
  58. package/dist/src/dendrite/types.d.ts.map +1 -1
  59. package/dist/src/failure-text.d.ts +36 -0
  60. package/dist/src/failure-text.d.ts.map +1 -0
  61. package/dist/src/failure-text.js +89 -0
  62. package/dist/src/failure-text.js.map +1 -0
  63. package/dist/src/foreign-rounds.d.ts +69 -0
  64. package/dist/src/foreign-rounds.d.ts.map +1 -0
  65. package/dist/src/foreign-rounds.js +152 -0
  66. package/dist/src/foreign-rounds.js.map +1 -0
  67. package/dist/src/framework.d.ts +2064 -842
  68. package/dist/src/framework.d.ts.map +1 -1
  69. package/dist/src/framework.js +8586 -3739
  70. package/dist/src/framework.js.map +1 -1
  71. package/dist/src/gate/event-gate.d.ts +125 -21
  72. package/dist/src/gate/event-gate.d.ts.map +1 -1
  73. package/dist/src/gate/event-gate.js +487 -72
  74. package/dist/src/gate/event-gate.js.map +1 -1
  75. package/dist/src/gate/index.d.ts +1 -1
  76. package/dist/src/gate/index.d.ts.map +1 -1
  77. package/dist/src/gate/index.js.map +1 -1
  78. package/dist/src/gate/reply-judge.d.ts +4 -0
  79. package/dist/src/gate/reply-judge.d.ts.map +1 -1
  80. package/dist/src/gate/reply-judge.js +12 -3
  81. package/dist/src/gate/reply-judge.js.map +1 -1
  82. package/dist/src/gate/rule-scope.d.ts +30 -0
  83. package/dist/src/gate/rule-scope.d.ts.map +1 -0
  84. package/dist/src/gate/rule-scope.js +41 -0
  85. package/dist/src/gate/rule-scope.js.map +1 -0
  86. package/dist/src/gate/suppress-script-worker.d.ts +2 -0
  87. package/dist/src/gate/suppress-script-worker.d.ts.map +1 -0
  88. package/dist/src/gate/suppress-script-worker.js +103 -0
  89. package/dist/src/gate/suppress-script-worker.js.map +1 -0
  90. package/dist/src/gate/suppress-scripts.d.ts +105 -0
  91. package/dist/src/gate/suppress-scripts.d.ts.map +1 -0
  92. package/dist/src/gate/suppress-scripts.js +403 -0
  93. package/dist/src/gate/suppress-scripts.js.map +1 -0
  94. package/dist/src/gate/suppression-review.d.ts +143 -0
  95. package/dist/src/gate/suppression-review.d.ts.map +1 -0
  96. package/dist/src/gate/suppression-review.js +463 -0
  97. package/dist/src/gate/suppression-review.js.map +1 -0
  98. package/dist/src/gate/suppression-stats.d.ts +125 -0
  99. package/dist/src/gate/suppression-stats.d.ts.map +1 -0
  100. package/dist/src/gate/suppression-stats.js +214 -0
  101. package/dist/src/gate/suppression-stats.js.map +1 -0
  102. package/dist/src/gate/types.d.ts +142 -7
  103. package/dist/src/gate/types.d.ts.map +1 -1
  104. package/dist/src/gate/wake-onboarding.d.ts +119 -0
  105. package/dist/src/gate/wake-onboarding.d.ts.map +1 -0
  106. package/dist/src/gate/wake-onboarding.js +277 -0
  107. package/dist/src/gate/wake-onboarding.js.map +1 -0
  108. package/dist/src/heartbeat/heartbeat.js +1 -1
  109. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  110. package/dist/src/held-writes.d.ts +31 -23
  111. package/dist/src/held-writes.d.ts.map +1 -1
  112. package/dist/src/held-writes.js +44 -59
  113. package/dist/src/held-writes.js.map +1 -1
  114. package/dist/src/index.d.ts +6 -2
  115. package/dist/src/index.d.ts.map +1 -1
  116. package/dist/src/index.js +4 -0
  117. package/dist/src/index.js.map +1 -1
  118. package/dist/src/inference-bounds.d.ts +2 -1
  119. package/dist/src/inference-bounds.d.ts.map +1 -1
  120. package/dist/src/inference-bounds.js +9 -6
  121. package/dist/src/inference-bounds.js.map +1 -1
  122. package/dist/src/jsonrpc-envelope.d.ts +69 -0
  123. package/dist/src/jsonrpc-envelope.d.ts.map +1 -0
  124. package/dist/src/jsonrpc-envelope.js +66 -0
  125. package/dist/src/jsonrpc-envelope.js.map +1 -0
  126. package/dist/src/mcpl/channel-registry.d.ts +188 -28
  127. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  128. package/dist/src/mcpl/channel-registry.js +426 -70
  129. package/dist/src/mcpl/channel-registry.js.map +1 -1
  130. package/dist/src/mcpl/checkpoint-manager.d.ts +2 -4
  131. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  132. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  133. package/dist/src/mcpl/feature-set-manager.d.ts +2 -4
  134. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  135. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  136. package/dist/src/mcpl/inbound-source.d.ts +2 -1
  137. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  138. package/dist/src/mcpl/inbound-source.js +2 -1
  139. package/dist/src/mcpl/inbound-source.js.map +1 -1
  140. package/dist/src/mcpl/inference-router.d.ts +6 -6
  141. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  142. package/dist/src/mcpl/inference-router.js.map +1 -1
  143. package/dist/src/mcpl/modern-connection.d.ts +109 -15
  144. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  145. package/dist/src/mcpl/modern-connection.js +593 -86
  146. package/dist/src/mcpl/modern-connection.js.map +1 -1
  147. package/dist/src/mcpl/protocol-family.d.ts +3 -1
  148. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  149. package/dist/src/mcpl/protocol-family.js +15 -9
  150. package/dist/src/mcpl/protocol-family.js.map +1 -1
  151. package/dist/src/mcpl/push-coalescer.d.ts +132 -22
  152. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  153. package/dist/src/mcpl/push-coalescer.js +105 -15
  154. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  155. package/dist/src/mcpl/push-handler.d.ts +9 -7
  156. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  157. package/dist/src/mcpl/push-handler.js.map +1 -1
  158. package/dist/src/mcpl/server-connection.d.ts +50 -28
  159. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  160. package/dist/src/mcpl/server-connection.js +165 -72
  161. package/dist/src/mcpl/server-connection.js.map +1 -1
  162. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  163. package/dist/src/mcpl/tool-classes.js +3 -0
  164. package/dist/src/mcpl/tool-classes.js.map +1 -1
  165. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  166. package/dist/src/mcpl/tool-presence.js +10 -6
  167. package/dist/src/mcpl/tool-presence.js.map +1 -1
  168. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  169. package/dist/src/mcpl/toolset-host.js +7 -6
  170. package/dist/src/mcpl/toolset-host.js.map +1 -1
  171. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  172. package/dist/src/mcpl/toolsets-module.js +2 -1
  173. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  174. package/dist/src/mcpl/types.d.ts +33 -6
  175. package/dist/src/mcpl/types.d.ts.map +1 -1
  176. package/dist/src/mcpl/types.js.map +1 -1
  177. package/dist/src/module-registry.d.ts +24 -1
  178. package/dist/src/module-registry.d.ts.map +1 -1
  179. package/dist/src/module-registry.js +68 -18
  180. package/dist/src/module-registry.js.map +1 -1
  181. package/dist/src/modules/discord/index.d.ts +45 -8
  182. package/dist/src/modules/discord/index.d.ts.map +1 -1
  183. package/dist/src/modules/discord/index.js +238 -87
  184. package/dist/src/modules/discord/index.js.map +1 -1
  185. package/dist/src/modules/health/index.js +2 -2
  186. package/dist/src/modules/health/index.js.map +1 -1
  187. package/dist/src/modules/history/index.d.ts +59 -0
  188. package/dist/src/modules/history/index.d.ts.map +1 -1
  189. package/dist/src/modules/history/index.js +432 -67
  190. package/dist/src/modules/history/index.js.map +1 -1
  191. package/dist/src/modules/history/semantic.d.ts +6 -5
  192. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  193. package/dist/src/modules/history/semantic.js +18 -25
  194. package/dist/src/modules/history/semantic.js.map +1 -1
  195. package/dist/src/modules/workspace/disk-agreement.d.ts +1 -1
  196. package/dist/src/modules/workspace/disk-agreement.js +1 -1
  197. package/dist/src/modules/workspace/index.d.ts +28 -7
  198. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  199. package/dist/src/modules/workspace/index.js +80 -25
  200. package/dist/src/modules/workspace/index.js.map +1 -1
  201. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  202. package/dist/src/modules/workspace/observe.js +2 -1
  203. package/dist/src/modules/workspace/observe.js.map +1 -1
  204. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  205. package/dist/src/modules/workspace/reconcile.js +3 -2
  206. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  207. package/dist/src/notice-tags.d.ts +20 -9
  208. package/dist/src/notice-tags.d.ts.map +1 -1
  209. package/dist/src/notice-tags.js +20 -9
  210. package/dist/src/notice-tags.js.map +1 -1
  211. package/dist/src/operator-log.d.ts +36 -3
  212. package/dist/src/operator-log.d.ts.map +1 -1
  213. package/dist/src/operator-log.js +2 -2
  214. package/dist/src/operator-log.js.map +1 -1
  215. package/dist/src/presentation.d.ts +747 -0
  216. package/dist/src/presentation.d.ts.map +1 -0
  217. package/dist/src/presentation.js +1437 -0
  218. package/dist/src/presentation.js.map +1 -0
  219. package/dist/src/prose-drafts.d.ts +15 -11
  220. package/dist/src/prose-drafts.d.ts.map +1 -1
  221. package/dist/src/prose-drafts.js +16 -12
  222. package/dist/src/prose-drafts.js.map +1 -1
  223. package/dist/src/read-marks.d.ts +192 -0
  224. package/dist/src/read-marks.d.ts.map +1 -0
  225. package/dist/src/read-marks.js +242 -0
  226. package/dist/src/read-marks.js.map +1 -0
  227. package/dist/src/reasoning-loss.d.ts +5 -0
  228. package/dist/src/reasoning-loss.d.ts.map +1 -0
  229. package/dist/src/reasoning-loss.js +8 -0
  230. package/dist/src/reasoning-loss.js.map +1 -0
  231. package/dist/src/recovery/offline-branch.d.ts +91 -6
  232. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  233. package/dist/src/recovery/offline-branch.js +277 -32
  234. package/dist/src/recovery/offline-branch.js.map +1 -1
  235. package/dist/src/recovery/recover-cli.js +24 -4
  236. package/dist/src/recovery/recover-cli.js.map +1 -1
  237. package/dist/src/send-tools.d.ts +5 -9
  238. package/dist/src/send-tools.d.ts.map +1 -1
  239. package/dist/src/send-tools.js +40 -7
  240. package/dist/src/send-tools.js.map +1 -1
  241. package/dist/src/sent-rows.js +1 -1
  242. package/dist/src/stored-injections.d.ts +2 -2
  243. package/dist/src/stored-injections.js +2 -2
  244. package/dist/src/stored-removal.d.ts +17 -4
  245. package/dist/src/stored-removal.d.ts.map +1 -1
  246. package/dist/src/stored-removal.js +29 -5
  247. package/dist/src/stored-removal.js.map +1 -1
  248. package/dist/src/supplied-text.d.ts +1 -1
  249. package/dist/src/supplied-text.js +3 -3
  250. package/dist/src/supplied-text.js.map +1 -1
  251. package/dist/src/thinking-drops.d.ts +2 -0
  252. package/dist/src/thinking-drops.d.ts.map +1 -1
  253. package/dist/src/thinking-drops.js.map +1 -1
  254. package/dist/src/tool-presentation.d.ts.map +1 -1
  255. package/dist/src/tool-presentation.js +38 -9
  256. package/dist/src/tool-presentation.js.map +1 -1
  257. package/dist/src/tool-result-guard.d.ts +12 -1
  258. package/dist/src/tool-result-guard.d.ts.map +1 -1
  259. package/dist/src/tool-result-guard.js +42 -8
  260. package/dist/src/tool-result-guard.js.map +1 -1
  261. package/dist/src/tune-out/coordinator.d.ts +1170 -81
  262. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  263. package/dist/src/tune-out/coordinator.js +3732 -369
  264. package/dist/src/tune-out/coordinator.js.map +1 -1
  265. package/dist/src/tune-out/tools.d.ts +114 -17
  266. package/dist/src/tune-out/tools.d.ts.map +1 -1
  267. package/dist/src/tune-out/tools.js +89 -28
  268. package/dist/src/tune-out/tools.js.map +1 -1
  269. package/dist/src/tune-out/window-anchor.d.ts +143 -0
  270. package/dist/src/tune-out/window-anchor.d.ts.map +1 -0
  271. package/dist/src/tune-out/window-anchor.js +202 -0
  272. package/dist/src/tune-out/window-anchor.js.map +1 -0
  273. package/dist/src/types/agent.d.ts +121 -25
  274. package/dist/src/types/agent.d.ts.map +1 -1
  275. package/dist/src/types/events.d.ts +2 -0
  276. package/dist/src/types/events.d.ts.map +1 -1
  277. package/dist/src/types/framework.d.ts +72 -17
  278. package/dist/src/types/framework.d.ts.map +1 -1
  279. package/dist/src/types/inference-log.d.ts +1 -0
  280. package/dist/src/types/inference-log.d.ts.map +1 -1
  281. package/dist/src/types/module.d.ts +180 -69
  282. package/dist/src/types/module.d.ts.map +1 -1
  283. package/dist/src/types/trace.d.ts +573 -39
  284. package/dist/src/types/trace.d.ts.map +1 -1
  285. package/package.json +4 -4
  286. package/src/admission/admit.ts +32 -3
  287. package/src/agent.ts +392 -132
  288. package/src/api/mcp-server-core.ts +54 -17
  289. package/src/api/server.ts +4 -23
  290. package/src/append-all.ts +6 -0
  291. package/src/code-execution/isolate/scripts.ts +28 -3
  292. package/src/config-checks.ts +14 -0
  293. package/src/context-manager-errors.ts +37 -0
  294. package/src/context-options.ts +106 -0
  295. package/src/cut-off-round.ts +4 -4
  296. package/src/deferred-import.ts +239 -0
  297. package/src/dendrite/audience.ts +315 -0
  298. package/src/dendrite/index.ts +14 -2
  299. package/src/dendrite/presets.ts +18 -6
  300. package/src/dendrite/registry.ts +213 -14
  301. package/src/dendrite/types.ts +92 -8
  302. package/src/failure-text.ts +88 -0
  303. package/src/foreign-rounds.ts +199 -0
  304. package/src/framework.ts +8796 -3813
  305. package/src/gate/event-gate.ts +518 -91
  306. package/src/gate/index.ts +3 -0
  307. package/src/gate/reply-judge.ts +12 -3
  308. package/src/gate/rule-scope.ts +45 -0
  309. package/src/gate/suppress-script-worker.ts +89 -0
  310. package/src/gate/suppress-scripts.ts +414 -0
  311. package/src/gate/suppression-review.ts +517 -0
  312. package/src/gate/suppression-stats.ts +314 -0
  313. package/src/gate/types.ts +148 -7
  314. package/src/gate/wake-onboarding.ts +309 -0
  315. package/src/heartbeat/heartbeat.ts +1 -1
  316. package/src/held-writes.ts +49 -60
  317. package/src/index.ts +10 -2
  318. package/src/inference-bounds.ts +11 -6
  319. package/src/jsonrpc-envelope.ts +81 -0
  320. package/src/mcpl/channel-registry.ts +513 -116
  321. package/src/mcpl/checkpoint-manager.ts +3 -2
  322. package/src/mcpl/feature-set-manager.ts +2 -1
  323. package/src/mcpl/inbound-source.ts +2 -1
  324. package/src/mcpl/inference-router.ts +7 -4
  325. package/src/mcpl/modern-connection.ts +629 -91
  326. package/src/mcpl/protocol-family.ts +15 -9
  327. package/src/mcpl/push-coalescer.ts +193 -34
  328. package/src/mcpl/push-handler.ts +10 -5
  329. package/src/mcpl/server-connection.ts +169 -75
  330. package/src/mcpl/tool-classes.ts +3 -0
  331. package/src/mcpl/tool-presence.ts +10 -5
  332. package/src/mcpl/toolset-host.ts +7 -6
  333. package/src/mcpl/toolsets-module.ts +2 -1
  334. package/src/mcpl/types.ts +33 -6
  335. package/src/module-registry.ts +73 -17
  336. package/src/modules/discord/index.ts +230 -82
  337. package/src/modules/health/index.ts +2 -2
  338. package/src/modules/history/index.ts +439 -70
  339. package/src/modules/history/semantic.ts +18 -24
  340. package/src/modules/workspace/disk-agreement.ts +1 -1
  341. package/src/modules/workspace/index.ts +89 -26
  342. package/src/modules/workspace/observe.ts +2 -1
  343. package/src/modules/workspace/reconcile.ts +3 -2
  344. package/src/notice-tags.ts +21 -9
  345. package/src/operator-log.ts +36 -3
  346. package/src/presentation.ts +1678 -0
  347. package/src/prose-drafts.ts +16 -12
  348. package/src/read-marks.ts +328 -0
  349. package/src/reasoning-loss.ts +7 -0
  350. package/src/recovery/offline-branch.ts +350 -35
  351. package/src/recovery/recover-cli.ts +23 -4
  352. package/src/send-tools.ts +40 -7
  353. package/src/sent-rows.ts +1 -1
  354. package/src/stored-injections.ts +2 -2
  355. package/src/stored-removal.ts +31 -7
  356. package/src/supplied-text.ts +3 -3
  357. package/src/thinking-drops.ts +2 -0
  358. package/src/tool-presentation.ts +34 -9
  359. package/src/tool-result-guard.ts +41 -7
  360. package/src/tune-out/coordinator.ts +4121 -472
  361. package/src/tune-out/tools.ts +162 -44
  362. package/src/tune-out/window-anchor.ts +277 -0
  363. package/src/types/agent.ts +122 -25
  364. package/src/types/events.ts +2 -0
  365. package/src/types/framework.ts +73 -17
  366. package/src/types/inference-log.ts +1 -0
  367. package/src/types/module.ts +181 -68
  368. package/src/types/trace.ts +607 -43
@@ -18,8 +18,13 @@ import { NOTICE_TAG } from '../notice-tags.js';
18
18
  import { cutToFit, quotedValue, suppliedValue } from '../supplied-text.js';
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { authorLine, authorNamed, beginsWithAuthorLine } from '../author-presentation.js';
21
+ import { qualifiedChannelName } from '../mcpl/channel-registry.js';
21
22
  import { CapabilityGrant } from '../mcpl/capability-grant.js';
22
23
  import { armAfter, armAt } from '../deadline-timer.js';
24
+ /** A hold asked for with no reader to watch it: there is none (a reader ended, or none was configured). */
25
+ const NO_READER_REFUSAL = "Nothing was tuned out: no reader is running here to read a held channel, and this host doesn't "
26
+ + "hold one without a reader, so tune_out isn't available here, and calling it again won't change that. If "
27
+ + 'you need channels diverted, ask whoever runs this host.';
23
28
  /** Defaults for tune_out tool params. */
24
29
  export const TUNE_OUT_DEFAULTS = {
25
30
  cadenceSeconds: 1800,
@@ -44,7 +49,91 @@ export function renderHeldLine(m) {
44
49
  const firstText = m.content.find((b) => b.type === 'text')?.text;
45
50
  return firstText !== undefined && beginsWithAuthorLine(author, firstText) ? line : authorLine(author, line);
46
51
  }
47
- /** The deferred write a stored row landed from, when it came through the queue. */
52
+ /**
53
+ * The answer to a cancel that waits for held writes (deferCancel): what it
54
+ * waits for, and what a stop meanwhile does. `noteDropped`: the caller's
55
+ * note isn't delivered, as the cancel already waiting carries one.
56
+ * `noteKept`: the caller's note waits with the cancel, and the answer says
57
+ * where it goes and what the caller is told of it. It is delivered when a
58
+ * cancel ends the hold, or tries to, else told lost to its author
59
+ * (tellNoteLost), a notice that can't be written being traced and
60
+ * tried again (writeLossNotices) until it is written, and, for the
61
+ * persistent subconscious (`persistent`), kept in the store too
62
+ * (writeLossRecord), so the next boot writes one a stop left unwritten
63
+ * (restoreLossNotices). Where another cancel ends the hold and the note
64
+ * can't be delivered then, the loss is told in that cancel's release
65
+ * (queuedNoteFirst), which the subconscious's window holds. A note written
66
+ * while writes are held again is kept in memory with them, the hold in
67
+ * force; where storing it when they are stored fails, the framework says
68
+ * so (heldWriteLost), and the note is told lost by a notice, as above, once
69
+ * the hold ends, or as one that may be lost where the store can't tell
70
+ * whether it landed, as is the stop's notice, or a hold's that ends
71
+ * without the cancel, before then (`noteUnknown`); it isn't written
72
+ * again. Stored then, it counts as delivered (heldWriteStored), even while
73
+ * the cancel still waits. The waiting
74
+ * cancel, and the note with it, is kept in memory only: a stop before the
75
+ * hold ends loses both, unless the stop's own wait for an operator's
76
+ * operation lets the hold end, which runs the cancel first (the
77
+ * framework's stop calls stop() only after that wait; stopTimers before
78
+ * it), or the note went out already (the cancel's run delivered it and
79
+ * failed before the end: the stop drops the cancel alone, dropQueued; AF
80
+ * #844 review 5, #26), and the subconscious is told of the note's loss
81
+ * as of any other, by a notice kept in the store for the next boot (stop),
82
+ * or, where the store refuses that or the host crashes, by the next boot's
83
+ * notice that any cancel then waiting was lost (noteUnstampedStop).
84
+ * The cancel can't wait for the delivery, which comes only when a cancel
85
+ * ends the hold, or tries to.
86
+ * A reader fork's (`fork`) goes out as its mail even after its run has
87
+ * ended (deliverReaderNote), but a notice of its loss reaches it only while
88
+ * it runs: an ended fork is no agent the framework writes to, and the loss
89
+ * is then traced only (writeLossNotice); a stop drops a fork's notice not
90
+ * yet written, and a fork's note not delivered yet is lost with the cancel.
91
+ */
92
+ export function cancelWaitsNote(what, noteDropped = false, noteKept, unmarked = false) {
93
+ const hold = what.charAt(0).toLowerCase() + what.slice(1);
94
+ return 'Not cancelled yet: an operator\'s change to the store is in progress, or a context the cancel writes to '
95
+ + 'refuses writes until the host re-initializes it. The host runs the cancel when that ends and retries it if it '
96
+ + 'fails; cancelling again won\'t make it sooner while that lasts. '
97
+ + `${what} stays in force; when it ends, what it still holds is delivered, up to its backlog cap and what `
98
+ + 'fits in one message. '
99
+ + 'If the host stops first, the cancel is lost, and the restart doesn\'t run it.'
100
+ + (noteDropped ? ' Your note isn\'t delivered with it: the cancel already waiting carries a note of its own.' : '')
101
+ + (noteKept === 'persistent' && !noteDropped
102
+ ? ` Unless the host stops first, your note goes to the resident when a cancel ends ${hold}, or tries to. `
103
+ + 'If it can\'t be delivered, or the host stops first, you are told what is known of it once the host can write '
104
+ + 'to you, or when it starts again with you as its reader.'
105
+ + (unmarked
106
+ ? ' This run\'s start isn\'t recorded in the store yet: if the host stops or dies before the cancel runs '
107
+ + 'while the store still refuses it, you may not be told.'
108
+ : '')
109
+ : '')
110
+ + (noteKept === 'fork' && !noteDropped
111
+ ? ` Your note goes to the resident as your mail when a cancel ends ${hold}, or tries to, even if your run has `
112
+ + 'ended by then. If it can\'t be delivered, you are told so while your run lasts; after that, no one is. If the '
113
+ + 'host stops before '
114
+ + `${hold} ends, your note is lost with the cancel, unless it went out already, and you are not told.`
115
+ : '');
116
+ }
117
+ /**
118
+ * The reason a reader's cancel_tuneout gives its cancel, which the resident
119
+ * reads in the cancel's lifecycle line ("[Tune-out: #x cancelled — by Muse,
120
+ * 2 wakes]"), the reader in its cancel notice, and a host in the trace: who
121
+ * cancelled, by the name it runs under (the persistent reader's, or a reader
122
+ * fork's own), marked as a value the host supplied, or "the reader" where the
123
+ * call names none. It says who, never why. The line an all-except hold's
124
+ * release posts in the excepted channel says "by its reader" instead
125
+ * (cancelAllExcept): an agent's name there would show the channel an
126
+ * internal identity.
127
+ */
128
+ function cancelledBy(caller) {
129
+ return caller ? `by ${suppliedValue(caller)}` : 'by the reader';
130
+ }
131
+ /**
132
+ * The write a stored row landed from, when an earlier version's deferred
133
+ * queue held it: the boot's import stores it with the queued write's id
134
+ * (`metadata.deferredWriteId`), which a release record that version wrote
135
+ * names it by.
136
+ */
48
137
  function writeIdOf(m) {
49
138
  const writeId = m.metadata?.deferredWriteId;
50
139
  return typeof writeId === 'string' ? writeId : undefined;
@@ -52,7 +141,7 @@ function writeIdOf(m) {
52
141
  function keysOf(rows) {
53
142
  const keys = { ids: [], writeIds: [] };
54
143
  for (const m of rows) {
55
- const writeId = m.writeId ?? writeIdOf(m);
144
+ const writeId = writeIdOf(m);
56
145
  if (writeId !== undefined)
57
146
  keys.writeIds.push(writeId);
58
147
  else if (m.id !== undefined)
@@ -64,7 +153,7 @@ function keyedBy(keys) {
64
153
  const ids = new Set(keys.flatMap((k) => k.ids));
65
154
  const writeIds = new Set(keys.flatMap((k) => k.writeIds));
66
155
  return (m) => {
67
- const writeId = m.writeId ?? writeIdOf(m);
156
+ const writeId = writeIdOf(m);
68
157
  return writeId !== undefined ? writeIds.has(writeId) : m.id !== undefined && ids.has(m.id);
69
158
  };
70
159
  }
@@ -88,6 +177,32 @@ function releaseRecordsOf(rows, epochId) {
88
177
  }
89
178
  return records;
90
179
  }
180
+ /**
181
+ * Whether the resident's last word on this hold is the boot's row that it
182
+ * still stands (`tune-out-end-unrecorded`, tellResidentUnended): one of
183
+ * that epoch after its latest release. A cancel that then delivers nothing
184
+ * more writes a release all the same, saying the end is recorded now
185
+ * (AF #844, Oscar).
186
+ */
187
+ function toldStillStands(rows, epochId) {
188
+ // In store order.
189
+ let release = -1;
190
+ let told = -1;
191
+ rows.forEach((m, at) => {
192
+ const meta = m.metadata;
193
+ if (meta?.epochId !== epochId)
194
+ return;
195
+ if (meta.kind === 'tune-out-cancel')
196
+ release = at;
197
+ else if (meta.kind === 'tune-out-end-unrecorded')
198
+ told = at;
199
+ });
200
+ return told > release;
201
+ }
202
+ /** What that release adds (toldStillStands), agreeing with the boot's row: the earlier release, nothing held since, the end recorded now. */
203
+ function endedNow(it) {
204
+ return `Its earlier release reached you before its end was recorded, and nothing has been held since; this release records the end, so the ${it} has ended.`;
205
+ }
91
206
  function truncationNote(records) {
92
207
  const truncated = records.reduce((n, r) => n + r.truncated, 0);
93
208
  if (truncated === 0)
@@ -109,8 +224,198 @@ function inChannelOf(serverId, channelId) {
109
224
  return (meta?.channelId ?? channelId) === channelId && (meta?.serverId ?? serverId) === serverId;
110
225
  };
111
226
  }
112
- /** How long after an end time's or a wake budget's cancel threw the cancel is tried again. */
227
+ /** The most characters of a note's first line a boot's notice of an unrecorded stop quotes (askedCancelOf). */
228
+ const NOTE_QUOTE_CHARS = 120;
229
+ /** How long after an end time's or a wake budget's cancel threw, or a deferred cancel's run, the cancel is tried again. */
113
230
  const EXPIRY_RETRY_MS = 60_000;
231
+ /** How many times releaseAll tries a release that keeps failing, backing off from EXPIRY_RETRY_MS. */
232
+ const RELEASE_RETRY_ATTEMPTS = 5;
233
+ /**
234
+ * A drop of a queued cancel whose note may be stored after all
235
+ * (lostAtRelease with `mayBeStored`), carrying that (`noteUnknown`) where
236
+ * it is a `hold-ended` or `stopped` drop, which would otherwise tell the
237
+ * note as known lost; any other drop as it is.
238
+ */
239
+ function withUnknownNote(drop, lost) {
240
+ if (!lost?.mayBeStored)
241
+ return drop;
242
+ if (drop.reason === 'hold-ended' || drop.reason === 'stopped')
243
+ return { ...drop, noteUnknown: lost.error };
244
+ return drop;
245
+ }
246
+ /**
247
+ * Whether a queued cancel's note went out already: settled, neither by a
248
+ * release that told its loss (lostInRelease) nor by a write kept in memory
249
+ * (heldInMemory). A cancel settled by a loss told is off the queue by then
250
+ * (dropDeferred's callers), so on the queue settled means delivered. Such a
251
+ * cancel, waiting on after its run (its end not recorded), carries nothing
252
+ * more to deliver: a later cancel that joins it gives it its note, which
253
+ * goes out when the hold ends, rather than being dropped beside one
254
+ * delivered (deferCancel, requeue; AF #844 review 5, #22).
255
+ */
256
+ function noteDelivered(entry) {
257
+ return entry.note !== undefined && entry.noteSettled === true && !entry.lostInRelease && !entry.heldInMemory;
258
+ }
259
+ /** A later cancel's note (`note`, `noteAuthor`) given to a waiting cancel whose own went out already (noteDelivered), unsettled. */
260
+ function takeLaterNote(entry, note, noteAuthor) {
261
+ entry.note = note;
262
+ delete entry.noteSettled;
263
+ if (noteAuthor !== undefined)
264
+ entry.noteAuthor = noteAuthor;
265
+ else
266
+ delete entry.noteAuthor;
267
+ }
268
+ /** How definite each finding is (noteUnstampedStop): a later boot tells a hold again only what says more. */
269
+ const FOUND_RANK = { unknown: 0, asked: 1, delivered: 2, ran: 2 };
270
+ /** What a loss notice tells its author, as the log lines name it. */
271
+ function lossSubject(notice) {
272
+ if (notice.drop.reason !== 'unstamped-stop') {
273
+ const drop = notice.drop;
274
+ const unknown = drop.reason === 'note-undelivered' ? drop.mayBeStored === true
275
+ : (drop.reason === 'stopped' || drop.reason === 'hold-ended') && drop.noteUnknown !== undefined;
276
+ return `that the note it left with the cancel of ${notice.what} ${unknown ? 'may not have been' : 'was not'} delivered`;
277
+ }
278
+ return `that the host stopped without recording the state of ${notice.what}`
279
+ + (notice.drop.found === 'delivered' ? ', after the note it left with a cancel of it was delivered'
280
+ : notice.drop.found === 'ran' ? ', after a cancel of it ran, its end not recorded'
281
+ : notice.drop.found === 'asked' ? ', so that the cancel of it it asked for was lost' : ', so that a cancel of it then waiting was lost');
282
+ }
283
+ /** A kept notice in the record's form, or undefined for an entry this version can't read. */
284
+ function readPersistedLossNotice(value) {
285
+ if (!value || typeof value !== 'object' || Array.isArray(value))
286
+ return undefined;
287
+ const entry = value;
288
+ const at = entry.for;
289
+ const drop = entry.drop;
290
+ if (typeof entry.id !== 'string' || typeof entry.author !== 'string' || entry.author === ''
291
+ || typeof entry.what !== 'string' || typeof entry.keptAtMs !== 'number')
292
+ return undefined;
293
+ if (!at || typeof at !== 'object' || typeof at.epochId !== 'string' || typeof at.serverId !== 'string'
294
+ || typeof at.channelId !== 'string' || (at.scope !== undefined && at.scope !== 'all-except'))
295
+ return undefined;
296
+ let why;
297
+ const noteUnknown = typeof drop?.noteUnknown === 'string' ? { noteUnknown: drop.noteUnknown } : {};
298
+ if (drop?.reason === 'hold-ended' && (drop.current === undefined || typeof drop.current === 'string')) {
299
+ why = { reason: 'hold-ended', ...(typeof drop.current === 'string' ? { current: drop.current } : {}), ...noteUnknown };
300
+ }
301
+ else if (drop?.reason === 'note-undelivered' && (drop.by === 'another' || drop.by === 'itself') && typeof drop.error === 'string') {
302
+ why = { reason: 'note-undelivered', by: drop.by, error: drop.error, ...(drop.mayBeStored === true ? { mayBeStored: true } : {}) };
303
+ }
304
+ else if (drop?.reason === 'stopped') {
305
+ why = { reason: 'stopped', ...noteUnknown };
306
+ }
307
+ else if (drop?.reason === 'superseded') {
308
+ why = { reason: 'superseded' };
309
+ }
310
+ else if (drop?.reason === 'unstamped-stop') {
311
+ const found = drop.found === 'delivered' || drop.found === 'ran' || drop.found === 'asked' ? drop.found : 'unknown';
312
+ why = {
313
+ reason: 'unstamped-stop', found,
314
+ ...(drop.asked === true ? { asked: true } : {}),
315
+ ...(typeof drop.note === 'string' ? { note: drop.note } : {}),
316
+ ...(drop.noteDelivered === true ? { noteDelivered: true } : {}),
317
+ ...(drop.own === true ? { own: true } : {}),
318
+ ...(typeof drop.ranBy === 'string' ? { ranBy: drop.ranBy } : {}),
319
+ ...(drop.rowsUnread === true ? { rowsUnread: true } : {}),
320
+ ...(drop.noteKept === true ? { noteKept: true } : {}),
321
+ ...(Array.isArray(drop.joined) && drop.joined.every((j) => typeof j === 'string') ? { joined: drop.joined } : {}),
322
+ };
323
+ }
324
+ else {
325
+ return undefined;
326
+ }
327
+ return {
328
+ id: entry.id, author: entry.author, what: entry.what,
329
+ for: { epochId: at.epochId, serverId: at.serverId, channelId: at.channelId, ...(at.scope === 'all-except' ? { scope: 'all-except' } : {}) },
330
+ drop: why, keptAtMs: entry.keptAtMs,
331
+ ...(typeof entry.keptAtSequence === 'number' ? { keptAtSequence: entry.keptAtSequence } : {}),
332
+ ...(entry.traced === true ? { traced: true } : {}),
333
+ ...(stopBoundOf(entry.stopBound) ? { stopBound: stopBoundOf(entry.stopBound) } : {}),
334
+ };
335
+ }
336
+ /** A kept bound (LossNotice's `stopBound`, a row's `lostAtStop`) in its form, or undefined. */
337
+ function stopBoundOf(value) {
338
+ if (!value || typeof value !== 'object' || Array.isArray(value))
339
+ return undefined;
340
+ const bound = value;
341
+ if ((bound.runStartedAt !== undefined && typeof bound.runStartedAt !== 'number') || (bound.branch !== undefined && typeof bound.branch !== 'string'))
342
+ return undefined;
343
+ return {
344
+ ...(typeof bound.runStartedAt === 'number' ? { runStartedAt: bound.runStartedAt } : {}),
345
+ ...(typeof bound.branch === 'string' ? { branch: bound.branch } : {}),
346
+ };
347
+ }
348
+ /**
349
+ * The rows of a window (`window`, the persistent subconscious's, `author`)
350
+ * that `keep` keeps and that come after its last row of its own: shown to
351
+ * it, and no turn of its since. A note of its that a cancel delivered is a
352
+ * row under its name but no turn of it (`subconscious-note`), so isn't its
353
+ * own here. A row stored in shards is one row: its first shard.
354
+ */
355
+ export function unansweredIn(window, author, keep) {
356
+ let after = 0;
357
+ window.forEach((m, at) => {
358
+ if (m.participant === author && m.metadata?.kind !== 'subconscious-note')
359
+ after = at + 1;
360
+ });
361
+ return window.slice(after).filter((m) => (m.shardIndex ?? 0) === 0 && keep(m));
362
+ }
363
+ /** A tool result's content as an answer's JSON object, or undefined where it isn't one (a guard's placeholder, say). */
364
+ function answerIn(content) {
365
+ const raw = typeof content === 'string' ? content
366
+ : Array.isArray(content) ? content.map((c) => (c.type === 'text' ? String(c.text ?? '') : '')).join('')
367
+ : '';
368
+ try {
369
+ const answer = JSON.parse(raw);
370
+ return answer && typeof answer === 'object' && !Array.isArray(answer) ? answer : undefined;
371
+ }
372
+ catch {
373
+ return undefined;
374
+ }
375
+ }
376
+ /**
377
+ * The `cancel_tuneout` calls a window (`window`, the persistent
378
+ * subconscious's, `author`) holds, its own rows', each with what its
379
+ * result, where the window holds it, answered: waiting or not, and the
380
+ * server and scope it names. A result row that isn't an answer's JSON (the
381
+ * tool-result guard's placeholder, its batch not settled) is read from
382
+ * `archived`, the original results by tool use id (the guard's audit,
383
+ * subconsciousGuardedResults), where it holds one. A result that isn't
384
+ * there, or isn't an answer, isn't waiting.
385
+ */
386
+ export function cancelAsksIn(window, author, archived) {
387
+ const results = new Map();
388
+ for (const m of window) {
389
+ for (const b of m.content) {
390
+ if (b.type !== 'tool_result')
391
+ continue;
392
+ const id = typeof b.toolUseId === 'string' ? b.toolUseId : b.tool_use_id;
393
+ if (typeof id !== 'string')
394
+ continue;
395
+ const answer = answerIn(b.content) ?? (archived?.has(id) ? answerIn(archived.get(id)) : undefined);
396
+ if (answer)
397
+ results.set(id, answer);
398
+ }
399
+ }
400
+ const asks = [];
401
+ for (const m of window) {
402
+ if (m.participant !== author)
403
+ continue;
404
+ for (const b of m.content) {
405
+ if (b.type !== 'tool_use' || b.name !== 'cancel_tuneout' || typeof b.id !== 'string')
406
+ continue;
407
+ const answer = results.get(b.id);
408
+ asks.push({
409
+ sequence: m.sequence,
410
+ input: b.input && typeof b.input === 'object' ? b.input : {},
411
+ waiting: answer?.waiting === true,
412
+ ...(typeof answer?.serverId === 'string' ? { serverId: answer.serverId } : {}),
413
+ ...(typeof answer?.scope === 'string' ? { scope: answer.scope } : {}),
414
+ });
415
+ }
416
+ }
417
+ return asks;
418
+ }
114
419
  /** Ends a backlog line cut to fit in one of the residents' messages. */
115
420
  const CUT_MARK = ` [${NOTICE_TAG.cutToFit}; the whole message is in the channel's history]`;
116
421
  /**
@@ -129,7 +434,8 @@ export function alreadyTunedOutText(quoted, p, transport) {
129
434
  return `Nothing changed${transport !== undefined ? ' in the tune-out' : ''}: ${quoted} is already `
130
435
  + `tuned out, with cadenceSeconds ${p.cadenceSeconds}, backlogCap ${p.backlogCap} and maxWakes ${p.maxWakes}${until}. `
131
436
  + now
132
- + 'To change those, cancel it first with tune_out mode "cancel", which delivers its backlog to you now, then call '
437
+ + 'To change those, cancel it first with tune_out mode "cancel", which delivers its backlog to you when it ends (its '
438
+ + 'answer says if it waits), then call '
133
439
  + 'tune_out again with the new settings.';
134
440
  }
135
441
  /** What a thrown value says, without throwing: an Error's message, or the
@@ -153,6 +459,20 @@ export class TuneOutCoordinator {
153
459
  * the end time does when its cancel finds the tune-out already ended,
154
460
  * and the channel's next accepted enter does in any case. */
155
461
  expiryTimers = new Map();
462
+ /** A releaseAll asked to retry its failures (a reader that ended): the next try. */
463
+ releaseRetry = null;
464
+ /** stop() has run: no timer is armed after it. */
465
+ stopped = false;
466
+ /**
467
+ * stop() has kept what it found (keepAtStop): a loss told after it (a
468
+ * trace listener's stop, during a cancel's run or a notice's write) is
469
+ * kept as it kept those (keepAfterStop).
470
+ */
471
+ stopDone = false;
472
+ /** The holds the stop's stamp left out (writeStopStamp), which a stamp written again after it leaves out too. */
473
+ stopUnaccounted = new Set();
474
+ /** The holds the stop left out of its stamp because a waiting cancel's note was still kept in memory (StopStamp's `notesKept`). */
475
+ stopNotesKept = new Set();
156
476
  /** Channels with a wake invocation pending inside the coalescing window. */
157
477
  pendingWakes = new Map();
158
478
  /**
@@ -183,19 +503,11 @@ export class TuneOutCoordinator {
183
503
  * held, which is the right side to err on.
184
504
  */
185
505
  readerCursor = new Map();
186
- /**
187
- * `reader: 'forks'`: the deferred writes handed to a reader fork while
188
- * still queued, per epoch and channel. Each lands in the store later as a row
189
- * stamped with the same id (`metadata.deferredWriteId`), past the cursor;
190
- * that row is not handed again. Cleared with the cursor.
191
- */
192
- readerHandedWrites = new Map();
193
506
  /**
194
507
  * `reader: 'forks'`: held messages a reader fork was handed but never read,
195
- * per epoch and channel: its derive failed, or its run threw before it reported
196
- * (readerFailed). The next invocation is handed them again, cursor or no:
197
- * stored rows by id, and by write id those that were queued when handed
198
- * (stored or still queued now). Cleared with the cursor.
508
+ * per epoch and channel, by id: its derive failed, or its run threw before
509
+ * it reported (readerFailed). The next invocation is handed them again,
510
+ * cursor or no. Cleared with the cursor.
199
511
  */
200
512
  readerReturned = new Map();
201
513
  /**
@@ -205,15 +517,126 @@ export class TuneOutCoordinator {
205
517
  lateForks = new WeakSet();
206
518
  /** The all-except scope's durable record, cached (undefined = not read yet). */
207
519
  scopeCache;
208
- /** Timer key for the scope's cadence and expiry. */
209
- static SCOPE_KEY = '\0all-except';
520
+ /**
521
+ * Cancels asked for while the framework keeps writes in memory
522
+ * (writesHeld), by channel key, the all-except hold's under SCOPE_KEY:
523
+ * each runs as it was asked, with its note, or a later cancel's when it
524
+ * had none (deferCancel), once the writes are stored again, or told of
525
+ * where storing one fails (runDeferredCancels). Each is for the epoch it
526
+ * was asked for (`for`), and ends that one only: one whose epoch no longer
527
+ * holds is dropped, told (dropDeferred), never run against a later one.
528
+ * In memory: a restart before then finds the tune-out still in force,
529
+ * and an end time that passed cancels it at once (resumeActiveEpochs); a
530
+ * note one carries is told lost at the stop, its notice kept in the store
531
+ * for the persistent subconscious (stop).
532
+ */
533
+ deferredCancels = new Map();
534
+ /**
535
+ * The notices to a lost queued note's author that weren't written yet
536
+ * (tellNoteLost), by author, in the order the losses came: each is tried
537
+ * again, by runDeferredCancels, at the next release and at the retry,
538
+ * until it is written. Those for the persistent subconscious are kept in
539
+ * the store too (writeLossRecord), and stay there at stop for the next
540
+ * boot to write (restoreLossNotices); a reader fork's are in memory only,
541
+ * cleared at stop, as the deferred cancels are.
542
+ */
543
+ lossNotices = new Map();
544
+ /**
545
+ * The persistent subconscious's kept notices as the store's record was
546
+ * last written or read (writeLossRecord, readLossRecord), so that it is
547
+ * written only when they change.
548
+ */
549
+ lossRecordShape = '[]';
550
+ /**
551
+ * The record may not hold the notices kept in memory: its write failed,
552
+ * or was put off while writes were held. The next change, or the stop,
553
+ * writes it.
554
+ */
555
+ lossRecordStale = false;
556
+ /** The record couldn't be read: nothing is written over it until it can be (readLossRecord). */
557
+ lossRecordUnread = false;
558
+ /**
559
+ * The store's head sequence when this run began (noteUnstampedStop), which
560
+ * the stop's stamp names, so that the next boot reads only this run's rows
561
+ * for what became of a note. Undefined until the boot, or where it
562
+ * couldn't be read.
563
+ */
564
+ runStartedAt;
565
+ /** The branch `runStartedAt` is a place on (currentBranch), where the host names one. */
566
+ runBranch;
567
+ /** Where this run's rows begin on each branch it has been on, by branch (branchMoved): a move back to one keeps its start. */
568
+ runStarts = new Map();
569
+ /**
570
+ * The bound the last stop's stamp gave this boot's reading of that run
571
+ * (its `runStartedAt` and `branch`), kept while a notice the boot derived
572
+ * from it is kept in memory only (stampNoticesUnkept): this run's stamp
573
+ * carries it then (runBound), so a crash meanwhile leaves the next boot
574
+ * reading the same rows, and telling the same.
575
+ */
576
+ carriedBound;
577
+ /** The ids of the notices the store's loss record holds, as last written or read (writeLossRecord, readLossRecord). */
578
+ lossRecordIds = new Set();
579
+ /**
580
+ * This run's stamp (noteUnstampedStop) where the store refused it at boot,
581
+ * tried again until it lands (writePendingMarker); a stop's own stamp
582
+ * replaces it. Meanwhile a waiting cancel's answer to the persistent
583
+ * subconscious says the host's death before the cancel runs may go untold.
584
+ */
585
+ markerPending;
586
+ /** A write of `markerPending` failed, which was logged (once). */
587
+ markerUnwritten = false;
588
+ /**
589
+ * The boot's telling of the last stop's stamp (tellUnstampedHolds) threw
590
+ * before it finished, so a hold it didn't reach may be told nothing: the
591
+ * carried bound stays for the run (carriedNeeded), so a crash leaves the
592
+ * next boot reading those rows again.
593
+ */
594
+ carriedUntold = false;
595
+ /**
596
+ * The notices of an unrecorded stop (`unstamped-stop`) this boot wrote
597
+ * from the store's record (restoreLossNotices): told, though the
598
+ * subconscious's window may not hold them yet (a quiesced boot), as the
599
+ * boot's own reading of the stamp counts them (noteUnstampedStop).
600
+ */
601
+ restoredTold = [];
602
+ /**
603
+ * The ids of the notices the boot read from the store's record whose
604
+ * rows it couldn't read (restoreLossNotices): each may be written already,
605
+ * so none is written until a try reads those rows (checkLossNoticesWritten).
606
+ */
607
+ lossNoticesUnchecked = new Set();
608
+ /** The authors whose loss notice is being written now (writeLossNotices): a loss told for one meanwhile waits behind it. */
609
+ lossNoticesWriting = new Set();
610
+ /** The host's stop has begun (stopTimers): a turn asked for from here on is dropped with the stop's closed queue. */
611
+ timersStopped = false;
612
+ /** The release turns asked for while the stop was under way (StopStamp's `wakesOwed`), for the stop's stamp. */
613
+ wakesInStop = {};
614
+ /** The release turns this run asked for, by whose: a boot that asks one for its own release asks none again for the last stop's. */
615
+ releaseTurnsAsked = new Set();
616
+ /**
617
+ * The one retry of the deferred cancels whose run threw (a release that
618
+ * couldn't be made durable, a store operation that threw), and of the
619
+ * loss notices not written (lossNotices), armed by runDeferredCancels and
620
+ * tellNoteLost, unref'd, cleared at stop. A later release's
621
+ * runDeferredCancels runs them sooner.
622
+ */
623
+ deferredRetry;
624
+ /** Timer key for the scope's cadence and expiry; never a channel's key,
625
+ * which starts with `[`. */
626
+ static SCOPE_KEY = 'all-except';
210
627
  constructor(channelRegistry, serverRegistry, hooks) {
211
628
  this.channelRegistry = channelRegistry;
212
629
  this.serverRegistry = serverRegistry;
213
630
  this.hooks = hooks;
214
631
  }
632
+ /** A channel's key in the coordinator's maps and its stored last looks.
633
+ * The store refuses an object key holding a NUL, so no separator: the
634
+ * pair as JSON, which no server or channel id can make ambiguous. */
215
635
  key(serverId, channelId) {
216
- return `${serverId}\0${channelId}`;
636
+ return JSON.stringify([serverId, channelId]);
637
+ }
638
+ channelOf(key) {
639
+ return JSON.parse(key);
217
640
  }
218
641
  /** The readers' hand-off bookkeeping's key: one epoch's reading of one channel. */
219
642
  handOffKey(epochId, serverId, channelId) {
@@ -221,7 +644,7 @@ export class TuneOutCoordinator {
221
644
  }
222
645
  /** An ended epoch's hand-off bookkeeping goes with it, every channel's. */
223
646
  dropHandOffs(epochId) {
224
- for (const map of [this.readerCursor, this.readerHandedWrites, this.readerReturned]) {
647
+ for (const map of [this.readerCursor, this.readerReturned]) {
225
648
  for (const key of map.keys())
226
649
  if (key.startsWith(`${epochId}\0`))
227
650
  map.delete(key);
@@ -243,12 +666,53 @@ export class TuneOutCoordinator {
243
666
  shown(serverId, channelId) {
244
667
  return this.channelRegistry.nameShown({ serverId, channelId });
245
668
  }
669
+ /**
670
+ * Every message of the primary's store, in arrival order: those stored,
671
+ * then those a live surgery's hold keeps in memory until its release
672
+ * (getSurgeryHeldMessages). What a check counts and peeks, and what a
673
+ * dump lists: a dump made during the hold, which a cancel makes only
674
+ * where writesHeld throws, still has what the tune-out held then. A
675
+ * reader is handed stored rows only (getStoredMessages): those the hold
676
+ * keeps have no id until stored, and the dump has them.
677
+ */
678
+ residentMessages() {
679
+ const stored = this.hooks.getStoredMessages();
680
+ let kept = [];
681
+ try {
682
+ kept = this.hooks.getSurgeryHeldMessages?.() ?? [];
683
+ }
684
+ catch (err) {
685
+ console.error('[tune-out] could not read the writes a surgery holds in memory, so the stored messages alone are read:', err);
686
+ }
687
+ return kept.length > 0 ? [...stored, ...kept] : stored;
688
+ }
246
689
  quotedShown(serverId, channelId) {
247
690
  return this.channelRegistry.quotedShown({ serverId, channelId });
248
691
  }
249
692
  /** Restart cadence + expiry timers and re-anchor after a process restart. */
250
693
  resumeActiveEpochs() {
251
- let oldestAnchor = null;
694
+ // The holds on record before any is resumed or ended here, and the
695
+ // store's head then, for the last stop's stamp (noteUnstampedStop).
696
+ let bootHead;
697
+ try {
698
+ bootHead = this.hooks.currentSequence();
699
+ }
700
+ catch {
701
+ bootHead = undefined;
702
+ }
703
+ // The all-except hold's record first, uncaught: its first read is the
704
+ // store's (getAllExcept caches it once read), and one that throws
705
+ // refuses the start, as the read below would. Caught with the holds
706
+ // below, it read as no hold, and the last stop's stamp was written over
707
+ // unchecked, its bound with it (AF #844 review 5, #36).
708
+ this.getAllExcept();
709
+ let onRecord = [];
710
+ try {
711
+ onRecord = this.holdsOnRecord();
712
+ }
713
+ catch (err) {
714
+ console.error(`[tune-out] the holds on record could not be read for the last stop's stamp, so none is checked against it: ${thrownReason(err)}`);
715
+ }
252
716
  for (const { serverId, channelId, params } of this.listActive()) {
253
717
  // An epoch whose deadline passed while the process was down cancels
254
718
  // immediately — the resident gets their backlog on the schedule they
@@ -272,28 +736,22 @@ export class TuneOutCoordinator {
272
736
  const wakeCount = this.channelRegistry.getTuneOutState(serverId, channelId)?.wakeCount ?? 0;
273
737
  if (wakeCount > params.maxWakes) {
274
738
  this.cancelPastBudget(serverId, channelId, params.maxWakes, 'start');
275
- if (!inForce())
276
- continue;
277
739
  }
278
740
  else {
279
741
  this.armExpiry(serverId, channelId, expired ? { ...params, expiresAtMs: Date.now() + EXPIRY_RETRY_MS } : params);
280
742
  }
281
- if (oldestAnchor === null || params.startedAtSequence < oldestAnchor) {
282
- oldestAnchor = params.startedAtSequence;
283
- }
284
743
  }
285
744
  const scope = this.getAllExcept();
286
745
  if (scope) {
287
- let inForce = true;
746
+ // Whether the hold is still in force, and so anchors the subconscious,
747
+ // is read from the record below (anchorSubconscious), as a channel's is.
288
748
  if (scope.params.expiresAtMs <= Date.now()) {
289
749
  try {
290
750
  this.cancelAllExcept('duration', 'duration elapsed');
291
- inForce = false;
292
751
  }
293
752
  catch (err) {
294
753
  if (this.getAllExcept()?.params.epochId !== scope.params.epochId) {
295
754
  console.error('[tune-out] the all-except hold reached its end time while the process was down and its release ended it, but a step after the end failed:', err);
296
- inForce = false;
297
755
  }
298
756
  else {
299
757
  console.error(`[tune-out] the all-except hold reached its end time while the process was down, and its release failed, so it stays in force and its release is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
@@ -304,21 +762,18 @@ export class TuneOutCoordinator {
304
762
  else {
305
763
  this.armScopeTimers(scope.params);
306
764
  }
307
- if (inForce && (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor)) {
308
- oldestAnchor = scope.params.startedAtSequence;
309
- }
310
- }
311
- if (oldestAnchor !== null) {
312
- this.hooks.setSubconsciousAnchor(oldestAnchor);
313
765
  }
766
+ // A window start kept for a tune-out that ended while the process was
767
+ // down, its cancel's own forget not written, goes now.
768
+ this.pruneWindowStarts();
769
+ this.anchorSubconscious('resume');
314
770
  // What each held channel's reader hasn't looked at was counted in
315
771
  // memory, so it is counted again from the held rows: without it, a
316
772
  // channel held across the restart gets no cadence look until something
317
- // new arrives.
318
- // Stored and still queued alike: at a quiesced boot, restored writes stay
319
- // queued until the resume flushes them.
773
+ // new arrives. Every held message is stored as it arrives (#291), so the
774
+ // stored rows are all of them.
320
775
  const held = new Set();
321
- for (const m of [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])]) {
776
+ for (const m of this.hooks.getStoredMessages()) {
322
777
  const meta = m.metadata;
323
778
  const epochId = meta?.tuneOut?.epochId;
324
779
  if (!meta || !epochId || !meta.channelId)
@@ -328,10 +783,329 @@ export class TuneOutCoordinator {
328
783
  held.add(key);
329
784
  }
330
785
  for (const key of held) {
331
- const [serverId, channelId] = key.split('\0');
786
+ const [serverId, channelId] = this.channelOf(key);
332
787
  this.rebuildTally(serverId, channelId);
333
788
  }
334
789
  this.pruneLooks();
790
+ // A loss told to the subconscious before the stop whose turn the stop
791
+ // lost is given its turn again; then a loss notice a stop left kept in
792
+ // the store is written, the holds' state above restored first, which it
793
+ // reads; then those for the holds the last stop didn't stamp.
794
+ this.askTurnsForUnreadLosses();
795
+ this.noteUnstampedStop(onRecord, this.restoreLossNotices(), bootHead);
796
+ // And the resident, of a hold still in force whose release reached it
797
+ // before the last stop, its end not recorded (tellResidentUnended).
798
+ this.tellResidentUnended(onRecord, bootHead);
799
+ }
800
+ /**
801
+ * At boot (resumeActiveEpochs), after this boot's own releases (an end
802
+ * time passed, a wake budget spent): each hold on record then still in
803
+ * force whose release the resident has, stored before this boot (a
804
+ * `tune-out-cancel` row of its epoch), its end never recorded. The
805
+ * resident read "[Tune-out: … cancelled …]", and nothing said the
806
+ * tune-out still stood: a stop whose wait for an operator's operation ran
807
+ * out, the hold's release after it storing the release, or a throw
808
+ * between a cancel's release and its end (a store fault). It is told,
809
+ * once, by a row in the residents' slot (`tune-out-end-unrecorded`) that
810
+ * the release reached it but the end wasn't recorded, so the tune-out is
811
+ * still in force, what arrives there held for its next release, and how
812
+ * it ends; the release row stays as the record of the release. A row of
813
+ * that kind for the epoch after its latest release silences a later boot;
814
+ * a release after it (another cancel that failed at its end) is told
815
+ * again. The subconscious, whose window holds the residents' rows, reads
816
+ * it too. A write that throws is logged, and tried at the next boot
817
+ * (AF #844, Oscar). Never throws.
818
+ */
819
+ tellResidentUnended(onRecord, bootHead) {
820
+ try {
821
+ if (onRecord.length === 0)
822
+ return;
823
+ const rows = this.hooks.getStoredMessages();
824
+ for (const { epochId, serverId, channelId, scope } of onRecord) {
825
+ const params = scope
826
+ ? this.getAllExcept()?.params
827
+ : this.channelRegistry.getTuneOutState(serverId, channelId)?.params;
828
+ if (!params || params.epochId !== epochId)
829
+ continue;
830
+ let release = -Infinity;
831
+ let told = -Infinity;
832
+ for (const m of rows) {
833
+ const meta = m.metadata;
834
+ if (meta?.epochId !== epochId || typeof m.sequence !== 'number')
835
+ continue;
836
+ if (bootHead !== undefined && m.sequence > bootHead)
837
+ continue;
838
+ if (meta.kind === 'tune-out-cancel')
839
+ release = Math.max(release, m.sequence);
840
+ else if (meta.kind === 'tune-out-end-unrecorded')
841
+ told = Math.max(told, m.sequence);
842
+ }
843
+ if (release === -Infinity || told > release)
844
+ continue;
845
+ const shown = this.shown(serverId, channelId);
846
+ const expires = params.expiresAtMs !== undefined ? new Date(params.expiresAtMs) : undefined;
847
+ const endTime = expires && Number.isFinite(expires.getTime()) ? expires.toISOString().replace(/\.\d+Z$/, 'Z') : undefined;
848
+ // A channel's tune-out also ends by itself at the first wake past its
849
+ // wake budget (cancelPastBudget, from fireWake and the start); the
850
+ // all-except hold's budget pauses its reader, and ends nothing.
851
+ const budget = !scope && typeof params.maxWakes === 'number' && Number.isFinite(params.maxWakes)
852
+ ? `, or by itself at the first wake past its wake budget (maxWakes ${params.maxWakes})` : '';
853
+ const text = scope
854
+ ? `[${NOTICE_TAG.tuneOut}: the all-except hold (all channels except ${shown}) is still in force. Its release above reached you, but its `
855
+ + 'end wasn\'t recorded before the host stopped, so the hold is still in force: what arrives in those channels is held, and comes '
856
+ + `with its next release. It ends when it is cancelled (tune_out mode "cancel" with scope "all-except")${endTime ? `, or at its end time, ${endTime}` : ''}.]`
857
+ : `[${NOTICE_TAG.tuneOut}: ${shown} is still tuned out. Its release above reached you, but its end wasn't recorded before the `
858
+ + 'host stopped, so the tune-out is still in force: what arrives there is held, and comes with its next release. It ends when it '
859
+ + `is cancelled (tune_out mode "cancel")${endTime ? `${budget ? ',' : ', or'} at its end time, ${endTime}` : ''}${budget}.]`;
860
+ this.hooks.addMessage('user', [{ type: 'text', text }], {
861
+ system: true, kind: 'tune-out-end-unrecorded', channelId, serverId, epochId, ...(scope ? { scope } : {}),
862
+ });
863
+ console.error(`[tune-out] the release of ${scope ? 'the all-except hold' : shown} (epoch ${epochId}) reached the resident before the last stop, `
864
+ + 'but its end was not recorded, so the resident is told the hold is still in force');
865
+ }
866
+ }
867
+ catch (err) {
868
+ console.error(`[tune-out] the resident could not be told, at boot, of a hold whose release reached it with its end not recorded: ${thrownReason(err)}`);
869
+ }
870
+ }
871
+ /** The epochs of the tune-outs active now, a channel's and the all-except hold (the anchor's branchMoved reads them). */
872
+ activeEpochIds() {
873
+ return this.activeStarts().map((e) => e.epochId);
874
+ }
875
+ /** The tune-outs active now, a channel's and the all-except hold, with where each started in the record. */
876
+ activeStarts() {
877
+ const starts = this.listActive().map(({ params }) => ({ epochId: params.epochId, sequence: params.startedAtSequence }));
878
+ const scope = this.getAllExcept();
879
+ if (scope)
880
+ starts.push({ epochId: scope.params.epochId, sequence: scope.params.startedAtSequence });
881
+ return starts;
882
+ }
883
+ /**
884
+ * Set the subconscious's window start to the oldest active tune-out's
885
+ * (setSubconsciousAnchor), with its ordinal when one was fixed for it. The
886
+ * all-except hold counts with the channels' tune-outs, wherever this is
887
+ * called, so a channel's tune-out entered during an older hold leaves the
888
+ * start at the hold's. Nothing when none is active: the anchor stays where
889
+ * it was, as a cancel leaves it.
890
+ *
891
+ * A cancel leaves the start where it was while another tune-out stays
892
+ * active too, so the subconscious's report on the one cancelled still has
893
+ * what it held, and the start in force is kept (`inForce`). At a resume
894
+ * that start is set again rather than the oldest active tune-out's, which
895
+ * is later than it once the oldest was cancelled: so the window is the
896
+ * same across a restart. An enter sets the oldest active tune-out's, as it
897
+ * always has. Where the window presents, the other active tune-outs whose
898
+ * start isn't kept yet go with it, for the framework to fix as soon as
899
+ * each can be: the oldest one's too, where a resume sets the start in
900
+ * force instead of it.
901
+ */
902
+ anchorSubconscious(occasion = 'enter') {
903
+ const active = this.activeStarts();
904
+ if (active.length === 0)
905
+ return;
906
+ const sequence = active.reduce((min, e) => Math.min(min, e.sequence), Infinity);
907
+ const epochIds = active.filter((e) => e.sequence === sequence).map((e) => e.epochId);
908
+ const kept = this.ordinalsKept();
909
+ const read = kept ? this.readWindowState() : { starts: {} };
910
+ if (read === null) {
911
+ // Which starts are kept can't be told: set by sequence, the window
912
+ // could move off a start fixed for an active tune-out, and the fix
913
+ // would write over the record. It stays where it was, nothing written;
914
+ // the next enter or restart sets it.
915
+ console.error(`[tune-out] the subconscious's window start stays where it was: its kept starts can't be read; the next enter or restart sets it`);
916
+ return;
917
+ }
918
+ const state = read;
919
+ const own = epochIds.map((id) => state.starts[id]).find((o) => o !== undefined);
920
+ let ordinal = own;
921
+ // At a resume, the start in force before the restart (a cancelled
922
+ // tune-out's, say), and never past the oldest active one's own start
923
+ // where one is kept. With none in force kept, the anchor (window-anchor)
924
+ // keeps the one its strategy persisted where that is earlier than the
925
+ // oldest active one's own start, or, where none of that is kept either,
926
+ // rather than converting its sequence again (AF #844 review 5, #47).
927
+ const inForceOnly = occasion === 'resume' && state.inForce !== undefined && (own === undefined || state.inForce < own);
928
+ if (inForceOnly)
929
+ ordinal = state.inForce;
930
+ const keepEarlierAnchor = occasion === 'resume' && kept && state.inForce === undefined;
931
+ // The start in force from now: an ordinal known is kept as in force once
932
+ // it is set (the anchor's fix reports it, subconsciousWindowFixed), not
933
+ // before, so a set that fails leaves the record with the start the
934
+ // strategy still has. While the start waits to be fixed, none is in
935
+ // force until the fix reports it.
936
+ if (kept && ordinal === undefined && state.inForce !== undefined) {
937
+ const { inForce: _was, ...rest } = state;
938
+ this.writeWindowState(rest);
939
+ }
940
+ const others = [];
941
+ if (kept) {
942
+ const bySequence = new Map();
943
+ for (const e of active) {
944
+ // The oldest active start goes with them where the start in force is
945
+ // set in its place (inForceOnly), so it is fixed too, unmoved to.
946
+ if ((e.sequence === sequence && !inForceOnly) || state.starts[e.epochId] !== undefined)
947
+ continue;
948
+ bySequence.set(e.sequence, [...(bySequence.get(e.sequence) ?? []), e.epochId]);
949
+ }
950
+ for (const [at, ids] of bySequence)
951
+ others.push({ sequence: at, epochIds: ids });
952
+ }
953
+ this.hooks.setSubconsciousAnchor({
954
+ sequence,
955
+ ...(ordinal !== undefined ? { ordinal } : {}),
956
+ epochIds,
957
+ ...(inForceOnly ? { inForceOnly: true } : {}),
958
+ ...(keepEarlierAnchor ? { keepEarlierAnchor: true } : {}),
959
+ }, others);
960
+ }
961
+ // ==========================================================================
962
+ // Window starts (the subconscious's, by ordinal)
963
+ // ==========================================================================
964
+ /** Where each active tune-out's window start is kept, by ordinal: a snapshot state, on each branch its own. */
965
+ get windowStartsStateId() {
966
+ return `subconscious/${this.hooks.primaryName() ?? 'default'}/window-starts`;
967
+ }
968
+ /** Whether the subconscious's window keeps ordinals, so its starts are kept (subconsciousWindowOrdinals). */
969
+ ordinalsKept() {
970
+ try {
971
+ return this.hooks.subconsciousName() != null && this.hooks.subconsciousWindowOrdinals?.() === true;
972
+ }
973
+ catch {
974
+ return false;
975
+ }
976
+ }
977
+ /**
978
+ * Each tune-out's window start as an ordinal, by epoch id, as kept on the
979
+ * store's branch now, and the start in force (`inForce`: the ordinal the
980
+ * window start was last set to while a tune-out is active), which a
981
+ * restart sets again (anchorSubconscious). A state that isn't this format
982
+ * (`{ v: 1, starts, inForce? }`) reads as none kept, and is written over
983
+ * at the next fix, with a line saying so. A read that throws answers null:
984
+ * what is kept can't be told, so nothing is written over it (a fix isn't
985
+ * kept, and is tried again at the next presentation), and the window
986
+ * start isn't moved. A state an earlier version kept, with no `inForce`,
987
+ * reads as one with none in force, and a restart sets the oldest active
988
+ * tune-out's start, or the anchor the subconscious's strategy persisted
989
+ * where that is earlier (keepEarlierAnchor).
990
+ */
991
+ readWindowState() {
992
+ let raw;
993
+ try {
994
+ raw = this.channelRegistry.readCoordinatorState(this.windowStartsStateId);
995
+ }
996
+ catch (err) {
997
+ console.error(`[tune-out] the subconscious's window starts (${this.windowStartsStateId}) can't be read, so nothing is written over them now:`, err);
998
+ return null;
999
+ }
1000
+ if (raw == null)
1001
+ return { starts: {} };
1002
+ const state = raw;
1003
+ if (state.v !== 1 || !state.starts || typeof state.starts !== 'object') {
1004
+ console.error(`[tune-out] the subconscious's window starts (${this.windowStartsStateId}) aren't in a form this version reads, so none is kept; the next fix writes over them`);
1005
+ return { starts: {} };
1006
+ }
1007
+ const isOrdinal = (o) => typeof o === 'number' && Number.isInteger(o) && o >= 0;
1008
+ // One ill-formed entry is dropped, logged by its epoch, the rest read:
1009
+ // the next fix writes over it.
1010
+ const starts = {};
1011
+ for (const [epochId, ordinal] of Object.entries(state.starts)) {
1012
+ if (isOrdinal(ordinal))
1013
+ starts[epochId] = ordinal;
1014
+ else
1015
+ console.error(`[tune-out] the subconscious's window start kept for epoch ${epochId} (${this.windowStartsStateId}) isn't an ordinal (${JSON.stringify(ordinal) ?? String(ordinal)}), so none is kept for it; the next fix writes over it`);
1016
+ }
1017
+ if (state.inForce !== undefined && !isOrdinal(state.inForce)) {
1018
+ console.error(`[tune-out] the subconscious's window start in force (${this.windowStartsStateId}) isn't an ordinal (${JSON.stringify(state.inForce) ?? String(state.inForce)}), so none is read as in force; the next fix writes over it`);
1019
+ }
1020
+ return { starts, ...(isOrdinal(state.inForce) ? { inForce: state.inForce } : {}) };
1021
+ }
1022
+ /** Keep `state`, answering whether it was written; a write that fails is logged, and the anchor the strategy holds stays as it is. */
1023
+ writeWindowState(state) {
1024
+ try {
1025
+ this.channelRegistry.writeCoordinatorState(this.windowStartsStateId, {
1026
+ v: 1,
1027
+ starts: state.starts,
1028
+ ...(state.inForce !== undefined ? { inForce: state.inForce } : {}),
1029
+ });
1030
+ return true;
1031
+ }
1032
+ catch (err) {
1033
+ console.error(`[tune-out] the subconscious's window starts (${this.windowStartsStateId}) couldn't be written, so a restart fixes them again:`, err);
1034
+ return false;
1035
+ }
1036
+ }
1037
+ /**
1038
+ * The framework fixed the subconscious's window start for `epochIds` at
1039
+ * `ordinal` (its index ordinal, #291): kept for each of them still active,
1040
+ * so a later anchor (another tune-out's enter, a restart) sets that
1041
+ * ordinal again rather than converting the tune-out's sequence anew, and
1042
+ * kept as the start in force while any tune-out is active, which a
1043
+ * restart sets again (anchorSubconscious), when it is the start in force
1044
+ * (`inForce`; another active tune-out's start fixed beside it isn't).
1045
+ * Answers whether it is kept, so that the anchor tries again at its next
1046
+ * presentation when it isn't: not when the record can't be read, which
1047
+ * is never written over then.
1048
+ */
1049
+ subconsciousWindowFixed(epochIds, ordinal, inForce = true) {
1050
+ if (!this.ordinalsKept())
1051
+ return true;
1052
+ const active = new Set(this.activeStarts().map((e) => e.epochId));
1053
+ const state = this.readWindowState();
1054
+ if (state === null)
1055
+ return false;
1056
+ let changed = false;
1057
+ for (const epochId of epochIds) {
1058
+ if (!active.has(epochId) || state.starts[epochId] === ordinal)
1059
+ continue;
1060
+ state.starts[epochId] = ordinal;
1061
+ changed = true;
1062
+ }
1063
+ // The window starts there now, whether or not the tune-outs it was fixed
1064
+ // for are active still (one cancelled before its start was fixed leaves
1065
+ // it in force, as any cancel does), while any tune-out is.
1066
+ if (inForce && active.size > 0 && state.inForce !== ordinal) {
1067
+ state.inForce = ordinal;
1068
+ changed = true;
1069
+ }
1070
+ return changed ? this.writeWindowState(state) : true;
1071
+ }
1072
+ /**
1073
+ * An ended tune-out's window start goes, and, once none is active, the
1074
+ * start in force with it: the next enter sets its own.
1075
+ */
1076
+ forgetWindowStart(epochId) {
1077
+ if (!this.ordinalsKept())
1078
+ return;
1079
+ const state = this.readWindowState();
1080
+ // Unreadable: left as it is; a resume's prune takes the start out later.
1081
+ if (state === null)
1082
+ return;
1083
+ const noneActive = this.activeStarts().length === 0;
1084
+ if (!(epochId in state.starts) && !(noneActive && state.inForce !== undefined))
1085
+ return;
1086
+ delete state.starts[epochId];
1087
+ if (noneActive)
1088
+ delete state.inForce;
1089
+ this.writeWindowState(state);
1090
+ }
1091
+ /** The window starts of tune-outs no longer active go. */
1092
+ pruneWindowStarts() {
1093
+ if (!this.ordinalsKept())
1094
+ return;
1095
+ const state = this.readWindowState();
1096
+ if (state === null)
1097
+ return;
1098
+ const active = new Set(this.activeStarts().map((e) => e.epochId));
1099
+ const stale = Object.keys(state.starts).filter((epochId) => !active.has(epochId));
1100
+ // With none active, no start is in force for the resume to set again.
1101
+ const staleInForce = active.size === 0 && state.inForce !== undefined;
1102
+ if (stale.length === 0 && !staleInForce)
1103
+ return;
1104
+ for (const epochId of stale)
1105
+ delete state.starts[epochId];
1106
+ if (staleInForce)
1107
+ delete state.inForce;
1108
+ this.writeWindowState(state);
335
1109
  }
336
1110
  armExpiry(serverId, channelId, params) {
337
1111
  if (params.expiresAtMs === undefined)
@@ -379,147 +1153,2357 @@ export class TuneOutCoordinator {
379
1153
  active.push({ serverId: entry.serverId, channelId: entry.descriptor.id, params: state.params });
380
1154
  }
381
1155
  }
382
- return active;
1156
+ return active;
1157
+ }
1158
+ // ==========================================================================
1159
+ // Enter / cancel
1160
+ // ==========================================================================
1161
+ /**
1162
+ * The options a tune-out call gives, read as numbers before anything
1163
+ * changes (F76). A tool's input reaches here as the model wrote it, and
1164
+ * the store can't keep a NaN or an Infinity: its append would throw after
1165
+ * the registry had marked the channel tuned out. So each option is read
1166
+ * as a number first, a numeric string included, as the sleep tool reads
1167
+ * its seconds; null counts as left out. A value that gives no finite
1168
+ * number is refused, naming the option and, where it can be left out,
1169
+ * what leaving it out means; so is a duration whose end time is too far
1170
+ * off to store. `now` is the clock the end time was counted from. One
1171
+ * rule for a channel's tune-out and the all-except hold.
1172
+ */
1173
+ readOptions(opts, leftOut) {
1174
+ const given = {};
1175
+ for (const name of Object.keys(leftOut)) {
1176
+ const value = opts[name];
1177
+ if (value == null)
1178
+ continue;
1179
+ const n = Number(value);
1180
+ if (!Number.isFinite(n)) {
1181
+ const got = typeof value === 'number'
1182
+ ? String(value)
1183
+ : `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`;
1184
+ return {
1185
+ ok: false,
1186
+ error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
1187
+ + `and it was ${got}.${leftOut[name] ? ` Leave it out ${leftOut[name]}.` : ''}`,
1188
+ };
1189
+ }
1190
+ given[name] = n;
1191
+ }
1192
+ const now = Date.now();
1193
+ const expiresAtMs = given.durationSeconds === undefined
1194
+ ? undefined
1195
+ : now + Math.max(60, given.durationSeconds) * 1000;
1196
+ if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
1197
+ return {
1198
+ ok: false,
1199
+ error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept.`
1200
+ + (leftOut.durationSeconds ? ` Leave it out ${leftOut.durationSeconds}.` : ''),
1201
+ };
1202
+ }
1203
+ return { ok: true, given, now, expiresAtMs };
1204
+ }
1205
+ enter(serverId, channelId, opts, source) {
1206
+ // No reader: no hold. One nothing summarizes would hand the resident an
1207
+ // undated backlog all at once when it ends.
1208
+ if (!this.readerAvailable())
1209
+ return { ok: false, error: NO_READER_REFUSAL };
1210
+ if (this.hooks.isForkBound({ serverId, channelId })) {
1211
+ return {
1212
+ ok: false,
1213
+ error: `Nothing changed: a conversation is bound to ${this.quotedShown(serverId, channelId)}, so its messages go to that `
1214
+ + "conversation, and tune_out won't divert a channel while one is. Calling tune_out on it again won't change "
1215
+ + 'that until the conversation ends, which happens only once the channel has been quiet for a while. Tune '
1216
+ + 'out a different channel, or ask whoever runs this host if this one should be diverted.',
1217
+ };
1218
+ }
1219
+ const current = this.channelRegistry.getTuneOutState(serverId, channelId);
1220
+ if (current)
1221
+ return { ok: false, error: alreadyTunedOutText(this.quotedShown(serverId, channelId), current.params), alreadyTunedOut: true };
1222
+ const scope = this.getAllExcept();
1223
+ if (scope && scope.params.except.serverId === serverId && scope.params.except.channelId === channelId) {
1224
+ return { ok: false, error: `channel ${this.quotedShown(serverId, channelId)} is the excepted channel of the active all-except hold; cancel that first` };
1225
+ }
1226
+ const read = this.readOptions(opts, {
1227
+ durationSeconds: 'to stay tuned out until you cancel',
1228
+ cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
1229
+ backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
1230
+ maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
1231
+ });
1232
+ if (!read.ok)
1233
+ return read;
1234
+ const { given, expiresAtMs } = read;
1235
+ const params = {
1236
+ epochId: randomUUID(),
1237
+ cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
1238
+ backlogCap: Math.max(0, given.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap),
1239
+ maxWakes: Math.max(1, given.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
1240
+ startedAtSequence: this.hooks.currentSequence(),
1241
+ ...(expiresAtMs !== undefined ? { expiresAtMs } : {}),
1242
+ };
1243
+ // A tune-out that ended in the registry without this coordinator's
1244
+ // cancel would have left its timers armed: clear them, so none of them
1245
+ // acts on this one.
1246
+ this.clearTimers(serverId, channelId);
1247
+ this.channelRegistry.enterTuneOut(serverId, channelId, params, source);
1248
+ this.armExpiry(serverId, channelId, params);
1249
+ // Window anchor: start of the OLDEST active tune-out. Never move the
1250
+ // anchor forward here — an older active epoch owns it.
1251
+ this.anchorSubconscious();
1252
+ this.divertedSinceInvocation.set(this.key(serverId, channelId), 0);
1253
+ if (this.readerAvailable())
1254
+ this.startCadence(serverId, channelId, params.cadenceSeconds);
1255
+ this.hooks.emitTrace({
1256
+ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId, reader: this.readerAvailable(),
1257
+ });
1258
+ return { ok: true, params };
1259
+ }
1260
+ /**
1261
+ * Whether a write the coordinator just made was kept in memory, by what
1262
+ * it answered: an empty id, the framework's answer for a write it keeps
1263
+ * (a surgery's hold, a context manager refusing its work), with writes
1264
+ * held now, as they are while it keeps one. A write that answered an id
1265
+ * landed, whether or not writes are held since (a hold begun as it
1266
+ * returned); one that answered no id with nothing held (a host that
1267
+ * answers none) isn't kept. Never judged by writesHeld alone (Weft, #844).
1268
+ */
1269
+ writeKept(id) {
1270
+ return id === '' && this.writesHeldNow();
1271
+ }
1272
+ /**
1273
+ * Whether the resident's last word on a hold (`what`, as the log names
1274
+ * it) is the boot's row that it still stands (toldStillStands), read from
1275
+ * the residents' stored rows for the cancel that ends it with nothing
1276
+ * more to deliver. A read that throws, or rows that can't be read, count
1277
+ * as yes, said once: one redundant release line is the cheaper error
1278
+ * against a "still stands" left as the last word on a tune-out that has
1279
+ * ended (AF #844, Oscar). Never throws.
1280
+ */
1281
+ residentToldStillStands(epochId, what) {
1282
+ try {
1283
+ return toldStillStands(this.hooks.getStoredMessages(), epochId);
1284
+ }
1285
+ catch (err) {
1286
+ console.error(`[tune-out] the resident's rows can't be read to tell whether its last word on ${what} is that it still stands, `
1287
+ + `so the cancel that ends it writes it a short release all the same: ${thrownReason(err)}`);
1288
+ return true;
1289
+ }
1290
+ }
1291
+ /** Whether the framework keeps writes in memory now (writesHeld), with `forAgent`'s among them; a hook that throws counts as not. */
1292
+ writesHeldNow(forAgent) {
1293
+ try {
1294
+ return this.hooks.writesHeld?.(forAgent) === true;
1295
+ }
1296
+ catch (err) {
1297
+ console.error('[tune-out] could not ask whether writes are held in memory, so a cancel goes on:', err);
1298
+ return false;
1299
+ }
1300
+ }
1301
+ /**
1302
+ * Keep a cancel asked for while writes are kept in memory, for
1303
+ * runDeferredCancels, with its note, for the epoch it was asked for
1304
+ * (`target`). The first one asked for a hold stands, with its reason and
1305
+ * source. A later one for the same hold (an end time's retry, a wake past
1306
+ * the budget, an agent's cancel) adds nothing, except a note: the waiting
1307
+ * cancel takes it, with its author, when it has none or its own went out
1308
+ * already (noteDelivered), and otherwise the
1309
+ * later note is dropped, with a log line, and this answers 'note-dropped'
1310
+ * so its caller can be told. One waiting for an earlier epoch there,
1311
+ * whose hold ended without it, is dropped (dropDeferred), and this one
1312
+ * waits in its place. Once stopped (a listener of that drop's trace, say),
1313
+ * none waits: this one is told as the stop tells a waiting cancel.
1314
+ */
1315
+ deferCancel(key, target, what, run, note, noteAuthor) {
1316
+ let waiting = this.deferredCancels.get(key);
1317
+ if (waiting && waiting.for.epochId !== target.epochId) {
1318
+ this.deferredCancels.delete(key);
1319
+ this.dropQueued(waiting, { reason: 'hold-ended', current: target.epochId });
1320
+ waiting = undefined;
1321
+ }
1322
+ // Stopped, by a listener of that drop's trace, say: nothing runs it now,
1323
+ // so it is told as the stop tells a cancel it finds waiting.
1324
+ if (this.stopped) {
1325
+ this.dropDeferred({
1326
+ for: target, what, run,
1327
+ ...(note !== undefined ? { note } : {}), ...(noteAuthor !== undefined ? { noteAuthor } : {}),
1328
+ askers: noteAuthor !== undefined ? [noteAuthor] : [],
1329
+ }, { reason: 'stopped' });
1330
+ return 'waits';
1331
+ }
1332
+ if (!waiting) {
1333
+ this.deferredCancels.set(key, {
1334
+ for: target, what, run,
1335
+ ...(note !== undefined ? { note } : {}), ...(noteAuthor !== undefined ? { noteAuthor } : {}),
1336
+ askers: noteAuthor !== undefined ? [noteAuthor] : [],
1337
+ });
1338
+ console.error(`[tune-out] the cancel of ${what} waits until the store takes writes again (an operator's change to it is in progress, or a context it writes to refuses writes until it is re-initialized), so that its release is stored before the tune-out's end is recorded`);
1339
+ return 'waits';
1340
+ }
1341
+ // A later call joins the cancel waiting: its caller is one of its askers.
1342
+ if (noteAuthor !== undefined && !waiting.askers.includes(noteAuthor))
1343
+ waiting.askers.push(noteAuthor);
1344
+ if (note === undefined)
1345
+ return 'waits';
1346
+ if (waiting.note === undefined) {
1347
+ waiting.note = note;
1348
+ if (noteAuthor !== undefined)
1349
+ waiting.noteAuthor = noteAuthor;
1350
+ return 'waits';
1351
+ }
1352
+ // One whose note went out already (its run delivered it, and failed
1353
+ // before the end) takes this one's, which goes out when the hold ends.
1354
+ if (noteDelivered(waiting)) {
1355
+ takeLaterNote(waiting, note, noteAuthor);
1356
+ return 'waits';
1357
+ }
1358
+ console.error(`[tune-out] a later cancel of ${what} is dropped with its note: the cancel already waiting carries a note of its own`);
1359
+ return 'note-dropped';
1360
+ }
1361
+ /**
1362
+ * Run the cancels asked for while writes were kept in memory (deferCancel),
1363
+ * in the order asked, now that the framework stores them again: it calls
1364
+ * this once a surgery's release has stored what its hold kept, or told of
1365
+ * what it couldn't store, and the retry timer calls it. One whose writes
1366
+ * are still held waits on. One whose epoch no longer holds (its hold
1367
+ * ended without it, and a later one may hold there now) is dropped, told
1368
+ * (dropDeferred): it ends nothing, a later hold included. One that throws
1369
+ * (its release couldn't be made durable, a store operation threw) stays
1370
+ * waiting, with its note, the tune-out still in force: it runs again at
1371
+ * the next call, or EXPIRY_RETRY_MS later, which one timer arms; one that
1372
+ * threw after it recorded its hold's end isn't kept, and a note it hadn't
1373
+ * delivered by then is told lost (dropDeferred). Each leaves the queue
1374
+ * just before it runs, so a stop meanwhile (a trace listener's) finds
1375
+ * those not run yet, and tells them as it tells any (stop): none runs
1376
+ * after it, and no retry is armed then. First, the loss notices not
1377
+ * written yet are tried again (writeLossNotices); the retry is armed while
1378
+ * one is left. Never throws.
1379
+ */
1380
+ runDeferredCancels() {
1381
+ if (this.stopped)
1382
+ return;
1383
+ if (this.deferredCancels.size === 0 && this.lossNotices.size === 0 && !this.markerPending && !this.lossRecordUnread)
1384
+ return;
1385
+ if (this.writesHeldNow()) {
1386
+ // Writes are held for some agent. A loss notice is held only where
1387
+ // its author's are (writeLossNotices asks writesHeld of the author):
1388
+ // a reader fork is told while the primary's or the subconscious's
1389
+ // context manager refuses its work, which holds the cancels' writes
1390
+ // but not the fork's (AF #844 review 5, #53). The cancels, the marker
1391
+ // and the record wait.
1392
+ if (this.lossNotices.size > 0 || this.lossRecordUnread)
1393
+ this.writeLossNotices();
1394
+ return;
1395
+ }
1396
+ if (this.deferredRetry !== undefined) {
1397
+ clearTimeout(this.deferredRetry);
1398
+ this.deferredRetry = undefined;
1399
+ }
1400
+ // The loss notices first: the marker after them names this run's own
1401
+ // start once those a boot read from the last stop's stamp are told or
1402
+ // kept (runBound).
1403
+ this.writeLossNotices();
1404
+ // A stop meanwhile (a listener of a notice's write) wrote its stamp,
1405
+ // which no marker of this run is written over, and took the queue.
1406
+ if (this.stopped)
1407
+ return;
1408
+ if (this.carriedBound && !this.carriedNeeded()) {
1409
+ this.carriedBound = undefined;
1410
+ this.markerPending = this.runMarker();
1411
+ }
1412
+ this.writePendingMarker();
1413
+ let failed = false;
1414
+ for (const key of [...this.deferredCancels.keys()]) {
1415
+ if (this.stopped)
1416
+ break;
1417
+ // Taken meanwhile: a cancel of its hold ran, from a trace listener.
1418
+ const entry = this.deferredCancels.get(key);
1419
+ if (!entry)
1420
+ continue;
1421
+ this.deferredCancels.delete(key);
1422
+ try {
1423
+ const now = entry.for.current();
1424
+ if (now !== entry.for.epochId) {
1425
+ this.dropQueued(entry, { reason: 'hold-ended', current: now });
1426
+ continue;
1427
+ }
1428
+ entry.run(entry);
1429
+ }
1430
+ catch (err) {
1431
+ // The end is recorded before what follows it in a release, so a run
1432
+ // whose epoch no longer holds ended it, and failed after: it isn't
1433
+ // kept, or its retry would act on whatever holds there next. One
1434
+ // whose epoch can't be read is kept, as one still in force.
1435
+ let ended;
1436
+ try {
1437
+ ended = entry.for.current() !== entry.for.epochId;
1438
+ }
1439
+ catch {
1440
+ ended = false;
1441
+ }
1442
+ if (ended) {
1443
+ console.error(`[tune-out] the cancel of ${entry.what}, asked for while writes were held in memory, ended its tune-out (epoch ${entry.for.epochId}) when it ran, but a step after the end failed:`, err);
1444
+ // The run writes its note before the end (noteNow), or, joining
1445
+ // another cancel, after it (deliverQueuedNote); a note the throw
1446
+ // left unsettled is told lost here. One lost when the writes were
1447
+ // stored is told lost with that error (lostAtRelease).
1448
+ if (entry.note !== undefined && !entry.noteSettled) {
1449
+ this.dropDeferred(entry, entry.lostAtRelease
1450
+ ? { ...entry.lostAtRelease, by: 'itself' }
1451
+ : { reason: 'note-undelivered', by: 'itself', error: thrownReason(err) });
1452
+ }
1453
+ this.lossesToldInRelease([entry], this.whereOf(entry.for));
1454
+ continue;
1455
+ }
1456
+ // A stop during its run (a trace listener's) found it off the queue:
1457
+ // it isn't put back for a retry after the stop, and is told as the
1458
+ // stop tells a waiting cancel.
1459
+ if (this.stopped) {
1460
+ console.error(`[tune-out] stopping: the cancel of ${entry.what} failed when it ran, as the host stopped:`, err);
1461
+ this.dropQueued(entry, { reason: 'stopped' });
1462
+ continue;
1463
+ }
1464
+ failed = true;
1465
+ this.requeue(key, entry);
1466
+ console.error(`[tune-out] a cancel asked for while writes were held in memory failed when it ran, so it stays waiting, the tune-out in force, and is tried again in ${EXPIRY_RETRY_MS / 1000} s or at the next release:`, err);
1467
+ }
1468
+ }
1469
+ if (!this.stopped && (failed || this.lossNotices.size > 0 || this.markerPending || this.lossRecordUnread))
1470
+ this.armDeferredRetry();
1471
+ }
1472
+ /**
1473
+ * A write the framework kept in memory while writes were held, lost when
1474
+ * it stored them: its replay threw (`error`), and where context-manager
1475
+ * couldn't tell whether any of it landed (`mayBeStored`), it may be stored
1476
+ * after all. `metadata` is the write's, as it was given: what tells the
1477
+ * coordinator's own writes apart. The framework calls this from its
1478
+ * release, before it runs the deferred cancels (runDeferredCancels), and
1479
+ * tells the operator as well (`message:lost`, `store-held-lost`).
1480
+ * A queued cancel's note delivered while writes were held (heldInMemory:
1481
+ * by its own run, or ahead of another cancel's release, queuedNoteFirst)
1482
+ * was answered as waiting, with the promise that its author is told if it
1483
+ * can't be delivered, and why. Its write (`subconscious-note`, of the
1484
+ * cancel's epoch) lost here unsettles it (lostAtRelease): the run that ends
1485
+ * the hold tells it lost then, with this error, as it tells a note it
1486
+ * can't write (noteNow, dropDeferred), and a stop before then tells it
1487
+ * lost with the cancel (dropQueued). The note is never written again,
1488
+ * since it may be stored after all. A release kept so that told a queued
1489
+ * note's loss (`tune-out-cancel` with `lostNote`, queuedNoteFirst), lost
1490
+ * here, told nothing: the entry it settled (lostInRelease, heldInMemory)
1491
+ * is unsettled, that loss moved to lostAtRelease, so the run that ends
1492
+ * the hold tells it by a notice, and the note isn't written again (AF
1493
+ * #844 review 5, #18). The note's loss is known whether or not that
1494
+ * release may have landed: told twice, where it did, rather than never.
1495
+ * Any other write is left to the operator. Never throws.
1496
+ */
1497
+ heldWriteLost(metadata, error, mayBeStored = false) {
1498
+ try {
1499
+ if (metadata?.kind === 'tune-out-cancel' && metadata.lostNote === true && typeof metadata.epochId === 'string') {
1500
+ for (const entry of this.deferredCancels.values()) {
1501
+ const told = entry.lostInRelease;
1502
+ if (entry.for.epochId !== metadata.epochId || entry.note === undefined || !entry.noteSettled || !entry.heldInMemory || !told)
1503
+ continue;
1504
+ delete entry.noteSettled;
1505
+ delete entry.heldInMemory;
1506
+ delete entry.lostInRelease;
1507
+ entry.lostAtRelease = told;
1508
+ console.error(`[tune-out] the release that told the loss of the note left with the cancel of ${entry.what}, kept in memory as it was written, `
1509
+ + `${mayBeStored ? 'may be lost' : 'is lost'}: storing it when the writes were stored threw (${thrownReason(error)}), so the note's author is told by a notice once the hold ends`);
1510
+ return;
1511
+ }
1512
+ return;
1513
+ }
1514
+ if (metadata?.kind !== 'subconscious-note' || typeof metadata.epochId !== 'string')
1515
+ return;
1516
+ for (const entry of this.deferredCancels.values()) {
1517
+ if (entry.for.epochId !== metadata.epochId || entry.note === undefined || !entry.noteSettled || !entry.heldInMemory || entry.lostInRelease)
1518
+ continue;
1519
+ const reason = thrownReason(error);
1520
+ delete entry.noteSettled;
1521
+ delete entry.heldInMemory;
1522
+ entry.lostAtRelease = { reason: 'note-undelivered', by: 'itself', error: reason, ...(mayBeStored ? { mayBeStored: true } : {}) };
1523
+ console.error(`[tune-out] the note left with the cancel of ${entry.what}, kept in memory as it was written, ${mayBeStored ? 'may be lost' : 'is lost'}: `
1524
+ + `storing it when the writes were stored threw (${reason}), so its author is told so once the hold ends, and it isn't written again`);
1525
+ return;
1526
+ }
1527
+ }
1528
+ catch (err) {
1529
+ console.error(`[tune-out] a write kept in memory and lost when the writes were stored could not be matched to a waiting cancel's note: ${thrownReason(err)}`);
1530
+ }
1531
+ }
1532
+ /**
1533
+ * A write the framework kept in memory while writes were held, stored when
1534
+ * it stored them (its replay landed), by its metadata as it was given:
1535
+ * heldWriteLost's sibling, called from the same release. What a queued
1536
+ * cancel kept in memory so (heldInMemory) is in the store now, so a stop
1537
+ * no longer loses it: the queued note written by the cancel's own run or
1538
+ * ahead of another cancel's release (`subconscious-note`, of the cancel's
1539
+ * epoch) counts as delivered, and a release that told its loss
1540
+ * (`tune-out-cancel` with `lostNote`, queuedNoteFirst) as told. The cancel
1541
+ * can still wait after that (writes held again before it runs, or a run
1542
+ * that throws before it records the end): a stop then drops it as one
1543
+ * whose note is settled, telling the author nothing false. A loss notice
1544
+ * whose own write was kept so (writeLossNotice: a hold begun at it) is
1545
+ * written now, by its id (`lossNoticeId`): it leaves the notices kept, and
1546
+ * the store's record, so it isn't written a second time (Weft, #844). Any
1547
+ * other write changes nothing. Never throws.
1548
+ */
1549
+ heldWriteStored(metadata) {
1550
+ try {
1551
+ if (metadata?.kind === 'tune-out-note-undelivered' && typeof metadata.lossNoticeId === 'string') {
1552
+ for (const [author, kept] of this.lossNotices) {
1553
+ const at = kept.findIndex((notice) => notice.id === metadata.lossNoticeId);
1554
+ if (at < 0)
1555
+ continue;
1556
+ const [notice] = kept.splice(at, 1);
1557
+ if (kept.length === 0)
1558
+ this.lossNotices.delete(author);
1559
+ console.error(`[tune-out] ${author} is told ${lossSubject(notice)}: the notice, kept in memory as it was written, is stored now, so it isn't written again`);
1560
+ // Brought in line now, or, while writes are still held, once they are stored.
1561
+ this.writeLossRecord();
1562
+ return;
1563
+ }
1564
+ return;
1565
+ }
1566
+ if (typeof metadata?.epochId !== 'string')
1567
+ return;
1568
+ const note = metadata.kind === 'subconscious-note';
1569
+ if (!note && !(metadata.kind === 'tune-out-cancel' && metadata.lostNote === true))
1570
+ return;
1571
+ for (const entry of this.deferredCancels.values()) {
1572
+ if (entry.for.epochId !== metadata.epochId || entry.note === undefined || !entry.noteSettled || !entry.heldInMemory)
1573
+ continue;
1574
+ // A note's write settled one whose note went out; a release's, one whose loss it told.
1575
+ if (note === (entry.lostInRelease !== undefined))
1576
+ continue;
1577
+ delete entry.heldInMemory;
1578
+ return;
1579
+ }
1580
+ }
1581
+ catch (err) {
1582
+ console.error(`[tune-out] a write kept in memory and stored when the writes were stored could not be matched to a waiting cancel: ${thrownReason(err)}`);
1583
+ }
1584
+ }
1585
+ /**
1586
+ * A queued cancel back on the queue (`key`) after a run that didn't end
1587
+ * its hold: one that threw, or met writes held again (waitForWrites). A
1588
+ * cancel queued there meanwhile, for the same epoch (a trace listener's,
1589
+ * during the run), joins it, as a later cancel joins one waiting
1590
+ * (deferCancel): the run's stands, with its reason and its note, as it
1591
+ * was (settled, or told in a release), and takes the later one's askers,
1592
+ * and its note where it has none, or where its own went out already
1593
+ * (noteDelivered: the run delivered it, and failed before the end), the
1594
+ * later note then going out when the hold ends; a later note beside its
1595
+ * own still to go out is dropped,
1596
+ * told (dropDeferred, `superseded`), its caller having been answered as
1597
+ * waiting. One queued for a later epoch leaves the run nothing to end: it
1598
+ * is dropped, told (dropQueued).
1599
+ */
1600
+ requeue(key, entry) {
1601
+ const waiting = this.deferredCancels.get(key);
1602
+ if (!waiting) {
1603
+ this.deferredCancels.set(key, entry);
1604
+ }
1605
+ else if (waiting.for.epochId !== entry.for.epochId) {
1606
+ this.dropQueued(entry, { reason: 'hold-ended', current: waiting.for.epochId });
1607
+ }
1608
+ else {
1609
+ entry.askers = [...entry.askers, ...waiting.askers.filter((asker) => !entry.askers.includes(asker))];
1610
+ this.deferredCancels.set(key, entry);
1611
+ if (waiting.note === undefined)
1612
+ return;
1613
+ if (entry.note === undefined) {
1614
+ const { note, noteAuthor, noteSettled, lostInRelease, heldInMemory, lostAtRelease } = waiting;
1615
+ Object.assign(entry, {
1616
+ note, ...(noteAuthor !== undefined ? { noteAuthor } : {}), ...(noteSettled ? { noteSettled } : {}),
1617
+ ...(lostInRelease ? { lostInRelease } : {}), ...(heldInMemory ? { heldInMemory } : {}),
1618
+ ...(lostAtRelease ? { lostAtRelease } : {}),
1619
+ });
1620
+ return;
1621
+ }
1622
+ // The run's own note went out (its sync, or a step after, failed
1623
+ // before the end): the later note isn't dropped beside it, and its
1624
+ // author isn't told that one is still to go out.
1625
+ if (noteDelivered(entry) && !waiting.noteSettled) {
1626
+ takeLaterNote(entry, waiting.note, waiting.noteAuthor);
1627
+ return;
1628
+ }
1629
+ this.dropDeferred(waiting, { reason: 'superseded' });
1630
+ }
1631
+ }
1632
+ /** Where a hold is, as askTurnForLoss names it: the all-except hold's, or the channel shown. */
1633
+ whereOf(at) {
1634
+ if (at.scope)
1635
+ return 'all-except';
1636
+ try {
1637
+ return this.shown(at.serverId, at.channelId);
1638
+ }
1639
+ catch {
1640
+ return at.channelId;
1641
+ }
1642
+ }
1643
+ /**
1644
+ * A queued cancel off the queue with its work undone (`drop`), as
1645
+ * dropDeferred tells it, its note as it stands: one whose loss a release
1646
+ * told is told so (lossesToldInRelease), where its hold has ended
1647
+ * (`hold-ended`); one delivered, or told lost already, isn't told lost
1648
+ * again: its drop is logged and traced as one with no note, unless the
1649
+ * write that settled it is kept in memory at a stop (heldInMemory), which
1650
+ * loses it; one stored since (heldWriteStored) is settled. One whose write
1651
+ * was lost when the writes were stored (lostAtRelease) isn't settled: it
1652
+ * is told lost by this drop, once.
1653
+ */
1654
+ dropQueued(entry, drop) {
1655
+ if (entry.lostInRelease && drop.reason === 'hold-ended') {
1656
+ this.lossesToldInRelease([entry], this.whereOf(entry.for));
1657
+ return;
1658
+ }
1659
+ // At a stop, a note whose delivery, or the release that told its loss,
1660
+ // is a write kept in memory (heldInMemory) is lost with that write: told
1661
+ // lost with the cancel, as one not settled is.
1662
+ if (drop.reason === 'stopped' && entry.note !== undefined && entry.heldInMemory) {
1663
+ this.dropDeferred(entry, drop);
1664
+ return;
1665
+ }
1666
+ if (entry.note !== undefined && entry.noteSettled) {
1667
+ const { note: _told, ...settled } = entry;
1668
+ this.dropDeferred(settled, drop);
1669
+ return;
1670
+ }
1671
+ this.dropDeferred(entry, drop);
1672
+ }
1673
+ /**
1674
+ * Queued cancels leaving the queue with their hold ended, by the run that
1675
+ * ended it or after it: a note whose loss a release told (lostInRelease;
1676
+ * the release this run wrote, or an earlier cancel's, not made durable
1677
+ * then) is traced and logged as lost now, as that release says, and the
1678
+ * subconscious is asked for a turn to read it (at `where`), once.
1679
+ */
1680
+ lossesToldInRelease(entries, where) {
1681
+ let told = false;
1682
+ for (const entry of new Set(entries)) {
1683
+ const lost = entry?.lostInRelease;
1684
+ if (!entry || !lost)
1685
+ continue;
1686
+ delete entry.lostInRelease;
1687
+ this.dropDeferred(entry, lost, 'release');
1688
+ told = true;
1689
+ }
1690
+ if (told)
1691
+ this.askTurnForLoss(where);
1692
+ }
1693
+ /**
1694
+ * A deferred cancel taken off the queue without what it was for being
1695
+ * done (`drop`, DeferredDrop), told on stderr and as a trace
1696
+ * (`tune-out:deferred-cancel-dropped`) with the epoch it was asked for. A
1697
+ * note it carried is lost: the trace names its author (noteAuthorOf), and
1698
+ * the author is told so in its own context where it still exists, a
1699
+ * reader fork whose run has ended being written nothing (tellNoteLost),
1700
+ * unless the release of the cancel that ended the hold tells it already
1701
+ * (`told` release, queuedNoteFirst): no second notice is written then. A
1702
+ * note that may be stored after all (lostAtRelease, `mayBeStored`) is
1703
+ * traced so (`noteMayBeStored`, with the `error`), as its notice says it.
1704
+ */
1705
+ dropDeferred(entry, asked, told = 'notice') {
1706
+ const { epochId, serverId, channelId, scope } = entry.for;
1707
+ const noteLost = entry.note !== undefined;
1708
+ const author = noteLost ? this.noteAuthorOf(entry) : undefined;
1709
+ // A note whose store couldn't tell whether it landed (lostAtRelease,
1710
+ // `mayBeStored`) may be stored: a stop's drop, or one for a hold that
1711
+ // ended without it, says so (`noteUnknown`), never that it was lost.
1712
+ const drop = noteLost ? withUnknownNote(asked, entry.lostAtRelease) : asked;
1713
+ const noteUnknown = drop.reason === 'hold-ended' || drop.reason === 'stopped' ? drop.noteUnknown : undefined;
1714
+ const unknown = noteUnknown !== undefined;
1715
+ if (drop.reason === 'hold-ended') {
1716
+ const current = drop.current;
1717
+ console.error(`[tune-out] the cancel of ${entry.what}, asked for while writes were held in memory for epoch ${epochId}, is dropped: `
1718
+ + `that epoch's hold ended without it${current !== undefined ? `, and epoch ${current} holds there now, which it was not asked for` : ''}, `
1719
+ + `so it ends nothing${noteLost ? `, and the note it carried ${unknown ? 'may not have been' : 'is not'} delivered` : ''}`);
1720
+ }
1721
+ else if (drop.reason === 'stopped') {
1722
+ console.error(`[tune-out] stopping: the cancel of ${entry.what}, asked for while writes were held in memory for epoch ${epochId}, `
1723
+ + `ends nothing, the tune-out staying in force on disk`
1724
+ + (noteLost ? `, and the note${author !== undefined ? ` ${author} left` : ''} with it ${unknown ? 'may not have been' : 'is not'} delivered` : ''));
1725
+ }
1726
+ else if (drop.reason === 'superseded') {
1727
+ console.error(`[tune-out] the cancel of ${entry.what}, asked for while an earlier cancel of epoch ${epochId} was running, joins that one, `
1728
+ + `whose note stands${noteLost ? `, so the note${author !== undefined ? ` ${author} left` : ''} with it is not delivered` : ''}`);
1729
+ }
1730
+ else {
1731
+ console.error(`[tune-out] the note${author !== undefined ? ` ${author} left with` : ' carried by'} the cancel of ${entry.what}, asked for while writes were held in memory for epoch ${epochId}, `
1732
+ + `${drop.mayBeStored ? 'may be lost' : 'is lost'}: ${drop.by === 'another' ? 'another cancel' : 'that cancel itself, when it ran,'} ended that hold, `
1733
+ + `and the note ${drop.mayBeStored ? 'may not have been' : 'could not be'} delivered: ${drop.error}`
1734
+ + (told === 'release' ? '; the release of the cancel that ended it says so' : ''));
1735
+ }
1736
+ // Its loss kept before the trace, so a stop from a listener of the
1737
+ // trace keeps it in the store with the others (stop). Told, it is
1738
+ // settled: neither a write kept in memory (heldInMemory) nor one lost
1739
+ // when the writes were stored (lostAtRelease) tells it again.
1740
+ if (noteLost) {
1741
+ entry.noteSettled = true;
1742
+ delete entry.heldInMemory;
1743
+ delete entry.lostAtRelease;
1744
+ if (told === 'notice')
1745
+ this.tellNoteLost(entry, author, drop);
1746
+ }
1747
+ this.hooks.emitTrace({
1748
+ type: 'tune-out:deferred-cancel-dropped', serverId, channelId, epochId,
1749
+ ...(scope ? { scope } : {}),
1750
+ reason: drop.reason,
1751
+ ...(drop.reason === 'hold-ended' && drop.current !== undefined ? { currentEpochId: drop.current } : {}),
1752
+ noteLost,
1753
+ ...(author !== undefined ? { noteAuthor: author } : {}),
1754
+ // A note that may be stored after all (`mayBeStored`, `noteUnknown`)
1755
+ // is marked so, with what the store couldn't tell, as its author's
1756
+ // notice says it: not delivered, read alone, would be a known loss.
1757
+ ...(drop.reason === 'note-undelivered' ? { error: drop.error } : noteUnknown !== undefined ? { error: noteUnknown } : {}),
1758
+ ...((drop.reason === 'note-undelivered' && drop.mayBeStored) || unknown ? { noteMayBeStored: true } : {}),
1759
+ });
1760
+ }
1761
+ /**
1762
+ * Whose a queued cancel's note is: the name deliverReaderNote would
1763
+ * deliver it under, the persistent subconscious's, else the reader fork's
1764
+ * that left it (`noteAuthor`). Undefined where neither is known; the
1765
+ * fork's name where the subconscious's can't be read.
1766
+ */
1767
+ noteAuthorOf(entry) {
1768
+ try {
1769
+ return this.hooks.subconsciousName() ?? entry.noteAuthor;
1770
+ }
1771
+ catch {
1772
+ return entry.noteAuthor;
1773
+ }
1774
+ }
1775
+ /**
1776
+ * The author of a queued cancel's lost note (`author`, noteAuthorOf), told
1777
+ * in its own context that the note was not delivered and why (`drop`):
1778
+ * its cancel was answered as waiting, so nothing else tells it. The
1779
+ * notice is kept (lossNotices) and written now (writeLossNotices). One
1780
+ * whose write fails stays kept, its first failure traced, and is tried
1781
+ * again at the next release and at the retry, which this arms, until it
1782
+ * is written; what dropped the cancel goes on either way. One for the
1783
+ * persistent subconscious is kept in the store as well (writeLossRecord),
1784
+ * so a stop leaves it for the next boot to write (restoreLossNotices); a
1785
+ * reader fork's is lost at a stop, as the fork's run is. While writes are
1786
+ * held it waits for their release, as the cancels do. A reader fork that
1787
+ * has ended is no agent the framework writes to any more: it is written
1788
+ * nothing, the loss reaching no one, which is traced (writeLossNotice),
1789
+ * and the trace of the drop (dropDeferred) still names the fork. An
1790
+ * author not known is logged. One told at the stop is kept, not written:
1791
+ * the stop keeps it in the store with the others (writeLossRecord), for
1792
+ * the next boot; one told after the stop has kept those (a trace
1793
+ * listener's stop, during the run of a cancel it found off its queue) is
1794
+ * added to that record (keepAfterStop). Where the store refuses that record too, the stop's
1795
+ * stamp leaves the hold out (writeStopStamp), and the next boot tells the
1796
+ * subconscious, for a hold still on record, what that run's rows show of
1797
+ * a cancel of it then waiting (noteUnstampedStop); a stop that can't write
1798
+ * its stamp either leaves the one this run's boot wrote, which covers no
1799
+ * hold, so the next boot tells it the same, where that one landed. Where
1800
+ * the store refused every one, this run's too, nothing in the store says
1801
+ * the stop came: the next boot reads no stamp as a first start (#844
1802
+ * round 8, Weft) and tells nothing. Where every
1803
+ * write refuses through the stop (the note, the notice, the store's
1804
+ * record) and no release carries the loss (queuedNoteFirst), for a hold
1805
+ * that has ended, the trace of the drop, with that of the notice's
1806
+ * failure, and the log are all that is left of it: nothing else here
1807
+ * outlives the stop.
1808
+ */
1809
+ tellNoteLost(entry, author, drop) {
1810
+ if (author === undefined) {
1811
+ console.error(`[tune-out] the note carried by the cancel of ${entry.what} is lost, and no author of it is known here, so none is told`);
1812
+ return;
1813
+ }
1814
+ const { epochId, serverId, channelId, scope, current } = entry.for;
1815
+ // In persistent mode the author is the subconscious (noteAuthorOf), and
1816
+ // no reader fork is configured; with reader forks, a fork.
1817
+ let persistent;
1818
+ try {
1819
+ persistent = this.hooks.subconsciousName() === author;
1820
+ }
1821
+ catch {
1822
+ persistent = typeof this.hooks.invokeReader !== 'function';
1823
+ }
1824
+ const notice = {
1825
+ id: randomUUID(),
1826
+ for: { epochId, serverId, channelId, ...(scope ? { scope } : {}), current },
1827
+ what: entry.what, drop, keptAtMs: Date.now(),
1828
+ ...(persistent ? { persistent: true } : {}),
1829
+ ...(drop.reason === 'stopped' && this.stopped ? { stopBound: this.runBound() } : {}),
1830
+ };
1831
+ const kept = this.lossNotices.get(author);
1832
+ if (kept)
1833
+ kept.push(notice);
1834
+ else
1835
+ this.lossNotices.set(author, [notice]);
1836
+ if (this.stopped) {
1837
+ if (this.stopDone)
1838
+ this.keepAfterStop();
1839
+ return;
1840
+ }
1841
+ this.writeLossNotices(author);
1842
+ if (this.lossNotices.size > 0)
1843
+ this.armDeferredRetry();
1844
+ }
1845
+ /**
1846
+ * Write the loss notices kept (lossNotices), `author`'s or every
1847
+ * author's, each author's in the order its losses came: one written, or
1848
+ * whose author has ended, leaves the queue; one whose write fails stays
1849
+ * for the next try (writeLossNotice), and that author's later ones with
1850
+ * it, behind it. None is written while its author's writes are
1851
+ * held (writesHeld(author)): the framework would keep it in memory and answer an
1852
+ * empty id, which then couldn't tell a kept write from an ended author's
1853
+ * dropped one. The release or the retry writes them. Nor is one the boot
1854
+ * read from the store's record while whether it was written before the
1855
+ * stop can't be read (checkLossNoticesWritten). The store's record, where
1856
+ * it wasn't read in yet, is read first, and while it can't be, none of
1857
+ * the persistent subconscious's is written, as one it keeps would come
1858
+ * after it. Then the store's
1859
+ * record of the persistent subconscious's kept notices is brought in
1860
+ * line (writeLossRecord). A loss told for an author while its notice is
1861
+ * being written (a listener of that write) waits behind it, written next
1862
+ * in this pass. Each leaves its author's list just before it
1863
+ * is written, as a deferred cancel leaves its queue, so a stop meanwhile
1864
+ * (a listener of the write) keeps those not written yet in the store
1865
+ * (stop), and none is written after it; one whose write that stop met and
1866
+ * failed is kept as a loss told after the stop is (keepAfterStop). Never
1867
+ * throws.
1868
+ */
1869
+ writeLossNotices(author) {
1870
+ // A record not read in yet (a read at boot threw, or a branch move) is
1871
+ // read first: the notices it keeps are older than any kept since, and
1872
+ // are written first (AF #844 review 5, #40).
1873
+ if (this.lossRecordUnread)
1874
+ this.readLossRecord();
1875
+ this.checkLossNoticesWritten();
1876
+ for (const name of author !== undefined ? [author] : [...this.lossNotices.keys()]) {
1877
+ // A loss told for this author from a listener of its notice's write
1878
+ // (that write's trace, or its failure's) only joins the queue, behind
1879
+ // the one in flight: written now, it went ahead of it (AF #844
1880
+ // review 5, #24). This pass writes it next.
1881
+ if (this.lossNoticesWriting.has(name))
1882
+ continue;
1883
+ // Each from the head: one that fails goes back there, the rest
1884
+ // waiting behind it for the next try, so the author is told in the
1885
+ // order the losses came; a loss told meanwhile is kept after them.
1886
+ for (;;) {
1887
+ const kept = this.lossNotices.get(name);
1888
+ const notice = kept?.[0];
1889
+ // One the boot read from the record, which may be written already,
1890
+ // waits until that can be read (checkLossNoticesWritten), and those
1891
+ // after it with it.
1892
+ // Nor is the persistent subconscious's while that record can't be
1893
+ // read: one it keeps would be written after it.
1894
+ if (!kept || !notice || this.stopped || this.writesHeldNow(name) || this.lossNoticesUnchecked.has(notice.id)
1895
+ || (notice.persistent && this.lossRecordUnread))
1896
+ break;
1897
+ kept.shift();
1898
+ if (kept.length === 0)
1899
+ this.lossNotices.delete(name);
1900
+ this.lossNoticesWriting.add(name);
1901
+ let written;
1902
+ try {
1903
+ written = this.writeLossNotice(name, notice);
1904
+ }
1905
+ finally {
1906
+ this.lossNoticesWriting.delete(name);
1907
+ }
1908
+ if (written !== 'failed')
1909
+ continue;
1910
+ this.lossNotices.set(name, [notice, ...(this.lossNotices.get(name) ?? [])]);
1911
+ break;
1912
+ }
1913
+ }
1914
+ if (this.stopped) {
1915
+ if (this.stopDone)
1916
+ this.keepAfterStop();
1917
+ return;
1918
+ }
1919
+ this.writeLossRecord();
1920
+ }
1921
+ /** Where the persistent subconscious's kept loss notices are kept: a snapshot state, beside the coordinator's others. */
1922
+ get lossNoticesStateId() {
1923
+ return `tune-out/${this.hooks.primaryName() ?? 'default'}/loss-notices`;
1924
+ }
1925
+ /** The persistent subconscious's kept notices (lossNotices) in the record's form, in the order kept. */
1926
+ persistedLossNotices() {
1927
+ const out = [];
1928
+ for (const [author, kept] of this.lossNotices) {
1929
+ for (const notice of kept) {
1930
+ if (!notice.persistent)
1931
+ continue;
1932
+ const { epochId, serverId, channelId, scope } = notice.for;
1933
+ const { drop } = notice;
1934
+ out.push({
1935
+ id: notice.id, author, what: notice.what,
1936
+ for: { epochId, serverId, channelId, ...(scope ? { scope } : {}) },
1937
+ drop: drop.reason === 'hold-ended'
1938
+ ? { reason: 'hold-ended', ...(drop.current !== undefined ? { current: drop.current } : {}), ...(drop.noteUnknown !== undefined ? { noteUnknown: drop.noteUnknown } : {}) }
1939
+ : drop.reason === 'stopped'
1940
+ ? { reason: 'stopped', ...(drop.noteUnknown !== undefined ? { noteUnknown: drop.noteUnknown } : {}) }
1941
+ : drop.reason === 'superseded'
1942
+ ? { reason: drop.reason }
1943
+ : drop.reason === 'unstamped-stop'
1944
+ ? { ...drop }
1945
+ : { reason: 'note-undelivered', by: drop.by, error: drop.error, ...(drop.mayBeStored ? { mayBeStored: true } : {}) },
1946
+ keptAtMs: notice.keptAtMs,
1947
+ ...(notice.keptAtSequence !== undefined ? { keptAtSequence: notice.keptAtSequence } : {}),
1948
+ ...(notice.traced ? { traced: true } : {}),
1949
+ ...(notice.stopBound ? { stopBound: notice.stopBound } : {}),
1950
+ });
1951
+ }
1952
+ }
1953
+ return out;
1954
+ }
1955
+ /**
1956
+ * Keep the persistent subconscious's kept loss notices in the store
1957
+ * (`tune-out/<primary>/loss-notices`, PersistedLossNotice), as they are
1958
+ * in memory: one kept is added, one written leaves. Only when they
1959
+ * changed since the record was last written or read, or a write of it
1960
+ * failed; never while writes are held (writesHeld): the release, the
1961
+ * retry or the stop writes it then. Not over a record that couldn't be
1962
+ * read (readLossRecord, tried again first). A write that fails, or can't
1963
+ * be made durable at once (stateDurable), is logged, and the notices stay
1964
+ * kept in memory, written or tried again as before;
1965
+ * a stop meanwhile loses them. At `stop`, written whenever one is kept,
1966
+ * held writes or not, and answers whether the record holds them. At
1967
+ * `boot`, written where they changed, held writes or not, as the boot's
1968
+ * stamp is (noteUnstampedStop). Never throws.
1969
+ */
1970
+ writeLossRecord(at) {
1971
+ try {
1972
+ const notices = this.persistedLossNotices();
1973
+ const shape = JSON.stringify(notices);
1974
+ if (at !== 'stop' && shape === this.lossRecordShape && !this.lossRecordStale)
1975
+ return true;
1976
+ if (at === 'stop' && notices.length === 0 && !this.lossRecordStale && !this.lossRecordUnread)
1977
+ return true;
1978
+ if (at === undefined && this.writesHeldNow()) {
1979
+ this.lossRecordStale = true;
1980
+ return false;
1981
+ }
1982
+ if (this.lossRecordUnread && !this.readLossRecord()) {
1983
+ this.lossRecordStale = true;
1984
+ console.error(`[tune-out] the loss notices kept for the subconscious (${this.lossNoticesStateId}) can't be read, so they aren't written over: `
1985
+ + `${notices.length === 0 ? 'none is' : `${notices.length} kept now ${notices.length === 1 ? 'is' : 'are'}`} not added to them`);
1986
+ return false;
1987
+ }
1988
+ // Read again just now (readLossRecord): what it kept is in memory too.
1989
+ const all = this.persistedLossNotices();
1990
+ this.channelRegistry.writeCoordinatorState(this.lossNoticesStateId, { v: 1, pendingLossNotices: all });
1991
+ // A boot reads it after a crash, and the stop's stamp counts on it:
1992
+ // durable now, or not kept (the stamp leaving its holds out).
1993
+ if (!this.stateDurable()) {
1994
+ this.lossRecordStale = true;
1995
+ console.error(`[tune-out] the loss notices kept for the subconscious (${this.lossNoticesStateId}) were written but could not be made durable, `
1996
+ + `so they are written again${at === 'stop' ? ', and the stop counts them as not kept' : ' at the next change or the stop'}`);
1997
+ return false;
1998
+ }
1999
+ this.lossRecordShape = JSON.stringify(all);
2000
+ this.lossRecordIds = new Set(all.map((n) => n.id));
2001
+ this.lossRecordStale = false;
2002
+ return true;
2003
+ }
2004
+ catch (err) {
2005
+ this.lossRecordStale = true;
2006
+ console.error(`[tune-out] the loss notices kept for the subconscious (${this.lossNoticesStateId}) couldn't be written to the store, `
2007
+ + `so they are kept in memory only${at === 'stop' ? ', which the stop clears' : ', and a stop before they are written loses them'}: ${thrownReason(err)}`);
2008
+ return false;
2009
+ }
2010
+ }
2011
+ /**
2012
+ * Read the persistent subconscious's kept loss notices from the store
2013
+ * (writeLossRecord) into memory (lossNotices), ahead of any kept since for
2014
+ * the same author: at boot (restoreLossNotices), and, where it couldn't
2015
+ * be read before (a boot's read threw, or a branch move), before the
2016
+ * notices are written (writeLossNotices) and before the record is
2017
+ * written. Answers whether it was read.
2018
+ * One that throws is logged and answers false: nothing is written over it
2019
+ * until it is read. A record not in this format (`{ v: 1,
2020
+ * pendingLossNotices }`) reads as none kept, logged, and is written over
2021
+ * at the next change; an entry this version can't read is logged and left
2022
+ * out, so the next write drops it. Those read in wait to be checked
2023
+ * against the subconscious's rows (lossNoticesUnchecked), as a boot's do.
2024
+ */
2025
+ readLossRecord() {
2026
+ let raw;
2027
+ try {
2028
+ raw = this.channelRegistry.readCoordinatorState(this.lossNoticesStateId);
2029
+ }
2030
+ catch (err) {
2031
+ this.lossRecordUnread = true;
2032
+ console.error(`[tune-out] the loss notices kept for the subconscious (${this.lossNoticesStateId}) can't be read, so none is written now, `
2033
+ + `and nothing is written over them until they can be read: ${thrownReason(err)}`);
2034
+ return false;
2035
+ }
2036
+ this.lossRecordUnread = false;
2037
+ this.lossRecordIds = new Set();
2038
+ if (raw == null)
2039
+ return true;
2040
+ const record = raw;
2041
+ if (record.v !== 1 || !Array.isArray(record.pendingLossNotices)) {
2042
+ console.error(`[tune-out] the loss notices kept for the subconscious (${this.lossNoticesStateId}) aren't in a form this version reads, so none is written; the next change writes over them`);
2043
+ return true;
2044
+ }
2045
+ const known = new Set([...this.lossNotices.values()].flat().map((n) => n.id));
2046
+ const restored = new Map();
2047
+ let unreadable = 0;
2048
+ for (const value of record.pendingLossNotices) {
2049
+ const entry = readPersistedLossNotice(value);
2050
+ if (!entry) {
2051
+ unreadable++;
2052
+ continue;
2053
+ }
2054
+ this.lossRecordIds.add(entry.id);
2055
+ if (known.has(entry.id))
2056
+ continue;
2057
+ known.add(entry.id);
2058
+ const { epochId, serverId, channelId, scope } = entry.for;
2059
+ const notice = {
2060
+ id: entry.id,
2061
+ for: { epochId, serverId, channelId, ...(scope ? { scope } : {}), current: this.epochHoldingAt(serverId, channelId, scope) },
2062
+ what: entry.what, drop: entry.drop, keptAtMs: entry.keptAtMs, persistent: true,
2063
+ ...(entry.keptAtSequence !== undefined ? { keptAtSequence: entry.keptAtSequence } : {}),
2064
+ ...(entry.traced ? { traced: true } : {}),
2065
+ ...(entry.stopBound ? { stopBound: entry.stopBound } : {}),
2066
+ };
2067
+ restored.set(entry.author, [...(restored.get(entry.author) ?? []), notice]);
2068
+ }
2069
+ for (const [author, notices] of restored) {
2070
+ this.lossNotices.set(author, [...notices, ...(this.lossNotices.get(author) ?? [])]);
2071
+ // Written before the record was last read may be (a stop, or a
2072
+ // record the store refused, between the two): each waits until the
2073
+ // subconscious's rows say whether (checkLossNoticesWritten).
2074
+ for (const notice of notices)
2075
+ this.lossNoticesUnchecked.add(notice.id);
2076
+ }
2077
+ if (unreadable > 0) {
2078
+ console.error(`[tune-out] ${unreadable} of the loss notices kept for the subconscious (${this.lossNoticesStateId}) can't be read by this version, `
2079
+ + 'so they are not written, and the next write of the record leaves them out');
2080
+ }
2081
+ // What the store holds, so the next write is made when memory differs:
2082
+ // at once where an entry was left out.
2083
+ this.lossRecordShape = unreadable > 0 ? '' : JSON.stringify(this.persistedLossNotices());
2084
+ return true;
2085
+ }
2086
+ /**
2087
+ * Make the coordinator's own record just written (the loss record, a
2088
+ * stamp) durable (commitDeliveries), answering whether it is: a boot reads
2089
+ * them after a crash, which a write the store's periodic sync hadn't
2090
+ * reached yet doesn't outlive. Left out, a write counts as durable.
2091
+ */
2092
+ stateDurable() {
2093
+ try {
2094
+ return this.hooks.commitDeliveries?.() !== false;
2095
+ }
2096
+ catch {
2097
+ return false;
2098
+ }
2099
+ }
2100
+ /**
2101
+ * The persistent subconscious's rows as a boot reads them for what it was
2102
+ * told already: its window, and its own stored rows
2103
+ * (subconsciousStoredMessages), which hold a notice its window doesn't yet.
2104
+ * Either read that throws is logged, with what follows (`then`), and read
2105
+ * as none; `unread` says one did.
2106
+ */
2107
+ subconsciousRows(then) {
2108
+ let unread = false;
2109
+ const read = (what, rows) => {
2110
+ try {
2111
+ return rows?.() ?? [];
2112
+ }
2113
+ catch (err) {
2114
+ unread = true;
2115
+ console.error(`[tune-out] the subconscious's ${what} could not be read, ${then}: ${thrownReason(err)}`);
2116
+ return [];
2117
+ }
2118
+ };
2119
+ const rows = [...read('window', this.hooks.subconsciousWindow), ...read('stored rows', this.hooks.subconsciousStoredMessages)];
2120
+ return { rows, unread };
2121
+ }
2122
+ /**
2123
+ * Of the notices read from the store's record at boot (restoreLossNotices),
2124
+ * those the subconscious's rows hold already (`lossNoticeId`: written,
2125
+ * the record not brought in line before the stop) leave the record, not
2126
+ * written again. Where its rows can't be read, they stay unchecked
2127
+ * (lossNoticesUnchecked), and none of them is written (writeLossNotices)
2128
+ * until a later try reads them.
2129
+ */
2130
+ checkLossNoticesWritten() {
2131
+ if (this.lossNoticesUnchecked.size === 0)
2132
+ return;
2133
+ const { rows, unread } = this.subconsciousRows(`so the loss notices kept in the store, which may have been written before the stop, aren't written until it can be`);
2134
+ if (unread)
2135
+ return;
2136
+ const written = new Set();
2137
+ for (const m of rows) {
2138
+ const id = m.metadata?.lossNoticeId;
2139
+ if (typeof id === 'string' && this.lossNoticesUnchecked.has(id))
2140
+ written.add(id);
2141
+ }
2142
+ this.lossNoticesUnchecked.clear();
2143
+ for (const [author, kept] of this.lossNotices) {
2144
+ const left = kept.filter((n) => !written.has(n.id));
2145
+ if (left.length === kept.length)
2146
+ continue;
2147
+ console.error(`[tune-out] ${kept.length - left.length} loss notice(s) kept in the store for ${author} ${kept.length - left.length === 1 ? 'was' : 'were'} written before the last stop already, so ${kept.length - left.length === 1 ? 'it leaves' : 'they leave'} the record`);
2148
+ if (left.length > 0)
2149
+ this.lossNotices.set(author, left);
2150
+ else
2151
+ this.lossNotices.delete(author);
2152
+ }
2153
+ }
2154
+ /** The epoch holding where a kept notice's cancel was (DeferredFor's `current`): the channel's tune-out, or the all-except hold. */
2155
+ epochHoldingAt(serverId, channelId, scope) {
2156
+ return scope === 'all-except'
2157
+ ? () => this.getAllExcept()?.params.epochId
2158
+ : () => this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId;
2159
+ }
2160
+ /**
2161
+ * The holds on record, a channel's and the all-except hold (its excepted
2162
+ * channel, with `scope`): the registry's record of each channel's, whether
2163
+ * or not the channel is registered yet, as at boot it isn't
2164
+ * (tuneOutsOnRecord; the registered ones, listActive, where the registry
2165
+ * has no such list). May throw.
2166
+ */
2167
+ holdsOnRecord() {
2168
+ const registry = this.channelRegistry;
2169
+ const channels = typeof registry.tuneOutsOnRecord === 'function'
2170
+ ? registry.tuneOutsOnRecord.call(this.channelRegistry)
2171
+ : this.listActive();
2172
+ const held = this.getAllExcept();
2173
+ return [
2174
+ ...channels.filter(({ params }) => typeof params.epochId === 'string')
2175
+ .map(({ serverId, channelId, params }) => ({
2176
+ epochId: params.epochId, serverId, channelId,
2177
+ ...(typeof params.startedAtSequence === 'number' ? { startedAt: params.startedAtSequence } : {}),
2178
+ })),
2179
+ ...(held ? [{
2180
+ epochId: held.params.epochId, serverId: held.params.except.serverId, channelId: held.params.except.channelId, scope: 'all-except',
2181
+ ...(typeof held.params.startedAtSequence === 'number' ? { startedAt: held.params.startedAtSequence } : {}),
2182
+ }] : []),
2183
+ ];
2184
+ }
2185
+ /** Where the last stop's stamp is kept (StopStamp): a snapshot state, beside the loss notices. */
2186
+ get stopStampStateId() {
2187
+ return `tune-out/${this.hooks.primaryName() ?? 'default'}/stop-stamp`;
2188
+ }
2189
+ /**
2190
+ * At the stop, once the loss notices are written or kept: the stamp
2191
+ * (StopStamp) naming the holds on record now, a channel's and the
2192
+ * all-except hold, whose waiting cancels this stop accounted for, written
2193
+ * and made durable (stateDurable), as the loss record before it is. Left
2194
+ * out are the epochs of a note whose loss couldn't be kept (`unaccounted`;
2195
+ * every persistent notice's where the store refused the record,
2196
+ * `recorded` false), and of one still kept in memory at the stop, which
2197
+ * the stamp names (`notesKept`, stopNotesKept), so the next boot tells
2198
+ * the subconscious of them (noteUnstampedStop), that a kept note may be
2199
+ * lost where that run's rows don't hold it. A stamp the store refuses too is logged: nothing
2200
+ * more can be done at the stop. The next boot then finds this run's own
2201
+ * stamp, covering no hold, where it landed, and tells the subconscious of
2202
+ * each hold; where the store refused that too, it finds none, which it
2203
+ * reads as a first start, and tells nothing. Never throws.
2204
+ */
2205
+ writeStopStamp(recorded, unaccounted) {
2206
+ try {
2207
+ if (!recorded) {
2208
+ for (const kept of this.lossNotices.values()) {
2209
+ for (const notice of kept)
2210
+ if (notice.persistent)
2211
+ unaccounted.add(notice.for.epochId);
2212
+ }
2213
+ }
2214
+ const onRecord = this.holdsOnRecord().map((hold) => hold.epochId);
2215
+ const owed = this.wakesInStop;
2216
+ const kept = onRecord.filter((epochId) => this.stopNotesKept.has(epochId) && unaccounted.has(epochId));
2217
+ const stamp = {
2218
+ v: 1, epochs: onRecord.filter((epochId) => !unaccounted.has(epochId)), ...this.runBound(),
2219
+ ...(owed.resident || owed.subconscious ? { wakesOwed: { ...owed } } : {}),
2220
+ ...(kept.length > 0 ? { notesKept: kept } : {}),
2221
+ };
2222
+ this.channelRegistry.writeCoordinatorState(this.stopStampStateId, stamp);
2223
+ if (!this.stateDurable())
2224
+ throw new Error('it was written but could not be made durable');
2225
+ }
2226
+ catch (err) {
2227
+ console.error(`[tune-out] stopping: the stop could not be stamped (${this.stopStampStateId}), so the next boot tells the subconscious, `
2228
+ + `for each hold still on record, what it finds of a cancel of it waiting now, where this run's own stamp landed; `
2229
+ + `${this.markerPending ? 'it did not either, so nothing will' : 'it did'}: ${thrownReason(err)}`);
2230
+ }
2231
+ }
2232
+ /**
2233
+ * At boot (resumeActiveEpochs), for the holds on record (`onRecord`, read
2234
+ * before any is resumed or ended): those the last stop's stamp doesn't
2235
+ * cover (writeStopStamp; after a crash the stamp is the one the boot
2236
+ * before wrote, covering none; one the store refused at a stop is that
2237
+ * boot's too) may have had a cancel waiting, with a note, which that stop
2238
+ * didn't account for. No stamp at all is a first boot on this version
2239
+ * (every boot writes one), not a crash: every hold counts as covered. A
2240
+ * hold whose waiting cancel's loss the stop kept in the store
2241
+ * (`stopKept`, restoreLossNotices) is accounted for. For the others, the
2242
+ * rows from that run on (the stamp's `runStartedAt`) say what they can
2243
+ * (UnstampedStop): a release that told the queued note's loss
2244
+ * (`lostNote`, queuedNoteFirst) needs nothing more; a release of that
2245
+ * epoch stored since the cancel was asked (or since the hold and the run
2246
+ * began) is `ran`; a note of that epoch delivered with no release since,
2247
+ * `delivered`; the subconscious's own `cancel_tuneout` of it answered as
2248
+ * waiting (askedCancelOf), `asked`; else `unknown`, told conditionally.
2249
+ * Each by a loss notice (`unstamped-stop`) kept and written as the others
2250
+ * are (writeLossNotices), with a turn to read it, kept in the store and
2251
+ * tried again where the store still refuses; traced as
2252
+ * `tune-out:stop-unrecorded` with what was `found`. A hold already told so
2253
+ * (its notice in the subconscious's window, or kept to be written) is told
2254
+ * again only what says more (FOUND_RANK), or another note. A reader fork
2255
+ * is told nothing: none outlives the stop. The stamp is then written again
2256
+ * covering no hold and naming this run's start (`runStartedAt`, and its
2257
+ * `branch`), so it can't cover a later stop and a crash during this run
2258
+ * leaves it so: last, once those notices are written or kept in the store,
2259
+ * and, while one is neither, naming the last stop's bound instead
2260
+ * (runBound), so a crash meanwhile reads the same rows again. A write that
2261
+ * fails is kept and tried again as the loss notices are
2262
+ * (writePendingMarker), and meanwhile a waiting cancel's answer says so.
2263
+ * A stamp that can't be read, or isn't in this version's form, covers
2264
+ * nothing; one whose run start is on another branch than the store's now
2265
+ * bounds nothing here, sequences being each branch's own, so no row is
2266
+ * read as that run's and each hold it doesn't cover is told conditionally
2267
+ * (`unknown`). Never throws.
2268
+ */
2269
+ noteUnstampedStop(onRecord, stopKept, bootHead) {
2270
+ try {
2271
+ let raw;
2272
+ let readable = true;
2273
+ try {
2274
+ raw = this.channelRegistry.readCoordinatorState(this.stopStampStateId);
2275
+ }
2276
+ catch (err) {
2277
+ readable = false;
2278
+ console.error(`[tune-out] the last stop's stamp (${this.stopStampStateId}) can't be read, so it covers no hold: ${thrownReason(err)}`);
2279
+ }
2280
+ const stamp = raw;
2281
+ const covered = new Set();
2282
+ const notesKept = new Set();
2283
+ let since;
2284
+ let stampBranch;
2285
+ const firstBoot = readable && raw == null;
2286
+ if (stamp && stamp.v === 1 && Array.isArray(stamp.epochs)) {
2287
+ for (const epochId of stamp.epochs)
2288
+ if (typeof epochId === 'string')
2289
+ covered.add(epochId);
2290
+ if (Array.isArray(stamp.notesKept))
2291
+ for (const epochId of stamp.notesKept)
2292
+ if (typeof epochId === 'string')
2293
+ notesKept.add(epochId);
2294
+ if (typeof stamp.runStartedAt === 'number')
2295
+ since = stamp.runStartedAt;
2296
+ if (typeof stamp.branch === 'string')
2297
+ stampBranch = stamp.branch;
2298
+ }
2299
+ else if (raw != null) {
2300
+ console.error(`[tune-out] the last stop's stamp (${this.stopStampStateId}) isn't in a form this version reads, so it covers no hold`);
2301
+ }
2302
+ if (firstBoot && onRecord.length > 0) {
2303
+ console.error(`[tune-out] no stop stamp is kept (${this.stopStampStateId}): a first start on a version that stamps its stops, `
2304
+ + 'so the holds on record count as stopped cleanly');
2305
+ }
2306
+ if (stamp && stamp.v === 1 && stamp.wakesOwed && typeof stamp.wakesOwed === 'object')
2307
+ this.askWakesOwed(stamp.wakesOwed);
2308
+ // This run begins at the store's head as the boot found it, before
2309
+ // the boot's own releases (an end time passed, a wake budget spent):
2310
+ // those are this run's, not the last one's. Its marker is written last
2311
+ // (finally, below), once what this boot reads of the last stop's
2312
+ // stamp is told or kept: written first, a crash or a refused record
2313
+ // between the two left the next boot reading from this run's start,
2314
+ // past what the notice told of.
2315
+ this.runStartedAt = bootHead;
2316
+ this.runBranch = this.branchNow();
2317
+ if (this.runBranch !== undefined)
2318
+ this.runStarts.set(this.runBranch, bootHead);
2319
+ this.carriedBound = {
2320
+ ...(since !== undefined ? { runStartedAt: since } : {}), ...(stampBranch !== undefined ? { branch: stampBranch } : {}),
2321
+ };
2322
+ // A run start on another branch is a place on that branch: this one's
2323
+ // sequences say nothing of it, so no row here is read as that run's.
2324
+ const elsewhere = stampBranch !== undefined && this.runBranch !== undefined && stampBranch !== this.runBranch;
2325
+ let told = false;
2326
+ try {
2327
+ this.tellUnstampedHolds(onRecord, stopKept, bootHead, { covered, notesKept, since, firstBoot, elsewhere, stampBranch });
2328
+ told = true;
2329
+ }
2330
+ finally {
2331
+ this.carriedUntold = !told;
2332
+ if (!this.carriedNeeded())
2333
+ this.carriedBound = undefined;
2334
+ this.markerPending = this.runMarker();
2335
+ this.writePendingMarker();
2336
+ if (this.markerPending)
2337
+ this.armDeferredRetry();
2338
+ }
2339
+ }
2340
+ catch (err) {
2341
+ console.error(`[tune-out] whether the last stop recorded the holds on record could not be checked at boot: ${thrownReason(err)}`);
2342
+ }
2343
+ }
2344
+ /**
2345
+ * At boot, the release turns the last stop's stamp says it dropped
2346
+ * (StopStamp's `wakesOwed`: asked while it was under way), asked again
2347
+ * as the boot's wake for a release row still owed asks them (the
2348
+ * framework's `tune-out released (restored)` and `tune-out (restored)`,
2349
+ * so a row both owed and recorded is asked once), each unless this boot
2350
+ * asked that turn for a release of its own already. Asked once: the
2351
+ * boot's marker is written over that stamp. Never throws.
2352
+ */
2353
+ askWakesOwed(owed) {
2354
+ const ask = (name, reason, whose) => {
2355
+ if (!name || this.releaseTurnsAsked.has(whose))
2356
+ return;
2357
+ try {
2358
+ this.hooks.requestInference(name, reason, 'tune-out');
2359
+ console.error(`[tune-out] the last stop dropped the turn a release asked for ${name} while it was under way, so it is asked for again`);
2360
+ }
2361
+ catch (err) {
2362
+ console.error(`[tune-out] the turn the last stop dropped for ${name}'s release could not be asked for again: ${thrownReason(err)}`);
2363
+ }
2364
+ };
2365
+ try {
2366
+ if (owed.resident === true)
2367
+ ask(this.hooks.primaryName(), 'tune-out released (restored)', 'resident');
2368
+ if (owed.subconscious === true)
2369
+ ask(this.hooks.subconsciousName(), 'tune-out (restored)', 'subconscious');
2370
+ }
2371
+ catch (err) {
2372
+ console.error(`[tune-out] the turns the last stop dropped could not be asked for again: ${thrownReason(err)}`);
2373
+ }
2374
+ }
2375
+ /**
2376
+ * noteUnstampedStop's telling, for the holds on record (`onRecord`) the
2377
+ * last stop's stamp doesn't cover (`covered`), reading that run's rows
2378
+ * from its start (`since`) to this boot's (`bootHead`), none where its
2379
+ * start is on another branch (`elsewhere`). May throw.
2380
+ */
2381
+ tellUnstampedHolds(onRecord, stopKept, bootHead, last) {
2382
+ const { covered, since, firstBoot, elsewhere } = last;
2383
+ let author;
2384
+ try {
2385
+ author = this.hooks.subconsciousName();
2386
+ }
2387
+ catch {
2388
+ author = null;
2389
+ }
2390
+ if (!author || firstBoot)
2391
+ return;
2392
+ const uncovered = onRecord.filter((hold) => !covered.has(hold.epochId));
2393
+ if (uncovered.length === 0)
2394
+ return;
2395
+ if (elsewhere) {
2396
+ console.error(`[tune-out] the last stop's stamp names its run's start on branch ${JSON.stringify(last.stampBranch)}, not this one `
2397
+ + `(${JSON.stringify(this.runBranch)}), so no row here can be told to be that run's: each hold it doesn't cover is told conditionally`);
2398
+ }
2399
+ // What that run's residents' rows show, by epoch: from its start to
2400
+ // this boot's (`bootHead`), so a release this boot made isn't read as
2401
+ // the last run's.
2402
+ const notes = new Map();
2403
+ // A queued cancel's own run that delivered its note names that cancel's
2404
+ // askers on the note (NoteMetadata), by epoch, the latest: a run that
2405
+ // wrote no release of its own records its askers nowhere else.
2406
+ const noteRuns = new Map();
2407
+ const releases = new Map();
2408
+ // The newest release of each epoch that told a queued note's loss (`lostNote`).
2409
+ const toldInRelease = new Map();
2410
+ // Whether that run's rows were read: not where they are another
2411
+ // branch's, or the read throws, and then whether a cancel ran isn't known.
2412
+ let rowsRead = !elsewhere;
2413
+ try {
2414
+ for (const m of elsewhere ? [] : this.hooks.getStoredMessages()) {
2415
+ if (since !== undefined && typeof m.sequence === 'number' && m.sequence <= since)
2416
+ continue;
2417
+ if (bootHead !== undefined && typeof m.sequence === 'number' && m.sequence > bootHead)
2418
+ continue;
2419
+ const meta = m.metadata;
2420
+ if (typeof meta?.epochId !== 'string')
2421
+ continue;
2422
+ if (meta.kind === 'subconscious-note') {
2423
+ notes.set(meta.epochId, Math.max(notes.get(meta.epochId) ?? -Infinity, m.sequence));
2424
+ const askers = Array.isArray(meta.cancel?.askers)
2425
+ ? meta.cancel.askers.filter((a) => typeof a === 'string') : undefined;
2426
+ if (askers && (noteRuns.get(meta.epochId)?.sequence ?? -Infinity) < m.sequence)
2427
+ noteRuns.set(meta.epochId, { sequence: m.sequence, askers });
2428
+ }
2429
+ else if (meta.kind === 'tune-out-cancel') {
2430
+ if (meta.lostNote === true)
2431
+ toldInRelease.set(meta.epochId, Math.max(toldInRelease.get(meta.epochId) ?? -Infinity, m.sequence));
2432
+ releases.set(meta.epochId, [...(releases.get(meta.epochId) ?? []), {
2433
+ sequence: m.sequence,
2434
+ ...(typeof meta.cancel?.source === 'string' ? { source: meta.cancel.source } : {}),
2435
+ ...(typeof meta.cancel?.reason === 'string' ? { reason: meta.cancel.reason } : {}),
2436
+ askers: Array.isArray(meta.cancel?.askers) ? meta.cancel.askers.filter((a) => typeof a === 'string')
2437
+ : typeof meta.cancel?.by === 'string' ? [meta.cancel.by] : [],
2438
+ }]);
2439
+ }
2440
+ }
2441
+ }
2442
+ catch (err) {
2443
+ rowsRead = false;
2444
+ console.error(`[tune-out] the residents' rows could not be read at boot, so what became of a note waiting at the last stop isn't known: ${thrownReason(err)}`);
2445
+ }
2446
+ // What the subconscious's own window shows: its calls, and what it was told already.
2447
+ let window = [];
2448
+ try {
2449
+ window = this.hooks.subconsciousWindow?.() ?? [];
2450
+ }
2451
+ catch (err) {
2452
+ console.error(`[tune-out] the subconscious's window could not be read at boot, so a cancel it asked for isn't named: ${thrownReason(err)}`);
2453
+ }
2454
+ let archived;
2455
+ try {
2456
+ archived = this.hooks.subconsciousGuardedResults?.();
2457
+ }
2458
+ catch (err) {
2459
+ console.error(`[tune-out] the tool-result guard's audit could not be read at boot, so a result it withheld isn't read from it: ${thrownReason(err)}`);
2460
+ }
2461
+ const asks = elsewhere ? [] : cancelAsksIn(window, author, archived);
2462
+ // Its rows, its window's and its stored ones (a notice written but not
2463
+ // presented yet among them), for what it was told already.
2464
+ const told = this.subconsciousRows('at boot, so what it was told is read without it').rows;
2465
+ // A hold whose waiting cancel's loss the stop that wrote this stamp kept,
2466
+ // the boot that wrote it then dying before its marker: its row names
2467
+ // that stamp's bound (`lostAtStop`), and accounts for the hold, as the
2468
+ // record did (restoreLossNotices).
2469
+ const kept = new Set(stopKept);
2470
+ for (const m of told) {
2471
+ const meta = m.metadata;
2472
+ const bound = meta?.kind === 'tune-out-note-undelivered' && typeof meta.epochId === 'string' ? stopBoundOf(meta.lostAtStop) : undefined;
2473
+ if (bound && bound.runStartedAt === since && bound.branch === last.stampBranch)
2474
+ kept.add(meta.epochId);
2475
+ }
2476
+ // What it was told of a hold's unrecorded stop already, and when: by
2477
+ // the store's head when the boot that told it decided it
2478
+ // (keptAtSequence), on its row or on a notice kept to be written; a
2479
+ // row with none by its own sequence, a kept notice with none as told
2480
+ // now. A notice written late, its writes held, was told then.
2481
+ const toldBefore = (epochId) => [
2482
+ ...told.flatMap((m) => {
2483
+ const meta = m.metadata;
2484
+ if (meta?.kind !== 'tune-out-note-undelivered' || meta.epochId !== epochId || !meta.stopUnrecorded)
2485
+ return [];
2486
+ const found = meta.stopUnrecorded.found;
2487
+ return found === 'delivered' || found === 'ran' || found === 'asked' || found === 'unknown'
2488
+ ? [{
2489
+ found: found, ...(typeof meta.stopUnrecorded.note === 'string' ? { note: meta.stopUnrecorded.note } : {}),
2490
+ at: typeof meta.stopUnrecorded.keptAtSequence === 'number' ? meta.stopUnrecorded.keptAtSequence : m.sequence,
2491
+ ...(meta.stopUnrecorded.rowsUnread === true ? { rowsUnread: true } : {}),
2492
+ }] : [];
2493
+ }),
2494
+ // Kept to be written, and written by this boot from the store's record
2495
+ // (restoredTold), its window not holding them yet where it is frozen.
2496
+ ...[...(this.lossNotices.get(author) ?? []), ...this.restoredTold].flatMap((n) => (n.drop.reason === 'unstamped-stop' && n.for.epochId === epochId
2497
+ ? [{
2498
+ found: n.drop.found, ...(n.drop.note !== undefined ? { note: n.drop.note } : {}), at: n.keptAtSequence ?? Infinity,
2499
+ ...(n.drop.rowsUnread ? { rowsUnread: true } : {}),
2500
+ }] : [])),
2501
+ ];
2502
+ for (const hold of uncovered) {
2503
+ const { epochId, serverId, channelId, scope } = hold;
2504
+ const what = scope ? `the all-except hold (epoch ${epochId})` : `the tune-out of ${this.shown(serverId, channelId)} (epoch ${epochId})`;
2505
+ if (kept.has(epochId)) {
2506
+ console.error(`[tune-out] the last stop's stamp doesn't cover ${what}, but the stop kept the loss of its waiting note in the store, which is told`);
2507
+ continue;
2508
+ }
2509
+ const asked = this.askedCancelOf(hold, since, asks, onRecord);
2510
+ // With no ask found, a release of the epoch in that run is after the
2511
+ // hold and the run began by construction (an epoch's release follows
2512
+ // its entry, and the rows are that run's), so this bound can't be
2513
+ // told from none by any store; it is kept for the reading's sake.
2514
+ const after = asked ? asked.sequence : Math.max(hold.startedAt ?? -Infinity, since ?? -Infinity);
2515
+ // A release that told a queued note's loss, and a note, count for
2516
+ // this ask only where stored after it, as a release that ran does
2517
+ // (ranAfter): one stored before is an earlier cancel's, its end
2518
+ // unrecorded, and says nothing of the note left with a later ask
2519
+ // (AF #844 review 5, #25).
2520
+ if ((toldInRelease.get(epochId) ?? -Infinity) > after) {
2521
+ console.error(`[tune-out] the last stop did not record the state of ${what}, but a release of it already told ${author} that its waiting note was lost`);
2522
+ continue;
2523
+ }
2524
+ const ranAfter = (releases.get(epochId) ?? []).filter((r) => r.sequence > after);
2525
+ // The subconscious's own cancel: one whose askers name it (a call of
2526
+ // its joined to another's waiting cancel among them), or one its
2527
+ // tool asked for (`subconscious`) under no name. A release with no
2528
+ // askers recorded is one a build before them wrote (unreleased
2529
+ // builds of #844 only), read as its tool's. A reader fork's, under
2530
+ // the fork's name, is another's, as anyone else's is.
2531
+ // So is the run of a queued cancel whose note names it among its
2532
+ // askers (noteRuns): one it joined after that cancel's release was
2533
+ // written, which no release names it in, ran with that release (the
2534
+ // latest before the note), its own (Weft, #844).
2535
+ const noteRun = noteRuns.get(epochId);
2536
+ const ranWithNote = noteRun !== undefined && noteRun.sequence > after && noteRun.askers.includes(author) ? noteRun : undefined;
2537
+ const ownRelease = ranAfter.filter((r) => r.askers.includes(author) || (r.source === 'subconscious' && r.askers.length === 0)).at(-1)
2538
+ ?? (ranWithNote ? ranAfter.filter((r) => r.sequence < ranWithNote.sequence).at(-1) : undefined);
2539
+ const own = ownRelease !== undefined;
2540
+ // Credited to the latest that ran.
2541
+ const by = own ? undefined : ranAfter.at(-1);
2542
+ // Who else its own release credits: the other askers (the note's too,
2543
+ // where it names more), and the cancel it joined where that wasn't the
2544
+ // subconscious's tool's.
2545
+ const others = ownRelease ? [...new Set([...ownRelease.askers, ...(ranWithNote?.askers ?? [])])].filter((a) => a !== author) : [];
2546
+ const joined = ownRelease
2547
+ ? [...others.map((a) => `${a}'s`),
2548
+ ...(ownRelease.source !== 'subconscious' && ownRelease.reason !== undefined ? [`the cancel for ${ownRelease.reason}`] : [])]
2549
+ : [];
2550
+ const noteAt = (notes.get(epochId) ?? -Infinity) > after ? notes.get(epochId) : undefined;
2551
+ const drop = ranAfter.length > 0
2552
+ ? {
2553
+ reason: 'unstamped-stop', found: 'ran', ...(asked ? { asked: true } : {}),
2554
+ ...(asked?.note !== undefined ? { note: asked.note } : {}), ...(noteAt !== undefined ? { noteDelivered: true } : {}),
2555
+ ...(own ? { own: true, ...(joined.length > 0 ? { joined } : {}) } : by?.reason !== undefined ? { ranBy: by.reason } : {}),
2556
+ }
2557
+ : noteAt !== undefined ? { reason: 'unstamped-stop', found: 'delivered' }
2558
+ : asked ? { reason: 'unstamped-stop', found: 'asked', ...(asked.note !== undefined ? { note: asked.note } : {}), ...(rowsRead ? {} : { rowsUnread: true }) }
2559
+ : { reason: 'unstamped-stop', found: 'unknown', ...(rowsRead ? {} : { rowsUnread: true }) };
2560
+ // A note the stop found still kept in memory (notesKept) that these
2561
+ // rows don't hold: a release after the stop may store it yet, or none
2562
+ // did, so it may be lost.
2563
+ if (last.notesKept?.has(epochId) && noteAt === undefined)
2564
+ drop.noteKept = true;
2565
+ const found = drop.found;
2566
+ // What it was told silences this only where it was told at or after
2567
+ // what this reads (the newest ask of the hold, the release, the note):
2568
+ // a notice records the head when it was decided, and the boot read
2569
+ // every row up to it, so one at that head saw this already. A row
2570
+ // older than a later ask never does, so a loss asked again after it
2571
+ // was told is told again; nor does one that couldn't read that run's
2572
+ // rows, where these are read.
2573
+ const evidence = Math.max(asked?.newest ?? -Infinity, ranAfter.at(-1)?.sequence ?? -Infinity, noteAt ?? -Infinity);
2574
+ const said = toldBefore(epochId).filter((t) => t.at >= evidence);
2575
+ if (said.some((t) => FOUND_RANK[t.found] > FOUND_RANK[found]
2576
+ || (t.found === found && t.note === drop.note && (!t.rowsUnread || drop.rowsUnread === true)))) {
2577
+ console.error(`[tune-out] the last stop did not record the state of ${what}, which ${author} was told already, with nothing more to say now`);
2578
+ continue;
2579
+ }
2580
+ console.error(`[tune-out] the last stop did not record the state of ${what}, so ${author} is told `
2581
+ + (found === 'delivered' ? 'that the note it left with a cancel of it was delivered, and its end not recorded'
2582
+ : found === 'ran' ? 'that a cancel of it ran, its release stored and its end not recorded'
2583
+ : found === 'asked' && drop.rowsUnread ? 'that the cancel of it it asked for, answered as waiting, may not have run, that run\'s rows unread'
2584
+ : found === 'asked' ? 'that the cancel of it it asked for, answered as waiting, was lost, with its note'
2585
+ : drop.rowsUnread ? 'that whether a cancel of it then waiting ran isn\'t known, that run\'s rows unread'
2586
+ : 'that a cancel of it then waiting, if it asked for one, was lost, with any note'));
2587
+ try {
2588
+ this.hooks.emitTrace({ type: 'tune-out:stop-unrecorded', serverId, channelId, epochId, ...(scope ? { scope } : {}), noteAuthor: author, found });
2589
+ }
2590
+ catch (err) {
2591
+ console.error(`[tune-out] that the last stop did not record the state of ${what} could not be traced: ${thrownReason(err)}`);
2592
+ }
2593
+ const notice = {
2594
+ id: randomUUID(),
2595
+ for: { epochId, serverId, channelId, ...(scope ? { scope } : {}), current: this.epochHoldingAt(serverId, channelId, scope) },
2596
+ what, drop, keptAtMs: Date.now(), ...(bootHead !== undefined ? { keptAtSequence: bootHead } : {}), persistent: true,
2597
+ };
2598
+ this.lossNotices.set(author, [...(this.lossNotices.get(author) ?? []), notice]);
2599
+ }
2600
+ this.writeLossNotices(author);
2601
+ // Kept, not written (writes held, or the subconscious's context
2602
+ // refusing them), each is kept in the store at once, held writes or
2603
+ // not, as the boot's stamp is: a crash before the writes land then
2604
+ // leaves it for the next boot's replay (restoreLossNotices), whose scan
2605
+ // reads only the run that crashed. Where the store refuses the record
2606
+ // too, it is tried again when writes land (writeLossNotices); a crash
2607
+ // before then loses it, which nothing here can help.
2608
+ this.writeLossRecord('boot');
2609
+ if (this.lossNotices.size > 0)
2610
+ this.armDeferredRetry();
2611
+ }
2612
+ /** The store's branch now (currentBranch), or undefined where the host names none or it can't be read. */
2613
+ branchNow() {
2614
+ try {
2615
+ return this.hooks.currentBranch?.();
2616
+ }
2617
+ catch {
2618
+ return undefined;
2619
+ }
2620
+ }
2621
+ /**
2622
+ * Whether a notice a boot derived from the last stop's stamp
2623
+ * (`unstamped-stop`) is kept in memory only: not written to the
2624
+ * subconscious yet, nor in the store's loss record (lossRecordIds).
2625
+ */
2626
+ stampNoticesUnkept() {
2627
+ for (const kept of this.lossNotices.values()) {
2628
+ for (const n of kept)
2629
+ if (n.persistent && n.drop.reason === 'unstamped-stop' && !this.lossRecordIds.has(n.id))
2630
+ return true;
2631
+ }
2632
+ return false;
2633
+ }
2634
+ /**
2635
+ * Whether the last stop's bound (carriedBound) is still needed: a notice
2636
+ * this boot derived from that stamp is kept in memory only
2637
+ * (stampNoticesUnkept), or the boot's telling of it threw before it
2638
+ * finished (carriedUntold).
2639
+ */
2640
+ carriedNeeded() {
2641
+ return this.carriedUntold || this.stampNoticesUnkept();
2642
+ }
2643
+ /**
2644
+ * Where the next boot reads this run's rows from (StopStamp's
2645
+ * `runStartedAt` and `branch`): this run's start, or, while that stamp's
2646
+ * bound is needed (carriedNeeded: a notice this boot derived from the last
2647
+ * stop's stamp is kept in memory only, or its telling threw), that bound
2648
+ * (carriedBound), so a crash then leaves the next boot reading what this
2649
+ * boot read.
2650
+ */
2651
+ runBound() {
2652
+ const bound = this.carriedBound && this.carriedNeeded()
2653
+ ? this.carriedBound
2654
+ : { runStartedAt: this.runStartedAt, branch: this.runBranch };
2655
+ return {
2656
+ ...(bound.runStartedAt !== undefined ? { runStartedAt: bound.runStartedAt } : {}),
2657
+ ...(bound.branch !== undefined ? { branch: bound.branch } : {}),
2658
+ };
2659
+ }
2660
+ /** This run's marker: a stamp covering no hold, bounded where this run's rows begin (runBound). */
2661
+ runMarker() {
2662
+ return { v: 1, epochs: [], ...this.runBound() };
2663
+ }
2664
+ /**
2665
+ * The store's branch moved under this run (the framework's
2666
+ * followBranchMove), so the snapshot states the coordinator keeps are the
2667
+ * destination's now. Its stamp may cover a hold this run holds, and
2668
+ * bounds the rows by another run's sequence: this run's marker is written
2669
+ * there (writePendingMarker, tried again as at boot where it fails),
2670
+ * naming the destination and its head now as where this run's rows on it
2671
+ * begin (or where they began, on a branch this run was on before: a failed
2672
+ * surgery's restore, say; or this run's start, on a branch made from this
2673
+ * run's past it, which holds those rows: inheritedStart), so that a crash from here on reads only what
2674
+ * this run stored on it. The destination's kept loss notices aren't in memory: the next write
2675
+ * of the record reads them in first (lossRecordUnread), and writes this
2676
+ * run's kept ones there too; where this run keeps any, that write is
2677
+ * made here, before the marker, and the last stop's bound is judged by
2678
+ * the destination's record (carriedNeeded). The subconscious's last
2679
+ * looks are read again too, so the next look reads the destination's
2680
+ * record (lastLooks), unless `sameBranch` says the store switched to the
2681
+ * branch it was on, as a refusing manager's recovery does: no branch
2682
+ * moved, and the cache keeps any look a refused write held only there.
2683
+ * Never throws.
2684
+ */
2685
+ branchMoved(sameBranch = false) {
2686
+ if (this.stopped)
2687
+ return;
2688
+ if (!sameBranch)
2689
+ this.lastLooksCache = undefined;
2690
+ try {
2691
+ const branch = this.branchNow();
2692
+ if (branch !== undefined && this.runStarts.has(branch)) {
2693
+ this.runStartedAt = this.runStarts.get(branch);
2694
+ }
2695
+ else {
2696
+ // A branch made from this run's past its start (a suppression's
2697
+ // fork, at its head) holds this run's rows since then
2698
+ // at their sequences: this run's start stands there (AF #844
2699
+ // review 5, #74). Any other is read from its head.
2700
+ const inherited = this.inheritedStart(branch);
2701
+ if (inherited !== undefined) {
2702
+ this.runStartedAt = inherited;
2703
+ }
2704
+ else {
2705
+ try {
2706
+ this.runStartedAt = this.hooks.currentSequence();
2707
+ }
2708
+ catch {
2709
+ this.runStartedAt = undefined;
2710
+ }
2711
+ }
2712
+ if (branch !== undefined)
2713
+ this.runStarts.set(branch, this.runStartedAt);
2714
+ }
2715
+ this.runBranch = branch;
2716
+ // The destination's record isn't read yet, so a notice only the
2717
+ // record left held is kept in none here (stampNoticesUnkept). It is
2718
+ // written there first, where writes aren't held, so the marker below
2719
+ // lands after it.
2720
+ this.lossRecordUnread = true;
2721
+ this.lossRecordIds = new Set();
2722
+ if (this.persistedLossNotices().length > 0) {
2723
+ this.lossRecordStale = true;
2724
+ this.writeLossRecord();
2725
+ }
2726
+ // Kept while still needed (carriedNeeded), as the boot keeps it: a
2727
+ // failed surgery's restore to the stamp's own branch reads those rows
2728
+ // again, and on another branch the bound reads as elsewhere.
2729
+ if (!this.carriedNeeded())
2730
+ this.carriedBound = undefined;
2731
+ this.markerPending = this.runMarker();
2732
+ this.writePendingMarker();
2733
+ if (this.markerPending)
2734
+ this.armDeferredRetry();
2735
+ }
2736
+ catch (err) {
2737
+ console.error(`[tune-out] the stop stamp and loss record could not be moved to the store's new branch: ${thrownReason(err)}`);
2738
+ }
2739
+ }
2740
+ /**
2741
+ * This run's start (runStartedAt), where `branch`, moved to, was made
2742
+ * from the branch this run was on (runBranch) at a point past that start
2743
+ * (branchOrigin): the branch holds this run's rows since then, at their
2744
+ * sequences. Undefined otherwise, or where the store can't say.
2745
+ */
2746
+ inheritedStart(branch) {
2747
+ if (branch === undefined || this.runBranch === undefined || this.runStartedAt === undefined)
2748
+ return undefined;
2749
+ let origin;
2750
+ try {
2751
+ origin = this.hooks.branchOrigin?.(branch);
2752
+ }
2753
+ catch {
2754
+ return undefined;
2755
+ }
2756
+ // Past the start, not at it: a branch's head only grows, so one made at
2757
+ // a point past this run's start was made during this run; one made at
2758
+ // the start itself may be older, holding another run's rows past it.
2759
+ return origin && origin.parent === this.runBranch && typeof origin.branchPoint === 'number' && origin.branchPoint > this.runStartedAt
2760
+ ? this.runStartedAt : undefined;
2761
+ }
2762
+ /**
2763
+ * This run's stamp (noteUnstampedStop), written; one the store refused is
2764
+ * kept (`markerPending`) and tried again at each release and by the retry
2765
+ * timer, as the loss notices are (runDeferredCancels), never while writes
2766
+ * are held but at boot and at a branch move (branchMoved), until it lands
2767
+ * or the stop writes its own. Never throws.
2768
+ */
2769
+ writePendingMarker() {
2770
+ // The stop's stamp replaces it: none is written over that.
2771
+ if (this.stopped)
2772
+ this.markerPending = undefined;
2773
+ const marker = this.markerPending;
2774
+ if (!marker)
2775
+ return;
2776
+ try {
2777
+ this.channelRegistry.writeCoordinatorState(this.stopStampStateId, marker);
2778
+ if (!this.stateDurable())
2779
+ throw new Error('it was written but could not be made durable');
2780
+ if (this.markerUnwritten)
2781
+ console.error(`[tune-out] this run's stop stamp (${this.stopStampStateId}) is written, at a later try`);
2782
+ this.markerPending = undefined;
2783
+ this.markerUnwritten = false;
2784
+ }
2785
+ catch (err) {
2786
+ if (!this.markerUnwritten) {
2787
+ console.error(`[tune-out] this run's stop stamp (${this.stopStampStateId}) could not be written, so if the host dies before its next `
2788
+ + `stop, the stamp the last stop left may cover the holds it names; it is tried again at the next release or `
2789
+ + `${EXPIRY_RETRY_MS / 1000} s later: ${thrownReason(err)}`);
2790
+ }
2791
+ this.markerUnwritten = true;
2792
+ }
2793
+ }
2794
+ /**
2795
+ * For a hold the last stop didn't stamp (noteUnstampedStop): the persistent
2796
+ * subconscious's own `cancel_tuneout` of it (`asks`, cancelAsksIn),
2797
+ * answered as waiting, since the hold began and since the last run began
2798
+ * (`since`). One names the hold where its result says the all-except hold
2799
+ * (`scope`, the call naming that scope or a channel only that hold held),
2800
+ * for that hold; else, for a channel's, where it names the channel (its
2801
+ * id, its shown name, or its server-qualified name) and its server: the
2802
+ * result's (`serverId`), the call's, or the qualified name's. A bare name
2803
+ * with no server that two holds on record answer to names neither. Answers
2804
+ * the first such call's sequence, and the first line, at most
2805
+ * NOTE_QUOTE_CHARS characters, of the first such call's note (a cancel
2806
+ * waiting with none takes a later call's note; a later note is dropped,
2807
+ * and its caller told so). Undefined where none is found.
2808
+ */
2809
+ askedCancelOf(hold, since, asks, onRecord) {
2810
+ const after = Math.max(hold.startedAt ?? -Infinity, since ?? -Infinity);
2811
+ const namesOf = (h) => {
2812
+ let shown;
2813
+ try {
2814
+ shown = this.shown(h.serverId, h.channelId);
2815
+ }
2816
+ catch {
2817
+ shown = h.channelId;
2818
+ }
2819
+ return [h.channelId, shown, qualifiedChannelName({ serverId: h.serverId, channelId: h.channelId })];
2820
+ };
2821
+ const names = (ask, h) => {
2822
+ const toScope = ask.scope === 'all-except' || ask.input.scope === 'all-except';
2823
+ if (h.scope === 'all-except')
2824
+ return toScope;
2825
+ if (toScope || typeof ask.input.channelId !== 'string')
2826
+ return false;
2827
+ const name = ask.input.channelId;
2828
+ if (!namesOf(h).includes(name))
2829
+ return false;
2830
+ // The qualified name's server: a name that is this hold's qualified
2831
+ // one names its server, which is read here. Without it, the bare-name
2832
+ // check below can reach another answer: another server's channel can
2833
+ // have that name as its id (`editor:noisy` on server `other`), and
2834
+ // then two holds answer to the name, so the ask would count for
2835
+ // neither.
2836
+ const server = ask.serverId
2837
+ ?? (typeof ask.input.serverId === 'string' && ask.input.serverId ? ask.input.serverId : undefined)
2838
+ ?? (name === qualifiedChannelName({ serverId: h.serverId, channelId: h.channelId }) ? h.serverId : undefined);
2839
+ if (server !== undefined)
2840
+ return server === h.serverId;
2841
+ return !onRecord.some((other) => other !== h && !other.scope && namesOf(other).includes(name));
2842
+ };
2843
+ const mine = asks.filter((ask) => ask.waiting && ask.sequence > after && names(ask, hold));
2844
+ if (mine.length === 0)
2845
+ return undefined;
2846
+ const withNote = mine.find((ask) => typeof ask.input.text === 'string' && ask.input.text.trim() !== '');
2847
+ const newest = mine.at(-1).sequence;
2848
+ if (!withNote)
2849
+ return { sequence: mine[0].sequence, newest };
2850
+ const first = withNote.input.text.trim().split(/\r?\n/)[0].trim();
2851
+ return { sequence: mine[0].sequence, newest, note: first.length > NOTE_QUOTE_CHARS ? `${cutToFit(first, NOTE_QUOTE_CHARS - 1)}…` : first };
2852
+ }
2853
+ /**
2854
+ * At boot (resumeActiveEpochs), before this boot writes any notice: a
2855
+ * loss told to the persistent subconscious before the stop, by a notice
2856
+ * (`tune-out-note-undelivered`) or in a release (`lostNote`,
2857
+ * queuedNoteFirst), asked for a turn to read it, which was kept in memory
2858
+ * only. Shown to it at rest, such a row is in its window, so the boot's
2859
+ * wake for rows its window is still owed doesn't count it; one with no
2860
+ * turn of the subconscious's after it in its window (unansweredIn) gets
2861
+ * that turn asked for again, once per hold, for the reason the notice
2862
+ * asked it for. Never throws.
2863
+ */
2864
+ askTurnsForUnreadLosses() {
2865
+ try {
2866
+ const author = this.hooks.subconsciousName();
2867
+ if (!author || !this.hooks.subconsciousWindow)
2868
+ return;
2869
+ const unread = unansweredIn(this.hooks.subconsciousWindow(), author, (m) => {
2870
+ const meta = m.metadata;
2871
+ return meta?.kind === 'tune-out-note-undelivered' || (meta?.kind === 'tune-out-cancel' && meta.lostNote === true);
2872
+ });
2873
+ const asked = new Set();
2874
+ for (const m of unread) {
2875
+ const meta = m.metadata;
2876
+ const where = meta?.scope === 'all-except' ? 'all-except'
2877
+ : typeof meta?.channelId === 'string' ? this.shown(typeof meta.serverId === 'string' ? meta.serverId : '', meta.channelId) : 'a tune-out';
2878
+ const reason = meta?.stopUnrecorded ? `tune-out stop not recorded: ${where}` : `tune-out note not delivered: ${where}`;
2879
+ if (asked.has(reason))
2880
+ continue;
2881
+ asked.add(reason);
2882
+ this.hooks.requestInference(author, reason, 'tune-out');
2883
+ }
2884
+ if (asked.size > 0) {
2885
+ console.error(`[tune-out] ${author} was told of a lost note before the stop and has had no turn since, so a turn to read it is asked for again (${[...asked].join(', ')})`);
2886
+ }
2887
+ }
2888
+ catch (err) {
2889
+ console.error(`[tune-out] the losses told to the subconscious before the stop could not be checked for a turn at boot: ${thrownReason(err)}`);
2890
+ }
2891
+ }
2892
+ /**
2893
+ * At boot (resumeActiveEpochs): the loss notices a stop left kept in the
2894
+ * store for the persistent subconscious (writeLossRecord) are written now,
2895
+ * as tellNoteLost writes them, each with a turn to read it
2896
+ * (writeLossNotice); one written leaves the record, and one whose write
2897
+ * fails stays, kept and tried again at the next release and at the retry,
2898
+ * which this arms. A notice whose author is no agent the framework writes
2899
+ * to now reaches no one, which is traced (`author-gone`), and leaves the
2900
+ * record. One whose row the subconscious's rows hold already
2901
+ * (`lossNoticeId`: written, its record not brought in line before the
2902
+ * stop) leaves the record, not written again; where those rows can't be
2903
+ * read, none is written, kept in the record, until a retry reads them
2904
+ * (checkLossNoticesWritten). The notices of an unrecorded stop written
2905
+ * here count as told (restoredTold). Never throws.
2906
+ */
2907
+ restoreLossNotices() {
2908
+ // The holds whose waiting cancel's loss the last stop kept (`stopped`),
2909
+ // which accounts for them (noteUnstampedStop).
2910
+ const stopKept = new Set();
2911
+ try {
2912
+ // One that can't be read is read again by the retry, which this arms:
2913
+ // nothing else may read it before the stop (AF #844 review 5, #39).
2914
+ if (!this.readLossRecord()) {
2915
+ this.armDeferredRetry();
2916
+ return stopKept;
2917
+ }
2918
+ if (this.lossNotices.size === 0)
2919
+ return stopKept;
2920
+ for (const kept of this.lossNotices.values()) {
2921
+ for (const notice of kept)
2922
+ if (notice.persistent && notice.drop.reason === 'stopped')
2923
+ stopKept.add(notice.for.epochId);
2924
+ }
2925
+ // One written already, its record not brought in line after (a stop,
2926
+ // or a record the store refused, between the two), is in the
2927
+ // subconscious's rows: it leaves the record, not written again; none
2928
+ // is written while those rows can't be read (checkLossNoticesWritten).
2929
+ for (const kept of this.lossNotices.values())
2930
+ for (const notice of kept)
2931
+ this.lossNoticesUnchecked.add(notice.id);
2932
+ this.checkLossNoticesWritten();
2933
+ const unstamped = [...this.lossNotices.values()].flat().filter((n) => n.drop.reason === 'unstamped-stop');
2934
+ const count = [...this.lossNotices.values()].reduce((n, kept) => n + kept.length, 0);
2935
+ if (count > 0)
2936
+ console.error(`[tune-out] ${count} loss notice(s) kept in the store for the subconscious before the last stop ${count === 1 ? 'is' : 'are'} tried again now`);
2937
+ this.writeLossNotices();
2938
+ const still = new Set([...this.lossNotices.values()].flat());
2939
+ this.restoredTold = unstamped.filter((n) => !still.has(n));
2940
+ if (this.lossNotices.size > 0)
2941
+ this.armDeferredRetry();
2942
+ }
2943
+ catch (err) {
2944
+ console.error(`[tune-out] the loss notices kept in the store for the subconscious could not be written at boot: ${thrownReason(err)}`);
2945
+ }
2946
+ return stopKept;
2947
+ }
2948
+ /**
2949
+ * One loss notice (LossNotice) written into its author's context: a
2950
+ * host-framed notice, as the coordinator's other notices to a reader are,
2951
+ * and for the persistent subconscious a turn to read it, as they ask: the
2952
+ * loss can come with no turn of its (a release, a retry, a cancel it
2953
+ * didn't make), and one asked for with a cancel's own notice is the same
2954
+ * turn. A reader fork still alive is in its run, which takes the notice
2955
+ * at its next point. Answers what became of it. `failed`: its write
2956
+ * threw, or its words couldn't be put together; it is logged, traced at
2957
+ * its first failure (`tune-out:note-loss-untold`, `write-failed`), and
2958
+ * kept. `gone`: the framework wrote nothing, answering an empty id, as it
2959
+ * does for a write for an agent it has no registration of, a reader fork
2960
+ * whose run has ended among them: the loss reaches no one, which is
2961
+ * logged and traced (`author-gone`). It is written only while writes
2962
+ * aren't held (writeLossNotices), so an empty id is no write kept in
2963
+ * memory, unless this write began such a hold (a context manager that
2964
+ * refuses its work from it on), which writesHeld then says: memory alone
2965
+ * holds it then, which a crash or a stop loses, so it answers `failed`,
2966
+ * kept in the store's record and written again once writes are stored,
2967
+ * unless the kept write is stored then: the framework says so
2968
+ * (heldWriteStored), and it leaves the notices kept, written once. A stop
2969
+ * before then keeps it in the store, and a boot that finds its row
2970
+ * (`lossNoticeId`) leaves the record (restoreLossNotices). writesHeld is asked about
2971
+ * the author too, so a reader fork still registered whose own context
2972
+ * manager refuses its work is told apart from one whose run has ended:
2973
+ * the notice stays kept for it, and isn't written while that lasts
2974
+ * (writeLossNotices). A turn that can't be asked for is logged. Never
2975
+ * throws.
2976
+ */
2977
+ writeLossNotice(author, notice) {
2978
+ const { serverId, channelId, epochId, scope } = notice.for;
2979
+ const { drop } = notice;
2980
+ let shown;
2981
+ let id;
2982
+ try {
2983
+ shown = this.shown(serverId, channelId);
2984
+ const hold = scope === 'all-except' ? `the all-except hold (all channels except ${shown})` : shown;
2985
+ const resident = this.hooks.primaryName();
2986
+ const it = scope ? 'hold' : 'tune-out';
2987
+ // Whether the hold is still in force when the notice is written, the
2988
+ // next boot's or a later retry's: unknown where that can't be read.
2989
+ const nowInForce = () => {
2990
+ let still;
2991
+ try {
2992
+ still = notice.for.current() === epochId;
2993
+ }
2994
+ catch {
2995
+ still = undefined;
2996
+ }
2997
+ return still === true ? ` That ${it} is still in force.` : still === false ? ` That ${it} has ended since.` : '';
2998
+ };
2999
+ const to = resident ? suppliedValue(resident) : 'the resident';
3000
+ let why = '';
3001
+ // The note's store couldn't tell whether it landed (`noteUnknown`):
3002
+ // the cancel's loss is told as it is, the note's as not known, with
3003
+ // the error, as a note-undelivered drop's `mayBeStored` says it.
3004
+ const noteUnknown = drop.reason === 'hold-ended' || drop.reason === 'stopped' ? drop.noteUnknown : undefined;
3005
+ const withNote = noteUnknown === undefined ? ', and the note with it.' : '.';
3006
+ const notKnown = (error) => {
3007
+ const reason = suppliedValue(error);
3008
+ return ` Whether the note was delivered to ${to} isn't known here, as what the store holds of it can't be read here: `
3009
+ + `${reason}${/[.!?]$/.test(reason) ? '' : '.'}`;
3010
+ };
3011
+ if (drop.reason === 'hold-ended') {
3012
+ why = `That ${it} ended without your cancel while it waited, so your cancel was dropped${withNote}`
3013
+ + (noteUnknown !== undefined ? notKnown(noteUnknown) : '');
3014
+ if (drop.current !== undefined) {
3015
+ // The later hold as it is when the notice is written, which a
3016
+ // kept notice is after the drop: whether it still holds, unknown
3017
+ // where that can't be read.
3018
+ let still;
3019
+ try {
3020
+ still = notice.for.current() === drop.current;
3021
+ }
3022
+ catch {
3023
+ still = undefined;
3024
+ }
3025
+ const later = scope ? 'A later all-except hold' : `A later tune-out of ${shown}`;
3026
+ why += still
3027
+ ? ` ${later} is in force now, which your cancel was not for.`
3028
+ : ` ${later}, which your cancel was not for, was in force when your cancel was dropped`
3029
+ + `${still === false ? ', and has ended since' : ''}.`;
3030
+ }
3031
+ }
3032
+ else if (drop.reason === 'stopped') {
3033
+ why = `The host stopped while your cancel waited, so your cancel was lost${withNote}`
3034
+ + `${noteUnknown !== undefined ? notKnown(noteUnknown) : ''}${nowInForce()}`;
3035
+ }
3036
+ else if (drop.reason === 'superseded') {
3037
+ why = `An earlier cancel of that ${it} was running when you asked for yours, and yours joined it, with a note of its own that stands, so your note was dropped. That cancel's note is to go out when it ends the ${it}; where it can't be delivered, its author is told, if it still runs.`;
3038
+ }
3039
+ else if (drop.reason === 'unstamped-stop') {
3040
+ // Written whole below.
3041
+ }
3042
+ else {
3043
+ const reason = suppliedValue(drop.error);
3044
+ // A thrown error's words may end without a stop. A note whose store
3045
+ // couldn't tell whether it landed (mayBeStored) is one that may be
3046
+ // lost, said as a boot says what it can't read (noteUnstampedStop's
3047
+ // `rowsUnread`).
3048
+ why = `${drop.by === 'another' ? `Another cancel ended that ${it} while yours waited` : `Your cancel ended that ${it} when it ran`}, `
3049
+ + (drop.mayBeStored
3050
+ ? `and whether the note was delivered to ${to} isn't known here, as what the store holds of it can't be read here: `
3051
+ : 'and delivering the note then failed: ')
3052
+ + `${reason}${/[.!?]$/.test(reason) ? '' : '.'}`;
3053
+ }
3054
+ let text;
3055
+ if (drop.reason === 'unstamped-stop') {
3056
+ const unrecorded = `[${NOTICE_TAG.tuneOut}: the host stopped without recording the state of `
3057
+ + `${scope ? `the all-except hold (all channels except ${shown}; epoch ${epochId})` : `the tune-out of ${shown} (epoch ${epochId})`}. `;
3058
+ let still;
3059
+ try {
3060
+ still = notice.for.current() === epochId;
3061
+ }
3062
+ catch {
3063
+ still = undefined;
3064
+ }
3065
+ const notRecorded = (whose) => `${whose} was not recorded`
3066
+ + (still === true ? `, so that ${it} is still in force.` : still === false ? `, and that ${it} has ended since.` : '.');
3067
+ // A note the stop found kept in memory, not in that run's rows
3068
+ // (noteKept): it may be lost, and nothing below says it was.
3069
+ const kept = drop.noteKept && !drop.noteDelivered
3070
+ ? ` The note you left with your cancel of it was kept in memory when the host stopped, to be stored when an operator's change to the store `
3071
+ + "ended, and the store didn't hold it when this was written: it may be lost."
3072
+ : '';
3073
+ if (drop.found === 'delivered') {
3074
+ // The note's row is stored, no release since, the end of its cancel's
3075
+ // hold not. The rows prove it is stored at this start, not that it
3076
+ // was before the stop: a note kept in memory then can land after
3077
+ // it, at a release in the same process (Oscar, #844).
3078
+ text = `${unrecorded}The note you left with your cancel of it was delivered to ${to} before this start. ${notRecorded("The cancel's end")}]`;
3079
+ }
3080
+ else if (drop.found === 'ran' && drop.own) {
3081
+ // Its own cancel stored the release since it asked, the end not.
3082
+ const who = drop.asked ? 'The cancel of it you asked for' : 'Your cancel of it';
3083
+ const joined = drop.joined && drop.joined.length > 0 ? ` (with ${drop.joined.map((j) => suppliedValue(j)).join(' and ')})` : '';
3084
+ const note = drop.noteDelivered ? ', with the note you left with it'
3085
+ : drop.note !== undefined && !drop.noteKept ? `, but the note you left with it, ${quotedValue(drop.note)}, did not` : '';
3086
+ text = `${unrecorded}${who} ran${joined}: its release reached ${to}${note}. ${notRecorded('Its end')}${kept}]`;
3087
+ }
3088
+ else if (drop.found === 'ran') {
3089
+ // Another's cancel stored the release, the end not: the
3090
+ // subconscious's own, asked for, had not run. A note of its found
3091
+ // went out with that release (queuedNoteFirst writes it first).
3092
+ const by = drop.ranBy !== undefined ? ` that wasn't yours (${suppliedValue(drop.ranBy)})` : '';
3093
+ text = `${unrecorded}A cancel of it${by} ran: its release reached ${to}`
3094
+ + `${drop.noteDelivered ? ', with the note you left with your cancel of it' : ''}. ${notRecorded('Its end')}`
3095
+ + (drop.asked
3096
+ ? ` The cancel of it you asked for had not run when the host stopped: it was lost`
3097
+ + `${!drop.noteDelivered && !drop.noteKept && drop.note !== undefined ? `, and the note you left with it, ${quotedValue(drop.note)}` : ''}.`
3098
+ : '')
3099
+ + `${kept}]`;
3100
+ }
3101
+ else if (drop.found === 'asked' && drop.rowsUnread) {
3102
+ // Its own call, answered as waiting, and no rows of that run to say whether it ran.
3103
+ text = `${unrecorded}The cancel of it you asked for ${drop.note !== undefined ? `with your note ${quotedValue(drop.note)}` : 'with no note'} `
3104
+ + `was answered as waiting; whether it ran before the host stopped isn't known here, as what that run stored can't be read here: `
3105
+ + `its end was not recorded${drop.note !== undefined ? ', and the note you left with it may be lost' : ''}.${nowInForce()}]`;
3106
+ }
3107
+ else if (drop.found === 'asked') {
3108
+ // Its own call, answered as waiting, read from its window.
3109
+ text = `${unrecorded}The cancel of it you asked for ${drop.note !== undefined ? `with your note ${quotedValue(drop.note)}` : 'with no note'} `
3110
+ + `had not run when the host stopped: it was lost${drop.note !== undefined && !drop.noteKept ? ', and the note you left with it' : ''}.${nowInForce()}${kept}]`;
3111
+ }
3112
+ else if (drop.rowsUnread) {
3113
+ text = `${unrecorded}If you asked for a cancel of it before the stop, whether it ran isn't known here, as what that run stored can't be read here: `
3114
+ + `its end was not recorded, and any note you left with it may be lost.${nowInForce()}]`;
3115
+ }
3116
+ else {
3117
+ text = `${unrecorded}If you asked for a cancel of it before the stop, it had not run when the host stopped: it was lost`
3118
+ + `${drop.noteKept ? '' : ', and any note you left with it'}.${nowInForce()}${kept}]`;
3119
+ }
3120
+ }
3121
+ else if ((drop.reason === 'note-undelivered' && drop.mayBeStored) || noteUnknown !== undefined) {
3122
+ text = `[${NOTICE_TAG.tuneOut}: the note you left with your cancel of ${hold} may be lost. ${why}]`;
3123
+ }
3124
+ else {
3125
+ text = `[${NOTICE_TAG.tuneOut}: the note you left with your cancel of ${hold} was not delivered to ${to}. ${why}]`;
3126
+ }
3127
+ id = this.hooks.addMessage('user', [{ type: 'text', text }], {
3128
+ system: true, kind: 'tune-out-note-undelivered', channelId, epochId, ...(scope ? { scope } : {}),
3129
+ // Which notice it is, so a boot that finds its record not brought
3130
+ // in line doesn't write it again (restoreLossNotices); and, for a
3131
+ // loss the stop kept, the bound of the stamp that stop wrote, so a
3132
+ // later boot reading that stamp finds the hold accounted for.
3133
+ lossNoticeId: notice.id,
3134
+ ...(drop.reason === 'stopped' && notice.stopBound ? { lostAtStop: notice.stopBound } : {}),
3135
+ // What an unrecorded stop's notice told, for a later boot (noteUnstampedStop).
3136
+ // When the boot decided it (keptAtSequence), which a later boot
3137
+ // reads as when it was told, though it was written later.
3138
+ ...(drop.reason === 'unstamped-stop' ? { stopUnrecorded: {
3139
+ found: drop.found, ...(drop.note !== undefined ? { note: drop.note } : {}),
3140
+ ...(notice.keptAtSequence !== undefined ? { keptAtSequence: notice.keptAtSequence } : {}),
3141
+ ...(drop.rowsUnread ? { rowsUnread: true } : {}),
3142
+ } } : {}),
3143
+ }, author);
3144
+ }
3145
+ catch (err) {
3146
+ const error = thrownReason(err);
3147
+ console.error(`[tune-out] ${author} could not be told ${lossSubject(notice)}, `
3148
+ + `so the notice is kept and tried again at the next release or ${EXPIRY_RETRY_MS / 1000} s later: ${error}`);
3149
+ if (!notice.traced) {
3150
+ notice.traced = true;
3151
+ try {
3152
+ this.hooks.emitTrace({
3153
+ type: 'tune-out:note-loss-untold', serverId, channelId, epochId, ...(scope ? { scope } : {}),
3154
+ noteAuthor: author, reason: 'write-failed', error,
3155
+ });
3156
+ }
3157
+ catch (traceErr) {
3158
+ console.error(`[tune-out] the failure to tell ${author} of its lost note could not be traced: ${thrownReason(traceErr)}`);
3159
+ }
3160
+ }
3161
+ return 'failed';
3162
+ }
3163
+ if (id === '' && this.writesHeldNow(author)) {
3164
+ console.error(`[tune-out] ${author} could not be told ${lossSubject(notice)} yet: the notice was kept in memory as it was written `
3165
+ + '(writes are held from it on), so it stays kept, in the store too, and is written again once writes are stored');
3166
+ return 'failed';
3167
+ }
3168
+ if (id === '') {
3169
+ console.error(`[tune-out] ${author} is no agent the framework writes to any more, so the notice ${lossSubject(notice)} reaches no one`);
3170
+ try {
3171
+ this.hooks.emitTrace({
3172
+ type: 'tune-out:note-loss-untold', serverId, channelId, epochId, ...(scope ? { scope } : {}),
3173
+ noteAuthor: author, reason: 'author-gone',
3174
+ });
3175
+ }
3176
+ catch (traceErr) {
3177
+ console.error(`[tune-out] that the loss of ${author}'s note reaches no one could not be traced: ${thrownReason(traceErr)}`);
3178
+ }
3179
+ return 'gone';
3180
+ }
3181
+ if (notice.traced) {
3182
+ console.error(`[tune-out] ${author} is told, at a later try, ${lossSubject(notice)}`);
3183
+ }
3184
+ let subName;
3185
+ try {
3186
+ subName = this.hooks.subconsciousName();
3187
+ }
3188
+ catch {
3189
+ subName = null;
3190
+ }
3191
+ if (subName === null || subName !== author)
3192
+ return 'written';
3193
+ try {
3194
+ const where = scope ? 'all-except' : shown;
3195
+ this.hooks.requestInference(author, drop.reason === 'unstamped-stop' ? `tune-out stop not recorded: ${where}` : `tune-out note not delivered: ${where}`, 'tune-out');
3196
+ }
3197
+ catch (err) {
3198
+ console.error(`[tune-out] ${author} was told ${lossSubject(notice)}, but a turn to read it could not be asked for: ${thrownReason(err)}`);
3199
+ }
3200
+ return 'written';
3201
+ }
3202
+ /**
3203
+ * The queued cancel of this hold (`epochId`, deferCancel), off the queue:
3204
+ * a cancel that ends the hold first leaves it nothing to end, and
3205
+ * delivers its note: the persistent subconscious's before its release
3206
+ * (queuedNoteFirst), a reader fork's after the end (deliverQueuedNote). One queued for an earlier epoch
3207
+ * there is dropped, told (dropDeferred). The retry timer goes with the
3208
+ * last one, unless a loss notice waits for it (lossNotices).
3209
+ */
3210
+ takeDeferred(key, epochId) {
3211
+ const entry = this.deferredCancels.get(key);
3212
+ if (!entry)
3213
+ return undefined;
3214
+ this.deferredCancels.delete(key);
3215
+ if (this.deferredCancels.size === 0 && this.lossNotices.size === 0 && !this.markerPending && !this.lossRecordUnread && this.deferredRetry !== undefined) {
3216
+ clearTimeout(this.deferredRetry);
3217
+ this.deferredRetry = undefined;
3218
+ }
3219
+ if (entry.for.epochId === epochId)
3220
+ return entry;
3221
+ this.dropQueued(entry, { reason: 'hold-ended' });
3222
+ return undefined;
3223
+ }
3224
+ /**
3225
+ * A reader's note to the resident, verbatim: the persistent subconscious's
3226
+ * under its own name, a reader fork's (`author`) as attributed mail under
3227
+ * the fork's name and incarnation, never the resident's voice. `metadata`
3228
+ * is the subconscious's row's; a fork's mail has it without the server.
3229
+ * Answers how it went, from what the write itself answered: `stored`, or
3230
+ * `kept`, where the framework kept the write in memory (an empty id: a
3231
+ * surgery's hold, or a context manager refusing its work), to store it at
3232
+ * their release; false where neither is configured to deliver it. A
3233
+ * write stored, and writes held only after it, is `stored`: whether
3234
+ * writes are held now says nothing of a write already made (Weft, #844).
3235
+ * A fork's mail, which the registry keeps until it is delivered, or a
3236
+ * write whose answer isn't known, counts as `stored`.
3237
+ */
3238
+ deliverReaderNote(note, author, metadata) {
3239
+ const subName = this.hooks.subconsciousName();
3240
+ if (subName) {
3241
+ const id = this.hooks.addMessage(subName, [{ type: 'text', text: note }], { ...metadata });
3242
+ return this.writeKept(id) ? 'kept' : 'stored';
3243
+ }
3244
+ if (author && this.hooks.deliverFromReader) {
3245
+ const { serverId: _server, ...mail } = metadata;
3246
+ const id = this.hooks.deliverFromReader(author, [{ type: 'text', text: note }], mail);
3247
+ return this.writeKept(id) ? 'kept' : 'stored';
3248
+ }
3249
+ return false;
3250
+ }
3251
+ /**
3252
+ * The note a queued cancel carries, delivered once its hold has ended, as
3253
+ * a cancel's release delivers its caller's (deliverReaderNote): by
3254
+ * another cancel that ended the hold first (takeDeferred, `by` another),
3255
+ * a reader fork's, the subconscious's going out before that cancel's
3256
+ * release instead (queuedNoteFirst), or by the queued cancel's own run
3257
+ * (`by` itself). One that can't be is told lost, with the reason, on
3258
+ * stderr, as a trace and to its author where it still exists
3259
+ * (dropDeferred), and the cancel that ended the hold goes on: no caller
3260
+ * waits on it to be told. One settled already, delivered or told lost
3261
+ * (an earlier end whose release then failed), isn't delivered again.
3262
+ * Answers whether it was delivered now.
3263
+ */
3264
+ deliverQueuedNote(entry, metadata, by) {
3265
+ const now = this.noteNow(entry, metadata, by);
3266
+ if (now?.lost)
3267
+ this.dropDeferred(entry, now.lost);
3268
+ return now?.delivered === true;
3269
+ }
3270
+ /**
3271
+ * A queued cancel's note (deliverReaderNote), delivered now and settled,
3272
+ * or, where it can't be, why (`lost`, for the caller to tell); undefined
3273
+ * where it has none, or one settled already. One delivered while writes
3274
+ * were held, and lost when they were stored (lostAtRelease), is lost with
3275
+ * that error, `by` this caller, and isn't written again.
3276
+ */
3277
+ noteNow(entry, metadata, by) {
3278
+ if (entry.note === undefined || entry.noteSettled)
3279
+ return undefined;
3280
+ if (entry.lostAtRelease)
3281
+ return { lost: { ...entry.lostAtRelease, by } };
3282
+ let delivered;
3283
+ try {
3284
+ delivered = this.deliverReaderNote(entry.note, entry.noteAuthor, metadata);
3285
+ }
3286
+ catch (err) {
3287
+ return { lost: { reason: 'note-undelivered', by, error: thrownReason(err) } };
3288
+ }
3289
+ if (!delivered) {
3290
+ return { lost: {
3291
+ reason: 'note-undelivered', by,
3292
+ error: 'there is no subconscious here, and no delivery of a reader fork\'s note for its author',
3293
+ } };
3294
+ }
3295
+ entry.noteSettled = true;
3296
+ return { delivered: true, kept: delivered === 'kept' };
383
3297
  }
384
- // ==========================================================================
385
- // Enter / cancel
386
- // ==========================================================================
387
3298
  /**
388
- * The options a tune-out call gives, read as numbers before anything
389
- * changes (F76). A tool's input reaches here as the model wrote it, and
390
- * the store can't keep a NaN or an Infinity: its append would throw after
391
- * the registry had marked the channel tuned out. So each option is read
392
- * as a number first, a numeric string included, as the sleep tool reads
393
- * its seconds; null counts as left out. A value that gives no finite
394
- * number is refused, naming the option and, where it can be left out,
395
- * what leaving it out means; so is a duration whose end time is too far
396
- * off to store. `now` is the clock the end time was counted from. One
397
- * rule for a channel's tune-out and the all-except hold.
3299
+ * Before a cancel writes its release, the note of a cancel of this hold
3300
+ * still queued (deferCancel; its own release failed since, say) for the
3301
+ * persistent subconscious: delivered now, ahead of the release, where a
3302
+ * reader fork's goes out after the end (deliverQueuedNote), so that the
3303
+ * release can say so where it can't be (lossInRelease). The loss is then
3304
+ * stored with the release and made durable with it, or not at all: the
3305
+ * hold doesn't end without the release, which throws, the hold staying in
3306
+ * force and the queued cancel waiting on with its note, not lost. A
3307
+ * notice to the subconscious and the store's record of it are writes of
3308
+ * their own, which can be refused while the release is stored, leaving
3309
+ * nothing of the loss after a stop; the subconscious's window holds the
3310
+ * residents' rows, the release among them, so no notice is written too.
3311
+ * Answers the queued cancel and, where its note couldn't be delivered,
3312
+ * why; undefined where there is no such note: none queued, one for
3313
+ * another epoch (takeDeferred drops it), one with no note or one settled
3314
+ * already, or no subconscious (reader forks read); nor one written while
3315
+ * writes were held and lost when they were stored (lostAtRelease), which
3316
+ * isn't written again: it is told lost after the end instead, as a reader
3317
+ * fork's is (deliverQueuedNote).
398
3318
  */
399
- readOptions(opts, leftOut) {
400
- const given = {};
401
- for (const name of Object.keys(leftOut)) {
402
- const value = opts[name];
403
- if (value == null)
404
- continue;
405
- const n = Number(value);
406
- if (!Number.isFinite(n)) {
407
- const got = typeof value === 'number'
408
- ? String(value)
409
- : `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`;
410
- return {
411
- ok: false,
412
- error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
413
- + `and it was ${got}.${leftOut[name] ? ` Leave it out ${leftOut[name]}.` : ''}`,
414
- };
3319
+ queuedNoteFirst(key, epochId, metadata) {
3320
+ const entry = this.deferredCancels.get(key);
3321
+ if (!entry || entry.for.epochId !== epochId || entry.note === undefined || entry.noteSettled || entry.lostAtRelease)
3322
+ return undefined;
3323
+ try {
3324
+ if (!this.hooks.subconsciousName())
3325
+ return undefined;
3326
+ }
3327
+ catch {
3328
+ return undefined;
3329
+ }
3330
+ try {
3331
+ const delivered = this.deliverReaderNote(entry.note, entry.noteAuthor, metadata);
3332
+ if (delivered) {
3333
+ entry.noteSettled = true;
3334
+ // Kept in memory as it was written, by what the write answered (a
3335
+ // hold begun only after it stores nothing of it): the queued
3336
+ // cancel's run tells it lost where storing it fails when the writes
3337
+ // are stored (heldWriteLost).
3338
+ if (delivered === 'kept')
3339
+ entry.heldInMemory = true;
3340
+ return { entry };
415
3341
  }
416
- given[name] = n;
3342
+ return { entry, lost: { reason: 'note-undelivered', by: 'another', error: 'there is no subconscious here to deliver it' } };
417
3343
  }
418
- const now = Date.now();
419
- const expiresAtMs = given.durationSeconds === undefined
420
- ? undefined
421
- : now + Math.max(60, given.durationSeconds) * 1000;
422
- if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
423
- return {
424
- ok: false,
425
- error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept.`
426
- + (leftOut.durationSeconds ? ` Leave it out ${leftOut.durationSeconds}.` : ''),
427
- };
3344
+ catch (err) {
3345
+ return { entry, lost: { reason: 'note-undelivered', by: 'another', error: thrownReason(err) } };
428
3346
  }
429
- return { ok: true, given, now, expiresAtMs };
430
3347
  }
431
- enter(serverId, channelId, opts, source) {
432
- // No reader (issue #232 §3.3): a silent hold, where the host opted into
433
- // one. Nothing summarizes, nothing wakes, mentions are still
434
- // acknowledged, the backlog is released on cancel; the resident is told
435
- // so in the tool result. A host that didn't opt in has no tune_out
436
- // without a reader.
437
- if (!this.readerAvailable() && this.hooks.holdWithoutReader?.() !== true) {
438
- return {
439
- ok: false,
440
- error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out '
441
- + "isn't available here, and calling it again won't change that. If you need channels diverted, ask "
442
- + 'whoever runs this host.',
443
- };
3348
+ /**
3349
+ * The words a cancel's release adds for a queued cancel's note it
3350
+ * couldn't deliver (queuedNoteFirst): whose note it was, the hold
3351
+ * (`hold`, a `tune-out` or a `hold`), the resident it was for, and why,
3352
+ * as a notice to the note's author says them (writeLossNotice). The
3353
+ * residents and the subconscious read them there.
3354
+ */
3355
+ lossInRelease(entry, error, hold, it) {
3356
+ const author = this.noteAuthorOf(entry);
3357
+ let resident;
3358
+ try {
3359
+ resident = this.hooks.primaryName();
444
3360
  }
445
- if (this.hooks.isForkBound({ serverId, channelId })) {
446
- return {
447
- ok: false,
448
- error: `Nothing changed: a conversation is bound to ${this.quotedShown(serverId, channelId)}, so its messages go to that `
449
- + "conversation, and tune_out won't divert a channel while one is. Calling tune_out on it again won't change "
450
- + 'that until the conversation ends, which happens only once the channel has been quiet for a while. Tune '
451
- + 'out a different channel, or ask whoever runs this host if this one should be diverted.',
452
- };
3361
+ catch {
3362
+ resident = null;
453
3363
  }
454
- const current = this.channelRegistry.getTuneOutState(serverId, channelId);
455
- if (current)
456
- return { ok: false, error: alreadyTunedOutText(this.quotedShown(serverId, channelId), current.params), alreadyTunedOut: true };
457
- const scope = this.getAllExcept();
458
- if (scope && scope.params.except.serverId === serverId && scope.params.except.channelId === channelId) {
459
- return { ok: false, error: `channel ${this.quotedShown(serverId, channelId)} is the excepted channel of the active all-except hold; cancel that first` };
3364
+ const reason = suppliedValue(error);
3365
+ return `The note${author !== undefined ? ` ${suppliedValue(author)} left` : ' left'} with an earlier cancel of ${hold} was not `
3366
+ + `delivered to ${resident ? suppliedValue(resident) : 'the resident'}. This cancel ended that ${it} while that one waited, `
3367
+ + `and delivering the note then failed: ${reason}${/[.!?]$/.test(reason) ? '' : '.'}`;
3368
+ }
3369
+ /**
3370
+ * The turn a release asks for (`whose`: the resident's, or the
3371
+ * subconscious's for its report), as requestInference asks it; one asked
3372
+ * while the host's stop is under way (stopTimers, the stop proper not
3373
+ * yet run) is dropped with the stop's closed queue, so the stop's stamp
3374
+ * records it (`wakesOwed`) and the next boot asks it again.
3375
+ */
3376
+ askReleaseTurn(agent, reason, whose) {
3377
+ this.hooks.requestInference(agent, reason, 'tune-out');
3378
+ this.releaseTurnsAsked.add(whose);
3379
+ if (this.timersStopped && !this.stopped)
3380
+ this.wakesInStop[whose] = true;
3381
+ }
3382
+ /** A turn for the persistent subconscious to read the loss of its note that a release told (queuedNoteFirst), as a notice asks for one (writeLossNotice). */
3383
+ askTurnForLoss(where) {
3384
+ let subName;
3385
+ try {
3386
+ subName = this.hooks.subconsciousName();
460
3387
  }
461
- const read = this.readOptions(opts, {
462
- durationSeconds: 'to stay tuned out until you cancel',
463
- cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
464
- backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
465
- maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
466
- });
467
- if (!read.ok)
468
- return read;
469
- const { given, expiresAtMs } = read;
470
- const params = {
471
- epochId: randomUUID(),
472
- cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
473
- backlogCap: Math.max(0, given.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap),
474
- maxWakes: Math.max(1, given.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
475
- startedAtSequence: this.hooks.currentSequence(),
476
- ...(expiresAtMs !== undefined ? { expiresAtMs } : {}),
477
- };
478
- // A tune-out that ended in the registry without this coordinator's
479
- // cancel would have left its timers armed: clear them, so none of them
480
- // acts on this one.
481
- this.clearTimers(serverId, channelId);
482
- this.channelRegistry.enterTuneOut(serverId, channelId, params, source);
483
- this.armExpiry(serverId, channelId, params);
484
- // Window anchor: start of the OLDEST active tune-out. Never move the
485
- // anchor forward here — an older active epoch owns it.
486
- const oldest = this.listActive().reduce((min, e) => Math.min(min, e.params.startedAtSequence), params.startedAtSequence);
487
- this.hooks.setSubconsciousAnchor(oldest);
488
- this.divertedSinceInvocation.set(this.key(serverId, channelId), 0);
489
- if (this.readerAvailable())
490
- this.startCadence(serverId, channelId, params.cadenceSeconds);
491
- this.hooks.emitTrace({
492
- type: 'tune-out:entered', serverId, channelId, epochId: params.epochId, reader: this.readerAvailable(),
493
- });
494
- return { ok: true, params };
3388
+ catch {
3389
+ return;
3390
+ }
3391
+ if (!subName)
3392
+ return;
3393
+ try {
3394
+ this.hooks.requestInference(subName, `tune-out note not delivered: ${where}`, 'tune-out');
3395
+ }
3396
+ catch (err) {
3397
+ console.error(`[tune-out] a release told ${subName} that its note to the resident was not delivered, but a turn to read it could not be asked for: ${thrownReason(err)}`);
3398
+ }
3399
+ }
3400
+ /**
3401
+ * Arm the one retry of the deferred cancels and the loss notices not
3402
+ * written (lossNotices), and of the loss record not read yet
3403
+ * (lossRecordUnread), unless it is armed; it arms itself again while
3404
+ * one still waits.
3405
+ */
3406
+ armDeferredRetry() {
3407
+ if (this.deferredRetry !== undefined || this.stopped)
3408
+ return;
3409
+ const timer = setTimeout(() => {
3410
+ this.deferredRetry = undefined;
3411
+ this.runDeferredCancels();
3412
+ // Writes held again at the retry: the cancels and notices wait on,
3413
+ // for the next release or this retry.
3414
+ if (this.deferredCancels.size > 0 || this.lossNotices.size > 0 || this.markerPending || this.lossRecordUnread)
3415
+ this.armDeferredRetry();
3416
+ }, EXPIRY_RETRY_MS);
3417
+ timer.unref?.();
3418
+ this.deferredRetry = timer;
3419
+ }
3420
+ /**
3421
+ * A cancel that waits for the writes kept in memory (deferCancel), its
3422
+ * hold in force: one asked for while writes are held, or one whose release
3423
+ * met a hold as it was written. A queued cancel's own run (`asQueued`)
3424
+ * goes back on the queue as it is, its note and askers with it (requeue).
3425
+ */
3426
+ waitForWrites(key, target, what, run, note, noteAuthor, asQueued) {
3427
+ if (asQueued) {
3428
+ // A stop during its run (a listener's, of the very write that met the
3429
+ // hold) found it off the queue: it isn't put back on a stopped one,
3430
+ // and is told as the stop tells a waiting cancel.
3431
+ if (this.stopped) {
3432
+ console.error(`[tune-out] stopping: the cancel of ${asQueued.what} met held writes when it ran, as the host stopped`);
3433
+ this.dropQueued(asQueued, { reason: 'stopped' });
3434
+ return { ok: true, deferred: true };
3435
+ }
3436
+ this.requeue(key, asQueued);
3437
+ return { ok: true, deferred: true };
3438
+ }
3439
+ const kept = this.deferCancel(key, target, what, run, note, noteAuthor);
3440
+ return { ok: true, deferred: true, ...(kept === 'note-dropped' ? { noteDropped: true } : {}) };
495
3441
  }
496
3442
  /** Whether a reader (persistent subconscious or reader forks) attends holds. */
497
3443
  hasReader() {
498
3444
  return this.readerAvailable();
499
3445
  }
3446
+ /**
3447
+ * End a tune-out: system-framed backlog dump into the resident's window
3448
+ * (capped), stored and made durable before the epoch's end is recorded,
3449
+ * then the lifecycle notice and one wake for the resident.
3450
+ * `subconsciousNote` is the subconscious's own text (from its
3451
+ * cancel_tuneout call) — delivered verbatim under its name, never
3452
+ * synthesized here. A dump that can't be made durable (commitDeliveries)
3453
+ * throws, the tune-out still in force, and a later cancel dumps what no
3454
+ * earlier dump of the epoch covered (`released`). While the framework
3455
+ * keeps writes in memory (writesHeld: a surgery's hold, a manager refusing
3456
+ * its work), nothing is dumped or ended yet: the cancel waits, the
3457
+ * tune-out in force, and runs once the writes are stored, or told of
3458
+ * where storing one fails (runDeferredCancels). It answers `deferred`
3459
+ * then.
3460
+ */
500
3461
  cancel(serverId, channelId, source, reason, subconsciousNote,
501
3462
  /** `reader: 'forks'`: the reader fork whose note this is. */
502
3463
  noteAuthor,
503
3464
  /** The lifecycle state the channel returns to (a tool close ends in 'closed'). */
504
3465
  nextState = 'open') {
3466
+ return this.endTuneOut(serverId, channelId, source, reason, subconsciousNote, noteAuthor, nextState);
3467
+ }
3468
+ /**
3469
+ * cancel's work. `asQueued`: the queued cancel this call runs
3470
+ * (runDeferredCancels), whose note (`subconsciousNote`) is delivered as a
3471
+ * queued one is (deliverQueuedNote), told lost (dropDeferred) where it
3472
+ * can't be, the cancel going on.
3473
+ */
3474
+ endTuneOut(serverId, channelId, source, reason, subconsciousNote, noteAuthor, nextState, asQueued) {
3475
+ // Who its release credits, by the names they run under: a queued run's
3476
+ // every asker (deferCancel), else this call's caller, its note's author
3477
+ // (a reader's cancel_tuneout passes its caller so, note or none).
3478
+ const askers = asQueued ? asQueued.askers : noteAuthor !== undefined ? [noteAuthor] : [];
505
3479
  const ending = this.channelRegistry.getTuneOutState(serverId, channelId);
506
3480
  if (!ending) {
507
3481
  // Its reader is the resident's tune_out: cancel_tuneout finds the channel first.
508
3482
  return { ok: false, error: this.notTunedOut(this.quotedShown(serverId, channelId), 'you') };
509
3483
  }
3484
+ const key = this.key(serverId, channelId);
3485
+ const waitForWrites = () => this.waitForWrites(key, {
3486
+ epochId: ending.params.epochId, serverId, channelId,
3487
+ current: () => this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId,
3488
+ }, `${this.shown(serverId, channelId)} (${reason})`, (entry) => { this.endTuneOut(serverId, channelId, source, reason, entry.note, entry.noteAuthor, nextState, entry); }, subconsciousNote, noteAuthor, asQueued);
3489
+ if (this.writesHeldNow())
3490
+ return waitForWrites();
3491
+ // Backlog: every message held under this epoch that no earlier dump of
3492
+ // it covered (one whose cancel failed before the end was recorded), in
3493
+ // store order, which is arrival order: a held message is stored as it
3494
+ // arrives (#291). A cancel waits while a surgery's hold keeps writes in
3495
+ // memory (writesHeld), so they are stored by now; one that went on during
3496
+ // the hold, writesHeld having thrown, lists those the hold keeps after
3497
+ // every stored one (residentMessages).
510
3498
  const epochId = ending.params.epochId;
511
3499
  const ofEpoch = (m) => m.metadata?.tuneOut?.epochId === epochId;
512
- const storedRows = this.hooks.getStoredMessages();
513
- const queuedRows = this.hooks.getDeferredMessages?.() ?? [];
514
- const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
3500
+ const rows = this.residentMessages();
3501
+ const earlier = releaseRecordsOf(rows, epochId);
515
3502
  const dumpedEarlier = keyedBy(earlier);
516
- const unreleased = (m) => ofEpoch(m) && !dumpedEarlier(m);
517
- const held = queuedRows.filter(unreleased);
518
- const backlog = [...storedRows.filter(unreleased), ...held];
3503
+ const backlog = rows.filter((m) => ofEpoch(m) && !dumpedEarlier(m));
519
3504
  // Whole messages: a cap below one shows none, where slice(-0.5) would
520
3505
  // start at 0 and take them all.
521
3506
  const cap = Math.floor(ending.params.backlogCap);
522
- const authorOf = (m) => suppliedValue(m.metadata?.author?.name ?? m.participant);
523
3507
  // A line names its author as the resident knows them and marks what a
524
3508
  // text line can't carry (`[image]`), so the dump says what it leaves out.
525
3509
  const lineOf = renderHeldLine;
@@ -528,7 +3512,15 @@ export class TuneOutCoordinator {
528
3512
  const historyTool = this.hooks.historyTool ? this.hooks.historyTool() : 'fetch_history';
529
3513
  // The channel as the resident and the subconscious are shown it.
530
3514
  const name = this.shown(serverId, channelId);
531
- const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ending.wakeCount} wake${ending.wakeCount === 1 ? '' : 's'}]`;
3515
+ // A cancel of this hold still queued (asked for while writes were held,
3516
+ // its release failing since): the subconscious's note it carries goes
3517
+ // out now, ahead of this release, which says so where it can't
3518
+ // (queuedNoteFirst); the lines fitted below make room for that.
3519
+ const noteMetadata = { kind: 'subconscious-note', channelId, serverId, epochId };
3520
+ const first = asQueued ? undefined : this.queuedNoteFirst(key, epochId, noteMetadata);
3521
+ const lost = first?.lost ? this.lossInRelease(first.entry, first.lost.error, name, 'tune-out') : undefined;
3522
+ const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ending.wakeCount} wake${ending.wakeCount === 1 ? '' : 's'}]`
3523
+ + (lost ? `\n\n${lost}` : '');
532
3524
  const render = (lines, truncated) => {
533
3525
  const attrs = [
534
3526
  `channel=${this.quotedShown(serverId, channelId)}`,
@@ -555,7 +3547,6 @@ export class TuneOutCoordinator {
555
3547
  // residents' strategies cut its frame, the header and tags.
556
3548
  let shown = cap > 0 ? backlog.slice(-cap) : [];
557
3549
  let lines = shown.map(lineOf);
558
- let newestCut = false;
559
3550
  const room = this.hooks.residentMessageChars?.();
560
3551
  if (room !== undefined && render(lines, backlog.length - shown.length).length > room) {
561
3552
  // The dump's length with no line, for the count that showing k lines
@@ -581,7 +3572,6 @@ export class TuneOutCoordinator {
581
3572
  if (kept === 0 && head !== '') {
582
3573
  lines = [head + CUT_MARK];
583
3574
  shown = shown.slice(-1);
584
- newestCut = true;
585
3575
  }
586
3576
  else {
587
3577
  lines = lines.slice(lines.length - kept);
@@ -589,98 +3579,168 @@ export class TuneOutCoordinator {
589
3579
  }
590
3580
  }
591
3581
  const truncated = backlog.length - shown.length;
592
- // The held messages the dump leaves out, the oldest of them. The
593
- // subconscious's report covers what the dump leaves out, but its view
594
- // holds stored rows only, so these go to it with its notice below. So does
595
- // a held message the dump shows but can't give whole: one whose image or
596
- // audio its text lines drop, or the newest, cut to fit.
597
- const heldLeftOut = held.slice(0, Math.max(0, held.length - shown.length));
598
- const heldForReport = [
599
- ...heldLeftOut,
600
- ...held.slice(heldLeftOut.length).filter((m) => m.content.some((b) => b.type !== 'text') || (newestCut && m === shown[0])),
601
- ];
3582
+ // Host-framed system artifacts — never voiced as anyone. The dump records
3583
+ // the held rows it covered (`released`), so a cancel tried again after
3584
+ // this one failed before the end was recorded, in this process or after
3585
+ // a restart, dumps only those no dump covered, and writes none when
3586
+ // every one was. There is no dump to write when an earlier one covered
3587
+ // them all, but the end is still to be recorded.
602
3588
  const records = [...earlier];
3589
+ // Whether the release written was kept in memory, by what its write
3590
+ // answered (an empty id), not by whether writes are held after it.
3591
+ let releaseKept = false;
603
3592
  if (earlier.length === 0 || backlog.length > 0) {
604
- const record = { ...keysOf(backlog), total: backlog.length, truncated, report: keysOf(heldForReport) };
605
- this.hooks.addMessage('user', [{ type: 'text', text: render(lines, truncated) }], { system: true, kind: 'tune-out-cancel', channelId, epochId, released: record });
3593
+ const record = { ...keysOf(backlog), total: backlog.length, truncated, report: { ids: [], writeIds: [] } };
3594
+ releaseKept = this.writeKept(this.hooks.addMessage('user', [{ type: 'text', text: render(lines, truncated) }], { system: true, kind: 'tune-out-cancel', channelId, epochId, released: record, cancel: { source, reason, ...(askers.length > 0 ? { by: askers[0], askers } : {}) }, ...(lost ? { lostNote: true } : {}) }));
606
3595
  records.push(record);
607
3596
  }
3597
+ else if (lost) {
3598
+ // Every held message is in an earlier release: this one carries the
3599
+ // loss alone, and covers none (no `released`). `lostNote` marks a
3600
+ // release that tells a queued note's loss, as a boot reads it
3601
+ // (noteUnstampedStop).
3602
+ releaseKept = this.writeKept(this.hooks.addMessage('user', [{ type: 'text', text: lifecycle }], { system: true, kind: 'tune-out-cancel', channelId, epochId, cancel: { source, reason, ...(askers.length > 0 ? { by: askers[0], askers } : {}) }, lostNote: true }));
3603
+ }
3604
+ else if (this.residentToldStillStands(epochId, name)) {
3605
+ // The boot told the resident this tune-out still stands, after its
3606
+ // last release (tellResidentUnended): this cancel delivers nothing
3607
+ // more, and says it has ended, so that row isn't the resident's last
3608
+ // word on it. It covers none (no `released`).
3609
+ releaseKept = this.writeKept(this.hooks.addMessage('user', [{ type: 'text', text: `${lifecycle}\n\n${endedNow('tune-out')}` }], { system: true, kind: 'tune-out-cancel', channelId, epochId, cancel: { source, reason, ...(askers.length > 0 ? { by: askers[0], askers } : {}) } }));
3610
+ }
3611
+ // Told, in the release written: not delivered or told again, even if
3612
+ // the release isn't made durable below and the queued cancel runs later,
3613
+ // whose run, or whichever ends the hold, traces the loss and asks the
3614
+ // turn then (lossesToldInRelease).
3615
+ if (first?.lost) {
3616
+ first.entry.noteSettled = true;
3617
+ first.entry.lostInRelease = first.lost;
3618
+ }
3619
+ // A release that met a hold as it was written (a context manager that
3620
+ // refuses its work from that write on) is kept in memory, not stored,
3621
+ // whatever a sync answers: the cancel waits for the writes again, the
3622
+ // tune-out in force, and its run once they are stored finds this release
3623
+ // there, covering the backlog, and records the end then. So does one
3624
+ // stored, writes held only after it (a listener's hold, begun as the
3625
+ // write returned): no end is recorded while writes are held, but
3626
+ // nothing of the release is in memory.
3627
+ if (releaseKept || this.writesHeldNow()) {
3628
+ console.error(`[tune-out] the release of ${this.shown(serverId, channelId)} ${releaseKept ? 'was kept in memory as it was written' : 'was stored, and writes are held since'}, so its tune-out stays in force and the cancel waits until the writes are stored`);
3629
+ // The loss it told is in memory with it, which a stop loses.
3630
+ if (first?.lost && releaseKept)
3631
+ first.entry.heldInMemory = true;
3632
+ return waitForWrites();
3633
+ }
3634
+ // The release is durable before the end is recorded (the registry
3635
+ // writes the end to its log at once): a crash or a throw before the end
3636
+ // leaves the tune-out in force with its release stored, never ended with
3637
+ // none. It is stored, not kept in memory: a cancel made while the
3638
+ // framework keeps writes in memory waits until it stores them again
3639
+ // (deferCancel).
608
3640
  if (this.hooks.commitDeliveries?.() === false) {
609
3641
  throw new Error(`the backlog of ${this.quotedShown(serverId, channelId)} could not be made durable, so its tune-out stays in force`);
610
3642
  }
3643
+ // A queued cancel's own run delivers its note now, its release durable,
3644
+ // and makes the note durable too before the end is recorded: a crash
3645
+ // after the end, the hold no longer on record for the next boot to read,
3646
+ // finds the note stored. One that can't be delivered is told lost once
3647
+ // the hold has ended (dropDeferred, below).
3648
+ // Its note's row names this cancel and its askers (NoteMetadata): the
3649
+ // run may write no release of its own, an earlier one covering the
3650
+ // backlog, and a boot after a crash reads its run from the note.
3651
+ const own = asQueued
3652
+ ? this.noteNow(asQueued, { ...noteMetadata, cancel: { source, reason, askers: [...askers] } }, 'itself')
3653
+ : undefined;
3654
+ // A note kept in memory as it was written (its write answered so) is no
3655
+ // more stored than a release kept so: the cancel waits for the writes,
3656
+ // the tune-out in force, its note settled, to be stored at their release.
3657
+ // Where storing it there fails, the framework says so (heldWriteLost),
3658
+ // and the cancel's next run tells it lost after the end (noteNow). A
3659
+ // note stored, writes held only after it, waits too, for no end is
3660
+ // recorded while writes are held, but isn't kept in memory: a stop
3661
+ // meanwhile doesn't tell it lost (Weft, #844).
3662
+ if (own?.delivered && (own.kept || this.writesHeldNow())) {
3663
+ console.error(`[tune-out] the note left with the cancel of ${this.shown(serverId, channelId)} ${own.kept ? 'was kept in memory as it was written' : 'was stored, and writes are held since'}, so its tune-out stays in force and the cancel waits until the writes are stored`);
3664
+ if (own.kept)
3665
+ asQueued.heldInMemory = true;
3666
+ return waitForWrites();
3667
+ }
3668
+ if (own?.delivered && this.hooks.commitDeliveries?.() === false) {
3669
+ throw new Error(`the note left with the cancel of ${this.quotedShown(serverId, channelId)} could not be made durable, so its tune-out stays in force`);
3670
+ }
611
3671
  this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source);
612
3672
  this.clearTimers(serverId, channelId);
613
3673
  this.divertedSinceInvocation.delete(this.key(serverId, channelId));
614
- const reportedEarlier = keyedBy(earlier.map((r) => r.report));
615
- const release = {
616
- truncated: records.reduce((n, r) => n + r.truncated, 0),
617
- heldForReport: [
618
- ...[...storedRows, ...queuedRows].filter((m) => ofEpoch(m) && reportedEarlier(m)),
619
- ...(records.length > earlier.length ? heldForReport : []),
620
- ],
621
- };
622
- // The subconscious's own words, under its own name, verbatim.
3674
+ // Its own note that couldn't be delivered is told lost now the hold has
3675
+ // ended, ahead of any step after the end that may throw, and after its
3676
+ // timers are cleared, so that a tune-out a listener of the drop's trace
3677
+ // enters keeps its own.
3678
+ if (own?.lost)
3679
+ this.dropDeferred(asQueued, own.lost);
3680
+ // The subconscious's own words, under its own name, verbatim; a reader
3681
+ // fork's as its attributed mail. With its server: a conversational row
3682
+ // whose channel the framework reads (repinChannel), where another
3683
+ // server's channel can share the id. A cancel of this hold still queued
3684
+ // has nothing left to end: it leaves the queue, and the note it carries
3685
+ // goes out first, since it was asked for first, then this caller's: the
3686
+ // subconscious's went out before the release (queuedNoteFirst), whose
3687
+ // loss the release told, with a turn to read it; a reader fork's goes
3688
+ // out now. A queued cancel's own run delivers its note as a queued one
3689
+ // (asQueued), told lost where it can't be.
623
3690
  const subName = this.hooks.subconsciousName();
624
- if (subconsciousNote && subName) {
625
- this.hooks.addMessage(subName, [{ type: 'text', text: subconsciousNote }],
626
- // With its server: a conversational row whose channel the framework
627
- // reads (repinChannel), where another server's channel can share the id.
628
- { kind: 'subconscious-note', channelId, serverId, epochId });
629
- }
630
- else if (subconsciousNote && noteAuthor && this.hooks.deliverFromReader) {
631
- // A reader fork's note: attributed mail, under the fork's name and
632
- // incarnation, never the resident's voice.
633
- this.hooks.deliverFromReader(noteAuthor, [{ type: 'text', text: subconsciousNote }], { kind: 'subconscious-note', channelId, epochId });
634
- }
3691
+ const queued = this.takeDeferred(key, epochId);
3692
+ const queuedNote = queued !== undefined
3693
+ && (queued === first?.entry ? !first.lost : this.deliverQueuedNote(queued, noteMetadata, 'another'));
3694
+ // A loss a release told: this one's, or an earlier one's that wasn't
3695
+ // made durable then, the hold ending only now.
3696
+ this.lossesToldInRelease([first?.entry, queued, asQueued], name);
3697
+ const ownNote = asQueued
3698
+ ? own?.delivered === true
3699
+ : !!subconsciousNote && this.deliverReaderNote(subconsciousNote, noteAuthor, noteMetadata);
635
3700
  const primary = this.hooks.primaryName();
636
3701
  if (primary) {
637
- this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out');
3702
+ this.askReleaseTurn(primary, `tune-out cancelled (${reason})`, 'resident');
638
3703
  }
639
3704
  // The subconscious gets one cancel turn of its own: the issue's cancel
640
3705
  // shape is dump + a report from the subconscious, and when the dump left
641
- // messages out, its summary is what covers them. The held messages the
642
- // dump can't give the resident whole follow the notice, each a row of its
643
- // own with its own content, as its view can't have them until they're
644
- // stored. A cancel the subconscious makes itself gets the notice and the
645
- // turn when some do, since it wrote its note before it could see them,
646
- // and when it wrote no note and the dump left any message out, since
647
- // nothing else then covers it. Otherwise its note speaks for what it saw.
3706
+ // messages out, its summary is what covers them. Every held message is
3707
+ // stored (#291), so its view has them all, the newest at its next point.
3708
+ // A cancel the subconscious makes itself gets the notice and the turn
3709
+ // when some of the epoch's messages are ones it hasn't been shown yet
3710
+ // (unseenBySubconscious: those that came in during its own turn, say),
3711
+ // since it wrote its note before it could see them, and the notice says
3712
+ // how many; and when no note of its went out (its own, or a queued
3713
+ // cancel's) and the dump left any message out, since nothing else then
3714
+ // covers it. Otherwise its note speaks for what it saw.
648
3715
  const selfCancel = source === 'subconscious';
649
- if (subName && (!selfCancel || release.heldForReport.length > 0 || (!subconsciousNote && release.truncated > 0))) {
3716
+ const unseen = subName && selfCancel ? this.unseenBySubconscious(ofEpoch) : 0;
3717
+ const truncatedInAll = records.reduce((n, r) => n + r.truncated, 0);
3718
+ if (subName && (!selfCancel || unseen > 0 || (!ownNote && !queuedNote && truncatedInAll > 0))) {
650
3719
  const truncNote = truncationNote(records);
651
- const heldNote = release.heldForReport.length > 0
652
- ? ` Still held at the cancel, so your view may not have them yet: the ${release.heldForReport.length} message${release.heldForReport.length === 1 ? '' : 's'} after this one.`
3720
+ const unseenNote = unseen > 0
3721
+ ? ` ${unseen === 1 ? 'One of its held messages' : `${unseen} of its held messages`} came in after you last looked; your view has ${unseen === 1 ? 'it' : 'them'} now.`
653
3722
  : '';
654
- this.hooks.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${reason}.${truncNote}${heldNote}]` }], { system: true, kind: 'tune-out-cancel-report', channelId }, subName);
655
- // Each held message is a row of its own, so a per-message cap cuts each
656
- // one on its own, its author's label included, not the batch's newest.
657
- for (const m of release.heldForReport) {
658
- this.hooks.addMessage('user', [{ type: 'text', text: `${authorOf(m)}:` }, ...m.content], { system: true, kind: 'tune-out-cancel-held', channelId }, subName);
659
- }
660
- this.hooks.requestInference(subName, `tune-out cancelled: ${name}`, 'tune-out');
3723
+ this.hooks.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${reason}.${truncNote}${unseenNote}]` }], { system: true, kind: 'tune-out-cancel-report', channelId }, subName);
3724
+ this.askReleaseTurn(subName, `tune-out cancelled: ${name}`, 'subconscious');
661
3725
  }
662
3726
  else if (!subName && this.forkReader) {
663
3727
  // Forks are the reader: one last fork for the epoch, handed what no
664
- // fork has been handed yet, stored or still queued behind the
665
- // resident's turn, plus the cancel notice; its report follows the dump.
666
- // Once the epoch ends, a message still queued lands in the store
667
- // view-filtered, where nothing else reads it, so it goes to this fork
668
- // now. On a reader's own cancel, the reader has read what it was
3728
+ // fork has been handed yet, plus the cancel notice; its report follows
3729
+ // the dump. On a reader's own cancel, the reader has read what it was
669
3730
  // handed, so a fork follows only when messages arrived after that.
670
3731
  const stored = this.hooks.getStoredMessages().filter(ofEpoch);
671
- const queued = this.queuedHeld(ofEpoch);
672
- const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
673
- const unread = rest.backlog.length + rest.queued.length;
3732
+ const unread = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored).length;
674
3733
  if (!selfCancel || unread > 0) {
675
3734
  const truncNote = truncationNote(records);
676
3735
  const lateNote = selfCancel
677
3736
  ? ` ${unread === 1 ? 'This message' : `These ${unread} messages`} arrived after the reader that cancelled was handed its own.`
678
3737
  : '';
679
- this.invokeReaderFork(serverId, channelId, epochId, 'cancel', `[${NOTICE_TAG.tuneOutCancelled}: ${name} — ${reason}.${truncNote}${lateNote}]`, stored, queued);
3738
+ this.invokeReaderFork(serverId, channelId, epochId, 'cancel', `[${NOTICE_TAG.tuneOutCancelled}: ${name} — ${reason}.${truncNote}${lateNote}]`, stored);
680
3739
  }
681
3740
  }
682
3741
  this.dropHandOffs(epochId);
683
3742
  this.dropLooks(epochId);
3743
+ this.forgetWindowStart(epochId);
684
3744
  // Where the all-except hold still holds the channel, its own messages
685
3745
  // there that its reader hasn't looked at are counted again, as the
686
3746
  // channel's own tune-out's count stood in for theirs.
@@ -688,6 +3748,16 @@ export class TuneOutCoordinator {
688
3748
  this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ending.wakeCount });
689
3749
  return { ok: true };
690
3750
  }
3751
+ /** How many stored messages `keep` keeps the subconscious hasn't been shown (the hook's count), 0 where the host can't say. */
3752
+ unseenBySubconscious(keep) {
3753
+ try {
3754
+ return this.hooks.unseenBySubconscious?.(keep) ?? 0;
3755
+ }
3756
+ catch (err) {
3757
+ console.error('[tune-out] could not count the held messages the subconscious has not been shown, so none is counted:', err);
3758
+ return 0;
3759
+ }
3760
+ }
691
3761
  // ==========================================================================
692
3762
  // Ingestion: divert, stamp, classify
693
3763
  // ==========================================================================
@@ -730,6 +3800,76 @@ export class TuneOutCoordinator {
730
3800
  get scopeStateId() {
731
3801
  return `tune-out/${this.hooks.primaryName() ?? 'default'}/all-except`;
732
3802
  }
3803
+ /**
3804
+ * End every hold on record and deliver each backlog to the resident: the
3805
+ * per-channel holds, then the all-except scope. For a host with no reader
3806
+ * (AgentFramework.releaseTuneOutsWithoutReader) and a reader that ended; a
3807
+ * hold whose end fails stays on record, and says so in the log. A
3808
+ * per-channel hold ends under its own source, so an agent's tune-out keeps
3809
+ * the agent's provenance (the housekeeping guard reads it); `source` is the
3810
+ * all-except scope's.
3811
+ */
3812
+ releaseAll(source, reason, options = {}) {
3813
+ if (this.releaseRetry) {
3814
+ clearTimeout(this.releaseRetry);
3815
+ this.releaseRetry = null;
3816
+ }
3817
+ let failed = false;
3818
+ // What the registry has on record, registered or not: at a start the
3819
+ // channels aren't registered yet, so listActive() would find none.
3820
+ let onRecord = [];
3821
+ try {
3822
+ onRecord = this.channelRegistry.tuneOutsOnRecord();
3823
+ }
3824
+ catch (err) {
3825
+ console.error(`[tune-out] the tune-outs on record could not be read to release them (${reason}), so they stay on record:`, err);
3826
+ failed = true;
3827
+ }
3828
+ for (const { serverId, channelId, source: recorded } of onRecord) {
3829
+ try {
3830
+ const r = this.cancel(serverId, channelId, recorded, reason);
3831
+ if (!r.ok) {
3832
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} could not be released (${reason}), so it stays on record: ${r.error}`);
3833
+ failed = true;
3834
+ }
3835
+ }
3836
+ catch (err) {
3837
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} could not be released (${reason}), so it stays on record:`, err);
3838
+ failed = true;
3839
+ }
3840
+ }
3841
+ try {
3842
+ if (this.getAllExcept()) {
3843
+ const r = this.cancelAllExcept(source, reason);
3844
+ if (!r.ok) {
3845
+ console.error(`[tune-out] the all-except hold could not be released (${reason}), so it stays on record: ${r.error}`);
3846
+ failed = true;
3847
+ }
3848
+ }
3849
+ }
3850
+ catch (err) {
3851
+ console.error(`[tune-out] the all-except hold could not be released (${reason}), so it stays on record:`, err);
3852
+ failed = true;
3853
+ }
3854
+ // With a reader gone mid-run the hold still diverts traffic, and an
3855
+ // open-ended one has no end time to release it: try again, backing off,
3856
+ // a few times (a try that fails after its dump is durable writes a new
3857
+ // one only for messages held since). After the last, the hold stays
3858
+ // until the resident cancels it, or the next start releases it (with no
3859
+ // reader configured) or resumes it (with one).
3860
+ const attempt = options.attempt ?? 1;
3861
+ if (failed && options.retry && !this.stopped) {
3862
+ if (attempt >= RELEASE_RETRY_ATTEMPTS) {
3863
+ console.error(`[tune-out] releasing the holds (${reason}) failed ${attempt} times, so what failed stays held until it is cancelled, or the next start releases it (with no reader configured) or resumes it (with one)`);
3864
+ }
3865
+ else {
3866
+ this.releaseRetry = setTimeout(() => {
3867
+ this.releaseRetry = null;
3868
+ this.releaseAll(source, reason, { ...options, attempt: attempt + 1 });
3869
+ }, EXPIRY_RETRY_MS * 2 ** (attempt - 1));
3870
+ }
3871
+ }
3872
+ }
733
3873
  getAllExcept() {
734
3874
  if (this.scopeCache !== undefined)
735
3875
  return this.scopeCache;
@@ -750,6 +3890,8 @@ export class TuneOutCoordinator {
750
3890
  this.scopeCache = state;
751
3891
  }
752
3892
  enterAllExcept(except, opts, source) {
3893
+ if (!this.readerAvailable())
3894
+ return { ok: false, error: NO_READER_REFUSAL };
753
3895
  const read = this.readOptions(opts, {
754
3896
  durationSeconds: null,
755
3897
  cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
@@ -785,8 +3927,7 @@ export class TuneOutCoordinator {
785
3927
  source,
786
3928
  };
787
3929
  this.writeScope({ params, wakeCount: 0 });
788
- const oldest = this.listActive().reduce((min, e) => Math.min(min, e.params.startedAtSequence), params.startedAtSequence);
789
- this.hooks.setSubconsciousAnchor(oldest);
3930
+ this.anchorSubconscious();
790
3931
  this.armScopeTimers(params);
791
3932
  this.hooks.emitTrace({
792
3933
  type: 'tune-out:entered', serverId: except.serverId, channelId: except.channelId, epochId: params.epochId,
@@ -852,7 +3993,7 @@ export class TuneOutCoordinator {
852
3993
  for (const [channelKey, diverted] of this.divertedSinceInvocation) {
853
3994
  if (diverted <= 0)
854
3995
  continue;
855
- const [serverId, channelId] = channelKey.split('\0');
3996
+ const [serverId, channelId] = this.channelOf(channelKey);
856
3997
  if (this.channelRegistry.getTuneOutState(serverId, channelId))
857
3998
  continue; // a per-channel epoch's own cadence
858
3999
  this.invokeSubconscious(serverId, channelId, 'cadence', diverted);
@@ -876,12 +4017,11 @@ export class TuneOutCoordinator {
876
4017
  this.expiryTimers.delete(key);
877
4018
  }
878
4019
  /** Channels held by the scope (not by their own epoch) with the count held under it. */
879
- scopeHeldCounts(epochId) {
4020
+ scopeHeldCounts(epochId, held) {
880
4021
  const counts = new Map();
881
- const all = [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])];
882
- for (const m of all) {
4022
+ for (const m of this.residentMessages()) {
883
4023
  const meta = m.metadata;
884
- if (meta?.tuneOut?.epochId !== epochId)
4024
+ if (meta?.tuneOut?.epochId !== epochId || (held && !held(m)))
885
4025
  continue;
886
4026
  const channelId = typeof meta.channelId === 'string' ? meta.channelId : '(unknown channel)';
887
4027
  const serverId = typeof meta.serverId === 'string' ? meta.serverId : '';
@@ -892,16 +4032,40 @@ export class TuneOutCoordinator {
892
4032
  }
893
4033
  return counts;
894
4034
  }
4035
+ /**
4036
+ * End the all-except hold: one release notice with a per-channel backlog
4037
+ * (each capped), stored and made durable before the hold's end is
4038
+ * recorded, as a channel's cancel does, then the reader's note if any,
4039
+ * one wake for the resident, and the reader's own look at what it had not
4040
+ * seen. While the framework keeps writes in memory it waits, as cancel
4041
+ * does.
4042
+ */
895
4043
  cancelAllExcept(source, reason, note, noteAuthor) {
4044
+ return this.endAllExcept(source, reason, note, noteAuthor);
4045
+ }
4046
+ /** cancelAllExcept's work; `asQueued` as endTuneOut's. */
4047
+ endAllExcept(source, reason, note, noteAuthor, asQueued) {
4048
+ // Who its release credits, as endTuneOut's.
4049
+ const askers = asQueued ? asQueued.askers : noteAuthor !== undefined ? [noteAuthor] : [];
896
4050
  const scope = this.getAllExcept();
897
4051
  if (!scope)
898
4052
  return { ok: false, error: 'no all-except hold is active' };
4053
+ // As a channel's cancel: while writes are kept in memory, the release
4054
+ // would be, and writeScope(null) below is on disk at once.
4055
+ const waitForWrites = () => this.waitForWrites(TuneOutCoordinator.SCOPE_KEY, {
4056
+ epochId: scope.params.epochId, serverId: scope.params.except.serverId, channelId: scope.params.except.channelId,
4057
+ scope: 'all-except', current: () => this.getAllExcept()?.params.epochId,
4058
+ }, `the all-except hold (${reason})`, (entry) => { this.endAllExcept(source, reason, entry.note, entry.noteAuthor, entry); }, note, noteAuthor, asQueued);
4059
+ if (this.writesHeldNow())
4060
+ return waitForWrites();
899
4061
  const { epochId, except, backlogCap } = scope.params;
900
4062
  const ofEpoch = (m) => m.metadata?.tuneOut?.epochId === epochId;
901
- const storedRows = this.hooks.getStoredMessages();
902
- const queuedRows = this.hooks.getDeferredMessages?.() ?? [];
903
- const all = [...storedRows.filter(ofEpoch), ...queuedRows.filter(ofEpoch)];
904
- const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
4063
+ // Stored, and kept by a surgery's hold until its release
4064
+ // (residentMessages), less what an earlier release of the hold covered
4065
+ // (`released`), as a channel's cancel reads them.
4066
+ const rows = this.residentMessages();
4067
+ const all = rows.filter(ofEpoch);
4068
+ const earlier = releaseRecordsOf(rows, epochId);
905
4069
  const releasedEarlier = keyedBy(earlier);
906
4070
  const fresh = all.filter((m) => !releasedEarlier(m));
907
4071
  const byChannel = new Map();
@@ -933,41 +4097,94 @@ export class TuneOutCoordinator {
933
4097
  const summary = byChannel.size === 0
934
4098
  ? 'Nothing was held.'
935
4099
  : `${fresh.length} message${fresh.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`;
4100
+ // A cancel of this hold still queued: the subconscious's note goes out
4101
+ // now, ahead of this release, which says so where it can't, as a
4102
+ // channel's cancel does (queuedNoteFirst).
4103
+ const shownExcept = this.shown(except.serverId, except.channelId);
4104
+ const first = asQueued ? undefined : this.queuedNoteFirst(TuneOutCoordinator.SCOPE_KEY, epochId, { kind: 'subconscious-note', epochId });
4105
+ const lost = first?.lost
4106
+ ? this.lossInRelease(first.entry, first.lost.error, `the all-except hold (all channels except ${shownExcept})`, 'hold')
4107
+ : undefined;
4108
+ const header = `[${NOTICE_TAG.tuneOut}: all channels except ${shownExcept} released — ${reason}, ${scope.wakeCount} wake${scope.wakeCount === 1 ? '' : 's'}]`
4109
+ + (lost ? `\n\n${lost}` : '');
4110
+ // As a channel's: kept in memory by what the write answered.
4111
+ let releaseKept = false;
936
4112
  if (earlier.length === 0 || fresh.length > 0) {
937
4113
  const record = { ...keysOf(fresh), total: fresh.length, truncated: truncatedInAll, report: { ids: [], writeIds: [] } };
938
- this.hooks.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.tuneOut}: all channels except ${this.shown(except.serverId, except.channelId)} released — ${reason}, ${scope.wakeCount} wake${scope.wakeCount === 1 ? '' : 's'}]\n\n${summary}${dumps.length > 0 ? '\n\n' + dumps.join('\n\n') : ''}` }], { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId, released: record });
4114
+ releaseKept = this.writeKept(this.hooks.addMessage('user', [{ type: 'text', text: `${header}\n\n${summary}${dumps.length > 0 ? '\n\n' + dumps.join('\n\n') : ''}` }], { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId, released: record, cancel: { source, reason, ...(askers.length > 0 ? { by: askers[0], askers } : {}) }, ...(lost ? { lostNote: true } : {}) }));
4115
+ }
4116
+ else if (lost) {
4117
+ releaseKept = this.writeKept(this.hooks.addMessage('user', [{ type: 'text', text: header }], { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId, cancel: { source, reason, ...(askers.length > 0 ? { by: askers[0], askers } : {}) }, lostNote: true }));
4118
+ }
4119
+ else if (this.residentToldStillStands(epochId, 'the all-except hold')) {
4120
+ // As a channel's: the boot told the resident the hold still stands.
4121
+ releaseKept = this.writeKept(this.hooks.addMessage('user', [{ type: 'text', text: `${header}\n\n${endedNow('hold')}` }], { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId, cancel: { source, reason, ...(askers.length > 0 ? { by: askers[0], askers } : {}) } }));
4122
+ }
4123
+ if (first?.lost) {
4124
+ first.entry.noteSettled = true;
4125
+ first.entry.lostInRelease = first.lost;
4126
+ }
4127
+ // As a channel's cancel: a release kept in memory as it was written
4128
+ // waits for the writes, and a queued cancel's own note is made durable
4129
+ // before the end.
4130
+ if (releaseKept || this.writesHeldNow()) {
4131
+ console.error(`[tune-out] the all-except release ${releaseKept ? 'was kept in memory as it was written' : 'was stored, and writes are held since'}, so the hold stays in force and the cancel waits until the writes are stored`);
4132
+ if (first?.lost && releaseKept)
4133
+ first.entry.heldInMemory = true;
4134
+ return waitForWrites();
939
4135
  }
940
4136
  if (this.hooks.commitDeliveries?.() === false) {
941
4137
  throw new Error('the all-except release could not be made durable, so the hold stays in force');
942
4138
  }
4139
+ // As a channel's, one kept in memory and lost when the writes are stored
4140
+ // is told lost by the cancel's next run, after the end (heldWriteLost).
4141
+ const own = asQueued
4142
+ ? this.noteNow(asQueued, { kind: 'subconscious-note', epochId, cancel: { source, reason, askers: [...askers] } }, 'itself')
4143
+ : undefined;
4144
+ if (own?.delivered && (own.kept || this.writesHeldNow())) {
4145
+ console.error(`[tune-out] the note left with the all-except release ${own.kept ? 'was kept in memory as it was written' : 'was stored, and writes are held since'}, so the hold stays in force and the cancel waits until the writes are stored`);
4146
+ if (own.kept)
4147
+ asQueued.heldInMemory = true;
4148
+ return waitForWrites();
4149
+ }
4150
+ if (own?.delivered && this.hooks.commitDeliveries?.() === false) {
4151
+ throw new Error('the note left with the all-except release could not be made durable, so the hold stays in force');
4152
+ }
943
4153
  this.writeScope(null);
944
4154
  this.clearScopeTimers();
945
4155
  for (const [key, pending] of this.pendingWakes) {
946
- const [serverId, channelId] = key.split('\0');
4156
+ const [serverId, channelId] = this.channelOf(key);
947
4157
  if (this.channelRegistry.getTuneOutState(serverId, channelId))
948
4158
  continue;
949
4159
  clearTimeout(pending.timer);
950
4160
  this.pendingWakes.delete(key);
951
4161
  }
4162
+ // As a channel's: told lost after the hold's timers are cleared.
4163
+ if (own?.lost)
4164
+ this.dropDeferred(asQueued, own.lost);
4165
+ // A cancel of this hold still queued has nothing left to end, and its
4166
+ // note goes out first, as a channel's cancel delivers one: the
4167
+ // subconscious's went out before the release (queuedNoteFirst, settled
4168
+ // then), a reader fork's goes out now; so does a queued cancel's own run
4169
+ // deliver its note (asQueued).
952
4170
  const subName = this.hooks.subconsciousName();
953
- if (note && subName) {
954
- this.hooks.addMessage(subName, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
955
- }
956
- else if (note && noteAuthor && this.hooks.deliverFromReader) {
957
- this.hooks.deliverFromReader(noteAuthor, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
958
- }
4171
+ const queued = this.takeDeferred(TuneOutCoordinator.SCOPE_KEY, epochId);
4172
+ if (queued && queued !== first?.entry)
4173
+ this.deliverQueuedNote(queued, { kind: 'subconscious-note', epochId }, 'another');
4174
+ this.lossesToldInRelease([first?.entry, queued, asQueued], 'all-except');
4175
+ if (!asQueued && note)
4176
+ this.deliverReaderNote(note, noteAuthor, { kind: 'subconscious-note', epochId });
959
4177
  const primary = this.hooks.primaryName();
960
4178
  if (primary)
961
- this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out');
4179
+ this.askReleaseTurn(primary, `tune-out cancelled (${reason})`, 'resident');
962
4180
  const notice = `[${NOTICE_TAG.tuneOutCancelled}: all channels except ${this.shown(except.serverId, except.channelId)} — ${reason}.]`;
963
4181
  if (source !== 'subconscious' && subName) {
964
4182
  this.hooks.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'tune-out-cancel-report', scope: 'all-except' }, subName);
965
- this.hooks.requestInference(subName, 'tune-out cancelled: all-except', 'tune-out');
4183
+ this.askReleaseTurn(subName, 'tune-out cancelled: all-except', 'subconscious');
966
4184
  }
967
4185
  else if (!subName && this.forkReader) {
968
- // One last look per channel with messages no fork was handed, stored or
969
- // still queued behind the resident's turn. That holds for a reader's
970
- // own release too: the reader that released read one channel's
4186
+ // One last look per channel with messages no fork was handed. That
4187
+ // holds for a reader's own release too: the reader that released read one channel's
971
4188
  // messages, and every other channel's would otherwise reach no reader.
972
4189
  // A channel is its server and id here, as the readers' bookkeeping is.
973
4190
  const channels = new Map();
@@ -980,29 +4197,39 @@ export class TuneOutCoordinator {
980
4197
  const ofChannel = inChannelOf(serverId, channelId);
981
4198
  const inChannel = (m) => ofEpoch(m) && ofChannel(m);
982
4199
  const stored = this.hooks.getStoredMessages().filter(inChannel);
983
- const queued = this.queuedHeld(inChannel);
984
- const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
985
- if (rest.backlog.length + rest.queued.length > 0) {
986
- this.invokeReaderFork(serverId, channelId, epochId, 'cancel', notice, stored, queued);
4200
+ if (this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored).length > 0) {
4201
+ this.invokeReaderFork(serverId, channelId, epochId, 'cancel', notice, stored);
987
4202
  }
988
4203
  }
989
4204
  }
990
4205
  for (const key of this.divertedSinceInvocation.keys()) {
991
- const [serverId, channelId] = key.split('\0');
4206
+ const [serverId, channelId] = this.channelOf(key);
992
4207
  if (!this.channelRegistry.getTuneOutState(serverId, channelId))
993
4208
  this.divertedSinceInvocation.delete(key);
994
4209
  }
995
4210
  this.dropHandOffs(epochId);
996
4211
  this.dropLooks(epochId);
4212
+ this.forgetWindowStart(epochId);
997
4213
  this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId: except.serverId, channelId: except.channelId, epochId, scope: 'all-except', reason, wakeCount: scope.wakeCount });
998
- this.announce(except, epochId, 'released', `[${NOTICE_TAG.focus}: ${primary ?? 'the resident'} released — ${reason}.]`);
4214
+ // The line the excepted channel sees names no agent but the resident:
4215
+ // a reader's cancel is "by its reader" there, its name kept for the
4216
+ // resident's line, the reader's notice and the trace above.
4217
+ const channelReason = source === 'subconscious' ? 'by its reader' : reason;
4218
+ this.announce(except, epochId, 'released', `[${NOTICE_TAG.focus}: ${primary ?? 'the resident'} released — ${channelReason}.]`);
999
4219
  return { ok: true };
1000
4220
  }
1001
4221
  /** What is held right now — per-channel epochs and the scope — without releasing. */
1002
4222
  check(opts = {}) {
1003
- const stored = this.hooks.getStoredMessages();
1004
- const deferred = this.hooks.getDeferredMessages?.() ?? [];
1005
- const countOf = (epochId) => [...stored, ...deferred].filter((m) => m.metadata?.tuneOut?.epochId === epochId).length;
4223
+ // Stored, and kept by a surgery's hold until its release (residentMessages).
4224
+ const stored = this.residentMessages();
4225
+ // Held now: an epoch's messages no earlier release of it covered, which
4226
+ // its next release delivers (a release whose end wasn't recorded leaves
4227
+ // the hold in force, and delivered what it covered).
4228
+ const heldOf = (epochId) => {
4229
+ const delivered = keyedBy(releaseRecordsOf(stored, epochId));
4230
+ return (m) => m.metadata?.tuneOut?.epochId === epochId && !delivered(m);
4231
+ };
4232
+ const countOf = (epochId) => stored.filter(heldOf(epochId)).length;
1006
4233
  const channels = this.listActive().map(({ serverId, channelId, params }) => ({
1007
4234
  serverId, channelId, epochId: params.epochId, held: countOf(params.epochId),
1008
4235
  ...(params.expiresAtMs !== undefined ? { expiresAtMs: params.expiresAtMs } : {}),
@@ -1011,7 +4238,8 @@ export class TuneOutCoordinator {
1011
4238
  let allExcept = null;
1012
4239
  if (scope) {
1013
4240
  const held = {};
1014
- for (const { serverId, channelId, count } of this.scopeHeldCounts(scope.params.epochId).values())
4241
+ const isHeld = heldOf(scope.params.epochId);
4242
+ for (const { serverId, channelId, count } of this.scopeHeldCounts(scope.params.epochId, isHeld).values())
1015
4243
  held[this.shown(serverId, channelId)] = count;
1016
4244
  allExcept = { epochId: scope.params.epochId, except: scope.params.except, expiresAtMs: scope.params.expiresAtMs, wakeCount: scope.wakeCount, held };
1017
4245
  }
@@ -1028,10 +4256,12 @@ export class TuneOutCoordinator {
1028
4256
  }
1029
4257
  if (scope)
1030
4258
  active.add(scope.params.epochId);
1031
- const lines = [...stored, ...deferred]
4259
+ // Nor what an earlier release of the epoch delivered, its end not recorded.
4260
+ const heldIn = new Map([...active].map((epochId) => [epochId, heldOf(epochId)]));
4261
+ const lines = stored
1032
4262
  .filter((m) => {
1033
4263
  const meta = m.metadata;
1034
- return typeof meta?.tuneOut?.epochId === 'string' && active.has(meta.tuneOut.epochId)
4264
+ return typeof meta?.tuneOut?.epochId === 'string' && active.has(meta.tuneOut.epochId) && heldIn.get(meta.tuneOut.epochId)(m)
1035
4265
  && meta.channelId === opts.peek.channelId
1036
4266
  && (!opts.peek.serverId || meta.serverId === opts.peek.serverId);
1037
4267
  })
@@ -1229,7 +4459,10 @@ export class TuneOutCoordinator {
1229
4459
  /**
1230
4460
  * One subconscious turn: a system-framed trigger notice (bracket style —
1231
4461
  * what happened, never what to say) plus standing dispositions, then an
1232
- * inference request. The merged view supplies the actual traffic.
4462
+ * inference request. Its view supplies the actual traffic: the merged
4463
+ * record while its window is the record, and with the presentation index
4464
+ * (#291) what its window holds, which takes what it hasn't been shown at
4465
+ * the turn's start, the notice included.
1233
4466
  */
1234
4467
  invokeSubconscious(serverId, channelId, trigger, count) {
1235
4468
  const subName = this.hooks.subconsciousName();
@@ -1240,39 +4473,55 @@ export class TuneOutCoordinator {
1240
4473
  const notice = trigger === 'wake'
1241
4474
  ? `[${NOTICE_TAG.tuneOutWake}: ${shown} — ${count} addressed message${count === 1 ? '' : 's'}]`
1242
4475
  : `[${NOTICE_TAG.tuneOutCadence}: ${shown} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`;
4476
+ const ofChannel = inChannelOf(serverId, channelId);
4477
+ const heldIn = (epochId) => (m) => m.metadata?.tuneOut?.epochId === epochId && ofChannel(m);
1243
4478
  if (!subName) {
1244
4479
  const hold = this.holdFor(serverId, channelId);
1245
4480
  if (!hold)
1246
4481
  return;
1247
- const ofChannel = inChannelOf(serverId, channelId);
1248
- const inChannel = (m) => m.metadata?.tuneOut?.epochId === hold.epochId && ofChannel(m);
1249
- // Stored and still-queued alike: while the resident's turn runs, what
1250
- // arrives waits in the deferred queue, and that is the traffic a reader
1251
- // is there for.
1252
- this.invokeReaderFork(serverId, channelId, hold.epochId, trigger, notice, this.hooks.getStoredMessages().filter(inChannel), this.queuedHeld(inChannel));
4482
+ const inChannel = heldIn(hold.epochId);
4483
+ // A held message is stored as it arrives, the resident's turn alive or
4484
+ // not, and what arrives while the resident is busy is the traffic a
4485
+ // reader is there for.
4486
+ this.invokeReaderFork(serverId, channelId, hold.epochId, trigger, notice, this.hooks.getStoredMessages().filter(inChannel));
1253
4487
  // The count restarts once the fork has been handed what was held, as it
1254
4488
  // does below once the subconscious's turn has been asked for. Otherwise
1255
4489
  // every later cadence period still sees this one's count and derives a
1256
- // fork with nothing new to read.
1257
- this.divertedSinceInvocation.set(key, 0);
4490
+ // fork with nothing new to read. A held message a surgery's hold, or a
4491
+ // manager waiting for its recovery, keeps in memory wasn't handed: it
4492
+ // isn't stored yet. It still counts, so the first cadence period after
4493
+ // it is stored hands it.
4494
+ this.divertedSinceInvocation.set(key, this.keptInMemory(inChannel));
1258
4495
  return;
1259
4496
  }
1260
4497
  this.hooks.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: `tune-out-${trigger}`, channelId }, subName);
1261
4498
  this.hooks.requestInference(subName, `tune-out ${trigger}: ${shown}`, 'tune-out');
1262
4499
  // Counted from here only once the turn has been asked for, so a turn that
1263
- // throws leaves its messages for the next period's turn.
1264
- this.divertedSinceInvocation.set(key, 0);
4500
+ // throws leaves its messages for the next period's turn. A held message
4501
+ // kept in memory isn't in the subconscious's window yet, so it still
4502
+ // counts, as for a fork above, and the first period after it is stored
4503
+ // looks again.
1265
4504
  const epochId = this.holdFor(serverId, channelId)?.epochId;
4505
+ this.divertedSinceInvocation.set(key, epochId ? this.keptInMemory(heldIn(epochId)) : 0);
1266
4506
  if (epochId)
1267
4507
  this.markLook(epochId, serverId, channelId);
1268
4508
  }
4509
+ /** How many of the writes the framework keeps in memory (getSurgeryHeldMessages) `keep` keeps; 0 where they can't be read. */
4510
+ keptInMemory(keep) {
4511
+ try {
4512
+ return (this.hooks.getSurgeryHeldMessages?.() ?? []).filter(keep).length;
4513
+ }
4514
+ catch (err) {
4515
+ console.error('[tune-out] could not read the writes a surgery holds in memory, so none is counted:', err);
4516
+ return 0;
4517
+ }
4518
+ }
1269
4519
  /**
1270
- * Count again, from the held messages, stored or still queued behind the
1271
- * resident's turn, those the reader of the epoch holding this channel now
1272
- * hasn't looked at: for reader forks, those no fork was handed; for the
1273
- * persistent subconscious, the stored ones held since its last look
1274
- * (lastLooks) and every queued one, which no view of it can show yet.
1275
- * Nothing holding the channel leaves no count.
4520
+ * Count again, from the held messages (all stored, #291), those the reader
4521
+ * of the epoch holding this channel now hasn't looked at: for reader
4522
+ * forks, those no fork was handed; for the persistent subconscious, those
4523
+ * held since its last look (lastLooks). Nothing holding the channel leaves
4524
+ * no count.
1276
4525
  */
1277
4526
  rebuildTally(serverId, channelId) {
1278
4527
  const key = this.key(serverId, channelId);
@@ -1284,15 +4533,13 @@ export class TuneOutCoordinator {
1284
4533
  const ofChannel = inChannelOf(serverId, channelId);
1285
4534
  const inEpoch = (m) => m.metadata?.tuneOut?.epochId === hold.epochId && ofChannel(m);
1286
4535
  const rows = this.hooks.getStoredMessages().filter(inEpoch);
1287
- const queued = this.queuedHeld(inEpoch);
1288
4536
  let count;
1289
4537
  if (this.forkReader) {
1290
- const unread = this.unhandedBacklog(this.handOffKey(hold.epochId, serverId, channelId), rows, queued);
1291
- count = unread.backlog.length + unread.queued.length;
4538
+ count = this.unhandedBacklog(this.handOffKey(hold.epochId, serverId, channelId), rows).length;
1292
4539
  }
1293
4540
  else {
1294
4541
  const mark = this.lastLooks()?.[hold.epochId]?.[key];
1295
- count = (mark === undefined ? rows.length : rows.filter((m) => m.sequence > mark).length) + queued.length;
4542
+ count = mark === undefined ? rows.length : rows.filter((m) => m.sequence > mark).length;
1296
4543
  }
1297
4544
  this.divertedSinceInvocation.set(key, count);
1298
4545
  }
@@ -1356,17 +4603,20 @@ export class TuneOutCoordinator {
1356
4603
  const { [epochId]: _ended, ...rest } = looks;
1357
4604
  this.writeLooks(rest);
1358
4605
  }
1359
- /** At boot: the last looks of epochs no longer active go. */
1360
4606
  pruneLooks() {
1361
4607
  if (this.hooks.subconsciousName() == null)
1362
4608
  return;
1363
4609
  const looks = this.lastLooks();
1364
4610
  if (!looks)
1365
4611
  return;
1366
- const active = new Set(this.listActive().map((e) => e.params.epochId));
1367
- const scope = this.getAllExcept();
1368
- if (scope)
1369
- active.add(scope.params.epochId);
4612
+ let active;
4613
+ try {
4614
+ active = new Set(this.holdsOnRecord().map((hold) => hold.epochId));
4615
+ }
4616
+ catch (err) {
4617
+ console.error(`[tune-out] the holds on record could not be read, so the subconscious's last looks are all kept: ${thrownReason(err)}`);
4618
+ return;
4619
+ }
1370
4620
  const kept = Object.fromEntries(Object.entries(looks).filter(([epochId]) => active.has(epochId)));
1371
4621
  if (Object.keys(kept).length !== Object.keys(looks).length)
1372
4622
  this.writeLooks(kept);
@@ -1374,47 +4624,26 @@ export class TuneOutCoordinator {
1374
4624
  /**
1375
4625
  * `reader: 'forks'`: hand the framework one invocation. The backlog is
1376
4626
  * the epoch's held messages not yet handed to any fork — each is
1377
- * delivered to exactly one reader — stored ones first, then those still
1378
- * queued behind the resident's turn, plus the standing dispositions. A
4627
+ * delivered to exactly one reader — plus the standing dispositions. A
1379
4628
  * cadence look with nothing new is skipped. True when a fork was asked for.
1380
4629
  */
1381
- invokeReaderFork(serverId, channelId, epochId, trigger, notice, epochBacklog, epochQueued = []) {
4630
+ invokeReaderFork(serverId, channelId, epochId, trigger, notice, epochBacklog) {
1382
4631
  if (!this.hooks.invokeReader)
1383
4632
  return false;
1384
4633
  const key = this.handOffKey(epochId, serverId, channelId);
1385
- const { backlog, queued } = this.unhandedBacklog(key, epochBacklog, epochQueued);
1386
- if (trigger === 'cadence' && backlog.length === 0 && queued.length === 0)
4634
+ const backlog = this.unhandedBacklog(key, epochBacklog);
4635
+ if (trigger === 'cadence' && backlog.length === 0)
1387
4636
  return false;
1388
4637
  // The cursor only moves forward: a returned row handed again can be
1389
4638
  // behind it.
1390
- const cursor = Math.max(this.readerCursor.get(key) ?? -1, ...backlog.map((m) => m.sequence));
4639
+ const cursor = backlog.reduce((max, m) => Math.max(max, m.sequence), this.readerCursor.get(key) ?? -1);
1391
4640
  if (backlog.length > 0)
1392
4641
  this.readerCursor.set(key, cursor);
1393
- const handed = this.readerHandedWrites.get(key) ?? new Set();
1394
- for (const m of queued)
1395
- handed.add(m.writeId);
1396
- // A handed write whose row has landed behind the cursor can no longer be
1397
- // handed by sequence, so its id has done its work.
1398
- for (const m of epochBacklog) {
1399
- const writeId = writeIdOf(m);
1400
- if (writeId !== undefined && m.sequence <= cursor)
1401
- handed.delete(writeId);
1402
- }
1403
- if (handed.size > 0)
1404
- this.readerHandedWrites.set(key, handed);
1405
- else
1406
- this.readerHandedWrites.delete(key);
1407
4642
  const returned = this.readerReturned.get(key);
1408
4643
  if (returned) {
1409
- for (const m of backlog) {
1410
- returned.ids.delete(m.id);
1411
- const writeId = writeIdOf(m);
1412
- if (writeId !== undefined)
1413
- returned.writeIds.delete(writeId);
1414
- }
1415
- for (const m of queued)
1416
- returned.writeIds.delete(m.writeId);
1417
- if (returned.ids.size === 0 && returned.writeIds.size === 0)
4644
+ for (const m of backlog)
4645
+ returned.delete(m.id);
4646
+ if (returned.size === 0)
1418
4647
  this.readerReturned.delete(key);
1419
4648
  }
1420
4649
  const entries = Object.entries(this.readDispositions());
@@ -1425,32 +4654,21 @@ export class TuneOutCoordinator {
1425
4654
  trigger,
1426
4655
  notice,
1427
4656
  backlog,
1428
- queued,
1429
4657
  dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
1430
4658
  });
1431
4659
  return true;
1432
4660
  }
1433
4661
  /**
1434
- * What no reader fork has read yet, of the given held messages: the stored
1435
- * ones past the epoch's cursor for the channel, less those a fork was handed while they
1436
- * were still queued, plus those a failed fork returned, oldest first; then
1437
- * the queued ones no fork was handed, or a failed fork returned.
4662
+ * What no reader fork has read yet, of the given held messages, oldest
4663
+ * first: those past the epoch's cursor for the channel, plus those a
4664
+ * failed fork returned.
1438
4665
  */
1439
- unhandedBacklog(key, stored, queued) {
4666
+ unhandedBacklog(key, stored) {
1440
4667
  const cursor = this.readerCursor.get(key) ?? -1;
1441
- const handed = this.readerHandedWrites.get(key);
1442
4668
  const returned = this.readerReturned.get(key);
1443
- const wasReturned = (id, writeId) => returned !== undefined && ((id !== undefined && returned.ids.has(id)) || (writeId !== undefined && returned.writeIds.has(writeId)));
1444
- const unread = (m) => {
1445
- const writeId = writeIdOf(m);
1446
- if (wasReturned(m.id, writeId))
1447
- return true;
1448
- return m.sequence > cursor && !(handed !== undefined && writeId !== undefined && handed.has(writeId));
1449
- };
1450
- return {
1451
- backlog: stored.filter(unread).sort((a, b) => a.sequence - b.sequence),
1452
- queued: queued.filter((m) => !handed?.has(m.writeId) || wasReturned(undefined, m.writeId)),
1453
- };
4669
+ return stored
4670
+ .filter((m) => returned?.has(m.id) || m.sequence > cursor)
4671
+ .sort((a, b) => a.sequence - b.sequence);
1454
4672
  }
1455
4673
  /**
1456
4674
  * A reader fork that never read what it was handed: its derive failed, or
@@ -1471,12 +4689,10 @@ export class TuneOutCoordinator {
1471
4689
  if (!active)
1472
4690
  return this.lateFork(invocation);
1473
4691
  const key = this.handOffKey(epochId, serverId, channelId);
1474
- const returned = this.readerReturned.get(key) ?? { ids: new Set(), writeIds: new Set() };
4692
+ const returned = this.readerReturned.get(key) ?? new Set();
1475
4693
  for (const m of invocation.backlog)
1476
- returned.ids.add(m.id);
1477
- for (const m of invocation.queued)
1478
- returned.writeIds.add(m.writeId);
1479
- const count = invocation.backlog.length + invocation.queued.length;
4694
+ returned.add(m.id);
4695
+ const count = invocation.backlog.length;
1480
4696
  if (count > 0) {
1481
4697
  this.readerReturned.set(key, returned);
1482
4698
  // They count again toward the next cadence look of the epoch holding
@@ -1493,25 +4709,17 @@ export class TuneOutCoordinator {
1493
4709
  /**
1494
4710
  * A fork that failed after its epoch ended: one late fork is handed what it
1495
4711
  * was, with a notice saying a reader handed them failed after the tune-out
1496
- * ended. Those still queued when it was handed that have landed since are
1497
- * handed as stored. A late fork's own failure derives no other, so a
1498
- * reader that keeps failing costs one more fork, not a chain of them.
1499
- * Returns how many the late fork was handed.
4712
+ * ended. Every held message is stored as it arrives (#291), so what it was
4713
+ * handed is what the late fork is. A late fork's own failure derives no
4714
+ * other, so a reader that keeps failing costs one more fork, not a chain
4715
+ * of them. Returns how many the late fork was handed.
1500
4716
  */
1501
4717
  lateFork(invocation) {
1502
4718
  if (!this.hooks.invokeReader || this.lateForks.has(invocation))
1503
4719
  return 0;
1504
4720
  const { serverId, channelId, epochId } = invocation;
1505
- const landed = new Map();
1506
- for (const m of this.hooks.getStoredMessages()) {
1507
- const writeId = writeIdOf(m);
1508
- if (writeId !== undefined)
1509
- landed.set(writeId, m);
1510
- }
1511
- const backlog = [...invocation.backlog, ...invocation.queued.flatMap((m) => landed.get(m.writeId) ?? [])]
1512
- .sort((a, b) => a.sequence - b.sequence);
1513
- const queued = invocation.queued.filter((m) => !landed.has(m.writeId));
1514
- const count = backlog.length + queued.length;
4721
+ const backlog = [...invocation.backlog].sort((a, b) => a.sequence - b.sequence);
4722
+ const count = backlog.length;
1515
4723
  if (count === 0)
1516
4724
  return 0;
1517
4725
  const these = count === 1 ? 'This message was' : `These ${count} messages were`;
@@ -1525,23 +4733,12 @@ export class TuneOutCoordinator {
1525
4733
  trigger: 'cancel',
1526
4734
  notice,
1527
4735
  backlog,
1528
- queued,
1529
4736
  dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
1530
4737
  };
1531
4738
  this.lateForks.add(late);
1532
4739
  this.hooks.invokeReader(late);
1533
4740
  return count;
1534
4741
  }
1535
- /**
1536
- * The held messages still queued behind the resident's turn that `keep`
1537
- * keeps, oldest first, with their write ids. One the host gives no id for
1538
- * is left to reach a reader once it is stored: without the id, its stored
1539
- * row couldn't be told from a message no reader has had.
1540
- */
1541
- queuedHeld(keep) {
1542
- return (this.hooks.getDeferredMessages?.() ?? [])
1543
- .filter((m) => typeof m.writeId === 'string' && keep(m));
1544
- }
1545
4742
  // ==========================================================================
1546
4743
  // Dispositions (standing notes, snapshot slot)
1547
4744
  // ==========================================================================
@@ -1624,8 +4821,7 @@ export class TuneOutCoordinator {
1624
4821
  if (!text) {
1625
4822
  return {
1626
4823
  success: false,
1627
- error: 'Nothing was delivered: deliver_summary needs the summary in "text". If there\'s nothing to '
1628
- + "report, don't call it: staying silent is allowed.",
4824
+ error: 'Nothing was delivered: deliver_summary needs the summary in "text".',
1629
4825
  isError: true,
1630
4826
  };
1631
4827
  }
@@ -1644,7 +4840,7 @@ export class TuneOutCoordinator {
1644
4840
  // server, which a re-pin reads by name when it weighs the row, as it
1645
4841
  // reads any message without its server.
1646
4842
  if (!subName && !(caller && this.hooks.deliverFromReader)) {
1647
- return { success: false, error: 'subconscious not configured', isError: true };
4843
+ return { success: false, error: 'Nothing was delivered: no reader is running here.', isError: true };
1648
4844
  }
1649
4845
  const mine = this.channelRegistry.ownChannelFor(channelId, own, 'deliver');
1650
4846
  if (mine && 'error' in mine)
@@ -1679,10 +4875,12 @@ export class TuneOutCoordinator {
1679
4875
  : undefined;
1680
4876
  if (refused)
1681
4877
  return { success: false, error: refused, isError: true };
1682
- const result = this.cancelAllExcept('subconscious', 'subconscious judgment', note, caller);
1683
- return result.ok
1684
- ? { success: true, data: { cancelled: true, scope: 'all-except' } }
1685
- : { success: false, error: result.error, isError: true };
4878
+ const result = this.cancelAllExcept('subconscious', cancelledBy(caller), note, caller);
4879
+ if (!result.ok)
4880
+ return { success: false, error: result.error, isError: true };
4881
+ return result.deferred
4882
+ ? { success: true, data: { waiting: true, scope: 'all-except', note: cancelWaitsNote('The all-except hold', result.noteDropped === true, note === undefined ? undefined : fork ? 'fork' : 'persistent', this.markerPending !== undefined) } }
4883
+ : { success: true, data: { cancelled: true, scope: 'all-except' } };
1686
4884
  };
1687
4885
  // A call naming the all-except scope ends that hold outright, as the
1688
4886
  // tool describes it and as the resident's tune_out cancel does: the
@@ -1733,10 +4931,14 @@ export class TuneOutCoordinator {
1733
4931
  const refused = fork && this.forkRefusal(fork, { epochId: held.params.epochId }, 'cancel', `the tune-out on ${this.quotedShown(channel.serverId, channel.channelId)} isn't the hold you were started to read`);
1734
4932
  if (refused)
1735
4933
  return { success: false, error: refused, isError: true };
1736
- const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note, caller);
1737
- return result.ok
1738
- ? { success: true, data: { cancelled: true } }
1739
- : { success: false, error: result.error, isError: true };
4934
+ const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', cancelledBy(caller), note, caller);
4935
+ if (!result.ok)
4936
+ return { success: false, error: result.error, isError: true };
4937
+ return result.deferred
4938
+ // The server it resolved to, which a boot reads to tell two
4939
+ // servers' channels of one id apart (askedCancelOf).
4940
+ ? { success: true, data: { waiting: true, serverId: channel.serverId, note: cancelWaitsNote('The tune-out', result.noteDropped === true, note === undefined ? undefined : fork ? 'fork' : 'persistent', this.markerPending !== undefined) } }
4941
+ : { success: true, data: { cancelled: true } };
1740
4942
  }
1741
4943
  case 'note_disposition': {
1742
4944
  const key = String(input.key ?? '').trim();
@@ -1774,9 +4976,9 @@ export class TuneOutCoordinator {
1774
4976
  success: false,
1775
4977
  isError: true,
1776
4978
  error: "Nothing was posted: speak_in_channel is turned off for you in the resident's configuration, and "
1777
- + "calling it again won't change that. The resident can speak there: tell it what's needed with "
1778
- + 'deliver_summary, or, if the channel is tuned out and needs the resident now, end the tune-out with '
1779
- + 'cancel_tuneout.',
4979
+ + "calling it again won't change that. The resident can speak there. deliver_summary puts your text in "
4980
+ + "the resident's context, and cancel_tuneout ends the tune-out and delivers its backlog to the resident, up "
4981
+ + 'to its backlog cap (its answer says if the cancel has to wait).',
1780
4982
  };
1781
4983
  }
1782
4984
  const channelId = String(input.channelId ?? '');
@@ -1803,7 +5005,7 @@ export class TuneOutCoordinator {
1803
5005
  return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? caller ?? 'Subconscious');
1804
5006
  }
1805
5007
  default:
1806
- return { success: false, error: `unknown subconscious tool: ${name}`, isError: true };
5008
+ return { success: false, error: `unknown reader tool: ${name}`, isError: true };
1807
5009
  }
1808
5010
  }
1809
5011
  /**
@@ -1847,8 +5049,9 @@ export class TuneOutCoordinator {
1847
5049
  }
1848
5050
  /**
1849
5051
  * The reader ended, not the framework: its turns stop (cadence and
1850
- * coalescing wakes), and every hold keeps its end time, which releases it
1851
- * to the resident with no reader.
5052
+ * coalescing wakes). The framework then releases every hold with
5053
+ * releaseAll ("its reader ended"); a hold whose release fails keeps its end
5054
+ * time, which releases it to the resident with no reader.
1852
5055
  */
1853
5056
  stopReader() {
1854
5057
  for (const timer of this.cadenceTimers.values())
@@ -1858,8 +5061,168 @@ export class TuneOutCoordinator {
1858
5061
  clearTimeout(timer);
1859
5062
  this.pendingWakes.clear();
1860
5063
  }
1861
- /** Stop all timers (framework shutdown). */
5064
+ /**
5065
+ * The stop's keeping of the loss notices kept (stop): the persistent
5066
+ * subconscious's in the store (writeLossRecord), the stop stamped
5067
+ * (writeStopStamp, leaving out `unaccounted`) at the stop (`stamp`
5068
+ * 'stop'), or again where the store refused the record (`unrecorded`),
5069
+ * and each logged and dropped from memory. Never throws.
5070
+ */
5071
+ keepAtStop(stamp, unaccounted) {
5072
+ const recorded = this.writeLossRecord('stop');
5073
+ if (stamp === 'stop' || (stamp === 'unrecorded' && !recorded))
5074
+ this.writeStopStamp(recorded, unaccounted);
5075
+ this.markerPending = undefined;
5076
+ for (const [author, kept] of this.lossNotices) {
5077
+ for (const notice of kept) {
5078
+ const told = `the notice telling ${author} ${lossSubject(notice)}`;
5079
+ if (notice.persistent && recorded) {
5080
+ console.error(`[tune-out] stopping: ${told} isn't written yet, and is kept in the store for the next boot to write`);
5081
+ }
5082
+ else if (notice.persistent) {
5083
+ console.error(`[tune-out] stopping: ${told} was never written, and the store couldn't keep it, so it is dropped`);
5084
+ }
5085
+ else {
5086
+ console.error(`[tune-out] stopping: ${told} was never written, and is dropped: a reader fork's run doesn't outlast a stop`);
5087
+ }
5088
+ }
5089
+ }
5090
+ this.lossNotices.clear();
5091
+ }
5092
+ /**
5093
+ * A loss told once the stop had kept what it found (a trace listener's
5094
+ * stop, during a queued cancel's run or a notice's write, that found the
5095
+ * cancel or the notice off its queue): kept as the stop keeps those
5096
+ * (keepAtStop), the store's record read in again first, so that what the
5097
+ * stop kept there stays, and the stamp written again, its hold left out,
5098
+ * where the store refuses the record. Never throws.
5099
+ */
5100
+ keepAfterStop() {
5101
+ if (this.lossNotices.size === 0)
5102
+ return;
5103
+ if (this.persistedLossNotices().length > 0)
5104
+ this.lossRecordUnread = true;
5105
+ this.keepAtStop('unrecorded', this.stopUnaccounted);
5106
+ }
5107
+ /**
5108
+ * The timers quieted, at the start of the framework's stop: a hold's end
5109
+ * time, a reader's cadence and wake, a release's or a waiting cancel's
5110
+ * retry. Nothing else stops: a waiting cancel still runs when the
5111
+ * framework stores the writes it waits for (runDeferredCancels), which the
5112
+ * stop's wait for an operator's operation can let happen; then stop()
5113
+ * tells and keeps what is left (Weft's hold 2, #844). Never throws.
5114
+ */
5115
+ stopTimers() {
5116
+ this.timersStopped = true;
5117
+ if (this.releaseRetry) {
5118
+ clearTimeout(this.releaseRetry);
5119
+ this.releaseRetry = null;
5120
+ }
5121
+ if (this.deferredRetry !== undefined)
5122
+ clearTimeout(this.deferredRetry);
5123
+ this.deferredRetry = undefined;
5124
+ for (const timer of this.cadenceTimers.values())
5125
+ clearTimeout(timer);
5126
+ this.cadenceTimers.clear();
5127
+ for (const timer of this.expiryTimers.values())
5128
+ clearTimeout(timer);
5129
+ this.expiryTimers.clear();
5130
+ for (const { timer } of this.pendingWakes.values())
5131
+ clearTimeout(timer);
5132
+ this.pendingWakes.clear();
5133
+ }
5134
+ /**
5135
+ * Stop all timers (framework shutdown), tell each waiting cancel's note
5136
+ * lost (one still kept in memory left for the next boot instead, its hold
5137
+ * out of the stamp: a release after the stop may store it), keep the
5138
+ * persistent subconscious's notices in the store, and
5139
+ * stamp the stop (writeStopStamp). A second stop stamps nothing: the
5140
+ * first one's notices are no longer in memory to say what it kept. The
5141
+ * framework calls it once its stop has waited for an operator's
5142
+ * operations, so a hold that keeps a waiting cancel's writes, released
5143
+ * during that wait, lets the cancel run first (stopTimers, before the
5144
+ * wait, quiets the timers).
5145
+ */
1862
5146
  stop() {
5147
+ const again = this.stopped;
5148
+ this.stopped = true;
5149
+ if (this.releaseRetry) {
5150
+ clearTimeout(this.releaseRetry);
5151
+ this.releaseRetry = null;
5152
+ }
5153
+ // A cancel still waiting for held writes ends nothing: the tune-out stays
5154
+ // in force on disk, for the next boot, which doesn't run the cancel
5155
+ // again. A note it carries is lost with it, and told lost as any other
5156
+ // (dropDeferred: `stopped`, or `hold-ended` where its hold ended without
5157
+ // it), the notice kept, not written (tellNoteLost). A loss notice not
5158
+ // written yet for the persistent subconscious, that one included, stays
5159
+ // kept in the store (writeLossRecord), for the next boot to write
5160
+ // (restoreLossNotices); a reader fork's, whose run doesn't outlast the
5161
+ // stop, is dropped, as is one the store couldn't keep, which the stamp
5162
+ // then leaves out (writeStopStamp), for the next boot to tell. Each is
5163
+ // logged, naming its author.
5164
+ // Every one is logged and traced, one with no note, or one whose note
5165
+ // was delivered or told lost already, as one that loses no note.
5166
+ // One whose note, or the release telling its loss, is still kept in
5167
+ // memory (heldInMemory: a hold this stop's wait for an operator's
5168
+ // operation didn't see end, its budget run out, or a manager refusing
5169
+ // its work) isn't told lost: a release after the stop, in this process,
5170
+ // may store it yet. For the persistent subconscious, its hold, still in
5171
+ // force, is left out of the stamp as one whose note was kept
5172
+ // (notesKept), and the next boot tells it what that run's rows show
5173
+ // then, that the note may be lost where they don't hold it
5174
+ // (noteUnstampedStop; AF #844, the stop's budget, nissa's house). A
5175
+ // reader fork's is dropped as before: no fork outlives the stop.
5176
+ const waiting = [...this.deferredCancels.values()];
5177
+ this.deferredCancels.clear();
5178
+ const unaccounted = new Set();
5179
+ const notesKept = new Set();
5180
+ let persistent;
5181
+ try {
5182
+ persistent = !!this.hooks.subconsciousName();
5183
+ }
5184
+ catch {
5185
+ persistent = false;
5186
+ }
5187
+ for (const entry of waiting) {
5188
+ try {
5189
+ let now;
5190
+ let read = true;
5191
+ try {
5192
+ now = entry.for.current();
5193
+ }
5194
+ catch {
5195
+ read = false;
5196
+ }
5197
+ if (persistent && entry.note !== undefined && entry.heldInMemory && (!read || now === entry.for.epochId)) {
5198
+ const { note: _kept, ...cancel } = entry;
5199
+ this.dropDeferred(cancel, { reason: 'stopped' });
5200
+ unaccounted.add(entry.for.epochId);
5201
+ notesKept.add(entry.for.epochId);
5202
+ console.error(`[tune-out] stopping: the note left with the cancel of ${entry.what} is still kept in memory, to be stored when that hold ends, `
5203
+ + "so it isn't told lost: the stop's stamp leaves its hold out, and the next boot tells what the store holds of it then");
5204
+ continue;
5205
+ }
5206
+ this.dropQueued(entry, read && now !== entry.for.epochId
5207
+ ? { reason: 'hold-ended', ...(now !== undefined ? { current: now } : {}) }
5208
+ : { reason: 'stopped' });
5209
+ }
5210
+ catch (err) {
5211
+ const noteLost = entry.note !== undefined && !entry.noteSettled;
5212
+ if (noteLost)
5213
+ unaccounted.add(entry.for.epochId);
5214
+ console.error(`[tune-out] stopping: ${noteLost ? 'the note left with ' : ''}the cancel of ${entry.what} is lost, and ${noteLost ? 'its loss' : 'that'} could not be kept: ${thrownReason(err)}`);
5215
+ }
5216
+ }
5217
+ if (!again) {
5218
+ this.stopUnaccounted = unaccounted;
5219
+ this.stopNotesKept = notesKept;
5220
+ }
5221
+ this.keepAtStop(again ? 'none' : 'stop', unaccounted);
5222
+ this.stopDone = true;
5223
+ if (this.deferredRetry !== undefined)
5224
+ clearTimeout(this.deferredRetry);
5225
+ this.deferredRetry = undefined;
1863
5226
  for (const timer of this.cadenceTimers.values())
1864
5227
  clearTimeout(timer);
1865
5228
  this.cadenceTimers.clear();