@haikit/server 0.18.0 → 0.20.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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The channel signals and presence travel on between servers, through the
3
+ * store's `publish` and `subscribe`. Those are keyed by conversation; this
4
+ * name has a colon, which no conversation id a store issues has, so it can't
5
+ * be mistaken for one.
6
+ */
7
+ export const SIGNAL_CHANNEL = "haikit:signals";
8
+ /**
9
+ * What one Hai broadcasts, and who is watching: every open events stream in
10
+ * this process, each signal's latest payload, and how many conversations have
11
+ * a stream open, here and on the other servers sharing the store.
12
+ */
13
+ export class Broadcast {
14
+ store;
15
+ graceMs;
16
+ heartbeatMs;
17
+ /** Every events stream open in this process. */
18
+ streams = new Set();
19
+ /** Each signal's latest payload, by name, for a stream that opens later. */
20
+ latest = new Map();
21
+ /** Conversations with a stream open here, and how many. */
22
+ open = new Map();
23
+ /** Conversations whose last stream closed, still counted until the grace ends. */
24
+ leaving = new Map();
25
+ /** The other servers' counts, by origin, with when each was last heard. */
26
+ peers = new Map();
27
+ listeners = new Set();
28
+ total = 0;
29
+ /** The local count other servers last heard from this one. */
30
+ told = 0;
31
+ started = false;
32
+ /** Settles once this server hears the others: before then, what they send is missed. */
33
+ hearing = Promise.resolve();
34
+ /** A publish to the other servers in flight, and what waits behind it: the latest of each kind only. */
35
+ publishing = false;
36
+ unsent = new Map();
37
+ origin = globalThis.crypto.randomUUID();
38
+ constructor(store, graceMs, heartbeatMs) {
39
+ this.store = store;
40
+ this.graceMs = graceMs;
41
+ this.heartbeatMs = heartbeatMs;
42
+ }
43
+ /** Send a signal to every stream, here and on the other servers, and keep it for later ones. */
44
+ send(event) {
45
+ this.latest.set(event.name, event);
46
+ for (const send of this.streams)
47
+ send(event);
48
+ this.publish({ kind: "signal", event });
49
+ }
50
+ /**
51
+ * An events stream opened for `conversationId`: it hears signals from now
52
+ * on, through `emit`, starting with each one's latest payload, through
53
+ * `replay`; and the conversation counts as present. Call what this returns
54
+ * when the stream closes.
55
+ */
56
+ opened(conversationId, emit, replay = emit) {
57
+ this.start();
58
+ this.streams.add(emit);
59
+ let closed = false;
60
+ // Each signal's latest, once this server hears the others, so one they
61
+ // sent is among them. A live one may come first; the latest is still the
62
+ // latest, so at worst one arrives twice.
63
+ void this.hearing.then(() => {
64
+ if (!closed)
65
+ for (const event of this.latest.values())
66
+ replay(event);
67
+ });
68
+ const wasLeaving = this.leaving.get(conversationId);
69
+ if (wasLeaving !== undefined) {
70
+ // back within the grace: it never stopped counting
71
+ clearTimeout(wasLeaving);
72
+ this.leaving.delete(conversationId);
73
+ }
74
+ const before = this.open.get(conversationId) ?? 0;
75
+ this.open.set(conversationId, before + 1);
76
+ if (before === 0 && wasLeaving === undefined)
77
+ this.changed();
78
+ return () => {
79
+ if (closed)
80
+ return;
81
+ closed = true;
82
+ this.streams.delete(emit);
83
+ const left = (this.open.get(conversationId) ?? 1) - 1;
84
+ if (left > 0) {
85
+ this.open.set(conversationId, left);
86
+ return;
87
+ }
88
+ this.open.delete(conversationId);
89
+ // A browser that drops its stream reconnects within a second or two:
90
+ // keep counting it a little longer, so the number doesn't flicker.
91
+ const timer = setTimeout(() => {
92
+ this.leaving.delete(conversationId);
93
+ this.changed();
94
+ }, this.graceMs);
95
+ timer.unref?.();
96
+ this.leaving.set(conversationId, timer);
97
+ };
98
+ }
99
+ /** Hear the total now and whenever it changes. Returns a function that stops. */
100
+ onPresence(listener) {
101
+ this.start();
102
+ this.listeners.add(listener);
103
+ try {
104
+ listener(this.total);
105
+ }
106
+ catch (err) {
107
+ // never handed its way to stop, so it must not stay
108
+ this.listeners.delete(listener);
109
+ throw err;
110
+ }
111
+ return () => void this.listeners.delete(listener);
112
+ }
113
+ /** Conversations with a stream open in this process, or within the grace. */
114
+ get local() {
115
+ return this.open.size + this.leaving.size;
116
+ }
117
+ /**
118
+ * Start hearing the other servers, and telling them this one's count, once.
119
+ * A Hai that sends signals starts as it is created, so it has the others'
120
+ * latest values before its first stream opens; any other, when one does.
121
+ */
122
+ start() {
123
+ if (this.started)
124
+ return;
125
+ this.started = true;
126
+ // For as long as this Hai lives. A store that can't listen still resolves,
127
+ // and hears nothing: signals and presence then stay on this server.
128
+ const subscribed = this.store.subscribe?.(SIGNAL_CHANNEL, (message) => this.hear(message), new AbortController().signal);
129
+ if (subscribed)
130
+ this.hearing = Promise.resolve(subscribed).catch(() => { });
131
+ const beat = setInterval(() => this.beat(), this.heartbeatMs);
132
+ beat.unref?.();
133
+ }
134
+ hear(message) {
135
+ let heard;
136
+ try {
137
+ heard = JSON.parse(message);
138
+ }
139
+ catch {
140
+ return;
141
+ }
142
+ if (!heard || heard.origin === this.origin || typeof heard.origin !== "string")
143
+ return;
144
+ if (heard.kind === "signal" && heard.event?.type === "signal" && typeof heard.event.name === "string") {
145
+ this.latest.set(heard.event.name, heard.event);
146
+ for (const send of this.streams)
147
+ send(heard.event);
148
+ }
149
+ else if (heard.kind === "presence" && Number.isSafeInteger(heard.count) && heard.count >= 0) {
150
+ // a count is a number of conversations: a whole number, never Infinity
151
+ const known = this.peers.has(heard.origin);
152
+ const count = heard.count;
153
+ if (count > 0)
154
+ this.peers.set(heard.origin, { count, heard: Date.now() });
155
+ else
156
+ this.peers.delete(heard.origin);
157
+ // a server it hadn't heard from yet hasn't heard from it either
158
+ if (!known && count > 0 && this.local > 0)
159
+ this.publish({ kind: "presence", count: this.local });
160
+ this.recount();
161
+ }
162
+ }
163
+ /** This server's count changed: tell the listeners, and the other servers. */
164
+ changed() {
165
+ this.recount();
166
+ if (this.local !== this.told) {
167
+ this.told = this.local;
168
+ this.publish({ kind: "presence", count: this.local });
169
+ }
170
+ }
171
+ /** On a timer: drop servers gone quiet, and remind the others this one is here. */
172
+ beat() {
173
+ this.recount();
174
+ if (this.local > 0)
175
+ this.publish({ kind: "presence", count: this.local });
176
+ }
177
+ recount() {
178
+ // three missed beats and a server's count no longer counts: it has gone
179
+ const stale = Date.now() - 3 * this.heartbeatMs;
180
+ let others = 0;
181
+ for (const [origin, peer] of this.peers) {
182
+ if (peer.heard < stale)
183
+ this.peers.delete(origin);
184
+ else
185
+ others += peer.count;
186
+ }
187
+ // a sum of whole counts, kept a safe integer however large the peers claim to be
188
+ const total = Math.min(this.local + others, Number.MAX_SAFE_INTEGER);
189
+ if (total === this.total)
190
+ return;
191
+ this.total = total;
192
+ for (const listener of [...this.listeners]) {
193
+ try {
194
+ listener(total);
195
+ }
196
+ catch {
197
+ // one listener's mistake doesn't stop the others hearing
198
+ }
199
+ }
200
+ }
201
+ /**
202
+ * Tell the other servers. One publish is in flight at a time, and only the
203
+ * latest of each signal, and of this server's count, waits behind it: a
204
+ * burst of signals can't queue every obsolete payload in the store.
205
+ */
206
+ publish(body) {
207
+ if (!this.store.publish)
208
+ return;
209
+ this.unsent.delete(body.kind === "signal" ? `signal:${body.event.name}` : "presence");
210
+ this.unsent.set(body.kind === "signal" ? `signal:${body.event.name}` : "presence", body);
211
+ if (!this.publishing)
212
+ void this.flush();
213
+ }
214
+ async flush() {
215
+ this.publishing = true;
216
+ while (this.unsent.size) {
217
+ const [key, body] = this.unsent.entries().next().value;
218
+ this.unsent.delete(key);
219
+ try {
220
+ await this.store.publish(SIGNAL_CHANNEL, JSON.stringify({ origin: this.origin, ...body }));
221
+ }
222
+ catch {
223
+ // best effort: the other servers miss this one, and the next beat corrects presence
224
+ }
225
+ }
226
+ this.publishing = false;
227
+ }
228
+ }
229
+ //# sourceMappingURL=signals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signals.js","sourceRoot":"","sources":["../src/signals.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/server",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "The haikit server runtime: agent loop, elicit state machine, derived query_ui, and the interaction security boundary.",
5
5
  "keywords": [
6
6
  "haikit",
@@ -36,7 +36,7 @@
36
36
  "prepack": "npm --prefix ../.. run build"
37
37
  },
