@botbuddy/cli 1.42.1 → 1.43.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/stack.mjs +49 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.42.1",
3
+ "version": "1.43.0",
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,9 @@ 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
+ /** BOT-2230: the bounds the control plane enforces on an owner place hold (hold_stack_lease_place). */
64
+ export const HOLD_ON_TIMEOUT_MIN_SEC = 60;
65
+ export const HOLD_ON_TIMEOUT_MAX_SEC = 14400;
63
66
  /** How often a run renews its stack directory's retention while it still exists
64
67
  * (BOT-1798): frequent enough to cover the pre-child queue/provision waits. */
65
68
  const RETENTION_REFRESH_MS = 5 * 60 * 1000;
@@ -104,6 +107,13 @@ ${bold("up OPTIONS")}
104
107
  --idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
105
108
  --timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
106
109
  --no-wait If the host is full, print the queue position and exit (don't park).
110
+ --hold-on-timeout <seconds>
111
+ BOT-2230: when the --timeout park runs out with the lease still queued,
112
+ keep its queue place for <seconds> (60–14400) instead of losing it: the
113
+ lease stays queued but is not dispatched, and re-running the same
114
+ \`stack up\` (same agent + slot) before then re-attaches to it. The exit-2
115
+ receipt carries queue_hold {held, until, queue_position}; \`stack done
116
+ <lease_id>\` gives the place up.
107
117
  --claim-only LOCAL-EXEC only: wait for and hold a provisioning lease without starting
108
118
  Supabase. Resume with the identical \`stack up --local-exec\` command.
109
119
  --wait-capacity <dur> Opt-in (BOT-2024): on a TRANSIENT HOST_CAPACITY_SOURCE_STALE refusal, park
@@ -255,7 +265,7 @@ export function parseStackArgs(argv) {
255
265
  slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
256
266
  prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
257
267
  timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
258
- connectionFile: null, noWait: false, claimOnly: false, localExec: false, waitCapacity: null,
268
+ connectionFile: null, noWait: false, claimOnly: false, localExec: false, waitCapacity: null, holdOnTimeout: null,
259
269
  dockerContext: null, dockerEndpoint: null,
260
270
  // BOT-2142: execution placement (null = command default), remote-lease callback + bundle cap.
261
271
  execution: null, remote: false, localOnly: false, callbackPort: null, bundleMaxBytes: null,
@@ -356,6 +366,15 @@ export function parseStackArgs(argv) {
356
366
  }
357
367
  break;
358
368
  }
369
+ case "--hold-on-timeout": {
370
+ if (need(a, rest[i + 1])) {
371
+ const n = Number(rest[++i]);
372
+ if (!Number.isInteger(n) || n < HOLD_ON_TIMEOUT_MIN_SEC || n > HOLD_ON_TIMEOUT_MAX_SEC) {
373
+ errors.push(`--hold-on-timeout must be a whole number of seconds from ${HOLD_ON_TIMEOUT_MIN_SEC} to ${HOLD_ON_TIMEOUT_MAX_SEC}`);
374
+ } else opts.holdOnTimeout = n;
375
+ }
376
+ break;
377
+ }
359
378
  case "--no-wait": opts.noWait = true; break;
360
379
  case "--claim-only": opts.claimOnly = true; break;
361
380
  case "--local-exec": opts.localExec = true; break;
@@ -410,6 +429,11 @@ export function parseStackArgs(argv) {
410
429
  if (opts.waitCapacity != null && !["up", "run"].includes(command)) {
411
430
  errors.push("--wait-capacity is valid only with `stack up` or `stack run`");
412
431
  }
432
+ if (opts.holdOnTimeout != null && command !== "up") {
433
+ errors.push("--hold-on-timeout is valid only with `stack up`");
434
+ } else if (opts.holdOnTimeout != null && opts.noWait) {
435
+ errors.push("--hold-on-timeout cannot be combined with --no-wait: without a park there is no timeout to keep a place across");
436
+ }
413
437
  if (opts.claimOnly && (command !== "up" || !opts.localExec)) {
414
438
  errors.push("--claim-only is valid only with `stack up --local-exec`");
415
439
  }
@@ -453,6 +477,22 @@ export function describeQueuePosition(d) {
453
477
  return d?.scored_ticket_id ? `${where}, scored under ${d.scored_ticket_id}` : where;
454
478
  }
455
479
 
480
+ /**
481
+ * BOT-2230: hold a timed-out queued lease's place for its owner (`hold_stack_lease_place`).
482
+ * Never throws: a refusal or a control plane without the tool is reported as `held: false`
483
+ * with the server's code, so the caller decides (the hermetic lane then cancels as before).
484
+ */
485
+ export async function holdQueuePlace(call, leaseId, holdSecs) {
486
+ let res;
487
+ try { res = await call("hold_stack_lease_place", { lease_id: leaseId, hold_secs: holdSecs }); }
488
+ catch (err) { return { held: false, code: null, error: String(err?.message ?? err) }; }
489
+ const d = res?.data;
490
+ if (res?.ok && d?.success) {
491
+ return { held: true, until: d.queue_hold_until ?? null, queue_position: d.queue_position ?? null, hold_secs: d.hold_secs ?? holdSecs };
492
+ }
493
+ return { held: false, code: d?.code ?? null, error: d?.message || d?.error || res?.error || "hold refused" };
494
+ }
495
+
456
496
  /** BOT-2019: the queue fields a server returned for a queued lease, for a receipt. */
457
497
  /**
458
498
  * BOT-2099: the human lines for the server's unit ledger (`stack_lease_unit_ledger`) — who
@@ -2107,7 +2147,14 @@ export async function cmdUp(opts, {
2107
2147
  let ledger = d.ledger ?? null;
2108
2148
  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
2149
  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);
2150
+ // BOT-2230: keep the queue place for the owner's next attempt instead of losing it.
2151
+ const queueHold = opts.holdOnTimeout ? await holdQueuePlace(call, leaseId, opts.holdOnTimeout) : null;
2152
+ if (queueHold?.held) {
2153
+ 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`);
2154
+ } else if (queueHold) {
2155
+ process.stderr.write(`${yellow("⚠")} stack: lease ${leaseId} could not keep its queue place (${queueHold.code ?? "error"}: ${queueHold.error})\n`);
2156
+ }
2157
+ 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
2158
  }
2112
2159
  if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
2113
2160
  if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);