@botbuddy/cli 1.33.0 → 1.33.2

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 +59 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.33.0",
3
+ "version": "1.33.2",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -805,7 +805,13 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
805
805
 
806
806
  const isActive = (s) => s === "active";
807
807
  const nonQueued = (s) => s != null && s !== "queued";
808
- const isReaped = (s) => s === "reaping" || s === "reaped";
808
+ // BOT-1822 (Codex round 3 P2): `reap_failed` is a distinct terminal wake
809
+ // (accept_stack_lease_receipt's fenced-reap-failure branch) alongside the real
810
+ // DB states "reaping"/"reaped" — a wait parked on isReaped as its failure
811
+ // predicate must end on it too, or a `reap_failed` wake that arrives while
812
+ // still waiting for activation is silently dropped and the wait parks until
813
+ // its full timeout instead of surfacing the failure immediately.
814
+ const isReaped = (s) => s === "reaping" || s === "reaped" || s === "reap_failed";
809
815
 
810
816
  /** Read-only pressure gate used before local managed-stack provisioning. */
811
817
  export async function runLocalExecPreflight(opts, runCommand = runDockerCommand) {
@@ -1833,8 +1839,38 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1833
1839
  return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
1834
1840
  }
1835
1841
  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 });
1842
+ // BOT-1822 (Codex P2 round 2, extended round 3): `reap_failed` is synthetic
1843
+ // (never a real DB state), so a Helper's failed receipt landing in the split
1844
+ // second between this release RPC returning and the wait below registering
1845
+ // cannot be replayed from the spine's initial snapshot the way every other
1846
+ // signal type can — the wake is already behind the cursor, and the initial
1847
+ // snapshot only surfaces the durable `reaping` state, which neither
1848
+ // predicate accepts. Recover the durable failure reason directly:
1849
+ // `stack_leases.error` (which `get_stack_lease` already returns) holds the
1850
+ // most recent failure message regardless of which live signal was or
1851
+ // wasn't seen.
1852
+ const durableReapFailure = (read) => (read?.ok && read.data?.success && read.data.state === "reaping" && read.data.error)
1853
+ ? { ok: false, error: `the lease is still reaping and may still hold containers — ${read.data.error}` }
1854
+ : null;
1855
+ // BOT-1822 (Codex round 3 P2): check BEFORE opening the wait, not only after
1856
+ // it times out — a failure already persisted must be reported at once
1857
+ // instead of burning the entire --reap-timeout waiting for a wake that will
1858
+ // never arrive because it predates the wait's registration.
1859
+ const preCheck = durableReapFailure(await api.get(leaseId));
1860
+ if (preCheck) return cleanupResult = preCheck;
1861
+ // BOT-1822 (Codex P2): a signed reap that FAILS on a provisioned (fenced) lease
1862
+ // emits `state: "reap_failed"` — a distinct terminal outcome, never a real DB
1863
+ // state — instead of leaving this wait to park until --reap-timeout on a reap
1864
+ // that already failed. Report that specific outcome instead of the generic
1865
+ // "was not proven" timeout message.
1866
+ const reaped = await wait(leaseId, (state) => state === "reaped", (state) => state === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth });
1837
1867
  if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
1868
+ if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
1869
+ // Belt-and-braces: the same race can still slip between the pre-check above
1870
+ // 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));
1873
+ if (postCheck) return cleanupResult = postCheck;
1838
1874
  return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
1839
1875
  })();
1840
1876
  return cleanupPromise;
@@ -1944,11 +1980,29 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1944
1980
  }
1945
1981
  if (state !== "active") {
1946
1982
  activeWaitAbort = new AbortController();
1947
- const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
1983
+ // BOT-1822 (Codex P2 round 2): a provision failure now emits a live
1984
+ // `reaping` signal (accept_stack_lease_receipt's provision-failed branch),
1985
+ // so this wait must treat it as terminal too — not just `reaped` — or the
1986
+ // live-wake fix is defeated: the run would still burn the FULL
1987
+ // provisionTimeout before the belt-and-braces recheck below ever notices
1988
+ // the lease is no longer provisioning. isReaped (== "reaping" || "reaped")
1989
+ // already covers both; cmdUp's equivalent waits use it for this reason.
1990
+ const active = await wait(leaseId, (s) => s === "active", isReaped, { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
1948
1991
  activeWaitAbort = null;
1949
1992
  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 }; }
1993
+ if (active?.timeout) {
1994
+ // BOT-1822 belt-and-braces: the zero-poll wait can still miss a live
1995
+ // `stack_lease` signal (an older server before this fix, a relay hiccup,
1996
+ // BOT-1816's class of dropped wakes) even though the lease genuinely went
1997
+ // active. One direct read before declaring a timeout tells that apart from
1998
+ // a lease that truly never provisioned, instead of reaping a healthy stack.
1999
+ const recheck = await api.get(leaseId);
2000
+ if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
2001
+ const cleanup = await requestCleanup();
2002
+ return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup };
2003
+ }
2004
+ }
2005
+ 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
2006
  }
1953
2007
  const current = await api.get(leaseId);
1954
2008
  if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {