@noetic-tools/core 0.3.0 → 1.1.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.
@@ -1,8 +1,14 @@
1
1
  import { frameworkCast, NoeticErrorImpl } from '@noetic-tools/types';
2
- function isExternalChannel(ch) {
3
- return 'external' in ch && ch.external === true;
4
- }
5
2
  const MAX_TOPIC_TIMEOUT = 300_000; // 5 minutes
3
+ const DEFAULT_SEND_TIMEOUT = 30_000;
4
+ const RESOLVED = Promise.resolve();
5
+ /** Build the `cancelled` error a blocked channel operation rejects with on abort. */
6
+ function cancelledError(signal) {
7
+ return new NoeticErrorImpl({
8
+ kind: 'cancelled',
9
+ reason: typeof signal.reason === 'string' ? signal.reason : undefined,
10
+ });
11
+ }
6
12
  export class ChannelStore {
7
13
  channels = new Map();
8
14
  closedExecutions = new Set();
@@ -16,6 +22,7 @@ export class ChannelStore {
16
22
  queue: [],
17
23
  capacity: channel.capacity ?? 1_000,
18
24
  queueWaiters: [],
25
+ pendingSenders: [],
19
26
  topicSubscribers: new Set(),
20
27
  wakeSubscribers: new Set(),
21
28
  };
@@ -39,14 +46,38 @@ export class ChannelStore {
39
46
  subscribers.delete(callback);
40
47
  };
41
48
  }
