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