@botbuddy/cli 1.64.0 → 1.64.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.0",
3
- "source_commit": "e6cc3786c39bf5326fe5b5dabccd80c1fba8b17d",
4
- "source_sha256": "0f97518d4f2ad1ac4d1195fd80ce4fa875a587b3ab2f102fbb5e28d6d5d62172",
2
+ "release_version": "1.64.2",
3
+ "source_commit": "48e7d1b3e42d27353ef734e0c61402b9051eb51c",
4
+ "source_sha256": "8f806cc487b47875fd051cf2c3f4cfbcf4a3b3e7f25930cd81ff613a2e400d86",
5
5
  "target": "darwin-arm64",
6
- "sha256": "675ce80ef07e7ef87f3a9111e911cd6241a775d5af4812dd9f53fb29846b9832"
6
+ "sha256": "1f1bff159d7c87ec19a13539afee99b58a325bb695d1ab2c22c0c794d59907d6"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.0",
3
- "source_commit": "e6cc3786c39bf5326fe5b5dabccd80c1fba8b17d",
4
- "source_sha256": "0f97518d4f2ad1ac4d1195fd80ce4fa875a587b3ab2f102fbb5e28d6d5d62172",
2
+ "release_version": "1.64.2",
3
+ "source_commit": "48e7d1b3e42d27353ef734e0c61402b9051eb51c",
4
+ "source_sha256": "8f806cc487b47875fd051cf2c3f4cfbcf4a3b3e7f25930cd81ff613a2e400d86",
5
5
  "target": "linux-x64",
