@botbuddy/cli 1.53.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.53.0",
3
+ "version": "1.54.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -83,6 +83,11 @@ export function repairFailureRecovery(code, error = null) {
83
83
  if (code === "agent_tenant_immutable") {
84
84
  return "this worktree's agent identity belongs to another tenant; run `bb setup` to register this tenant, then `bb doctor --fix`";
85
85
  }
86
+ // BOT-2415: the cached session names a different agent than the one holding this
87
+ // worktree's technical name. Deterministic: a retry repeats the same conflict.
88
+ if (code === "agent_name_conflict") {
89
+ return "this worktree's cached session (.botbuddy/agent-state.json) names a different agent than the one holding the worktree's technical name; move that file aside, then run `bb doctor --fix`";
90
+ }
86
91
  // BOT-1750 (Codex R9 P2): `session_mint_unauthorized` means a locally-unexpired
87
92
  // client key was revoked/rejected server-side — only a fresh login replaces it, so
88
93
  // point at `bb login`, not a binding repair (the binding is valid).
@@ -345,6 +345,9 @@ export async function selfHealAgentSession({
345
345
  branch: currentBranch || undefined,
346
346
  ticket_id: ticketFromBranch(currentBranch),
347
347
  agent_id: presentedAgentId ?? undefined,
348
+ // A rejected-token renewal may still own locks or leases under this agent.
349
+ // Ask the server to keep it; ordinary setup/doctor repair may adopt a holder.
350
+ ...(renewal ? { preserve_agent_identity: true } : {}),
348
351
  harness_session_id: presentedHarness ?? undefined,
349
352
  harness: env.CLAUDE_CODE || env.CLAUDECODE ? "claude-code" : Object.keys(env).some((key) => key.startsWith("CODEX_")) ? "codex" : "cli",
350
353
  model: env.BOTBUDDY_MODEL || undefined,
@@ -372,8 +375,14 @@ export async function selfHealAgentSession({
372
375
  const typedCode = typeof response?.code === "string"
373
376
  ? response.code
374
377
  : typeof data.code === "string" ? data.code : null;
375
- const code = typedCode === "agent_tenant_immutable"
378
+ // BOT-2415: a technical-name conflict is deterministic (the cached agent_id
379
+ // names a different agent than the one holding this worktree's name), so no
380
+ // retry fixes it. Recognise the typed refusal and an older server's raw 23505.
381
+ const nameConflict = typedCode === "agent_name_taken"
382
+ || /agents_name_key/.test(String(response?.error ?? data.error ?? ""));
383
+ const code = ["agent_tenant_immutable", "session_identity_mismatch"].includes(typedCode)
376
384
  ? typedCode
385
+ : nameConflict ? "agent_name_conflict"
377
386
  : response?.auth ? "session_mint_unauthorized" : "session_mint_failed";
378
387
  // BOT-1891: name the credential (never its value) so `bb doctor` can point a
379
388
  // rejected tier-2 key at rotation instead of `bb login`.
package/src/commands.mjs CHANGED
@@ -568,7 +568,7 @@ function setupFailureRecovery(error) {
568
568
  if (code === "tenant_binding_required" || code === "invalid_binding") {
569
569
  return "fix the committed .botbuddy-agent.json binding, then retry `bb setup`";
570
570
  }
571
- if (code === "client_key_unavailable" || code === "session_mint_unauthorized") return repairFailureRecovery(code, error);
571
+ if (code === "client_key_unavailable" || code === "session_mint_unauthorized" || code === "agent_name_conflict") return repairFailureRecovery(code, error);
572
572
  return "retry `bb setup`";
573
573
  }
574
574
 
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 ?? {};
@@ -985,7 +1013,7 @@ export function retryingStackCall({
985
1013
  // taxonomy so automation sees AUTH/BACKEND, not a CLI-defect INTERNAL. Credential
986
1014
  // problems are AUTH (3); a transport/server mint failure is BACKEND (5); anything
987
1015
  // unrecognized stays INTERNAL (7).
988
- const AUTH_ERROR_CODES = new Set(["client_key_required", "client_key_unavailable", "tenant_binding_required", "session_token_malformed", "session_mint_unauthorized", "session_identity_mismatch"]);
1016
+ const AUTH_ERROR_CODES = new Set(["client_key_required", "client_key_unavailable", "tenant_binding_required", "session_token_malformed", "session_mint_unauthorized", "session_identity_mismatch", "agent_name_conflict"]);
989
1017
  // BOT-1750 (Codex R11 P2): `session_mint_invalid` is an incompatible/malformed SERVER
990
1018
  // response, not a credential problem — classify it with the transport/server failures.
991
1019
  const BACKEND_ERROR_CODES = new Set(["session_mint_failed", "session_mint_invalid"]);
@@ -1423,6 +1451,7 @@ export const PARK_AUTH_BUDGET_MS = 5 * 60_000;
1423
1451
  // the regular auth budget; it is not evidence that the lease changed owner.
1424
1452
  const DEFINITIVE_REAUTH_CODES = new Set([
1425
1453
  "session_identity_mismatch", "client_key_required", "tenant_binding_required", "session_token_malformed", "agent_tenant_immutable",
1454
+ "agent_name_conflict",
1426
1455
  ]);
1427
1456
 
1428
1457
  function abortableSleep(ms, signal) {
@@ -3546,16 +3575,28 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3546
3575
  const cleanup = await requestCleanup();
3547
3576
  return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
3548
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 (;;) {
3549
3585
  if (state === "queued") {
3550
3586
  // BOT-2019: say where the lease sits (server dispatch rank, or why it has none) before parking.
3551
- stderrLine(`stack: lease ${leaseId} queued — ${describeQueuePosition(request.data)}`);
3552
- for (const line of describeUnitLedger(request.data.ledger)) stderrLine(`stack: ${line}`);
3553
- 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
+ }
3554
3595
  activeWaitAbort = new AbortController();
3555
3596
  // BOT-2013: every reap state (reaping / reaped / reap_failed) means the queued lease is lost.
3556
3597
  // BOT-2099: an auth/availability blip is retried inside the park until its deadline;
3557
3598
  // it never releases a queued lease or becomes the lease's outcome.
3558
- 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 });
3559
3600
  activeWaitAbort = null;
3560
3601
  const incident = parkIncident ? { incident: parkIncident } : {};
3561
3602
  if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup, ...incident }; }
@@ -3577,7 +3618,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3577
3618
  error: `lease ${leaseId} parked ${opts.timeout}s inside a platform incident (${parkIncident.reason} since ${parkIncident.first_at}), so its admission was never observed`,
3578
3619
  };
3579
3620
  }
3580
- 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 } : {}) };
3581
3622
  }