42
- send(channel, value) {
49
+ /**
50
+ * Internal-sender write with back-pressure (spec 06). Delivery is
51
+ * synchronous when possible; the returned promise only parks when a queue
52
+ * channel is at capacity:
53
+ *
54
+ * - **value**: store last-write-wins value, drain ALL parked recv waiters,
55
+ * resolve immediately.
56
+ * - **queue**: hand off to a parked recv waiter or push below capacity —
57
+ * resolve immediately. At capacity, park the sender FIFO until a consumer
58
+ * dequeues an item; reject `channel_timeout` after `opts.timeout`
59
+ * (default 30s) or `cancelled` when `opts.signal` aborts.
60
+ * - **topic**: deliver to current subscribers, resolve immediately.
61
+ *
62
+ * External callers use `ChannelHandle.send` (sync, drop-oldest) instead.
63
+ */
64
+ send(channel, value, opts) {
43
65
  const state = this.getOrCreate(channel);
66
+ let result = RESOLVED;
44
67
  switch (state.mode) {
45
68
  case 'value':
46
69
  state.currentValue = value;
47
70
  state.hasValue = true;
71
+ // Value-mode reads are non-consuming (last-write-wins), so once
72
+ // hasValue flips true EVERY parked waiter must be drained — waking
73
+ // only one would strand the rest until channel_timeout while new
74
+ // recv calls succeed instantly. Splice in place: timeout timers and
75
+ // abort listeners close over the array identity.
48
76
  if (state.valueWaiters.length > 0) {
49
- state.valueWaiters.shift().resolve(value);
77
+ const drained = state.valueWaiters.splice(0, state.valueWaiters.length);
78
+ for (const waiter of drained) {
79
+ waiter.resolve(value);
80
+ }
50
81
  }
51
82
  break;
52
83
  case 'queue':
@@ -57,13 +88,8 @@ export class ChannelStore {
57
88
  state.queue.push(value);
58
89
  }
59
90
  else {
60
- // At capacity
61
- console.warn(`[noetic] Channel '${channel.name}': queue at capacity (${state.capacity}), dropping message.`);
62
- if (isExternalChannel(channel)) {
63
- state.queue.shift();
64
- state.queue.push(value);
65
- }
66
- // For internal, drop the new value
91
+ // At capacity — park the sender (back-pressure).
92
+ result = this.parkSender(state, channel.name, value, opts);
67
93
  }
68
94
  break;
69
95
  case 'topic':
@@ -72,30 +98,128 @@ export class ChannelStore {
72
98
  }
73
99
  break;
74
100
  }
75
- if (state.wakeSubscribers.size > 0) {
76
- // Swap in a fresh set before firing so a callback that re-subscribes
77
- // (typical for `every` re-arming on its next iteration) lands on the
78
- // new set and survives until the next send.
79
- const wakers = state.wakeSubscribers;
80
- state.wakeSubscribers = new Set();
81
- for (const wake of wakers) {
82
- wake();
101
+ this.fireWakes(state);
102
+ return result;
103
+ }
104
+ /** Park an internal sender on a full queue until a slot frees (FIFO). */
105
+ parkSender(state, channelName, value, opts) {
106
+ const timeout = opts?.timeout ?? DEFAULT_SEND_TIMEOUT;
107
+ const signal = opts?.signal;
108
+ if (signal?.aborted) {
109
+ return Promise.reject(cancelledError(signal));
110
+ }
111
+ return new Promise((resolve, reject) => {
112
+ let timer = null;
113
+ let removeAbortListener = null;
114
+ const cleanup = () => {
115
+ if (timer) {
116
+ clearTimeout(timer);
117
+ }
118
+ removeAbortListener?.();
119
+ };
120
+ const entry = {
121
+ value,
122
+ resolve: () => {
123
+ cleanup();
124
+ resolve();
125
+ },
126
+ reject: (e) => {
127
+ cleanup();
128
+ reject(e);
129
+ },
130
+ };
131
+ state.pendingSenders.push(entry);
132
+ const removeEntry = () => {
133
+ const idx = state.pendingSenders.indexOf(entry);
134
+ if (idx < 0) {
135
+ return false;
136
+ }
137
+ state.pendingSenders.splice(idx, 1);
138
+ return true;
139
+ };
140
+ if (timeout > 0) {
141
+ timer = setTimeout(() => {
142
+ if (removeEntry()) {
143
+ entry.reject(new NoeticErrorImpl({
144
+ kind: 'channel_timeout',
145
+ channelName,
146
+ timeout,
147
+ }));
148
+ }
149
+ }, timeout);
83
150
  }
151
+ if (signal) {
152
+ const onAbort = () => {
153
+ if (removeEntry()) {
154
+ entry.reject(cancelledError(signal));
155
+ }
156
+ };
157
+ signal.addEventListener('abort', onAbort, {
158
+ once: true,
159
+ });
160
+ removeAbortListener = () => {
161
+ signal.removeEventListener('abort', onAbort);
162
+ };
163
+ }
164
+ });
165
+ }
166
+ /**
167
+ * A dequeue freed a queue slot — shift the oldest parked sender's value
168
+ * into it (FIFO) and resolve that sender's promise.
169
+ */
170
+ promotePendingSender(state) {
171
+ if (state.pendingSenders.length === 0) {
172
+ return;
173
+ }
174
+ if (state.queue.length >= state.capacity) {
175
+ return;
176
+ }
177
+ const sender = state.pendingSenders.shift();
178
+ state.queue.push(sender.value);
179
+ sender.resolve();
180
+ }
181
+ /** Fire-and-reset the one-shot wake subscribers for a channel state. */
182
+ fireWakes(state) {
183
+ if (state.wakeSubscribers.size === 0) {
184
+ return;
185
+ }
186
+ // Swap in a fresh set before firing so a callback that re-subscribes
187
+ // (typical for `every` re-arming on its next iteration) lands on the
188
+ // new set and survives until the next send.
189
+ const wakers = state.wakeSubscribers;
190
+ state.wakeSubscribers = new Set();
191
+ for (const wake of wakers) {
192
+ wake();
84
193
  }
85
194
  }
86
- async recv(channel, timeout = 30_000) {
195
+ async recv(channel, timeout = 30_000, signal) {
196
+ // An already-aborted context must reject promptly rather than park a
197
+ // waiter that nothing will ever wake (spec 09, Cancellation item 2).
198
+ if (signal?.aborted) {
199
+ throw cancelledError(signal);
200
+ }
87
201
  const state = this.getOrCreate(channel);
88
202
  switch (state.mode) {
89
203
  case 'value':
90
204
  if (state.hasValue) {
91
205
  return state.currentValue;
92
206
  }
93
- return this.waitWithTimeout(state.valueWaiters, channel.name, timeout);
94
- case 'queue':
207
+ return this.waitWithTimeout(state.valueWaiters, channel.name, timeout, signal);
208
+ case 'queue': {
95
209
  if (state.queue.length > 0) {
96
- return state.queue.shift();
210
+ const head = state.queue.shift();
211
+ this.promotePendingSender(state);
212
+ return head;
97
213
  }
98
- return this.waitWithTimeout(state.queueWaiters, channel.name, timeout);
214
+ // capacity-0 edge: senders can be parked while the queue is empty —
215
+ // hand the oldest parked value straight to this receiver.
216
+ if (state.pendingSenders.length > 0) {
217
+ const sender = state.pendingSenders.shift();
218
+ sender.resolve();
219
+ return sender.value;
220
+ }
221
+ return this.waitWithTimeout(state.queueWaiters, channel.name, timeout, signal);
222
+ }
99
223
  case 'topic': {
100
224
  // Clamp timeout to prevent indefinite subscriber leaks
101
225
  let effectiveTimeout = timeout;
@@ -104,8 +228,10 @@ export class ChannelStore {
104
228
  effectiveTimeout = MAX_TOPIC_TIMEOUT;
105
229
  }
106
230
  return new Promise((resolve, reject) => {
231
+ let removeAbortListener = null;
107
232
  const timer = setTimeout(() => {
108
233
  state.topicSubscribers.delete(handler);
234
+ removeAbortListener?.();
109
235
  reject(new NoeticErrorImpl({
110
236
  kind: 'channel_timeout',
111
237
  channelName: channel.name,
@@ -115,9 +241,23 @@ export class ChannelStore {
115
241
  const handler = (value) => {
116
242
  clearTimeout(timer);
117
243
  state.topicSubscribers.delete(handler);
244
+ removeAbortListener?.();
118
245
  resolve(value);
119
246
  };
120
247
  state.topicSubscribers.add(handler);
248
+ if (signal) {
249
+ const onAbort = () => {
250
+ clearTimeout(timer);
251
+ state.topicSubscribers.delete(handler);
252
+ reject(cancelledError(signal));
253
+ };
254
+ signal.addEventListener('abort', onAbort, {
255
+ once: true,
256
+ });
257
+ removeAbortListener = () => {
258
+ signal.removeEventListener('abort', onAbort);
259
+ };
260
+ }
121
261
  });
122
262
  }
123
263
  }
@@ -127,32 +267,55 @@ export class ChannelStore {
127
267
  switch (state.mode) {
128
268
  case 'value':
129
269
  return state.hasValue ? state.currentValue : null;
130
- case 'queue':
131
- return state.queue.length > 0 ? state.queue.shift() : null;
270
+ case 'queue': {
271
+ if (state.queue.length > 0) {
272
+ const head = state.queue.shift();
273
+ this.promotePendingSender(state);
274
+ return head;
275
+ }
276
+ return null;
277
+ }
132
278
  case 'topic':
133
279
  return null;
134
280
  }
135
281
  }
136
- waitWithTimeout(waiters, channelName, timeout) {
282
+ waitWithTimeout(waiters, channelName, timeout, signal) {
137
283
  return new Promise((resolve, reject) => {
138
284
  let timer = null;
139
- const wrappedResolve = (v) => {
285
+ let removeAbortListener = null;
286
+ // Symmetric cleanup: whichever path settles the waiter (delivery,
287
+ // timeout, abort) must release both the timer and the abort listener.
288
+ const cleanup = () => {
140
289
  if (timer) {
141
290
  clearTimeout(timer);
142
291
  }
292
+ removeAbortListener?.();
293
+ };
294
+ const wrappedResolve = (v) => {
295
+ cleanup();
143
296
  resolve(v);
144
297
  };
298
+ const wrappedReject = (e) => {
299
+ cleanup();
300
+ reject(e);
301
+ };
145
302
  const entry = {
146
303
  resolve: wrappedResolve,
147
- reject,
304
+ reject: wrappedReject,
148
305
  };
149
306
  waiters.push(entry);
307
+ const removeEntry = () => {
308
+ const idx = waiters.indexOf(entry);
309
+ if (idx < 0) {
310
+ return false;
311
+ }
312
+ waiters.splice(idx, 1);
313
+ return true;
314
+ };
150
315
  if (timeout > 0) {
151
316
  timer = setTimeout(() => {
152
- const idx = waiters.indexOf(entry);
153
- if (idx >= 0) {
154
- waiters.splice(idx, 1);
155
- reject(new NoeticErrorImpl({
317
+ if (removeEntry()) {
318
+ wrappedReject(new NoeticErrorImpl({
156
319
  kind: 'channel_timeout',
157
320
  channelName,
158
321
  timeout,
@@ -160,8 +323,42 @@ export class ChannelStore {
160
323
  }
161
324
  }, timeout);
162
325
  }
326
+ if (signal) {
327
+ const onAbort = () => {
328
+ if (removeEntry()) {
329
+ wrappedReject(cancelledError(signal));
330
+ }
331
+ };
332
+ signal.addEventListener('abort', onAbort, {
333
+ once: true,
334
+ });
335
+ removeAbortListener = () => {
336
+ signal.removeEventListener('abort', onAbort);
337
+ };
338
+ }
163
339
  });
164
340
  }
341
+ /**
342
+ * External-sender write: synchronous, never back-pressured (spec 06,
343
+ * External Sender Back-Pressure). At capacity the OLDEST queued item is
344
+ * dropped (with a warning) so external callers never block.
345
+ */
346
+ sendExternal(channel, value) {
347
+ const state = this.getOrCreate(channel);
348
+ const isFullQueue = state.mode === 'queue' &&
349
+ state.queueWaiters.length === 0 &&
350
+ state.queue.length >= state.capacity;
351
+ if (isFullQueue) {
352
+ console.warn(`[noetic] Channel '${channel.name}': queue at capacity (${state.capacity}), dropping oldest message (external sender).`);
353
+ state.queue.shift();
354
+ state.queue.push(value);
355
+ this.fireWakes(state);
356
+ return;
357
+ }
358
+ // Below capacity (or non-queue mode) the internal path resolves
359
+ // synchronously — nothing can park here.
360
+ void this.send(channel, value);
361
+ }
165
362
  getHandle(channel, executionId) {
166
363
  const store = this;
167
364
  return {
@@ -176,7 +373,7 @@ export class ChannelStore {
176
373
  channelName: channel.name,
177
374
  });
178
375
  }
179
- store.send(channel, value);
376
+ store.sendExternal(channel, value);
180
377
  },
181
378
  };
182
379
  }
@@ -34,6 +34,15 @@ export declare class ContextImpl implements Context<ContextMemory> {
34
34
  private _completed;
35
35
  private _aborted;
36
36
  private _abortReason?;
37
+ /**
38
+ * Per-context abort fan-out. `abort()` fires it so operations blocked on
39
+ * this context (channel `recv` waiters, parked back-pressure senders)
40
+ * reject promptly with `{ kind: 'cancelled' }` instead of hanging until
41
+ * their timeout (spec 09, Cancellation item 2). Each fork/spawn child
42
+ * constructs its own ContextImpl and therefore its own controller —
43
+ * aborting a child never rejects the parent's waiters.
44
+ */
45
+ private readonly _abortController;
37
46
  private _memory?;
38
47
  /**
39
48
  * Stack of steps currently in flight on this context, most-recent last.
@@ -77,7 +86,7 @@ export declare class ContextImpl implements Context<ContextMemory> {
77
86
  recv<T>(ch: Channel<T>, opts?: {
78
87
  timeout?: number;
79
88
  }): Promise<T>;
80
- send<T>(ch: Channel<T>, value: T): void;
89
+ send<T>(ch: Channel<T>, value: T): Promise<void>;
81
90
  tryRecv<T>(ch: Channel<T>): T | null;
82
91
  checkpoint(): Promise<void>;
83
92
  complete<T>(value: T): void;
@@ -85,6 +94,13 @@ export declare class ContextImpl implements Context<ContextMemory> {
85
94
  get completionValue(): unknown;
86
95
  get abortReason(): string | undefined;
87
96
  abort(reason?: string): void;
97
+ /**
98
+ * @internal
99
+ * Abort signal scoped to this context. Channel operations register on it
100
+ * so `abort()` rejects them promptly. Not part of the public Context
101
+ * interface.
102
+ */
103
+ get abortSignal(): AbortSignal;
88
104
  /**
89
105
  * @internal
90
106
  * Push a frame onto the execution frontier. Called by `execute()` at the
@@ -48,6 +48,15 @@ export class ContextImpl {
48
48
  _completed = false;
49
49
  _aborted = false;
50
50
  _abortReason;
51
+ /**
52
+ * Per-context abort fan-out. `abort()` fires it so operations blocked on
53
+ * this context (channel `recv` waiters, parked back-pressure senders)
54
+ * reject promptly with `{ kind: 'cancelled' }` instead of hanging until
55
+ * their timeout (spec 09, Cancellation item 2). Each fork/spawn child
56
+ * constructs its own ContextImpl and therefore its own controller —
57
+ * aborting a child never rejects the parent's waiters.
58
+ */
59
+ _abortController = new AbortController();
51
60
  _memory;
52
61
  /**
53
62
  * Stack of steps currently in flight on this context, most-recent last.
@@ -110,13 +119,18 @@ export class ContextImpl {
110
119
  if (!this.channelStore) {
111
120
  return Promise.reject(new Error('No channel store configured'));
112
121
  }
113
- return this.channelStore.recv(ch, opts?.timeout);
122
+ return this.channelStore.recv(ch, opts?.timeout, this._abortController.signal);
114
123
  }
115
124
  send(ch, value) {
116
125
  if (!this.channelStore) {
117
- throw new Error('No channel store configured');
126
+ return Promise.reject(new Error('No channel store configured'));
118
127
  }
119
- this.channelStore.send(ch, value);
128
+ // Internal sender: back-pressured on full queue channels (default 30s →
129
+ // channel_timeout); aborting this context rejects a parked send with
130
+ // 'cancelled' via the shared abort signal.
131
+ return this.channelStore.send(ch, value, {
132
+ signal: this._abortController.signal,
133
+ });
120
134
  }
121
135
  tryRecv(ch) {
122
136
  if (!this.channelStore) {
@@ -145,6 +159,19 @@ export class ContextImpl {
145
159
  abort(reason) {
146
160
  this._aborted = true;
147
161
  this._abortReason = reason;
162
+ // Reject everything blocked on this context (channel recv waiters,
163
+ // parked back-pressure senders) with { kind: 'cancelled' }. Idempotent —
164
+ // a second abort() leaves the already-aborted signal untouched.
165
+ this._abortController.abort(reason ?? 'context aborted');
166
+ }
167
+ /**
168
+ * @internal
169
+ * Abort signal scoped to this context. Channel operations register on it
170
+ * so `abort()` rejects them promptly. Not part of the public Context
171
+ * interface.
172
+ */
173
+ get abortSignal() {
174
+ return this._abortController.signal;
148
175
  }
149
176
  /**
150
177
  * @internal
@@ -5,11 +5,14 @@
5
5
  * and without the agent asking for external input — i.e. the agent
6
6
  * stopped without calling either a terminal tool or an ask-user-style
7
7
  * tool. That's a soft failure: rather than wait forever, we nudge the
8
- * agent once to remind it of its options. If the second turn also
9
- * ends in the same stalled shape we escalate: the caller-supplied
10
- * `onStall` callback records the escalation (task pause, metrics,
11
- * whatever the domain demands) and we settle the signal ourselves with
12
- * a canonical "stalled" outcome so the parent unwinds cleanly.
8
+ * agent once to remind it of its options and wait for the nudged turn
9
+ * to actually complete (`harness.execute` is enqueue-only, so the hook
10
+ * awaits `getAgentResponse` — raced against the signal — before
11
+ * re-checking). If the second turn also ends in the same stalled shape
12
+ * we escalate: the caller-supplied `onStall` callback records the
13
+ * escalation (task pause, metrics, whatever the domain demands) and we
14
+ * settle the signal ourselves with a canonical "stalled" outcome so
15
+ * the parent unwinds cleanly.
13
16
  *
14
17
  * The helper factors the state machine out of the loop so callers can
15
18
  * opt in per-runner. Task-specific concerns (how "pending external
@@ -74,9 +77,13 @@ export declare function createNudgeMessage(opts: CreateNudgeMessageOpts): InputM
74
77
  * - the caller reports pending external input (`hasPendingExternal`
75
78
  * returns true — e.g. an outstanding ask-user request).
76
79
  *
77
- * Otherwise it sends the nudge message via `harness.execute`, waits
78
- * for microtasks to flush, and re-checks the same two conditions. If
79
- * the agent still hasn't progressed, the hook fires `onStall` and
80
- * resolves the signal with `buildStalledOutcome()`.
80
+ * Otherwise it sends the nudge message via `harness.execute` and waits
81
+ * for the nudged turn to actually complete. `execute()` only enqueues
82
+ * the message — the turn runs asynchronously through the session
83
+ * runner — so the hook awaits `harness.getAgentResponse(threadId)`
84
+ * (raced against `signal.done` so a terminal tool that settles the
85
+ * signal mid-turn short-circuits the wait) before re-checking the same
86
+ * two conditions. If the agent still hasn't progressed, the hook fires
87
+ * `onStall` and resolves the signal with `buildStalledOutcome()`.
81
88
  */
82
89
  export declare function createStallNudgeHook<TOutcome>(opts: StallNudgeOpts<TOutcome>): (ctx: AfterFirstTurnContext) => Promise<void>;
@@ -5,11 +5,14 @@
5
5
  * and without the agent asking for external input — i.e. the agent
6
6
  * stopped without calling either a terminal tool or an ask-user-style
7
7
  * tool. That's a soft failure: rather than wait forever, we nudge the
8
- * agent once to remind it of its options. If the second turn also
9
- * ends in the same stalled shape we escalate: the caller-supplied
10
- * `onStall` callback records the escalation (task pause, metrics,
11
- * whatever the domain demands) and we settle the signal ourselves with
12
- * a canonical "stalled" outcome so the parent unwinds cleanly.
8
+ * agent once to remind it of its options and wait for the nudged turn
9
+ * to actually complete (`harness.execute` is enqueue-only, so the hook
10
+ * awaits `getAgentResponse` — raced against the signal — before
11
+ * re-checking). If the second turn also ends in the same stalled shape
12
+ * we escalate: the caller-supplied `onStall` callback records the
13
+ * escalation (task pause, metrics, whatever the domain demands) and we
14
+ * settle the signal ourselves with a canonical "stalled" outcome so
15
+ * the parent unwinds cleanly.
13
16
  *
14
17
  * The helper factors the state machine out of the loop so callers can
15
18
  * opt in per-runner. Task-specific concerns (how "pending external
@@ -55,10 +58,14 @@ export function createNudgeMessage(opts) {
55
58
  * - the caller reports pending external input (`hasPendingExternal`
56
59
  * returns true — e.g. an outstanding ask-user request).
57
60
  *
58
- * Otherwise it sends the nudge message via `harness.execute`, waits
59
- * for microtasks to flush, and re-checks the same two conditions. If
60
- * the agent still hasn't progressed, the hook fires `onStall` and
61
- * resolves the signal with `buildStalledOutcome()`.
61
+ * Otherwise it sends the nudge message via `harness.execute` and waits
62
+ * for the nudged turn to actually complete. `execute()` only enqueues
63
+ * the message — the turn runs asynchronously through the session
64
+ * runner — so the hook awaits `harness.getAgentResponse(threadId)`
65
+ * (raced against `signal.done` so a terminal tool that settles the
66
+ * signal mid-turn short-circuits the wait) before re-checking the same
67
+ * two conditions. If the agent still hasn't progressed, the hook fires
68
+ * `onStall` and resolves the signal with `buildStalledOutcome()`.
62
69
  */
63
70
  export function createStallNudgeHook(opts) {
64
71
  return async (ctx) => {
@@ -72,7 +79,19 @@ export function createStallNudgeHook(opts) {
72
79
  await opts.harness.execute(opts.nudgeMessage, {
73
80
  threadId: opts.threadId,
74
81
  });
75
- await Promise.resolve();
82
+ // Wait for the nudged turn to genuinely finish. A one-microtask wait
83
+ // would escalate before the agent's LLM round trip even starts —
84
+ // `execute()` resolves as soon as the message is enqueued. Errors from
85
+ // `getAgentResponse` are swallowed: a failed response fetch must not
86
+ // crash the hook, it just means we proceed to the stall re-check.
87
+ await Promise.race([
88
+ opts.harness
89
+ .getAgentResponse({
90
+ threadId: opts.threadId,
91
+ })
92
+ .catch(() => undefined),
93
+ opts.signal.done.then(() => undefined, () => undefined),
94
+ ]);
76
95
  if (ctx.signalSettled()) {
77
96
  return;
78
97
  }
@@ -45,7 +45,13 @@ export declare class EventBroadcaster {
45
45
  }
46
46
  declare class BroadcastIterator implements AsyncIterator<StreamEvent> {
47
47
  private cursor;
48
- private waiter?;
48
+ /**
49
+ * Parked `next()` calls, FIFO. The AsyncIterator protocol allows pipelined
50
+ * `next()` calls (calling again before the previous promise settles), so a
51
+ * single waiter slot would orphan all but the latest call — the overwritten
52
+ * promises would never settle. `notify()` drains in arrival order.
53
+ */
54
+ private readonly waiters;
49
55
  private returned;
50
56
  private readonly broadcaster;
51
57
  constructor(broadcaster: EventBroadcaster);
@@ -54,6 +60,8 @@ declare class BroadcastIterator implements AsyncIterator<StreamEvent> {
54
60
  /** Called by the broadcaster when new data or completion is available. */
55
61
  notify(): void;
56
62
  next(): Promise<IteratorResult<StreamEvent>>;
63
+ /** Settle every parked waiter with `{ done: true }` — used on early exit. */
64
+ private settleWaiters;
57
65
  throw(err: Error): Promise<IteratorResult<StreamEvent>>;
58
66
  return(): Promise<IteratorResult<StreamEvent>>;
59
67
  private tryRead;