38
38
  "dependencies": {
39
- "@haikit/core": "0.18.0",
39
+ "@haikit/core": "0.20.0",
40
40
  "@types/node": ">=22"
41
41
  },
42
42
  "publishConfig": {
package/src/routes.ts CHANGED
@@ -22,7 +22,7 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
22
22
  if (!url.pathname.startsWith(basePath)) return false;
23
23
  const route = url.pathname.slice(basePath.length);
24
24
 
25
- if (req.method === "GET" && route === "/events" && hai.sendsNotices) {
25
+ if (req.method === "GET" && route === "/events" && hai.streamsEvents) {
26
26
  await streamNotices(hai, req, res, url);
27
27
  return true;
28
28
  }
@@ -59,7 +59,7 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
59
59
  type: "hello",
60
60
  conversationId: conversation.id,
61
61
  model: hai.config.model.id,
62
- ...(hai.sendsNotices ? { events: true as const } : {}),
62
+ ...(hai.streamsEvents ? { events: true as const } : {}),
63
63
  });
64
64
  }
65
65
 
@@ -111,6 +111,27 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
111
111
  * this is what still finds a notice whose wake-up was lost.
112
112
  */
113
113
  const POLL_MS = 2_000;
114
+ /**
115
+ * A short fingerprint of signal values, by name: what a polling browser says it
116
+ * has, so a poll knows whether the latest values are news to it. The client
117
+ * computes the same (packages/client/src/index.js); change both together.
118
+ */
119
+ export function signalsDigest(entries: [string, unknown][]): string {
120
+ const text = JSON.stringify([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
121
+ let hash = 0x811c9dc5;
122
+ for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
123
+ return (hash >>> 0).toString(16);
124
+ }
125
+
126
+ /** How long a process keeps hearing a conversation's turns after its last stream closes. */
127
+ const WATCH_LINGER_MS = 60_000;
128
+ /** How long a wake turn that has ended is kept, for a poll that missed its end, and how many at most. */
129
+ const ENDED_TURN_KEPT_MS = 60_000;
130
+ const ENDED_TURNS_KEPT = 5;
131
+ /** The longest a poll holds when there is nothing new to send. */
132
+ const POLL_HOLD_MS = 15_000;
133
+ /** How long a poll waits after something new, for what comes with it, before it ends. */
134
+ const POLL_GATHER_MS = 250;
114
135
  /** Proxies drop a stream that says nothing for long; a comment keeps it open. */
115
136
  const HEARTBEAT_MS = 25_000;
116
137
 
@@ -132,6 +153,18 @@ const PAGE = 100;
132
153
  async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse, url: URL) {
133
154
  const { store } = hai.config;
134
155
  const conversationId = url.searchParams.get("conversationId") ?? "";
156
+ // A poll, for a browser behind a proxy that holds a stream back until it
157
+ // ends: what's waiting, then the response ends, which any proxy delivers.
158
+ // `poll=now` answers at once; `poll=1` holds until something new arrives,
159
+ // or for `wait` ms (at most POLL_HOLD_MS) if nothing does.
160
+ const poll = url.searchParams.get("poll");
161
+ const polling = poll === "1" || poll === "now";
162
+ // a fingerprint of the signal values the browser already has (signalsDigest)
163
+ const hasSignals = url.searchParams.get("signals");
164
+ // the last wake turn the browser saw end, by its number
165
+ const sawTurn = Number(url.searchParams.get("turn") ?? 0);
166
+ const asked = Number(url.searchParams.get("wait") ?? POLL_HOLD_MS);
167
+ const holdMs = Number.isFinite(asked) ? Math.min(Math.max(asked, 0), POLL_HOLD_MS) : POLL_HOLD_MS;
135
168
  const resume = Number(req.headers["last-event-id"] ?? url.searchParams.get("after") ?? 0);
136
169
  let after = Number.isSafeInteger(resume) && resume > 0 ? resume : 0;
137
170
 
@@ -147,6 +180,7 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
147
180
  // Counted as watching from here, so nothing lets the entry go while this
148
181
  // stream is still being set up.
149
182
  const watchers = watchersOf(hai, conversationId);
183
+ watchers.stay();
150
184
  watchers.pending++;
151
185
  try {
152
186
  await watchers.ready;
@@ -166,8 +200,11 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
166
200
  connection: "keep-alive",
167
201
  });
168
202
  // Sent now, not with the first frame: with nothing to catch up on, that is a
169
- // heartbeat away, and the browser's request would hang open until then.
203
+ // heartbeat away, and the browser's request would hang open until then. A
204
+ // comment with them, so the browser hears its first bytes at once: one that
205
+ // hears nothing knows something on the way is holding the stream, and polls.
170
206
  res.flushHeaders();
207
+ res.write(": open\n\n");
171
208
  const aborter = new AbortController();
172
209
  const { signal } = aborter;
173
210
  res.on("close", () => aborter.abort());
@@ -186,21 +223,84 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
186
223
  });