3582
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 }; }
3583
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 }; }
@@ -3592,17 +3633,33 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3592
3633
  // provisionTimeout before the belt-and-braces recheck below ever notices
3593
3634
  // the lease is no longer provisioning. isReaped (== "reaping" || "reaped")
3594
3635
  // already covers both; cmdUp's equivalent waits use it for this reason.
3595
- 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 });
3596
3638
  activeWaitAbort = null;
3597
3639
  if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
3598
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
+ }
3599
3649
  if (active?.timeout) {
3600
3650
  // BOT-1822 belt-and-braces: the zero-poll wait can still miss a live
3601
3651
  // `stack_lease` signal (an older server before this fix, a relay hiccup,
3602
3652
  // BOT-1816's class of dropped wakes) even though the lease genuinely went
3603
- // active. One direct read before declaring a timeout tells that apart from
3604
- // 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.
3605
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
+ }
3606
3663
  if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
3607
3664
  const cleanup = await requestCleanup();
3608
3665
  return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
@@ -3620,6 +3677,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3620
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 } : {}) };
3621
3678
  }
3622
3679
  }
3680
+ break;
3681
+ }
3623
3682
  const current = await api.get(leaseId);
3624
3683
  if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {
3625
3684
  const cleanup = await requestCleanup();
package/src/wait.mjs CHANGED
@@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url";
20
20
  import { latestPublicCliCommand } from "./public-invocation.mjs";
21
21
  import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
22
22
  import { clearAgentState, hasAgentSessionRuntime, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
23
+ import { repairFailureRecovery } from "./agent-doctor.mjs";
23
24
  import { touchAgentStateExpiry } from "./agent-state.mjs";
24
25
  import { SETUP_BLOCK } from "./setup-block.mjs";
25
26
  import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
@@ -1679,7 +1680,10 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1679
1680
  opts.credentialSource = credential.source;
1680
1681
  } catch (error) {
1681
1682
  const code = error?.code ?? "session_mint_failed";
1682
- const recovery = code === "client_key_required"
1683
+ // BOT-2415: a stale cache naming another agent is not fixed by `bb setup` + retry.
1684
+ const recovery = code === "agent_name_conflict"
1685
+ ? repairFailureRecovery(code)
1686
+ : code === "client_key_required"
1683
1687
  ? "run `bb setup` from this project worktree, then retry"
1684
1688
  : code === "tenant_binding_required"
1685
1689
  ? "run from a configured project worktree, then run `bb setup` and retry"