@botbuddy/cli 1.42.1 → 1.43.1

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 +95 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.42.1",
3
+ "version": "1.43.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -60,6 +60,16 @@ const CALLBACK_PORT_REMOTE_REFUSAL =
60
60
  "(pre-forward GatewayPorts probe). Use --local-only for a run that needs a callback port.";
61
61
  export const DEFAULT_RECEIPT_MAX_BYTES = 10240;
62
62
  export const DEFAULT_TIMEOUT_SEC = 3600;
63
+ /**
64
+ * BOT-2154: how long `stack up --local-exec` lets `supabase start` run before it gives up and
65
+ * releases the lease itself (`--provision-timeout` overrides). Deliberately below the server's
66
+ * 1200 s `local_exec_provision_ttl_secs` backstop, so the CLI's own clean `stack done` wins the
67
+ * race against the reaper.
68
+ */
69
+ export const LOCAL_EXEC_PROVISION_TIMEOUT_SEC = 900;
70
+ /** BOT-2230: the bounds the control plane enforces on an owner place hold (hold_stack_lease_place). */
71
+ export const HOLD_ON_TIMEOUT_MIN_SEC = 60;
72
+ export const HOLD_ON_TIMEOUT_MAX_SEC = 14400;
63
73
  /** How often a run renews its stack directory's retention while it still exists
64
74
  * (BOT-1798): frequent enough to cover the pre-child queue/provision waits. */
65
75
  const RETENTION_REFRESH_MS = 5 * 60 * 1000;
@@ -104,6 +114,13 @@ ${bold("up OPTIONS")}
104
114
  --idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
105
115
  --timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
