@botbuddy/cli 1.33.1 → 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 +120 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.33.1",
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
@@ -805,7 +850,13 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
805
850
 
806
851
  const isActive = (s) => s === "active";
807
852
  const nonQueued = (s) => s != null && s !== "queued";
808
- const isReaped = (s) => s === "reaping" || s === "reaped";
853
+ // BOT-1822 (Codex round 3 P2): `reap_failed` is a distinct terminal wake
854
+ // (accept_stack_lease_receipt's fenced-reap-failure branch) alongside the real
855
+ // DB states "reaping"/"reaped" — a wait parked on isReaped as its failure
856
+ // predicate must end on it too, or a `reap_failed` wake that arrives while
857
+ // still waiting for activation is silently dropped and the wait parks until
858
+ // its full timeout instead of surfacing the failure immediately.
859
+ const isReaped = (s) => s === "reaping" || s === "reaped" || s === "reap_failed";
809
860
 
810
861
  /** Read-only pressure gate used before local managed-stack provisioning. */
811
862
  export async function runLocalExecPreflight(opts, runCommand = runDockerCommand) {
@@ -1833,8 +1884,46 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1833
1884
  return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
1834
1885
  }
1835
1886
  if (release.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
1836
- const reaped = await wait(leaseId, (state) => state === "reaped", () => false, { timeoutSec: opts.reapTimeout, auth: rpc.auth });
1887
+ // BOT-1822 (Codex P2 round 2, extended round 3): `reap_failed` is synthetic
1888
+ // (never a real DB state), so a Helper's failed receipt landing in the split
1889
+ // second between this release RPC returning and the wait below registering
1890
+ // cannot be replayed from the spine's initial snapshot the way every other
1891
+ // signal type can — the wake is already behind the cursor, and the initial
1892
+ // snapshot only surfaces the durable `reaping` state, which neither
1893
+ // predicate accepts. Recover the durable failure reason directly:
1894
+ // `stack_leases.error` (which `get_stack_lease` already returns) holds the
1895
+ // most recent failure message regardless of which live signal was or
1896
+ // wasn't seen.
1897
+ const durableReapFailure = (read) => (read?.ok && read.data?.success && read.data.state === "reaping" && read.data.error)
1898
+ ? { ok: false, error: `the lease is still reaping and may still hold containers — ${read.data.error}` }
1899
+ : null;
1900
+ // BOT-1822 (Codex round 3 P2): check BEFORE opening the wait, not only after
1901
+ // it times out — a failure already persisted must be reported at once
1902
+ // instead of burning the entire --reap-timeout waiting for a wake that will
1903
+ // never arrive because it predates the wait's registration.
1904
+ const preCheck = durableReapFailure(await api.get(leaseId));
1905
+ if (preCheck) return cleanupResult = preCheck;
1906
+ // BOT-1822 (Codex P2): a signed reap that FAILS on a provisioned (fenced) lease
1907
+ // emits `state: "reap_failed"` — a distinct terminal outcome, never a real DB
1908
+ // state — instead of leaving this wait to park until --reap-timeout on a reap
1909
+ // that already failed. Report that specific outcome instead of the generic
1910
+ // "was not proven" timeout message.
1911
+ const reaped = await wait(leaseId, (state) => state === "reaped", (state) => state === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth });
1837
1912
  if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
1913
+ if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
1914
+ // Belt-and-braces: the same race can still slip between the pre-check above
1915
+ // and the wait registering (or the wait's own live-signal miss), so recheck
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);
1926
+ if (postCheck) return cleanupResult = postCheck;
1838
1927
  return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
1839
1928
  })();
1840
1929
  return cleanupPromise;
@@ -1944,11 +2033,29 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1944
2033
  }
1945
2034
  if (state !== "active") {
1946
2035
  activeWaitAbort = new AbortController();
1947
- const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
2036
+ // BOT-1822 (Codex P2 round 2): a provision failure now emits a live
2037
+ // `reaping` signal (accept_stack_lease_receipt's provision-failed branch),
2038
+ // so this wait must treat it as terminal too — not just `reaped` — or the
2039
+ // live-wake fix is defeated: the run would still burn the FULL
2040
+ // provisionTimeout before the belt-and-braces recheck below ever notices
2041
+ // the lease is no longer provisioning. isReaped (== "reaping" || "reaped")
2042
+ // already covers both; cmdUp's equivalent waits use it for this reason.
2043
+ const active = await wait(leaseId, (s) => s === "active", isReaped, { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
1948
2044
  activeWaitAbort = null;
1949
2045
  if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
1950
- if (active?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup }; }
1951
- if (active?.failed || active?.error || active?.auth) { const cleanup = await requestCleanup(); return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || "lease was reaped before active", cleanup }; }
2046
+ if (active?.timeout) {
2047
+ // BOT-1822 belt-and-braces: the zero-poll wait can still miss a live
2048
+ // `stack_lease` signal (an older server before this fix, a relay hiccup,
2049
+ // BOT-1816's class of dropped wakes) even though the lease genuinely went
2050
+ // active. One direct read before declaring a timeout tells that apart from
2051
+ // a lease that truly never provisioned, instead of reaping a healthy stack.
2052
+ const recheck = await api.get(leaseId);
2053
+ if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
2054
+ const cleanup = await requestCleanup();
2055
+ return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup };
2056
+ }
2057
+ }
2058
+ if (active?.failed || active?.error || active?.auth) { const cleanup = await requestCleanup(); return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active`, cleanup }; }
1952
2059
  }
1953
2060
  const current = await api.get(leaseId);
1954
2061
  if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {