@botbuddy/cli 1.36.2 → 1.37.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.36.2",
3
+ "version": "1.37.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -27,6 +27,7 @@ import { open, unlink, readFile } from "fs/promises";
27
27
  import { tmpdir } from "os";
28
28
  import { basename, dirname, join, relative, isAbsolute } from "path";
29
29
  import { randomUUID } from "crypto";
30
+ import { fileURLToPath } from "url";
30
31
  import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
31
32
  import { SERVER_URL, getConfig } from "./config.mjs";
32
33
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
@@ -86,6 +87,10 @@ ${bold("up OPTIONS")}
86
87
  --idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
87
88
  --timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
88
89
  --no-wait If the host is full, print the queue position and exit (don't park).
90
+ --wait-capacity <dur> Opt-in (BOT-2024): on a TRANSIENT HOST_CAPACITY_SOURCE_STALE refusal, park
91
+ on the server's \`bb wait 'capacity:…'\` up to <dur> (90s, 10m, 1h) and
92
+ re-attempt admission once when it matches. Sustained refusals, the
93
+ deadline (exit 2) and a failed wait (its own exit code) never retry.
89
94
  --local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
90
95
  stack and self-activate. Refused at the worktree root (shared stack).
91
96
  --docker-context <name> Explicit Docker context for the mandatory local preflight
@@ -114,6 +119,7 @@ ${bold("run OPTIONS")}
114
119
  --connection-file P Optional empty file path for the mode-0600 connection JSON.
115
120
  --hard-ttl N Bound the child runtime; timeout requests normal cleanup.
116
121
  --local-exec Rejected: stack run never starts Docker directly.
122
+ --wait-capacity <dur> Same as up: park a TRANSIENT stale-capacity refusal, then re-admit once.
117
123
 
118
124
  ${bold("GLOBAL")}
119
125
  --json Pretty-print the receipt (default is one compact JSON line).
@@ -124,6 +130,13 @@ ${bold("EXIT CODES")}
124
130
  4 invalid arguments 5 backend/coordination 6 lease failed/reaped
125
131
  7 internal error 8 cleanup/reap proof failed
126
132
 
133
+ ${bold("HOST_CAPACITY_SOURCE_STALE")} (BOT-2024)
134
+ HOST_CAPACITY_SOURCE_STALE is a capacity flag, not a Helper diagnosis. Read \`phase\` on the
135
+ receipt. transient → arm the printed \`bb wait 'capacity:…'\` and re-run once it fires
136
+ (or pass --wait-capacity); never report blocked, never ask for a Helper restart.
137
+ sustained (no beacon ever or >= 10 min) → report BLOCKED quoting \`stale_since\`; a Helper
138
+ check/restart is the right ask. A single refusal is never evidence of an outage.
139
+
127
140
  ${bold("EXAMPLE")}
128
141
  # request a per-worktree stack for this batch, run a lane against it, then reap it
129
142
  botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
@@ -175,7 +188,7 @@ export function parseStackArgs(argv) {
175
188
  slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
176
189
  prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
177
190
  timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
178
- connectionFile: null, noWait: false, localExec: false,
191
+ connectionFile: null, noWait: false, localExec: false, waitCapacity: null,
179
192
  dockerContext: null, dockerEndpoint: null,
180
193
  disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
181
194
  };
@@ -248,6 +261,14 @@ export function parseStackArgs(argv) {
248
261
  }
249
262
  break;
250
263
  }
264
+ case "--wait-capacity": {
265
+ if (need(a, rest[i + 1])) {
266
+ const secs = parseWaitCapacityDuration(rest[++i]);
267
+ if (secs == null) errors.push("--wait-capacity must be a positive duration: <N>, <N>s, <N>m or <N>h");
268
+ else opts.waitCapacity = secs;
269
+ }
270
+ break;
271
+ }
251
272
  case "--no-wait": opts.noWait = true; break;
252
273
  case "--local-exec": opts.localExec = true; break;
253
274
  case "--docker-context": if (need(a, rest[i + 1])) opts.dockerContext = rest[++i]; break;
@@ -280,6 +301,9 @@ export function parseStackArgs(argv) {
280
301
  "worktree root's default (shared canonical) project. Point --stack-path at a slot-derived stack directory with its " +
281
302
  "own supabase/config.toml (distinct project_id + remapped ports).");
282
303
  }
304
+ if (opts.waitCapacity != null && !["up", "run"].includes(command)) {
305
+ errors.push("--wait-capacity is valid only with `stack up` or `stack run`");
306
+ }
283
307
  if ((opts.dockerContext || opts.dockerEndpoint) && !(["up", "done"].includes(command) && opts.localExec)) {
284
308
  errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
285
309
  }
@@ -708,6 +732,124 @@ export function leaseRefusalError(data) {
708
732
  return data?.message || data?.code || "lease request refused";
709
733
  }
710
734
 
735
+ // ── BOT-2024: phase-aware HOST_CAPACITY_SOURCE_STALE ─────────────────────────
736
+ // The refusal is a capacity FLAG, not a Helper diagnosis. The server classifies it:
737
+ // `sustained` iff the host never beaconed or its last beacon is >= 600 s old, else
738
+ // `transient` (scheduler lag; the flag's own age is informational only). Agents escalated transient lag as an outage and asked the
739
+ // user to restart a healthy Helper (2026-10-03), so the CLI renders the phase.
740
+ export const HOST_CAPACITY_SOURCE_STALE_CODE = "HOST_CAPACITY_SOURCE_STALE";
741
+ const CAPACITY_STALE_RULE =
742
+ "HOST_CAPACITY_SOURCE_STALE is a capacity flag, not a Helper diagnosis. Read `phase`: transient → arm the printed " +
743
+ "`bb wait 'capacity:…'` and re-run once it fires (never report blocked, never ask for a Helper restart); " +
744
+ "sustained (no beacon ever or >= 10 min) → report BLOCKED quoting `stale_since`. A single refusal is never evidence of an outage.";
745
+
746
+ function humanSecs(secs) {
747
+ if (!Number.isFinite(secs)) return "an unknown time";
748
+ if (secs < 120) return `${secs}s`;
749
+ if (secs < 7200) return `${Math.floor(secs / 60)}m`;
750
+ return `${Math.floor(secs / 3600)}h${Math.floor((secs % 3600) / 60)}m`;
751
+ }
752
+
753
+ /** Operator/agent-facing guidance for a stale-capacity refusal (or null for any other code). */
754
+ export function capacityStaleGuidance(data) {
755
+ if (data?.code !== HOST_CAPACITY_SOURCE_STALE_CODE) return null;
756
+ const host = data.host_key ? ` on ${data.host_key}` : "";
757
+ if (data.phase === "transient") {
758
+ const beacon = Number.isFinite(data.beacon_age_secs) ? `the Helper beaconed ${humanSecs(data.beacon_age_secs)} ago` : "no beacon recorded yet";
759
+ return `${yellow("⚠")} stack: capacity flag is stale${host} (${beacon}). Scheduler lag, not a Helper outage. ` +
760
+ "Do NOT report blocked or ask for a Helper restart.\n" +
761
+ (data.wait ? ` Wait for capacity, then re-run: bb wait '${data.wait}' (or pass --wait-capacity <dur>)` : ` ${CAPACITY_STALE_RULE}`);
762
+ }
763
+ if (data.phase === "sustained") {
764
+ const age = Number.isFinite(data.beacon_age_secs) ? `for ${humanSecs(data.beacon_age_secs)}` : "ever recorded";
765
+ return `${yellow("⚠")} stack: no Helper beacon ${age}${host} (stale since ${data.stale_since ?? "unknown"}) — ` +
766
+ "report BLOCKED quoting stale_since; check `launchctl print user/$UID/com.botbuddy.helper.worker` and the worker log, " +
767
+ "or restart BotBuddy Helper.";
768
+ }
769
+ // A pre-BOT-2024 server sends no phase: give the rule, never a restart ask.
770
+ return `${yellow("⚠")} stack: ${CAPACITY_STALE_RULE}`;
771
+ }
772
+
773
+ /** The refusal's phase fields worth keeping on a receipt (empty for any other refusal). */
774
+ export function capacityStaleReceiptFields(data) {
775
+ if (data?.code !== HOST_CAPACITY_SOURCE_STALE_CODE) return {};
776
+ const out = {};
777
+ for (const k of ["phase", "wait", "stale_since", "beacon_age_secs", "stale_for_secs"]) {
778
+ if (data[k] !== undefined) out[k] = data[k];
779
+ }
780
+ return out;
781
+ }
782
+
783
+ /** `--wait-capacity <dur>`: `90`, `90s`, `10m`, `1h` → seconds (null when invalid). */
784
+ export function parseWaitCapacityDuration(raw) {
785
+ const m = /^([1-9]\d*)([smh]?)$/.exec(String(raw ?? "").trim());
786
+ if (!m) return null;
787
+ return Number(m[1]) * ({ "": 1, s: 1, m: 60, h: 3600 })[m[2]];
788
+ }
789
+
790
+ const BOTBUDDY_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "botbuddy.mjs");
791
+
792
+ /** argv for the park: this CLI's own `bb wait` on the server's condition, bounded by --timeout. */
793
+ export function capacityWaitArgv(condition, timeoutSec) {
794
+ return [process.execPath, BOTBUDDY_BIN, "wait", condition, "--timeout", String(timeoutSec)];
795
+ }
796
+
797
+ /**
798
+ * Park on the server's capacity wait via the real `bb wait` (registration, ranked
799
+ * grants, beacon-stale handling, checkpoint) — never a shell sleep/poll loop. Its
800
+ * receipt is captured; its stderr (arm notices) passes through.
801
+ */
802
+ export function defaultWaitCapacity(condition, timeoutSec, { signal } = {}) {
803
+ const [cmd, ...args] = capacityWaitArgv(condition, timeoutSec);
804
+ return new Promise((resolve) => {
805
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- fixed argv (this CLI's own bin), no shell.
806
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "inherit"], signal });
807
+ let stdout = "";
808
+ child.stdout.on("data", (d) => { stdout += d; });
809
+ const receiptOf = () => {
810
+ const last = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
811
+ try { return last ? JSON.parse(last) : null; } catch { return null; }
812
+ };
813
+ child.on("error", (error) => resolve({ exitCode: EXIT.INTERNAL, receipt: null, error: error?.message ?? String(error) }));
814
+ child.on("close", (code) => resolve({ exitCode: code ?? EXIT.INTERNAL, receipt: receiptOf() }));
815
+ });
816
+ }
817
+
818
+ /**
819
+ * BOT-2024: request a lease; on a TRANSIENT stale-capacity refusal with
820
+ * `--wait-capacity`, park on the server's `wait` condition and re-attempt admission
821
+ * exactly once — ONLY when the wait MATCHED (exit 0). Sustained (or phase-less)
822
+ * refusals never park. The deadline returns `capacityTimeout`; any other nonzero wait
823
+ * exit (auth/backend failure, the host-pinned capacity_source_stale exit after its
824
+ * beacon-confirm window, …) is the wait's own terminal outcome and returns
825
+ * `waitFailed` for the caller to propagate typed — retrying there would mask it
826
+ * (Codex #1018 P2).
827
+ */
828
+ export async function requestWithCapacityWait(request, opts, { waitCapacity = defaultWaitCapacity, log, signal = null } = {}) {
829
+ let req = await request();
830
+ const stale = (r) => r?.ok && r.data && !r.data.success && r.data.code === HOST_CAPACITY_SOURCE_STALE_CODE;
831
+ if (!stale(req)) return { req };
832
+ const guidance = capacityStaleGuidance(req.data);
833
+ if (guidance) log(guidance);
834
+ if (!opts.waitCapacity || req.data.phase !== "transient" || !req.data.wait) return { req };
835
+ log(`${dim("…")} stack: --wait-capacity: parking up to ${opts.waitCapacity}s on bb wait '${req.data.wait}'`);
836
+ const parked = await waitCapacity(req.data.wait, opts.waitCapacity, { signal });
837
+ if (signal?.aborted) return { req, interrupted: true };
838
+ if (parked?.exitCode === EXIT.TIMEOUT) return { req, capacityTimeout: true, waitReceipt: parked.receipt ?? null };
839
+ if (parked?.exitCode !== 0) {
840
+ const exitCode = Number.isInteger(parked?.exitCode) ? parked.exitCode : EXIT.INTERNAL;
841
+ const detail = parked?.receipt?.error ?? parked?.error ?? parked?.receipt?.outcome ?? "no receipt";
842
+ log(`${yellow("⚠")} stack: --wait-capacity: bb wait '${req.data.wait}' exited ${exitCode} (${detail}); not retrying admission.`);
843
+ return { req, waitFailed: { exitCode, receipt: parked?.receipt ?? null, detail } };
844
+ }
845
+ req = await request();
846
+ if (stale(req)) {
847
+ const again = capacityStaleGuidance(req.data);
848
+ if (again) log(again);
849
+ }
850
+ return { req };
851
+ }
852
+
711
853
  // The MCP lease endpoint accepts a session token in x-agent-api-key, whereas
712
854
  // the event-stream relay requires that same token in its bearer form too. Keep
713
855
  // the lease RPC shape unchanged and derive the relay-compatible form only at
@@ -1279,21 +1421,62 @@ export const STACK_DISPOSITIONS = Object.freeze(["destroy", "stop"]);
1279
1421
  * the project's data volumes (`supabase_db_/storage_/edge_runtime_<project_id>`); `--workdir`
1280
1422
  * scopes that to the stack's own project_id, never `--all`. `stop` keeps the volumes for a
1281
1423
  * cheap re-provision. An unknown disposition throws before Docker is touched.
1424
+ *
1425
+ * BOT-2001: when `supabase stop` fails (2026-10-02: a transient exit 1 whose real error sat
1426
+ * behind the CLI's deprecation WARN), log the tail of its output and fall back to a
1427
+ * label-scoped teardown of exactly this project (`com.supabase.cli.project=<project_id>`),
1428
+ * on the same validated daemon. Success requires the post-check `docker ps -a` for that
1429
+ * label to be empty; no readable project_id → no fallback, never an unscoped `docker rm`.
1282
1430
  */
1283
- export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawnSync, env = process.env) {
1431
+ export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawnSync, env = process.env, {
1432
+ readProjectId = projectIdUnder, log = (line) => process.stderr.write(line),
1433
+ } = {}) {
1284
1434
  if (!STACK_DISPOSITIONS.includes(disposition)) {
1285
1435
  throw new Error(`localTeardown: unknown disposition ${JSON.stringify(disposition)} (expected one of ${STACK_DISPOSITIONS.join(", ")})`);
1286
1436
  }
1287
1437
  const args = ["stop", "--workdir", stackDir, ...(disposition === "destroy" ? ["--no-backup"] : [])];
1288
- process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold(`supabase ${args.join(" ")}`)} (disposition ${disposition}).\n`);
1289
- const res = spawn("supabase", args, {
1290
- encoding: "utf8",
1291
- env: dockerEnvForTarget(dockerTarget, env),
1292
- });
1293
- if (res.status !== 0) {
1294
- process.stderr.write(`${yellow("⚠")} stack: supabase stop returned ${res.status}: ${(res.stderr || res.stdout || "").slice(0, 300)}\n`);
1438
+ log(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold(`supabase ${args.join(" ")}`)} (disposition ${disposition}).\n`);
1439
+ const dockerEnv = dockerEnvForTarget(dockerTarget, env);
1440
+ const res = spawn("supabase", args, { encoding: "utf8", env: dockerEnv });
1441
+ if (res.status === 0) return true;
1442
+ // The real error is LAST (WARN/upgrade notices lead), so keep the tail, not the head.
1443
+ const output = `${res.stderr || ""}${res.stdout || ""}`.trim();
1444
+ log(`${yellow("⚠")} stack: supabase stop returned ${res.status}:\n${output.slice(-2000)}\n`);
1445
+ const projectId = readProjectId(stackDir);
1446
+ if (!projectId) {
1447
+ log(`${yellow("⚠")} stack: no readable project_id under ${stackDir} — not attempting a label-scoped teardown.\n`);
1448
+ return false;
1449
+ }
1450
+ return labelScopedTeardown(projectId, disposition, spawn, dockerEnv, log);
1451
+ }
1452
+
1453
+ /** BOT-2001: remove exactly one Supabase project's containers, network and (for `destroy`)
1454
+ * volumes by the CLI's project label; true iff a re-list finds NONE of the resource classes
1455
+ * this disposition removes (Codex #1012: a busy volume or network fails closed too). */
1456
+ function labelScopedTeardown(projectId, disposition, spawn, dockerEnv, log) {
1457
+ const filter = ["--filter", `label=com.supabase.cli.project=${projectId}`];
1458
+ const docker = (args) => spawn("docker", args, { encoding: "utf8", env: dockerEnv });
1459
+ const ids = (res) => (res.status === 0 ? (res.stdout || "").split(/\s+/).filter(Boolean) : null);
1460
+ const classes = [
1461
+ { name: "container", list: ["ps", "-aq", ...filter], remove: ["rm", "-f"] },
1462
+ ...(disposition === "destroy" ? [{ name: "volume", list: ["volume", "ls", "-q", ...filter], remove: ["volume", "rm", "-f"] }] : []),
1463
+ { name: "network", list: ["network", "ls", "-q", ...filter], remove: ["network", "rm"] },
1464
+ ];
1465
+ log(`${yellow("⚠")} stack: falling back to a label-scoped teardown of project ${bold(projectId)}.\n`);
1466
+ for (const c of classes) {
1467
+ const found = ids(docker(c.list));
1468
+ if (found?.length) {
1469
+ const rm = docker([...c.remove, ...found]);
1470
+ if (rm.status !== 0) log(`${yellow("⚠")} stack: removing ${c.name}(s) of ${projectId} failed: ${`${rm.stderr || rm.stdout || ""}`.trim().slice(-500)}\n`);
1471
+ }
1472
+ }
1473
+ const left = classes.map((c) => ({ name: c.name, ids: ids(docker(c.list)) })).filter((c) => c.ids === null || c.ids.length);
1474
+ if (left.length) {
1475
+ log(`${yellow("⚠")} stack: project ${projectId} still has ${left.map((c) => (c.ids === null ? `unverifiable ${c.name}s` : `${c.ids.length} ${c.name}(s)`)).join(", ")} after the fallback — the slot stays fenced.\n`);
1476
+ return false;
1295
1477
  }
1296
- return res.status === 0;
1478
+ log(`${yellow("⚠")} stack: label-scoped teardown of project ${projectId} succeeded.\n`);
1479
+ return true;
1297
1480
  }
1298
1481
 
1299
1482
  function emit(receipt, opts, code) {
@@ -1314,6 +1497,7 @@ export async function cmdUp(opts, {
1314
1497
  machineUuidFn = machineUuid,
1315
1498
  assertIsolated = validateLocalExecTarget,
1316
1499
  refreshAuth = refreshRejectedCacheAuth,
1500
+ waitCapacity = defaultWaitCapacity,
1317
1501
  } = {}) {
1318
1502
  let slot;
1319
1503
  try { slot = deriveSlot(opts); } catch (e) {
@@ -1396,11 +1580,58 @@ export async function cmdUp(opts, {
1396
1580
  ? emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
1397
1581
  : emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, EXIT.BACKEND);
1398
1582
  }
1583
+ // BOT-2024: phase-aware stale-capacity guidance; --wait-capacity parks a TRANSIENT
1584
+ // refusal on the server's capacity wait and re-admits once.
1585
+ if (req.data && !req.data.success && req.data.code === HOST_CAPACITY_SOURCE_STALE_CODE) {
1586
+ let first = req;
1587
+ // Codex #1018 R4: the park can outlive the pre-wait credential (expired/revoked
1588
+ // while parked; the spawned `bb wait` may itself have renewed the worktree
1589
+ // session). So the post-wait retry RE-LOADS the credential from disk, then takes
1590
+ // the standard refresh-once-on-auth-rejection path. A second rejection falls
1591
+ // through to the typed AUTH exit below — never a loop.
1592
+ const retryAfterWait = async () => {
1593
+ const reloaded = await authProvider();
1594
+ if (reloaded) auth = reloaded;
1595
+ let retried = await call("request_stack_lease", leaseArgs);
1596
+ if (!retried.ok && retried.auth) {
1597
+ const fresh = await refreshAuth(auth, { cwd: process.cwd() });
1598
+ if (fresh) {
1599
+ auth = fresh;
1600
+ retried = await call("request_stack_lease", leaseArgs);
1601
+ }
1602
+ }
1603
+ return retried;
1604
+ };
1605
+ const admission = await requestWithCapacityWait(
1606
+ async () => { if (first) { const r = first; first = null; return r; } return retryAfterWait(); },
1607
+ opts, { waitCapacity, log: (line) => process.stderr.write(`${line}\n`) },
1608
+ );
1609
+ if (admission.capacityTimeout) {
1610
+ return emitResult(buildReceipt({
1611
+ command: "up", outcome: "timeout", code: admission.req.data.code, ...capacityStaleReceiptFields(admission.req.data),
1612
+ wait_exit_code: EXIT.TIMEOUT, wait_receipt: admission.waitReceipt,
1613
+ error: `capacity did not recover within --wait-capacity ${opts.waitCapacity}s (${admission.req.data.code})`, slot,
1614
+ }), opts, EXIT.TIMEOUT);
1615
+ }
1616
+ if (admission.waitFailed) {
1617
+ return emitResult(buildReceipt({
1618
+ command: "up", outcome: "error", code: admission.req.data.code, ...capacityStaleReceiptFields(admission.req.data),
1619
+ wait_exit_code: admission.waitFailed.exitCode, wait_receipt: admission.waitFailed.receipt,
1620
+ error: `--wait-capacity: bb wait exited ${admission.waitFailed.exitCode} (${admission.waitFailed.detail})`, slot,
1621
+ }), opts, admission.waitFailed.exitCode);
1622
+ }
1623
+ req = admission.req;
1624
+ if (!req.ok) {
1625
+ return emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, req.auth ? EXIT.AUTH : EXIT.BACKEND);
1626
+ }
1627
+ }
1399
1628
  const d = req.data;
1400
1629
  if (!d.success) {
1401
1630
  const hint = worktreeRegistrationHint(d.code, auth) ?? stackPathRootRefusalNotice(d.code, d.message);
1402
1631
  if (hint) process.stderr.write(`${hint}\n`);
1403
- return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: leaseRefusalError(d), slot }), opts, leaseRefusalExit(d.code));
1632
+ return emitResult(buildReceipt({
1633
+ command: "up", outcome: "error", code: d.code, ...capacityStaleReceiptFields(d), error: leaseRefusalError(d), slot,
1634
+ }), opts, leaseRefusalExit(d.code));
1404
1635
  }
1405
1636
  let leaseId = d.lease_id;
1406
1637
  let state = d.state;
@@ -1989,6 +2220,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1989
2220
  const clock = adapters.clock ?? { setInterval, clearInterval, setTimeout, clearTimeout };
1990
2221
  const signals = adapters.signals ?? process;
1991
2222
  const provisionTimeout = opts.provisionTimeout ?? opts.timeout;
2223
+ const stderrLine = adapters.stderr ?? ((line) => process.stderr.write(`${line}\n`));
1992
2224
  let leaseId = null;
1993
2225
  let child = null;
1994
2226
  let childResult = null;
@@ -2135,7 +2367,34 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
2135
2367
  };
2136
2368
  // The retrying caller (api → rpc.call) transparently clears+self-heals+retries the
2137
2369
  // FIRST auth rejection among any of request/get/touch/release.
2138
- const request = await api.request(leaseArgs);
2370
+ // BOT-2024: a stale-capacity refusal prints phase-aware guidance; with the opt-in
2371
+ // --wait-capacity a TRANSIENT one parks on the server's capacity wait and re-admits once.
2372
+ activeWaitAbort = new AbortController();
2373
+ const admission = await requestWithCapacityWait(() => api.request(leaseArgs), opts, {
2374
+ waitCapacity: adapters.waitCapacity ?? defaultWaitCapacity, log: stderrLine, signal: activeWaitAbort.signal,
2375
+ });
2376
+ activeWaitAbort = null;
2377
+ const request = admission.req;
2378
+ // Interrupted DURING the capacity park: the refused request created no lease, so
2379
+ // there is nothing to release. (A signal during a request that DID admit falls
2380
+ // through to the existing post-lease interruption branch, which cleans up.)
2381
+ if (admission.interrupted) {
2382
+ return { exitCode: SIGNAL_EXIT[receivedSignal] ?? EXIT.INTERNAL, outcome: "interrupted", code: request?.data?.code, ...capacityStaleReceiptFields(request?.data) };
2383
+ }
2384
+ if (admission.capacityTimeout) {
2385
+ return {
2386
+ exitCode: EXIT.TIMEOUT, outcome: "timeout", code: request.data.code, ...capacityStaleReceiptFields(request.data),
2387
+ wait_exit_code: EXIT.TIMEOUT, wait_receipt: admission.waitReceipt,
2388
+ error: `capacity did not recover within --wait-capacity ${opts.waitCapacity}s (${request.data.code})`,
2389
+ };
2390
+ }
2391
+ if (admission.waitFailed) {
2392
+ return {
2393
+ exitCode: admission.waitFailed.exitCode, outcome: "error", code: request.data.code, ...capacityStaleReceiptFields(request.data),
2394
+ wait_exit_code: admission.waitFailed.exitCode, wait_receipt: admission.waitFailed.receipt,
2395
+ error: `--wait-capacity: bb wait exited ${admission.waitFailed.exitCode} (${admission.waitFailed.detail})`,
2396
+ };
2397
+ }
2139
2398
  if (!request?.ok) {
2140
2399
  // Codex #928 R23 (P2): `status: null` (callToolJson's fetch-threw branch) means
2141
2400
  // NO response was ever received — the request may have reached the backend and
@@ -2151,8 +2410,11 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
2151
2410
  if (!request.data?.success) {
2152
2411
  const hint = worktreeRegistrationHint(request.data?.code, rpc.auth)
2153
2412
  ?? stackPathRootRefusalNotice(request.data?.code, request.data?.message);
2154
- if (hint) process.stderr.write(`${hint}\n`);
2155
- return { exitCode: leaseRefusalExit(request.data?.code), outcome: "error", code: request.data?.code, error: leaseRefusalError(request.data) };
2413
+ if (hint) stderrLine(hint);
2414
+ return {
2415
+ exitCode: leaseRefusalExit(request.data?.code), outcome: "error", code: request.data?.code,
2416
+ ...capacityStaleReceiptFields(request.data), error: leaseRefusalError(request.data),
2417
+ };
2156
2418
  }
2157
2419
  leaseId = request.data.lease_id;
2158
2420
  if (request.data.reused) {
@@ -2300,6 +2562,9 @@ async function cmdRun(opts, childArgv) {
2300
2562
  // BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
2301
2563
  // so automation can branch on it instead of matching the message text.
2302
2564
  command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
2565
+ // BOT-2024: a stale-capacity refusal carries its phase + armable wait on the receipt.
2566
+ ...capacityStaleReceiptFields(result),
2567
+ ...(result.wait_exit_code != null ? { wait_exit_code: result.wait_exit_code, wait_receipt: result.wait_receipt ?? null } : {}),
2303
2568
  child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
2304
2569
  fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
2305
2570
  }), opts, result.exitCode);
package/src/wait-core.mjs CHANGED
@@ -1179,13 +1179,13 @@ export async function runWaitLoop({
1179
1179
  // the primary hysteresis); 0 = exit immediately on the stale signal. Injectable so
1180
1180
  // a clock-controlled test can drive it. Not a grammar surface (no new condition name).
1181
1181
  //
1182
- // Default MUST exceed the beacon evaluator's worst-case cadence (Codex P2). The
1183
- // recovery transition is emitted by the `* * * * *` (60s) eval_container_beacon_staleness
1184
- // cron, and claim_container_job only refreshes last_beacon_at — it does not itself emit
1185
- // recovery — so a host that resumes polling right after a stale emission has its
1186
- // recovery frame arrive up to ~60s later. A confirm shorter than that would exit the
1187
- // wait capacity_source_stale even though the host recovered seconds after the stale
1188
- // frame. 90s = 60s cron + margin.
1182
+ // Default MUST exceed the beacon evaluator's worst-case cadence (Codex P2). Before
1183
+ // BOT-2024 the recovery transition was emitted only by the `* * * * *` (60s)
1184
+ // eval_container_beacon_staleness cron, so a host that resumed polling right after a
1185
+ // stale emission had its recovery frame arrive up to ~60s later. Since BOT-2024 the
1186
+ // beacon itself (claim_container_job / attested registerHost) emits the recovery, so
1187
+ // the frame now lands on the Helper's next beacon; 90s still covers one ~45-60s
1188
+ // Helper beacon interval plus margin, and an older server's cron-only recovery.
1189
1189
  beaconConfirmMs = 90_000,
1190
1190
  // BOT-1837: the arm-time notice (if any) to carry into a TIMEOUT receipt's
1191
1191
  // `error`, so the receipt itself says what CI was already doing when this wait