@botbuddy/cli 1.53.1 → 1.54.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 +66 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.53.1",
3
+ "version": "1.54.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -586,6 +586,7 @@ const CONSIDERED_VERDICT_TEXT = {
586
586
  HOST_CAPACITY_WAIT: () => "Docker pressure full",
587
587
  HOST_CAPACITY_BUDGET_FULL: () => "slot budget full",
588
588
  HOST_CAPACITY_INVENTORY_UNKNOWN: () => "unknown Docker resources in its census",
589
+ HOST_PRESSURE: () => "host under pressure",
589
590
  TENANT_RESERVATION_FULL: () => "tenant reservation full",
590
591
  SLOT_HELD: () => "this slot is already leased there",
591
592
  HOST_HELPER_OUTDATED: () => "Helper predates remote leases (protocol < 4)",
@@ -605,6 +606,33 @@ export function describeConsidered(considered) {
605
606
  });
606
607
  }
607
608
 
609
+ /**
610
+ * BOT-1630: the typed park reason of a queued lease, or null. The server holds a host under
611
+ * pressure (`docker info` failing, low memory, engine footprint) with verdict HOST_PRESSURE, and
612
+ * a Helper that hits it mid-provision defers the lease back to the queue with that reason.
613
+ */
614
+ export function queuedParkReason(d) {
615
+ if (!d || typeof d !== "object") return null;
616
+ const ledger = d.ledger && typeof d.ledger === "object" ? d.ledger : null;
617
+ if (ledger?.verdict === "HOST_PRESSURE" || ledger?.capacity?.code === "HOST_PRESSURE") return "host_pressure";
618
+ const here = Array.isArray(d.considered) ? d.considered.find((c) => c?.host_key === d.host_key) : null;
619
+ if (here?.verdict_code === "HOST_PRESSURE") return "host_pressure";
620
+ if (d.provision_defer_reason === "HOST_PRESSURE") return "host_pressure";
621
+ return null;
622
+ }
623
+
624
+ /** BOT-1630: "docker_unresponsive (docker_command_failed:docker info:-2; clears …)" per binding dimension. */
625
+ export function describePressure(d) {
626
+ const reasons = d?.ledger?.capacity?.pressure?.reasons;
627
+ if (!Array.isArray(reasons) || reasons.length === 0) return null;
628
+ return reasons.filter((r) => r && typeof r.dimension === "string").map((r) => {
629
+ const parts = [];
630
+ if (r.observed != null) parts.push(r.limit != null ? `${r.observed} vs limit ${r.limit}` : String(r.observed));
631
+ if (typeof r.clears_at === "string") parts.push(`clears ${r.clears_at}`);
632
+ return parts.length ? `${r.dimension} (${parts.join("; ")})` : r.dimension;
633
+ }).join(", ") || null;
634
+ }
635
+
608
636
  export function describeUnitLedger(ledger) {
609
637
  if (!ledger || typeof ledger !== "object") return [];
610
638
  const cap = ledger.capacity ?? {};
@@ -3547,16 +3575,28 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3547
3575
  const cleanup = await requestCleanup();
3548
3576
  return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
3549
3577
  }
3578
+ // BOT-1630: ONE park budget (--timeout) across every park. A Helper that finds the host under
3579
+ // pressure mid-provision defers the lease back to `queued` (its place kept); run then parks
3580
+ // again against the same deadline instead of timing out on the provision clock.
3581
+ const parkDeadlineMs = (adapters.now ?? Date.now)() + opts.timeout * 1000;
3582
+ let parkView = request.data;
3583
+ let parkReason = null;
3584
+ for (;;) {
3550
3585
  if (state === "queued") {
3551
3586
  // BOT-2019: say where the lease sits (server dispatch rank, or why it has none) before parking.
3552
- stderrLine(`stack: lease ${leaseId} queued — ${describeQueuePosition(request.data)}`);
3553
- for (const line of describeUnitLedger(request.data.ledger)) stderrLine(`stack: ${line}`);
3554
- for (const line of describeConsidered(request.data.considered)) stderrLine(`stack: ${line}`);
3587
+ stderrLine(`stack: lease ${leaseId} queued — ${describeQueuePosition(parkView)}`);
3588
+ for (const line of describeUnitLedger(parkView.ledger)) stderrLine(`stack: ${line}`);
3589
+ for (const line of describeConsidered(parkView.considered)) stderrLine(`stack: ${line}`);
3590
+ parkReason = queuedParkReason(parkView) ?? parkReason;
3591
+ if (parkReason === "host_pressure") {
3592
+ const why = describePressure(parkView);
3593
+ stderrLine(`stack: parked — park_reason host_pressure${why ? `: ${why}` : ""}; the lease keeps its queue place and starts when the host's pressure clears`);
3594
+ }
3555
3595
  activeWaitAbort = new AbortController();
3556
3596
  // BOT-2013: every reap state (reaping / reaped / reap_failed) means the queued lease is lost.
3557
3597
  // BOT-2099: an auth/availability blip is retried inside the park until its deadline;
3558
3598
  // it never releases a queued lease or becomes the lease's outcome.
3559
- const parked = await resilientWait(leaseId, (s) => s !== "queued" && s != null, isReaped, { timeoutSec: opts.timeout, auth: rpc.auth, signal: activeWaitAbort.signal });
3599
+ const parked = await resilientWait(leaseId, (s) => s !== "queued" && s != null, isReaped, { timeoutSec: opts.timeout, deadlineMs: parkDeadlineMs, auth: rpc.auth, signal: activeWaitAbort.signal });
3560
3600
  activeWaitAbort = null;
3561
3601
  const incident = parkIncident ? { incident: parkIncident } : {};
3562
3602
  if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup, ...incident }; }
