@botbuddy/cli 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/wait.mjs +70 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/wait.mjs CHANGED
@@ -399,6 +399,14 @@ function makeConnect(opts) {
399
399
  // the agent (and its locks) aren't reaped during a long wait.
400
400
  if (opts.heartbeat) url.searchParams.set("heartbeat", "1");
401
401
 
402
+ // BOT-1565: abort the fetch itself on idle. A silently half-open SSE body
403
+ // already has an `iterator.next()` pending, and `iterator.return()` queues
404
+ // BEHIND that read — it cannot cancel it until bytes/EOF eventually arrive,
405
+ // so it would leave the response locked and the socket open while we
406
+ // reconnect, leaking a connection per stall (Codex P2). Aborting the fetch's
407
+ // signal tears the stalled socket down immediately, then the read rejects and
408
+ // the stream ends so runWaitLoop reconnects.
409
+ const ac = new AbortController();
402
410
  const res = await fetch(url, {
403
411
  headers: {
404
412
  Authorization: `Bearer ${opts.token}`,
@@ -407,12 +415,13 @@ function makeConnect(opts) {
407
415
  // BOT-741: never let a proxy gzip-buffer an SSE stream.
408
416
  "Accept-Encoding": "identity",
409
417
  },
418
+ signal: ac.signal,
410
419
  });
411
420
  if (res.status === 401) return errorStream("unauthorized");
412
421
  if (res.status === 403) return errorStream("forbidden");
413
422
  if (!res.ok || !res.body) throw new Error(`relay responded ${res.status}`);
414
423
 
415
- return sseFrameStream(res.body);
424
+ return sseFrameStream(res.body, { onIdle: () => ac.abort() });
416
425
  };
417
426
  }
418
427
 
@@ -468,14 +477,68 @@ function makeFeedLagProbe(opts) {
468
477
  };
469
478
  }
470
479
 
471
- async function* sseFrameStream(body) {
480
+ // BOT-1565: a silently half-open SSE socket (a network blip, or a gateway/edge
481
+ // connection-lifetime ceiling ~45–51 min) delivers no bytes AND no end-of-stream,
482
+ // so a bare `for await` here would block forever and never reconnect. Meanwhile
483
+ // the relay's last_seen_at keepalive stops bumping and the wait reaper abandons
484
+ // the still-parked wait after its grace → /waits goes blank (BOT-1565). Guard
485
+ // every read with an IDLE watchdog: the relay sends a keepalive comment every
486
+ // ~30s (EVENT_STREAM_KEEPALIVE_MS), and every chunk (comment or frame) resets
487
+ // the timer, so a healthy-but-quiet connection never trips it. If NOTHING
488
+ // arrives for idleMs the connection is presumed dead: end the stream so
489
+ // runWaitLoop reconnects from the cursor, which re-bumps wait_sessions.last_seen_at
490
+ // (and, with --heartbeat, agents.last_heartbeat) before the reaper's grace.
491
+ // 70s ≈ 2.3 missed keepalives — long enough to never false-trip on jitter, short
492
+ // enough that the reconnect lands inside the 90s last_seen reap window.
493
+ export const DEFAULT_SSE_IDLE_TIMEOUT_MS = 70_000;
494
+
495
+ function sseIdleTimeoutMs() {
496
+ const raw = Number(process.env.BOTBUDDY_WAIT_IDLE_TIMEOUT_MS);
497
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SSE_IDLE_TIMEOUT_MS;
498
+ }
499
+
500
+ const SSE_IDLE = Symbol("sse_idle");
501
+
502
+ export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdle = null } = {}) {
472
503
  const decoder = new TextDecoder();
473
504
  let buf = "";
474
- for await (const chunk of body) {
475
- buf += decoder.decode(chunk, { stream: true });
476
- const { frames, rest } = parseSseFrames(buf);
477
- buf = rest;
478
- for (const f of frames) yield f;
505
+ const iterator = body[Symbol.asyncIterator]();
506
+ try {
507
+ while (true) {
508
+ const nextP = iterator.next();
509
+ let timer;
510
+ const idle = new Promise((resolve) => { timer = setTimeout(() => resolve(SSE_IDLE), idleMs); });
511
+ let result;
512
+ try {
513
+ result = await Promise.race([nextP, idle]);
514
+ } finally {
515
+ clearTimeout(timer);
516
+ }
517
+ if (result === SSE_IDLE) {
518
+ // Presumed dead. onIdle() aborts the underlying fetch (makeConnect wires
519
+ // it to the request's AbortController) — the ONLY thing that actually
520
+ // tears a stalled socket down, since iterator.return() would queue behind
521
+ // the pending read and never fire until bytes/EOF arrive (Codex P2). The
522
+ // abort settles the pending read (rejects with AbortError); swallow it so
523
+ // it isn't an unhandled rejection, then end the stream to force a reconnect.
524
+ nextP.then(() => {}, () => {});
525
+ if (onIdle) onIdle();
526
+ return;
527
+ }
528
+ const { value: chunk, done } = result;
529
+ if (done) return;
530
+ buf += decoder.decode(chunk, { stream: true });
531
+ const { frames, rest } = parseSseFrames(buf);
532
+ buf = rest;
533
+ for (const f of frames) yield f;
534
+ }
535
+ } finally {
536
+ // Belt-and-braces release for the non-idle early-stop path (the consumer
537
+ // stopped iterating, e.g. runWaitLoop matched). On the idle path the fetch
538
+ // has already been aborted above, so the body is torn down regardless; this
539
+ // return() then resolves promptly instead of queuing behind a live read.
540
+ // Fire-and-forget + swallow: never let teardown block the generator's exit.
541
+ Promise.resolve(iterator.return?.()).then(() => {}, () => {});
479
542
  }
480
543
  }
481
544