@animalabs/agent-framework 0.29.0 → 0.30.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/dist/src/admission/admit.d.ts +90 -0
- package/dist/src/admission/admit.d.ts.map +1 -0
- package/dist/src/admission/admit.js +43 -0
- package/dist/src/admission/admit.js.map +1 -0
- package/dist/src/agent.d.ts +21 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +43 -6
- package/dist/src/agent.js.map +1 -1
- package/dist/src/author-presentation.d.ts +50 -0
- package/dist/src/author-presentation.d.ts.map +1 -0
- package/dist/src/author-presentation.js +58 -0
- package/dist/src/author-presentation.js.map +1 -0
- package/dist/src/dendrite/index.d.ts +6 -0
- package/dist/src/dendrite/index.d.ts.map +1 -0
- package/dist/src/dendrite/index.js +3 -0
- package/dist/src/dendrite/index.js.map +1 -0
- package/dist/src/dendrite/presets.d.ts +60 -0
- package/dist/src/dendrite/presets.d.ts.map +1 -0
- package/dist/src/dendrite/presets.js +117 -0
- package/dist/src/dendrite/presets.js.map +1 -0
- package/dist/src/dendrite/registry.d.ts +260 -0
- package/dist/src/dendrite/registry.d.ts.map +1 -0
- package/dist/src/dendrite/registry.js +742 -0
- package/dist/src/dendrite/registry.js.map +1 -0
- package/dist/src/dendrite/types.d.ts +392 -0
- package/dist/src/dendrite/types.d.ts.map +1 -0
- package/dist/src/dendrite/types.js +24 -0
- package/dist/src/dendrite/types.js.map +1 -0
- package/dist/src/framework.d.ts +684 -33
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +2998 -350
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +12 -4
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +25 -5
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/host-presentation.d.ts +4 -0
- package/dist/src/host-presentation.d.ts.map +1 -1
- package/dist/src/host-presentation.js +7 -3
- package/dist/src/host-presentation.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 +6 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +84 -9
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +136 -58
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +9 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +21 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
- package/dist/src/mcpl/toolsets-module.js +7 -0
- package/dist/src/mcpl/toolsets-module.js.map +1 -1
- package/dist/src/notice-tags.d.ts +2 -0
- package/dist/src/notice-tags.d.ts.map +1 -1
- package/dist/src/notice-tags.js +2 -0
- package/dist/src/notice-tags.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +4 -2
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +7 -4
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/stored-injections.d.ts +4 -0
- package/dist/src/stored-injections.d.ts.map +1 -1
- package/dist/src/stored-injections.js +6 -0
- package/dist/src/stored-injections.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +379 -9
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +1080 -97
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +101 -5
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/tune-out/tools.js +55 -18
- package/dist/src/tune-out/tools.js.map +1 -1
- package/dist/src/types/agent.d.ts +25 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +99 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +136 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/admission/admit.ts +113 -0
- package/src/agent.ts +48 -6
- package/src/author-presentation.ts +81 -0
- package/src/dendrite/index.ts +32 -0
- package/src/dendrite/presets.ts +149 -0
- package/src/dendrite/registry.ts +808 -0
- package/src/dendrite/types.ts +397 -0
- package/src/framework.ts +3239 -369
- package/src/gate/event-gate.ts +31 -5
- package/src/host-presentation.ts +10 -3
- package/src/index.ts +15 -2
- package/src/mcpl/channel-registry.ts +170 -60
- package/src/mcpl/push-coalescer.ts +19 -0
- package/src/mcpl/toolsets-module.ts +7 -0
- package/src/notice-tags.ts +2 -0
- package/src/silent-heartbeat.ts +7 -4
- package/src/stored-injections.ts +7 -0
- package/src/tune-out/coordinator.ts +1229 -98
- package/src/tune-out/tools.ts +152 -11
- package/src/types/agent.ts +28 -0
- package/src/types/framework.ts +102 -0
- package/src/types/trace.ts +150 -0
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { NOTICE_TAG } from '../notice-tags.js';
|
|
19
|
-
import { cutToFit, suppliedValue } from '../supplied-text.js';
|
|
19
|
+
import { cutToFit, quotedValue, suppliedValue } from '../supplied-text.js';
|
|
20
20
|
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { authorLine, authorNamed, beginsWithAuthorLine } from '../author-presentation.js';
|
|
21
22
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
22
23
|
import type { StoredMessage } from '@animalabs/context-manager';
|
|
23
24
|
import type { ChannelRef, ChannelRegistry, TuneOutParams } from '../mcpl/channel-registry.js';
|
|
@@ -25,6 +26,9 @@ import type { McplServerRegistry } from '../mcpl/server-registry.js';
|
|
|
25
26
|
import { CapabilityGrant } from '../mcpl/capability-grant.js';
|
|
26
27
|
import { armAfter, armAt } from '../deadline-timer.js';
|
|
27
28
|
|
|
29
|
+
/** The options a tune-out call can give. */
|
|
30
|
+
type TuneOutOption = 'durationSeconds' | 'cadenceSeconds' | 'backlogCap' | 'maxWakes';
|
|
31
|
+
|
|
28
32
|
/** Defaults for tune_out tool params. */
|
|
29
33
|
export const TUNE_OUT_DEFAULTS = {
|
|
30
34
|
cadenceSeconds: 1800,
|
|
@@ -36,6 +40,111 @@ export const TUNE_OUT_DEFAULTS = {
|
|
|
36
40
|
* subconscious turn, and max-wakes counts invocations, not messages. */
|
|
37
41
|
const WAKE_COALESCE_MS = 2_000;
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* One backlog line: author, then the message's blocks in order — text as
|
|
45
|
+
* is, anything else named by kind (`[image]`, `[document]`), so a release
|
|
46
|
+
* never drops a block silently (#232 gap 4). The author is named once: a
|
|
47
|
+
* server that renders `Name: body` into the text itself is not doubled.
|
|
48
|
+
* A name the line adds is a value someone supplied, kept to one line with
|
|
49
|
+
* its notice openings marked (authorLine), as main's dump writes it.
|
|
50
|
+
*/
|
|
51
|
+
export function renderHeldLine(m: Pick<StoredMessage, 'participant' | 'content' | 'metadata'>): string {
|
|
52
|
+
const author = authorNamed(m.metadata) ?? m.participant;
|
|
53
|
+
const parts = m.content.map((b: ContentBlock) =>
|
|
54
|
+
b.type === 'text' ? (b as Extract<ContentBlock, { type: 'text' }>).text : `[${b.type}]`,
|
|
55
|
+
);
|
|
56
|
+
const line = parts.filter((p) => p.length > 0).join(' ');
|
|
57
|
+
const firstText = m.content.find((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')?.text;
|
|
58
|
+
return firstText !== undefined && beginsWithAuthorLine(author, firstText) ? line : authorLine(author, line);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One reader invocation under `reader: 'forks'` (see SubconsciousConfig). */
|
|
62
|
+
export interface ReaderInvocation {
|
|
63
|
+
serverId: string;
|
|
64
|
+
channelId: string;
|
|
65
|
+
epochId: string;
|
|
66
|
+
trigger: 'cadence' | 'wake' | 'cancel';
|
|
67
|
+
/** The host-framed notice for this invocation (bracket style). */
|
|
68
|
+
notice: string;
|
|
69
|
+
/** Held messages not yet handed to any reader fork, oldest first. */
|
|
70
|
+
backlog: StoredMessage[];
|
|
71
|
+
/**
|
|
72
|
+
* Held messages not yet handed to any reader fork that are still queued
|
|
73
|
+
* behind the resident's turn (admitted, not yet stored), oldest first.
|
|
74
|
+
* They come after `backlog` in arrival order. A reader is most useful
|
|
75
|
+
* while the resident is busy, which is exactly when its traffic is here
|
|
76
|
+
* rather than in the store.
|
|
77
|
+
*/
|
|
78
|
+
queued: QueuedHeldMessage[];
|
|
79
|
+
/** Standing dispositions, as text, or null when there are none. */
|
|
80
|
+
dispositions: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A held message admitted for the resident but not yet in its store: it was
|
|
85
|
+
* deferred while a turn was alive. `writeId` is the deferred write's durable
|
|
86
|
+
* id, which the stored row carries as `metadata.deferredWriteId` once it
|
|
87
|
+
* lands.
|
|
88
|
+
*/
|
|
89
|
+
export type QueuedHeldMessage = Pick<StoredMessage, 'participant' | 'content' | 'metadata'> & {
|
|
90
|
+
writeId: string;
|
|
91
|
+
/** When the framework first held the write, in ms since the epoch. */
|
|
92
|
+
heldAt?: number;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** The deferred write a stored row landed from, when it came through the queue. */
|
|
96
|
+
function writeIdOf(m: Pick<StoredMessage, 'metadata'>): string | undefined {
|
|
97
|
+
const writeId = (m.metadata as { deferredWriteId?: unknown } | undefined)?.deferredWriteId;
|
|
98
|
+
return typeof writeId === 'string' ? writeId : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a held message is one of this channel's: by its channel id, and by
|
|
103
|
+
* its server where it records one, since two servers can register the same
|
|
104
|
+
* channel id. A message recording neither is taken as the channel's, as
|
|
105
|
+
* before servers were recorded.
|
|
106
|
+
*/
|
|
107
|
+
function inChannelOf(serverId: string, channelId: string): (m: Pick<StoredMessage, 'metadata'>) => boolean {
|
|
108
|
+
return (m) => {
|
|
109
|
+
const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
|
|
110
|
+
return (meta?.channelId ?? channelId) === channelId && (meta?.serverId ?? serverId) === serverId;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The all-except scope (issue #232 §3.2; focus folded into tune-out): a
|
|
116
|
+
* resident-level policy epoch holding every channel but one, for a bounded
|
|
117
|
+
* time. It covers DMs and channels registered after entry, since the
|
|
118
|
+
* decision is "not the exception" rather than a per-channel state.
|
|
119
|
+
*/
|
|
120
|
+
export interface AllExceptParams {
|
|
121
|
+
epochId: string;
|
|
122
|
+
except: { serverId: string; channelId: string };
|
|
123
|
+
cadenceSeconds: number;
|
|
124
|
+
/** Per held channel, at release. */
|
|
125
|
+
backlogCap: number;
|
|
126
|
+
/** Reader wakes before the reader is paused (the hold itself stays). */
|
|
127
|
+
maxWakes: number;
|
|
128
|
+
startedAtSequence: number;
|
|
129
|
+
startedAtMs: number;
|
|
130
|
+
/** Mandatory: the scope always ends on its own. */
|
|
131
|
+
expiresAtMs: number;
|
|
132
|
+
source: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface AllExceptState {
|
|
136
|
+
params: AllExceptParams;
|
|
137
|
+
wakeCount: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `check`: what is held right now, without releasing anything. */
|
|
141
|
+
export interface TuneOutCheck {
|
|
142
|
+
allExcept: { epochId: string; except: { serverId: string; channelId: string }; expiresAtMs: number; wakeCount: number; held: Record<string, number> } | null;
|
|
143
|
+
channels: Array<{ serverId: string; channelId: string; epochId: string; held: number; expiresAtMs?: number }>;
|
|
144
|
+
/** Up to `peekLimit` newest held lines of the peeked channel, oldest first. */
|
|
145
|
+
peek?: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
39
148
|
/** How long after an end time whose cancel threw the cancel is tried again. */
|
|
40
149
|
const EXPIRY_RETRY_MS = 60_000;
|
|
41
150
|
|
|
@@ -82,8 +191,20 @@ export interface TuneOutFrameworkHooks {
|
|
|
82
191
|
) => string;
|
|
83
192
|
/** Queue an inference request for an agent. */
|
|
84
193
|
requestInference: (agentName: string, reason: string, source: string) => void;
|
|
85
|
-
/** The subconscious's registry/participant name. */
|
|
194
|
+
/** The persistent subconscious's registry/participant name, or null. */
|
|
86
195
|
subconsciousName: () => string | null;
|
|
196
|
+
/**
|
|
197
|
+
* `reader: 'forks'`: derive and run one reader fork. Absent in persistent
|
|
198
|
+
* mode. The framework owns the fork's lifecycle; the coordinator owns the
|
|
199
|
+
* epoch.
|
|
200
|
+
*/
|
|
201
|
+
invokeReader?: (invocation: ReaderInvocation) => void;
|
|
202
|
+
/**
|
|
203
|
+
* `reader: 'forks'`: deliver a reader fork's own words to the resident
|
|
204
|
+
* as attributed mail (its name, its incarnation). Absent in persistent
|
|
205
|
+
* mode, where the subconscious writes under its own name directly.
|
|
206
|
+
*/
|
|
207
|
+
deliverFromReader?: (reader: string, content: ContentBlock[], metadata: Record<string, unknown>) => void;
|
|
87
208
|
/** The primary resident's registry name. */
|
|
88
209
|
primaryName: () => string | null;
|
|
89
210
|
/** Read the primary's stored messages (shared slot, unfiltered). */
|
|
@@ -93,7 +214,19 @@ export interface TuneOutFrameworkHooks {
|
|
|
93
214
|
* while a turn was alive), oldest first. A cancel reads them too, so a
|
|
94
215
|
* message held mid-turn is in the backlog it belongs to (#232 gap 7).
|
|
95
216
|
*/
|
|
96
|
-
getDeferredMessages?: () => Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'
|
|
217
|
+
getDeferredMessages?: () => Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'> & { writeId?: string; heldAt?: number }>;
|
|
218
|
+
/**
|
|
219
|
+
* Make what was just delivered durable, including anything the delivery
|
|
220
|
+
* left in the deferred queue: a release flips durable epoch state, so its
|
|
221
|
+
* dump must survive a crash in the same window (#232 gap 6).
|
|
222
|
+
*/
|
|
223
|
+
commitDeliveries?: () => void;
|
|
224
|
+
/**
|
|
225
|
+
* The history-reading tool the resident actually has (e.g. a server's
|
|
226
|
+
* `fetch_history`), or null. A truncation note names it only when it exists
|
|
227
|
+
* (#232 gap 11).
|
|
228
|
+
*/
|
|
229
|
+
historyTool?: () => string | null;
|
|
97
230
|
/**
|
|
98
231
|
* The most characters of text every reader of the residents' shared slot
|
|
99
232
|
* is shown whole in one message: the smallest of their strategies'
|
|
@@ -112,6 +245,19 @@ export interface TuneOutFrameworkHooks {
|
|
|
112
245
|
isPrivilegedAuthor: (authorId: string | null | undefined) => boolean;
|
|
113
246
|
/** speak_in_channel master switch (recipe-side, canary-gated). */
|
|
114
247
|
allowChannelSpeech: () => boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Whether a tune-out may hold a channel with no reader at all — a silent
|
|
250
|
+
* hold (issue #232 §3.3), which the host opts into (`tuneOut.enabled`).
|
|
251
|
+
* Left out, or false, a hold with no reader is refused.
|
|
252
|
+
*/
|
|
253
|
+
holdWithoutReader?: () => boolean;
|
|
254
|
+
/**
|
|
255
|
+
* Post a line into a channel under the resident's name. Used for the
|
|
256
|
+
* all-except scope's entry and release announcements in the excepted
|
|
257
|
+
* channel (Linn, 2026-10-12: the room should hear the door shut, not
|
|
258
|
+
* discover it). Absent when `tuneOut.announce` is off.
|
|
259
|
+
*/
|
|
260
|
+
announce?: (serverId: string, channelId: string, text: string) => Promise<{ success: boolean; error?: string }>;
|
|
115
261
|
emitTrace: (event: { type: string; [key: string]: unknown }) => void;
|
|
116
262
|
}
|
|
117
263
|
|
|
@@ -125,8 +271,60 @@ export class TuneOutCoordinator {
|
|
|
125
271
|
private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
126
272
|
/** Channels with a wake invocation pending inside the coalescing window. */
|
|
127
273
|
private pendingWakes = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>();
|
|
128
|
-
/**
|
|
274
|
+
/**
|
|
275
|
+
* Held messages the channel's reader hasn't looked at, per channel key:
|
|
276
|
+
* what makes a cadence period worth a look. Counted up as messages are
|
|
277
|
+
* held and back to zero at each look; rebuilt from the held rows where
|
|
278
|
+
* memory can't know it (rebuildTally): after a restart, and when a
|
|
279
|
+
* channel's own tune-out ends while the all-except hold still holds it.
|
|
280
|
+
* A failed reader fork's messages count again (readerFailed).
|
|
281
|
+
*/
|
|
129
282
|
private divertedSinceInvocation = new Map<string, number>();
|
|
283
|
+
/**
|
|
284
|
+
* The persistent subconscious's last look at each held channel, per epoch:
|
|
285
|
+
* the store's sequence when the look was asked for. Durable, so that after
|
|
286
|
+
* a restart its next look counts only what was held since (rebuildTally).
|
|
287
|
+
* Reader forks need none: what they were handed is in memory, and after a
|
|
288
|
+
* restart the first fork of an epoch is handed all it has held.
|
|
289
|
+
*/
|
|
290
|
+
private lastLooksCache: Record<string, Record<string, number>> | undefined;
|
|
291
|
+
/**
|
|
292
|
+
* `reader: 'forks'`: the highest sequence of a held message already
|
|
293
|
+
* handed to a reader fork, per epoch and channel (handOffKey). Each held
|
|
294
|
+
* message is delivered to exactly one fork. A channel the all-except hold
|
|
295
|
+
* holds can be taken by a tune-out of its own and given back, and each
|
|
296
|
+
* epoch's readers read that epoch's messages, so what one epoch's readers
|
|
297
|
+
* were handed never moves or clears the other's. In-memory: after a
|
|
298
|
+
* restart the first fork of an epoch is handed everything the epoch has
|
|
299
|
+
* held, which is the right side to err on.
|
|
300
|
+
*/
|
|
301
|
+
private readerCursor = new Map<string, number>();
|
|
302
|
+
/**
|
|
303
|
+
* `reader: 'forks'`: the deferred writes handed to a reader fork while
|
|
304
|
+
* still queued, per epoch and channel. Each lands in the store later as a row
|
|
305
|
+
* stamped with the same id (`metadata.deferredWriteId`), past the cursor;
|
|
306
|
+
* that row is not handed again. Cleared with the cursor.
|
|
307
|
+
*/
|
|
308
|
+
private readerHandedWrites = new Map<string, Set<string>>();
|
|
309
|
+
/**
|
|
310
|
+
* `reader: 'forks'`: held messages a reader fork was handed but never read,
|
|
311
|
+
* per epoch and channel: its derive failed, or its run threw before it reported
|
|
312
|
+
* (readerFailed). The next invocation is handed them again, cursor or no:
|
|
313
|
+
* stored rows by id, and by write id those that were queued when handed
|
|
314
|
+
* (stored or still queued now). Cleared with the cursor.
|
|
315
|
+
*/
|
|
316
|
+
private readerReturned = new Map<string, { ids: Set<string>; writeIds: Set<string> }>();
|
|
317
|
+
/**
|
|
318
|
+
* `reader: 'forks'`: the late forks derived for an ended epoch's messages
|
|
319
|
+
* (readerFailed), so that a late fork's own failure derives no other.
|
|
320
|
+
*/
|
|
321
|
+
private lateForks = new WeakSet<object>();
|
|
322
|
+
/** The all-except scope's durable record, cached (undefined = not read yet). */
|
|
323
|
+
private scopeCache: AllExceptState | null | undefined;
|
|
324
|
+
/** The epoch whose release this process delivered and whose hold is not yet recorded as cleared. */
|
|
325
|
+
private scopeReleased: string | undefined;
|
|
326
|
+
/** Timer key for the scope's cadence and expiry. */
|
|
327
|
+
private static readonly SCOPE_KEY = '\0all-except';
|
|
130
328
|
|
|
131
329
|
constructor(
|
|
132
330
|
private readonly channelRegistry: ChannelRegistry,
|
|
@@ -138,6 +336,28 @@ export class TuneOutCoordinator {
|
|
|
138
336
|
return `${serverId}\0${channelId}`;
|
|
139
337
|
}
|
|
140
338
|
|
|
339
|
+
/** The readers' hand-off bookkeeping's key: one epoch's reading of one channel. */
|
|
340
|
+
private handOffKey(epochId: string, serverId: string, channelId: string): string {
|
|
341
|
+
return `${epochId}\0${serverId}\0${channelId}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** An ended epoch's hand-off bookkeeping goes with it, every channel's. */
|
|
345
|
+
private dropHandOffs(epochId: string): void {
|
|
346
|
+
for (const map of [this.readerCursor, this.readerHandedWrites, this.readerReturned]) {
|
|
347
|
+
for (const key of map.keys()) if (key.startsWith(`${epochId}\0`)) map.delete(key);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A reader exists: the persistent subconscious, or forks on demand. */
|
|
352
|
+
private readerAvailable(): boolean {
|
|
353
|
+
return this.hooks.subconsciousName() != null || typeof this.hooks.invokeReader === 'function';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Reader forks are the configured reader. */
|
|
357
|
+
private get forkReader(): boolean {
|
|
358
|
+
return this.hooks.subconsciousName() == null && typeof this.hooks.invokeReader === 'function';
|
|
359
|
+
}
|
|
360
|
+
|
|
141
361
|
/** A channel as the resident and the subconscious are shown it
|
|
142
362
|
* (ChannelRegistry.nameShown): its id, or, where another server registers
|
|
143
363
|
* the same id, its server-qualified name, which tune_out and
|
|
@@ -168,9 +388,39 @@ export class TuneOutCoordinator {
|
|
|
168
388
|
oldestAnchor = params.startedAtSequence;
|
|
169
389
|
}
|
|
170
390
|
}
|
|
391
|
+
const scope = this.getAllExcept();
|
|
392
|
+
if (scope) {
|
|
393
|
+
if (scope.params.expiresAtMs <= Date.now()) {
|
|
394
|
+
this.cancelAllExcept('duration', 'duration elapsed');
|
|
395
|
+
} else {
|
|
396
|
+
this.armScopeTimers(scope.params);
|
|
397
|
+
if (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor) {
|
|
398
|
+
oldestAnchor = scope.params.startedAtSequence;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
171
402
|
if (oldestAnchor !== null) {
|
|
172
403
|
this.hooks.setSubconsciousAnchor(oldestAnchor);
|
|
173
404
|
}
|
|
405
|
+
// What each held channel's reader hasn't looked at was counted in
|
|
406
|
+
// memory, so it is counted again from the held rows: without it, a
|
|
407
|
+
// channel held across the restart gets no cadence look until something
|
|
408
|
+
// new arrives.
|
|
409
|
+
// Stored and still queued alike: at a quiesced boot, restored writes stay
|
|
410
|
+
// queued until the resume flushes them.
|
|
411
|
+
const held = new Set<string>();
|
|
412
|
+
for (const m of [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])]) {
|
|
413
|
+
const meta = m.metadata as { tuneOut?: { epochId?: string }; serverId?: string; channelId?: string } | undefined;
|
|
414
|
+
const epochId = meta?.tuneOut?.epochId;
|
|
415
|
+
if (!meta || !epochId || !meta.channelId) continue;
|
|
416
|
+
const key = this.key(meta.serverId ?? '', meta.channelId);
|
|
417
|
+
if (!held.has(key) && this.holdFor(meta.serverId ?? '', meta.channelId)?.epochId === epochId) held.add(key);
|
|
418
|
+
}
|
|
419
|
+
for (const key of held) {
|
|
420
|
+
const [serverId, channelId] = key.split('\0') as [string, string];
|
|
421
|
+
this.rebuildTally(serverId, channelId);
|
|
422
|
+
}
|
|
423
|
+
this.pruneLooks();
|
|
174
424
|
}
|
|
175
425
|
|
|
176
426
|
private armExpiry(serverId: string, channelId: string, params: TuneOutParams): void {
|
|
@@ -225,13 +475,65 @@ export class TuneOutCoordinator {
|
|
|
225
475
|
// Enter / cancel
|
|
226
476
|
// ==========================================================================
|
|
227
477
|
|
|
478
|
+
/**
|
|
479
|
+
* The options a tune-out call gives, read as numbers before anything
|
|
480
|
+
* changes (F76). A tool's input reaches here as the model wrote it, and
|
|
481
|
+
* the store can't keep a NaN or an Infinity: its append would throw after
|
|
482
|
+
* the registry had marked the channel tuned out. So each option is read
|
|
483
|
+
* as a number first, a numeric string included, as the sleep tool reads
|
|
484
|
+
* its seconds; null counts as left out. A value that gives no finite
|
|
485
|
+
* number is refused, naming the option and, where it can be left out,
|
|
486
|
+
* what leaving it out means; so is a duration whose end time is too far
|
|
487
|
+
* off to store. `now` is the clock the end time was counted from. One
|
|
488
|
+
* rule for a channel's tune-out and the all-except hold.
|
|
489
|
+
*/
|
|
490
|
+
private readOptions(
|
|
491
|
+
opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown },
|
|
492
|
+
leftOut: Record<TuneOutOption, string | null>,
|
|
493
|
+
): { ok: true; given: Partial<Record<TuneOutOption, number>>; now: number; expiresAtMs: number | undefined } | { ok: false; error: string } {
|
|
494
|
+
const given: Partial<Record<TuneOutOption, number>> = {};
|
|
495
|
+
for (const name of Object.keys(leftOut) as TuneOutOption[]) {
|
|
496
|
+
const value: unknown = opts[name];
|
|
497
|
+
if (value == null) continue;
|
|
498
|
+
const n = Number(value);
|
|
499
|
+
if (!Number.isFinite(n)) {
|
|
500
|
+
const got = typeof value === 'number'
|
|
501
|
+
? String(value)
|
|
502
|
+
: `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`;
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
|
|
506
|
+
+ `and it was ${got}.${leftOut[name] ? ` Leave it out ${leftOut[name]}.` : ''}`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
given[name] = n;
|
|
510
|
+
}
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
const expiresAtMs = given.durationSeconds === undefined
|
|
513
|
+
? undefined
|
|
514
|
+
: now + Math.max(60, given.durationSeconds) * 1000;
|
|
515
|
+
if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept.`
|
|
519
|
+
+ (leftOut.durationSeconds ? ` Leave it out ${leftOut.durationSeconds}.` : ''),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
return { ok: true, given, now, expiresAtMs };
|
|
523
|
+
}
|
|
524
|
+
|
|
228
525
|
enter(
|
|
229
526
|
serverId: string,
|
|
230
527
|
channelId: string,
|
|
231
|
-
opts: { cadenceSeconds?:
|
|
528
|
+
opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown },
|
|
232
529
|
source: string,
|
|
233
530
|
): { ok: true; params: TuneOutParams } | { ok: false; error: string; alreadyTunedOut?: true } {
|
|
234
|
-
|
|
531
|
+
// No reader (issue #232 §3.3): a silent hold, where the host opted into
|
|
532
|
+
// one. Nothing summarizes, nothing wakes, mentions are still
|
|
533
|
+
// acknowledged, the backlog is released on cancel; the resident is told
|
|
534
|
+
// so in the tool result. A host that didn't opt in has no tune_out
|
|
535
|
+
// without a reader.
|
|
536
|
+
if (!this.readerAvailable() && this.hooks.holdWithoutReader?.() !== true) {
|
|
235
537
|
return {
|
|
236
538
|
ok: false,
|
|
237
539
|
error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out '
|
|
@@ -250,45 +552,18 @@ export class TuneOutCoordinator {
|
|
|
250
552
|
}
|
|
251
553
|
const current = this.channelRegistry.getTuneOutState(serverId, channelId);
|
|
252
554
|
if (current) return { ok: false, error: alreadyTunedOutText(this.quotedShown(serverId, channelId), current.params), alreadyTunedOut: true };
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// finite number is refused.
|
|
259
|
-
const leftOut = {
|
|
555
|
+
const scope = this.getAllExcept();
|
|
556
|
+
if (scope && scope.params.except.serverId === serverId && scope.params.except.channelId === channelId) {
|
|
557
|
+
return { ok: false, error: `channel ${this.quotedShown(serverId, channelId)} is the excepted channel of the active all-except hold; cancel that first` };
|
|
558
|
+
}
|
|
559
|
+
const read = this.readOptions(opts, {
|
|
260
560
|
durationSeconds: 'to stay tuned out until you cancel',
|
|
261
561
|
cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
|
|
262
562
|
backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
|
|
263
563
|
maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const value: unknown = opts[name];
|
|
268
|
-
if (value == null) continue;
|
|
269
|
-
const n = Number(value);
|
|
270
|
-
if (!Number.isFinite(n)) {
|
|
271
|
-
const got = typeof value === 'number'
|
|
272
|
-
? String(value)
|
|
273
|
-
: `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`;
|
|
274
|
-
return {
|
|
275
|
-
ok: false,
|
|
276
|
-
error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
|
|
277
|
-
+ `and it was ${got}. Leave it out ${leftOut[name]}.`,
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
given[name] = n;
|
|
281
|
-
}
|
|
282
|
-
const expiresAtMs = given.durationSeconds === undefined
|
|
283
|
-
? undefined
|
|
284
|
-
: Date.now() + Math.max(60, given.durationSeconds) * 1000;
|
|
285
|
-
if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
|
|
286
|
-
return {
|
|
287
|
-
ok: false,
|
|
288
|
-
error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept. `
|
|
289
|
-
+ `Leave it out ${leftOut.durationSeconds}.`,
|
|
290
|
-
};
|
|
291
|
-
}
|
|
564
|
+
});
|
|
565
|
+
if (!read.ok) return read;
|
|
566
|
+
const { given, expiresAtMs } = read;
|
|
292
567
|
const params: TuneOutParams = {
|
|
293
568
|
epochId: randomUUID(),
|
|
294
569
|
cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
|
|
@@ -313,11 +588,18 @@ export class TuneOutCoordinator {
|
|
|
313
588
|
this.hooks.setSubconsciousAnchor(oldest);
|
|
314
589
|
|
|
315
590
|
this.divertedSinceInvocation.set(this.key(serverId, channelId), 0);
|
|
316
|
-
this.startCadence(serverId, channelId, params.cadenceSeconds);
|
|
317
|
-
this.hooks.emitTrace({
|
|
591
|
+
if (this.readerAvailable()) this.startCadence(serverId, channelId, params.cadenceSeconds);
|
|
592
|
+
this.hooks.emitTrace({
|
|
593
|
+
type: 'tune-out:entered', serverId, channelId, epochId: params.epochId, reader: this.readerAvailable(),
|
|
594
|
+
});
|
|
318
595
|
return { ok: true, params };
|
|
319
596
|
}
|
|
320
597
|
|
|
598
|
+
/** Whether a reader (persistent subconscious or reader forks) attends holds. */
|
|
599
|
+
hasReader(): boolean {
|
|
600
|
+
return this.readerAvailable();
|
|
601
|
+
}
|
|
602
|
+
|
|
321
603
|
/**
|
|
322
604
|
* End a tune-out: durable flip, system-framed backlog dump into the
|
|
323
605
|
* resident's window (capped), lifecycle notice, one wake for the
|
|
@@ -331,8 +613,12 @@ export class TuneOutCoordinator {
|
|
|
331
613
|
source: string,
|
|
332
614
|
reason: string,
|
|
333
615
|
subconsciousNote?: string,
|
|
616
|
+
/** `reader: 'forks'`: the reader fork whose note this is. */
|
|
617
|
+
noteAuthor?: string,
|
|
618
|
+
/** The lifecycle state the channel returns to (a tool close ends in 'closed'). */
|
|
619
|
+
nextState: 'open' | 'closed' = 'open',
|
|
334
620
|
): { ok: true } | { ok: false; error: string } {
|
|
335
|
-
const ended = this.channelRegistry.cancelTuneOut(serverId, channelId,
|
|
621
|
+
const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source);
|
|
336
622
|
if (!ended) {
|
|
337
623
|
// Its reader is the resident's tune_out: cancel_tuneout finds the channel first.
|
|
338
624
|
return { ok: false, error: this.notTunedOut(this.quotedShown(serverId, channelId), 'you') };
|
|
@@ -359,13 +645,12 @@ export class TuneOutCoordinator {
|
|
|
359
645
|
const cap = Math.floor(ended.params.backlogCap);
|
|
360
646
|
const authorOf = (m: Pick<StoredMessage, 'participant' | 'metadata'>): string =>
|
|
361
647
|
suppliedValue((m.metadata as { author?: { name?: string } } | undefined)?.author?.name ?? m.participant);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
};
|
|
648
|
+
// A line names its author as the resident knows them and marks what a
|
|
649
|
+
// text line can't carry (`[image]`), so the dump says what it leaves out.
|
|
650
|
+
const lineOf = renderHeldLine;
|
|
651
|
+
// The tool the truncation note names (#232 gap 11): the one the resident
|
|
652
|
+
// has, when the host says; main's `fetch_history` where no host says.
|
|
653
|
+
const historyTool = this.hooks.historyTool ? this.hooks.historyTool() : 'fetch_history';
|
|
369
654
|
// The channel as the resident and the subconscious are shown it.
|
|
370
655
|
const name = this.shown(serverId, channelId);
|
|
371
656
|
const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`;
|
|
@@ -373,7 +658,9 @@ export class TuneOutCoordinator {
|
|
|
373
658
|
const attrs = [
|
|
374
659
|
`channel=${this.quotedShown(serverId, channelId)}`,
|
|
375
660
|
`messages=${backlog.length}`,
|
|
376
|
-
...(truncated > 0
|
|
661
|
+
...(truncated > 0
|
|
662
|
+
? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`]
|
|
663
|
+
: []),
|
|
377
664
|
];
|
|
378
665
|
return `${lifecycle}\n\n<tuned-out-backlog ${attrs.join(' ')}>\n${lines.join('\n')}\n</tuned-out-backlog>`;
|
|
379
666
|
};
|
|
@@ -444,6 +731,8 @@ export class TuneOutCoordinator {
|
|
|
444
731
|
[{ type: 'text', text: render(lines, truncated) }],
|
|
445
732
|
{ system: true, kind: 'tune-out-cancel', channelId, epochId },
|
|
446
733
|
);
|
|
734
|
+
// The epoch is already ended durably; the release must be as durable.
|
|
735
|
+
this.hooks.commitDeliveries?.();
|
|
447
736
|
// The subconscious's own words, under its own name, verbatim.
|
|
448
737
|
const subName = this.hooks.subconsciousName();
|
|
449
738
|
if (subconsciousNote && subName) {
|
|
@@ -454,6 +743,14 @@ export class TuneOutCoordinator {
|
|
|
454
743
|
// reads (repinChannel), where another server's channel can share the id.
|
|
455
744
|
{ kind: 'subconscious-note', channelId, serverId, epochId },
|
|
456
745
|
);
|
|
746
|
+
} else if (subconsciousNote && noteAuthor && this.hooks.deliverFromReader) {
|
|
747
|
+
// A reader fork's note: attributed mail, under the fork's name and
|
|
748
|
+
// incarnation, never the resident's voice.
|
|
749
|
+
this.hooks.deliverFromReader(
|
|
750
|
+
noteAuthor,
|
|
751
|
+
[{ type: 'text', text: subconsciousNote }],
|
|
752
|
+
{ kind: 'subconscious-note', channelId, epochId },
|
|
753
|
+
);
|
|
457
754
|
}
|
|
458
755
|
const primary = this.hooks.primaryName();
|
|
459
756
|
if (primary) {
|
|
@@ -493,7 +790,34 @@ export class TuneOutCoordinator {
|
|
|
493
790
|
);
|
|
494
791
|
}
|
|
495
792
|
this.hooks.requestInference(subName, `tune-out cancelled: ${name}`, 'tune-out');
|
|
793
|
+
} else if (!subName && this.forkReader) {
|
|
794
|
+
// Forks are the reader: one last fork for the epoch, handed what no
|
|
795
|
+
// fork has been handed yet, stored or still queued behind the
|
|
796
|
+
// resident's turn, plus the cancel notice; its report follows the dump.
|
|
797
|
+
// Once the epoch ends, a message still queued lands in the store
|
|
798
|
+
// view-filtered, where nothing else reads it, so it goes to this fork
|
|
799
|
+
// now. On a reader's own cancel, the reader has read what it was
|
|
800
|
+
// handed, so a fork follows only when messages arrived after that.
|
|
801
|
+
const stored = this.hooks.getStoredMessages().filter(ofEpoch);
|
|
802
|
+
const queued = this.queuedHeld(ofEpoch);
|
|
803
|
+
const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
|
|
804
|
+
const unread = rest.backlog.length + rest.queued.length;
|
|
805
|
+
if (!selfCancel || unread > 0) {
|
|
806
|
+
const truncNote = truncated > 0
|
|
807
|
+
? ` ${truncated} of ${backlog.length} diverted messages, the oldest, weren't delivered raw to the resident.`
|
|
808
|
+
: '';
|
|
809
|
+
const lateNote = selfCancel
|
|
810
|
+
? ` ${unread === 1 ? 'This message' : `These ${unread} messages`} arrived after the reader that cancelled was handed its own.`
|
|
811
|
+
: '';
|
|
812
|
+
this.invokeReaderFork(serverId, channelId, epochId, 'cancel', `[${NOTICE_TAG.tuneOutCancelled}: ${name} — ${reason}.${truncNote}${lateNote}]`, stored, queued);
|
|
813
|
+
}
|
|
496
814
|
}
|
|
815
|
+
this.dropHandOffs(epochId);
|
|
816
|
+
this.dropLooks(epochId);
|
|
817
|
+
// Where the all-except hold still holds the channel, its own messages
|
|
818
|
+
// there that its reader hasn't looked at are counted again, as the
|
|
819
|
+
// channel's own tune-out's count stood in for theirs.
|
|
820
|
+
this.rebuildTally(serverId, channelId);
|
|
497
821
|
this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount });
|
|
498
822
|
return { ok: true };
|
|
499
823
|
}
|
|
@@ -518,9 +842,377 @@ export class TuneOutCoordinator {
|
|
|
518
842
|
authorId?: string,
|
|
519
843
|
gatePass = true,
|
|
520
844
|
): { epochId: string } | null {
|
|
845
|
+
const hold = this.holdFor(serverId, channelId);
|
|
846
|
+
if (!hold) return null;
|
|
847
|
+
this.recordHeld(serverId, channelId, messageId, tags, authorId, gatePass);
|
|
848
|
+
return hold;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** The epoch holding this channel, or null. Pure: the admission stage's lookup. */
|
|
852
|
+
holdFor(serverId: string, channelId: string): { epochId: string } | null {
|
|
853
|
+
// Precedence: a per-channel epoch keeps its own hold (cadence, budget,
|
|
854
|
+
// stamp); the all-except scope holds whatever else is not the exception.
|
|
521
855
|
const state = this.channelRegistry.getTuneOutState(serverId, channelId);
|
|
522
|
-
if (
|
|
856
|
+
if (state) return { epochId: state.params.epochId };
|
|
857
|
+
const scope = this.getAllExcept();
|
|
858
|
+
if (scope && !(scope.params.except.serverId === serverId && scope.params.except.channelId === channelId)) {
|
|
859
|
+
return { epochId: scope.params.epochId };
|
|
860
|
+
}
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** The channel unprefixed speech lands in while an all-except hold is active, with its server, else null. */
|
|
865
|
+
focusChannel(): ChannelRef | null {
|
|
866
|
+
const except = this.getAllExcept()?.params.except;
|
|
867
|
+
return except ? { serverId: except.serverId, channelId: except.channelId } : null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ==========================================================================
|
|
871
|
+
// All-except scope
|
|
872
|
+
// ==========================================================================
|
|
873
|
+
|
|
874
|
+
private get scopeStateId(): string {
|
|
875
|
+
return `tune-out/${this.hooks.primaryName() ?? 'default'}/all-except`;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
getAllExcept(): AllExceptState | null {
|
|
879
|
+
if (this.scopeCache !== undefined) return this.scopeCache;
|
|
880
|
+
// A record that can't be read, or isn't a hold's, is not taken for no
|
|
881
|
+
// hold: the error goes to the caller and nothing is cached, so the next
|
|
882
|
+
// read tries again. At create() that refuses the start, as an
|
|
883
|
+
// unreadable registry does.
|
|
884
|
+
const raw = this.channelRegistry.readCoordinatorState(this.scopeStateId) as AllExceptState | null;
|
|
885
|
+
if (raw !== null && raw !== undefined && !(typeof raw === 'object' && raw.params && typeof raw.params.epochId === 'string')) {
|
|
886
|
+
throw new Error(`the all-except hold's stored record (${this.scopeStateId}) is not a hold's record; it was left as it is`);
|
|
887
|
+
}
|
|
888
|
+
this.scopeCache = raw ?? null;
|
|
889
|
+
return this.scopeCache;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private writeScope(state: AllExceptState | null): void {
|
|
893
|
+
// Durable first: a write that fails leaves memory as the store has it.
|
|
894
|
+
this.channelRegistry.writeCoordinatorState(this.scopeStateId, state);
|
|
895
|
+
this.scopeCache = state;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
enterAllExcept(
|
|
899
|
+
except: { serverId: string; channelId: string },
|
|
900
|
+
opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown },
|
|
901
|
+
source: string,
|
|
902
|
+
): { ok: true; params: AllExceptParams } | { ok: false; error: string } {
|
|
903
|
+
const read = this.readOptions(opts, {
|
|
904
|
+
durationSeconds: null,
|
|
905
|
+
cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
|
|
906
|
+
backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
|
|
907
|
+
maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
|
|
908
|
+
});
|
|
909
|
+
if (!read.ok) return read;
|
|
910
|
+
const { given, expiresAtMs } = read;
|
|
911
|
+
if (expiresAtMs === undefined) {
|
|
912
|
+
return { ok: false, error: 'an all-except hold needs `durationSeconds`: it always ends on its own' };
|
|
913
|
+
}
|
|
914
|
+
if (this.getAllExcept()) {
|
|
915
|
+
return { ok: false, error: 'an all-except hold is already active (cancel it first)' };
|
|
916
|
+
}
|
|
917
|
+
if (this.channelRegistry.getTuneOutState(except.serverId, except.channelId)) {
|
|
918
|
+
return { ok: false, error: `channel ${except.channelId} is tuned out; it cannot be the exception` };
|
|
919
|
+
}
|
|
920
|
+
if (this.hooks.isForkBound(except)) {
|
|
921
|
+
return { ok: false, error: `channel ${except.channelId} is bound to a conversation fork` };
|
|
922
|
+
}
|
|
923
|
+
const params: AllExceptParams = {
|
|
924
|
+
epochId: randomUUID(),
|
|
925
|
+
except,
|
|
926
|
+
cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
|
|
927
|
+
// Whole messages, as a channel's cap is counted: a cap below one shows
|
|
928
|
+
// none, where slice(-0.5) would take them all.
|
|
929
|
+
backlogCap: Math.max(0, Math.floor(given.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap)),
|
|
930
|
+
maxWakes: Math.max(1, given.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
|
|
931
|
+
startedAtSequence: this.hooks.currentSequence(),
|
|
932
|
+
startedAtMs: read.now,
|
|
933
|
+
expiresAtMs,
|
|
934
|
+
source,
|
|
935
|
+
};
|
|
936
|
+
this.writeScope({ params, wakeCount: 0 });
|
|
937
|
+
const oldest = this.listActive().reduce((min, e) => Math.min(min, e.params.startedAtSequence), params.startedAtSequence);
|
|
938
|
+
this.hooks.setSubconsciousAnchor(oldest);
|
|
939
|
+
this.armScopeTimers(params);
|
|
940
|
+
this.hooks.emitTrace({
|
|
941
|
+
type: 'tune-out:entered', serverId: except.serverId, channelId: except.channelId, epochId: params.epochId,
|
|
942
|
+
scope: 'all-except', reader: this.readerAvailable(), expiresAtMs: params.expiresAtMs,
|
|
943
|
+
});
|
|
944
|
+
const minutes = Math.max(1, Math.round((params.expiresAtMs - params.startedAtMs) / 60_000));
|
|
945
|
+
const until = new Date(params.expiresAtMs).toISOString().slice(11, 16);
|
|
946
|
+
this.announce(except, params.epochId, 'entered',
|
|
947
|
+
`[${NOTICE_TAG.focus}: ${this.hooks.primaryName() ?? 'the resident'} is here for ${minutes} min, until ${until} UTC; other channels are held meanwhile.]`);
|
|
948
|
+
return { ok: true, params };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Whether entry and release of the all-except scope are announced in the excepted channel. */
|
|
952
|
+
announces(): boolean {
|
|
953
|
+
return typeof this.hooks.announce === 'function';
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* The excepted channel hears the scope open and close: one bracketed line
|
|
958
|
+
* under the resident's name, posted through the same path as a publish.
|
|
959
|
+
* Fire-and-forget; the outcome is traced, never awaited by the epoch flip.
|
|
960
|
+
*/
|
|
961
|
+
private announce(
|
|
962
|
+
target: { serverId: string; channelId: string },
|
|
963
|
+
epochId: string,
|
|
964
|
+
kind: 'entered' | 'released',
|
|
965
|
+
text: string,
|
|
966
|
+
): void {
|
|
967
|
+
if (!this.hooks.announce) return;
|
|
968
|
+
const base = { serverId: target.serverId, channelId: target.channelId, epochId, kind };
|
|
969
|
+
this.hooks.announce(target.serverId, target.channelId, text).then(
|
|
970
|
+
(r) => this.hooks.emitTrace(r.success
|
|
971
|
+
? { type: 'tune-out:announced', ...base }
|
|
972
|
+
: { type: 'tune-out:announce-failed', ...base, error: r.error ?? 'publish failed' }),
|
|
973
|
+
(err: unknown) => this.hooks.emitTrace({ type: 'tune-out:announce-failed', ...base, error: (err as Error).message }),
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** The scope's end time and cadence, on timers no longer than one holds
|
|
978
|
+
* (deadline-timer), as a channel's tune-out arms its own. */
|
|
979
|
+
private armScopeTimers(params: AllExceptParams): void {
|
|
980
|
+
const key = TuneOutCoordinator.SCOPE_KEY;
|
|
981
|
+
const expiry = this.expiryTimers.get(key);
|
|
982
|
+
if (expiry) clearTimeout(expiry);
|
|
983
|
+
// As a channel's timers: the next turn is armed before this one's work,
|
|
984
|
+
// and a throw is reported (timerFailed) rather than left to end the host.
|
|
985
|
+
const { serverId: exceptServer, channelId: exceptChannel } = params.except;
|
|
986
|
+
const holdExpiry = (timer: ReturnType<typeof setTimeout>): void => { this.expiryTimers.set(key, timer); };
|
|
987
|
+
const end = (): void => {
|
|
988
|
+
try {
|
|
989
|
+
holdExpiry(setTimeout(end, EXPIRY_RETRY_MS));
|
|
990
|
+
if (!this.cancelAllExcept('duration', 'duration elapsed').ok) this.clearScopeTimers();
|
|
991
|
+
} catch (err) {
|
|
992
|
+
this.timerFailed('expiry', exceptServer, exceptChannel, err);
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
armAt(params.expiresAtMs, holdExpiry, end);
|
|
996
|
+
if (!this.readerAvailable()) return;
|
|
997
|
+
const cadence = this.cadenceTimers.get(key);
|
|
998
|
+
if (cadence) clearTimeout(cadence);
|
|
999
|
+
const hold = (timer: ReturnType<typeof setTimeout>): void => { this.cadenceTimers.set(key, timer); };
|
|
1000
|
+
const tick = () => {
|
|
1001
|
+
try {
|
|
1002
|
+
const scope = this.getAllExcept();
|
|
1003
|
+
if (!scope) return;
|
|
1004
|
+
armAfter(params.cadenceSeconds * 1000, hold, tick);
|
|
1005
|
+
// One look per held channel with new traffic since the last look.
|
|
1006
|
+
for (const [channelKey, diverted] of this.divertedSinceInvocation) {
|
|
1007
|
+
if (diverted <= 0) continue;
|
|
1008
|
+
const [serverId, channelId] = channelKey.split('\0') as [string, string];
|
|
1009
|
+
if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue; // a per-channel epoch's own cadence
|
|
1010
|
+
this.invokeSubconscious(serverId, channelId, 'cadence', diverted);
|
|
1011
|
+
}
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
this.timerFailed('cadence', exceptServer, exceptChannel, err);
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
armAfter(params.cadenceSeconds * 1000, hold, tick);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
private clearScopeTimers(): void {
|
|
1020
|
+
const key = TuneOutCoordinator.SCOPE_KEY;
|
|
1021
|
+
const cadence = this.cadenceTimers.get(key);
|
|
1022
|
+
if (cadence) clearTimeout(cadence);
|
|
1023
|
+
this.cadenceTimers.delete(key);
|
|
1024
|
+
const expiry = this.expiryTimers.get(key);
|
|
1025
|
+
if (expiry) clearTimeout(expiry);
|
|
1026
|
+
this.expiryTimers.delete(key);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Channels held by the scope (not by their own epoch) with the count held under it. */
|
|
1030
|
+
private scopeHeldCounts(epochId: string): Map<string, { serverId: string; channelId: string; count: number }> {
|
|
1031
|
+
const counts = new Map<string, { serverId: string; channelId: string; count: number }>();
|
|
1032
|
+
const all = [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])];
|
|
1033
|
+
for (const m of all) {
|
|
1034
|
+
const meta = m.metadata as { tuneOut?: { epochId?: string }; channelId?: string; serverId?: string } | undefined;
|
|
1035
|
+
if (meta?.tuneOut?.epochId !== epochId) continue;
|
|
1036
|
+
const channelId = typeof meta.channelId === 'string' ? meta.channelId : '(unknown channel)';
|
|
1037
|
+
const serverId = typeof meta.serverId === 'string' ? meta.serverId : '';
|
|
1038
|
+
const k = this.key(serverId, channelId);
|
|
1039
|
+
const entry = counts.get(k) ?? { serverId, channelId, count: 0 };
|
|
1040
|
+
entry.count += 1;
|
|
1041
|
+
counts.set(k, entry);
|
|
1042
|
+
}
|
|
1043
|
+
return counts;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* End the all-except hold: durable flip, one release notice with a
|
|
1048
|
+
* per-channel backlog (each capped), the reader's note if any, one wake
|
|
1049
|
+
* for the resident, and the reader's own look at what it had not seen.
|
|
1050
|
+
*/
|
|
1051
|
+
cancelAllExcept(
|
|
1052
|
+
source: string,
|
|
1053
|
+
reason: string,
|
|
1054
|
+
note?: string,
|
|
1055
|
+
noteAuthor?: string,
|
|
1056
|
+
): { ok: true } | { ok: false; error: string } {
|
|
1057
|
+
const scope = this.getAllExcept();
|
|
1058
|
+
if (!scope) return { ok: false, error: 'no all-except hold is active' };
|
|
1059
|
+
const { epochId, except, backlogCap } = scope.params;
|
|
523
1060
|
|
|
1061
|
+
const ofEpoch = (m: Pick<StoredMessage, 'metadata'>): boolean =>
|
|
1062
|
+
(m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId;
|
|
1063
|
+
const all = [...this.hooks.getStoredMessages().filter(ofEpoch), ...(this.hooks.getDeferredMessages?.() ?? []).filter(ofEpoch)];
|
|
1064
|
+
const byChannel = new Map<string, { serverId: string; channelId: string; backlog: Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'>> }>();
|
|
1065
|
+
for (const m of all) {
|
|
1066
|
+
const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
|
|
1067
|
+
const serverId = meta?.serverId ?? '';
|
|
1068
|
+
const channelId = meta?.channelId ?? '(unknown channel)';
|
|
1069
|
+
const k = this.key(serverId, channelId);
|
|
1070
|
+
const entry = byChannel.get(k) ?? { serverId, channelId, backlog: [] };
|
|
1071
|
+
entry.backlog.push(m);
|
|
1072
|
+
byChannel.set(k, entry);
|
|
1073
|
+
}
|
|
1074
|
+
const historyTool = this.hooks.historyTool?.() ?? null;
|
|
1075
|
+
const dumps = [...byChannel.values()].map(({ serverId, channelId, backlog }) => {
|
|
1076
|
+
// Floored here too: a hold stored before entry floored its cap can hold
|
|
1077
|
+
// a fraction.
|
|
1078
|
+
const cap = Math.floor(backlogCap);
|
|
1079
|
+
const shown = cap > 0 ? backlog.slice(-cap) : [];
|
|
1080
|
+
const truncated = backlog.length - shown.length;
|
|
1081
|
+
const attrs = [
|
|
1082
|
+
`channel=${quotedValue(this.shown(serverId, channelId))}`,
|
|
1083
|
+
`messages=${backlog.length}`,
|
|
1084
|
+
...(truncated > 0 ? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`] : []),
|
|
1085
|
+
];
|
|
1086
|
+
return `<tuned-out-backlog ${attrs.join(' ')}>\n${shown.map(renderHeldLine).join('\n')}\n</tuned-out-backlog>`;
|
|
1087
|
+
});
|
|
1088
|
+
const summary = byChannel.size === 0
|
|
1089
|
+
? 'Nothing was held.'
|
|
1090
|
+
: `${all.length} message${all.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`;
|
|
1091
|
+
if (this.scopeReleased !== epochId) {
|
|
1092
|
+
this.hooks.addMessage(
|
|
1093
|
+
'user',
|
|
1094
|
+
[{ type: 'text', text: `[${NOTICE_TAG.tuneOut}: all channels except ${this.shown(except.serverId, except.channelId)} released — ${reason}, ${scope.wakeCount} wake${scope.wakeCount === 1 ? '' : 's'}]\n\n${summary}${dumps.length > 0 ? '\n\n' + dumps.join('\n\n') : ''}` }],
|
|
1095
|
+
{ system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId },
|
|
1096
|
+
);
|
|
1097
|
+
this.hooks.commitDeliveries?.();
|
|
1098
|
+
this.scopeReleased = epochId;
|
|
1099
|
+
}
|
|
1100
|
+
// The release is recorded before the hold is: a failure past this point
|
|
1101
|
+
// leaves the hold in force with its release already delivered. A retry in
|
|
1102
|
+
// this process records only the hold's end; a restart between the two
|
|
1103
|
+
// delivers the release again, where the other order lost it outright.
|
|
1104
|
+
this.writeScope(null);
|
|
1105
|
+
this.scopeReleased = undefined;
|
|
1106
|
+
this.clearScopeTimers();
|
|
1107
|
+
for (const [key, pending] of this.pendingWakes) {
|
|
1108
|
+
const [serverId, channelId] = key.split('\0') as [string, string];
|
|
1109
|
+
if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue;
|
|
1110
|
+
clearTimeout(pending.timer);
|
|
1111
|
+
this.pendingWakes.delete(key);
|
|
1112
|
+
}
|
|
1113
|
+
const subName = this.hooks.subconsciousName();
|
|
1114
|
+
if (note && subName) {
|
|
1115
|
+
this.hooks.addMessage(subName, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
|
|
1116
|
+
} else if (note && noteAuthor && this.hooks.deliverFromReader) {
|
|
1117
|
+
this.hooks.deliverFromReader(noteAuthor, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
|
|
1118
|
+
}
|
|
1119
|
+
const primary = this.hooks.primaryName();
|
|
1120
|
+
if (primary) this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out');
|
|
1121
|
+
const notice = `[${NOTICE_TAG.tuneOutCancelled}: all channels except ${this.shown(except.serverId, except.channelId)} — ${reason}.]`;
|
|
1122
|
+
if (source !== 'subconscious' && subName) {
|
|
1123
|
+
this.hooks.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'tune-out-cancel-report', scope: 'all-except' }, subName);
|
|
1124
|
+
this.hooks.requestInference(subName, 'tune-out cancelled: all-except', 'tune-out');
|
|
1125
|
+
} else if (!subName && this.forkReader) {
|
|
1126
|
+
// One last look per channel with messages no fork was handed, stored or
|
|
1127
|
+
// still queued behind the resident's turn. That holds for a reader's
|
|
1128
|
+
// own release too: the reader that released read one channel's
|
|
1129
|
+
// messages, and every other channel's would otherwise reach no reader.
|
|
1130
|
+
// A channel is its server and id here, as the readers' bookkeeping is.
|
|
1131
|
+
const channels = new Map<string, ChannelRef>();
|
|
1132
|
+
for (const m of all) {
|
|
1133
|
+
const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
|
|
1134
|
+
const ref = { serverId: meta?.serverId ?? '', channelId: meta?.channelId ?? '(unknown channel)' };
|
|
1135
|
+
channels.set(this.key(ref.serverId, ref.channelId), ref);
|
|
1136
|
+
}
|
|
1137
|
+
for (const { serverId, channelId } of channels.values()) {
|
|
1138
|
+
const ofChannel = inChannelOf(serverId, channelId);
|
|
1139
|
+
const inChannel = (m: Pick<StoredMessage, 'metadata'>): boolean => ofEpoch(m) && ofChannel(m);
|
|
1140
|
+
const stored = this.hooks.getStoredMessages().filter(inChannel);
|
|
1141
|
+
const queued = this.queuedHeld(inChannel);
|
|
1142
|
+
const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
|
|
1143
|
+
if (rest.backlog.length + rest.queued.length > 0) {
|
|
1144
|
+
this.invokeReaderFork(serverId, channelId, epochId, 'cancel', notice, stored, queued);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
for (const key of this.divertedSinceInvocation.keys()) {
|
|
1149
|
+
const [serverId, channelId] = key.split('\0') as [string, string];
|
|
1150
|
+
if (!this.channelRegistry.getTuneOutState(serverId, channelId)) this.divertedSinceInvocation.delete(key);
|
|
1151
|
+
}
|
|
1152
|
+
this.dropHandOffs(epochId);
|
|
1153
|
+
this.dropLooks(epochId);
|
|
1154
|
+
this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId: except.serverId, channelId: except.channelId, epochId, scope: 'all-except', reason, wakeCount: scope.wakeCount });
|
|
1155
|
+
this.announce(except, epochId, 'released', `[${NOTICE_TAG.focus}: ${primary ?? 'the resident'} released — ${reason}.]`);
|
|
1156
|
+
return { ok: true };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** What is held right now — per-channel epochs and the scope — without releasing. */
|
|
1160
|
+
check(opts: { peek?: { serverId?: string; channelId: string }; peekLimit?: number } = {}): TuneOutCheck {
|
|
1161
|
+
const stored = this.hooks.getStoredMessages();
|
|
1162
|
+
const deferred = this.hooks.getDeferredMessages?.() ?? [];
|
|
1163
|
+
const countOf = (epochId: string) => [...stored, ...deferred].filter(
|
|
1164
|
+
(m) => (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId,
|
|
1165
|
+
).length;
|
|
1166
|
+
const channels = this.listActive().map(({ serverId, channelId, params }) => ({
|
|
1167
|
+
serverId, channelId, epochId: params.epochId, held: countOf(params.epochId),
|
|
1168
|
+
...(params.expiresAtMs !== undefined ? { expiresAtMs: params.expiresAtMs } : {}),
|
|
1169
|
+
}));
|
|
1170
|
+
const scope = this.getAllExcept();
|
|
1171
|
+
let allExcept: TuneOutCheck['allExcept'] = null;
|
|
1172
|
+
if (scope) {
|
|
1173
|
+
const held: Record<string, number> = {};
|
|
1174
|
+
for (const { serverId, channelId, count } of this.scopeHeldCounts(scope.params.epochId).values()) held[this.shown(serverId, channelId)] = count;
|
|
1175
|
+
allExcept = { epochId: scope.params.epochId, except: scope.params.except, expiresAtMs: scope.params.expiresAtMs, wakeCount: scope.wakeCount, held };
|
|
1176
|
+
}
|
|
1177
|
+
const result: TuneOutCheck = { allExcept, channels };
|
|
1178
|
+
if (opts.peek) {
|
|
1179
|
+
const limit = Math.max(1, Math.min(50, opts.peekLimit ?? 5));
|
|
1180
|
+
// Only what is held NOW: the channel's own epoch, or the scope's. A
|
|
1181
|
+
// released epoch's messages are delivered, not held; showing them here
|
|
1182
|
+
// would be a false declaration (first live run, 2026-10-12, finding B).
|
|
1183
|
+
const active = new Set<string>();
|
|
1184
|
+
for (const e of channels) {
|
|
1185
|
+
if (e.channelId === opts.peek.channelId && (!opts.peek.serverId || e.serverId === opts.peek.serverId)) active.add(e.epochId);
|
|
1186
|
+
}
|
|
1187
|
+
if (scope) active.add(scope.params.epochId);
|
|
1188
|
+
const lines = [...stored, ...deferred]
|
|
1189
|
+
.filter((m) => {
|
|
1190
|
+
const meta = m.metadata as { tuneOut?: { epochId?: string }; channelId?: string; serverId?: string } | undefined;
|
|
1191
|
+
return typeof meta?.tuneOut?.epochId === 'string' && active.has(meta.tuneOut.epochId)
|
|
1192
|
+
&& meta.channelId === opts.peek!.channelId
|
|
1193
|
+
&& (!opts.peek!.serverId || meta.serverId === opts.peek!.serverId);
|
|
1194
|
+
})
|
|
1195
|
+
.slice(-limit)
|
|
1196
|
+
.map(renderHeldLine);
|
|
1197
|
+
result.peek = lines;
|
|
1198
|
+
}
|
|
1199
|
+
return result;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Bookkeeping for one held message, after admission decided the hold:
|
|
1204
|
+
* the cadence tally, the deterministic acknowledgement of a suppressed
|
|
1205
|
+
* mention, and the reader wake when the resident's gate would have woken
|
|
1206
|
+
* them (`gatePass` is that verdict, probed without side effects).
|
|
1207
|
+
*/
|
|
1208
|
+
recordHeld(
|
|
1209
|
+
serverId: string,
|
|
1210
|
+
channelId: string,
|
|
1211
|
+
messageId: string,
|
|
1212
|
+
tags: string[] | undefined,
|
|
1213
|
+
authorId?: string,
|
|
1214
|
+
gatePass = true,
|
|
1215
|
+
): void {
|
|
524
1216
|
const key = this.key(serverId, channelId);
|
|
525
1217
|
this.divertedSinceInvocation.set(key, (this.divertedSinceInvocation.get(key) ?? 0) + 1);
|
|
526
1218
|
|
|
@@ -541,10 +1233,20 @@ export class TuneOutCoordinator {
|
|
|
541
1233
|
// standing mute to its subject.
|
|
542
1234
|
void this.acknowledgeSuppressed(serverId, channelId, messageId);
|
|
543
1235
|
}
|
|
544
|
-
if (gatePass && (addressed || privileged)) {
|
|
1236
|
+
if (gatePass && (addressed || privileged) && this.readerAvailable()) {
|
|
545
1237
|
this.scheduleWakeInvocation(serverId, channelId);
|
|
546
1238
|
}
|
|
547
|
-
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/** Reader forks that reported through `deliver_summary` (so their trailing prose is not a second report). */
|
|
1242
|
+
private readerReports = new Set<string>();
|
|
1243
|
+
|
|
1244
|
+
readerReported(reader: string): boolean {
|
|
1245
|
+
return this.readerReports.has(reader);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
forgetReader(reader: string): void {
|
|
1249
|
+
this.readerReports.delete(reader);
|
|
548
1250
|
}
|
|
549
1251
|
|
|
550
1252
|
private async acknowledgeSuppressed(serverId: string, channelId: string, messageId: string): Promise<void> {
|
|
@@ -625,19 +1327,7 @@ export class TuneOutCoordinator {
|
|
|
625
1327
|
try {
|
|
626
1328
|
const entry = this.pendingWakes.get(key);
|
|
627
1329
|
this.pendingWakes.delete(key);
|
|
628
|
-
|
|
629
|
-
const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
|
|
630
|
-
if (!counted) return; // cancelled inside the window
|
|
631
|
-
if (counted.wakeCount > counted.params.maxWakes) {
|
|
632
|
-
this.cancel(
|
|
633
|
-
serverId,
|
|
634
|
-
channelId,
|
|
635
|
-
'tune-out-max-wakes',
|
|
636
|
-
`wake budget exhausted (${counted.params.maxWakes})`,
|
|
637
|
-
);
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
this.invokeSubconscious(serverId, channelId, 'wake', entry?.count ?? 1);
|
|
1330
|
+
this.fireWake(serverId, channelId, entry?.count ?? 1);
|
|
641
1331
|
} catch (err) {
|
|
642
1332
|
this.timerFailed('wake', serverId, channelId, err);
|
|
643
1333
|
}
|
|
@@ -645,6 +1335,41 @@ export class TuneOutCoordinator {
|
|
|
645
1335
|
this.pendingWakes.set(key, { count: 1, timer });
|
|
646
1336
|
}
|
|
647
1337
|
|
|
1338
|
+
/** One coalesced wake: counted durably against the epoch that holds the channel. */
|
|
1339
|
+
private fireWake(serverId: string, channelId: string, count: number): void {
|
|
1340
|
+
if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
|
|
1341
|
+
// Count ONE durable wake per coalesced invocation.
|
|
1342
|
+
const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
|
|
1343
|
+
if (!counted) return; // cancelled inside the window
|
|
1344
|
+
if (counted.wakeCount > counted.params.maxWakes) {
|
|
1345
|
+
this.cancel(
|
|
1346
|
+
serverId,
|
|
1347
|
+
channelId,
|
|
1348
|
+
'tune-out-max-wakes',
|
|
1349
|
+
`wake budget exhausted (${counted.params.maxWakes})`,
|
|
1350
|
+
);
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
this.invokeSubconscious(serverId, channelId, 'wake', count);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const scope = this.getAllExcept();
|
|
1357
|
+
if (!scope) return; // released inside the window
|
|
1358
|
+
// The all-except scope: exhausting the budget pauses the reader, it does
|
|
1359
|
+
// not end the hold (#232 §3.2 — the hold is the resident's bounded
|
|
1360
|
+
// commitment; the deadline ends it).
|
|
1361
|
+
const wakeCount = scope.wakeCount + 1;
|
|
1362
|
+
this.writeScope({ ...scope, wakeCount });
|
|
1363
|
+
if (wakeCount > scope.params.maxWakes) {
|
|
1364
|
+
this.hooks.emitTrace({
|
|
1365
|
+
type: 'tune-out:wake-budget-paused', epochId: scope.params.epochId, scope: 'all-except',
|
|
1366
|
+
serverId, channelId, wakeCount, maxWakes: scope.params.maxWakes,
|
|
1367
|
+
});
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
this.invokeSubconscious(serverId, channelId, 'wake', count);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
648
1373
|
/**
|
|
649
1374
|
* One subconscious turn: a system-framed trigger notice (bracket style —
|
|
650
1375
|
* what happened, never what to say) plus standing dispositions, then an
|
|
@@ -657,13 +1382,38 @@ export class TuneOutCoordinator {
|
|
|
657
1382
|
count: number,
|
|
658
1383
|
): void {
|
|
659
1384
|
const subName = this.hooks.subconsciousName();
|
|
660
|
-
if (!subName) return;
|
|
1385
|
+
if (!subName && !this.forkReader) return;
|
|
661
1386
|
const key = this.key(serverId, channelId);
|
|
662
1387
|
|
|
663
1388
|
const shown = this.shown(serverId, channelId);
|
|
664
1389
|
const notice = trigger === 'wake'
|
|
665
1390
|
? `[${NOTICE_TAG.tuneOutWake}: ${shown} — ${count} addressed message${count === 1 ? '' : 's'}]`
|
|
666
1391
|
: `[${NOTICE_TAG.tuneOutCadence}: ${shown} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`;
|
|
1392
|
+
if (!subName) {
|
|
1393
|
+
const hold = this.holdFor(serverId, channelId);
|
|
1394
|
+
if (!hold) return;
|
|
1395
|
+
const ofChannel = inChannelOf(serverId, channelId);
|
|
1396
|
+
const inChannel = (m: Pick<StoredMessage, 'metadata'>): boolean =>
|
|
1397
|
+
(m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === hold.epochId && ofChannel(m);
|
|
1398
|
+
// Stored and still-queued alike: while the resident's turn runs, what
|
|
1399
|
+
// arrives waits in the deferred queue, and that is the traffic a reader
|
|
1400
|
+
// is there for.
|
|
1401
|
+
this.invokeReaderFork(
|
|
1402
|
+
serverId,
|
|
1403
|
+
channelId,
|
|
1404
|
+
hold.epochId,
|
|
1405
|
+
trigger,
|
|
1406
|
+
notice,
|
|
1407
|
+
this.hooks.getStoredMessages().filter(inChannel),
|
|
1408
|
+
this.queuedHeld(inChannel),
|
|
1409
|
+
);
|
|
1410
|
+
// The count restarts once the fork has been handed what was held, as it
|
|
1411
|
+
// does below once the subconscious's turn has been asked for. Otherwise
|
|
1412
|
+
// every later cadence period still sees this one's count and derives a
|
|
1413
|
+
// fork with nothing new to read.
|
|
1414
|
+
this.divertedSinceInvocation.set(key, 0);
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
667
1417
|
this.hooks.addMessage(
|
|
668
1418
|
'user',
|
|
669
1419
|
[{ type: 'text', text: notice }],
|
|
@@ -674,6 +1424,280 @@ export class TuneOutCoordinator {
|
|
|
674
1424
|
// Counted from here only once the turn has been asked for, so a turn that
|
|
675
1425
|
// throws leaves its messages for the next period's turn.
|
|
676
1426
|
this.divertedSinceInvocation.set(key, 0);
|
|
1427
|
+
const epochId = this.holdFor(serverId, channelId)?.epochId;
|
|
1428
|
+
if (epochId) this.markLook(epochId, serverId, channelId);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* Count again, from the held messages, stored or still queued behind the
|
|
1433
|
+
* resident's turn, those the reader of the epoch holding this channel now
|
|
1434
|
+
* hasn't looked at: for reader forks, those no fork was handed; for the
|
|
1435
|
+
* persistent subconscious, the stored ones held since its last look
|
|
1436
|
+
* (lastLooks) and every queued one, which no view of it can show yet.
|
|
1437
|
+
* Nothing holding the channel leaves no count.
|
|
1438
|
+
*/
|
|
1439
|
+
private rebuildTally(serverId: string, channelId: string): void {
|
|
1440
|
+
const key = this.key(serverId, channelId);
|
|
1441
|
+
const hold = this.holdFor(serverId, channelId);
|
|
1442
|
+
if (!hold || !this.readerAvailable()) {
|
|
1443
|
+
this.divertedSinceInvocation.delete(key);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const ofChannel = inChannelOf(serverId, channelId);
|
|
1447
|
+
const inEpoch = (m: Pick<StoredMessage, 'metadata'>): boolean =>
|
|
1448
|
+
(m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === hold.epochId && ofChannel(m);
|
|
1449
|
+
const rows = this.hooks.getStoredMessages().filter(inEpoch);
|
|
1450
|
+
const queued = this.queuedHeld(inEpoch);
|
|
1451
|
+
let count: number;
|
|
1452
|
+
if (this.forkReader) {
|
|
1453
|
+
const unread = this.unhandedBacklog(this.handOffKey(hold.epochId, serverId, channelId), rows, queued);
|
|
1454
|
+
count = unread.backlog.length + unread.queued.length;
|
|
1455
|
+
} else {
|
|
1456
|
+
const mark = this.lastLooks()?.[hold.epochId]?.[key];
|
|
1457
|
+
count = (mark === undefined ? rows.length : rows.filter((m) => m.sequence > mark).length) + queued.length;
|
|
1458
|
+
}
|
|
1459
|
+
this.divertedSinceInvocation.set(key, count);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
private get lastLooksStateId(): string {
|
|
1463
|
+
return `tune-out/${this.hooks.primaryName() ?? 'default'}/last-looks`;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* The stored last looks. Where they can't be read, none: every held row
|
|
1468
|
+
* counts, and nothing is cached, so the next read tries again. Nor is
|
|
1469
|
+
* anything written back over the stored record then (markLook, dropLooks,
|
|
1470
|
+
* pruneLooks), which would erase the other epochs' and channels' looks.
|
|
1471
|
+
*/
|
|
1472
|
+
private lastLooks(): Record<string, Record<string, number>> | undefined {
|
|
1473
|
+
if (this.lastLooksCache !== undefined) return this.lastLooksCache;
|
|
1474
|
+
let raw: unknown;
|
|
1475
|
+
try {
|
|
1476
|
+
raw = this.channelRegistry.readCoordinatorState(this.lastLooksStateId);
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be read:`, error);
|
|
1479
|
+
return undefined;
|
|
1480
|
+
}
|
|
1481
|
+
const looks: Record<string, Record<string, number>> = {};
|
|
1482
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
1483
|
+
for (const [epochId, channels] of Object.entries(raw as Record<string, unknown>)) {
|
|
1484
|
+
if (!channels || typeof channels !== 'object' || Array.isArray(channels)) continue;
|
|
1485
|
+
const marks = Object.entries(channels as Record<string, unknown>).filter((e): e is [string, number] => typeof e[1] === 'number');
|
|
1486
|
+
if (marks.length > 0) looks[epochId] = Object.fromEntries(marks);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
this.lastLooksCache = looks;
|
|
1490
|
+
return looks;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
private writeLooks(looks: Record<string, Record<string, number>>): void {
|
|
1494
|
+
this.lastLooksCache = looks;
|
|
1495
|
+
try {
|
|
1496
|
+
this.channelRegistry.writeCoordinatorState(this.lastLooksStateId, looks);
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
// The look itself stands. After a restart, this look's messages would
|
|
1499
|
+
// count as unseen at the next one.
|
|
1500
|
+
console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be stored:`, error);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
private markLook(epochId: string, serverId: string, channelId: string): void {
|
|
1505
|
+
const looks = this.lastLooks();
|
|
1506
|
+
if (!looks) return;
|
|
1507
|
+
this.writeLooks({ ...looks, [epochId]: { ...looks[epochId], [this.key(serverId, channelId)]: this.hooks.currentSequence() } });
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** An ended epoch's last looks go with it. */
|
|
1511
|
+
private dropLooks(epochId: string): void {
|
|
1512
|
+
if (this.hooks.subconsciousName() == null) return;
|
|
1513
|
+
const looks = this.lastLooks();
|
|
1514
|
+
if (!looks || !(epochId in looks)) return;
|
|
1515
|
+
const { [epochId]: _ended, ...rest } = looks;
|
|
1516
|
+
this.writeLooks(rest);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/** At boot: the last looks of epochs no longer active go. */
|
|
1520
|
+
private pruneLooks(): void {
|
|
1521
|
+
if (this.hooks.subconsciousName() == null) return;
|
|
1522
|
+
const looks = this.lastLooks();
|
|
1523
|
+
if (!looks) return;
|
|
1524
|
+
const active = new Set(this.listActive().map((e) => e.params.epochId));
|
|
1525
|
+
const scope = this.getAllExcept();
|
|
1526
|
+
if (scope) active.add(scope.params.epochId);
|
|
1527
|
+
const kept = Object.fromEntries(Object.entries(looks).filter(([epochId]) => active.has(epochId)));
|
|
1528
|
+
if (Object.keys(kept).length !== Object.keys(looks).length) this.writeLooks(kept);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* `reader: 'forks'`: hand the framework one invocation. The backlog is
|
|
1533
|
+
* the epoch's held messages not yet handed to any fork — each is
|
|
1534
|
+
* delivered to exactly one reader — stored ones first, then those still
|
|
1535
|
+
* queued behind the resident's turn, plus the standing dispositions. A
|
|
1536
|
+
* cadence look with nothing new is skipped. True when a fork was asked for.
|
|
1537
|
+
*/
|
|
1538
|
+
private invokeReaderFork(
|
|
1539
|
+
serverId: string,
|
|
1540
|
+
channelId: string,
|
|
1541
|
+
epochId: string,
|
|
1542
|
+
trigger: ReaderInvocation['trigger'],
|
|
1543
|
+
notice: string,
|
|
1544
|
+
epochBacklog: StoredMessage[],
|
|
1545
|
+
epochQueued: QueuedHeldMessage[] = [],
|
|
1546
|
+
): boolean {
|
|
1547
|
+
if (!this.hooks.invokeReader) return false;
|
|
1548
|
+
const key = this.handOffKey(epochId, serverId, channelId);
|
|
1549
|
+
const { backlog, queued } = this.unhandedBacklog(key, epochBacklog, epochQueued);
|
|
1550
|
+
if (trigger === 'cadence' && backlog.length === 0 && queued.length === 0) return false;
|
|
1551
|
+
// The cursor only moves forward: a returned row handed again can be
|
|
1552
|
+
// behind it.
|
|
1553
|
+
const cursor = Math.max(this.readerCursor.get(key) ?? -1, ...backlog.map((m) => m.sequence));
|
|
1554
|
+
if (backlog.length > 0) this.readerCursor.set(key, cursor);
|
|
1555
|
+
const handed = this.readerHandedWrites.get(key) ?? new Set<string>();
|
|
1556
|
+
for (const m of queued) handed.add(m.writeId);
|
|
1557
|
+
// A handed write whose row has landed behind the cursor can no longer be
|
|
1558
|
+
// handed by sequence, so its id has done its work.
|
|
1559
|
+
for (const m of epochBacklog) {
|
|
1560
|
+
const writeId = writeIdOf(m);
|
|
1561
|
+
if (writeId !== undefined && m.sequence <= cursor) handed.delete(writeId);
|
|
1562
|
+
}
|
|
1563
|
+
if (handed.size > 0) this.readerHandedWrites.set(key, handed);
|
|
1564
|
+
else this.readerHandedWrites.delete(key);
|
|
1565
|
+
const returned = this.readerReturned.get(key);
|
|
1566
|
+
if (returned) {
|
|
1567
|
+
for (const m of backlog) {
|
|
1568
|
+
returned.ids.delete(m.id);
|
|
1569
|
+
const writeId = writeIdOf(m);
|
|
1570
|
+
if (writeId !== undefined) returned.writeIds.delete(writeId);
|
|
1571
|
+
}
|
|
1572
|
+
for (const m of queued) returned.writeIds.delete(m.writeId);
|
|
1573
|
+
if (returned.ids.size === 0 && returned.writeIds.size === 0) this.readerReturned.delete(key);
|
|
1574
|
+
}
|
|
1575
|
+
const entries = Object.entries(this.readDispositions());
|
|
1576
|
+
this.hooks.invokeReader({
|
|
1577
|
+
serverId,
|
|
1578
|
+
channelId,
|
|
1579
|
+
epochId,
|
|
1580
|
+
trigger,
|
|
1581
|
+
notice,
|
|
1582
|
+
backlog,
|
|
1583
|
+
queued,
|
|
1584
|
+
dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
|
|
1585
|
+
});
|
|
1586
|
+
return true;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* What no reader fork has read yet, of the given held messages: the stored
|
|
1591
|
+
* ones past the epoch's cursor for the channel, less those a fork was handed while they
|
|
1592
|
+
* were still queued, plus those a failed fork returned, oldest first; then
|
|
1593
|
+
* the queued ones no fork was handed, or a failed fork returned.
|
|
1594
|
+
*/
|
|
1595
|
+
private unhandedBacklog(
|
|
1596
|
+
key: string,
|
|
1597
|
+
stored: StoredMessage[],
|
|
1598
|
+
queued: QueuedHeldMessage[],
|
|
1599
|
+
): { backlog: StoredMessage[]; queued: QueuedHeldMessage[] } {
|
|
1600
|
+
const cursor = this.readerCursor.get(key) ?? -1;
|
|
1601
|
+
const handed = this.readerHandedWrites.get(key);
|
|
1602
|
+
const returned = this.readerReturned.get(key);
|
|
1603
|
+
const wasReturned = (id: string | undefined, writeId: string | undefined): boolean =>
|
|
1604
|
+
returned !== undefined && ((id !== undefined && returned.ids.has(id)) || (writeId !== undefined && returned.writeIds.has(writeId)));
|
|
1605
|
+
const unread = (m: StoredMessage): boolean => {
|
|
1606
|
+
const writeId = writeIdOf(m);
|
|
1607
|
+
if (wasReturned(m.id, writeId)) return true;
|
|
1608
|
+
return m.sequence > cursor && !(handed !== undefined && writeId !== undefined && handed.has(writeId));
|
|
1609
|
+
};
|
|
1610
|
+
return {
|
|
1611
|
+
backlog: stored.filter(unread).sort((a, b) => a.sequence - b.sequence),
|
|
1612
|
+
queued: queued.filter((m) => !handed?.has(m.writeId) || wasReturned(undefined, m.writeId)),
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* A reader fork that never read what it was handed: its derive failed, or
|
|
1618
|
+
* its run threw before it reported. While the epoch that held the
|
|
1619
|
+
* messages is still active, they go back to that epoch's backlog for the
|
|
1620
|
+
* channel, and its next invocation for the channel is handed them: the
|
|
1621
|
+
* scope's while the scope is active, though the channel's own tune-out
|
|
1622
|
+
* has taken the channel meanwhile, and a channel's own tune-out's while it
|
|
1623
|
+
* holds the channel. Once the epoch has ended, its cancel's last fork was
|
|
1624
|
+
* handed only what no fork had been, so a fork still running then is the
|
|
1625
|
+
* only reader its messages had: one late fork is handed them instead
|
|
1626
|
+
* (lateFork). Returns how many went back or to the late fork.
|
|
1627
|
+
*/
|
|
1628
|
+
readerFailed(invocation: Pick<ReaderInvocation, 'serverId' | 'channelId' | 'epochId' | 'backlog' | 'queued'>): number {
|
|
1629
|
+
const { serverId, channelId, epochId } = invocation;
|
|
1630
|
+
const active = this.getAllExcept()?.params.epochId === epochId
|
|
1631
|
+
|| this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
|
|
1632
|
+
if (!active) return this.lateFork(invocation);
|
|
1633
|
+
const key = this.handOffKey(epochId, serverId, channelId);
|
|
1634
|
+
const returned = this.readerReturned.get(key) ?? { ids: new Set<string>(), writeIds: new Set<string>() };
|
|
1635
|
+
for (const m of invocation.backlog) returned.ids.add(m.id);
|
|
1636
|
+
for (const m of invocation.queued) returned.writeIds.add(m.writeId);
|
|
1637
|
+
const count = invocation.backlog.length + invocation.queued.length;
|
|
1638
|
+
if (count > 0) {
|
|
1639
|
+
this.readerReturned.set(key, returned);
|
|
1640
|
+
// They count again toward the next cadence look of the epoch holding
|
|
1641
|
+
// the channel, which hands them back. Where another epoch holds it
|
|
1642
|
+
// now, they're counted again when the channel comes back to theirs
|
|
1643
|
+
// (rebuildTally), and its release hands them in any case.
|
|
1644
|
+
const tally = this.key(serverId, channelId);
|
|
1645
|
+
if (this.holdFor(serverId, channelId)?.epochId === epochId) {
|
|
1646
|
+
this.divertedSinceInvocation.set(tally, (this.divertedSinceInvocation.get(tally) ?? 0) + count);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return count;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* A fork that failed after its epoch ended: one late fork is handed what it
|
|
1654
|
+
* was, with a notice saying a reader handed them failed after the tune-out
|
|
1655
|
+
* ended. Those still queued when it was handed that have landed since are
|
|
1656
|
+
* handed as stored. A late fork's own failure derives no other, so a
|
|
1657
|
+
* reader that keeps failing costs one more fork, not a chain of them.
|
|
1658
|
+
* Returns how many the late fork was handed.
|
|
1659
|
+
*/
|
|
1660
|
+
private lateFork(invocation: Pick<ReaderInvocation, 'serverId' | 'channelId' | 'epochId' | 'backlog' | 'queued'>): number {
|
|
1661
|
+
if (!this.hooks.invokeReader || this.lateForks.has(invocation)) return 0;
|
|
1662
|
+
const { serverId, channelId, epochId } = invocation;
|
|
1663
|
+
const landed = new Map<string, StoredMessage>();
|
|
1664
|
+
for (const m of this.hooks.getStoredMessages()) {
|
|
1665
|
+
const writeId = writeIdOf(m);
|
|
1666
|
+
if (writeId !== undefined) landed.set(writeId, m);
|
|
1667
|
+
}
|
|
1668
|
+
const backlog = [...invocation.backlog, ...invocation.queued.flatMap((m) => landed.get(m.writeId) ?? [])]
|
|
1669
|
+
.sort((a, b) => a.sequence - b.sequence);
|
|
1670
|
+
const queued = invocation.queued.filter((m) => !landed.has(m.writeId));
|
|
1671
|
+
const count = backlog.length + queued.length;
|
|
1672
|
+
if (count === 0) return 0;
|
|
1673
|
+
const these = count === 1 ? 'This message was' : `These ${count} messages were`;
|
|
1674
|
+
const notice = `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${these} handed to a reader that `
|
|
1675
|
+
+ `failed after the tune-out ended, so no reader has read ${count === 1 ? 'it' : 'them'} yet.]`;
|
|
1676
|
+
const entries = Object.entries(this.readDispositions());
|
|
1677
|
+
const late: ReaderInvocation = {
|
|
1678
|
+
serverId,
|
|
1679
|
+
channelId,
|
|
1680
|
+
epochId,
|
|
1681
|
+
trigger: 'cancel',
|
|
1682
|
+
notice,
|
|
1683
|
+
backlog,
|
|
1684
|
+
queued,
|
|
1685
|
+
dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
|
|
1686
|
+
};
|
|
1687
|
+
this.lateForks.add(late);
|
|
1688
|
+
this.hooks.invokeReader(late);
|
|
1689
|
+
return count;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* The held messages still queued behind the resident's turn that `keep`
|
|
1694
|
+
* keeps, oldest first, with their write ids. One the host gives no id for
|
|
1695
|
+
* is left to reach a reader once it is stored: without the id, its stored
|
|
1696
|
+
* row couldn't be told from a message no reader has had.
|
|
1697
|
+
*/
|
|
1698
|
+
private queuedHeld(keep: (m: Pick<StoredMessage, 'metadata'>) => boolean): QueuedHeldMessage[] {
|
|
1699
|
+
return (this.hooks.getDeferredMessages?.() ?? [])
|
|
1700
|
+
.filter((m): m is QueuedHeldMessage => typeof m.writeId === 'string' && keep(m));
|
|
677
1701
|
}
|
|
678
1702
|
|
|
679
1703
|
// ==========================================================================
|
|
@@ -754,6 +1778,11 @@ export class TuneOutCoordinator {
|
|
|
754
1778
|
input: Record<string, unknown>,
|
|
755
1779
|
/** The calling turn's own channels, for a call made inside it (the framework's callOwnChannels). */
|
|
756
1780
|
own: readonly ChannelRef[] = [],
|
|
1781
|
+
/** The calling agent: the persistent subconscious, or a reader fork. */
|
|
1782
|
+
caller?: string,
|
|
1783
|
+
/** A reader fork, with the epoch it was derived to read, if any: it ends and posts only through that epoch's
|
|
1784
|
+
* hold (forkRefusal). The persistent subconscious, which reads every epoch, passes none. */
|
|
1785
|
+
fork?: { epochId?: string },
|
|
757
1786
|
): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
|
|
758
1787
|
switch (name) {
|
|
759
1788
|
case 'deliver_summary': {
|
|
@@ -768,49 +1797,105 @@ export class TuneOutCoordinator {
|
|
|
768
1797
|
};
|
|
769
1798
|
}
|
|
770
1799
|
const subName = this.hooks.subconsciousName();
|
|
771
|
-
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
1800
|
+
// Verbatim, under its own participant name, into the resident's window
|
|
1801
|
+
// (or, from a reader fork, as its attributed mail), tagged with the
|
|
1802
|
+
// channel the name means and its server, since the framework reads a
|
|
1803
|
+
// conversational row's channel (repinChannel). The name means what it
|
|
1804
|
+
// means to every channel tool: inside the calling turn, the id of one
|
|
1805
|
+
// of the turn's own channels means that channel, refused where its
|
|
1806
|
+
// server no longer registers it or the turn has channels with that id
|
|
1807
|
+
// on two servers (ownChannelFor); otherwise a name several registered
|
|
1808
|
+
// channels answer to is refused with them (sharedNameRefusalFor). So
|
|
1809
|
+
// no row is left for a re-pin to read as another server's channel. A
|
|
1810
|
+
// name no registered channel answers to is kept as given, with no
|
|
1811
|
+
// server, which a re-pin reads by name when it weighs the row, as it
|
|
1812
|
+
// reads any message without its server.
|
|
1813
|
+
if (!subName && !(caller && this.hooks.deliverFromReader)) {
|
|
1814
|
+
return { success: false, error: 'subconscious not configured', isError: true };
|
|
1815
|
+
}
|
|
784
1816
|
const mine = this.channelRegistry.ownChannelFor(channelId, own, 'deliver');
|
|
785
1817
|
if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true };
|
|
786
1818
|
const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(channelId, 'nothing was delivered');
|
|
787
1819
|
if (shared) return { success: false, error: shared, isError: true };
|
|
788
1820
|
const [named] = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId);
|
|
789
|
-
|
|
1821
|
+
const metadata = {
|
|
790
1822
|
kind: 'subconscious-summary',
|
|
791
1823
|
...(named ? { channelId: named.channelId, serverId: named.serverId } : { channelId }),
|
|
792
|
-
}
|
|
1824
|
+
};
|
|
1825
|
+
if (subName) {
|
|
1826
|
+
this.hooks.addMessage(subName, [{ type: 'text', text }], metadata);
|
|
1827
|
+
return { success: true, data: { delivered: true } };
|
|
1828
|
+
}
|
|
1829
|
+
// A reader fork's report: attributed mail under its own name. The fork
|
|
1830
|
+
// counts as having reported only once the mail is held: a delivery
|
|
1831
|
+
// that throws is an error result to the fork, and its prose is then
|
|
1832
|
+
// still delivered as its report when it ends (#546, probe R14).
|
|
1833
|
+
this.hooks.deliverFromReader!(caller!, [{ type: 'text', text }], metadata);
|
|
1834
|
+
this.readerReports.add(caller!);
|
|
793
1835
|
return { success: true, data: { delivered: true } };
|
|
794
1836
|
}
|
|
795
1837
|
case 'cancel_tuneout': {
|
|
796
1838
|
const name = String(input.channelId ?? '');
|
|
1839
|
+
const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
|
|
1840
|
+
const endScope = (): { success: boolean; data?: unknown; error?: string; isError?: boolean } => {
|
|
1841
|
+
const scope = this.getAllExcept();
|
|
1842
|
+
const refused = fork && scope
|
|
1843
|
+
? this.forkRefusal(fork, { epochId: scope.params.epochId }, 'cancel', "the all-except hold isn't the hold you were started to read")
|
|
1844
|
+
: undefined;
|
|
1845
|
+
if (refused) return { success: false, error: refused, isError: true };
|
|
1846
|
+
const result = this.cancelAllExcept('subconscious', 'subconscious judgment', note, caller);
|
|
1847
|
+
return result.ok
|
|
1848
|
+
? { success: true, data: { cancelled: true, scope: 'all-except' } }
|
|
1849
|
+
: { success: false, error: result.error, isError: true };
|
|
1850
|
+
};
|
|
1851
|
+
// A call naming the all-except scope ends that hold outright, as the
|
|
1852
|
+
// tool describes it and as the resident's tune_out cancel does: the
|
|
1853
|
+
// channel it names, tuned out on its own or not, doesn't turn it
|
|
1854
|
+
// into that channel's cancel, and with no hold it ends nothing.
|
|
1855
|
+
if (input.scope === 'all-except') return endScope();
|
|
1856
|
+
const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined;
|
|
797
1857
|
// The name means what it means to every channel tool, the
|
|
798
|
-
// resident's tune_out included:
|
|
799
|
-
//
|
|
800
|
-
//
|
|
1858
|
+
// resident's tune_out included: with a serverId, that server's
|
|
1859
|
+
// channel with the id; inside the calling turn, the id of one of the
|
|
1860
|
+
// turn's own channels means that channel (ownChannelFor); otherwise a
|
|
1861
|
+
// name several registered channels answer to picks none of them
|
|
801
1862
|
// (channelsNamed), whichever of them is tuned out, never taken to be
|
|
802
|
-
// the tuned-out one.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
1863
|
+
// the tuned-out one. A name that picks no channel, such a name or one
|
|
1864
|
+
// of the turn's own channels that ownChannelFor refuses, is refused,
|
|
1865
|
+
// unless the call names the all-except scope, which ends that hold
|
|
1866
|
+
// below.
|
|
1867
|
+
const mine = serverId ? undefined : this.channelRegistry.ownChannelFor(name, own);
|
|
1868
|
+
const refusal = mine && 'error' in mine ? mine.error
|
|
1869
|
+
: serverId || mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name);
|
|
1870
|
+
const named = serverId ? [{ serverId, channelId: name }]
|
|
1871
|
+
: mine && 'channel' in mine ? [mine.channel] : this.channelRegistry.channelsNamed(name);
|
|
1872
|
+
const channel = refusal ? undefined : named[0];
|
|
809
1873
|
if (!channel || !this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)) {
|
|
810
|
-
|
|
1874
|
+
// The reader keeps its latitude over the all-except hold too: a
|
|
1875
|
+
// channel it holds (or the scope named as the channel) ends the
|
|
1876
|
+
// scope. A call naming the scope "channel" ends only a channel's
|
|
1877
|
+
// own tune-out, so on a channel only the scope holds it ends
|
|
1878
|
+
// nothing.
|
|
1879
|
+
const scope = this.getAllExcept();
|
|
1880
|
+
const heldByScope = scope && (name === 'all-except'
|
|
1881
|
+
|| (channel !== undefined && this.holdFor(channel.serverId, channel.channelId)?.epochId === scope.params.epochId));
|
|
1882
|
+
if (!heldByScope) return { success: false, error: refusal ?? this.notTunedOut(JSON.stringify(name), 'the resident'), isError: true };
|
|
1883
|
+
if (input.scope === 'channel') {
|
|
1884
|
+
const shown = channel ? this.quotedShown(channel.serverId, channel.channelId) : JSON.stringify(name);
|
|
1885
|
+
return {
|
|
1886
|
+
success: false,
|
|
1887
|
+
error: `Nothing was cancelled: ${shown} isn't tuned out on its own, and scope "channel" ends only a channel's own `
|
|
1888
|
+
+ 'tune-out. The all-except hold holds it, and calling cancel_tuneout with scope "all-except" ends that hold.',
|
|
1889
|
+
isError: true,
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
return endScope();
|
|
811
1893
|
}
|
|
812
|
-
const
|
|
813
|
-
const
|
|
1894
|
+
const held = this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)!;
|
|
1895
|
+
const refused = fork && this.forkRefusal(fork, { epochId: held.params.epochId }, 'cancel',
|
|
1896
|
+
`the tune-out on ${this.quotedShown(channel.serverId, channel.channelId)} isn't the hold you were started to read`);
|
|
1897
|
+
if (refused) return { success: false, error: refused, isError: true };
|
|
1898
|
+
const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note, caller);
|
|
814
1899
|
return result.ok
|
|
815
1900
|
? { success: true, data: { cancelled: true } }
|
|
816
1901
|
: { success: false, error: result.error, isError: true };
|
|
@@ -864,13 +1949,47 @@ export class TuneOutCoordinator {
|
|
|
864
1949
|
// means that channel, as for every channel tool (ownChannelFor).
|
|
865
1950
|
const mine = this.channelRegistry.ownChannelFor(channelId, own, 'publish');
|
|
866
1951
|
if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true };
|
|
867
|
-
|
|
1952
|
+
if (fork) {
|
|
1953
|
+
const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId);
|
|
1954
|
+
const at = named.length === 1 ? named[0] : undefined;
|
|
1955
|
+
const hold = at ? this.holdFor(at.serverId, at.channelId) : null;
|
|
1956
|
+
const shown = at ? this.quotedShown(at.serverId, at.channelId) : JSON.stringify(channelId);
|
|
1957
|
+
const refused = (at || fork.epochId === undefined) && this.forkRefusal(fork, hold, 'speech', hold
|
|
1958
|
+
? `${shown} is held by a hold other than the one you were started to read`
|
|
1959
|
+
: `${shown} isn't held now`);
|
|
1960
|
+
if (refused) return { success: false, error: refused, isError: true };
|
|
1961
|
+
}
|
|
1962
|
+
return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? caller ?? 'Subconscious');
|
|
868
1963
|
}
|
|
869
1964
|
default:
|
|
870
1965
|
return { success: false, error: `unknown subconscious tool: ${name}`, isError: true };
|
|
871
1966
|
}
|
|
872
1967
|
}
|
|
873
1968
|
|
|
1969
|
+
/**
|
|
1970
|
+
* Why a reader fork may not act through `hold` (the epoch holding what its
|
|
1971
|
+
* call names, or null): a fork ends and posts only through the hold of the
|
|
1972
|
+
* epoch it was derived to read, and one derived for none acts through no
|
|
1973
|
+
* hold. Undefined where the hold is its own. `why` says, for the fork, how
|
|
1974
|
+
* what its call names isn't its own hold; its reports stay open to it.
|
|
1975
|
+
*/
|
|
1976
|
+
private forkRefusal(
|
|
1977
|
+
fork: { epochId?: string },
|
|
1978
|
+
hold: { epochId: string } | null,
|
|
1979
|
+
act: 'cancel' | 'speech',
|
|
1980
|
+
why: string,
|
|
1981
|
+
): string | undefined {
|
|
1982
|
+
if (fork.epochId !== undefined && hold?.epochId === fork.epochId) return undefined;
|
|
1983
|
+
const nothing = act === 'cancel' ? 'Nothing was cancelled' : 'Nothing was posted';
|
|
1984
|
+
const report = `Your report can say what you would ${act === 'cancel' ? 'judge of it' : 'have posted'}, and the resident reads it.`;
|
|
1985
|
+
if (fork.epochId === undefined) {
|
|
1986
|
+
return `${nothing}: you weren't started to read a tune-out, so there's no hold you can `
|
|
1987
|
+
+ `${act === 'cancel' ? 'end' : 'post through'}. ${report}`;
|
|
1988
|
+
}
|
|
1989
|
+
const rule = act === 'cancel' ? 'a reader ends only its own' : 'a reader posts only into a channel its own hold holds';
|
|
1990
|
+
return `${nothing}: ${why}, and ${rule}. ${report}`;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
874
1993
|
/**
|
|
875
1994
|
* A cancel of a channel that isn't tuned out, by the name, quoted, the caller gave
|
|
876
1995
|
* or knows it by: which channels are, so the next call can name one, and
|
|
@@ -892,6 +2011,18 @@ export class TuneOutCoordinator {
|
|
|
892
2011
|
+ `first wake past its wake budget, and its backlog goes to ${backlogTo} then.`;
|
|
893
2012
|
}
|
|
894
2013
|
|
|
2014
|
+
/**
|
|
2015
|
+
* The reader ended, not the framework: its turns stop (cadence and
|
|
2016
|
+
* coalescing wakes), and every hold keeps its end time, which releases it
|
|
2017
|
+
* to the resident with no reader.
|
|
2018
|
+
*/
|
|
2019
|
+
stopReader(): void {
|
|
2020
|
+
for (const timer of this.cadenceTimers.values()) clearTimeout(timer);
|
|
2021
|
+
this.cadenceTimers.clear();
|
|
2022
|
+
for (const { timer } of this.pendingWakes.values()) clearTimeout(timer);
|
|
2023
|
+
this.pendingWakes.clear();
|
|
2024
|
+
}
|
|
2025
|
+
|
|
895
2026
|
/** Stop all timers (framework shutdown). */
|
|
896
2027
|
stop(): void {
|
|
897
2028
|
for (const timer of this.cadenceTimers.values()) clearTimeout(timer);
|