@botbuddy/cli 1.51.0 → 1.52.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.51.0",
3
+ "version": "1.52.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -3804,7 +3804,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3804
3804
  incidentBox.portBlock = !provenReap
3805
3805
  ? { released: false, reason: retainBlock
3806
3806
  ? `a lease on this stack slot may still hold its ports (idle, unreaped, or another live batch)${retained} so its stack is never double-booked`
3807
- : `reap not proven (${cleanupResult?.error ?? "no cleanup ran"})${retained} so a stack the Helper may still run is never double-booked` }
3807
+ // BOT-2418: an idle release is a deliberate keep (BOT-1803), not an unproven reap.
3808
+ : cleanupResult?.ok === true && cleanupResult.idle === true
3809
+ ? `the lease is idle and keeps its stack for the next run on this slot, so its claim stays reserved with it (\`bb stack done ${leaseId} --reap\` reaps the lease; it does not release this host's claim)`
3810
+ : `reap not proven (${cleanupResult?.error ?? "no cleanup ran"})${retained} so a stack the Helper may still run is never double-booked` }
3808
3811
  : releaseError || disposition?.released === false
3809
3812
  ? { released: false, reason: `the reap was proven but the claim could not be removed (${releaseError?.message ?? "it is still on disk"})${retained}` }
3810
3813
  : { released: true };
package/src/wait-core.mjs CHANGED
@@ -1231,6 +1231,20 @@ const nowIso = () => new Date().toISOString();
1231
1231
  // deadline race, so an unbounded probe would blow past a short --timeout.
1232
1232
  const FEED_PROBE_BUDGET_MS = 2000;
1233
1233
 
1234
+ // BOT-2407: transport failures that cut an established event stream. undici reports a
1235
+ // socket closed mid-body as `TypeError: terminated` with the socket error as its cause
1236
+ // (ENT-5478 prod, 2026-10-15: three exit-7 deaths in 2 h). These are a stream END — the
1237
+ // relay is reconnected from the safe cursor — never an internal error.
1238
+ const STREAM_TRANSPORT_CODES = new Set(["UND_ERR_SOCKET", "UND_ERR_BODY_TIMEOUT", "ECONNRESET", "EPIPE", "ETIMEDOUT"]);
1239
+ export function isStreamTransportError(err) {
1240
+ if (!err || typeof err !== "object") return false;
1241
+ if (err.name === "AbortError") return true;
1242
+ for (let e = err, depth = 0; e && typeof e === "object" && depth < 4; e = e.cause, depth += 1) {
1243
+ if (STREAM_TRANSPORT_CODES.has(e.code)) return true;
1244
+ }
1245
+ return err instanceof TypeError && err.message === "terminated";
1246
+ }
1247
+
1234
1248
  /**
1235
1249
  * Run one wait to a terminal outcome, driven by an injected `connect(since)`
1236
1250
  * that returns an async iterable of parsed SSE frames.
@@ -1350,6 +1364,7 @@ export async function runWaitLoop({
1350
1364
  let reconnects = 0;
1351
1365
  let failures = 0;
1352
1366
  const noteBackendUnreachable = () => { if (!degraded.includes("backend_unreachable")) degraded.push("backend_unreachable"); };
1367
+ const noteDegraded = (marker) => { if (!degraded.includes(marker)) degraded.push(marker); };
1353
1368
 
1354
1369
  // Internal one-shot alarms (the deadline, the pre-deadline feed probe, reconnect backoff)
1355
1370
  // are REAL timers via the default sleep. Each exists only to resolve a race — once the loop
@@ -1682,13 +1697,29 @@ export async function runWaitLoop({
1682
1697
  // previous one has actually settled into a frame we consumed.
1683
1698
  let pendingNext = null;
1684
1699
  while (!streamEnded) {
1685
- if (!pendingNext) pendingNext = iterator.next();
1700
+ // BOT-2407: a rejected read settles into a marker instead of escaping the loop. A
1701
+ // transport cut ends the stream (reconnect); anything else is a typed error receipt.
1702
+ if (!pendingNext) {
1703
+ pendingNext = iterator.next().catch((err) => (
1704
+ isStreamTransportError(err) ? { __streamDropped: err } : { __streamError: err }
1705
+ ));
1706
+ }
1686
1707
  const racers = [deadlinePromise, pendingNext];
1687
1708
  if (staleConfirmPromise) racers.push(staleConfirmPromise);
1688
1709
  if (preDeadlineProbePromise && !preDeadlineProbeDone) racers.push(preDeadlineProbePromise);
1689
1710
  const raced = await Promise.race(racers);
1690
1711
 
1691
1712
  if (raced && raced.__deadline) return fireReplayGraceExpired();
1713
+ if (raced && raced.__streamDropped) {
1714
+ pendingNext = null;
1715
+ noteDegraded("stream_dropped");
1716
+ streamEnded = true;
1717
+ break;
1718
+ }
1719
+ if (raced && raced.__streamError) {
1720
+ const err = raced.__streamError;
1721
+ return finalize("error", { exitCode: EXIT.INTERNAL, error: `stream_error: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}` });
1722
+ }
1692
1723
  // BOT-1147 (Codex round-27 P2): the pre-deadline feed probe fired. Fold a
1693
1724
  // mid-wait stall/recovery into `degraded` before the imminent timeout receipt,
1694
1725
  // then keep waiting — pendingNext stays in flight (hoisted), re-raced next
@@ -1988,6 +2019,10 @@ export async function runWaitLoop({
1988
2019
  if (!deliveredFrame) {
1989
2020
  failures += 1;
1990
2021
  if (failures > maxReconnects) {
2022
+ // BOT-2407 AC-4: only consecutive failures spend the budget, so a give-up is always
2023
+ // an outage — name it. A connection that ended before any frame is
2024
+ // `stream_ended_empty`; `backend_unreachable` / `stream_dropped` are already noted.
2025
+ noteDegraded("stream_ended_empty");
1991
2026
  return finalize("error", { exitCode: EXIT.BACKEND, error: "reconnect budget exhausted" });
1992
2027
  }
1993
2028
  }
package/src/wait.mjs CHANGED
@@ -2192,12 +2192,80 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2192
2192
  }
2193
2193
  process.exit(exitCode);
2194
2194
  } catch (err) {
2195
- try { if (checkpoint?.local_wait_id) await store.update(checkpoint.local_wait_id, { status: "interrupted" }); } catch { /* retain original diagnostic state */ }
2196
- if (err instanceof WaitCheckpointError) return emitSavedWaitError(err);
2195
+ // A checkpoint-store failure is its own typed outcome (wait_checkpoint_write_failed):
2196
+ // the store is what failed, so it is not settled as `internal` (Codex #1190 R2).
2197
+ if (err instanceof WaitCheckpointError) {
2198
+ try { if (checkpoint?.local_wait_id) await store.update(checkpoint.local_wait_id, { status: "interrupted" }); } catch { /* retain original diagnostic state */ }
2199
+ return emitSavedWaitError(err);
2200
+ }
2201
+ const settled = await settleInternalError({
2202
+ err, checkpoint, store, waitSessionId, buildPublicReceipt,
2203
+ finalize: (receipt) => finalizeWait(opts, waitSessionId, receipt),
2204
+ });
2197
2205
  process.stderr.write(`bb-wait: ${err && err.stack || err}\n`);