187
224
  };
188
225
 
189
- // A wake turn's events go out as they come, one frame each, and a stream
190
- // the browser has left takes no more.
191
- const emit: Emit = (event) => {
226
+ // Something new for this browser: a poll ends a moment after it, to gather
227
+ // what comes with it.
228
+ let markFresh = () => {};
229
+ const freshened = new Promise<void>((resolve) => (markFresh = resolve));
230
+
231
+ // A frame out, and a stream the browser has left takes no more.
232
+ const send = (event: WireEvent) => {
192
233
  if (!signal.aborted && !res.writableEnded) res.write(`data: ${JSON.stringify(event)}\n\n`);
193
234
  };
235
+ // A wake turn's events go out as they come, one frame each.
236
+ const emit: Emit = (event) => {
237
+ send(event);
238
+ markFresh();
239
+ };
194
240
  // Every stream this process has open for the conversation hears a wake
195
241
  // turn, not only the one whose attempt won the lease: another tab shows the
196
242
  // reply too, and holds its requests until the turn lets go.
197
243
  watchers.streams.add(emit);
244
+ // Signals from here on, starting with each one's latest; and it counts as
245
+ // present. A signal only matters as its latest value, so a reader that has
246
+ // fallen behind is sent just the newest of each once it catches up: a
247
+ // stalled socket holds at most one per signal, never every one sent.
248
+ const behind = new Map<string, WireEvent>();
249
+ let draining = false;
250
+ const flush = () => {
251
+ draining = false;
252
+ const pending = [...behind.values()];
253
+ behind.clear();
254
+ for (const event of pending) emitSignal(event);
255
+ };
256
+ const emitSignal: Emit = (event) => {
257
+ if (event.type !== "signal") return emit(event);
258
+ if (signal.aborted || res.writableEnded) return;
259
+ if (res.writableNeedDrain || behind.size) {
260
+ behind.set(event.name, event);
261
+ if (!draining) {
262
+ draining = true;
263
+ res.once("drain", flush);
264
+ }
265
+ return;
266
+ }
267
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
268
+ };
269
+ // A new signal is news. Each one's latest, sent as the stream opens, is
270
+ // news to a poll only if the browser doesn't have those values yet, such as
271
+ // one sent between two polls: every poll gets them, and a poll that always
272
+ // ended on them would never wait.
273
+ const replayed: [string, unknown][] = [];
274
+ const closeStream = hai.streamOpened(
275
+ conversationId,
276
+ (event) => {
277
+ emitSignal(event);
278
+ markFresh();
279
+ },
280
+ (event) => {
281
+ emitSignal(event);
282
+ if (!polling || event.type !== "signal") return;
283
+ // the latest are sent one after another, at once: compare once all are in
284
+ if (!replayed.length) queueMicrotask(() => signalsDigest(replayed) !== hasSignals && markFresh());
285
+ replayed.push([event.name, event.payload]);
286
+ },
287
+ );
198
288
  // Joining while a wake turn runs: what it has shown so far, starting with
199
289
  // its `streaming` status, so the browser holds requests until it lets go,
200
290
  // and the reply's later text has a block to land in.
201
291
  // A surface it shows is sent with its age, so the browser counts its
202
292
  // freshness from when the surface was stored, not from now.
203
- if (watchers.live) for (const event of watchers.shown) emit(aged(event));
293
+ if (watchers.live) for (const event of watchers.shown) send(aged(event));
294
+ else if (polling) {
295
+ // A turn that ended between two polls, unseen: all it showed, its end
296
+ // included, which is news. Then that no turn is running, which a browser
297
+ // that saw nothing of one needs too, or it would wait for it for ever.
298
+ // every one it missed, oldest first, if more than one ended meanwhile
299
+ const missed = Number.isSafeInteger(sawTurn) ? watchers.ended.filter((turn) => turn.number > sawTurn) : [];
300
+ for (const turn of missed) for (const event of turn.shown) send(aged(event));
301
+ if (missed.length) markFresh();
302
+ send({ type: "released" });
303
+ }
204
304
 
205
305
  // Wake turns run beside the notice loop, so a notice never waits behind a
206
306
  // turn, nor behind the retries while another request holds the
@@ -273,6 +373,7 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
273
373
  if (n.replaces !== undefined && n.handle !== undefined) {
274
374
  await sendRevision(n.handle, n.replaces, n.seq);
275
375
  after = n.seq;
376
+ markFresh();
276
377
  woken ||= n.kind === "wake";
277
378
  continue;
278
379
  }
@@ -286,6 +387,7 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
286
387
  };
287
388
  await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
288
389
  after = n.seq;
390
+ markFresh();
289
391
  woken ||= n.kind === "wake";
290
392
  }
291
393
  if (records.length < PAGE || signal.aborted) break;
@@ -296,8 +398,15 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
296
398
 
297
399
  // a heartbeat is only for a stream gone quiet, never one already backed up
298
400
  const heartbeat = setInterval(() => res.writableNeedDrain || res.write(": ping\n\n"), HEARTBEAT_MS);
299
- const wakes = wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]();
300
- try {
401
+ // An app that sends no notices has nothing stored to read: the stream is only
402
+ // for signals, and stays open, reading nothing, until the browser leaves.
403
+ const wakes = hai.sendsNotices ? wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]() : null;
404
+ const left = new Promise<void>((resolve) => {
405
+ if (signal.aborted) resolve();
406
+ else signal.addEventListener("abort", () => resolve(), { once: true });
407
+ });
408
+ const run = async () => {
409
+ if (!wakes) return left;
301
410
  await catchUp(first);
302
411
  // A wake notice may be waiting from before this browser connected: one
303
412
  // that arrived while nobody watched, or while the conversation was busy.
@@ -311,12 +420,28 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
311
420
  next = wakes.next();
312
421
  await catchUp();
313
422
  }
423
+ };
424
+ try {
425
+ if (!polling) return await run();
426
+ // A poll: the same reading, until something new has gone out and what
427
+ // came with it has had a moment to follow, or the hold runs out.
428
+ if (poll === "now") markFresh();
429
+ let hold: ReturnType<typeof setTimeout> | undefined;
430
+ const done = Promise.race([
431
+ left,
432
+ freshened.then(() => new Promise<void>((resolve) => setTimeout(resolve, POLL_GATHER_MS))),
433
+ new Promise<void>((resolve) => (hold = setTimeout(resolve, holdMs))),
434
+ ]);
435
+ void run().catch(() => {});
436
+ await done;
437
+ clearTimeout(hold);
314
438
  } finally {
315
439
  clearInterval(heartbeat);
316
440
  aborter.abort();
441
+ closeStream();
317
442
  watchers.streams.delete(emit);
318
443
  if (!watchers.streams.size) forgetWatchers(hai, conversationId, watchers);
319
- await wakes.return?.();
444
+ await wakes?.return?.();
320
445
  res.end();
321
446
  }
322
447
  }
@@ -335,6 +460,13 @@ interface Watchers {
335
460
  * then everything else in order. A turn's worth, dropped when it ends.
336
461
  */
337
462
  shown: WireEvent[];
463
+ /**
464
+ * The wake turns that ended in the past minute, the last few, oldest first:
465
+ * each one's number and everything it showed, `released` included. A poll
466
+ * is sent each one its browser hasn't seen end, since they may have ended
467
+ * between two polls.
468
+ */
469
+ ended: { number: number; shown: WireEvent[] }[];
338
470
  /**
339
471
  * An emitter for one wake attempt in this process: its frames go to every
340
472
  * stream here and to the other processes, marked as one turn.
@@ -344,6 +476,14 @@ interface Watchers {
344
476
  pending: number;
345
477
  /** Let this entry go, and stop hearing other processes for it. */
346
478
  forget: () => void;
479
+ /**
480
+ * A stream for the conversation opened: stop lingering. And its last stream
481
+ * closed: keep hearing other processes a while, then let go. A browser that
482
+ * polls comes back in moments, perhaps to another server, and a wake turn
483
+ * that ends meanwhile, wherever it ran, has to be here to replay.
484
+ */
485
+ stay: () => void;
486
+ leave: () => void;
347
487
  /** Settles once this process is listening for other processes' turns. */
348
488
  ready: Promise<void>;
349
489
  }
@@ -426,8 +566,9 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
426
566
  const origin = originOf(hai);
427
567
  // Hearing other processes' wake turns lasts as long as this entry does.
428
568
  const hearing = new AbortController();
569
+ let lingering: ReturnType<typeof setTimeout> | null = null;
429
570
  const forget = () => {
430
- if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live) return;
571
+ if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live || fresh.ended.length || lingering) return;
431
572
  map.delete(conversationId);
432
573
  hearing.abort();
433
574
  };
@@ -442,12 +583,14 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
442
583
  // start. A turn takes over only with a number at least as high as any
443
584
  // seen; anything but the current turn's frames is dropped.
444
585
  let current: string | null = null;
586
+ let currentNumber: number | undefined;
445
587
  let newest = 0;
446
588
  const deliver = (event: WireEvent, turn: string, number: number | undefined) => {
447
589
  if (event.type === "status" && event.status === "streaming") {
448
590
  if (current !== turn) {
449
591
  if (number !== undefined && number < newest) return; // an older turn's late start
450
592
  current = turn;
593
+ currentNumber = number;
451
594
  if (number !== undefined) newest = number;
452
595
  fresh.live = true;
453
596
  fresh.shown = [];
@@ -457,6 +600,15 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
457
600
  }
458
601
  remember(fresh.shown, event);
459
602
  if (event.type === "released") {
603
+ // kept a while for a poll that missed the end, then let go
604
+ if (currentNumber !== undefined) {
605
+ const ended = { number: currentNumber, shown: fresh.shown };
606
+ fresh.ended = [...fresh.ended, ended].slice(-ENDED_TURNS_KEPT);
607
+ setTimeout(() => {
608
+ fresh.ended = fresh.ended.filter((turn) => turn !== ended);
609
+ forget();
610
+ }, ENDED_TURN_KEPT_MS).unref?.();
611
+ }
460
612
  current = null;
461
613
  fresh.live = false;
462
614
  fresh.shown = [];
@@ -470,7 +622,20 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
470
622
  streams: new Set(),
471
623
  live: false,
472
624
  shown: [],
625
+ ended: [],
473
626
  forget,
627
+ stay: () => {
628
+ if (lingering) clearTimeout(lingering);
629
+ lingering = null;
630
+ },
631
+ leave: () => {
632
+ if (lingering) clearTimeout(lingering);
633
+ lingering = setTimeout(() => {
634
+ lingering = null;
635
+ forget();
636
+ }, WATCH_LINGER_MS);
637
+ lingering.unref?.();
638
+ },
474
639
  ready: Promise.resolve(),
475
640
  // A wake turn running here: its frames to this process's streams, and,
476
641
  // through the store, to every other process's. All but `context`, the
@@ -518,8 +683,9 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
518
683
 
519
684
  function forgetWatchers(_hai: Hai, _conversationId: string, watchers: Watchers) {
520
685
  // Kept while a wake turn runs, so a stream that joins later still hears it,
521
- // and while any stream is open or being set up. `forget` checks all three.
522
- watchers.forget();
686
+ // while any stream is open or being set up, while an ended turn is kept, and
687
+ // for a while after the last stream closes. `forget` checks them all.
688
+ watchers.leave();
523
689
  }
524
690
 
525
691
  /** The first wait before trying a wake turn again on a busy conversation, and the longest. */