@@ -3578,7 +3618,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3578
3618
  error: `lease ${leaseId} parked ${opts.timeout}s inside a platform incident (${parkIncident.reason} since ${parkIncident.first_at}), so its admission was never observed`,
3579
3619
  };
3580
3620
  }
3581
- return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup, ...incident, ...(ledger ? { ledger } : {}) };
3621
+ return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup, ...incident, ...(ledger ? { ledger } : {}), ...(parkReason ? { parkReason } : {}) };
3582
3622
  }
3583
3623
  if (parked?.failed) { const cleanup = await requestCleanup(); return { exitCode: EXIT.LEASE_FAILED, outcome: "error", leaseId, error: `lease was ${parked.state} while queued (reap_reason=${parked.reap_reason ?? "unknown"})`, cleanup, ...incident }; }
3584
3624
  if (parked?.error || parked?.auth) { const cleanup = await requestCleanup(); return { exitCode: parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup, ...incident }; }
@@ -3593,17 +3633,33 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3593
3633
  // provisionTimeout before the belt-and-braces recheck below ever notices
3594
3634
  // the lease is no longer provisioning. isReaped (== "reaping" || "reaped")
3595
3635
  // already covers both; cmdUp's equivalent waits use it for this reason.
3596
- const active = await resilientWait(leaseId, (s) => s === "active", isReaped, { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
3636
+ // BOT-1630: `queued` also ends this wait — a deferred provision (HOST_PRESSURE, capacity).
3637
+ const active = await resilientWait(leaseId, (s) => s === "active" || s === "queued", isReaped, { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
3597
3638
  activeWaitAbort = null;
3598
3639
  if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
3599
3640
  if (active?.authUnavailable) { const cleanup = await requestCleanup(); return authUnavailableResult(active, "provisioning", cleanup); }
3641
+ if (active?.state === "queued" && !active.timeout && !active.failed && !active.error && !active.auth) {
3642
+ const read = await readLeaseQuietly(leaseId);
3643
+ parkView = read?.ok && read.data?.success ? read.data : { host_key: placement.hostKey };
3644
+ const why = parkView.provision_defer_reason ?? "host capacity";
3645
+ stderrLine(`stack: lease ${leaseId} was deferred back to the queue while provisioning (${why}) — its place is kept; parking again`);
3646
+ state = "queued";
3647
+ continue;
3648
+ }
3600
3649
  if (active?.timeout) {
3601
3650
  // BOT-1822 belt-and-braces: the zero-poll wait can still miss a live
3602
3651
  // `stack_lease` signal (an older server before this fix, a relay hiccup,
3603
3652
  // BOT-1816's class of dropped wakes) even though the lease genuinely went
3604
- // active. One direct read before declaring a timeout tells that apart from
3605
- // a lease that truly never provisioned, instead of reaping a healthy stack.
3653
+ // active or was deferred back to queued. One direct read before declaring
3654
+ // a timeout avoids reaping a healthy stack or destroying a kept queue place.
3606
3655
  const recheck = await api.get(leaseId);
3656
+ if (recheck?.ok && recheck.data?.success && recheck.data.state === "queued") {
3657
+ parkView = recheck.data;
3658
+ const why = parkView.provision_defer_reason ?? "host capacity";
3659
+ stderrLine(`stack: lease ${leaseId} was deferred back to the queue while provisioning (${why}) — its place is kept; parking again`);
3660
+ state = "queued";
3661
+ continue;
3662
+ }
3607
3663
  if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
3608
3664
  const cleanup = await requestCleanup();
3609
3665
  return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
@@ -3621,6 +3677,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3621
3677
  return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active${why}`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
3622
3678
  }
3623
3679
  }
3680
+ break;
3681
+ }
3624
3682
  const current = await api.get(leaseId);
3625
3683
  if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {
3626
3684
  const cleanup = await requestCleanup();