@botbuddy/cli 1.63.32 → 1.63.35

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.63.32",
3
- "source_commit": "91ea9b88abe8effa478b5dcf15d42e1898afc3e9",
4
- "source_sha256": "a7741aa15134ea47f319a63a32ee1d40cf993636e693e679d77534d257f31aa7",
2
+ "release_version": "1.63.35",
3
+ "source_commit": "4517f4b0fe820afa3c976ac2a07bbbf02253fb86",
4
+ "source_sha256": "20ba1366169f553685a44b2ffb1d08b8155f8c91e6a6c215909c5fb0825a5f81",
5
5
  "target": "darwin-arm64",
6
- "sha256": "8f3782ebb257b5676259ae478fe76ae54903e86d9343898cf48c26496ad6e64f"
6
+ "sha256": "e397a1b045b4ccd213e9a7a7dbc3631b83d8af2e0dff67b90b81c365ed9b1b41"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.32",
3
- "source_commit": "91ea9b88abe8effa478b5dcf15d42e1898afc3e9",
4
- "source_sha256": "a7741aa15134ea47f319a63a32ee1d40cf993636e693e679d77534d257f31aa7",
2
+ "release_version": "1.63.35",
3
+ "source_commit": "4517f4b0fe820afa3c976ac2a07bbbf02253fb86",
4
+ "source_sha256": "20ba1366169f553685a44b2ffb1d08b8155f8c91e6a6c215909c5fb0825a5f81",
5
5
  "target": "linux-x64",
6
- "sha256": "404616db3ad3184c701b3330a23b23f3a2636ca5858dbb4e5bdcea93775c6ca3"
6
+ "sha256": "c2a2d4b815867bf1e6c4008cbb2eda12d1aecee16e3f11727e445307742a5521"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.63.32",
4
- "source_commit": "91ea9b88abe8effa478b5dcf15d42e1898afc3e9",
5
- "source_sha256": "a7741aa15134ea47f319a63a32ee1d40cf993636e693e679d77534d257f31aa7",
3
+ "release_version": "1.63.35",
4
+ "source_commit": "4517f4b0fe820afa3c976ac2a07bbbf02253fb86",
5
+ "source_sha256": "20ba1366169f553685a44b2ffb1d08b8155f8c91e6a6c215909c5fb0825a5f81",
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": "8f3782ebb257b5676259ae478fe76ae54903e86d9343898cf48c26496ad6e64f",
11
+ "sha256": "e397a1b045b4ccd213e9a7a7dbc3631b83d8af2e0dff67b90b81c365ed9b1b41",
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": "404616db3ad3184c701b3330a23b23f3a2636ca5858dbb4e5bdcea93775c6ca3",
18
+ "sha256": "c2a2d4b815867bf1e6c4008cbb2eda12d1aecee16e3f11727e445307742a5521",
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.63.32",
3
+ "version": "1.63.35",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/run.mjs CHANGED
@@ -35,6 +35,11 @@ export const EXIT = Object.freeze({ OK: 0, AUTH: 3, INVALID: 4, BACKEND: 5, INTE
35
35
  export const DEFAULT_TIMEOUT_SECONDS = 8 * 60 * 60;
36
36
  export const TIMEOUT_GRACE_MS = 5_000;
37
37
  const MAX_CAPTURE_BYTES = 8_192;
38
+ // BOT-2530: a detached worker holds a server-clocked liveness lease on its run.
39
+ // It renews every 30 s; four missed renewals (120 s) let the server's reconciler
40
+ // terminalize the run as owner_lost so a `command-run:` waiter always ends.
41
+ export const WORKER_HEARTBEAT_TTL_SECONDS = 120;
42
+ export const WORKER_HEARTBEAT_INTERVAL_MS = 30_000;
38
43
  // BOT-1510: how often the worker flushes lane case verdicts to the backend.
39
44
  // Well under AC-3's 5 s so a begin/verdict is visible within the window.
40
45
  const LANE_FLUSH_INTERVAL_MS = 2_500;
@@ -583,6 +588,7 @@ export async function launchRun(argv, {
583
588
  command_hash: commandFingerprint, ...(launchSourceSha ? { source_sha: launchSourceSha } : {}), environment_hash: environmentHash(cwd, opts.environment),
584
589
  environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
585
590
  rerun_reason: opts.rerunReason,
591
+ worker_heartbeat_ttl_seconds: WORKER_HEARTBEAT_TTL_SECONDS,
586
592
  });
587
593
  let registration = await effectiveCall("register_command", registerArgs());
588
594
  // A cached session can be revoked server-side before its local expiry. The
@@ -610,7 +616,7 @@ export async function launchRun(argv, {
610
616
  // every callback is attributable to the same agent. It deliberately stays
611
617
  // outside child_env, receipts, and the workload's process environment.
612
618
  const knownSession = opts.sessionId ?? managedCredential?.identity?.sessionId ?? managedCredential?.sessionId;
613
- await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, command_hash: commandFingerprint, source_sha: launchSourceSha, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), server_url: SERVER_URL, ...(opts.sessionToken ? { callback_session_token: opts.sessionToken } : {}), ...(knownSession ? { callback_session_id: knownSession } : {}), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
619
+ await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, command_hash: commandFingerprint, source_sha: launchSourceSha, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), server_url: SERVER_URL, worker_heartbeat_ttl_seconds: WORKER_HEARTBEAT_TTL_SECONDS, ...(opts.sessionToken ? { callback_session_token: opts.sessionToken } : {}), ...(knownSession ? { callback_session_id: knownSession } : {}), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
614
620
  const [file, args] = workerInvocation(context);
615
621
  try {
616
622
  const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
@@ -623,7 +629,10 @@ export async function launchRun(argv, {
623
629
  }
624
630
  return { exitCode: EXIT.OK, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "launched", run_id: runId,
625
631
  ...(knownSession ? { session_id: knownSession } : { session_binding: "server_derived" }),
626
- durable_owner: "detached_worker", receipt_path: receipt, reentry: "update_command_run publishes the terminal callback for this run_id", exit_code: EXIT.OK } };
632
+ durable_owner: "detached_worker", receipt_path: receipt, reentry: "update_command_run publishes the terminal callback for this run_id",
633
+ // BOT-2318: the exact pending wait for this run. Keep its result pending in
634
+ // the same work session; its return is what resumes the caller (BOT-2501).
635
+ wait_command: `bb wait 'command-run:id=${runId}'`, exit_code: EXIT.OK } };
627
636
  }
