@botbuddy/cli 1.63.29 → 1.63.31

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.29",
3
- "source_commit": "732c555dd44141d581f96959ed590007b3ec09de",
4
- "source_sha256": "c2f0fc90169b4580f2d8918fb1100b853e0663a65fe470dc399c2a33787d7639",
2
+ "release_version": "1.63.31",
3
+ "source_commit": "3f7fe8fd0dfb2a66d2d1eedd7fa1a0e6852190a8",
4
+ "source_sha256": "acb5cf30dc9a9ecbfcddbbcae39c4c280a7da9c3f1186f9bafc232e1bebdb645",
5
5
  "target": "darwin-arm64",
6
- "sha256": "05fd954131e610edd04ff1ee4ee4a0e37047f3ab56bbb0d4fd5fc3cee82bcd86"
6
+ "sha256": "f0b5a4a0d1ee1d059dbad7c14f7f9bf99e9d1665e23e79a9a46dcdc0e8b65e8d"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.29",
3
- "source_commit": "732c555dd44141d581f96959ed590007b3ec09de",
4
- "source_sha256": "c2f0fc90169b4580f2d8918fb1100b853e0663a65fe470dc399c2a33787d7639",
2
+ "release_version": "1.63.31",
3
+ "source_commit": "3f7fe8fd0dfb2a66d2d1eedd7fa1a0e6852190a8",
4
+ "source_sha256": "acb5cf30dc9a9ecbfcddbbcae39c4c280a7da9c3f1186f9bafc232e1bebdb645",
5
5
  "target": "linux-x64",
6
- "sha256": "1b3d42884f57dd70d3946137b704009d42ee027e2ca060b2e4df70325c52c5b7"
6
+ "sha256": "1bb884654f83bbfd9c9083e6cd777297600fd473f7e22e8572610f002c433057"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.63.29",
4
- "source_commit": "732c555dd44141d581f96959ed590007b3ec09de",
5
- "source_sha256": "c2f0fc90169b4580f2d8918fb1100b853e0663a65fe470dc399c2a33787d7639",
3
+ "release_version": "1.63.31",
4
+ "source_commit": "3f7fe8fd0dfb2a66d2d1eedd7fa1a0e6852190a8",
5
+ "source_sha256": "acb5cf30dc9a9ecbfcddbbcae39c4c280a7da9c3f1186f9bafc232e1bebdb645",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "05fd954131e610edd04ff1ee4ee4a0e37047f3ab56bbb0d4fd5fc3cee82bcd86",
11
+ "sha256": "f0b5a4a0d1ee1d059dbad7c14f7f9bf99e9d1665e23e79a9a46dcdc0e8b65e8d",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "1b3d42884f57dd70d3946137b704009d42ee027e2ca060b2e4df70325c52c5b7",
18
+ "sha256": "1bb884654f83bbfd9c9083e6cd777297600fd473f7e22e8572610f002c433057",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.63.29",
3
+ "version": "1.63.31",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -530,7 +530,9 @@ export async function callToolJson(toolName, args = {}, {
530
530
  const text = payload.result?.content?.map((c) => c.text).filter(Boolean).join("\n") ?? "";
531
531
  let data;
532
532
  try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
533
- return { ok: true, data, isError: payload.result?.isError === true };
533
+ // BOT-2847 (Codex #1434): keep the HTTP status, so a gateway 5xx whose body is JSON (but not a
534
+ // JSON-RPC answer) is not mistaken for a definite tool answer by a retrying caller.
535
+ return { ok: true, data, isError: payload.result?.isError === true, status: res.status };
534
536
  }
535
537
 
536
538
  export async function listResources({ fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, log = (line) => console.log(line) } = {}) {
@@ -0,0 +1,81 @@
1
+ // BOT-2847: a transient backend failure on a stack lease release is retried, never leaked.
2
+ //
3
+ // The classification is BOT-2790 AC-6's (BOT-2828), shared with the native callback's
4
+ // status read: an UNANSWERED call is transient — a backend `RPC_ERROR` (for example a
5
+ // statement timeout), a transport failure, an HTTP 5xx/408/429 or an unreadable body. A
6
+ // 401/403 that survived the auth refresh, a typed JSON-RPC refusal or a typed tool refusal
7
+ // (`NOT_LEASE_OWNER`, `LEASE_NOT_FOUND`, ...) is a definite answer and is never retried.
8
+ //
9
+ // Retrying a release is safe: `release_stack_lease` is idempotent (a duplicate release is a
10
+ // no-op, an already-idle lease answers idle again), and an `RPC_ERROR` means the server's
11
+ // transaction rolled back.
12
+
13
+ /** Backoff between release attempts: 8 attempts within about 60 s. */
14
+ export const RELEASE_RETRY_DELAYS_MS = Object.freeze([1000, 2000, 4000, 8000, 15000, 15000, 15000]);
15
+ /** The wall-clock budget for one release's retries (the delays alone add up to it). */
16
+ export const RELEASE_RETRY_BUDGET_MS = 60_000;
17
+ /** Backoff for the handoff call made once the release's retries run out. */
18
+ export const RELEASE_HANDOFF_DELAYS_MS = Object.freeze([2000, 4000]);
19
+ /** The deadline for the handoff (its own: the release's deadline may already have passed). */
20
+ export const RELEASE_HANDOFF_TIMEOUT_MS = 20_000;
21
+ /** `cleanup.code` when the release never got an answer and the lease was handed off instead. */
22
+ export const CLEANUP_RETRY_EXHAUSTED = "CLEANUP_RETRY_EXHAUSTED";
23
+
24
+ /** True when a `callToolJson`-shaped result is an unanswered call (BOT-2790 AC-6). */
25
+ export function isTransientStackRpcResult(result) {
26
+ if (result == null || typeof result !== "object") return false;
27
+ const status = result.status;
28
+ const unansweredStatus = typeof status === "number" && (status >= 500 || status === 408 || status === 429);
29
+ // A gateway 5xx/408/429 whose body happens to be JSON still parses as `ok` (Codex #1434).
30
+ if (result.ok) return unansweredStatus || (result.data?.success !== true && result.data?.code === "RPC_ERROR");
31
+ if (result.auth) return false;
32
+ return status == null || unansweredStatus || result.error === "invalid_json";
33
+ }
34
+
35
+ /** One short line naming why a stack RPC failed, for a receipt. */
36
+ export function describeStackRpcFailure(result) {
37
+ if (result == null || typeof result !== "object") return "no response";
38
+ if (result.ok) {
39
+ const code = result.data?.code ?? (typeof result.status === "number" && result.status >= 300 ? `HTTP ${result.status}` : "failed");
40
+ const message = typeof result.data?.message === "string" && result.data.message ? `: ${result.data.message}` : "";
41
+ return `${code}${message}`.slice(0, 160);
42
+ }
43
+ if (result.error === "invalid_json") return `unreadable response (HTTP ${result.status ?? "?"})`;
44
+ if (result.status != null && result.status >= 300) return `HTTP ${result.status}${result.error ? ` ${result.error}` : ""}`.slice(0, 160);
45
+ return String(result.error ?? "no response").slice(0, 160);
46
+ }
47
+
48
+ /** A real-timer sleep that ends early when `signal` aborts. */
49
+ export function abortableSleep(ms, signal) {
50
+ return new Promise((resolve) => {
51
+ if (signal?.aborted) { resolve(); return; }
52
+ let timer = null;
53
+ const done = () => { clearTimeout(timer); signal?.removeEventListener?.("abort", done); resolve(); };
54
+ timer = setTimeout(done, ms);
55
+ signal?.addEventListener?.("abort", done, { once: true });
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Run `invoke` until it returns something other than a transient failure, backing off
61
+ * between attempts. Stops when the delays or `budgetMs` run out, or when `signal` aborts.
62
+ * A thrown error propagates unchanged (the caller owns that path).
63
+ */
64
+ export async function callWithTransientRetry(invoke, {
65
+ delaysMs = RELEASE_RETRY_DELAYS_MS, budgetMs = RELEASE_RETRY_BUDGET_MS,
66
+ sleep = abortableSleep, now = Date.now, signal,
67
+ } = {}) {
68
+ const started = now();
69
+ let attempts = 0;
70
+ let result;
71
+ for (;;) {
72
+ attempts += 1;
73
+ result = await invoke();
74
+ if (!isTransientStackRpcResult(result)) return { result, attempts, exhausted: false, elapsedMs: now() - started };
75
+ const delay = delaysMs[attempts - 1];
76
+ if (delay == null || signal?.aborted || now() - started + delay > budgetMs) break;
77
+ await sleep(delay, signal);
78
+ if (signal?.aborted) break;
79
+ }
80
+ return { result, attempts, exhausted: true, elapsedMs: now() - started };
81
+ }
package/src/stack.mjs CHANGED
@@ -45,6 +45,7 @@ import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from
45
45
  import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
46
46
  import { LEASED_STACK_IDLE_RETENTION_SECS, assertFreshRunStackAvailable, idleRequesterOriginPort, recordRequesterOrigin, stableRunStackIdle, recordAdmissionRequest, bindLeasedStackClaim, recoverLeasedStackClaim, hasLocalLeaseClaim, leasedClaimContext, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
47
47
  import { reserveRequesterOrigin } from "./stack-requester-origin.mjs";
48
+ import { CLEANUP_RETRY_EXHAUSTED, RELEASE_HANDOFF_DELAYS_MS, RELEASE_HANDOFF_TIMEOUT_MS, callWithTransientRetry, describeStackRpcFailure, isTransientStackRpcResult } from "./stack-release-retry.mjs";
48
49
  import { identifiedAdmissionAck, identifiedAdmissionDelivery, identifiedAdmissionRecovery } from "./stack-identified-admission.mjs";
49
50
  import { patchRequesterAuthOrigin } from "./stack-requester-origin-config.mjs";
50
51
  import { measureStackUsage } from "./stack-usage.mjs";
@@ -3278,6 +3279,7 @@ export async function cmdDone(leaseId, opts, {
3278
3279
  waitFn = waitForLease,
3279
3280
  recoverClaim = recoverLocalLeaseClaim,
3280
3281
  localClaimExists = hasLocalLeaseClaim,
3282
+ releaseRetry = {},
3281
3283
  } = {}) {
3282
3284
  // BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
3283
3285
  // its own — otherwise an active lease can't be released until the cache is manually
@@ -3420,9 +3422,14 @@ export async function cmdDone(leaseId, opts, {
3420
3422
  // batch on this slot). `--reap` is today's eager release, and `--local-exec` implies it:
3421
3423
  // its self-teardown below (and every hermetic lane / Supply Guard stop) needs the reap.
3422
3424
  const idleRelease = !opts.reap && !opts.localExec;
3423
- const r = await call("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition, ...(idleRelease ? { idle: true } : {}) });
3424
- if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
3425
- if (!r.data.success) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
3425
+ // BOT-2847: an unanswered release (RPC_ERROR, transport, 5xx) is retried for about 60 s; a
3426
+ // definite refusal is reported at once. An exhausted retry names the cause and the attempts.
3427
+ const released = await callWithTransientRetry(
3428
+ () => call("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition, ...(idleRelease ? { idle: true } : {}) }), releaseRetry);
3429
+ const r = released.result;
3430
+ const unanswered = released.exhausted ? `${describeStackRpcFailure(r)} (no answer in ${released.attempts} attempts over ${Math.round(released.elapsedMs / 1000)}s)` : null;
3431
+ if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: unanswered ?? r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
3432
+ if (!r.data.success) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: unanswered ?? r.data.code }), opts, EXIT.BACKEND);
3426
3433
  if (idleRelease && r.data.idle === true) {
3427
3434
  return emitResult(buildReceipt({
3428
3435
  command: "done", outcome: "idle", lease_id: leaseId, state: r.data.state ?? "active",
@@ -3904,6 +3911,53 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3904
3911
  }
3905
3912
  };
3906
3913
 
3914
+ // BOT-2847: the release's retry policy (tests inject an instant `sleep`).
3915
+ const cleanupRetry = adapters.cleanupRetry ?? {};
3916
+ // BOT-1803: an idle lease's stack is still running — keep its directory and host port block
3917
+ // reserved well past any idle TTL so no other worktree takes them; the next run renews the
3918
+ // claim. BOT-2847: an idle handoff after an unanswered release needs the same retention.
3919
+ const retainIdleStack = () => {
3920
+ if (!execution) return;
3921
+ try { refreshRetention(stackDirFor(execution), { idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
3922
+ try { execution.renew?.({ idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
3923
+ };
3924
+ // BOT-2847 AC-2: the release never got an answer, so the lease must not stay active. Hand it
3925
+ // off through the other disposition, a different server path: an eager release that failed
3926
+ // goes idle (reclaimable — a queued lease preempts it, and it is reaped once its owner is
3927
+ // gone); an idle release that failed goes to the reaper. Neither is a proven cleanup, so the
3928
+ // result stays failed (CLEANUP_FAILED) and the receipt names the cause and the handoff.
3929
+ const handOffUnreleasedLease = async (idle, attempt, releaseAs) => {
3930
+ const cause = describeStackRpcFailure(attempt.result);
3931
+ const secs = Math.round(attempt.elapsedMs / 1000);
3932
+ const handoffAbort = new AbortController();
3933
+ const handoffTimer = clock.setTimeout(() => handoffAbort.abort("handoff-timeout"), cleanupRetry.handoffTimeoutMs ?? RELEASE_HANDOFF_TIMEOUT_MS);
3934
+ let handoff;
3935
+ try {
3936
+ handoff = await callWithTransientRetry(releaseAs(!idle, handoffAbort.signal), {
3937
+ ...cleanupRetry, delaysMs: cleanupRetry.handoffDelaysMs ?? RELEASE_HANDOFF_DELAYS_MS, signal: handoffAbort.signal,
3938
+ });
3939
+ } catch (error) {
3940
+ handoff = { result: { ok: false, error: String(error?.message ?? error) } };
3941
+ } finally {
3942
+ clock.clearTimeout(handoffTimer);
3943
+ }
3944
+ const answer = handoff.result;
3945
+ const accepted = answer?.ok === true && answer.data?.success === true;
3946
+ const handedTo = !accepted ? null : answer.data.idle === true ? "idle" : "reaper";
3947
+ // Codex #1434 P2: the idle stack keeps running, so its directory and port claim must not expire.
3948
+ if (handedTo === "idle") retainIdleStack();
3949
+ const unanswered = `release_stack_lease${idle ? " (idle)" : ""} got no answer in ${attempt.attempts} attempts over ${secs}s (${cause})`;
3950
+ const outcome = handedTo === "idle"
3951
+ ? "; the lease was handed off idle instead (reclaimable: a queued lease preempts it, and it is reaped once its owner is gone)"
3952
+ : handedTo === "reaper"
3953
+ ? `; the lease was handed to the reaper instead (state ${answer.data.state ?? "unknown"}), its signed reap not awaited`
3954
+ : `; the ${idle ? "reaper" : "idle"} handoff also failed (${describeStackRpcFailure(answer)}), so the lease may still be active — release it with \`bb stack done ${leaseId} --reap\``;
3955
+ return {
3956
+ ok: false, code: CLEANUP_RETRY_EXHAUSTED, error: `${unanswered}${outcome}`,
3957
+ handoff: { handed_to: handedTo, state: accepted ? answer.data.state ?? null : null, release_attempts: attempt.attempts, handoff_attempts: handoff.attempts ?? 1 },
3958
+ };
3959
+ };
3960
+
3907
3961
  // BOT-1803: `idle` (only the normal end of a run) asks the server to idle the ACTIVE lease
3908
3962
  // — no reap job, no signed-reap wait. Any answer that is not an idle one (the lease was
3909
3963
  // not active) continues into today's signed-reap wait unchanged.
@@ -3918,12 +3972,18 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3918
3972
  if (!leaseId) return cleanupResult = { ok: true, state: null };
3919
3973
  const cleanupAbort = new AbortController();
3920
3974
  const cleanupTimer = clock.setTimeout(() => cleanupAbort.abort("reap-timeout"), opts.reapTimeout * 1000);
3975
+ const releaseAs = (asIdle, signal) => () => (asIdle ? api.release(leaseId, signal, { idle: true }) : api.release(leaseId, signal));
3921
3976
  let release;
3977
+ let attempt;
3922
3978
  try {
3923
- release = idle ? await api.release(leaseId, cleanupAbort.signal, { idle: true }) : await api.release(leaseId, cleanupAbort.signal);
3979
+ // BOT-2847: an unanswered release (RPC_ERROR, transport, 5xx) is retried with backoff for
3980
+ // about 60 s; a definite refusal is returned at once.
3981
+ attempt = await callWithTransientRetry(releaseAs(idle, cleanupAbort.signal), { ...cleanupRetry, signal: cleanupAbort.signal });
3982
+ release = attempt.result;
3924
3983
  } finally {
3925
3984
  clock.clearTimeout(cleanupTimer);
3926
3985
  }
3986
+ if (attempt.exhausted) return cleanupResult = await handOffUnreleasedLease(idle, attempt, releaseAs);
3927
3987
  if (!release?.ok || !release?.data?.success) {
3928
3988
  return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
3929
3989
  }
@@ -4839,11 +4899,12 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4839
4899
  readCurrent: async (signal) => { let observed; try { observed = await api.get(leaseId, { signal }); } catch (error) { throw Object.assign(new Error(`current callback status unreadable (${error?.message ?? error})`), { transient: true }); } if (!observed?.ok) {
4840
4900
  // Only an unanswered read is transient (transport, 5xx, 408/429, unreadable body). A 401/403
4841
4901
  // that survived the auth refresh or a typed JSON-RPC refusal is a definitive answer.
4842
- const transient = !observed?.auth && (observed?.status == null || observed.status >= 500 || observed.status === 408 || observed.status === 429 || observed.error === "invalid_json");
4902
+ // BOT-2847: the shared classification (stack-release-retry.mjs) the cleanup release retries on.
4903
+ const transient = observed == null || isTransientStackRpcResult(observed);
4843
4904
  throw Object.assign(new Error(transient ? "current callback status unreadable" : "current callback authorization refused"), transient ? { transient: true } : { code: "CALLBACK_AUTHORIZATION_LOST" });
4844
4905
  }
4845
4906
  // BOT-2790 AC-6 (BOT-2828): a backend RPC_ERROR (e.g. a statement timeout) is an unanswered read.
4846
- if (observed.data?.success !== true && observed.data?.code === "RPC_ERROR") throw Object.assign(new Error("current callback status unreadable (RPC_ERROR)"), { transient: true });
4907
+ if (isTransientStackRpcResult(observed)) throw Object.assign(new Error("current callback status unreadable (RPC_ERROR)"), { transient: true });
4847
4908
  if (observed.data?.success !== true) throw Object.assign(new Error("current callback authorization unavailable"), { code: "CALLBACK_AUTHORIZATION_LOST" });
4848
4909
  return observed.data; },
4849
4910
  });
@@ -4992,10 +5053,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4992
5053
  const idleOnEnd = !opts.reap && !receivedSignal && !fencing && !tunnelLost && !callbackCleanupError && forcedStopCode == null;
4993
5054
  const cleanup = await requestCleanup({ idle: idleOnEnd });
4994
5055
  if (cleanup?.idle) {
4995
- // The stack is still running: keep its directory and host port block reserved well
4996
- // past any idle TTL so no other worktree takes them; the next run renews the claim.
4997
- try { refreshRetention(stackDirFor(execution), { idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
4998
- try { execution.renew?.({ idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
5056
+ retainIdleStack();
4999
5057
  stderrLine(`stack: lease ${leaseId} is idle${cleanup.idle_since ? ` since ${cleanup.idle_since}` : ""} — its stack keeps running for the next run on this slot (\`bb stack done ${leaseId} --reap\` tears it down)`);
5000
5058
  }
5001
5059
  if (originLost && !tunnelLost && !receivedSignal) {
@@ -5094,6 +5152,8 @@ export function runReceiptFields(result) {
5094
5152
  cleanup: result.cleanup?.idle ? { idle: true, idle_since: result.cleanup.idle_since ?? null } : result.cleanup?.ok ?? null,
5095
5153
  cleanup_code: result.cleanup?.code ?? null,
5096
5154
  cleanup_error: result.cleanup?.ok === false ? result.cleanup.error ?? "cleanup was not proven" : null,
5155
+ // BOT-2847: an unanswered release names where the lease was handed off (idle | reaper | null).
5156
+ ...(result.cleanup?.handoff ? { cleanup_handoff: result.cleanup.handoff } : {}),
5097
5157
  ...(result.leaseId && result.cleanup?.ok === false ? { recovery: {
5098
5158
  status: `bb stack status ${result.leaseId}`,
5099
5159
  wait: `bb wait 'lease:id=${result.leaseId},state=reaped' --heartbeat`,