@animalabs/agent-framework 0.31.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.
- package/README.md +204 -14
- package/dist/src/admission/admit.d.ts +18 -0
- package/dist/src/admission/admit.d.ts.map +1 -1
- package/dist/src/admission/admit.js +16 -3
- package/dist/src/admission/admit.js.map +1 -1
- package/dist/src/agent.d.ts +185 -78
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +279 -102
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server-core.d.ts.map +1 -1
- package/dist/src/api/mcp-server-core.js +50 -12
- package/dist/src/api/mcp-server-core.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +4 -20
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/code-execution/isolate/scripts.d.ts +12 -8
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/scripts.js +24 -0
- package/dist/src/code-execution/isolate/scripts.js.map +1 -1
- package/dist/src/config-checks.d.ts +7 -0
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +14 -0
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/context-manager-errors.d.ts +25 -0
- package/dist/src/context-manager-errors.d.ts.map +1 -0
- package/dist/src/context-manager-errors.js +31 -0
- package/dist/src/context-manager-errors.js.map +1 -0
- package/dist/src/context-options.d.ts +67 -0
- package/dist/src/context-options.d.ts.map +1 -0
- package/dist/src/context-options.js +85 -0
- package/dist/src/context-options.js.map +1 -0
- package/dist/src/deferred-import.d.ts +93 -0
- package/dist/src/deferred-import.d.ts.map +1 -0
- package/dist/src/deferred-import.js +177 -0
- package/dist/src/deferred-import.js.map +1 -0
- package/dist/src/dendrite/audience.d.ts +223 -0
- package/dist/src/dendrite/audience.d.ts.map +1 -0
- package/dist/src/dendrite/audience.js +202 -0
- package/dist/src/dendrite/audience.js.map +1 -0
- package/dist/src/dendrite/index.d.ts +5 -3
- package/dist/src/dendrite/index.d.ts.map +1 -1
- package/dist/src/dendrite/index.js +2 -1
- package/dist/src/dendrite/index.js.map +1 -1
- package/dist/src/dendrite/presets.d.ts +10 -5
- package/dist/src/dendrite/presets.d.ts.map +1 -1
- package/dist/src/dendrite/presets.js +17 -5
- package/dist/src/dendrite/presets.js.map +1 -1
- package/dist/src/dendrite/registry.d.ts +62 -7
- package/dist/src/dendrite/registry.d.ts.map +1 -1
- package/dist/src/dendrite/registry.js +179 -10
- package/dist/src/dendrite/registry.js.map +1 -1
- package/dist/src/dendrite/types.d.ts +91 -8
- package/dist/src/dendrite/types.d.ts.map +1 -1
- package/dist/src/foreign-rounds.d.ts +69 -0
- package/dist/src/foreign-rounds.d.ts.map +1 -0
- package/dist/src/foreign-rounds.js +152 -0
- package/dist/src/foreign-rounds.js.map +1 -0
- package/dist/src/framework.d.ts +1977 -820
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +7571 -3457
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +85 -15
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +421 -47
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +1 -1
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/gate/rule-scope.d.ts +30 -0
- package/dist/src/gate/rule-scope.d.ts.map +1 -0
- package/dist/src/gate/rule-scope.js +41 -0
- package/dist/src/gate/rule-scope.js.map +1 -0
- package/dist/src/gate/suppress-script-worker.d.ts +2 -0
- package/dist/src/gate/suppress-script-worker.d.ts.map +1 -0
- package/dist/src/gate/suppress-script-worker.js +103 -0
- package/dist/src/gate/suppress-script-worker.js.map +1 -0
- package/dist/src/gate/suppress-scripts.d.ts +105 -0
- package/dist/src/gate/suppress-scripts.d.ts.map +1 -0
- package/dist/src/gate/suppress-scripts.js +403 -0
- package/dist/src/gate/suppress-scripts.js.map +1 -0
- package/dist/src/gate/suppression-review.d.ts +143 -0
- package/dist/src/gate/suppression-review.d.ts.map +1 -0
- package/dist/src/gate/suppression-review.js +463 -0
- package/dist/src/gate/suppression-review.js.map +1 -0
- package/dist/src/gate/suppression-stats.d.ts +125 -0
- package/dist/src/gate/suppression-stats.d.ts.map +1 -0
- package/dist/src/gate/suppression-stats.js +214 -0
- package/dist/src/gate/suppression-stats.js.map +1 -0
- package/dist/src/gate/types.d.ts +135 -7
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/gate/wake-onboarding.d.ts +119 -0
- package/dist/src/gate/wake-onboarding.d.ts.map +1 -0
- package/dist/src/gate/wake-onboarding.js +277 -0
- package/dist/src/gate/wake-onboarding.js.map +1 -0
- package/dist/src/held-writes.d.ts +31 -23
- package/dist/src/held-writes.d.ts.map +1 -1
- package/dist/src/held-writes.js +44 -59
- package/dist/src/held-writes.js.map +1 -1
- package/dist/src/index.d.ts +5 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/jsonrpc-envelope.d.ts +69 -0
- package/dist/src/jsonrpc-envelope.d.ts.map +1 -0
- package/dist/src/jsonrpc-envelope.js +66 -0
- package/dist/src/jsonrpc-envelope.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +131 -15
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +290 -46
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts +2 -4
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +2 -4
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +2 -1
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +2 -1
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +6 -6
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +132 -22
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +105 -15
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +9 -7
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +13 -10
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +134 -56
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
- package/dist/src/mcpl/tool-classes.js +3 -0
- package/dist/src/mcpl/tool-classes.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +8 -2
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +24 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +68 -18
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +45 -8
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +237 -86
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +59 -0
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +432 -67
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +6 -5
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +15 -22
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +1 -1
- package/dist/src/modules/workspace/index.d.ts +28 -7
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +76 -22
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/notice-tags.d.ts +10 -1
- package/dist/src/notice-tags.d.ts.map +1 -1
- package/dist/src/notice-tags.js +10 -1
- package/dist/src/notice-tags.js.map +1 -1
- package/dist/src/operator-log.d.ts +36 -3
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js +2 -2
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/presentation.d.ts +746 -0
- package/dist/src/presentation.d.ts.map +1 -0
- package/dist/src/presentation.js +1435 -0
- package/dist/src/presentation.js.map +1 -0
- package/dist/src/prose-drafts.d.ts +15 -11
- package/dist/src/prose-drafts.d.ts.map +1 -1
- package/dist/src/prose-drafts.js +16 -12
- package/dist/src/prose-drafts.js.map +1 -1
- package/dist/src/read-marks.d.ts +192 -0
- package/dist/src/read-marks.d.ts.map +1 -0
- package/dist/src/read-marks.js +242 -0
- package/dist/src/read-marks.js.map +1 -0
- package/dist/src/recovery/offline-branch.d.ts +91 -6
- package/dist/src/recovery/offline-branch.d.ts.map +1 -1
- package/dist/src/recovery/offline-branch.js +273 -29
- package/dist/src/recovery/offline-branch.js.map +1 -1
- package/dist/src/recovery/recover-cli.js +22 -3
- package/dist/src/recovery/recover-cli.js.map +1 -1
- package/dist/src/send-tools.d.ts +5 -9
- package/dist/src/send-tools.d.ts.map +1 -1
- package/dist/src/send-tools.js +40 -7
- package/dist/src/send-tools.js.map +1 -1
- package/dist/src/stored-injections.d.ts +2 -2
- package/dist/src/stored-injections.js +2 -2
- package/dist/src/stored-removal.d.ts +17 -4
- package/dist/src/stored-removal.d.ts.map +1 -1
- package/dist/src/stored-removal.js +29 -5
- package/dist/src/stored-removal.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +12 -1
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +42 -8
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +1159 -79
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +3709 -363
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +114 -17
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/tune-out/tools.js +87 -27
- package/dist/src/tune-out/tools.js.map +1 -1
- package/dist/src/tune-out/window-anchor.d.ts +143 -0
- package/dist/src/tune-out/window-anchor.d.ts.map +1 -0
- package/dist/src/tune-out/window-anchor.js +202 -0
- package/dist/src/tune-out/window-anchor.js.map +1 -0
- package/dist/src/types/agent.d.ts +86 -31
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +2 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +43 -21
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +180 -69
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +510 -36
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/admission/admit.ts +32 -3
- package/src/agent.ts +323 -120
- package/src/api/mcp-server-core.ts +54 -17
- package/src/api/server.ts +4 -23
- package/src/code-execution/isolate/scripts.ts +28 -3
- package/src/config-checks.ts +14 -0
- package/src/context-manager-errors.ts +37 -0
- package/src/context-options.ts +96 -0
- package/src/deferred-import.ts +239 -0
- package/src/dendrite/audience.ts +315 -0
- package/src/dendrite/index.ts +14 -2
- package/src/dendrite/presets.ts +18 -6
- package/src/dendrite/registry.ts +209 -12
- package/src/dendrite/types.ts +92 -8
- package/src/foreign-rounds.ts +199 -0
- package/src/framework.ts +7814 -3585
- package/src/gate/event-gate.ts +428 -65
- package/src/gate/index.ts +3 -0
- package/src/gate/rule-scope.ts +45 -0
- package/src/gate/suppress-script-worker.ts +89 -0
- package/src/gate/suppress-scripts.ts +414 -0
- package/src/gate/suppression-review.ts +517 -0
- package/src/gate/suppression-stats.ts +314 -0
- package/src/gate/types.ts +141 -7
- package/src/gate/wake-onboarding.ts +309 -0
- package/src/held-writes.ts +49 -60
- package/src/index.ts +9 -2
- package/src/jsonrpc-envelope.ts +81 -0
- package/src/mcpl/channel-registry.ts +353 -85
- package/src/mcpl/checkpoint-manager.ts +3 -2
- package/src/mcpl/feature-set-manager.ts +2 -1
- package/src/mcpl/inbound-source.ts +2 -1
- package/src/mcpl/inference-router.ts +7 -4
- package/src/mcpl/push-coalescer.ts +193 -34
- package/src/mcpl/push-handler.ts +10 -5
- package/src/mcpl/server-connection.ts +141 -60
- package/src/mcpl/tool-classes.ts +3 -0
- package/src/mcpl/types.ts +8 -2
- package/src/module-registry.ts +73 -17
- package/src/modules/discord/index.ts +229 -81
- package/src/modules/history/index.ts +439 -70
- package/src/modules/history/semantic.ts +15 -21
- package/src/modules/workspace/disk-agreement.ts +1 -1
- package/src/modules/workspace/index.ts +85 -23
- package/src/notice-tags.ts +10 -1
- package/src/operator-log.ts +36 -3
- package/src/presentation.ts +1676 -0
- package/src/prose-drafts.ts +16 -12
- package/src/read-marks.ts +328 -0
- package/src/recovery/offline-branch.ts +346 -32
- package/src/recovery/recover-cli.ts +21 -3
- package/src/send-tools.ts +40 -7
- package/src/stored-injections.ts +2 -2
- package/src/stored-removal.ts +31 -7
- package/src/tool-result-guard.ts +41 -7
- package/src/tune-out/coordinator.ts +4089 -457
- package/src/tune-out/tools.ts +160 -43
- package/src/tune-out/window-anchor.ts +277 -0
- package/src/types/agent.ts +90 -31
- package/src/types/events.ts +2 -0
- package/src/types/framework.ts +43 -21
- package/src/types/module.ts +181 -68
- package/src/types/trace.ts +542 -40
- package/dist/src/reasoning-loss-notice.d.ts +0 -23
- package/dist/src/reasoning-loss-notice.d.ts.map +0 -1
- package/dist/src/reasoning-loss-notice.js +0 -52
- package/dist/src/reasoning-loss-notice.js.map +0 -1
- package/dist/src/reasoning-loss.d.ts +0 -24
- package/dist/src/reasoning-loss.d.ts.map +0 -1
- package/dist/src/reasoning-loss.js +0 -102
- package/dist/src/reasoning-loss.js.map +0 -1
- package/src/reasoning-loss-notice.ts +0 -64
- package/src/reasoning-loss.ts +0 -120
|
@@ -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
|
-
/**
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
196
|
-
* (readerFailed). The next invocation is handed them again,
|
|
197
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
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
|
-
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
this.
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
513
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
// a
|
|
596
|
-
//
|
|
597
|
-
|
|
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:
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
//
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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.
|
|
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.
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
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
|
-
|
|
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
|
|
652
|
-
? `
|
|
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}${
|
|
655
|
-
|
|
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,
|
|
665
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
const
|
|
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:
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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.
|
|
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.
|
|
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
|
|
969
|
-
//
|
|
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
|
-
|
|
984
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1004
|
-
const
|
|
1005
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1248
|
-
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 —
|
|
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
|
|
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
|
|
1386
|
-
if (trigger === 'cadence' && backlog.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.
|
|
1411
|
-
|
|
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
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
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
|
|
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
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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) ??
|
|
4675
|
+
const returned = this.readerReturned.get(key) ?? new Set();
|
|
1475
4676
|
for (const m of invocation.backlog)
|
|
1476
|
-
returned.
|
|
1477
|
-
|
|
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.
|
|
1497
|
-
* handed
|
|
1498
|
-
* reader that keeps failing costs one more fork, not a chain
|
|
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
|
|
1506
|
-
|
|
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".
|
|
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: '
|
|
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',
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
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',
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
|
1778
|
-
+ '
|
|
1779
|
-
+ '
|
|
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
|
|
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)
|
|
1851
|
-
*
|
|
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
|
-
/**
|
|
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();
|