@botbuddy/cli 1.60.0 → 1.60.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.60.0",
3
- "source_commit": "2bde2741ba9158ce03147a2ff28f1c6a44f89e25",
4
- "source_sha256": "7091f785b4820eccd5f9596e04def9ef58a02cbb5130ccb84bd55ae77e71ec67",
2
+ "release_version": "1.60.1",
3
+ "source_commit": "ac9d46a93d57bb9d5af94cba238532767c1f19cc",
4
+ "source_sha256": "667acf404a591bd0e09357645192040cba7a0b2085f33d89be6a4bbc84a6dd53",
5
5
  "target": "darwin-arm64",
6
- "sha256": "b6fe4ed183d92a32e398cb9de3f82ac6b4634188409f51eadad457ca6b37c307"
6
+ "sha256": "9dd4af84ad4131cf69c94bbed69f3c51087ca8ac4d742df6e9367faadbeddb97"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.0",
3
- "source_commit": "2bde2741ba9158ce03147a2ff28f1c6a44f89e25",
4
- "source_sha256": "7091f785b4820eccd5f9596e04def9ef58a02cbb5130ccb84bd55ae77e71ec67",
2
+ "release_version": "1.60.1",
3
+ "source_commit": "ac9d46a93d57bb9d5af94cba238532767c1f19cc",
4
+ "source_sha256": "667acf404a591bd0e09357645192040cba7a0b2085f33d89be6a4bbc84a6dd53",
5
5
  "target": "linux-x64",
6
- "sha256": "3a1839c5800e38bf9bb7a1465555e0cace01ef82d9c382fde1e036608c536fe8"
6
+ "sha256": "61db8eb2108fcf9407460e96f487316728e262927241b5ee672ffca3443a11d3"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.0",
4
- "source_commit": "2bde2741ba9158ce03147a2ff28f1c6a44f89e25",
5
- "source_sha256": "7091f785b4820eccd5f9596e04def9ef58a02cbb5130ccb84bd55ae77e71ec67",
3
+ "release_version": "1.60.1",
4
+ "source_commit": "ac9d46a93d57bb9d5af94cba238532767c1f19cc",
5
+ "source_sha256": "667acf404a591bd0e09357645192040cba7a0b2085f33d89be6a4bbc84a6dd53",
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": "b6fe4ed183d92a32e398cb9de3f82ac6b4634188409f51eadad457ca6b37c307",
11
+ "sha256": "9dd4af84ad4131cf69c94bbed69f3c51087ca8ac4d742df6e9367faadbeddb97",
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": "3a1839c5800e38bf9bb7a1465555e0cace01ef82d9c382fde1e036608c536fe8",
18
+ "sha256": "61db8eb2108fcf9407460e96f487316728e262927241b5ee672ffca3443a11d3",
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.60.0",
3
+ "version": "1.60.1",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -483,12 +483,24 @@ does not prove that the workload is still running.
483
483
 
484
484
  ${bold("Safe recovery")}
485
485
  1. Preserve the command-run ID and the original result.
486
- 2. Prefer the owning runner's local terminal receipt for the workload verdict.
487
- 3. If the receipt is terminal, do not cancel or duplicate the command solely
486
+ 2. Run ${cyan("bb run inspect <command-run-id>")} from the owning checkout.
487
+ 3. Prefer the owning runner's local terminal receipt for the workload verdict.
488
+ 4. If the receipt is terminal, do not cancel or duplicate the command solely
488
489
  because remote state remains ${cyan("registered")} or ${cyan("running")}.
489
- 4. If the receipt is absent/non-terminal, retain the original runner result;
490
+ 5. If the receipt is absent/non-terminal, retain the original runner result;
490
491
  do not poll or infer completion from elapsed time.
491
- 5. Report the receipt and remote discrepancy as telemetry evidence.
492
+ 6. Report the receipt and remote discrepancy as telemetry evidence. A stale
493
+ test-run or command-run is never validation evidence.
494
+
495
+ ${bold("One controlled push retry")}
496
+ Only after inspection leaves the original push unresolved, arm an exact-SHA CI
497
+ wait before one new push, then verify the resulting CI run reports that SHA:
498
+
499
+ ${cyan("bb wait 'ci:repo=SupplySpark/supplyspark-ent,scope=next,sha=<full-sha>' --heartbeat")}
500
+
501
+ Do not substitute ${cyan("pr-state")}, a timer, or a status-polling loop for a bare
502
+ push. If the prior push has no terminal receipt, keep it unresolved rather than
503
+ counting it as validation evidence.
492
504
 
493
505
  ${bold("Boundary")}
494
506
  Command-run status is agent-owned. A different agent may be denied access; that
