@moltzap/client 2026.802.0 → 2026.804.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 (99) hide show
  1. package/dist/bounded-map.d.ts +3 -3
  2. package/dist/bounded-map.js +3 -3
  3. package/dist/channel-base/index.d.ts +1 -7
  4. package/dist/channel-base/index.d.ts.map +1 -1
  5. package/dist/channel-base/index.js +0 -6
  6. package/dist/channel-base/index.js.map +1 -1
  7. package/dist/channel-core-test-support.d.ts +5 -27
  8. package/dist/channel-core-test-support.d.ts.map +1 -1
  9. package/dist/channel-core-test-support.js +2 -90
  10. package/dist/channel-core-test-support.js.map +1 -1
  11. package/dist/channel-core.d.ts +156 -248
  12. package/dist/channel-core.d.ts.map +1 -1
  13. package/dist/channel-core.js +195 -525
  14. package/dist/channel-core.js.map +1 -1
  15. package/dist/cli/commands/start.d.ts +1 -5
  16. package/dist/cli/commands/start.d.ts.map +1 -1
  17. package/dist/cli/commands/start.js +0 -3
  18. package/dist/cli/commands/start.js.map +1 -1
  19. package/dist/harness/client-runtime.d.ts +24 -0
  20. package/dist/harness/client-runtime.d.ts.map +1 -0
  21. package/dist/harness/client-runtime.js +107 -0
  22. package/dist/harness/client-runtime.js.map +1 -0
  23. package/dist/harness/index.d.ts +5 -0
  24. package/dist/harness/index.d.ts.map +1 -0
  25. package/dist/harness/index.js +5 -0
  26. package/dist/harness/index.js.map +1 -0
  27. package/dist/harness/runtime.d.ts +149 -0
  28. package/dist/harness/runtime.d.ts.map +1 -0
  29. package/dist/harness/runtime.js +68 -0
  30. package/dist/harness/runtime.js.map +1 -0
  31. package/dist/harness-client.d.ts +43 -0
  32. package/dist/harness-client.d.ts.map +1 -0
  33. package/dist/harness-client.js +21 -0
  34. package/dist/harness-client.js.map +1 -0
  35. package/dist/harness-mcp-server.d.ts.map +1 -1
  36. package/dist/harness-mcp-server.js +107 -17
  37. package/dist/harness-mcp-server.js.map +1 -1
  38. package/dist/harness-mcp-subscription.d.ts +21 -0
  39. package/dist/harness-mcp-subscription.d.ts.map +1 -0
  40. package/dist/harness-mcp-subscription.js +324 -0
  41. package/dist/harness-mcp-subscription.js.map +1 -0
  42. package/dist/harness-mcp-wire.d.ts +9 -2
  43. package/dist/harness-mcp-wire.d.ts.map +1 -1
  44. package/dist/harness-mcp-wire.js +41 -10
  45. package/dist/harness-mcp-wire.js.map +1 -1
  46. package/dist/index.d.ts +3 -5
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -3
  49. package/dist/index.js.map +1 -1
  50. package/dist/local-daemon-rpc.d.ts +12 -195
  51. package/dist/local-daemon-rpc.d.ts.map +1 -1
  52. package/dist/local-daemon-rpc.js +1 -5
  53. package/dist/local-daemon-rpc.js.map +1 -1
  54. package/dist/moltzapd.d.ts +2 -1
  55. package/dist/moltzapd.d.ts.map +1 -1
  56. package/dist/moltzapd.js +19 -5
  57. package/dist/moltzapd.js.map +1 -1
  58. package/dist/notification/__tests__/subscribe-signatures.types-check.d.ts +14 -14
  59. package/dist/notification/__tests__/subscribe-signatures.types-check.d.ts.map +1 -1
  60. package/dist/notification/__tests__/subscribe-signatures.types-check.js +5 -5
  61. package/dist/notification/__tests__/subscribe-signatures.types-check.js.map +1 -1
  62. package/dist/service-local-daemon.d.ts +1 -1
  63. package/dist/service-local-daemon.d.ts.map +1 -1
  64. package/dist/service-local-daemon.js +2 -4
  65. package/dist/service-local-daemon.js.map +1 -1
  66. package/dist/service.d.ts +4 -25
  67. package/dist/service.d.ts.map +1 -1
  68. package/dist/service.js +7 -44
  69. package/dist/service.js.map +1 -1
  70. package/dist/test-utils/channel-service-fixture.d.ts +1 -3
  71. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  72. package/dist/test-utils/channel-service-fixture.js +2 -22
  73. package/dist/test-utils/channel-service-fixture.js.map +1 -1
  74. package/dist/test-utils/ids.d.ts +0 -13
  75. package/dist/test-utils/ids.d.ts.map +1 -1
  76. package/dist/test-utils/ids.js +1 -16
  77. package/dist/test-utils/ids.js.map +1 -1
  78. package/dist/test-utils/index.d.ts +1 -1
  79. package/dist/test-utils/index.d.ts.map +1 -1
  80. package/dist/test-utils/index.js +1 -1
  81. package/dist/test-utils/index.js.map +1 -1
  82. package/dist/tsconfig.tsbuildinfo +1 -1
  83. package/package.json +8 -4
  84. package/dist/app-client.d.ts +0 -5
  85. package/dist/app-client.d.ts.map +0 -1
  86. package/dist/app-client.js +0 -3
  87. package/dist/app-client.js.map +0 -1
  88. package/dist/channel-base/lease-guard.d.ts +0 -31
  89. package/dist/channel-base/lease-guard.d.ts.map +0 -1
  90. package/dist/channel-base/lease-guard.js +0 -44
  91. package/dist/channel-base/lease-guard.js.map +0 -1
  92. package/dist/channel-base/lease-store.d.ts +0 -48
  93. package/dist/channel-base/lease-store.d.ts.map +0 -1
  94. package/dist/channel-base/lease-store.js +0 -77
  95. package/dist/channel-base/lease-store.js.map +0 -1
  96. package/dist/channel-base/lease.d.ts +0 -96
  97. package/dist/channel-base/lease.d.ts.map +0 -1
  98. package/dist/channel-base/lease.js +0 -106
  99. package/dist/channel-base/lease.js.map +0 -1
@@ -1,23 +1,12 @@
1
1
  /**
2
- * Shared message-enrichment helper for MoltZap channel adapters.
2
+ * Channel core: serialized inbound turn delivery over a MoltZapService.
3
+ * Owns the inbound queue, same-conversation coalescing, the interceptor
4
+ * gate, the per-turn timeout, and channel teardown. Enriched adapter delivery
5
+ * uses channel-core-enrichment.ts; raw daemon delivery receives the coalesced
6
+ * Message batch without reading or committing presentation context.
3
7
  */
4
- import { Cause, Chunk, Data, Deferred, Duration, Effect, Fiber, Match, Option, Queue, } from "effect";
5
- import { BoundedMap } from "./bounded-map.js";
6
- import { DEFAULT_DISPATCH_LEASE_TIMEOUT_MS, } from "@moltzap/protocol/message/dispatch";
8
+ import { Cause, Chunk, Duration, Effect, Fiber, Option, Queue } from "effect";
7
9
  import { enrichChannelMessage } from "./channel-core-enrichment.js";
8
- class DispatchAdmissionTimedOut extends Data.TaggedError("DispatchAdmissionTimedOut") {
9
- get message() {
10
- return `dispatch request timed out after ${this.timeoutMs}ms`;
11
- }
12
- }
13
- class DispatchLeaseExpired extends Data.TaggedError("DispatchLeaseExpired") {
14
- get message() {
15
- return `dispatch lease expired after ${this.timeoutMs}ms`;
16
- }
17
- }
18
- const DEFAULT_DISPATCH_ADMISSION_TIMEOUT_MS = 30_000;
19
- const DISPATCH_RELEASE_RING_CAPACITY = 256;
20
- const DISPATCH_RELEASE_RING_SOFT_TTL_MS = 30_000;
21
10
  function errorSummary(err) {
22
11
  if (err instanceof Error) {
23
12
  return {
@@ -30,8 +19,8 @@ function errorSummary(err) {
30
19
  errorValue: String(err),
31
20
  };
32
21
  }
33
- function effectLogInfo(message, annotations) {
34
- return Effect.logInfo(message).pipe(Effect.annotateLogs(annotations));
22
+ function effectLogDebug(message, annotations) {
23
+ return Effect.logDebug(message).pipe(Effect.annotateLogs(annotations));
35
24
  }
36
25
  function effectLogWarning(message, annotations) {
37
26
  return Effect.logWarning(message).pipe(Effect.annotateLogs(annotations));
@@ -43,10 +32,15 @@ function runBackgroundLog(effect) {
43
32
  Effect.runFork(effect);
44
33
  }
45
34
  /**
46
- * Wraps a `MoltZapService` with message enrichment, dispatch-chain ordering,
47
- * and a send helper. One core per service — `getContextEntries()` is
48
- * side-effectful (advances per-conversation markers), so a second core
49
- * would consume entries the first expected.
35
+ * Wraps a `MoltZapService` with one-turn-at-a-time inbound delivery and a send
36
+ * helper. Adapter handlers receive enriched messages. A daemon handler may
37
+ * instead receive the admitted coalesced raw Message batch. One core per
38
+ * service — `getContextEntries()` is side-effectful (advances
39
+ * per-conversation markers), so a second core would consume entries the first
40
+ * expected.
41
+ *
42
+ * Turn-taking is entirely endpoint-local: the server delivers every message
43
+ * it accepts, and this core decides when the runtime sees them.
50
44
  *
51
45
  * Inbound path from wire bytes to user handler:.
52
46
  *
@@ -56,99 +50,86 @@ function runBackgroundLog(effect) {
56
50
  * participant ws as MoltZapAgentClient
57
51
  * participant svc as MoltZapService
58
52
  * participant core as MoltZapChannelCore
59
- * participant handler as InboundHandler
53
+ * participant raw as RawInboundHandler
54
+ * participant enriched as InboundHandler
60
55
  *
61
56
  * server->>ws: agent/message/received notification
62
57
  * ws->>svc: subscribers.dispatch — fanout(message)
63
58
  * svc->>core: message listener
64
- * Note over core: dedup via recordMessageIdIfNew; Queue.unsafeOffer(inboundQueue, work)
65
- * Note over core: messages observed without an inbound handler are transiently dropped; consumer fiber uses Queue.take and takeDispatchCandidate prefers parked[convId]
66
- * core->>server: agent/dispatch/request — dispatchAdmission
67
- * server-->>core: ack {leaseId, dispatchId}
68
- * Note over server,core: ack/release race absorbed via pendingDispatchesByLease (Deferred) and pendingReleasesByLease (ring 256, soft-TTL 30s)
69
- * server->>ws: agent/dispatch/released notification
70
- * ws->>core: recordDispatchRelease — settles Deferred or buffers
71
- * alt verdict deny
72
- * Note over core: log + drop
73
- * else verdict hold
74
- * Note over core: parkDispatchWork — front of parked[convId]
75
- * else verdict grant
76
- * Note over core: takeCoalescedConversationMessages; drains same-conv from queue + parked
77
- * Note over core: dispatchWithLease; lease scoped to ConversationId; enrichMessage — sender name, conversation, context entries
78
- * core->>handler: inboundHandler(enriched)
79
- * handler-->>core: Effect.void
80
- * Note over core: handler exceeds leaseTimeoutMs (90s) → DispatchLeaseExpired
59
+ * Note over core: dedup via recordMessageIdIfNew, drop when stopped or no handler is installed, then Queue.unsafeOffer(inboundQueue, message)
60
+ * Note over core: consumer fiber — Queue.take
61
+ * Note over core: takeCoalescedConversationMessages drains same-conv backlog into one turn
62
+ * alt raw daemon handler
63
+ * Note over core: inboundInterceptor — deliver or drop this batch
64
+ * core->>raw: rawInboundHandler(messages)
65
+ * raw-->>core: Effect.void
66
+ * else enriched adapter handler
67
+ * Note over core: enrichMessage — sender name, conversation, context entries
68
+ * Note over core: inboundInterceptor — deliver or drop this turn
69
+ * core->>enriched: inboundHandler(enriched)
70
+ * enriched-->>core: Effect.void
71
+ * Note over core: completed handler commits presentation context
81
72
  * end
73
+ * Note over core: handler exceeds turnTimeoutMs — turn abandoned, drain continues
82
74
  * ```
83
75
  *
84
- * Parking semantics: `hold` re-enters at `parked[convId]` FRONT.
85
- * `takeDispatchCandidate` prefers the parked queue for the next pull
86
- * so backpressure within one conversation does not starve others.
76
+ * The single consumer fiber awaits the handler inline, so at most one turn
77
+ * runs at a time and messages that arrive mid-turn wait in the queue.
87
78
  */
88
79
  export class MoltZapChannelCore {
89
80
  service;
90
- dispatchAdmissionTimeoutMs;
81
+ turnTimeoutMs;
82
+ inboundInterceptor;
91
83
  connected = false;
92
- inboundHandlerRegistration = null;
93
- /**
94
- * Dispatch authority is keyed by conversation so work performed through
95
- * the core for another conversation cannot inherit the active handler's
96
- * lease. The single consumer prevents overlapping writes for the same
97
- * conversation while set/restore cleanup is active.
98
- */
99
- leaseIdsInFlightByConversation = new Map();
100
- /**
101
- * Per-lease parking Deferreds for dispatches awaiting their
102
- * `dispatchRelease` verdict. Settled by the `dispatchRelease` event
103
- * handler when a matching frame arrives.
104
- */
105
- pendingDispatchesByLease = new Map();
106
- /**
107
- * Ring buffer of `dispatchRelease` frames that arrived before the
108
- * recipient registered its parking Deferred (release-then-ack
109
- * race). `BoundedMap` refreshes insertion order when a lease is set
110
- * again and evicts the oldest entry at capacity. Soft-TTL eviction at
111
- * `DISPATCH_RELEASE_RING_SOFT_TTL_MS` keeps a release without a matching
112
- * ack from retaining memory.
113
- */
114
- pendingReleasesByLease = new BoundedMap(DISPATCH_RELEASE_RING_CAPACITY);
115
- parkedByConversation = new Map();
84
+ // Terminal stop: disconnect() interrupts the sole consumer fiber and it is
85
+ // never restarted, while the service deliberately keeps message listeners
86
+ // registered across close()/connect() cycles. Without this flag a
87
+ // reconnected service would keep offering inbound messages into a queue
88
+ // nothing drains — unbounded growth and silent non-delivery. Stopped means
89
+ // stopped: inbound observed after disconnect() is dropped at the listener.
90
+ stopped = false;
91
+ inboundHandler = null;
116
92
  /**
117
93
  * Inbound messages with an installed handler enqueue synchronously; a single
118
- * forked consumer fiber serialises delivery in arrival order.
94
+ * forked consumer fiber serialises delivery so handlers execute
95
+ * one-at-a-time in arrival order.
119
96
  */
120
97
  inboundQueue = Effect.runSync(Queue.unbounded());
121
98
  consumerFiber;
122
99
  disconnectHandlers = [];
123
100
  constructor(opts) {
124
101
  this.service = opts.service;
125
- this.dispatchAdmissionTimeoutMs =
126
- opts.dispatchAdmissionTimeoutMs ?? DEFAULT_DISPATCH_ADMISSION_TIMEOUT_MS;
102
+ if (opts.turnTimeoutMs !== undefined) {
103
+ this.turnTimeoutMs = opts.turnTimeoutMs;
104
+ }
105
+ if (opts.inboundInterceptor !== undefined) {
106
+ this.inboundInterceptor = opts.inboundInterceptor;
107
+ }
127
108
  this.registerMessageListener();
128
109
  this.consumerFiber = this.startConsumerFiber();
129
110
  this.registerConnectionListeners();
130
- this.registerDispatchReleaseListener();
131
111
  }
132
112
  registerMessageListener() {
133
113
  this.service.on("message", ({ message }) => {
134
- if (this.inboundHandlerRegistration === null) {
114
+ // A core with no handler — a daemon that owns the connection without
115
+ // running a turn loop — observes messages it will never deliver.
116
+ // Dropping here keeps the queue from holding work nothing consumes, and
117
+ // makes the pre-registration window a definite drop rather than a race
118
+ // between the consumer fiber and the embedder's onInbound call.
119
+ if (this.stopped || this.inboundHandler === null) {
135
120
  return;
136
121
  }
137
- Queue.unsafeOffer(this.inboundQueue, {
138
- message,
139
- attempt: 0,
140
- receivedAtMs: Date.now(),
141
- });
122
+ Queue.unsafeOffer(this.inboundQueue, message);
142
123
  });
143
124
  }
144
125
  startConsumerFiber() {
145
- const consumer = Effect.forever(Queue.take(this.inboundQueue).pipe(Effect.flatMap((work) => this.dispatchInboundWork(work).pipe(Effect.catchAllCause((cause) => this.logInboundFailure(work, cause))))));
126
+ const consumer = Effect.forever(Queue.take(this.inboundQueue).pipe(Effect.flatMap((message) => this.runInboundTurn(message).pipe(Effect.catchAllCause((cause) => this.logInboundFailure(message, cause))))));
146
127
  return Effect.runFork(consumer);
147
128
  }
148
- logInboundFailure(work, cause) {
129
+ logInboundFailure(message, cause) {
149
130
  return effectLogError("MoltZapChannelCore: inbound handler failed", {
150
- messageId: work.message.id,
151
- conversationId: work.message.conversationId,
131
+ messageId: message.id,
132
+ conversationId: message.conversationId,
152
133
  causePretty: Cause.pretty(cause),
153
134
  ...errorSummary(Cause.squash(cause)),
154
135
  });
@@ -159,76 +140,21 @@ export class MoltZapChannelCore {
159
140
  this.fanout(this.disconnectHandlers, "disconnect");
160
141
  });
161
142
  }
162
- registerDispatchReleaseListener() {
163
- this.service.on("dispatchRelease", (frame) => {
164
- this.recordDispatchRelease(frame);
165
- });
166
- }
167
- /**
168
- * Record an incoming `dispatchRelease` frame. If a matching
169
- * parking Deferred is registered, settle it inline; otherwise
170
- * insert into the ring buffer for the future ack-side `consume`.
171
- * Soft-TTL evicts buffered entries whose age exceeds
172
- * `DISPATCH_RELEASE_RING_SOFT_TTL_MS`; insertion at the hard cap
173
- * evicts the oldest entry. Both eviction paths warn-log so operators
174
- * can spot release-without-ack adversarial patterns.
175
- * @param frame Value supplied to the operation.
176
- */
177
- recordDispatchRelease(frame) {
178
- const parked = this.pendingDispatchesByLease.get(frame.leaseId);
179
- if (parked) {
180
- this.pendingDispatchesByLease.delete(frame.leaseId);
181
- // `Deferred.unsafeDone` is the sync settler — `succeed` returns an
182
- // Effect that the caller would have to runFork. The Deferred has
183
- // `never` in the failure channel, so unsafeDone with Exit.succeed
184
- // is total.
185
- Effect.runSync(Deferred.succeed(parked, frame));
186
- return;
187
- }
188
- const nowMs = Date.now();
189
- this.evictDispatchReleaseRing(nowMs);
190
- const evicted = this.pendingReleasesByLease.set(frame.leaseId, {
191
- verdict: frame.verdict,
192
- leaseTimeoutMs: frame.leaseTimeoutMs,
193
- receivedAtMs: nowMs,
194
- });
195
- if (evicted !== undefined) {
196
- const [evictedLeaseId] = evicted;
197
- runBackgroundLog(effectLogWarning("MoltZapChannelCore: dispatchRelease ring buffer evicted oldest entry (capacity reached)", { leaseId: evictedLeaseId }));
198
- }
199
- }
200
- evictDispatchReleaseRing(nowMs) {
201
- for (const [leaseId, entry] of this.pendingReleasesByLease) {
202
- if (nowMs - entry.receivedAtMs <= DISPATCH_RELEASE_RING_SOFT_TTL_MS) {
203
- // BoundedMap iterates oldest-set first, so every subsequent entry is
204
- // fresher once this one remains inside the TTL.
205
- return;
206
- }
207
- this.pendingReleasesByLease.delete(leaseId);
208
- runBackgroundLog(effectLogWarning("MoltZapChannelCore: dispatchRelease ring buffer evicted stale entry (soft TTL)", { leaseId, ageMs: nowMs - entry.receivedAtMs }));
209
- }
210
- }
211
- consumeDispatchRelease(leaseId) {
212
- const entry = this.pendingReleasesByLease.get(leaseId);
213
- if (entry === undefined) {
214
- return undefined;
215
- }
216
- this.pendingReleasesByLease.delete(leaseId);
217
- return entry;
218
- }
219
143
  /**
220
144
  * Replaces any previous handler.
221
145
  * @param handler Handler invoked for matching requests.
222
- * @returns An idempotent disposer for this registration generation.
223
146
  */
224
147
  onInbound(handler) {
225
- const registration = { handler };
226
- this.inboundHandlerRegistration = registration;
227
- return () => {
228
- if (this.inboundHandlerRegistration === registration) {
229
- this.inboundHandlerRegistration = null;
230
- }
231
- };
148
+ this.inboundHandler = { _tag: "enriched", handler };
149
+ }
150
+ /**
151
+ * Replaces any previous handler with a raw coalesced-turn handler. The
152
+ * admitted batch passes the interceptor and reaches the handler without
153
+ * enrichment or presentation-context commits.
154
+ * @param handler Handler invoked for each admitted raw batch.
155
+ */
156
+ onRawInbound(handler) {
157
+ this.inboundHandler = { _tag: "raw", handler };
232
158
  }
233
159
  onDisconnect(handler) {
234
160
  this.disconnectHandlers.push(handler);
@@ -247,13 +173,32 @@ export class MoltZapChannelCore {
247
173
  }
248
174
  }
249
175
  connect() {
176
+ // A stopped core has no consumer fiber and its listener drops every
177
+ // inbound message; reporting a successful connect here would hand the
178
+ // embedder a healthy-looking channel that is permanently deaf. Stopped
179
+ // is terminal — reconnecting means constructing a new core.
180
+ if (this.stopped) {
181
+ return Effect.dieMessage("MoltZapChannelCore.connect called after disconnect; a stopped core cannot be reconnected — construct a new core");
182
+ }
250
183
  return this.service.connect().pipe(Effect.tap(() => Effect.sync(() => {
251
184
  this.connected = true;
252
185
  })), Effect.asVoid);
253
186
  }
187
+ /**
188
+ * Tear the channel down, resolving only once the service's own transports
189
+ * are closed. A scoped owner releases this before its process exits, so
190
+ * fire-and-forget teardown would leave sockets open past the scope.
191
+ *
192
+ * Service shutdown runs concurrently with interrupting the consumer fiber
193
+ * because an in-flight turn's finalizer can take arbitrarily long. Awaiting
194
+ * the interrupt first would hold the transports open for exactly as long as
195
+ * that finalizer runs.
196
+ * @returns Completion of the channel-owned teardown.
197
+ */
254
198
  disconnect() {
255
199
  return Effect.gen(function* () {
256
200
  this.connected = false;
201
+ this.stopped = true;
257
202
  // Interrupt the consumer fiber so any queued inbound messages are
258
203
  // dropped rather than delivered after the channel is torn down.
259
204
  const stopConsumer = Fiber.interrupt(this.consumerFiber);
@@ -273,390 +218,135 @@ export class MoltZapChannelCore {
273
218
  return this.connected;
274
219
  }
275
220
  /**
276
- * Reply into a conversation with an explicit dispatch lease when supplied,
277
- * otherwise with only the active lease for `conversationId`.
221
+ * Reply into a conversation.
278
222
  * @param conversationId Value supplied to the operation.
279
223
  * @param text Text to process.
280
- * @param opts Value supplied to the operation.
281
- * @param opts.dispatchLeaseId Value supplied to the operation.
282
224
  * @returns The send result.
283
225
  */
284
- sendReply(conversationId, text, opts) {
285
- return this.service.send(conversationId, text, {
286
- dispatchLeaseId: opts?.dispatchLeaseId ??
287
- this.leaseIdsInFlightByConversation.get(conversationId),
288
- });
289
- }
290
- takeDispatchCandidate(incoming) {
291
- const conversationId = incoming.message.conversationId;
292
- const parked = this.parkedByConversation.get(conversationId);
293
- if (!parked || parked.length === 0) {
294
- return incoming;
295
- }
296
- parked.push(incoming);
297
- const next =
298
- /* Safe because the surrounding invariant establishes this asserted shape. */ parked.shift();
299
- if (parked.length === 0) {
300
- this.parkedByConversation.delete(conversationId);
301
- }
302
- else {
303
- this.parkedByConversation.set(conversationId, parked);
304
- }
305
- return next;
306
- }
307
- parkDispatchWork(work) {
308
- const conversationId = work.message.conversationId;
309
- const parked = this.parkedByConversation.get(conversationId) ?? [];
310
- parked.unshift({
311
- ...work,
312
- attempt: work.attempt + 1,
313
- });
314
- this.parkedByConversation.set(conversationId, parked);
226
+ sendReply(conversationId, text) {
227
+ return this.service.send(conversationId, text);
315
228
  }
316
- /**
317
- * Issue `agent/dispatch/request` against the service, await the lease's
318
- * `dispatchRelease` verdict, and return the channel-core
319
- * `DispatchAdmissionDecision`. Absorbs the ack/release race via
320
- * `pendingDispatchesByLease` (Deferred) plus
321
- * `pendingReleasesByLease` (refresh-on-set FIFO ring buffer):
322
- * - if release arrives first, the `recordDispatchRelease` event
323
- * handler buffers it; this method consumes the buffered entry
324
- * after the ack returns.
325
- * - if ack arrives first, this method registers a Deferred that
326
- * `recordDispatchRelease` settles when the release frame
327
- * arrives.
328
- *
329
- * Per-message lease state machine:.
330
- *
331
- * ```mermaid
332
- * stateDiagram-v2
333
- * [*] --> PENDING
334
- * PENDING : agent/dispatch/request sent; server minting lease
335
- * PENDING --> AWAITING_RELEASE : ack returns leaseId
336
- * AWAITING_RELEASE : Deferred registered; or buffered release consumed
337
- * AWAITING_RELEASE --> GRANTED : verdict grant
338
- * AWAITING_RELEASE --> DENIED : verdict deny
339
- * AWAITING_RELEASE --> HELD : verdict hold
340
- * HELD : parkDispatchWork — re-queued at parked[convId] front
341
- * GRANTED : proceed to enrichment
342
- * DENIED : drop message — consumer fiber continues
343
- * GRANTED --> IN_FLIGHT : dispatchWithLease
344
- * IN_FLIGHT : lease keyed by ConversationId; handler executing; lease authorizes one agent/message/send
345
- * IN_FLIGHT --> CONSUMED : handler returns within leaseTimeoutMs; server marks via dispatchLeaseId
346
- * IN_FLIGHT --> EXPIRED : handler exceeds leaseTimeoutMs; DispatchLeaseExpired logged
347
- * CONSUMED --> [*]
348
- * DENIED --> [*]
349
- * EXPIRED --> [*]
350
- * ```
351
- *
352
- * `HELD` stays at the parked-front. Later inbound work for that same
353
- * conversation wakes its next dispatch attempt without preventing inbound
354
- * work for other conversations from progressing.
355
- *
356
- * When the service has no `requestDispatch` (test fakes that don't
357
- * exercise admission), default-grant.
358
- *
359
- * On request error or release wait timeout, fail-closed with a
360
- * synthetic deny verdict. The lease (if minted server-side) ages
361
- * out via the post-grant TTL or LeaseRegistry's
362
- * abandon-on-disconnect path; nothing here re-issues.
363
- * @param work Value supplied to the operation.
364
- * @returns The dispatch admission result.
365
- */
366
- dispatchAdmission(work) {
367
- if (!this.service.requestDispatch) {
368
- return Effect.succeed({ _tag: "grant" });
369
- }
370
- return Effect.suspend(() =>
371
- /* Safe because the surrounding invariant establishes this asserted shape. */ this
372
- .service.requestDispatch({
373
- conversationId: work.message.conversationId,
374
- messageId: work.message.id,
375
- senderAgentId: work.message.senderId,
376
- parts: work.message.parts,
377
- attempt: work.attempt,
378
- receivedAt: new Date(work.receivedAtMs).toISOString(),
379
- pending: this.pendingDispatchSnapshot(work),
380
- })).pipe(
381
- // Total deadline for the admission round-trip (ack + release).
382
- // Hangs in the underlying RPC fall through to the fail-closed
383
- // branch below; the request itself does not race the ack/release
384
- // timeouts independently.
385
- Effect.timeoutFail({
386
- duration: Duration.millis(this.dispatchAdmissionTimeoutMs),
387
- onTimeout: () => new DispatchAdmissionTimedOut({
388
- timeoutMs: this.dispatchAdmissionTimeoutMs,
389
- }),
390
- }), Effect.flatMap((result) => "outcome" in result
391
- ? Effect.succeed(MoltZapChannelCore.holdDecision(result.outcome))
392
- : this.awaitDispatchRelease(work, result.leaseId)), Effect.catchAll((err) => Effect.gen(function* () {
393
- yield* effectLogWarning("MoltZapChannelCore: dispatch admission failed closed", {
394
- messageId: work.message.id,
395
- conversationId: work.message.conversationId,
396
- attempt: work.attempt,
397
- err,
398
- });
399
- return {
400
- _tag: "deny",
401
- reason: "dispatch admission unavailable",
402
- };
403
- })));
404
- }
405
- /**
406
- * Wait for the verdict on `leaseId`. Consumes any buffered
407
- * `dispatchRelease` first; otherwise registers a parking Deferred
408
- * and bounded-waits for the `recordDispatchRelease` event handler
409
- * to settle it. The wait timeout matches today's
410
- * `dispatchAdmissionTimeoutMs` (default 30 s).
411
- * @param work Value supplied to the operation.
412
- * @param leaseId Value supplied to the operation.
413
- * @returns The buffered result.
414
- */
415
- awaitDispatchRelease(work, leaseId) {
229
+ runInboundTurn(primary) {
416
230
  return Effect.gen(function* () {
417
- const buffered = this.consumeDispatchRelease(leaseId);
418
- if (buffered) {
419
- return this.projectVerdict(work, leaseId, buffered.verdict);
231
+ const messages = yield* this.takeCoalescedConversationMessages(primary);
232
+ const registered = this.inboundHandler;
233
+ if (registered === null) {
234
+ return;
420
235
  }
421
- const deferred = yield* Deferred.make();
422
- this.pendingDispatchesByLease.set(leaseId, deferred);
423
- // `ensuring` (not `tap`) guarantees the entry is removed even on
424
- // interrupt — without it, a fiber interruption (consumer fiber
425
- // teardown on `disconnect`) would orphan the Deferred + leak the
426
- // lease entry until process exit.
427
- const settled = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(Duration.millis(this.dispatchAdmissionTimeoutMs)), Effect.ensuring(Effect.sync(() => {
428
- this.pendingDispatchesByLease.delete(leaseId);
429
- })));
430
- if (Option.isNone(settled)) {
431
- yield* effectLogWarning("MoltZapChannelCore: dispatchRelease wait timed out - fail-closed deny", {
432
- messageId: work.message.id,
433
- conversationId: work.message.conversationId,
434
- leaseId,
435
- timeoutMs: this.dispatchAdmissionTimeoutMs,
436
- });
437
- return {
438
- _tag: "deny",
439
- reason: "dispatch release wait timed out",
440
- };
236
+ if (registered._tag === "raw") {
237
+ if (!(yield* this.interceptTurn(messages))) {
238
+ return;
239
+ }
240
+ yield* this.awaitHandlerTurn(registered.handler, messages, primary);
241
+ return;
441
242
  }
442
- return this.projectVerdict(work, leaseId, settled.value.verdict);
443
- }.bind(this));
444
- }
445
- projectVerdict(work, leaseId, verdict) {
446
- if (verdict.decision === "grant") {
447
- return this.projectGrantVerdict(work, leaseId, verdict);
448
- }
449
- if (verdict.decision === "deny") {
450
- return MoltZapChannelCore.denyDecision(verdict.reason);
451
- }
452
- return MoltZapChannelCore.holdDecision(verdict.reason);
453
- }
454
- projectGrantVerdict(work, leaseId, verdict) {
455
- runBackgroundLog(effectLogInfo("MoltZapChannelCore: dispatch admission granted", {
456
- messageId: work.message.id,
457
- conversationId: work.message.conversationId,
458
- attempt: work.attempt,
459
- leaseId,
460
- leaseTimeoutMs: verdict.leaseTimeoutMs,
461
- dispatchMessageId: verdict.dispatchMessageId,
462
- }));
463
- return {
464
- _tag: "grant",
465
- leaseId: verdict.leaseId ?? leaseId,
466
- ...(verdict.leaseTimeoutMs !== undefined
467
- ? { leaseTimeoutMs: verdict.leaseTimeoutMs }
468
- : {}),
469
- ...(verdict.dispatchMessageId
470
- ? { dispatchMessageId: verdict.dispatchMessageId }
471
- : {}),
472
- };
473
- }
474
- static denyDecision(reason) {
475
- return {
476
- _tag: "deny",
477
- ...(reason !== undefined ? { reason } : {}),
478
- };
479
- }
480
- static holdDecision(reason) {
481
- return {
482
- _tag: "hold",
483
- ...(reason !== undefined ? { reason } : {}),
484
- };
485
- }
486
- dispatchInboundWork(work) {
487
- return Effect.gen(function* () {
488
- const current = this.takeDispatchCandidate(work);
489
- const decision = yield* this.dispatchAdmission(current);
490
- yield* this.handleDispatchDecision(current, decision);
491
- }.bind(this));
492
- }
493
- handleDispatchDecision(current, decision) {
494
- return Match.value(decision).pipe(Match.tag("grant", (grant) => this.dispatchGrantedWork(current, grant)), Match.tag("deny", (deny) => this.logDeniedDispatch(current, deny)), Match.tag("hold", (hold) => this.holdDispatchWork(current, hold)), Match.exhaustive);
495
- }
496
- dispatchGrantedWork(current, decision) {
497
- return Effect.gen(function* () {
498
- const messages = yield* this.messagesForGrantedDispatch(current, decision);
499
- if (messages.length === 0) {
500
- yield* this.logDispatchTargetUnavailable(current, decision);
243
+ const { enriched, commitContext } = yield* MoltZapChannelCore.enrichMessage(this.service, messages);
244
+ if (!(yield* this.interceptTurn(messages))) {
501
245
  return;
502
246
  }
503
- const primaryMessage =
504
- /* Safe because the surrounding invariant establishes this asserted shape. */ messages[0];
505
- yield* this.logDispatchStart(current, primaryMessage, messages, decision);
506
- const timedOut = yield* this.runGrantedDispatch(current, primaryMessage, messages, decision);
507
- if (!timedOut) {
508
- yield* this.logDispatchCompleted(current, primaryMessage, decision);
247
+ const completed = yield* this.awaitHandlerTurn(registered.handler, enriched, primary);
248
+ // Context markers advance only for a turn the handler finished. A
249
+ // dropped or abandoned one leaves its entries for the next turn.
250
+ if (completed && commitContext) {
251
+ commitContext();
509
252
  }
510
253
  }.bind(this));
511
254
  }
512
- messagesForGrantedDispatch(current, decision) {
513
- return this.service.requestDispatch
514
- ? this.takeCoalescedConversationMessages(current, decision.dispatchMessageId)
515
- : Effect.succeed([current.message]);
516
- }
517
- runGrantedDispatch(current, primaryMessage, messages, decision) {
518
- const dispatch = this.dispatchWithLease(primaryMessage.conversationId, messages, decision.leaseId);
519
- const timeoutMs = MoltZapChannelCore.leaseTimeoutMs(decision);
520
- if (timeoutMs === undefined) {
521
- return dispatch.pipe(Effect.as(false));
255
+ /**
256
+ * Consult the interceptor for this turn, if one is installed.
257
+ * @param messages The coalesced batch, primary first.
258
+ * @returns Whether the handler runs for this batch.
259
+ */
260
+ interceptTurn(messages) {
261
+ const interceptor = this.inboundInterceptor;
262
+ if (interceptor === undefined) {
263
+ return Effect.succeed(true);
522
264
  }
523
- return dispatch.pipe(Effect.timeoutFail({
524
- duration: Duration.millis(timeoutMs),
525
- onTimeout: () => new DispatchLeaseExpired({
526
- messageId: primaryMessage.id,
527
- conversationId: primaryMessage.conversationId,
528
- timeoutMs,
529
- }),
530
- }), Effect.as(false), Effect.catchAll((err) => this.handleDispatchFailure(err, current, decision)));
265
+ const newest =
266
+ /* Safe because a coalesced batch always holds at least its primary. */ messages[messages.length - 1];
267
+ // Suspended so an interceptor that throws before returning its Effect
268
+ // becomes a defect this pipeline can fail open on, not one the consumer
269
+ // fiber catches after the turn is already lost.
270
+ return Effect.suspend(() => interceptor(newest)).pipe(Effect.flatMap((decision) => this.applyInterceptDecision(newest, decision)), Effect.catchAllCause((cause) =>
271
+ // Interruption is teardown, not an interceptor bug: re-raise it so a
272
+ // disconnected channel stops draining instead of delivering the turn.
273
+ Cause.isInterruptedOnly(cause)
274
+ ? Effect.failCause(cause)
275
+ : this.logInterceptorFailure(newest, cause).pipe(Effect.as(true))));
531
276
  }
532
- dispatchWithLease(conversationId, messages, leaseId) {
533
- return Effect.sync(() => {
534
- const previous = this.leaseIdsInFlightByConversation.get(conversationId);
535
- if (leaseId === undefined) {
536
- this.leaseIdsInFlightByConversation.delete(conversationId);
537
- }
538
- else {
539
- this.leaseIdsInFlightByConversation.set(conversationId, leaseId);
540
- }
541
- return previous;
542
- }).pipe(Effect.flatMap((previous) => this.dispatchInboundEffect(messages).pipe(Effect.ensuring(Effect.sync(() => {
543
- if (previous === undefined) {
544
- this.leaseIdsInFlightByConversation.delete(conversationId);
545
- }
546
- else {
547
- this.leaseIdsInFlightByConversation.set(conversationId, previous);
548
- }
549
- })))));
550
- }
551
- static leaseTimeoutMs(decision) {
552
- return decision.leaseId
553
- ? (decision.leaseTimeoutMs ?? DEFAULT_DISPATCH_LEASE_TIMEOUT_MS)
554
- : undefined;
555
- }
556
- handleDispatchFailure(err, current, decision) {
557
- if (err instanceof DispatchLeaseExpired) {
558
- return this.logDispatchLeaseExpired(err, current, decision);
277
+ /**
278
+ * Apply a verdict, logging the drops at debug so a quiet channel can be
279
+ * traced back to its gate.
280
+ * @param newest Message the interceptor judged.
281
+ * @param decision Verdict for the whole coalesced batch.
282
+ * @returns Whether the handler runs.
283
+ */
284
+ applyInterceptDecision(newest, decision) {
285
+ if (decision._tag === "deliver") {
286
+ return Effect.succeed(true);
559
287
  }
560
- return Effect.fail(err);
561
- }
562
- holdDispatchWork(current, decision) {
563
- return Effect.gen(function* () {
564
- yield* effectLogInfo("MoltZapChannelCore: inbound dispatch held", {
565
- messageId: current.message.id,
566
- conversationId: current.message.conversationId,
567
- attempt: current.attempt,
568
- reason: decision.reason,
569
- });
570
- this.parkDispatchWork(current);
571
- }.bind(this));
572
- }
573
- logDispatchTargetUnavailable(current, decision) {
574
- return effectLogWarning("MoltZapChannelCore: dispatch admission target unavailable", {
575
- messageId: current.message.id,
576
- conversationId: current.message.conversationId,
577
- attempt: current.attempt,
578
- dispatchMessageId: decision.dispatchMessageId,
579
- });
580
- }
581
- logDispatchStart(current, primaryMessage, messages, decision) {
582
- return effectLogInfo("MoltZapChannelCore: inbound dispatch starting", {
583
- messageId: primaryMessage.id,
584
- admittedMessageId: current.message.id,
585
- conversationId: current.message.conversationId,
586
- attempt: current.attempt,
587
- leaseId: decision.leaseId,
588
- coalescedMessageCount: messages.length,
589
- });
590
- }
591
- logDispatchLeaseExpired(err, current, decision) {
592
- return effectLogWarning("MoltZapChannelCore: inbound dispatch lease expired", {
593
- messageId: err.messageId,
594
- conversationId: err.conversationId,
595
- attempt: current.attempt,
596
- leaseId: decision.leaseId,
597
- timeoutMs: err.timeoutMs,
598
- }).pipe(Effect.as(true));
599
- }
600
- logDispatchCompleted(current, primaryMessage, decision) {
601
- return effectLogInfo("MoltZapChannelCore: inbound dispatch completed", {
602
- messageId: primaryMessage.id,
603
- admittedMessageId: current.message.id,
604
- conversationId: current.message.conversationId,
605
- attempt: current.attempt,
606
- leaseId: decision.leaseId,
607
- });
608
- }
609
- logDeniedDispatch(current, decision) {
610
- return effectLogInfo("MoltZapChannelCore: inbound dispatch denied", {
611
- messageId: current.message.id,
612
- conversationId: current.message.conversationId,
613
- attempt: current.attempt,
614
- reason: decision.reason,
288
+ return effectLogDebug("MoltZapChannelCore: inbound turn dropped by interceptor", {
289
+ messageId: newest.id,
290
+ conversationId: newest.conversationId,
291
+ ...(decision.reason !== undefined ? { reason: decision.reason } : {}),
292
+ }).pipe(Effect.as(false));
293
+ }
294
+ logInterceptorFailure(newest, cause) {
295
+ return effectLogWarning("MoltZapChannelCore: inbound interceptor failed, delivering anyway", {
296
+ messageId: newest.id,
297
+ conversationId: newest.conversationId,
298
+ causePretty: Cause.pretty(cause),
299
+ ...errorSummary(Cause.squash(cause)),
615
300
  });
616
301
  }
617
- pendingDispatchSnapshot(active) {
618
- const queued = Chunk.toReadonlyArray(Effect.runSync(Queue.takeAll(this.inboundQueue)));
619
- for (const work of queued) {
620
- Queue.unsafeOffer(this.inboundQueue, work);
302
+ /**
303
+ * Await the user handler, bounded by `turnTimeoutMs` when it is set.
304
+ * @param handler Installed inbound handler.
305
+ * @param input Value delivered to the selected handler.
306
+ * @param primary Message that opened this turn.
307
+ * @returns Whether the handler ran to completion.
308
+ */
309
+ awaitHandlerTurn(handler, input, primary) {
310
+ // The handler is user code returning an Effect — yield it directly so its
311
+ // typed error channel propagates to the consumer fiber, which logs and
312
+ // continues. Awaiting it inline preserves arrival-order delivery.
313
+ const turn = handler(input);
314
+ const timeoutMs = this.turnTimeoutMs;
315
+ if (timeoutMs === undefined) {
316
+ return turn.pipe(Effect.as(true));
621
317
  }
622
- const parked = [...this.parkedByConversation.values()].flat();
623
- return [active, ...parked, ...queued].map((work) => ({
624
- messageId: work.message.id,
625
- conversationId: work.message.conversationId,
626
- senderAgentId: work.message.senderId,
627
- createdAt: work.message.createdAt,
628
- receivedAt: new Date(work.receivedAtMs).toISOString(),
629
- parts: work.message.parts,
630
- }));
318
+ return turn.pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.tap((finished) => Option.isNone(finished)
319
+ ? effectLogWarning("MoltZapChannelCore: inbound turn abandoned after timeout", {
320
+ messageId: primary.id,
321
+ conversationId: primary.conversationId,
322
+ timeoutMs,
323
+ })
324
+ : Effect.void), Effect.map(Option.isSome));
631
325
  }
632
- takeCoalescedConversationMessages(work, dispatchMessageId) {
326
+ /**
327
+ * Drain every queued message for the primary's conversation into one turn,
328
+ * leaving other conversations queued in arrival order. Coalescing keeps a
329
+ * burst in one conversation from costing one turn per message.
330
+ * @param primary Message that opened this turn.
331
+ * @returns The messages belonging to this turn, primary first.
332
+ */
333
+ takeCoalescedConversationMessages(primary) {
633
334
  return Effect.sync(() => {
634
335
  const queued = Chunk.toReadonlyArray(Effect.runSync(Queue.takeAll(this.inboundQueue)));
635
- const parked = this.parkedByConversation.get(work.message.conversationId);
636
- const sameConversation = [work.message];
336
+ const sameConversation = [primary];
637
337
  const remaining = [];
638
- if (parked) {
639
- sameConversation.push(...parked.map((parkedWork) => parkedWork.message));
640
- this.parkedByConversation.delete(work.message.conversationId);
641
- }
642
- for (const queuedWork of queued) {
643
- if (queuedWork.message.conversationId === work.message.conversationId) {
644
- sameConversation.push(queuedWork.message);
338
+ for (const queuedMessage of queued) {
339
+ if (queuedMessage.conversationId === primary.conversationId) {
340
+ sameConversation.push(queuedMessage);
645
341
  }
646
342
  else {
647
- remaining.push(queuedWork);
343
+ remaining.push(queuedMessage);
648
344
  }
649
345
  }
650
- const startIndex = dispatchMessageId === undefined
651
- ? 0
652
- : sameConversation.findIndex((message) => message.id === dispatchMessageId);
653
- for (const remainingWork of remaining) {
654
- Queue.unsafeOffer(this.inboundQueue, remainingWork);
346
+ for (const remainingMessage of remaining) {
347
+ Queue.unsafeOffer(this.inboundQueue, remainingMessage);
655
348
  }
656
- if (startIndex < 0) {
657
- return [];
658
- }
659
- return sameConversation.slice(startIndex);
349
+ return sameConversation;
660
350
  });
661
351
  }
662
352
  /**
@@ -664,30 +354,10 @@ export class MoltZapChannelCore {
664
354
  * `resolveAgentName` throws (e.g. Service not yet connected).
665
355
  * @param service Value supplied to the operation.
666
356
  * @param messageOrMessages Value supplied to the operation.
667
- * @returns The leased result.
357
+ * @returns The enriched message and its context-commit callback.
668
358
  */
669
359
  static enrichMessage(service, messageOrMessages) {
670
360
  return enrichChannelMessage(service, messageOrMessages);
671
361
  }
672
- dispatchInboundEffect(messages) {
673
- return Effect.gen(function* () {
674
- const registration = this.inboundHandlerRegistration;
675
- if (registration === null) {
676
- return;
677
- }
678
- const { enriched, commitContext } = yield* MoltZapChannelCore.enrichMessage(this.service, messages);
679
- const leaseId = this.leaseIdsInFlightByConversation.get(enriched.conversationId);
680
- const leased = leaseId !== undefined
681
- ? { ...enriched, dispatchLeaseId: leaseId }
682
- : enriched;
683
- // The handler is user code returning an Effect — yield it directly so
684
- // its typed error channel propagates to the consumer fiber, which logs
685
- // and continues. We await it inline to preserve arrival-order delivery.
686
- yield* registration.handler(leased);
687
- if (commitContext) {
688
- commitContext();
689
- }
690
- }.bind(this));
691
- }
692
362
  }
693
363
  //# sourceMappingURL=channel-core.js.map