@botbuddy/cli 1.41.1 → 1.42.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 +73 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.41.1",
3
+ "version": "1.42.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -174,6 +174,11 @@ ${bold("REMOTE LEASES")} (BOT-2142, stack run)
174
174
  The receipt carries execution_mode (local|remote), execution_host_key, requester_host,
175
175
  transport (none|ssh-forward), and for a remote lease forwards [{name, remote, local}] + bundle.
176
176
  The child also gets BOTBUDDY_STACK_EXECUTION_MODE and BOTBUDDY_STACK_TRANSPORT.
177
+ Requeue (BOT-2207): under auto, a lease that QUEUES on this machine uploads its bundle at once;
178
+ every minute the control plane re-offers it to this owner's other hosts and it may move there
179
+ (placement_reason moved_remote_on_requeue), and run then forwards to the new host. While queued,
180
+ run prints one \`considered: <host> — <verdict>\` line per host weighed (e.g. "census stale 131 s");
181
+ the receipt carries placement_reason and considered[].
177
182
  5 REMOTE_UNREACHABLE the host has no ssh_target (an operator must set it), or
178
183
  \`ssh <target>\` could not open the forwards (BatchMode: key auth only)
179
184
  5 REMOTE_UNSUPPORTED --remote, but the control plane placed the lease locally (pre-remote server)
@@ -455,6 +460,35 @@ export function describeQueuePosition(d) {
455
460
  * starved lease names its holders instead of only its position. Other tenants' leases are
456
461
  * tenant + state + since; unleased stacks are 5-character prefixes (the server redacts).
457
462
  */
463
+ // BOT-2207: one human line per host the control plane weighed for an \`auto\` lease
464
+ // (request_stack_lease / get_stack_lease \`considered[]\`), e.g. "considered: mac-mini — census
465
+ // stale 131 s". An unknown verdict code is printed as-is.
466
+ const CONSIDERED_VERDICT_TEXT = {
467
+ ADMITS: (c) => `admits (${c.free_slots ?? 0} free)`,
468
+ HOST_CAPACITY_INVENTORY_STALE: (c) => (Number.isFinite(c.snapshot_age_seconds) ? `census stale ${c.snapshot_age_seconds} s` : "no census yet"),
469
+ HOST_CAPACITY_SOURCE_STALE: () => "capacity beacon stale",
470
+ HOST_CAPACITY_WAIT: () => "Docker pressure full",
471
+ HOST_CAPACITY_BUDGET_FULL: () => "slot budget full",
472
+ HOST_CAPACITY_INVENTORY_UNKNOWN: () => "unknown Docker resources in its census",
473
+ TENANT_RESERVATION_FULL: () => "tenant reservation full",
474
+ SLOT_HELD: () => "this slot is already leased there",
475
+ HOST_HELPER_OUTDATED: () => "Helper predates remote leases (protocol < 4)",
476
+ HOST_NO_SSH_TARGET: () => "no ssh_target set",
477
+ HOST_INACTIVE: () => "host inactive",
478
+ };
479
+
480
+ export function describeConsidered(considered) {
481
+ if (!Array.isArray(considered)) return [];
482
+ return considered
483
+ .filter((c) => c && typeof c === "object" && typeof c.host_key === "string")
484
+ .map((c) => {
485
+ const name = (typeof c.display_name === "string" && c.display_name) || c.host_key;
486
+ const where = c.location === "local" ? " (this machine)" : "";
487
+ const text = CONSIDERED_VERDICT_TEXT[c.verdict_code]?.(c) ?? String(c.verdict_code ?? "unknown");
488
+ return `considered: ${name}${where} — ${text}`;
489
+ });
490
+ }
491
+
458
492
  export function describeUnitLedger(ledger) {
459
493
  if (!ledger || typeof ledger !== "object") return [];
460
494
  const cap = ledger.capacity ?? {};
@@ -2752,7 +2786,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
2752
2786
  // BOT-2142: the remote lease's forwards, and whether they died under a running child.
2753
2787
  let tunnel = null;
2754
2788
  let tunnelLost = null;
2755
- const placement = { mode: null, hostKey: null, requesterHost: null, transport: "none", forwards: null, bundle: null };
2789
+ const placement = { mode: null, hostKey: null, requesterHost: null, transport: "none", forwards: null, bundle: null, placementReason: null, considered: null };
2756
2790
  incidentBox.execution = placement;
2757
2791
  const closeTunnel = async () => {
2758
2792
  if (!tunnel) return;
@@ -2995,6 +3029,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
2995
3029
  placement.mode = request.data.execution_mode ?? null;
2996
3030
  placement.hostKey = request.data.host_key ?? null;
2997
3031
  placement.requesterHost = request.data.requester_host ?? null;
3032
+ // BOT-2207: why the server placed (or queued) it there, and every host it weighed.
3033
+ placement.placementReason = request.data.placement_reason ?? null;
3034
+ placement.considered = Array.isArray(request.data.considered) ? request.data.considered : null;
2998
3035
  if (executionRequest === "remote" && !remoteLease) {
2999
3036
  const cleanup = await requestCleanup();
3000
3037
  return {
@@ -3045,6 +3082,23 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3045
3082
  }
3046
3083
  placement.bundle = { sha256: bundle.sha256, bytes: bundle.size };
3047
3084
  stderrLine(`stack: lease ${leaseId} is remote on ${placement.hostKey ?? "?"} — uploaded the ${bundle.size}-byte stack bundle (sha256 ${bundle.sha256.slice(0, 12)}…)`);
3085
+ } else if (bundle && state === "queued" && request.data.bundle_upload_url) {
3086
+ // BOT-2207: the lease queued HERE but may still move: every minute the control plane
3087
+ // re-offers it to the owner's other hosts, and it can only move with its bundle in
3088
+ // storage. Upload now. A failed upload is not fatal — the lease just stays queued here.
3089
+ activeWaitAbort = new AbortController();
3090
+ const uploaded = await uploadBundle(request.data.bundle_upload_url, bundle, { signal: activeWaitAbort.signal });
3091
+ activeWaitAbort = null;
3092
+ if (receivedSignal) {
3093
+ const cleanup = await requestCleanup();
3094
+ return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
3095
+ }
3096
+ if (uploaded?.ok) {
3097
+ placement.bundle = { sha256: bundle.sha256, bytes: bundle.size };
3098
+ stderrLine(`stack: lease ${leaseId} queued on ${placement.hostKey ?? "?"} — uploaded the ${bundle.size}-byte stack bundle so it can move to another owned host that admits first`);
3099
+ } else {
3100
+ stderrLine(`${yellow("⚠")} stack: could not upload the stack bundle (${uploaded?.error || "upload failed"}); lease ${leaseId} stays queued on ${placement.hostKey ?? "this host"} and cannot move to another host.`);
3101
+ }
3048
3102
  }
3049
3103
  if (receivedSignal) {
3050
3104
  const cleanup = await requestCleanup();
@@ -3054,6 +3108,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3054
3108
  // BOT-2019: say where the lease sits (server dispatch rank, or why it has none) before parking.
3055
3109
  stderrLine(`stack: lease ${leaseId} queued — ${describeQueuePosition(request.data)}`);
3056
3110
  for (const line of describeUnitLedger(request.data.ledger)) stderrLine(`stack: ${line}`);
3111
+ for (const line of describeConsidered(request.data.considered)) stderrLine(`stack: ${line}`);
3057
3112
  activeWaitAbort = new AbortController();
3058
3113
  // BOT-2013: every reap state (reaping / reaped / reap_failed) means the queued lease is lost.
3059
3114
  // BOT-2099: an auth/availability blip is retried inside the park until its deadline;
@@ -3123,8 +3178,20 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3123
3178
 
3124
3179
  placement.hostKey = current.data.host_key ?? placement.hostKey;
3125
3180
  placement.mode = current.data.execution_mode ?? placement.mode ?? (remoteLease ? "remote" : null);
3181
+ placement.placementReason = current.data.placement_reason ?? placement.placementReason;
3182
+ if (Array.isArray(current.data.considered)) placement.considered = current.data.considered;
3183
+ // BOT-2207: a queued auto lease may have been MOVED to another owned host while it waited.
3184
+ // Follow it exactly like a first-time remote placement: forward to the NEW host's ssh_target.
3185
+ const servedRemote = remoteLease || current.data.execution_mode === "remote";
3186
+ if (servedRemote && !remoteLease) {
3187
+ stderrLine(`stack: lease ${leaseId} moved to ${placement.hostKey ?? "another host"} while queued (${placement.placementReason ?? "moved"}) — forwarding to it`);
3188
+ if (opts.callbackPort != null) {
3189
+ const cleanup = await requestCleanup();
3190
+ return { exitCode: EXIT.INVALID, outcome: "error", code: CALLBACK_PORT_UNSUPPORTED_REMOTE, leaseId, cleanup, error: CALLBACK_PORT_REMOTE_REFUSAL };
3191
+ }
3192
+ }
3126
3193
  let childConnection = current.data.connection;
3127
- if (remoteLease) {
3194
+ if (servedRemote) {
3128
3195
  // BOT-2142: forward every stack port over ONE ssh and hand the child the local ports.
3129
3196
  sshTarget = current.data.connection?.ssh_target ?? current.data.ssh_target ?? sshTarget;
3130
3197
  try {
@@ -3162,7 +3229,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3162
3229
  BOTBUDDY_STACK_CONNECTION_FILE: connectionFile,
3163
3230
  // BOT-2142: lets a lane's own checks (e.g. the port-owner preflight) know the
3164
3231
  // listeners are SSH forwards to another host, not this machine's Docker.
3165
- BOTBUDDY_STACK_EXECUTION_MODE: remoteLease ? "remote" : (placement.mode ?? "local"),
3232
+ BOTBUDDY_STACK_EXECUTION_MODE: servedRemote ? "remote" : (placement.mode ?? "local"),
3166
3233
  BOTBUDDY_STACK_TRANSPORT: placement.transport,
3167
3234
  ...(leasedTestConfig ? {
3168
3235
  BB_INTEGRATION_ENV_FILE: leasedTestConfig.envFile,
@@ -3262,6 +3329,9 @@ async function cmdRun(opts, childArgv) {
3262
3329
  transport: result.execution?.transport ?? "none",
3263
3330
  ...(result.execution?.forwards ? { forwards: result.execution.forwards } : {}),
3264
3331
  ...(result.execution?.bundle ? { bundle: result.execution.bundle } : {}),
3332
+ // BOT-2207: why it ran where it did (e.g. moved_remote_on_requeue) and every host weighed.
3333
+ ...(result.execution?.placementReason ? { placement_reason: result.execution.placementReason } : {}),
3334
+ ...(result.execution?.considered ? { considered: result.execution.considered } : {}),
3265
3335
  fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
3266
3336
  // BOT-2099: a platform auth/availability blip during the park is reported as an
3267
3337
  // incident (reason, window, attempts, recovered) — never as the lease's outcome.