package/src/run.mjs CHANGED
@@ -11,7 +11,7 @@ import { stackLeaseIdFromEnv, withStackLeaseCorrelation } from "./stack-lease-co
11
11
  import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
12
12
  import { dirname, join } from "node:path";
13
13
  import { homedir } from "node:os";
14
- import { spawn } from "node:child_process";
14
+ import { execFileSync, spawn } from "node:child_process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { performance } from "node:perf_hooks";
17
17
  import { callToolJson } from "./api.mjs";
@@ -26,7 +26,7 @@ import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
26
26
  import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
27
27
  import { VERSION } from "./version.mjs";
28
28
  import {
29
- TERMINAL_RETRY_BUDGET_MS, DELIVERY_CALL_TIMEOUT_MS, callWithContextIdentity, classifyTerminalUpdateFailure,
29
+ TERMINAL_RETRY_BUDGET_MS, DELIVERY_CALL_TIMEOUT_MS, callWithContextIdentity, classifyTerminalUpdateFailure, contextServerUrl,
30
30
  ensureRunDelivery, parkedRecord, runDelivery, terminalUpdateAccepted,
31
31
  } from "./run-delivery.mjs";
32
32
 
@@ -221,6 +221,154 @@ export function commandHash(command) { return createHash("sha256").update(JSON.s
221
221
  export function environmentHash(cwd, environment) { return createHash("sha256").update(`${cwd}\0${environment}`).digest("hex"); }
222
222
  export function receiptPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.receipt.json`); }
223
223
  export function contextPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.context.json`); }
