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