2198
- emitReceipt({ schema_version: 1, outcome: "error", error: "internal" });
2199
- process.exit(EXIT.INTERNAL);
2206
+ // Receipt first, stdout second, cloud finalization last — as on the normal path.
2207
+ emitReceipt(settled.receipt, { versioned: true });
2208
+ await settled.finalizeCloud();
2209
+ process.exit(settled.exitCode);
2210
+ }
2211
+ }
2212
+
2213
+ /**
2214
+ * BOT-2407 AC-2: the last-resort path for an unexpected throw (other than a
2215
+ * WaitCheckpointError, which the caller emits as its own typed receipt) while a wait runs. It
2216
+ * ends with a durable terminal receipt saying why — `interrupted` with no receipt is
2217
+ * reserved for a killed process — and returns the receipt to print, the exit code, and
2218
+ * `finalizeCloud` to call once the receipt is on stdout.
2219
+ * - The bounded public receipt is built once and is the same object that is persisted,
2220
+ * printed and finalized, so `bb wait resume` replays exactly what stdout carried.
2221
+ * - A registered wait is finalized in the cloud (best effort), so /waits learns why
2222
+ * instead of the row waiting for the reaper.
2223
+ * - A terminal receipt already saved (a match persisted from its frame) stays
2224
+ * authoritative: it is never overwritten by the internal error.
2225
+ * Never throws, and neither does `finalizeCloud`.
2226
+ */
2227
+ export async function settleInternalError({ err, checkpoint, store, waitSessionId = null, buildPublicReceipt, finalize = null }) {
2228
+ if (checkpoint?.status === "terminal" && checkpoint.terminal_receipt != null) {
2229
+ return {
2230
+ receipt: checkpoint.terminal_receipt,
2231
+ exitCode: Number.isInteger(checkpoint.exit_code) ? checkpoint.exit_code : EXIT.INTERNAL,
2232
+ finalizeCloud: async () => {},
2233
+ };
2200
2234
  }
2235
+ const internalReceipt = {
2236
+ schema_version: 1,
2237
+ outcome: "error",
2238
+ error: "internal",
2239
+ detail: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
2240
+ wait_session_id: waitSessionId ?? null,
2241
+ next_cursor: checkpoint?.safe_cursor ?? null,
2242
+ };
2243
+ let receipt = internalReceipt;
2244
+ try { receipt = buildPublicReceipt(internalReceipt); } catch { /* keep the raw receipt */ }
2245
+ const id = checkpoint?.local_wait_id;
2246
+ if (id) {
2247
+ try {
2248
+ await store.update(id, {
2249
+ status: "terminal",
2250
+ terminal_receipt: receipt,
2251
+ exit_code: EXIT.INTERNAL,
2252
+ safe_cursor: receipt.next_cursor ?? checkpoint.safe_cursor ?? null,
2253
+ cloud_finalize_pending: Boolean(waitSessionId),
2254
+ });
2255
+ } catch { /* retain original diagnostic state */ }
2256
+ }
2257
+ const finalizeCloud = async () => {
2258
+ if (!waitSessionId || !finalize) return;
2259
+ try {
2260
+ const finalized = await finalize(receipt);
2261
+ if (id) {
2262
+ await store.update(id, finalized?.delivered
2263
+ ? { cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null }
2264
+ : { cloud_finalize_pending: true, cloud_finalize_error: finalized?.error ?? null });
2265
+ }
2266
+ } catch { /* the pending marker stays for recovery */ }
2267
+ };
2268
+ return { receipt, exitCode: EXIT.INTERNAL, finalizeCloud };
2201
2269
  }
2202
2270
 
2203
2271
  if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {