@botbuddy/cli 1.33.2 → 1.33.3

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/stack.mjs +63 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.33.2",
3
+ "version": "1.33.3",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -713,6 +713,20 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl =
713
713
  return { waitSessionId: body.wait_session_id ?? null, cursorStart: body.cursor_start ?? null };
714
714
  }
715
715
 
716
+ // BOT-1827: the abort reason is always one of these four explicit strings —
717
+ // branch on the reason itself rather than inferring "anything but interrupted
718
+ // is a timeout". "matched"/"failed" are only ever set AFTER the SSE loop has
719
+ // already been left via `break` (never from inside the `for await`), so the
720
+ // controller's own teardown can never race a `{ woke: true }` / `{ failed: true }`
721
+ // return the way it did when `ac.abort()` was called before returning from
722
+ // inside the loop.
723
+ function resultForAbortReason(reason) {
724
+ if (reason === "interrupted") return { result: { interrupted: true }, status: "error" };
725
+ // "timeout", or any other/unset reason (e.g. an externally-triggered AbortError
726
+ // with no reason attached) — defensively treated the same as a real deadline.
727
+ return { result: { timeout: true }, status: "timeout" };
728
+ }
729
+
716
730
  /**
717
731
  * Zero-poll wait for `lease:<leaseId>` to reach a state satisfying `isDone(state)`.
718
732
  * Registers a wait_session (visible on /waits), then blocks on the event-stream SSE.
@@ -751,11 +765,17 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
751
765
  }
752
766
  return result;
753
767
  };
754
- if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
768
+ if (ac.signal.aborted) {
769
+ const { result, status } = resultForAbortReason(ac.signal.reason);
770
+ return finish(result, status);
771
+ }
755
772
  try {
756
773
  ({ cursorStart, waitSessionId } = await registerLeaseWait(leaseId, Math.max(0, Math.ceil((absoluteDeadlineMs - Date.now()) / 1000)), auth, ac.signal, fetchImpl));
757
774
  } catch (err) {
758
- if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
775
+ if (ac.signal.aborted) {
776
+ const { result, status } = resultForAbortReason(ac.signal.reason);
777
+ return finish(result, status);
778
+ }
759
779
  process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
760
780
  }
761
781
  const url = new URL(eventStreamBase());
@@ -767,15 +787,27 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
767
787
  try {
768
788
  res = await fetchImpl(url, { headers: { ...relayAuthHeaders(auth), Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
769
789
  } catch (err) {
770
- if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
790
+ if (ac.signal.aborted) {
791
+ const { result, status } = resultForAbortReason(ac.signal.reason);
792
+ return finish(result, status);
793
+ }
771
794
  return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
772
795
  }
773
796
  if (res.status === 401 || res.status === 403) return finish({ auth: true }, "error");
774
797
  if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}` }, "error");
775
798
  const decoder = new TextDecoder();
776
799
  let buf = "";
800
+ // BOT-1827: record a match/failure locally and `break` out of BOTH loops
801
+ // without touching `ac`/`abort()` from inside the `for await`. Aborting the
802
+ // controller while it is still the signal driving the body iterator makes
803
+ // the implicit IteratorClose (triggered by `break`/`return`) reject with an
804
+ // AbortError, which used to land in the `catch` below and override the
805
+ // already-decided `{ woke: true }` / `{ failed: true }` result with a
806
+ // `{ timeout: true }` — finalizing the wait session twice. Deciding first,
807
+ // aborting only AFTER the loop has been left cleanly, removes the race.
808
+ let decided = null;
777
809
  try {
778
- for await (const chunk of res.body) {
810
+ frameLoop: for await (const chunk of res.body) {
779
811
  buf += decoder.decode(chunk, { stream: true });
780
812
  const { frames, rest } = parseSseFrames(buf);
781
813
  buf = rest;
@@ -784,13 +816,26 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
784
816
  try { sig = JSON.parse(f.data); } catch { continue; }
785
817
  if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
786
818
  const st = sig.payload?.state ?? null;
787
- if (isFailed(st)) { ac.abort(); return finish({ failed: true, state: st }, "error"); }
788
- if (isDone(st)) { ac.abort(); return finish({ woke: true, state: st }, "matched"); }
819
+ if (isFailed(st)) { decided = { reason: "failed", result: { failed: true, state: st }, status: "error" }; break frameLoop; }
820
+ if (isDone(st)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
789
821
  }
790
822
  }
791
823
  } catch (err) {
792
- if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
793
- return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
824
+ // A decided result always wins over a teardown error surfaced by leaving
825
+ // the loop (e.g. the body iterator's `return()` rejecting during
826
+ // cleanup) — fall through to the `decided` handling below instead of
827
+ // reporting the teardown failure.
828
+ if (!decided) {
829
+ if (ac.signal.aborted) {
830
+ const { result, status } = resultForAbortReason(ac.signal.reason);
831
+ return finish(result, status);
832
+ }
833
+ return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
834
+ }
835
+ }
836
+ if (decided) {
837
+ ac.abort(decided.reason);
838
+ return finish(decided.result, decided.status);
794
839
  }
795
840
  // A clean relay EOF is not a timeout: the server intentionally closes on
796
841
  // scope changes and proxies can recycle idle streams. Re-arm from a fresh
@@ -1868,8 +1913,16 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1868
1913
  if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
1869
1914
  // Belt-and-braces: the same race can still slip between the pre-check above
1870
1915
  // and the wait registering (or the wait's own live-signal miss), so recheck
1871
- // once more before falling back to a generic timeout/error message.
1872
- const postCheck = durableReapFailure(await api.get(leaseId));
1916
+ // once more before falling back to a generic timeout/error message. Mirrors
1917
+ // the provision wait's BOT-1822 recheck (which only ever needed to confirm
1918
+ // failure), but the reap wait can miss EITHER terminal outcome the same
1919
+ // way — BOT-1827 (lease 239af20d): the reap genuinely completed (server
1920
+ // `reaped` live emit) while the zero-poll wait still reported `{ timeout }`,
1921
+ // so a healthy stack was reported as a hard failure. One direct read tells
1922
+ // a real timeout apart from a missed wake in either direction.
1923
+ const recheck = await api.get(leaseId);
1924
+ if (recheck?.ok && recheck.data?.success && recheck.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
1925
+ const postCheck = durableReapFailure(recheck);
1873
1926
  if (postCheck) return cleanupResult = postCheck;
1874
1927
  return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
1875
1928
  })();