@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,821 @@
1
+ /**
2
+ * RFC-006 event coalescing (mcpl PR #5, revision 7): replace-if-unread, deferred
3
+ * rendering, and atomic retraction, for both delivery lanes.
4
+ *
5
+ * The design keeps content IN context. A plain occurrence is delivered through
6
+ * the ordinary channel/push path the moment it is admitted, exactly as today;
7
+ * the coalescer only remembers WHERE it landed. A later occurrence of the same
8
+ * subject edits or removes that message in place while it is still unread
9
+ * (above the agent's consumed watermark, not folded by compression), and
10
+ * appends once it has been read. Nothing is staged, journaled or capped: on
11
+ * any uncertainty (restart, branch switch, eviction) the host behaves as an
12
+ * append-only host would, which is what the RFC's principle 4 asks for.
13
+ *
14
+ * Only deferred batches (§5) wait outside context, because a notice has no
15
+ * model-visible content until `push/render` produces it.
16
+ *
17
+ * All host effects go through {@link CoalescerHost}; this class is pure
18
+ * bookkeeping and is unit-tested without a framework.
19
+ */
20
+ import type { McplContentBlock, PushEventResult } from './types.js';
21
+
22
+ export const PUSH_COALESCING_SUPPORT = {
23
+ pushEvents: true, channelsIncoming: true, deferred: true, channelScopedPush: true,
24
+ retryWindowMs: 3_600_000,
25
+ } as const;
26
+
27
+ export const COALESCE_OUTCOMES = ['first', 'replaced', 'appended', 'retracted', 'noted', 'consumed'] as const;
28
+ export type CoalesceOutcome = typeof COALESCE_OUTCOMES[number];
29
+
30
+ class CancelledRender extends Error {}
31
+
32
+ export class CoalesceError extends Error {
33
+ constructor(readonly field: string, message: string, readonly code = -32602) {
34
+ super(message);
35
+ this.name = 'CoalesceError';
36
+ }
37
+ }
38
+
39
+ /** Where an unread occurrence currently lives. */
40
+ export interface CoalescingPlacement {
41
+ agent: string;
42
+ /** Stored message id in that agent's context manager. */
43
+ messageId?: string;
44
+ /** Entry id in the framework's deferred-write queue (turn-alive deferral). */
45
+ deferredId?: string;
46
+ }
47
+
48
+ export interface CoalescingScope { kind: 'featureSet' | 'channel'; id: string }
49
+
50
+ /** One admitted occurrence, normalized from either lane. */
51
+ export interface CoalescedOccurrence<E = unknown> {
52
+ serverId: string;
53
+ /** Host-owned binding identity (§3.2): stable across reconnects to the same
54
+ * configured peer, different when the id is reassigned to another one. */
55
+ binding: string;
56
+ /** Declaring feature set (push lane), echoed in push/render. */
57
+ featureSet?: string;
58
+ scope: CoalescingScope;
59
+ key: string;
60
+ eventId: string;
61
+ timestamp: string;
62
+ retract: boolean;
63
+ deferred: boolean;
64
+ initial: boolean;
65
+ data?: unknown;
66
+ tags?: string[];
67
+ /** Wire content (fallback for deferred; the notice for retract). */
68
+ content: McplContentBlock[];
69
+ /** Stable platform identity for channel subjects (§3.1). */
70
+ identity?: { messageId?: string; author?: { id: string; name: string }; threadId?: string };
71
+ /** The event the ordinary path would have queued — the host delivers it. */
72
+ event: E;
73
+ /** Set by the coalescer: deliver into this agent only (§3.2 audience may
74
+ * narrow, never widen — a replacement or notice follows the prior delivery). */
75
+ deliverTo?: string;
76
+ }
77
+
78
+ export interface DeferredNotice { eventId: string; timestamp: string; data?: unknown }
79
+
80
+ export interface PushRenderParams {
81
+ featureSet: string;
82
+ channelId?: string;
83
+ key: string;
84
+ eventId: string;
85
+ notices: DeferredNotice[];
86
+ dropped: number;
87
+ }
88
+ export interface PushRenderResult { content: McplContentBlock[]; timestamp?: string }
89
+
90
+ interface DeferredBatch<E> {
91
+ /** Latest notice; its content is the fallback, its tags/event drive delivery. */
92
+ latest: CoalescedOccurrence<E>;
93
+ notices: DeferredNotice[];
94
+ dropped: number;
95
+ /** Set on a batch restored from a snapshot whose render may have started. */
96
+ noRender?: boolean;
97
+ }
98
+
99
+ interface Rendering<E> {
100
+ batch: DeferredBatch<E>;
101
+ cancelled: boolean;
102
+ done: Promise<void>;
103
+ }
104
+
105
+ interface SubjectState<E> {
106
+ history: 'none' | 'some' | 'unknown';
107
+ occupant?: { eventId: string; placement: CoalescingPlacement };
108
+ batch?: DeferredBatch<E>;
109
+ rendering?: Rendering<E>;
110
+ consumedEventId?: string;
111
+ identity?: CoalescedOccurrence['identity'];
112
+ /** Agent that received the last delivery; replacements and notices follow it. */
113
+ audienceAgent?: string;
114
+ touchedAt: number;
115
+ }
116
+
117
+ export interface CoalescingSnapshot {
118
+ version: 1;
119
+ receipts: Array<[string, { result: PushEventResult; at: number }]>;
120
+ subjects: Array<{
121
+ subject: string;
122
+ history: SubjectState<unknown>['history'];
123
+ consumedEventId?: string;
124
+ identity?: CoalescedOccurrence['identity'];
125
+ occupant?: { eventId: string; placement: CoalescingPlacement };
126
+ audienceAgent?: string;
127
+ batch?: { latest: CoalescedOccurrence<unknown>; notices: DeferredNotice[]; dropped: number; rendering?: boolean };
128
+ }>;
129
+ }
130
+ /** One receipt, written synchronously at acceptance (bridges the snapshot throttle). */
131
+ export interface CoalescingReceiptRecord {
132
+ key: string;
133
+ result: PushEventResult;
134
+ at: number;
135
+ subject: string;
136
+ /** History the subject was created with, for a subject first seen by this receipt. */
137
+ born?: SubjectState<unknown>['history'];
138
+ /** The subject's pending batch AFTER this acceptance: the accepted work
139
+ * itself (so a crash before the snapshot flush cannot lose it while keeping
140
+ * the receipt that suppresses its retry), or `null` when the acceptance
141
+ * left no batch — a retraction or plain replacement must be recoverable
142
+ * too. Records replay in order, so the last one per subject wins. */
143
+ batch: { latest: CoalescedOccurrence<unknown>; notices: DeferredNotice[]; dropped: number } | null;
144
+ }
145
+
146
+ export interface CoalescerHost<E> {
147
+ /** True iff the placement still exists and no model request has included it. */
148
+ isUnread(placement: CoalescingPlacement): boolean;
149
+ /** False when the unread occurrence could not be removed (it may still be read). */
150
+ remove(placement: CoalescingPlacement): boolean;
151
+ /** Ordinary delivery of the event; undefined when routing delivered nowhere.
152
+ * A replacement is delivered as a fresh message after the unread prior was
153
+ * removed: it lands where a fresh event lands (§4.1 allows either place). */
154
+ deliver(occurrence: CoalescedOccurrence<E>, materialized?: McplContentBlock[], assemblingFor?: string): Promise<CoalescingPlacement | undefined>;
155
+ /** Queue a wake for a batch that has no model-visible content yet. */
156
+ wakeForBatch(occurrence: CoalescedOccurrence<E>): Promise<void>;
157
+ /** Withdraw unstarted wakes whose sole cause is this subject. */
158
+ cancelWake(subject: string): void;
159
+ /** The batch's audience still holds the authority it was admitted under. */
160
+ authorized(occurrence: CoalescedOccurrence<E>): boolean;
161
+ /** Agents that read this occurrence's delivery target (may spawn a fork). */
162
+ audience(occurrence: CoalescedOccurrence<E>): Promise<string[]>;
163
+ render(occurrence: CoalescedOccurrence<E>, params: PushRenderParams): Promise<PushRenderResult>;
164
+ audit(record: Record<string, unknown>): void;
165
+ /** Persist (throttled by the host); the thunk builds the snapshot lazily. */
166
+ save(snapshot: () => CoalescingSnapshot): void;
167
+ /** Persist the snapshot NOW (a render is about to start: that boundary must
168
+ * be recoverable before the RPC is issued, §3.2). Throws on failure. */
169
+ saveNow?(snapshot: CoalescingSnapshot): void;
170
+ /** Persist one receipt now, before the acceptance is acknowledged. Throws
171
+ * on failure, which fails the acceptance (the producer retries). */
172
+ recordReceipt?(record: CoalescingReceiptRecord): void;
173
+ /** Make every write so far DURABLE (fsync, group-committed by the host).
174
+ * Awaited before an acceptance is acknowledged and before push/render is
175
+ * issued: a kill after either boundary must find the receipt, the
176
+ * accepted work, and the render-start on disk. Rejects on failure. */
177
+ commit?(): Promise<void>;
178
+ /** Recovery: is this occurrence's content in a context (its durable
179
+ * delivery identity — subject + eventId in message metadata — is found
180
+ * there)? A published occurrence is read from recovery on (the watermark
181
+ * restarts at the head), whatever a stale record says about its history. */
182
+ wasPublished?(subject: string, eventId: string): boolean;
183
+ now?(): number;
184
+ }
185
+
186
+ export interface CoalescerOptions {
187
+ retryWindowMs?: number;
188
+ /** Base delay before re-waking a batch whose render-start commit failed
189
+ * (doubles per attempt, capped at 60× base). Default 1 s. */
190
+ recoveryBackoffMs?: number;
191
+ maxSubjects?: number;
192
+ maxNotices?: number;
193
+ maxContentBytes?: number;
194
+ maxDataBytes?: number;
195
+ }
196
+
197
+ export function coalescingSubjectKey(serverId: string, binding: string, scope: CoalescingScope, key: string): string {
198
+ return JSON.stringify([serverId, binding, scope.kind, scope.id, key]);
199
+ }
200
+ export function coalescingReceiptKey(serverId: string, binding: string, eventId: string): string {
201
+ return JSON.stringify([serverId, binding, eventId]);
202
+ }
203
+ /** Merge identity fields, never letting an absent field erase a known one (§3.1). */
204
+ function mergeIdentity(prior: CoalescedOccurrence['identity'], next: CoalescedOccurrence['identity']): CoalescedOccurrence['identity'] {
205
+ const out = { ...prior };
206
+ for (const [k, v] of Object.entries(next ?? {})) if (v !== undefined) (out as Record<string, unknown>)[k] = v;
207
+ return out;
208
+ }
209
+
210
+ /** Validate wire content before it is stored, rendered or converted. */
211
+ export function validateCoalescedContent(content: unknown, maxBytes = 1024 * 1024): asserts content is McplContentBlock[] {
212
+ if (!Array.isArray(content)) throw new CoalesceError('payload.content', 'content must be an array');
213
+ for (const b of content as Array<Record<string, unknown>>) {
214
+ if (!b || typeof b !== 'object' || Array.isArray(b)) throw new CoalesceError('payload.content', 'invalid content block');
215
+ if (b.type === 'text' && typeof b.text === 'string') continue;
216
+ if (b.type === 'resource' && typeof b.uri === 'string') continue;
217
+ if ((b.type === 'image' || b.type === 'audio')
218
+ && (typeof b.uri === 'string' || (typeof b.data === 'string' && typeof b.mimeType === 'string'))) continue;
219
+ throw new CoalesceError('payload.content', 'invalid content block');
220
+ }
221
+ if (Buffer.byteLength(JSON.stringify(content)) > maxBytes) throw new CoalesceError('payload.content', 'content exceeds host byte limit');
222
+ }
223
+
224
+ /** Validate the `coalesce` member of either lane (§13). */
225
+ export function validateCoalesceMember(
226
+ c: unknown,
227
+ lane: 'push' | 'channel',
228
+ maxDataBytes = 4096,
229
+ ): asserts c is NonNullable<import('./types.js').PushEventParams['coalesce']> {
230
+ if (!c || typeof c !== 'object' || Array.isArray(c)) throw new CoalesceError('coalesce', 'coalesce must be an object');
231
+ const m = c as Record<string, unknown>;
232
+ if (typeof m.key !== 'string' || !Buffer.byteLength(m.key) || Buffer.byteLength(m.key) > 256) {
233
+ throw new CoalesceError('coalesce.key', 'key must be 1..256 UTF-8 bytes');
234
+ }
235
+ for (const field of ['deferred', 'retract', 'initial'] as const) {
236
+ if (m[field] !== undefined && typeof m[field] !== 'boolean') throw new CoalesceError(`coalesce.${field}`, 'expected boolean');
237
+ }
238
+ if (m.channelId !== undefined && (typeof m.channelId !== 'string' || !m.channelId)) {
239
+ throw new CoalesceError('coalesce.channelId', 'channelId must be a non-empty string');
240
+ }
241
+ if (lane === 'channel' && ('channelId' in m || 'deferred' in m)) {
242
+ throw new CoalesceError('coalesce', 'channel messages cannot select scope or deferred mode');
243
+ }
244
+ if (m.deferred && m.retract) throw new CoalesceError('coalesce', 'deferred and retract are exclusive');
245
+ if (m.initial && m.retract) throw new CoalesceError('coalesce', 'initial and retract are exclusive');
246
+ if (m.data !== undefined && !m.deferred) throw new CoalesceError('coalesce.data', 'data requires deferred');
247
+ if (m.data !== undefined && Buffer.byteLength(JSON.stringify(m.data)) > maxDataBytes) {
248
+ throw new CoalesceError('coalesce.data', `data exceeds ${maxDataBytes} bytes`);
249
+ }
250
+ }
251
+
252
+ export class PushCoalescer<E = unknown> {
253
+ private readonly subjects = new Map<string, SubjectState<E>>();
254
+ /** `committed`: the durability barrier succeeded after this receipt was
255
+ * written. An uncommitted receipt deduplicates effects but is not an
256
+ * acknowledgement until a commit succeeds. */
257
+ private readonly receipts = new Map<string, { result: PushEventResult; at: number; committed: boolean }>();
258
+ private readonly retryWindowMs: number;
259
+ private readonly maxSubjects: number;
260
+ private readonly maxNotices: number;
261
+ /** Outstanding push/render RPCs per server, including cancelled ones (§10.7). */
262
+ private readonly rendersInFlight = new Map<string, number>();
263
+ /**
264
+ * The ONE critical section. Every state transition — an admission, the
265
+ * freezing of a batch for rendering, the settlement of a render's result —
266
+ * runs under it, awaits included (fork spawn, durability commit, delivery).
267
+ * Only the push/render RPC itself runs outside, so it stays cancellable.
268
+ * An admission therefore never observes a half-done assembly transition,
269
+ * and an assembly never acts on a batch an admission moved on from.
270
+ */
271
+ private serial: Promise<unknown> = Promise.resolve();
272
+ /** Liveness after a failed freeze: a bounded, backed-off re-wake per
273
+ * subject. `attempt` outlives the armed timer: it counts consecutive
274
+ * failures and resets only on success or disposal of the pending work. */
275
+ private readonly recovery = new Map<string, { timer?: ReturnType<typeof setTimeout>; attempt: number }>();
276
+ private suspended = false;
277
+
278
+ constructor(private readonly host: CoalescerHost<E>, private readonly options: CoalescerOptions = {}) {
279
+ this.retryWindowMs = options.retryWindowMs ?? PUSH_COALESCING_SUPPORT.retryWindowMs;
280
+ this.maxSubjects = options.maxSubjects ?? 4_096;
281
+ this.maxNotices = options.maxNotices ?? 64;
282
+ }
283
+
284
+ private now(): number { return this.host.now?.() ?? Date.now(); }
285
+
286
+ private locked<T>(fn: () => Promise<T>): Promise<T> {
287
+ const run = this.serial.then(fn, fn);
288
+ this.serial = run.then(() => undefined, () => undefined);
289
+ return run;
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Observation
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /** The unread occupant's or batch's placement/identity, for routing continuity. */
297
+ identity(subject: string): CoalescedOccurrence['identity'] | undefined {
298
+ return this.subjects.get(subject)?.identity;
299
+ }
300
+
301
+ pendingBatches(): number {
302
+ let n = 0;
303
+ for (const s of this.subjects.values()) if (s.batch) n++;
304
+ return n;
305
+ }
306
+
307
+ /** §10.7: a server handling push/render must not issue inference/request —
308
+ * including a render whose batch was cancelled while the RPC is outstanding. */
309
+ isRendering(serverId: string): boolean {
310
+ return (this.rendersInFlight.get(serverId) ?? 0) > 0;
311
+ }
312
+
313
+ receipt(serverId: string, binding: string, eventId: string): PushEventResult | undefined {
314
+ const entry = this.receiptEntry(coalescingReceiptKey(serverId, binding, eventId));
315
+ return entry && structuredClone(entry.result);
316
+ }
317
+
318
+ private receiptEntry(key: string): { result: PushEventResult; at: number; committed: boolean } | undefined {
319
+ const entry = this.receipts.get(key);
320
+ if (!entry) return undefined;
321
+ if (this.now() - entry.at > this.retryWindowMs) { this.receipts.delete(key); return undefined; }
322
+ return entry;
323
+ }
324
+
325
+ /** Acknowledge a receipt: durable first. A receipt whose commit failed is
326
+ * committed now (no effects are repeated); if that fails again, so does
327
+ * the acknowledgement, and the producer retries once more. */
328
+ private async acknowledge(key: string, entry: { result: PushEventResult; at: number; committed: boolean }, subject: string, eventId: string): Promise<PushEventResult> {
329
+ if (!entry.committed) {
330
+ try {
331
+ await this.host.commit?.();
332
+ entry.committed = true;
333
+ } catch (error) {
334
+ this.host.audit({ kind: 'persist-failed', subject, eventId, error: String(error) });
335
+ throw new CoalesceError('eventId', 'host could not make the acceptance durable; retry', -32000);
336
+ }
337
+ }
338
+ return structuredClone(entry.result);
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Admission
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /**
346
+ * Admit an occurrence that has already passed the lane's ordinary checks and
347
+ * the coalesce-member validation. Returns the wire result, performing the
348
+ * host effects (replace / remove / deliver) synchronously so a create, edit
349
+ * and delete cannot overtake one another.
350
+ */
351
+ accept(occurrence: CoalescedOccurrence<E>): Promise<PushEventResult> {
352
+ return this.locked(() => this.admit(occurrence));
353
+ }
354
+
355
+ private async admit(occurrence: CoalescedOccurrence<E>): Promise<PushEventResult> {
356
+ if (this.suspended) throw new CoalesceError('serverId', 'host is stopping', -32000);
357
+ const subject = coalescingSubjectKey(occurrence.serverId, occurrence.binding, occurrence.scope, occurrence.key);
358
+ const duplicate = this.receiptEntry(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId));
359
+ if (duplicate) {
360
+ // A producer retrying acknowledged work is also a chance to notice a
361
+ // batch left asleep by a failed freeze: wake it now, without effects.
362
+ const state = this.subjects.get(subject);
363
+ if (state?.batch && !state.rendering) { this.disarmRecovery(subject); await this.host.wakeForBatch(state.batch.latest); }
364
+ return this.acknowledge(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId), duplicate, subject, occurrence.eventId);
365
+ }
366
+ const born = this.subjects.has(subject) ? undefined : (occurrence.initial && !occurrence.retract ? 'none' as const : 'unknown' as const);
367
+ const state = this.subjectFor(subject, occurrence);
368
+ const occupantUnread = this.refreshOccupant(state);
369
+ const priorEventId = state.occupant?.eventId ?? state.batch?.latest.eventId ?? state.rendering?.batch.latest.eventId ?? state.consumedEventId;
370
+ this.host.audit({ kind: 'received', subject, eventId: occurrence.eventId, retract: occurrence.retract, deferred: occurrence.deferred });
371
+
372
+ let outcome: CoalesceOutcome;
373
+ if (occurrence.retract) {
374
+ outcome = await this.retract(subject, state, occurrence, occupantUnread);
375
+ } else if (occurrence.deferred) {
376
+ outcome = await this.acceptDeferred(subject, state, occurrence, occupantUnread);
377
+ } else {
378
+ outcome = await this.acceptPlain(subject, state, occurrence, occupantUnread);
379
+ }
380
+ if (occurrence.scope.kind === 'channel' && occurrence.identity && !occurrence.retract) {
381
+ state.identity = mergeIdentity(state.identity, occurrence.identity);
382
+ }
383
+ state.touchedAt = this.now();
384
+ const result: PushEventResult = { accepted: true, coalesce: { outcome, ...(priorEventId ? { priorEventId } : {}) } };
385
+ const receiptKey = coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId);
386
+ const entry = { result: structuredClone(result), at: this.now(), committed: false };
387
+ try {
388
+ this.host.recordReceipt?.({
389
+ key: receiptKey, result: entry.result, at: entry.at, subject, ...(born ? { born } : {}),
390
+ batch: state.batch ? structuredClone({ latest: state.batch.latest as CoalescedOccurrence<unknown>, notices: state.batch.notices, dropped: state.batch.dropped }) : null,
391
+ });
392
+ } catch (error) {
393
+ // Not acknowledged: the work would be lost while its receipt suppressed
394
+ // a retry. The producer retries within the window.
395
+ this.host.audit({ kind: 'persist-failed', subject, eventId: occurrence.eventId, error: String(error) });
396
+ throw new CoalesceError('eventId', 'host could not persist the acceptance; retry', -32000);
397
+ }
398
+ this.receipts.set(receiptKey, entry);
399
+ this.prune();
400
+ this.persist();
401
+ // Written but not yet durable: the in-memory receipt deduplicates a retry
402
+ // while this process lives, and the retry commits it (acknowledge); after
403
+ // a kill nothing of it exists and the retry is a fresh acceptance.
404
+ return this.acknowledge(receiptKey, entry, subject, occurrence.eventId);
405
+ }
406
+
407
+ private subjectFor(subject: string, occurrence: CoalescedOccurrence<E>): SubjectState<E> {
408
+ let state = this.subjects.get(subject);
409
+ if (!state) {
410
+ // §3.3: an untracked subject is `unknown` unless the producer marks the
411
+ // birth of the subject. A retraction is never a birth.
412
+ state = { history: occurrence.initial && !occurrence.retract ? 'none' : 'unknown', touchedAt: this.now() };
413
+ this.subjects.set(subject, state);
414
+ }
415
+ return state;
416
+ }
417
+
418
+ /** Re-check the occupant against the host: consumed occupants become history. */
419
+ private refreshOccupant(state: SubjectState<E>): boolean {
420
+ if (!state.occupant) return false;
421
+ if (this.host.isUnread(state.occupant.placement)) return true;
422
+ state.history = 'some';
423
+ state.consumedEventId = state.occupant.eventId;
424
+ state.occupant = undefined;
425
+ return false;
426
+ }
427
+
428
+ private scheduleRecovery(subject: string, state: SubjectState<E>): void {
429
+ const prior = this.recovery.get(subject);
430
+ if (prior?.timer) clearTimeout(prior.timer);
431
+ const attempt = (prior?.attempt ?? 0) + 1;
432
+ const base = this.options.recoveryBackoffMs ?? 1_000;
433
+ const delay = Math.min(base * 2 ** (attempt - 1), base * 60);
434
+ const entry = { attempt } as { timer?: ReturnType<typeof setTimeout>; attempt: number };
435
+ entry.timer = setTimeout(() => {
436
+ // Disarm, but keep the attempt count: a freeze that fails again backs
437
+ // off further. The count resets only on success or disposal.
438
+ if (this.recovery.get(subject) === entry) entry.timer = undefined;
439
+ if (this.suspended || this.subjects.get(subject) !== state || !state.batch || state.rendering) return;
440
+ // Authority and wake policy are the host's, re-evaluated at fire time.
441
+ if (!this.host.authorized(state.batch.latest)) return;
442
+ this.host.audit({ kind: 'recovery-wake', subject, eventId: state.batch.latest.eventId, attempt, delayMs: delay });
443
+ void this.host.wakeForBatch(state.batch.latest).catch(() => { /* audited by the host */ });
444
+ }, delay);
445
+ (entry.timer as { unref?: () => void }).unref?.();
446
+ this.recovery.set(subject, entry);
447
+ }
448
+
449
+ /** Disarm the timer only (a producer retry wakes now; the count stands). */
450
+ private disarmRecovery(subject: string): void {
451
+ const prior = this.recovery.get(subject);
452
+ if (prior?.timer) { clearTimeout(prior.timer); prior.timer = undefined; }
453
+ }
454
+
455
+ /** Success or disposal of the pending work: forget the failure series. */
456
+ private clearRecovery(subject: string): void {
457
+ const prior = this.recovery.get(subject);
458
+ if (prior?.timer) clearTimeout(prior.timer);
459
+ this.recovery.delete(subject);
460
+ }
461
+
462
+ private dropBatches(subject: string, state: SubjectState<E>): boolean {
463
+ this.clearRecovery(subject);
464
+ let dropped = false;
465
+ if (state.batch) { this.host.audit({ kind: 'displaced', subject, eventId: state.batch.latest.eventId, batch: true }); state.batch = undefined; dropped = true; }
466
+ if (state.rendering) {
467
+ this.host.audit({ kind: 'render-cancelled', subject, eventId: state.rendering.batch.latest.eventId });
468
+ state.rendering.cancelled = true;
469
+ state.rendering = undefined;
470
+ dropped = true;
471
+ }
472
+ return dropped;
473
+ }
474
+
475
+ private async retract(subject: string, state: SubjectState<E>, occurrence: CoalescedOccurrence<E>, occupantUnread: boolean): Promise<CoalesceOutcome> {
476
+ if (occupantUnread && state.occupant) {
477
+ this.host.audit({ kind: 'removed', subject, eventId: state.occupant.eventId });
478
+ if (!this.host.remove(state.occupant.placement)) {
479
+ // Still in context and possibly about to be read: say so, append the notice.
480
+ this.host.audit({ kind: 'remove-failed', subject, eventId: state.occupant.eventId });
481
+ state.history = 'unknown';
482
+ }
483
+ }
484
+ state.occupant = undefined;
485
+ this.dropBatches(subject, state);
486
+ this.host.cancelWake(subject);
487
+ state.consumedEventId = undefined;
488
+ if (state.history === 'none') return 'retracted';
489
+ if (!occurrence.content.length) return 'consumed';
490
+ // The notice is an ordinary occurrence: it rides the normal delivery path
491
+ // (tags → gate policy → wake) and is consumed like any message — in the
492
+ // context that read a version, never a new one (§3.2).
493
+ const placement = await this.host.deliver({ ...occurrence, deliverTo: state.audienceAgent });
494
+ if (placement) {
495
+ state.occupant = { eventId: occurrence.eventId, placement };
496
+ state.audienceAgent = placement.agent;
497
+ }
498
+ return 'noted';
499
+ }
500
+
501
+ private async acceptPlain(subject: string, state: SubjectState<E>, occurrence: CoalescedOccurrence<E>, occupantUnread: boolean): Promise<CoalesceOutcome> {
502
+ const hadBatch = this.dropBatches(subject, state);
503
+ if (occupantUnread && state.occupant) {
504
+ const prior = state.occupant;
505
+ this.host.audit({ kind: 'displaced', subject, eventId: prior.eventId });
506
+ if (this.host.remove(prior.placement)) {
507
+ this.host.cancelWake(subject);
508
+ // The replacement follows the prior delivery's audience (§3.2).
509
+ const placement = await this.host.deliver({ ...occurrence, deliverTo: prior.placement.agent });
510
+ state.occupant = placement ? { eventId: occurrence.eventId, placement } : undefined;
511
+ if (placement) state.audienceAgent = placement.agent;
512
+ return 'replaced';
513
+ }
514
+ // Could not remove: the prior may still be read. Append, as for consumed.
515
+ this.host.audit({ kind: 'remove-failed', subject, eventId: prior.eventId });
516
+ state.history = 'unknown';
517
+ state.consumedEventId = prior.eventId;
518
+ state.occupant = undefined;
519
+ }
520
+ if (hadBatch) this.host.cancelWake(subject);
521
+ const placement = await this.host.deliver(occurrence);
522
+ state.occupant = placement ? { eventId: occurrence.eventId, placement } : undefined;
523
+ if (placement) state.audienceAgent = placement.agent;
524
+ if (hadBatch) return 'replaced';
525
+ return state.consumedEventId || state.history !== 'none' ? 'appended' : 'first';
526
+ }
527
+
528
+ private async acceptDeferred(subject: string, state: SubjectState<E>, occurrence: CoalescedOccurrence<E>, occupantUnread: boolean): Promise<CoalesceOutcome> {
529
+ let outcome: CoalesceOutcome = 'first';
530
+ if (occupantUnread && state.occupant) {
531
+ // §5.1: a notice displaces an unread plain occurrence and opens a batch
532
+ // — in the audience that held the plain occurrence (§3.2).
533
+ this.host.audit({ kind: 'displaced', subject, eventId: state.occupant.eventId });
534
+ if (this.host.remove(state.occupant.placement)) {
535
+ outcome = 'replaced';
536
+ occurrence = { ...occurrence, deliverTo: state.occupant.placement.agent };
537
+ } else { this.host.audit({ kind: 'remove-failed', subject, eventId: state.occupant.eventId }); state.history = 'unknown'; }
538
+ this.host.cancelWake(subject);
539
+ state.occupant = undefined;
540
+ } else if (state.batch?.latest.deliverTo) {
541
+ // Later notices stay in the batch's audience.
542
+ occurrence = { ...occurrence, deliverTo: state.batch.latest.deliverTo };
543
+ }
544
+ const notice: DeferredNotice = { eventId: occurrence.eventId, timestamp: occurrence.timestamp, ...(occurrence.data !== undefined ? { data: structuredClone(occurrence.data) } : {}) };
545
+ if (state.batch) {
546
+ state.batch.notices.push(notice);
547
+ if (state.batch.notices.length > this.maxNotices) { state.batch.notices.shift(); state.batch.dropped++; }
548
+ state.batch.latest = occurrence;
549
+ outcome = 'replaced';
550
+ } else {
551
+ // Rule 1: a notice during a render opens a NEW batch ("first").
552
+ state.batch = { latest: occurrence, notices: [notice], dropped: 0 };
553
+ }
554
+ await this.host.wakeForBatch(occurrence);
555
+ return outcome;
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // Assembly (deferred batches only — plain content is already in context)
560
+ // ---------------------------------------------------------------------------
561
+
562
+ /**
563
+ * Render and materialize every pending batch whose audience includes
564
+ * `agentName`. Called by the host at a turn's assembly boundary, before the
565
+ * compile. Bounded by the host's render timeout; a failed or late render
566
+ * materializes the admitted fallback (§5.3).
567
+ */
568
+ async assemble(agentName: string): Promise<void> {
569
+ if (this.suspended) return;
570
+ const work: Promise<void>[] = [];
571
+ for (const subject of [...this.subjects.keys()]) {
572
+ // The handle is wrapped: a bare promise returned through `locked` would
573
+ // be flattened by `then`, holding the lock until the render settled —
574
+ // and the settlement itself needs the lock.
575
+ const started = await this.locked(() => this.freeze(subject, agentName));
576
+ if (started) work.push(started.done);
577
+ }
578
+ await Promise.all(work);
579
+ this.persist();
580
+ }
581
+
582
+ /**
583
+ * Under the lock: decide whether `subject`'s batch renders for `agentName`,
584
+ * freeze it, make the render-start durable, re-check authority, and start
585
+ * the RPC. Returns the render's completion (shared by every assembly that
586
+ * waits on this batch, vector 25) or undefined when nothing was started.
587
+ */
588
+ private async freeze(subject: string, agentName: string): Promise<{ done: Promise<void> } | undefined> {
589
+ const state = this.subjects.get(subject);
590
+ if (!state || this.suspended) return undefined;
591
+ if (state.rendering) {
592
+ // Another assembly froze this batch; share its outcome (vector 25).
593
+ const rendering = state.rendering;
594
+ return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined;
595
+ }
596
+ const batch = state.batch;
597
+ if (!batch) return undefined;
598
+ // The audience lookup may spawn a fork. Under the lock no admission can
599
+ // move the subject on meanwhile; the identity check guards the host.
600
+ const audience = await this.host.audience(batch.latest);
601
+ if (!audience.includes(agentName) || state.batch !== batch || state.rendering) return undefined;
602
+ state.batch = undefined;
603
+ this.host.cancelWake(subject);
604
+ if (!this.host.authorized(batch.latest)) {
605
+ this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId });
606
+ return undefined;
607
+ }
608
+ const rendering: Rendering<E> = { batch, cancelled: false, done: Promise.resolve() };
609
+ state.rendering = rendering;
610
+ // The render-start boundary is recoverable BEFORE the RPC: a restart
611
+ // then takes the fallback instead of asking the server a second time.
612
+ try {
613
+ if (this.host.saveNow) this.host.saveNow(this.snapshot()); else this.persist();
614
+ await this.host.commit?.();
615
+ } catch (error) {
616
+ this.host.audit({ kind: 'persist-failed', subject, eventId: batch.latest.eventId, error: String(error) });
617
+ state.rendering = undefined;
618
+ batch.noRender = true; // cannot prove a render never started → fallback
619
+ state.batch = batch;
620
+ // The wake was withdrawn above; acknowledged work must not sleep until
621
+ // unrelated traffic happens by. Re-wake with backoff, so a persistent
622
+ // storage outage does not become a tight loop of model turns.
623
+ this.scheduleRecovery(subject, state);
624
+ return undefined;
625
+ }
626
+ this.clearRecovery(subject);
627
+ if (this.suspended) { state.rendering = undefined; state.batch = batch; return undefined; }
628
+ // Authority (grant, registration, binding) is re-checked immediately
629
+ // before dispatch: a server id reassigned to another endpoint during the
630
+ // commit must not receive this batch's notices.
631
+ if (!this.host.authorized(batch.latest)) {
632
+ this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId });
633
+ state.rendering = undefined;
634
+ return undefined;
635
+ }
636
+ rendering.done = this.render(subject, state, rendering, agentName);
637
+ return { done: rendering.done };
638
+ }
639
+
640
+ private async render(subject: string, state: SubjectState<E>, rendering: Rendering<E>, assemblingFor: string): Promise<void> {
641
+ const { batch } = rendering;
642
+ const occurrence = batch.latest;
643
+ let content: McplContentBlock[] = occurrence.content;
644
+ let timestamp = occurrence.timestamp;
645
+ let source: 'render' | 'fallback' = 'fallback';
646
+ if (!batch.noRender) {
647
+ const params: PushRenderParams = {
648
+ featureSet: occurrence.featureSet ?? (occurrence.scope.kind === 'featureSet' ? occurrence.scope.id : ''),
649
+ ...(occurrence.scope.kind === 'channel' ? { channelId: occurrence.scope.id } : {}),
650
+ key: occurrence.key, eventId: occurrence.eventId,
651
+ notices: structuredClone(batch.notices), dropped: batch.dropped,
652
+ };
653
+ this.rendersInFlight.set(occurrence.serverId, (this.rendersInFlight.get(occurrence.serverId) ?? 0) + 1);
654
+ try {
655
+ const result = await this.host.render(occurrence, params);
656
+ if (rendering.cancelled) { this.host.audit({ kind: 'late-render', subject, eventId: occurrence.eventId, discarded: true }); }
657
+ else validateCoalescedContent(result?.content, this.options.maxContentBytes);
658
+ if (rendering.cancelled) throw new CancelledRender();
659
+ content = result.content;
660
+ timestamp = typeof result.timestamp === 'string' ? result.timestamp : new Date().toISOString();
661
+ source = 'render';
662
+ } catch (error) {
663
+ if (!(error instanceof CancelledRender)) this.host.audit({ kind: 'render-failed', subject, eventId: occurrence.eventId, error: String(error) });
664
+ } finally {
665
+ const left = (this.rendersInFlight.get(occurrence.serverId) ?? 1) - 1;
666
+ if (left > 0) this.rendersInFlight.set(occurrence.serverId, left); else this.rendersInFlight.delete(occurrence.serverId);
667
+ }
668
+ }
669
+ // Settlement runs under the lock: between the RPC's return and the state
670
+ // update no admission can withdraw or replace what is being published.
671
+ await this.locked(async () => {
672
+ try {
673
+ if (rendering.cancelled || state.rendering !== rendering || this.suspended) return;
674
+ // Rule 4: authority is re-checked at response.
675
+ if (!this.host.authorized(occurrence)) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; }
676
+ this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty: content.length === 0 });
677
+ if (!content.length) return; // §5.2: nothing happened
678
+ const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor);
679
+ if (placement?.deferredId) {
680
+ // Landed in another agent's deferred queue (its turn is alive): still
681
+ // unread there, so it stays replaceable and withdrawable.
682
+ state.occupant = { eventId: occurrence.eventId, placement };
683
+ state.audienceAgent = placement.agent;
684
+ return;
685
+ }
686
+ // The materialized occurrence is consumed by the request being assembled.
687
+ state.history = 'some';
688
+ state.consumedEventId = occurrence.eventId;
689
+ state.occupant = undefined;
690
+ if (placement) state.audienceAgent = placement.agent;
691
+ } finally {
692
+ if (state.rendering === rendering) state.rendering = undefined;
693
+ }
694
+ });
695
+ }
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // Lifecycle
699
+ // ---------------------------------------------------------------------------
700
+
701
+ restore(snapshot: CoalescingSnapshot | null | undefined): void {
702
+ if (!snapshot || snapshot.version !== 1) return;
703
+ const now = this.now();
704
+ for (const [key, entry] of snapshot.receipts) {
705
+ if (now - entry.at <= this.retryWindowMs) this.receipts.set(key, { ...entry, committed: true });
706
+ }
707
+ for (const s of snapshot.subjects) {
708
+ const state: SubjectState<E> = {
709
+ // A stored occupant is in context and will be in the next request:
710
+ // it counts as read from here on (the watermark restarts at the head).
711
+ history: s.occupant ? 'some' : s.history,
712
+ consumedEventId: s.occupant?.eventId ?? s.consumedEventId,
713
+ identity: s.identity,
714
+ audienceAgent: s.audienceAgent ?? s.occupant?.placement.agent,
715
+ touchedAt: now,
716
+ };
717
+ if (s.batch) {
718
+ // An interrupted render is not replayed (§3.2): a batch that was
719
+ // RENDERING keeps its fallback; a batch that was only pending renders
720
+ // normally at the next assembly.
721
+ state.batch = { latest: s.batch.latest as CoalescedOccurrence<E>, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.rendering ? { noRender: true } : {}) };
722
+ }
723
+ this.subjects.set(s.subject, state);
724
+ }
725
+ this.settlePublished();
726
+ }
727
+
728
+ /**
729
+ * A batch restored as RENDERING may already have been published (its
730
+ * result or fallback is a durable context message; only the completion
731
+ * snapshot was lost). Recovery then marks it consumed instead of
732
+ * delivering the fallback a second time.
733
+ */
734
+ private settlePublished(): void {
735
+ if (!this.host.wasPublished) return;
736
+ for (const [subject, state] of this.subjects) {
737
+ const batch = state.batch;
738
+ if (!batch?.noRender) continue;
739
+ if (this.host.wasPublished(subject, batch.latest.eventId)) {
740
+ state.batch = undefined;
741
+ state.history = 'some';
742
+ state.consumedEventId = batch.latest.eventId;
743
+ }
744
+ }
745
+ }
746
+
747
+ /** Apply receipts written since the last snapshot (crash-window bridge). */
748
+ restoreReceipts(records: CoalescingReceiptRecord[]): void {
749
+ const now = this.now();
750
+ for (const r of records) {
751
+ if (now - r.at > this.retryWindowMs) continue;
752
+ this.receipts.set(r.key, { result: r.result, at: r.at, committed: true });
753
+ if (r.born && !this.subjects.has(r.subject)) this.subjects.set(r.subject, { history: r.born, touchedAt: now });
754
+ // A birth fact is not proof the subject is still unread: the plain
755
+ // occurrence this record acknowledged is in context (durable before the
756
+ // record) and counts as read from recovery on, like a snapshot occupant.
757
+ let eventId: string | undefined;
758
+ try { eventId = JSON.parse(r.key)[2]; } catch { /* malformed key: no settlement */ }
759
+ if (eventId && this.host.wasPublished?.(r.subject, eventId)) {
760
+ const state = this.subjects.get(r.subject) ?? { history: 'unknown' as const, touchedAt: now };
761
+ state.history = 'some';
762
+ state.consumedEventId = eventId;
763
+ this.subjects.set(r.subject, state);
764
+ }
765
+ if (r.batch === undefined) continue; // pre-round-3 record: no batch information
766
+ // The subject's batch AFTER that acceptance, newer than the snapshot:
767
+ // the accepted deferred work itself, or null for a withdrawal /
768
+ // replacement that cleared it. It never began rendering before the
769
+ // record was written, but the snapshot cannot say whether it did
770
+ // afterwards: take the fallback.
771
+ const state = this.subjects.get(r.subject) ?? { history: 'unknown' as const, touchedAt: now };
772
+ state.batch = r.batch
773
+ ? { latest: r.batch.latest as CoalescedOccurrence<E>, notices: r.batch.notices, dropped: r.batch.dropped, noRender: true }
774
+ : undefined;
775
+ this.subjects.set(r.subject, state);
776
+ }
777
+ this.settlePublished();
778
+ }
779
+
780
+ snapshot(): CoalescingSnapshot {
781
+ return {
782
+ version: 1,
783
+ receipts: [...this.receipts],
784
+ subjects: [...this.subjects].map(([subject, s]) => ({
785
+ subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity,
786
+ ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}),
787
+ ...(s.occupant ? { occupant: s.occupant } : {}),
788
+ ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence<unknown>, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : {}) } }
789
+ : s.rendering ? { batch: { latest: s.rendering.batch.latest as CoalescedOccurrence<unknown>, notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } }
790
+ : {}),
791
+ })),
792
+ };
793
+ }
794
+
795
+ /** Batches whose wake should be re-queued after a restart (their fallback is pending). */
796
+ pendingBatchOccurrences(): CoalescedOccurrence<E>[] {
797
+ return [...this.subjects.values()].flatMap(s => s.batch ? [s.batch.latest] : []);
798
+ }
799
+
800
+ suspend(): void {
801
+ this.suspended = true;
802
+ for (const subject of [...this.recovery.keys()]) this.clearRecovery(subject);
803
+ for (const [subject, state] of this.subjects) {
804
+ if (state.rendering) { state.rendering.cancelled = true; this.host.audit({ kind: 'render-cancelled', subject, eventId: state.rendering.batch.latest.eventId, reason: 'suspend' }); }
805
+ }
806
+ }
807
+
808
+ private persist(): void { this.host.save(() => this.snapshot()); }
809
+
810
+ private prune(): void {
811
+ const now = this.now();
812
+ if (this.receipts.size > 4 * this.maxSubjects) {
813
+ for (const [key, entry] of this.receipts) if (now - entry.at > this.retryWindowMs) this.receipts.delete(key);
814
+ }
815
+ if (this.subjects.size <= this.maxSubjects) return;
816
+ // Evict the oldest idle subjects; their history degrades to `unknown`
817
+ // (an untracked subject) which is the conservative outcome (§3.3).
818
+ const idle = [...this.subjects].filter(([, s]) => !s.occupant && !s.batch && !s.rendering).sort((a, b) => a[1].touchedAt - b[1].touchedAt);
819
+ for (const [key] of idle.slice(0, Math.max(0, this.subjects.size - this.maxSubjects))) this.subjects.delete(key);
820
+ }
821
+ }