@animalabs/agent-framework 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +29 -0
  2. package/dist/src/agent.d.ts +22 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +128 -10
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/types.d.ts +1 -1
  7. package/dist/src/api/types.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +10 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +27 -2
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/tool-definition.d.ts +21 -0
  13. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  14. package/dist/src/code-execution/tool-definition.js +34 -0
  15. package/dist/src/code-execution/tool-definition.js.map +1 -1
  16. package/dist/src/framework.d.ts +153 -2
  17. package/dist/src/framework.d.ts.map +1 -1
  18. package/dist/src/framework.js +1175 -76
  19. package/dist/src/framework.js.map +1 -1
  20. package/dist/src/index.d.ts +2 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +4 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/capability-grant.d.ts +1 -1
  25. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  26. package/dist/src/mcpl/capability-grant.js +49 -5
  27. package/dist/src/mcpl/capability-grant.js.map +1 -1
  28. package/dist/src/mcpl/channel-registry.d.ts +59 -2
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +106 -6
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/conversation-router.d.ts +8 -6
  33. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  34. package/dist/src/mcpl/conversation-router.js +12 -6
  35. package/dist/src/mcpl/conversation-router.js.map +1 -1
  36. package/dist/src/mcpl/index.d.ts +3 -0
  37. package/dist/src/mcpl/index.d.ts.map +1 -1
  38. package/dist/src/mcpl/index.js +3 -0
  39. package/dist/src/mcpl/index.js.map +1 -1
  40. package/dist/src/mcpl/push-coalescer.d.ts +318 -0
  41. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
  42. package/dist/src/mcpl/push-coalescer.js +709 -0
  43. package/dist/src/mcpl/push-coalescer.js.map +1 -0
  44. package/dist/src/mcpl/push-handler.d.ts +7 -2
  45. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  46. package/dist/src/mcpl/push-handler.js +43 -4
  47. package/dist/src/mcpl/push-handler.js.map +1 -1
  48. package/dist/src/mcpl/references.d.ts.map +1 -1
  49. package/dist/src/mcpl/references.js +9 -4
  50. package/dist/src/mcpl/references.js.map +1 -1
  51. package/dist/src/mcpl/server-connection.d.ts +18 -0
  52. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  53. package/dist/src/mcpl/server-connection.js +44 -1
  54. package/dist/src/mcpl/server-connection.js.map +1 -1
  55. package/dist/src/mcpl/tool-classes.d.ts +71 -0
  56. package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
  57. package/dist/src/mcpl/tool-classes.js +170 -0
  58. package/dist/src/mcpl/tool-classes.js.map +1 -0
  59. package/dist/src/mcpl/tool-glob.d.ts +16 -0
  60. package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
  61. package/dist/src/mcpl/tool-glob.js +41 -0
  62. package/dist/src/mcpl/tool-glob.js.map +1 -0
  63. package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
  64. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
  65. package/dist/src/mcpl/tool-lifecycle.js +607 -0
  66. package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
  67. package/dist/src/mcpl/transport.d.ts +15 -0
  68. package/dist/src/mcpl/transport.d.ts.map +1 -1
  69. package/dist/src/mcpl/transport.js +65 -1
  70. package/dist/src/mcpl/transport.js.map +1 -1
  71. package/dist/src/mcpl/types.d.ts +68 -1
  72. package/dist/src/mcpl/types.d.ts.map +1 -1
  73. package/dist/src/mcpl/types.js +6 -0
  74. package/dist/src/mcpl/types.js.map +1 -1
  75. package/dist/src/modules/discord/index.js +1 -1
  76. package/dist/src/modules/discord/index.js.map +1 -1
  77. package/dist/src/modules/history/index.d.ts +85 -0
  78. package/dist/src/modules/history/index.d.ts.map +1 -1
  79. package/dist/src/modules/history/index.js +1019 -24
  80. package/dist/src/modules/history/index.js.map +1 -1
  81. package/dist/src/modules/history/semantic.d.ts +202 -0
  82. package/dist/src/modules/history/semantic.d.ts.map +1 -0
  83. package/dist/src/modules/history/semantic.js +418 -0
  84. package/dist/src/modules/history/semantic.js.map +1 -0
  85. package/dist/src/modules/index.d.ts +2 -1
  86. package/dist/src/modules/index.d.ts.map +1 -1
  87. package/dist/src/modules/index.js.map +1 -1
  88. package/dist/src/tool-result-guard.d.ts +82 -0
  89. package/dist/src/tool-result-guard.d.ts.map +1 -0
  90. package/dist/src/tool-result-guard.js +306 -0
  91. package/dist/src/tool-result-guard.js.map +1 -0
  92. package/dist/src/types/agent.d.ts +14 -0
  93. package/dist/src/types/agent.d.ts.map +1 -1
  94. package/dist/src/types/framework.d.ts +21 -0
  95. package/dist/src/types/framework.d.ts.map +1 -1
  96. package/dist/src/types/trace.d.ts +17 -0
  97. package/dist/src/types/trace.d.ts.map +1 -1
  98. package/dist/test/channel-registry-routing.test.js +59 -0
  99. package/dist/test/channel-registry-routing.test.js.map +1 -1
  100. package/dist/test/code-execution.test.js +139 -0
  101. package/dist/test/code-execution.test.js.map +1 -1
  102. package/dist/test/conversation-router.test.js +33 -19
  103. package/dist/test/conversation-router.test.js.map +1 -1
  104. package/dist/test/conversation-routing.test.js +89 -0
  105. package/dist/test/conversation-routing.test.js.map +1 -1
  106. package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
  107. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
  108. package/dist/test/helpers/coalescing-fixture.js +145 -0
  109. package/dist/test/helpers/coalescing-fixture.js.map +1 -0
  110. package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
  111. package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
  112. package/dist/test/helpers/coalescing-kill-child.js +24 -0
  113. package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
  114. package/dist/test/history-module-realstore.test.d.ts +2 -0
  115. package/dist/test/history-module-realstore.test.d.ts.map +1 -0
  116. package/dist/test/history-module-realstore.test.js +363 -0
  117. package/dist/test/history-module-realstore.test.js.map +1 -0
  118. package/dist/test/history-module-semantic.test.d.ts +2 -0
  119. package/dist/test/history-module-semantic.test.d.ts.map +1 -0
  120. package/dist/test/history-module-semantic.test.js +213 -0
  121. package/dist/test/history-module-semantic.test.js.map +1 -0
  122. package/dist/test/history-module-ux.test.d.ts +2 -0
  123. package/dist/test/history-module-ux.test.d.ts.map +1 -0
  124. package/dist/test/history-module-ux.test.js +304 -0
  125. package/dist/test/history-module-ux.test.js.map +1 -0
  126. package/dist/test/history-module.test.js +25 -1
  127. package/dist/test/history-module.test.js.map +1 -1
  128. package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
  129. package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
  130. package/dist/test/history-semantic-review-findings.test.js +338 -0
  131. package/dist/test/history-semantic-review-findings.test.js.map +1 -0
  132. package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
  133. package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
  134. package/dist/test/history-semantic-sync-converges.test.js +109 -0
  135. package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
  136. package/dist/test/mcpl-capability-grant.test.js +3 -1
  137. package/dist/test/mcpl-capability-grant.test.js.map +1 -1
  138. package/dist/test/mcpl-child-env.test.d.ts +2 -0
  139. package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
  140. package/dist/test/mcpl-child-env.test.js +158 -0
  141. package/dist/test/mcpl-child-env.test.js.map +1 -0
  142. package/dist/test/mcpl-coalescing.test.d.ts +2 -0
  143. package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
  144. package/dist/test/mcpl-coalescing.test.js +1050 -0
  145. package/dist/test/mcpl-coalescing.test.js.map +1 -0
  146. package/dist/test/mcpl-list-status.test.js +4 -0
  147. package/dist/test/mcpl-list-status.test.js.map +1 -1
  148. package/dist/test/mcpl-reference-fields.test.d.ts +2 -0
  149. package/dist/test/mcpl-reference-fields.test.d.ts.map +1 -0
  150. package/dist/test/mcpl-reference-fields.test.js +82 -0
  151. package/dist/test/mcpl-reference-fields.test.js.map +1 -0
  152. package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
  153. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
  154. package/dist/test/mcpl-tool-class-listing.test.js +147 -0
  155. package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
  156. package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
  157. package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
  158. package/dist/test/mcpl-tool-classes.test.js +80 -0
  159. package/dist/test/mcpl-tool-classes.test.js.map +1 -0
  160. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
  161. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
  162. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
  163. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
  164. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
  165. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
  166. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
  167. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
  168. package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
  169. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
  170. package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
  171. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
  172. package/dist/test/silent-heartbeat.test.d.ts +2 -0
  173. package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
  174. package/dist/test/silent-heartbeat.test.js +154 -0
  175. package/dist/test/silent-heartbeat.test.js.map +1 -0
  176. package/dist/test/tool-result-guard.test.d.ts +2 -0
  177. package/dist/test/tool-result-guard.test.d.ts.map +1 -0
  178. package/dist/test/tool-result-guard.test.js +1081 -0
  179. package/dist/test/tool-result-guard.test.js.map +1 -0
  180. package/dist/test/tools-observe-parser-conformance.test.d.ts +2 -0
  181. package/dist/test/tools-observe-parser-conformance.test.d.ts.map +1 -0
  182. package/dist/test/tools-observe-parser-conformance.test.js +129 -0
  183. package/dist/test/tools-observe-parser-conformance.test.js.map +1 -0
  184. package/dist/test/trunk-channel-routing.test.js +67 -0
  185. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  186. package/dist/tsconfig.tsbuildinfo +1 -1
  187. package/package.json +2 -2
  188. package/src/agent.ts +128 -10
  189. package/src/api/types.ts +2 -0
  190. package/src/code-execution/py-runner.ts +34 -2
  191. package/src/code-execution/tool-definition.ts +46 -0
  192. package/src/framework.ts +1207 -80
  193. package/src/index.ts +13 -0
  194. package/src/mcpl/capability-grant.ts +54 -6
  195. package/src/mcpl/channel-registry.ts +115 -7
  196. package/src/mcpl/conversation-router.ts +12 -8
  197. package/src/mcpl/index.ts +29 -0
  198. package/src/mcpl/push-coalescer.ts +821 -0
  199. package/src/mcpl/push-handler.ts +40 -4
  200. package/src/mcpl/references.ts +11 -3
  201. package/src/mcpl/server-connection.ts +48 -2
  202. package/src/mcpl/tool-classes.ts +188 -0
  203. package/src/mcpl/tool-glob.ts +36 -0
  204. package/src/mcpl/tool-lifecycle.ts +745 -0
  205. package/src/mcpl/transport.ts +67 -1
  206. package/src/mcpl/types.ts +71 -1
  207. package/src/modules/discord/index.ts +1 -1
  208. package/src/modules/history/index.ts +1089 -24
  209. package/src/modules/history/semantic.ts +473 -0
  210. package/src/modules/index.ts +2 -1
  211. package/src/tool-result-guard.ts +318 -0
  212. package/src/types/agent.ts +15 -0
  213. package/src/types/framework.ts +23 -0
  214. package/src/types/trace.ts +17 -0
@@ -0,0 +1,709 @@
1
+ export const PUSH_COALESCING_SUPPORT = {
2
+ pushEvents: true, channelsIncoming: true, deferred: true, channelScopedPush: true,
3
+ retryWindowMs: 3_600_000,
4
+ };
5
+ export const COALESCE_OUTCOMES = ['first', 'replaced', 'appended', 'retracted', 'noted', 'consumed'];
6
+ class CancelledRender extends Error {
7
+ }
8
+ export class CoalesceError extends Error {
9
+ field;
10
+ code;
11
+ constructor(field, message, code = -32602) {
12
+ super(message);
13
+ this.field = field;
14
+ this.code = code;
15
+ this.name = 'CoalesceError';
16
+ }
17
+ }
18
+ export function coalescingSubjectKey(serverId, binding, scope, key) {
19
+ return JSON.stringify([serverId, binding, scope.kind, scope.id, key]);
20
+ }
21
+ export function coalescingReceiptKey(serverId, binding, eventId) {
22
+ return JSON.stringify([serverId, binding, eventId]);
23
+ }
24
+ /** Merge identity fields, never letting an absent field erase a known one (§3.1). */
25
+ function mergeIdentity(prior, next) {
26
+ const out = { ...prior };
27
+ for (const [k, v] of Object.entries(next ?? {}))
28
+ if (v !== undefined)
29
+ out[k] = v;
30
+ return out;
31
+ }
32
+ /** Validate wire content before it is stored, rendered or converted. */
33
+ export function validateCoalescedContent(content, maxBytes = 1024 * 1024) {
34
+ if (!Array.isArray(content))
35
+ throw new CoalesceError('payload.content', 'content must be an array');
36
+ for (const b of content) {
37
+ if (!b || typeof b !== 'object' || Array.isArray(b))
38
+ throw new CoalesceError('payload.content', 'invalid content block');
39
+ if (b.type === 'text' && typeof b.text === 'string')
40
+ continue;
41
+ if (b.type === 'resource' && typeof b.uri === 'string')
42
+ continue;
43
+ if ((b.type === 'image' || b.type === 'audio')
44
+ && (typeof b.uri === 'string' || (typeof b.data === 'string' && typeof b.mimeType === 'string')))
45
+ continue;
46
+ throw new CoalesceError('payload.content', 'invalid content block');
47
+ }
48
+ if (Buffer.byteLength(JSON.stringify(content)) > maxBytes)
49
+ throw new CoalesceError('payload.content', 'content exceeds host byte limit');
50
+ }
51
+ /** Validate the `coalesce` member of either lane (§13). */
52
+ export function validateCoalesceMember(c, lane, maxDataBytes = 4096) {
53
+ if (!c || typeof c !== 'object' || Array.isArray(c))
54
+ throw new CoalesceError('coalesce', 'coalesce must be an object');
55
+ const m = c;
56
+ if (typeof m.key !== 'string' || !Buffer.byteLength(m.key) || Buffer.byteLength(m.key) > 256) {
57
+ throw new CoalesceError('coalesce.key', 'key must be 1..256 UTF-8 bytes');
58
+ }
59
+ for (const field of ['deferred', 'retract', 'initial']) {
60
+ if (m[field] !== undefined && typeof m[field] !== 'boolean')
61
+ throw new CoalesceError(`coalesce.${field}`, 'expected boolean');
62
+ }
63
+ if (m.channelId !== undefined && (typeof m.channelId !== 'string' || !m.channelId)) {
64
+ throw new CoalesceError('coalesce.channelId', 'channelId must be a non-empty string');
65
+ }
66
+ if (lane === 'channel' && ('channelId' in m || 'deferred' in m)) {
67
+ throw new CoalesceError('coalesce', 'channel messages cannot select scope or deferred mode');
68
+ }
69
+ if (m.deferred && m.retract)
70
+ throw new CoalesceError('coalesce', 'deferred and retract are exclusive');
71
+ if (m.initial && m.retract)
72
+ throw new CoalesceError('coalesce', 'initial and retract are exclusive');
73
+ if (m.data !== undefined && !m.deferred)
74
+ throw new CoalesceError('coalesce.data', 'data requires deferred');
75
+ if (m.data !== undefined && Buffer.byteLength(JSON.stringify(m.data)) > maxDataBytes) {
76
+ throw new CoalesceError('coalesce.data', `data exceeds ${maxDataBytes} bytes`);
77
+ }
78
+ }
79
+ export class PushCoalescer {
80
+ host;
81
+ options;
82
+ subjects = new Map();
83
+ /** `committed`: the durability barrier succeeded after this receipt was
84
+ * written. An uncommitted receipt deduplicates effects but is not an
85
+ * acknowledgement until a commit succeeds. */
86
+ receipts = new Map();
87
+ retryWindowMs;
88
+ maxSubjects;
89
+ maxNotices;
90
+ /** Outstanding push/render RPCs per server, including cancelled ones (§10.7). */
91
+ rendersInFlight = new Map();
92
+ /**
93
+ * The ONE critical section. Every state transition — an admission, the
94
+ * freezing of a batch for rendering, the settlement of a render's result —
95
+ * runs under it, awaits included (fork spawn, durability commit, delivery).
96
+ * Only the push/render RPC itself runs outside, so it stays cancellable.
97
+ * An admission therefore never observes a half-done assembly transition,
98
+ * and an assembly never acts on a batch an admission moved on from.
99
+ */
100
+ serial = Promise.resolve();
101
+ /** Liveness after a failed freeze: a bounded, backed-off re-wake per
102
+ * subject. `attempt` outlives the armed timer: it counts consecutive
103
+ * failures and resets only on success or disposal of the pending work. */
104
+ recovery = new Map();
105
+ suspended = false;
106
+ constructor(host, options = {}) {
107
+ this.host = host;
108
+ this.options = options;
109
+ this.retryWindowMs = options.retryWindowMs ?? PUSH_COALESCING_SUPPORT.retryWindowMs;
110
+ this.maxSubjects = options.maxSubjects ?? 4_096;
111
+ this.maxNotices = options.maxNotices ?? 64;
112
+ }
113
+ now() { return this.host.now?.() ?? Date.now(); }
114
+ locked(fn) {
115
+ const run = this.serial.then(fn, fn);
116
+ this.serial = run.then(() => undefined, () => undefined);
117
+ return run;
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // Observation
121
+ // ---------------------------------------------------------------------------
122
+ /** The unread occupant's or batch's placement/identity, for routing continuity. */
123
+ identity(subject) {
124
+ return this.subjects.get(subject)?.identity;
125
+ }
126
+ pendingBatches() {
127
+ let n = 0;
128
+ for (const s of this.subjects.values())
129
+ if (s.batch)
130
+ n++;
131
+ return n;
132
+ }
133
+ /** §10.7: a server handling push/render must not issue inference/request —
134
+ * including a render whose batch was cancelled while the RPC is outstanding. */
135
+ isRendering(serverId) {
136
+ return (this.rendersInFlight.get(serverId) ?? 0) > 0;
137
+ }
138
+ receipt(serverId, binding, eventId) {
139
+ const entry = this.receiptEntry(coalescingReceiptKey(serverId, binding, eventId));
140
+ return entry && structuredClone(entry.result);
141
+ }
142
+ receiptEntry(key) {
143
+ const entry = this.receipts.get(key);
144
+ if (!entry)
145
+ return undefined;
146
+ if (this.now() - entry.at > this.retryWindowMs) {
147
+ this.receipts.delete(key);
148
+ return undefined;
149
+ }
150
+ return entry;
151
+ }
152
+ /** Acknowledge a receipt: durable first. A receipt whose commit failed is
153
+ * committed now (no effects are repeated); if that fails again, so does
154
+ * the acknowledgement, and the producer retries once more. */
155
+ async acknowledge(key, entry, subject, eventId) {
156
+ if (!entry.committed) {
157
+ try {
158
+ await this.host.commit?.();
159
+ entry.committed = true;
160
+ }
161
+ catch (error) {
162
+ this.host.audit({ kind: 'persist-failed', subject, eventId, error: String(error) });
163
+ throw new CoalesceError('eventId', 'host could not make the acceptance durable; retry', -32000);
164
+ }
165
+ }
166
+ return structuredClone(entry.result);
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // Admission
170
+ // ---------------------------------------------------------------------------
171
+ /**
172
+ * Admit an occurrence that has already passed the lane's ordinary checks and
173
+ * the coalesce-member validation. Returns the wire result, performing the
174
+ * host effects (replace / remove / deliver) synchronously so a create, edit
175
+ * and delete cannot overtake one another.
176
+ */
177
+ accept(occurrence) {
178
+ return this.locked(() => this.admit(occurrence));
179
+ }
180
+ async admit(occurrence) {
181
+ if (this.suspended)
182
+ throw new CoalesceError('serverId', 'host is stopping', -32000);
183
+ const subject = coalescingSubjectKey(occurrence.serverId, occurrence.binding, occurrence.scope, occurrence.key);
184
+ const duplicate = this.receiptEntry(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId));
185
+ if (duplicate) {
186
+ // A producer retrying acknowledged work is also a chance to notice a
187
+ // batch left asleep by a failed freeze: wake it now, without effects.
188
+ const state = this.subjects.get(subject);
189
+ if (state?.batch && !state.rendering) {
190
+ this.disarmRecovery(subject);
191
+ await this.host.wakeForBatch(state.batch.latest);
192
+ }
193
+ return this.acknowledge(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId), duplicate, subject, occurrence.eventId);
194
+ }
195
+ const born = this.subjects.has(subject) ? undefined : (occurrence.initial && !occurrence.retract ? 'none' : 'unknown');
196
+ const state = this.subjectFor(subject, occurrence);
197
+ const occupantUnread = this.refreshOccupant(state);
198
+ const priorEventId = state.occupant?.eventId ?? state.batch?.latest.eventId ?? state.rendering?.batch.latest.eventId ?? state.consumedEventId;
199
+ this.host.audit({ kind: 'received', subject, eventId: occurrence.eventId, retract: occurrence.retract, deferred: occurrence.deferred });
200
+ let outcome;
201
+ if (occurrence.retract) {
202
+ outcome = await this.retract(subject, state, occurrence, occupantUnread);
203
+ }
204
+ else if (occurrence.deferred) {
205
+ outcome = await this.acceptDeferred(subject, state, occurrence, occupantUnread);
206
+ }
207
+ else {
208
+ outcome = await this.acceptPlain(subject, state, occurrence, occupantUnread);
209
+ }
210
+ if (occurrence.scope.kind === 'channel' && occurrence.identity && !occurrence.retract) {
211
+ state.identity = mergeIdentity(state.identity, occurrence.identity);
212
+ }
213
+ state.touchedAt = this.now();
214
+ const result = { accepted: true, coalesce: { outcome, ...(priorEventId ? { priorEventId } : {}) } };
215
+ const receiptKey = coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId);
216
+ const entry = { result: structuredClone(result), at: this.now(), committed: false };
217
+ try {
218
+ this.host.recordReceipt?.({
219
+ key: receiptKey, result: entry.result, at: entry.at, subject, ...(born ? { born } : {}),
220
+ batch: state.batch ? structuredClone({ latest: state.batch.latest, notices: state.batch.notices, dropped: state.batch.dropped }) : null,
221
+ });
222
+ }
223
+ catch (error) {
224
+ // Not acknowledged: the work would be lost while its receipt suppressed
225
+ // a retry. The producer retries within the window.
226
+ this.host.audit({ kind: 'persist-failed', subject, eventId: occurrence.eventId, error: String(error) });
227
+ throw new CoalesceError('eventId', 'host could not persist the acceptance; retry', -32000);
228
+ }
229
+ this.receipts.set(receiptKey, entry);
230
+ this.prune();
231
+ this.persist();
232
+ // Written but not yet durable: the in-memory receipt deduplicates a retry
233
+ // while this process lives, and the retry commits it (acknowledge); after
234
+ // a kill nothing of it exists and the retry is a fresh acceptance.
235
+ return this.acknowledge(receiptKey, entry, subject, occurrence.eventId);
236
+ }
237
+ subjectFor(subject, occurrence) {
238
+ let state = this.subjects.get(subject);
239
+ if (!state) {
240
+ // §3.3: an untracked subject is `unknown` unless the producer marks the
241
+ // birth of the subject. A retraction is never a birth.
242
+ state = { history: occurrence.initial && !occurrence.retract ? 'none' : 'unknown', touchedAt: this.now() };
243
+ this.subjects.set(subject, state);
244
+ }
245
+ return state;
246
+ }
247
+ /** Re-check the occupant against the host: consumed occupants become history. */
248
+ refreshOccupant(state) {
249
+ if (!state.occupant)
250
+ return false;
251
+ if (this.host.isUnread(state.occupant.placement))
252
+ return true;
253
+ state.history = 'some';
254
+ state.consumedEventId = state.occupant.eventId;
255
+ state.occupant = undefined;
256
+ return false;
257
+ }
258
+ scheduleRecovery(subject, state) {
259
+ const prior = this.recovery.get(subject);
260
+ if (prior?.timer)
261
+ clearTimeout(prior.timer);
262
+ const attempt = (prior?.attempt ?? 0) + 1;
263
+ const base = this.options.recoveryBackoffMs ?? 1_000;
264
+ const delay = Math.min(base * 2 ** (attempt - 1), base * 60);
265
+ const entry = { attempt };
266
+ entry.timer = setTimeout(() => {
267
+ // Disarm, but keep the attempt count: a freeze that fails again backs
268
+ // off further. The count resets only on success or disposal.
269
+ if (this.recovery.get(subject) === entry)
270
+ entry.timer = undefined;
271
+ if (this.suspended || this.subjects.get(subject) !== state || !state.batch || state.rendering)
272
+ return;
273
+ // Authority and wake policy are the host's, re-evaluated at fire time.
274
+ if (!this.host.authorized(state.batch.latest))
275
+ return;
276
+ this.host.audit({ kind: 'recovery-wake', subject, eventId: state.batch.latest.eventId, attempt, delayMs: delay });
277
+ void this.host.wakeForBatch(state.batch.latest).catch(() => { });
278
+ }, delay);
279
+ entry.timer.unref?.();
280
+ this.recovery.set(subject, entry);
281
+ }
282
+ /** Disarm the timer only (a producer retry wakes now; the count stands). */
283
+ disarmRecovery(subject) {
284
+ const prior = this.recovery.get(subject);
285
+ if (prior?.timer) {
286
+ clearTimeout(prior.timer);
287
+ prior.timer = undefined;
288
+ }
289
+ }
290
+ /** Success or disposal of the pending work: forget the failure series. */
291
+ clearRecovery(subject) {
292
+ const prior = this.recovery.get(subject);
293
+ if (prior?.timer)
294
+ clearTimeout(prior.timer);
295
+ this.recovery.delete(subject);
296
+ }
297
+ dropBatches(subject, state) {
298
+ this.clearRecovery(subject);
299
+ let dropped = false;
300
+ if (state.batch) {
301
+ this.host.audit({ kind: 'displaced', subject, eventId: state.batch.latest.eventId, batch: true });
302
+ state.batch = undefined;
303
+ dropped = true;
304
+ }
305
+ if (state.rendering) {
306
+ this.host.audit({ kind: 'render-cancelled', subject, eventId: state.rendering.batch.latest.eventId });
307
+ state.rendering.cancelled = true;
308
+ state.rendering = undefined;
309
+ dropped = true;
310
+ }
311
+ return dropped;
312
+ }
313
+ async retract(subject, state, occurrence, occupantUnread) {
314
+ if (occupantUnread && state.occupant) {
315
+ this.host.audit({ kind: 'removed', subject, eventId: state.occupant.eventId });
316
+ if (!this.host.remove(state.occupant.placement)) {
317
+ // Still in context and possibly about to be read: say so, append the notice.
318
+ this.host.audit({ kind: 'remove-failed', subject, eventId: state.occupant.eventId });
319
+ state.history = 'unknown';
320
+ }
321
+ }
322
+ state.occupant = undefined;
323
+ this.dropBatches(subject, state);
324
+ this.host.cancelWake(subject);
325
+ state.consumedEventId = undefined;
326
+ if (state.history === 'none')
327
+ return 'retracted';
328
+ if (!occurrence.content.length)
329
+ return 'consumed';
330
+ // The notice is an ordinary occurrence: it rides the normal delivery path
331
+ // (tags → gate policy → wake) and is consumed like any message — in the
332
+ // context that read a version, never a new one (§3.2).
333
+ const placement = await this.host.deliver({ ...occurrence, deliverTo: state.audienceAgent });
334
+ if (placement) {
335
+ state.occupant = { eventId: occurrence.eventId, placement };
336
+ state.audienceAgent = placement.agent;
337
+ }
338
+ return 'noted';
339
+ }
340
+ async acceptPlain(subject, state, occurrence, occupantUnread) {
341
+ const hadBatch = this.dropBatches(subject, state);
342
+ if (occupantUnread && state.occupant) {
343
+ const prior = state.occupant;
344
+ this.host.audit({ kind: 'displaced', subject, eventId: prior.eventId });
345
+ if (this.host.remove(prior.placement)) {
346
+ this.host.cancelWake(subject);
347
+ // The replacement follows the prior delivery's audience (§3.2).
348
+ const placement = await this.host.deliver({ ...occurrence, deliverTo: prior.placement.agent });
349
+ state.occupant = placement ? { eventId: occurrence.eventId, placement } : undefined;
350
+ if (placement)
351
+ state.audienceAgent = placement.agent;
352
+ return 'replaced';
353
+ }
354
+ // Could not remove: the prior may still be read. Append, as for consumed.
355
+ this.host.audit({ kind: 'remove-failed', subject, eventId: prior.eventId });
356
+ state.history = 'unknown';
357
+ state.consumedEventId = prior.eventId;
358
+ state.occupant = undefined;
359
+ }
360
+ if (hadBatch)
361
+ this.host.cancelWake(subject);
362
+ const placement = await this.host.deliver(occurrence);
363
+ state.occupant = placement ? { eventId: occurrence.eventId, placement } : undefined;
364
+ if (placement)
365
+ state.audienceAgent = placement.agent;
366
+ if (hadBatch)
367
+ return 'replaced';
368
+ return state.consumedEventId || state.history !== 'none' ? 'appended' : 'first';
369
+ }
370
+ async acceptDeferred(subject, state, occurrence, occupantUnread) {
371
+ let outcome = 'first';
372
+ if (occupantUnread && state.occupant) {
373
+ // §5.1: a notice displaces an unread plain occurrence and opens a batch
374
+ // — in the audience that held the plain occurrence (§3.2).
375
+ this.host.audit({ kind: 'displaced', subject, eventId: state.occupant.eventId });
376
+ if (this.host.remove(state.occupant.placement)) {
377
+ outcome = 'replaced';
378
+ occurrence = { ...occurrence, deliverTo: state.occupant.placement.agent };
379
+ }
380
+ else {
381
+ this.host.audit({ kind: 'remove-failed', subject, eventId: state.occupant.eventId });
382
+ state.history = 'unknown';
383
+ }
384
+ this.host.cancelWake(subject);
385
+ state.occupant = undefined;
386
+ }
387
+ else if (state.batch?.latest.deliverTo) {
388
+ // Later notices stay in the batch's audience.
389
+ occurrence = { ...occurrence, deliverTo: state.batch.latest.deliverTo };
390
+ }
391
+ const notice = { eventId: occurrence.eventId, timestamp: occurrence.timestamp, ...(occurrence.data !== undefined ? { data: structuredClone(occurrence.data) } : {}) };
392
+ if (state.batch) {
393
+ state.batch.notices.push(notice);
394
+ if (state.batch.notices.length > this.maxNotices) {
395
+ state.batch.notices.shift();
396
+ state.batch.dropped++;
397
+ }
398
+ state.batch.latest = occurrence;
399
+ outcome = 'replaced';
400
+ }
401
+ else {
402
+ // Rule 1: a notice during a render opens a NEW batch ("first").
403
+ state.batch = { latest: occurrence, notices: [notice], dropped: 0 };
404
+ }
405
+ await this.host.wakeForBatch(occurrence);
406
+ return outcome;
407
+ }
408
+ // ---------------------------------------------------------------------------
409
+ // Assembly (deferred batches only — plain content is already in context)
410
+ // ---------------------------------------------------------------------------
411
+ /**
412
+ * Render and materialize every pending batch whose audience includes
413
+ * `agentName`. Called by the host at a turn's assembly boundary, before the
414
+ * compile. Bounded by the host's render timeout; a failed or late render
415
+ * materializes the admitted fallback (§5.3).
416
+ */
417
+ async assemble(agentName) {
418
+ if (this.suspended)
419
+ return;
420
+ const work = [];
421
+ for (const subject of [...this.subjects.keys()]) {
422
+ // The handle is wrapped: a bare promise returned through `locked` would
423
+ // be flattened by `then`, holding the lock until the render settled —
424
+ // and the settlement itself needs the lock.
425
+ const started = await this.locked(() => this.freeze(subject, agentName));
426
+ if (started)
427
+ work.push(started.done);
428
+ }
429
+ await Promise.all(work);
430
+ this.persist();
431
+ }
432
+ /**
433
+ * Under the lock: decide whether `subject`'s batch renders for `agentName`,
434
+ * freeze it, make the render-start durable, re-check authority, and start
435
+ * the RPC. Returns the render's completion (shared by every assembly that
436
+ * waits on this batch, vector 25) or undefined when nothing was started.
437
+ */
438
+ async freeze(subject, agentName) {
439
+ const state = this.subjects.get(subject);
440
+ if (!state || this.suspended)
441
+ return undefined;
442
+ if (state.rendering) {
443
+ // Another assembly froze this batch; share its outcome (vector 25).
444
+ const rendering = state.rendering;
445
+ return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined;
446
+ }
447
+ const batch = state.batch;
448
+ if (!batch)
449
+ return undefined;
450
+ // The audience lookup may spawn a fork. Under the lock no admission can
451
+ // move the subject on meanwhile; the identity check guards the host.
452
+ const audience = await this.host.audience(batch.latest);
453
+ if (!audience.includes(agentName) || state.batch !== batch || state.rendering)
454
+ return undefined;
455
+ state.batch = undefined;
456
+ this.host.cancelWake(subject);
457
+ if (!this.host.authorized(batch.latest)) {
458
+ this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId });
459
+ return undefined;
460
+ }
461
+ const rendering = { batch, cancelled: false, done: Promise.resolve() };
462
+ state.rendering = rendering;
463
+ // The render-start boundary is recoverable BEFORE the RPC: a restart
464
+ // then takes the fallback instead of asking the server a second time.
465
+ try {
466
+ if (this.host.saveNow)
467
+ this.host.saveNow(this.snapshot());
468
+ else
469
+ this.persist();
470
+ await this.host.commit?.();
471
+ }
472
+ catch (error) {
473
+ this.host.audit({ kind: 'persist-failed', subject, eventId: batch.latest.eventId, error: String(error) });
474
+ state.rendering = undefined;
475
+ batch.noRender = true; // cannot prove a render never started → fallback
476
+ state.batch = batch;
477
+ // The wake was withdrawn above; acknowledged work must not sleep until
478
+ // unrelated traffic happens by. Re-wake with backoff, so a persistent
479
+ // storage outage does not become a tight loop of model turns.
480
+ this.scheduleRecovery(subject, state);
481
+ return undefined;
482
+ }
483
+ this.clearRecovery(subject);
484
+ if (this.suspended) {
485
+ state.rendering = undefined;
486
+ state.batch = batch;
487
+ return undefined;
488
+ }
489
+ // Authority (grant, registration, binding) is re-checked immediately
490
+ // before dispatch: a server id reassigned to another endpoint during the
491
+ // commit must not receive this batch's notices.
492
+ if (!this.host.authorized(batch.latest)) {
493
+ this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId });
494
+ state.rendering = undefined;
495
+ return undefined;
496
+ }
497
+ rendering.done = this.render(subject, state, rendering, agentName);
498
+ return { done: rendering.done };
499
+ }
500
+ async render(subject, state, rendering, assemblingFor) {
501
+ const { batch } = rendering;
502
+ const occurrence = batch.latest;
503
+ let content = occurrence.content;
504
+ let timestamp = occurrence.timestamp;
505
+ let source = 'fallback';
506
+ if (!batch.noRender) {
507
+ const params = {
508
+ featureSet: occurrence.featureSet ?? (occurrence.scope.kind === 'featureSet' ? occurrence.scope.id : ''),
509
+ ...(occurrence.scope.kind === 'channel' ? { channelId: occurrence.scope.id } : {}),
510
+ key: occurrence.key, eventId: occurrence.eventId,
511
+ notices: structuredClone(batch.notices), dropped: batch.dropped,
512
+ };
513
+ this.rendersInFlight.set(occurrence.serverId, (this.rendersInFlight.get(occurrence.serverId) ?? 0) + 1);
514
+ try {
515
+ const result = await this.host.render(occurrence, params);
516
+ if (rendering.cancelled) {
517
+ this.host.audit({ kind: 'late-render', subject, eventId: occurrence.eventId, discarded: true });
518
+ }
519
+ else
520
+ validateCoalescedContent(result?.content, this.options.maxContentBytes);
521
+ if (rendering.cancelled)
522
+ throw new CancelledRender();
523
+ content = result.content;
524
+ timestamp = typeof result.timestamp === 'string' ? result.timestamp : new Date().toISOString();
525
+ source = 'render';
526
+ }
527
+ catch (error) {
528
+ if (!(error instanceof CancelledRender))
529
+ this.host.audit({ kind: 'render-failed', subject, eventId: occurrence.eventId, error: String(error) });
530
+ }
531
+ finally {
532
+ const left = (this.rendersInFlight.get(occurrence.serverId) ?? 1) - 1;
533
+ if (left > 0)
534
+ this.rendersInFlight.set(occurrence.serverId, left);
535
+ else
536
+ this.rendersInFlight.delete(occurrence.serverId);
537
+ }
538
+ }
539
+ // Settlement runs under the lock: between the RPC's return and the state
540
+ // update no admission can withdraw or replace what is being published.
541
+ await this.locked(async () => {
542
+ try {
543
+ if (rendering.cancelled || state.rendering !== rendering || this.suspended)
544
+ return;
545
+ // Rule 4: authority is re-checked at response.
546
+ if (!this.host.authorized(occurrence)) {
547
+ this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId });
548
+ return;
549
+ }
550
+ this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty: content.length === 0 });
551
+ if (!content.length)
552
+ return; // §5.2: nothing happened
553
+ const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor);
554
+ if (placement?.deferredId) {
555
+ // Landed in another agent's deferred queue (its turn is alive): still
556
+ // unread there, so it stays replaceable and withdrawable.
557
+ state.occupant = { eventId: occurrence.eventId, placement };
558
+ state.audienceAgent = placement.agent;
559
+ return;
560
+ }
561
+ // The materialized occurrence is consumed by the request being assembled.
562
+ state.history = 'some';
563
+ state.consumedEventId = occurrence.eventId;
564
+ state.occupant = undefined;
565
+ if (placement)
566
+ state.audienceAgent = placement.agent;
567
+ }
568
+ finally {
569
+ if (state.rendering === rendering)
570
+ state.rendering = undefined;
571
+ }
572
+ });
573
+ }
574
+ // ---------------------------------------------------------------------------
575
+ // Lifecycle
576
+ // ---------------------------------------------------------------------------
577
+ restore(snapshot) {
578
+ if (!snapshot || snapshot.version !== 1)
579
+ return;
580
+ const now = this.now();
581
+ for (const [key, entry] of snapshot.receipts) {
582
+ if (now - entry.at <= this.retryWindowMs)
583
+ this.receipts.set(key, { ...entry, committed: true });
584
+ }
585
+ for (const s of snapshot.subjects) {
586
+ const state = {
587
+ // A stored occupant is in context and will be in the next request:
588
+ // it counts as read from here on (the watermark restarts at the head).
589
+ history: s.occupant ? 'some' : s.history,
590
+ consumedEventId: s.occupant?.eventId ?? s.consumedEventId,
591
+ identity: s.identity,
592
+ audienceAgent: s.audienceAgent ?? s.occupant?.placement.agent,
593
+ touchedAt: now,
594
+ };
595
+ if (s.batch) {
596
+ // An interrupted render is not replayed (§3.2): a batch that was
597
+ // RENDERING keeps its fallback; a batch that was only pending renders
598
+ // normally at the next assembly.
599
+ state.batch = { latest: s.batch.latest, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.rendering ? { noRender: true } : {}) };
600
+ }
601
+ this.subjects.set(s.subject, state);
602
+ }
603
+ this.settlePublished();
604
+ }
605
+ /**
606
+ * A batch restored as RENDERING may already have been published (its
607
+ * result or fallback is a durable context message; only the completion
608
+ * snapshot was lost). Recovery then marks it consumed instead of
609
+ * delivering the fallback a second time.
610
+ */
611
+ settlePublished() {
612
+ if (!this.host.wasPublished)
613
+ return;
614
+ for (const [subject, state] of this.subjects) {
615
+ const batch = state.batch;
616
+ if (!batch?.noRender)
617
+ continue;
618
+ if (this.host.wasPublished(subject, batch.latest.eventId)) {
619
+ state.batch = undefined;
620
+ state.history = 'some';
621
+ state.consumedEventId = batch.latest.eventId;
622
+ }
623
+ }
624
+ }
625
+ /** Apply receipts written since the last snapshot (crash-window bridge). */
626
+ restoreReceipts(records) {
627
+ const now = this.now();
628
+ for (const r of records) {
629
+ if (now - r.at > this.retryWindowMs)
630
+ continue;
631
+ this.receipts.set(r.key, { result: r.result, at: r.at, committed: true });
632
+ if (r.born && !this.subjects.has(r.subject))
633
+ this.subjects.set(r.subject, { history: r.born, touchedAt: now });
634
+ // A birth fact is not proof the subject is still unread: the plain
635
+ // occurrence this record acknowledged is in context (durable before the
636
+ // record) and counts as read from recovery on, like a snapshot occupant.
637
+ let eventId;
638
+ try {
639
+ eventId = JSON.parse(r.key)[2];
640
+ }
641
+ catch { /* malformed key: no settlement */ }
642
+ if (eventId && this.host.wasPublished?.(r.subject, eventId)) {
643
+ const state = this.subjects.get(r.subject) ?? { history: 'unknown', touchedAt: now };
644
+ state.history = 'some';
645
+ state.consumedEventId = eventId;
646
+ this.subjects.set(r.subject, state);
647
+ }
648
+ if (r.batch === undefined)
649
+ continue; // pre-round-3 record: no batch information
650
+ // The subject's batch AFTER that acceptance, newer than the snapshot:
651
+ // the accepted deferred work itself, or null for a withdrawal /
652
+ // replacement that cleared it. It never began rendering before the
653
+ // record was written, but the snapshot cannot say whether it did
654
+ // afterwards: take the fallback.
655
+ const state = this.subjects.get(r.subject) ?? { history: 'unknown', touchedAt: now };
656
+ state.batch = r.batch
657
+ ? { latest: r.batch.latest, notices: r.batch.notices, dropped: r.batch.dropped, noRender: true }
658
+ : undefined;
659
+ this.subjects.set(r.subject, state);
660
+ }
661
+ this.settlePublished();
662
+ }
663
+ snapshot() {
664
+ return {
665
+ version: 1,
666
+ receipts: [...this.receipts],
667
+ subjects: [...this.subjects].map(([subject, s]) => ({
668
+ subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity,
669
+ ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}),
670
+ ...(s.occupant ? { occupant: s.occupant } : {}),
671
+ ...(s.batch ? { batch: { latest: s.batch.latest, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : {}) } }
672
+ : s.rendering ? { batch: { latest: s.rendering.batch.latest, notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } }
673
+ : {}),
674
+ })),
675
+ };
676
+ }
677
+ /** Batches whose wake should be re-queued after a restart (their fallback is pending). */
678
+ pendingBatchOccurrences() {
679
+ return [...this.subjects.values()].flatMap(s => s.batch ? [s.batch.latest] : []);
680
+ }
681
+ suspend() {
682
+ this.suspended = true;
683
+ for (const subject of [...this.recovery.keys()])
684
+ this.clearRecovery(subject);
685
+ for (const [subject, state] of this.subjects) {
686
+ if (state.rendering) {
687
+ state.rendering.cancelled = true;
688
+ this.host.audit({ kind: 'render-cancelled', subject, eventId: state.rendering.batch.latest.eventId, reason: 'suspend' });
689
+ }
690
+ }
691
+ }
692
+ persist() { this.host.save(() => this.snapshot()); }
693
+ prune() {
694
+ const now = this.now();
695
+ if (this.receipts.size > 4 * this.maxSubjects) {
696
+ for (const [key, entry] of this.receipts)
697
+ if (now - entry.at > this.retryWindowMs)
698
+ this.receipts.delete(key);
699
+ }
700
+ if (this.subjects.size <= this.maxSubjects)
701
+ return;
702
+ // Evict the oldest idle subjects; their history degrades to `unknown`
703
+ // (an untracked subject) which is the conservative outcome (§3.3).
704
+ const idle = [...this.subjects].filter(([, s]) => !s.occupant && !s.batch && !s.rendering).sort((a, b) => a[1].touchedAt - b[1].touchedAt);
705
+ for (const [key] of idle.slice(0, Math.max(0, this.subjects.size - this.maxSubjects)))
706
+ this.subjects.delete(key);
707
+ }
708
+ }
709
+ //# sourceMappingURL=push-coalescer.js.map