106
116
  --no-wait If the host is full, print the queue position and exit (don't park).
117
+ --hold-on-timeout <seconds>
118
+ BOT-2230: when the --timeout park runs out with the lease still queued,
119
+ keep its queue place for <seconds> (60–14400) instead of losing it: the
120
+ lease stays queued but is not dispatched, and re-running the same
121
+ \`stack up\` (same agent + slot) before then re-attaches to it. The exit-2
122
+ receipt carries queue_hold {held, until, queue_position}; \`stack done
123
+ <lease_id>\` gives the place up.
107
124
  --claim-only LOCAL-EXEC only: wait for and hold a provisioning lease without starting
108
125
  Supabase. Resume with the identical \`stack up --local-exec\` command.
109
126
  --wait-capacity <dur> Opt-in (BOT-2024): on a TRANSIENT HOST_CAPACITY_SOURCE_STALE refusal, park
@@ -112,6 +129,10 @@ ${bold("up OPTIONS")}
112
129
  deadline (exit 2) and a failed wait (its own exit code) never retry.
113
130
  --local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
114
131
  stack and self-activate. Refused at the worktree root (shared stack).
132
+ --provision-timeout N LOCAL-EXEC: seconds 'supabase start' may run (default ${LOCAL_EXEC_PROVISION_TIMEOUT_SEC}). On
133
+ timeout up runs 'stack done --local-exec' itself and exits 6 with
134
+ park_reason provision_timeout (BOT-2154). The server also fails a local-exec
135
+ lease still provisioning after local_exec_provision_ttl_secs (default 1200).
115
136
  Refused (exit 4, ENGINE_PUBLISHES_BEYOND_LOOPBACK) when the Docker
116
137
  engine would publish the stack's ports beyond loopback (BOT-2146).
117
138
  --docker-context <name> Explicit Docker context for the mandatory local preflight
@@ -255,7 +276,7 @@ export function parseStackArgs(argv) {
255
276
  slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
256
277
  prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
257
278
  timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
258
- connectionFile: null, noWait: false, claimOnly: false, localExec: false, waitCapacity: null,
279
+ connectionFile: null, noWait: false, claimOnly: false, localExec: false, waitCapacity: null, holdOnTimeout: null,
259
280
  dockerContext: null, dockerEndpoint: null,
260
281
  // BOT-2142: execution placement (null = command default), remote-lease callback + bundle cap.
261
282
  execution: null, remote: false, localOnly: false, callbackPort: null, bundleMaxBytes: null,
@@ -356,6 +377,15 @@ export function parseStackArgs(argv) {
356
377
  }
357
378
  break;
358
379
  }
380
+ case "--hold-on-timeout": {
381
+ if (need(a, rest[i + 1])) {
382
+ const n = Number(rest[++i]);
383
+ if (!Number.isInteger(n) || n < HOLD_ON_TIMEOUT_MIN_SEC || n > HOLD_ON_TIMEOUT_MAX_SEC) {
384
+ errors.push(`--hold-on-timeout must be a whole number of seconds from ${HOLD_ON_TIMEOUT_MIN_SEC} to ${HOLD_ON_TIMEOUT_MAX_SEC}`);
385
+ } else opts.holdOnTimeout = n;
386
+ }
387
+ break;
388
+ }
359
389
  case "--no-wait": opts.noWait = true; break;
360
390
  case "--claim-only": opts.claimOnly = true; break;
361
391
  case "--local-exec": opts.localExec = true; break;
@@ -410,6 +440,11 @@ export function parseStackArgs(argv) {
410
440
  if (opts.waitCapacity != null && !["up", "run"].includes(command)) {
411
441
  errors.push("--wait-capacity is valid only with `stack up` or `stack run`");
412
442
  }
443
+ if (opts.holdOnTimeout != null && command !== "up") {
444
+ errors.push("--hold-on-timeout is valid only with `stack up`");
445
+ } else if (opts.holdOnTimeout != null && opts.noWait) {
446
+ errors.push("--hold-on-timeout cannot be combined with --no-wait: without a park there is no timeout to keep a place across");
447
+ }
413
448
  if (opts.claimOnly && (command !== "up" || !opts.localExec)) {
414
449
  errors.push("--claim-only is valid only with `stack up --local-exec`");
415
450
  }
@@ -453,6 +488,22 @@ export function describeQueuePosition(d) {
453
488
  return d?.scored_ticket_id ? `${where}, scored under ${d.scored_ticket_id}` : where;
454
489
  }
455
490
 
491
+ /**
492
+ * BOT-2230: hold a timed-out queued lease's place for its owner (`hold_stack_lease_place`).
493
+ * Never throws: a refusal or a control plane without the tool is reported as `held: false`
494
+ * with the server's code, so the caller decides (the hermetic lane then cancels as before).
495
+ */
496
+ export async function holdQueuePlace(call, leaseId, holdSecs) {
497
+ let res;
498
+ try { res = await call("hold_stack_lease_place", { lease_id: leaseId, hold_secs: holdSecs }); }
499
+ catch (err) { return { held: false, code: null, error: String(err?.message ?? err) }; }
500
+ const d = res?.data;
501
+ if (res?.ok && d?.success) {
502
+ return { held: true, until: d.queue_hold_until ?? null, queue_position: d.queue_position ?? null, hold_secs: d.hold_secs ?? holdSecs };
503
+ }
504
+ return { held: false, code: d?.code ?? null, error: d?.message || d?.error || res?.error || "hold refused" };
505
+ }
506
+
456
507
  /** BOT-2019: the queue fields a server returned for a queued lease, for a receipt. */
457
508
  /**
458
509
  * BOT-2099: the human lines for the server's unit ledger (`stack_lease_unit_ledger`) — who
@@ -1811,7 +1862,16 @@ export function localProvision(opts, dockerTarget, execution, spawn = spawnSync,
1811
1862
  const url = resolveIsolatedStackApiUrl(stackDir, env);
1812
1863
  const spawnEnv = { ...dockerEnvForTarget(dockerTarget, env), VITE_SUPABASE_URL: url };
1813
1864
  process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in ${stackDir} (VITE_SUPABASE_URL=${url}).\n`);
1814
- const start = spawn("supabase", ["start", "--workdir", stackDir], { encoding: "utf8", env: spawnEnv });
1865
+ // BOT-2154: never an unbounded start — a hung `supabase start` held the host slot for hours.
1866
+ const timeoutSec = opts?.provisionTimeout ?? LOCAL_EXEC_PROVISION_TIMEOUT_SEC;
1867
+ const start = spawn("supabase", ["start", "--workdir", stackDir], {
1868
+ encoding: "utf8", env: spawnEnv, timeout: timeoutSec * 1000, killSignal: "SIGTERM",
1869
+ });
1870
+ if (start.error?.code === "ETIMEDOUT") {
1871
+ const timedOut = new Error(`supabase start did not finish within ${timeoutSec}s (--provision-timeout); stopping it and releasing the lease`);
1872
+ timedOut.code = "PROVISION_TIMEOUT";
1873
+ throw timedOut;
1874
+ }
1815
1875
  if (start.status !== 0) {
1816
1876
  throw new Error(`supabase start failed (${start.status}): ${(start.stderr || start.stdout || "").slice(0, 400)}`);
1817
1877
  }