6
- "sha256": "3223c862b52f1e81777e7c9403f6c854c5192f618c2c7ee46c56ad5b0a9ccfd9"
6
+ "sha256": "806aa3ac12cba1b8fce8e29848f1c47be489201e7dd1f3347f0b9ce0b7ec2c1a"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.64.0",
4
- "source_commit": "e6cc3786c39bf5326fe5b5dabccd80c1fba8b17d",
5
- "source_sha256": "0f97518d4f2ad1ac4d1195fd80ce4fa875a587b3ab2f102fbb5e28d6d5d62172",
3
+ "release_version": "1.64.2",
4
+ "source_commit": "48e7d1b3e42d27353ef734e0c61402b9051eb51c",
5
+ "source_sha256": "8f806cc487b47875fd051cf2c3f4cfbcf4a3b3e7f25930cd81ff613a2e400d86",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "675ce80ef07e7ef87f3a9111e911cd6241a775d5af4812dd9f53fb29846b9832",
11
+ "sha256": "1f1bff159d7c87ec19a13539afee99b58a325bb695d1ab2c22c0c794d59907d6",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "3223c862b52f1e81777e7c9403f6c854c5192f618c2c7ee46c56ad5b0a9ccfd9",
18
+ "sha256": "806aa3ac12cba1b8fce8e29848f1c47be489201e7dd1f3347f0b9ce0b7ec2c1a",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.64.0",
3
+ "version": "1.64.2",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -263,6 +263,35 @@ export function refuse({ stdout, stderr }, code, exitCode = 3, extra = {}, vars
263
263
  async function loadPool() { return import("../staging-pool/cli.mjs"); }
264
264
  async function loadBinding() { return import("../staging-pool/binding.mjs"); }
265
265
 
266
+ // BOT-2894: pool_empty is an agent-visible symptom, but recovery is owned by
267
+ // the control plane. This fixed call sends only the registered tenant/pool and
268
+ // persona; no host, path, command, credential or retry policy can cross it.
269
+ async function requestPoolRefill({ ctxPool, persona, deps, env }) {
270
+ try {
271
+ if (deps.requestPoolRefill) return await deps.requestPoolRefill({
272
+ tenant: ctxPool.tenant, poolId: ctxPool.poolId, persona,
273
+ });
274
+ const bin = ctxPool.installed?.botbuddyBin;
275
+ if (!bin || !ctxPool.tenant) return { ok: false, code: "POOL_REFILL_UNAVAILABLE" };
276
+ const run = deps.poolRefillExec ?? execFileSync;
277
+ const stdout = String(run(bin, ["call", "request_staging_pool_refill", "--pin", ctxPool.tenant, "--json", JSON.stringify({ pool_id: ctxPool.poolId, persona })], {
278
+ encoding: "utf8", timeout: 10_000, env, stdio: ["ignore", "pipe", "ignore"],
279
+ }));
280
+ const parsed = JSON.parse(stdout);
281
+ return {
282
+ ok: parsed?.ok === true,
283
+ ...(typeof parsed?.job_id === "string" ? { job_id: parsed.job_id } : {}),
284
+ ...(typeof parsed?.status === "string" ? { status: parsed.status } : {}),
285
+ ...(typeof parsed?.code === "string" ? { code: parsed.code } : {}),
286
+ };
287
+ } catch {
288
+ // Recovery request failure does not turn an already truthful pool_empty
289
+ // into a hidden success. The one wait/retry remains bounded and reports its
290
+ // original symptom; the next attempt will request recovery again.
291
+ return { ok: false, code: "POOL_REFILL_UNAVAILABLE" };
292
+ }
293
+ }
294
+
266
295
  /**
267
296
  * `auth --persona <alias>` after the wrapper has proven: environment=staging, a
268
297
  * lock-gated lane held by this exact agent, and a staging (or untracked) browser
@@ -316,13 +345,24 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
316
345
  claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, poolId: ctxPool.poolId, tenantHint, ...poolOptions });
317
346
  } catch (error) {
318
347
  if (error?.code === "pool_empty") {
348
+ const refill = await requestPoolRefill({ ctxPool, persona: persona.email, deps, env });
319
349
  // The refusal's code is the pool's health reason when it is unhealthy (e.g. a
320
350
  // count-0 persona is persona_not_configured), so code, message and help agree.
321
351
  const reasonCode = typeof error.reason === "string" && error.reason !== "healthy" && isKnownCode(error.reason) ? error.reason : "pool_empty";
352
+ // A terminal refill receipt is a real, durable recovery boundary. Without
353
+ // one, never tell the agent to await a signal that does not exist.
354
+ const refillNext = refill.ok === true && typeof refill.job_id === "string"
355
+ ? `bb wait 'staging-pool-refill:id=${refill.job_id}' && ${vars.retry ?? authLine} # once; if it fails, report this code`
356
+ : undefined;
322
357
  // BOT-2836: a healthy empty pool is the agent's own wait-and-retry, so it is
323
358
  // rendered here with this lane; an unhealthy pool keeps the pool's health guidance.
324
359
  const forwarded = reasonCode === "pool_empty" ? ["reason", "detail"] : ["reason", "detail", ...GUIDED_FIELDS];
325
- return refuse(io, reasonCode, 3, Object.fromEntries(forwarded.filter((key) => error[key] !== undefined).map((key) => [key, error[key]])), vars);
360
+ const code = reasonCode === "pool_empty" && !refillNext ? "pool_refill_unavailable" : reasonCode;
361
+ return refuse(io, code, 3, {
362
+ ...Object.fromEntries(forwarded.filter((key) => error[key] !== undefined).map((key) => [key, error[key]])),
363
+ ...(refillNext ? { next: refillNext } : {}),
364
+ automatic_refill: refill.ok === true ? { requested: true, ...(refill.job_id ? { job_id: refill.job_id } : {}), ...(refill.status ? { status: refill.status } : {}) } : { requested: false, code: refill.code },
365
+ }, vars);
326
366
  }
327
367
  const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "pool_take_failed";
328
368
  return refuse(io, code, 3, { detail: String(error?.message ?? error).slice(0, 300) }, vars);
@@ -514,6 +514,7 @@ export async function runStagingPool(argv, deps = {}) {
514
514
  rawBinding,
515
515
  poolBinding,
516
516
  botbuddyBin,
517
+ nodeBin,
517
518
  intervalSeconds: explicitInterval ?? existing?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS,
518
519
  existingPersonas: existing?.personas,
519
520
  tenant,
@@ -37,7 +37,7 @@ export function poolHomePaths({ home = homedir(), poolId, root } = {}) {
37
37
  }
38
38
 
39
39
  // The installed config: { version, binding: <raw .botbuddy-agent.json subset>,
40
- // personas: { email: count }, botbuddyBin, intervalSeconds }. Returns it with the
40
+ // personas: { email: count }, botbuddyBin, nodeBin, intervalSeconds }. Returns it with the
41
41
  // parsed binding attached as `poolBinding`.
42
42
  export function validateInstalledConfig(config) {
43
43
  if (!config || typeof config !== "object") throw new ConfigError("Pool config is not an object.", "config_invalid");
@@ -52,6 +52,11 @@ export function validateInstalledConfig(config) {
52
52
  if (typeof config.botbuddyBin !== "string" || !config.botbuddyBin.startsWith("/")) {
53
53
  throw new ConfigError("Pool config botbuddyBin must be an absolute path (re-run install).", "config_invalid");
54
54
  }
55
+ // `nodeBin` is added by BOT-2894. Older human-installed pools remain valid,
56
+ // but an autonomous Helper may only advertise a pool that pins its runtime.
57
+ if (config.nodeBin !== undefined && (typeof config.nodeBin !== "string" || !config.nodeBin.startsWith("/"))) {
58
+ throw new ConfigError("Pool config nodeBin must be an absolute path (re-run install).", "config_invalid");
59
+ }
55
60
  if (!Number.isInteger(config.intervalSeconds) || config.intervalSeconds < 60 || config.intervalSeconds > 3600) {
56
61
  throw new ConfigError("Pool config intervalSeconds must be an integer 60-3600.", "config_invalid");
57
62
  }
@@ -87,7 +92,7 @@ export function readInstalledConfig(path, { readFile = readFileSync } = {}) {
87
92
  }
88
93
 
89
94
  // rawBinding: { tenant, staging_session_pool } exactly as committed.
90
- export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas, tenant, registry, installedAt }) {
95
+ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, nodeBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas, tenant, registry, installedAt }) {
91
96
  const personas = {};
92
97
  for (const persona of Object.values(poolBinding.personas)) {
93
98
  personas[persona.email] = Number.isInteger(existingPersonas?.[persona.email]) ? existingPersonas[persona.email] : persona.defaultCount;
@@ -97,6 +102,7 @@ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, int
97
102
  binding: { tenant: rawBinding.tenant, staging_session_pool: rawBinding.staging_session_pool },
98
103
  personas,
99
104
  botbuddyBin,
105
+ ...(nodeBin ? { nodeBin } : {}),
100
106
  intervalSeconds,
101
107
  // BOT-2648: installed from the tenant's control-plane registration.
102
108
  ...(tenant ? { tenant, poolId: poolBinding.poolId, source: "control-plane" } : {}),
@@ -135,8 +135,12 @@ export const ERROR_CODES = Object.freeze({
135
135
  // already asked the human-installed job to refill now, so the agent waits once and retries.
136
136
  pool_empty: entry("agent", "take",
137
137
  "The staging session pool is healthy but has no ready session for {persona}.",
138
- "Agents took every ready session since the last tick, or the ready ones were dead or AAL1 (factor rotated) and were discarded; the pool job was asked to refill now.",
139
- "bb wait 'timer:duration=60' && {retry} # once; if it repeats, report this code", "retry_once"),
138
+ "Agents took every ready session since the last tick, or the ready ones were dead or AAL1 (factor rotated) and were discarded; automatic refill creates a durable job before the single retry.",
139
+ "Automatic refill is requested by bb pw auth; wait for its refill job, then {retry} once; if it fails, report this code", "retry_once"),
140
+ pool_refill_unavailable: entry("agent", "take",
141
+ "The staging session pool is empty and automatic refill did not return a durable job.",
142
+ "The control plane was unavailable or rejected the refill request, so there is no job signal to await.",
143
+ "{retry} # automatic refill did not return a job; retry once; if it repeats, report this code", "retry_once"),
140
144
  job_not_loaded: entry("human", "install",
141
145
  "The staging session pool is not installed (or not loaded) on this Mac.",
142
146
  "The one-time install has not been run here, or the Mac was migrated.",
@@ -322,7 +326,7 @@ export const AUTH_ONLY_CODES = Object.freeze(new Set([
322
326
  "tenant_binding_required", "persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
323
327
  "session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_expired",
324
328
  "session_persona_mismatch", "slot_state_invalid", "slot_state_unreadable", "daemon_node_unsupported",
325
- "daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty",
329
+ "daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty", "pool_refill_unavailable",
326
330
  ]));
327
331
 
328
332
  // `bb staging-pool` subcommands only a human runs. A refusal from one names
@@ -91,7 +91,7 @@ export async function refreshExistingRuntime({ options, home, uid, env, exec, bu
91
91
  const contentDigest = hash.digest("hex");
92
92
  const runtimeName = `runtime-${metadata.version}-${contentDigest}`;
93
93
  const runtime = join(root, runtimeName);
94
- const config = validateInstalledConfig({ ...installed, binding: rawBinding, personas, source: "control-plane", tenant, poolId, registry: { commit_sha: row.commit_sha ?? null, updated_at: row.updated_at ?? null }, runtime: { version: metadata.version, sha256: contentDigest } });
94
+ const config = validateInstalledConfig({ ...installed, binding: rawBinding, personas, nodeBin: installed.nodeBin ?? await fs.realpath(process.execPath), source: "control-plane", tenant, poolId, registry: { commit_sha: row.commit_sha ?? null, updated_at: row.updated_at ?? null }, runtime: { version: metadata.version, sha256: contentDigest } });
95
95
  const token = randomUUID();
96
96
  const lockPath = join(root, ".runtime-refresh.lock");
97
97
  const lock = await fs.open(lockPath, "wx", 0o600);
package/src/wait-core.mjs CHANGED
@@ -216,6 +216,15 @@ const VALIDATORS = {
216
216
  }
217
217
  return { runId };
218
218
  },
219
+ "staging-pool-refill": (p, raw) => {
220
+ const pair = /^\s*id\s*=\s*([0-9A-Fa-f-]+)\s*$/.exec(raw ?? "");
221
+ if (!pair || Object.keys(p).length !== 1 || p.id !== pair[1]) {
222
+ throw new Error("staging-pool-refill needs exactly one id=<refill job UUID>");
223
+ }
224
+ const jobId = pair[1].toLowerCase();
225
+ if (!CANONICAL_UUID_RE.test(jobId)) throw new Error("staging-pool-refill id must be a canonical UUID");
226
+ return { jobId };
227
+ },
219
228
  // BOT-2629: this is deliberately a dedicated condition rather than a raw
220
229
  // event escape hatch. The UUID is chosen before dispatch and the exact subject
221
230
  // prevents another run on the same idle/re-adopted lease waking this waiter.
@@ -1295,6 +1304,11 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1295
1304
  signal.subject_key === `run:${params.runId}` &&
1296
1305
  signal.payload?.run_id === params.runId &&
1297
1306
  COMMAND_RUN_TERMINAL_STATUSES.has(signal.payload?.status);
1307
+ case "staging-pool-refill":
1308
+ return signal.signal_type === "staging_pool_refill" &&
1309
+ signal.subject_key === `staging-pool-refill:${params.jobId}` &&
1310
+ signal.payload?.job_id === params.jobId &&
1311
+ ["completed", "failed"].includes(signal.payload?.status);
1298
1312
  case "stack-run":
1299
1313
  return signal.signal_type === "stack_run" && signal.subject_key === `stack-run:${params.id}` &&
1300
1314
  signal.payload?.run_id === params.id && Number.isInteger(signal.payload?.exit_code);
package/src/wait.mjs CHANGED
@@ -165,6 +165,10 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
165
165
  condition, and keep this wait's result pending: its
166
166
  return is what resumes your task. A run whose worker is
167
167
  lost ends owner_lost (bb run inspect <run_id>).
168
+ staging-pool-refill:id=<job UUID> BOT-2894: a requested staging-pool refill reaching
169
+ completed or failed. This is the only condition of its
170
+ wait and is available to each tenant agent that requested
171
+ the deduped job; its terminal receipt is safe to display.
168
172
  stack-run:id=<run UUID> one named bb stack run reaching its terminal outcome
169
173
  (BOT-2629). Choose the UUID BEFORE dispatch and pass it as
170
174
  bb stack run --run-id <UUID>; the receipt carries exit_code,
@@ -648,6 +652,8 @@ async function registerWait(opts, conditions, deadlineIso) {
648
652
  // back to an untracked wait would only hide that error behind a timeout.
649
653
  "command_run_unavailable",
650
654
  "command_run_single_condition_required",
655
+ "staging_pool_refill_unavailable",
656
+ "staging_pool_refill_single_condition_required",
651
657
  ]);
652
658
  if (INVALID_CONDITION_CODES.has(body.error)) {
653
659
  const err = new Error(body.detail || body.error);