224
+
225
+ // A command receipt is evidence about one checkout revision, not a claim that
226
+ // its working tree was clean. Resolving this once at launch lets recovery
227
+ // distinguish a stale callback from an unrelated later command without ever
228
+ // returning the command line itself.
229
+ export function sourceSha(cwd, { exec = execFileSync } = {}) {
230
+ try {
231
+ const sha = String(exec("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })).trim();
232
+ return /^[0-9a-f]{40,64}$/i.test(sha) ? sha.toLowerCase() : null;
233
+ } catch { return null; }
234
+ }
235
+
236
+ const TERMINAL_COMMAND_STATUSES = new Set(["success", "failure", "cancelled", "timed_out", "owner_lost"]);
237
+ const TERMINAL_REMOTE_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out", "owner_lost", "invalidated"]);
238
+ const REMOTE_STATUS_FOR_COMMAND_STATUS = Object.freeze({
239
+ success: "succeeded",
240
+ failure: "failed",
241
+ cancelled: "cancelled",
242
+ timed_out: "timed_out",
243
+ owner_lost: "owner_lost",
244
+ });
245
+
246
+ function parseOwnedRunFile(raw, runId) {
247
+ try {
248
+ const value = JSON.parse(raw);
249
+ return value && value.run_id === runId ? value : null;
250
+ } catch { return null; }
251
+ }
252
+
253
+ function freshContext(context, now) {
254
+ const launchedAt = Date.parse(context?.launched_at ?? "");
255
+ const timeoutMs = Number(context?.timeout_seconds) * 1_000;
256
+ return Number.isFinite(launchedAt) && Number.isFinite(timeoutMs) && timeoutMs > 0
257
+ && now <= launchedAt + timeoutMs + TIMEOUT_GRACE_MS + 60_000;
258
+ }
259
+
260
+ function publicRemoteSummary(result) {
261
+ if (!result?.ok || result.isError || !result.data || typeof result.data !== "object") return { state: "unavailable" };
262
+ const status = typeof result.data.status === "string" ? result.data.status : null;
263
+ return {
264
+ state: status ? "available" : "unavailable",
265
+ ...(status ? { status } : {}),
266
+ ...(typeof result.data.exit_code === "number" ? { exit_code: result.data.exit_code } : {}),
267
+ ...(typeof result.data.ended_at === "string" ? { ended_at: result.data.ended_at } : {}),
268
+ };
269
+ }
270
+
271
+ function terminalOutcomesAgree(receipt, remote) {
272
+ if (!receipt || remote.state !== "available") return false;
273
+ if (REMOTE_STATUS_FOR_COMMAND_STATUS[receipt.status] !== remote.status) return false;
274
+ // A missing exit code is meaningful for cancellation, timeout, and
275
+ // owner-loss. When both sides do have one, a disagreement is never hidden.
276
+ return typeof receipt.exit_code !== "number" || typeof remote.exit_code !== "number"
277
+ || receipt.exit_code === remote.exit_code;
278
+ }
279
+
280
+ // Reconcile only evidence the current OS user and owning BotBuddy agent can
281
+ // read. This is intentionally advisory: an inspector must never terminate a
282
+ // worker, replay a callback, or make a retry safe merely by observing a file.
283
+ export async function inspectRun(runId, {
284
+ call = callToolJson,
285
+ read = readFile,
286
+ now = Date.now(),
287
+ processAlive = workerProcessGroupAlive,
288
+ receiptFile = receiptPath,
289
+ contextFile = contextPath,
290
+ } = {}) {
291
+ if (typeof runId !== "string" || !runId.trim()) {
292
+ return { schema_version: RUN_SCHEMA_VERSION, classification: "rejected", rerun_safe: false, cancellation_safe: false, error: "run_id_required" };
293
+ }
294
+ const id = runId.trim();
295
+ const [receiptRaw, contextRaw] = await Promise.all([
296
+ read(receiptFile(id), "utf8").catch(() => null),
297
+ read(contextFile(id), "utf8").catch(() => null),
298
+ ]);
299
+ const receipt = typeof receiptRaw === "string" ? parseOwnedRunFile(receiptRaw, id) : null;
300
+ const context = typeof contextRaw === "string" ? parseOwnedRunFile(contextRaw, id) : null;
301
+ // A durable run belongs to the agent that launched it. Prefer its sealed
302
+ // session identity; older contexts must use a current worktree session and
303
+ // never silently fall back to an unrelated durable machine credential.
304
+ const remoteResult = await callWithContextIdentity(context, call)("get_command_run", { run_id: id }, {
305
+ ...(context ? { serverUrl: contextServerUrl(context) } : {}),
306
+ }).catch(() => null);
307
+ const remote = publicRemoteSummary(remoteResult);
308
+ const receiptTerminal = Boolean(receipt && TERMINAL_COMMAND_STATUSES.has(receipt.status));
309
+ const receiptFingerprint = receipt && typeof receipt.command_hash === "string" && /^[a-f0-9]{64}$/i.test(receipt.command_hash)
310
+ && typeof receipt.source_sha === "string" && /^[a-f0-9]{40,64}$/i.test(receipt.source_sha);
311
+ const fingerprintMismatch = Boolean(context && (
312
+ (context.command_hash && context.command_hash !== receipt?.command_hash)
313
+ || (context.source_sha && context.source_sha !== receipt?.source_sha)
314
+ ));
315
+ const contextMatchesReceipt = !fingerprintMismatch;
316
+ const sourceBound = Boolean(receiptTerminal && receiptFingerprint && contextMatchesReceipt);
317
+
318
+ if (receiptTerminal) {
319
+ // A context that identifies a different command/revision must never be
320
+ // promoted to completed evidence, even if its receipt is terminal.
321
+ if (fingerprintMismatch) {
322
+ return {
323
+ schema_version: RUN_SCHEMA_VERSION,
324
+ run_id: id,
325
+ classification: "unknown",
326
+ rerun_safe: false,
327
+ cancellation_safe: false,
328
+ source_bound: false,
329
+ local: { status: null, exit_code: null },
330
+ remote,
331
+ };
332
+ }
333
+ const remoteTerminal = remote.state === "available" && TERMINAL_REMOTE_STATUSES.has(remote.status);
334
+ const outcomesAgree = remoteTerminal && terminalOutcomesAgree(receipt, remote);
335
+ return {
336
+ schema_version: RUN_SCHEMA_VERSION,
337
+ run_id: id,
338
+ classification: outcomesAgree ? "completed" : remoteTerminal ? "completed_locally_remote_conflict" : remote.status === "registered" || remote.status === "running" || remote.status === "approval_required" ? "completed_locally_remote_stale" : "completed_locally_remote_unknown",
339
+ rerun_safe: false,
340
+ cancellation_safe: false,
341
+ source_bound: sourceBound,
342
+ local: { status: receipt.status, exit_code: receipt.exit_code ?? null },
343
+ remote,
344
+ };
345
+ }
346
+
347
+ const pids = [context?.worker_pid, context?.workload_pid].filter((pid) => Number.isInteger(pid) && pid > 0);
348
+ if (context && freshContext(context, now) && pids.some((pid) => processAlive(pid))) {
349
+ return {
350
+ schema_version: RUN_SCHEMA_VERSION,
351
+ run_id: id,
352
+ classification: "running",
353
+ rerun_safe: false,
354
+ cancellation_safe: false,
355
+ source_bound: false,
356
+ local: { status: null, exit_code: null },
357
+ remote,
358
+ };
359
+ }
360
+
361
+ return {
362
+ schema_version: RUN_SCHEMA_VERSION,
363
+ run_id: id,
364
+ classification: "unknown",
365
+ rerun_safe: false,
366
+ cancellation_safe: false,
367
+ source_bound: false,
368
+ local: { status: null, exit_code: null },
369
+ remote,
370
+ };
371
+ }
224
372
  function cancellationPath(contextFile) { return `${contextFile}.cancelled`; }
225
373
 
226
374
  async function markCancellation(contextFile, runId) {
@@ -250,6 +398,8 @@ async function preWorkloadCancellationReceipt(context) {
250
398
  ended_at: now,
251
399
  exit_code: null,
252
400
  signal: "SIGTERM",
401
+ command_hash: context.command_hash ?? commandHash(context.command ?? []),
402
+ source_sha: context.source_sha ?? null,
253
403
  recovery_command: null,
254
404
  bounded_log: "",
255
405
  output_characters: 0,
@@ -347,7 +497,7 @@ function rejectedSessionReceipt(error, runId) {
347
497
 
348
498
  export async function launchRun(argv, {
349
499
  cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null,
350
- resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env,
500
+ resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env, getSourceSha = sourceSha,
351
501
  } = {}) {
352
502
  const parsed = parseRunArgs(argv, env);
353
503
  let managedCredential = null;
@@ -376,12 +526,14 @@ export async function launchRun(argv, {
376
526
  const effectiveCall = (t, a) => (call ?? callToolJson)(t, a,
377
527
  opts.sessionToken ? { auth: { "x-agent-api-key": opts.sessionToken } } : {});
378
528
  const runId = randomUUID();
529
+ const commandFingerprint = commandHash(command);
530
+ const launchSourceSha = getSourceSha(cwd);
379
531
  const registerArgs = () => ({
380
532
  // Omit session_id entirely when only a token is present — the backend derives
381
533
  // it. A plain id is still sent (and honoured) when supplied.
382
534
  ...(opts.sessionId ? { session_id: opts.sessionId } : {}),
383
535
  run_id: runId, tool_category: opts.category,
384
- command_hash: commandHash(command), environment_hash: environmentHash(cwd, opts.environment),
536
+ command_hash: commandFingerprint, ...(launchSourceSha ? { source_sha: launchSourceSha } : {}), environment_hash: environmentHash(cwd, opts.environment),
385
537
  environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
386
538
  rerun_reason: opts.rerunReason,
387
539
  });
@@ -411,7 +563,7 @@ export async function launchRun(argv, {
411
563
  // every callback is attributable to the same agent. It deliberately stays
412
564
  // outside child_env, receipts, and the workload's process environment.
413
565
  const knownSession = opts.sessionId ?? managedCredential?.identity?.sessionId ?? managedCredential?.sessionId;
414
- 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, 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 } : {}) });
566
+ 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 } : {}) });
415
567
  const [file, args] = workerInvocation(context);
416
568
  try {
417
569
  const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
@@ -804,7 +956,7 @@ export async function cancelRun(runId, {
804
956
  async function finalize(context, state, call, retry = {}) {
805
957
  const boundedLog = String(state.output ?? "").slice(-8192);
806
958
  const outputCharacters = state.outputCharacters ?? String(state.output ?? "").length;
807
- const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
959
+ const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, command_hash: context.command_hash ?? commandHash(context.command ?? []), source_sha: context.source_sha ?? null, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
808
960
  await writeJson(context.receipt_path, receipt);
809
961
  const bytes = await stat(context.receipt_path);
810
962
  const sha256 = createHash("sha256").update(await readFile(context.receipt_path)).digest("hex");
@@ -882,6 +1034,12 @@ export async function cmdRun(args) {
882
1034
  if (args.includes("--json")) oneLine({ schema_version: RUN_SCHEMA_VERSION, ...result }, false);
883
1035
  return;
884
1036
  }
1037
+ if (args[0] === "inspect") {
1038
+ const result = await inspectRun(args[1] ?? "");
1039
+ oneLine(result, false);
1040
+ process.exitCode = result.classification === "rejected" ? EXIT.INVALID : EXIT.OK;
1041
+ return;
1042
+ }
885
1043
  if (args[0] === "cancel") {
886
1044
  const result = await cancelRun(args[1] ?? "");
887
1045
  oneLine(result.receipt, false);
@@ -889,7 +1047,7 @@ export async function cmdRun(args) {
889
1047
  return;
890
1048
  }
891
1049
  if (args[0] === "help" || args[0] === "--help") {
892
- console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
1050
+ console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run inspect <run_id>\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
893
1051
  return;
894
1052
  }
895
1053
  // BOT-1701 (AC-6): one stderr nudge when a tier-3 legacy flag/env alias is used