@@ -1902,11 +1962,21 @@ function emit(receipt, opts, code) {
1902
1962
 
1903
1963
  // ── command orchestration ────────────────────────────────────────────────────
1904
1964
 
1965
+ /** BOT-2154: run `stack done` in-process and capture its receipt instead of printing it, so
1966
+ * `stack up` still emits exactly one receipt. */
1967
+ async function runLocalDone(leaseId, opts, deps = {}) {
1968
+ let receipt = null;
1969
+ const exitCode = await cmdDone(leaseId, opts, { ...deps, emitResult: (r, _o, code) => { receipt = r; return code; } });
1970
+ return { exitCode, receipt };
1971
+ }
1972
+
1905
1973
  export async function cmdUp(opts, {
1906
1974
  runPreflight = runLocalExecPreflight,
1907
1975
  callTool = callToolJson,
1908
1976
  authProvider = stackAuthHeader,
1909
1977
  localProvisionFn = localProvision,
1978
+ // BOT-2154: the self-release after a timed-out local start (`stack done --local-exec`).
1979
+ localDoneFn = null,
1910
1980
  waitFn = waitForLease,
1911
1981
  emitResult = emit,
1912
1982
  machineUuidFn = machineUuid,
@@ -2107,7 +2177,14 @@ export async function cmdUp(opts, {
2107
2177
  let ledger = d.ledger ?? null;
2108
2178
  try { const read = await call("get_stack_lease", { lease_id: leaseId }); if (read?.ok && read.data?.success && read.data.ledger) ledger = read.data.ledger; } catch { /* keep the request-time ledger */ }
2109
2179
  for (const line of describeUnitLedger(ledger)) process.stderr.write(`stack: ${line}\n`);
2110
- return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity`, ...(ledger ? { unit_ledger: ledger } : {}) }), opts, EXIT.TIMEOUT);
2180
+ // BOT-2230: keep the queue place for the owner's next attempt instead of losing it.
2181
+ const queueHold = opts.holdOnTimeout ? await holdQueuePlace(call, leaseId, opts.holdOnTimeout) : null;
2182
+ if (queueHold?.held) {
2183
+ process.stderr.write(`stack: lease ${leaseId} keeps queue place ${queueHold.queue_position ?? "?"} until ${queueHold.until} — re-run the same \`stack up\` before then to re-attach (\`stack done ${leaseId}\` gives it up)\n`);
2184
+ } else if (queueHold) {
2185
+ process.stderr.write(`${yellow("⚠")} stack: lease ${leaseId} could not keep its queue place (${queueHold.code ?? "error"}: ${queueHold.error})\n`);
2186
+ }
2187
+ return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity`, ...(ledger ? { unit_ledger: ledger } : {}), ...(queueHold ? { queue_hold: queueHold } : {}) }), opts, EXIT.TIMEOUT);
2111
2188
  }
2112
2189
  if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
2113
2190
  if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
@@ -2231,6 +2308,21 @@ export async function cmdUp(opts, {
2231
2308
  try {
2232
2309
  conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget, execution), localDockerTarget);
2233
2310
  } catch (e) {
2311
+ if (e?.code === "PROVISION_TIMEOUT") {
2312
+ // BOT-2154: a bounded start that ran out of time. Release the lease ourselves —
2313
+ // `stack done --local-exec` stops whatever the start left behind and reaps the lease
2314
+ // — instead of leaving it provisioning for the server's backstop to find.
2315
+ const done = await (localDoneFn ?? ((id, doneOpts) => runLocalDone(id, doneOpts, { callTool, authProvider })))(
2316
+ leaseId, { ...opts, localExec: true, disposition: "destroy", json: false });
2317
+ const doneReceipt = done?.receipt ?? {};
2318
+ const cleaned = done?.exitCode === 0;
2319
+ return emitResult(buildReceipt({
2320
+ command: "up", outcome: "timeout", code: "PROVISION_TIMEOUT", park_reason: "provision_timeout",
2321
+ lease_id: leaseId, slot, error: e.message, cleanup: cleaned,
2322
+ done: { outcome: doneReceipt.outcome ?? null, state: doneReceipt.state ?? null, exit_code: done?.exitCode ?? null },
2323
+ ...(cleaned ? {} : { cleanup_error: doneReceipt.error ?? `stack done --local-exec exited ${done?.exitCode}` }),
2324
+ }), opts, EXIT.LEASE_FAILED);
2325
+ }
2234
2326
  // Once the local provisioner has run, `supabase start` may have created
2235
2327
  // (or fully started) containers even on a nonzero exit or a status-parse
2236
2328
  // failure. Cancelling here would reap the lease and free the slot while