@moltzap/client 2026.802.0 → 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.
- package/dist/bounded-map.d.ts +3 -3
- package/dist/bounded-map.js +3 -3
- package/dist/channel-base/index.d.ts +3 -7
- package/dist/channel-base/index.d.ts.map +1 -1
- package/dist/channel-base/index.js +2 -6
- package/dist/channel-base/index.js.map +1 -1
- package/dist/channel-base/reply-guard.d.ts +52 -0
- package/dist/channel-base/reply-guard.d.ts.map +1 -0
- package/dist/channel-base/reply-guard.js +78 -0
- package/dist/channel-base/reply-guard.js.map +1 -0
- package/dist/channel-core-test-support.d.ts +5 -27
- package/dist/channel-core-test-support.d.ts.map +1 -1
- package/dist/channel-core-test-support.js +2 -90
- package/dist/channel-core-test-support.js.map +1 -1
- package/dist/channel-core.d.ts +130 -248
- package/dist/channel-core.d.ts.map +1 -1
- package/dist/channel-core.js +169 -526
- package/dist/channel-core.js.map +1 -1
- package/dist/cli/commands/start.d.ts +1 -5
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +0 -3
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/index.d.ts +1 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -3
- package/dist/index.js.map +1 -1
- package/dist/local-daemon-rpc.d.ts +12 -195
- package/dist/local-daemon-rpc.d.ts.map +1 -1
- package/dist/local-daemon-rpc.js +1 -5
- package/dist/local-daemon-rpc.js.map +1 -1
- package/dist/notification/__tests__/subscribe-signatures.types-check.d.ts +14 -14
- package/dist/notification/__tests__/subscribe-signatures.types-check.d.ts.map +1 -1
- package/dist/notification/__tests__/subscribe-signatures.types-check.js +5 -5
- package/dist/notification/__tests__/subscribe-signatures.types-check.js.map +1 -1
- package/dist/service-local-daemon.d.ts +1 -1
- package/dist/service-local-daemon.d.ts.map +1 -1
- package/dist/service-local-daemon.js +2 -4
- package/dist/service-local-daemon.js.map +1 -1
- package/dist/service.d.ts +4 -25
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +7 -44
- package/dist/service.js.map +1 -1
- package/dist/test-utils/channel-service-fixture.d.ts +1 -3
- package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
- package/dist/test-utils/channel-service-fixture.js +2 -22
- package/dist/test-utils/channel-service-fixture.js.map +1 -1
- package/dist/test-utils/ids.d.ts +0 -13
- package/dist/test-utils/ids.d.ts.map +1 -1
- package/dist/test-utils/ids.js +1 -16
- package/dist/test-utils/ids.js.map +1 -1
- package/dist/test-utils/index.d.ts +1 -1
- package/dist/test-utils/index.d.ts.map +1 -1
- package/dist/test-utils/index.js +1 -1
- package/dist/test-utils/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/dist/app-client.d.ts +0 -5
- package/dist/app-client.d.ts.map +0 -1
- package/dist/app-client.js +0 -3
- package/dist/app-client.js.map +0 -1
- package/dist/channel-base/lease-guard.d.ts +0 -31
- package/dist/channel-base/lease-guard.d.ts.map +0 -1
- package/dist/channel-base/lease-guard.js +0 -44
- package/dist/channel-base/lease-guard.js.map +0 -1
- package/dist/channel-base/lease-store.d.ts +0 -48
- package/dist/channel-base/lease-store.d.ts.map +0 -1
- package/dist/channel-base/lease-store.js +0 -77
- package/dist/channel-base/lease-store.js.map +0 -1
- package/dist/channel-base/lease.d.ts +0 -96
- package/dist/channel-base/lease.d.ts.map +0 -1
- package/dist/channel-base/lease.js +0 -106
- package/dist/channel-base/lease.js.map +0 -1
package/dist/channel-core.js
CHANGED
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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,
|
|
5
|
-
import { BoundedMap } from "./bounded-map.js";
|
|
6
|
-
import { DEFAULT_DISPATCH_LEASE_TIMEOUT_MS, } from "@moltzap/protocol/message/dispatch";
|
|
7
|
+
import { Cause, Chunk, Duration, Effect, Fiber, Option, Queue } from "effect";
|
|
7
8
|
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
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
|
|
34
|
-
return Effect.
|
|
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,
|
|
47
|
-
* and a send helper. One core per service —
|
|
48
|
-
* side-effectful (advances per-conversation
|
|
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,94 +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
|
|
65
|
-
* Note over core:
|
|
66
|
-
* core
|
|
67
|
-
*
|
|
68
|
-
* Note over
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
|
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
|
-
*
|
|
85
|
-
*
|
|
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
|
-
|
|
70
|
+
turnTimeoutMs;
|
|
71
|
+
inboundInterceptor;
|
|
91
72
|
connected = false;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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();
|
|
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;
|
|
80
|
+
inboundHandler = null;
|
|
116
81
|
/**
|
|
117
82
|
* Inbound messages with an installed handler enqueue synchronously; a single
|
|
118
|
-
* forked consumer fiber serialises delivery
|
|
83
|
+
* forked consumer fiber serialises delivery so handlers execute
|
|
84
|
+
* one-at-a-time in arrival order.
|
|
119
85
|
*/
|
|
120
86
|
inboundQueue = Effect.runSync(Queue.unbounded());
|
|
121
87
|
consumerFiber;
|
|
122
88
|
disconnectHandlers = [];
|
|
123
89
|
constructor(opts) {
|
|
124
90
|
this.service = opts.service;
|
|
125
|
-
|
|
126
|
-
|
|
91
|
+
if (opts.turnTimeoutMs !== undefined) {
|
|
92
|
+
this.turnTimeoutMs = opts.turnTimeoutMs;
|
|
93
|
+
}
|
|
94
|
+
if (opts.inboundInterceptor !== undefined) {
|
|
95
|
+
this.inboundInterceptor = opts.inboundInterceptor;
|
|
96
|
+
}
|
|
127
97
|
this.registerMessageListener();
|
|
128
98
|
this.consumerFiber = this.startConsumerFiber();
|
|
129
99
|
this.registerConnectionListeners();
|
|
130
|
-
this.registerDispatchReleaseListener();
|
|
131
100
|
}
|
|
132
101
|
registerMessageListener() {
|
|
133
102
|
this.service.on("message", ({ message }) => {
|
|
134
|
-
|
|
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) {
|
|
135
109
|
return;
|
|
136
110
|
}
|
|
137
|
-
Queue.unsafeOffer(this.inboundQueue,
|
|
138
|
-
message,
|
|
139
|
-
attempt: 0,
|
|
140
|
-
receivedAtMs: Date.now(),
|
|
141
|
-
});
|
|
111
|
+
Queue.unsafeOffer(this.inboundQueue, message);
|
|
142
112
|
});
|
|
143
113
|
}
|
|
144
114
|
startConsumerFiber() {
|
|
145
|
-
const consumer = Effect.forever(Queue.take(this.inboundQueue).pipe(Effect.flatMap((
|
|
115
|
+
const consumer = Effect.forever(Queue.take(this.inboundQueue).pipe(Effect.flatMap((message) => this.runInboundTurn(message).pipe(Effect.catchAllCause((cause) => this.logInboundFailure(message, cause))))));
|
|
146
116
|
return Effect.runFork(consumer);
|
|
147
117
|
}
|
|
148
|
-
logInboundFailure(
|
|
118
|
+
logInboundFailure(message, cause) {
|
|
149
119
|
return effectLogError("MoltZapChannelCore: inbound handler failed", {
|
|
150
|
-
messageId:
|
|
151
|
-
conversationId:
|
|
120
|
+
messageId: message.id,
|
|
121
|
+
conversationId: message.conversationId,
|
|
152
122
|
causePretty: Cause.pretty(cause),
|
|
153
123
|
...errorSummary(Cause.squash(cause)),
|
|
154
124
|
});
|
|
@@ -159,76 +129,12 @@ export class MoltZapChannelCore {
|
|
|
159
129
|
this.fanout(this.disconnectHandlers, "disconnect");
|
|
160
130
|
});
|
|
161
131
|
}
|
|
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
132
|
/**
|
|
220
133
|
* Replaces any previous handler.
|
|
221
134
|
* @param handler Handler invoked for matching requests.
|
|
222
|
-
* @returns An idempotent disposer for this registration generation.
|
|
223
135
|
*/
|
|
224
136
|
onInbound(handler) {
|
|
225
|
-
|
|
226
|
-
this.inboundHandlerRegistration = registration;
|
|
227
|
-
return () => {
|
|
228
|
-
if (this.inboundHandlerRegistration === registration) {
|
|
229
|
-
this.inboundHandlerRegistration = null;
|
|
230
|
-
}
|
|
231
|
-
};
|
|
137
|
+
this.inboundHandler = handler;
|
|
232
138
|
}
|
|
233
139
|
onDisconnect(handler) {
|
|
234
140
|
this.disconnectHandlers.push(handler);
|
|
@@ -247,13 +153,32 @@ export class MoltZapChannelCore {
|
|
|
247
153
|
}
|
|
248
154
|
}
|
|
249
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
|
+
}
|
|
250
163
|
return this.service.connect().pipe(Effect.tap(() => Effect.sync(() => {
|
|
251
164
|
this.connected = true;
|
|
252
165
|
})), Effect.asVoid);
|
|
253
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
|
+
*/
|
|
254
178
|
disconnect() {
|
|
255
179
|
return Effect.gen(function* () {
|
|
256
180
|
this.connected = false;
|
|
181
|
+
this.stopped = true;
|
|
257
182
|
// Interrupt the consumer fiber so any queued inbound messages are
|
|
258
183
|
// dropped rather than delivered after the channel is torn down.
|
|
259
184
|
const stopConsumer = Fiber.interrupt(this.consumerFiber);
|
|
@@ -273,390 +198,128 @@ export class MoltZapChannelCore {
|
|
|
273
198
|
return this.connected;
|
|
274
199
|
}
|
|
275
200
|
/**
|
|
276
|
-
* Reply into a conversation
|
|
277
|
-
* otherwise with only the active lease for `conversationId`.
|
|
201
|
+
* Reply into a conversation.
|
|
278
202
|
* @param conversationId Value supplied to the operation.
|
|
279
203
|
* @param text Text to process.
|
|
280
|
-
* @param opts Value supplied to the operation.
|
|
281
|
-
* @param opts.dispatchLeaseId Value supplied to the operation.
|
|
282
204
|
* @returns The send result.
|
|
283
205
|
*/
|
|
284
|
-
sendReply(conversationId, text
|
|
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);
|
|
315
|
-
}
|
|
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
|
-
})));
|
|
206
|
+
sendReply(conversationId, text) {
|
|
207
|
+
return this.service.send(conversationId, text);
|
|
404
208
|
}
|
|
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) {
|
|
209
|
+
runInboundTurn(primary) {
|
|
416
210
|
return Effect.gen(function* () {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
};
|
|
211
|
+
const messages = yield* this.takeCoalescedConversationMessages(primary);
|
|
212
|
+
const handler = this.inboundHandler;
|
|
213
|
+
if (!handler) {
|
|
214
|
+
return;
|
|
441
215
|
}
|
|
442
|
-
|
|
443
|
-
|
|
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);
|
|
216
|
+
const { enriched, commitContext } = yield* MoltZapChannelCore.enrichMessage(this.service, messages);
|
|
217
|
+
if (!(yield* this.interceptTurn(messages))) {
|
|
501
218
|
return;
|
|
502
219
|
}
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
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();
|
|
509
225
|
}
|
|
510
226
|
}.bind(this));
|
|
511
227
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
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);
|
|
522
237
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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))));
|
|
531
249
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
return
|
|
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);
|
|
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);
|
|
559
260
|
}
|
|
560
|
-
return
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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,
|
|
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)),
|
|
615
273
|
});
|
|
616
274
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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));
|
|
621
290
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
parts: work.message.parts,
|
|
630
|
-
}));
|
|
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));
|
|
631
298
|
}
|
|
632
|
-
|
|
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) {
|
|
633
307
|
return Effect.sync(() => {
|
|
634
308
|
const queued = Chunk.toReadonlyArray(Effect.runSync(Queue.takeAll(this.inboundQueue)));
|
|
635
|
-
const
|
|
636
|
-
const sameConversation = [work.message];
|
|
309
|
+
const sameConversation = [primary];
|
|
637
310
|
const remaining = [];
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
for (const queuedWork of queued) {
|
|
643
|
-
if (queuedWork.message.conversationId === work.message.conversationId) {
|
|
644
|
-
sameConversation.push(queuedWork.message);
|
|
311
|
+
for (const queuedMessage of queued) {
|
|
312
|
+
if (queuedMessage.conversationId === primary.conversationId) {
|
|
313
|
+
sameConversation.push(queuedMessage);
|
|
645
314
|
}
|
|
646
315
|
else {
|
|
647
|
-
remaining.push(
|
|
316
|
+
remaining.push(queuedMessage);
|
|
648
317
|
}
|
|
649
318
|
}
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
: sameConversation.findIndex((message) => message.id === dispatchMessageId);
|
|
653
|
-
for (const remainingWork of remaining) {
|
|
654
|
-
Queue.unsafeOffer(this.inboundQueue, remainingWork);
|
|
319
|
+
for (const remainingMessage of remaining) {
|
|
320
|
+
Queue.unsafeOffer(this.inboundQueue, remainingMessage);
|
|
655
321
|
}
|
|
656
|
-
|
|
657
|
-
return [];
|
|
658
|
-
}
|
|
659
|
-
return sameConversation.slice(startIndex);
|
|
322
|
+
return sameConversation;
|
|
660
323
|
});
|
|
661
324
|
}
|
|
662
325
|
/**
|
|
@@ -664,30 +327,10 @@ export class MoltZapChannelCore {
|
|
|
664
327
|
* `resolveAgentName` throws (e.g. Service not yet connected).
|
|
665
328
|
* @param service Value supplied to the operation.
|
|
666
329
|
* @param messageOrMessages Value supplied to the operation.
|
|
667
|
-
* @returns The
|
|
330
|
+
* @returns The enriched message and its context-commit callback.
|
|
668
331
|
*/
|
|
669
332
|
static enrichMessage(service, messageOrMessages) {
|
|
670
333
|
return enrichChannelMessage(service, messageOrMessages);
|
|
671
334
|
}
|
|
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
335
|
}
|
|
693
336
|
//# sourceMappingURL=channel-core.js.map
|