628
637
 
629
638
  export async function runWorker(contextFile, {
@@ -633,11 +642,14 @@ export async function runWorker(contextFile, {
633
642
  terminate = terminateProcessGroup,
634
643
  // BOT-2600: the bounded terminal delivery (see retryTerminalUpdate).
635
644
  sleep, now, terminalBudgetMs, startDelivery,
645
+ // BOT-2530: worker liveness lease renewal cadence (tests inject a fake timer).
646
+ workerHeartbeatIntervalMs = WORKER_HEARTBEAT_INTERVAL_MS, setIntervalFn = setInterval,
636
647
  } = {}) {
637
648
  const retry = { sleep, now, budgetMs: terminalBudgetMs, startDelivery };
638
649
  const context = JSON.parse(await readFile(contextFile, "utf8"));
639
650
  const callbackCall = callWithContextIdentity(context, call);
640
651
  const ownerHeartbeat = startOwnerHeartbeat(context, callbackCall);
652
+ const workerLease = startWorkerLease(context, callbackCall, { intervalMs: workerHeartbeatIntervalMs, setIntervalFn });
641
653
  const startedAt = new Date().toISOString();
642
654
  let timedOut = false;
643
655
  let child;
@@ -738,11 +750,44 @@ export async function runWorker(contextFile, {
738
750
  const receipt = await finalize(context, { status, startedAt, endedAt, exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, callbackCall, retry);
739
751
  return receipt;
740
752
  } finally {
753
+ if (workerLease) await workerLease.stop();
741
754
  if (ownerHeartbeat) await ownerHeartbeat.stop();
742
755
  signalEmitter.removeListener("SIGTERM", cancellationHandler);
743
756
  }
744
757
  }
745
758
 
759
+ // BOT-2530: renew this run's liveness lease while the worker lives. The first
760
+ // renewal also moves a registered run to running. A rejected or failed renewal
761
+ // is not retried specially: the next tick tries again, and a terminal run
762
+ // refuses renewals server-side. Contexts sealed by an older launcher (no
763
+ // declared lease) send nothing.
764
+ export function startWorkerLease(context, call, { intervalMs = WORKER_HEARTBEAT_INTERVAL_MS, setIntervalFn = setInterval } = {}) {
765
+ if (!Number.isInteger(context.worker_heartbeat_ttl_seconds)) return null;
766
+ let stopped = false;
767
+ let chain = Promise.resolve();
768
+ const tick = () => {
769
+ chain = chain.then(async () => {
770
+ if (stopped) return;
771
+ try {
772
+ await call("update_command_run", { run_id: context.run_id, run_credential: context.run_credential, status: "running", worker_heartbeat: true }, {
773
+ signal: AbortSignal.timeout(DELIVERY_CALL_TIMEOUT_MS),
774
+ ...(typeof context.server_url === "string" && context.server_url ? { serverUrl: context.server_url } : {}),
775
+ });
776
+ } catch { /* the lease lapses only after several missed renewals */ }
777
+ });
778
+ };
779
+ tick();
780
+ const timer = setIntervalFn(tick, intervalMs);
781
+ timer.unref?.();
782
+ return { async stop() {
783
+ stopped = true;
784
+ clearInterval(timer);
785
+ let bound;
786
+ await Promise.race([chain, new Promise((resolve) => { bound = setTimeout(resolve, 2_000); bound.unref?.(); })]);
787
+ clearTimeout(bound);
788
+ } };
789
+ }
790
+
746
791
  function startOwnerHeartbeat(context, call, { intervalMs = 5 * 60_000, setIntervalFn = setInterval } = {}) {
747
792
  // A detached worker must never acquire liveness through its ambient default
748
793
  // identity. It can only refresh the bearer sealed for its owner.
@@ -13,6 +13,7 @@
13
13
  // off to a detached, unref'd `npm install -g` and exits 0.
14
14
 
15
15
  import { mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { readFileSync } from "node:fs";
16
17
  import { spawn, spawnSync } from "node:child_process";
17
18
  import { homedir } from "node:os";
18
19
  import { dirname, join } from "node:path";
@@ -22,7 +23,11 @@ import { VERSION } from "./version.mjs";
22
23
  export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
23
24
  export const NPM_LATEST_URL = "https://registry.npmjs.org/@botbuddy/cli/latest";
24
25
  export const NPM_LATEST_TIMEOUT_MS = 1500;
25
- export const UPDATE_COMMAND = "npm i -g @botbuddy/cli@latest";
26
+ export const UPDATE_COMMAND = "npm install -g @botbuddy/cli@latest";
27
+
28
+ export function formatUpdateOffer(current, latest) {
29
+ return `\nUpdate available\n Current: v${current}\n Latest: v${latest}\n Run: bb update\n`;
30
+ }
26
31
 
27
32
  export function defaultUpdateCheckCachePath(home = homedir()) {
28
33
  return join(home, ".botbuddy", "update-check.json");
@@ -106,16 +111,26 @@ export async function maybeWarnStale({
106
111
  await writeCache(cachePath, { checkedAt: now(), latest });
107
112
  }
108
113
  if (typeof latest !== "string" || !isNewerVersion(latest, version)) return { warned: false, latest: latest ?? null };
109
- stderr.write(`botbuddy: update available ${version} → ${latest}: ${UPDATE_COMMAND}\n`);
114
+ stderr.write(formatUpdateOffer(version, latest));
110
115
  return { warned: true, latest };
111
116
  } catch {
112
117
  return { warned: false, latest: null };
113
118
  }
114
119
  }
115
120
 
116
- // `botbuddy update` — hand off to npm and get out of the way.
117
- export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = spawnSync, log = (line) => console.log(line), platform = process.platform, stderr = process.stderr } = {}) {
118
- log(`current: ${version}`);
121
+ export function readInstalledVersion({ npmBin = "npm", shellOpt = {}, probeImpl = spawnSync, readPackage = readFileSync } = {}) {
122
+ const root = probeImpl(npmBin, ["root", "-g"], { encoding: "utf8", ...shellOpt });
123
+ if (root?.error || root?.status !== 0) throw new Error(`npm global root lookup failed for ${npmBin}`);
124
+ const directory = String(root?.stdout ?? "").trim();
125
+ if (!directory) throw new Error("npm global root lookup returned no directory");
126
+ const packageJson = JSON.parse(readPackage(join(directory, "@botbuddy", "cli", "package.json"), "utf8"));
127
+ if (typeof packageJson?.version !== "string" || !parseSemver(packageJson.version)) throw new Error("installed @botbuddy/cli package has no valid version");
128
+ return packageJson.version;
129
+ }
130
+
131
+ // `botbuddy update` waits for npm because the terminal result, rather than the
132
+ // child-process launch, is the only trustworthy confirmation an update worked.
133
+ export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = spawnSync, readInstalledVersion: installedVersion = readInstalledVersion, log = (line) => console.log(line), platform = process.platform, stderr = process.stderr } = {}) {
119
134
  // BOT-1566 (Codex P1/P2): on Windows npm is the `npm.cmd` shim, and Node
120
135
  // cannot spawn a .cmd file shell-free (docs) — run it through a shell on
121
136
  // win32 (args are all static constants, so no injection surface) and select
@@ -134,34 +149,44 @@ export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = sp
134
149
  try { stderr.write(`botbuddy update: npm was not found — install Node.js/npm, then retry (looked for ${npmBin})\n`); } catch { /* ignore */ }
135
150
  return Promise.resolve(1);
136
151
  }
152
+ log(`BotBuddy CLI update\n Current: v${version}\n\n$ ${UPDATE_COMMAND}`);
137
153
  const child = spawnImpl(npmBin, ["install", "-g", "@botbuddy/cli@latest"], {
138
154
  stdio: "inherit",
139
- detached: true,
140
155
  ...shellOpt,
141
156
  });
142
- // Node reports a launch failure asynchronously via the `error` event, so we
143
- // must NOT announce success until the child has actually spawned (Codex P2).
144
- // Race spawn vs error: print + unref only on `spawn`, exit nonzero on `error`.
157
+ // Node reports a launch failure asynchronously via the `error` event. A
158
+ // successfully spawned child can still fail its install, so only `close: 0`
159
+ // plus a readable npm global package metadata record is a success.
145
160
  return new Promise((resolve) => {
146
161
  if (!child || typeof child.once !== "function") {
147
- log("updating in background — re-run botbuddy --version to confirm");
148
- if (child && typeof child.unref === "function") child.unref();
149
- resolve(0);
162
+ try { stderr.write("botbuddy update: npm did not return a trackable process\n"); } catch { /* ignore */ }
163
+ resolve(1);
150
164
  return;
151
165
  }
152
166
  let settled = false;
153
- child.once("spawn", () => {
154
- if (settled) return;
155
- settled = true;
156
- if (typeof child.unref === "function") child.unref();
157
- log("updating in background — re-run botbuddy --version to confirm");
158
- resolve(0);
159
- });
160
167
  child.once("error", (err) => {
161
168
  if (settled) return;
162
169
  settled = true;
163
170
  try { stderr.write(`botbuddy update: failed to launch npm (${err?.message ?? err})\n`); } catch { /* ignore */ }
164
171
  resolve(1);
165
172
  });
173
+ child.once("close", (code, signal) => {
174
+ if (settled) return;
175
+ settled = true;
176
+ if (code !== 0 || signal) {
177
+ const reason = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
178
+ try { stderr.write(`botbuddy update: npm exited with ${reason}; no update was confirmed\n`); } catch { /* ignore */ }
179
+ resolve(1);
180
+ return;
181
+ }
182
+ try {
183
+ const installed = installedVersion({ npmBin, shellOpt });
184
+ log(`✓ Updated to v${installed}`);
185
+ resolve(0);
186
+ } catch (error) {
187
+ try { stderr.write(`botbuddy update: npm completed but could not verify the installed version (${error?.message ?? error})\n`); } catch { /* ignore */ }
188
+ resolve(1);
189
+ }
190
+ });
166
191
  });
167
192
  }
package/src/wait-core.mjs CHANGED
@@ -46,6 +46,12 @@ export const EXIT = Object.freeze({
46
46
  // four, optionally wrapped in ONE pair of braces.
47
47
  const LEASE_UUID_SPELLING = /^(?:\{(?=.*\}$)|(?!.*\}$))((?:[0-9a-f]{4}-?){7}[0-9a-f]{4})\}?$/;
48
48
 
49
+ // BOT-2318: bb run generates a lower-case UUID before register_command.
50
+ const COMMAND_RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
51
+ export const COMMAND_RUN_TERMINAL_STATUSES = Object.freeze(new Set([
52
+ "succeeded", "failed", "cancelled", "timed_out", "owner_lost", "invalidated",
53
+ ]));
54
+
49
55
  // BOT-2013: stack_lease frame states that mean the lease is gone, not granted.
50
56
  export const LEASE_LOST_STATES = Object.freeze(new Set(["reaping", "reaped", "reap_failed"]));
51
57
  // BOT-2400: the lease states a `lease:id=…,state=<s>` wait may target.
@@ -198,6 +204,18 @@ const VALIDATORS = {
198
204
  if (!p.id) throw new Error("test-run needs id=<run_id>");
199
205
  return { runId: String(p.id) };
200
206
  },
207
+ // BOT-2318: server binds this selector to the registering tenant, agent and work session.
208
+ "command-run": (p, raw) => {
209
+ const pair = /^\s*id\s*=\s*([0-9A-Fa-f-]+)\s*$/.exec(raw ?? "");
210
+ if (!pair || Object.keys(p).length !== 1 || p.id !== pair[1]) {
211
+ throw new Error("command-run needs exactly one id=<registered run id>");
212
+ }
213
+ const runId = pair[1].toLowerCase();
214
+ if (!COMMAND_RUN_ID.test(runId)) {
215
+ throw new Error("command-run id must be the canonical UUID that bb run printed");
216
+ }
217
+ return { runId };
218
+ },
201
219
  // BOT-2629: this is deliberately a dedicated condition rather than a raw
202
220
  // event escape hatch. The UUID is chosen before dispatch and the exact subject
203
221
  // prevents another run on the same idle/re-adopted lease waking this waiter.
@@ -620,7 +638,7 @@ export function parseConditions(specs) {
620
638
  return;
621
639
  }
622
640
  try {
623
- const params = validator(parseParams(rawParams));
641
+ const params = validator(parseParams(rawParams), rawParams);
624
642
  conditions.push({ id: `c${index + 1}`, type, params });
625
643
  } catch (err) {
626
644
  errors.push({ spec, message: err.message });
@@ -1210,6 +1228,11 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1210
1228
  prReviewFiltersMatch(signal, params, condition.review_guard);
1211
1229
  case "test-run":
1212
1230
  return signal.signal_type === "test_run" && signal.subject_key === `run:${params.runId}`;
1231
+ case "command-run":
1232
+ return signal.signal_type === "command_run" &&
1233
+ signal.subject_key === `run:${params.runId}` &&
1234
+ signal.payload?.run_id === params.runId &&
1235
+ COMMAND_RUN_TERMINAL_STATUSES.has(signal.payload?.status);
1213
1236
  case "stack-run":
1214
1237
  return signal.signal_type === "stack_run" && signal.subject_key === `stack-run:${params.id}` &&
1215
1238
  signal.payload?.run_id === params.id && Number.isInteger(signal.payload?.exit_code);
package/src/wait.mjs CHANGED
@@ -152,6 +152,14 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
152
152
  valid workflow=/workflow_id=/workflow_path= lines.
153
153
  test-run:id=<run_id> a test run reaching a terminal status (owner-scoped);
154
154
  bb test <lane> prints the exact line to arm.
155
+ command-run:id=<run_id> BOT-2318: the bb run you registered reaching a terminal
156
+ outcome (succeeded, failed, cancelled, timed_out,
157
+ owner_lost, invalidated). Exit 0 on any terminal outcome;
158
+ the receipt's status says how the command ended. Arm it
159
+ from the same work session that ran bb run, as the ONLY
160
+ condition, and keep this wait's result pending: its
161
+ return is what resumes your task. A run whose worker is
162
+ lost ends owner_lost (bb run inspect <run_id>).
155
163
  stack-run:id=<run UUID> one named bb stack run reaching its terminal outcome
156
164
  (BOT-2629). Choose the UUID BEFORE dispatch and pass it as
157
165
  bb stack run --run-id <UUID>; the receipt carries exit_code,
@@ -585,6 +593,12 @@ async function registerWait(opts, conditions, deadlineIso) {
585
593
  // caller-actionable hard stops, not the untracked live-only fallback.
586
594
  "chat_not_participant",
587
595
  "chat_participant_ambiguous",
596
+ // BOT-2318: command-run targets are authorization-bound to the registering
597
+ // tenant, agent and work session, and the condition cannot be composed.
598
+ // Both refusals require the caller to change its target/conditions; falling
599
+ // back to an untracked wait would only hide that error behind a timeout.
600
+ "command_run_unavailable",
601
+ "command_run_single_condition_required",
588
602
  ]);
589
603
  if (INVALID_CONDITION_CODES.has(body.error)) {
590
604
  const err = new Error(body.detail || body.error);