@botbuddy/cli 1.64.0 → 1.64.1

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.1",
3
+ "source_commit": "fd01ca263ebebf1dda5ec3648d5300a788ee7887",
4
+ "source_sha256": "75f8bdc7954b24d97d7da80abe41f3814bf9bc9f9c00558d26cb78c030b58475",
5
5
  "target": "darwin-arm64",
6
- "sha256": "675ce80ef07e7ef87f3a9111e911cd6241a775d5af4812dd9f53fb29846b9832"
6
+ "sha256": "929631f9c2d05a901a16e8ba132c4166ca62b2c7b30ab5b8317cbcd59c49ee79"
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.1",
3
+ "source_commit": "fd01ca263ebebf1dda5ec3648d5300a788ee7887",
4
+ "source_sha256": "75f8bdc7954b24d97d7da80abe41f3814bf9bc9f9c00558d26cb78c030b58475",
5
5
  "target": "linux-x64",
6
- "sha256": "3223c862b52f1e81777e7c9403f6c854c5192f618c2c7ee46c56ad5b0a9ccfd9"
6
+ "sha256": "fc806cb0d6193a2c6c55eb076a28575b1fabc80955f4f9669b9f15269cbcdabb"
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.1",
4
+ "source_commit": "fd01ca263ebebf1dda5ec3648d5300a788ee7887",
5
+ "source_sha256": "75f8bdc7954b24d97d7da80abe41f3814bf9bc9f9c00558d26cb78c030b58475",
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": "929631f9c2d05a901a16e8ba132c4166ca62b2c7b30ab5b8317cbcd59c49ee79",
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": "fc806cb0d6193a2c6c55eb076a28575b1fabc80955f4f9669b9f15269cbcdabb",
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.1",
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,23 @@ 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. Keep
353
+ // the old bounded timer only when the request could not return a job id.
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
+ return refuse(io, reasonCode, 3, {
361
+ ...Object.fromEntries(forwarded.filter((key) => error[key] !== undefined).map((key) => [key, error[key]])),
362
+ ...(refillNext ? { next: refillNext } : {}),
363
+ 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 },
364
+ }, vars);
326
365
  }
327
366
  const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "pool_take_failed";
328
367
  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" } : {}),
@@ -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);