@botbuddy/cli 1.60.8 → 1.60.9

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.8",
3
- "source_commit": "057670de6f7ac5dc8e8e673dab13a317aef876e9",
4
- "source_sha256": "3678a4ff49dda524b2b97e5a9d57249b64041f587da4db0c936b2f650647c6ec",
2
+ "release_version": "1.60.9",
3
+ "source_commit": "460663423af03d399c5b15a72a0a493bdb888813",
4
+ "source_sha256": "bb012b774ec1728801e48d3239f0a0aecd4e948d21fae5ab866d91935a4d566f",
5
5
  "target": "darwin-arm64",
6
- "sha256": "234246b7e1b8634420dbe2bceba35a4efa51a0ade206b09882f9c80a0aa66bfb"
6
+ "sha256": "64623b249bafec25024c52e1953382ee6d15e09652fd8450593275a0504059c0"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.8",
3
- "source_commit": "057670de6f7ac5dc8e8e673dab13a317aef876e9",
4
- "source_sha256": "3678a4ff49dda524b2b97e5a9d57249b64041f587da4db0c936b2f650647c6ec",
2
+ "release_version": "1.60.9",
3
+ "source_commit": "460663423af03d399c5b15a72a0a493bdb888813",
4
+ "source_sha256": "bb012b774ec1728801e48d3239f0a0aecd4e948d21fae5ab866d91935a4d566f",
5
5
  "target": "linux-x64",
6
- "sha256": "c29069dc84ea8273ea4b560816eb130a82dfbba083a6d8149edecd8cc85b534c"
6
+ "sha256": "2b75efb4413d95344478fdf9506679e435f48ef2f048b44236f00e941bcad445"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.8",
4
- "source_commit": "057670de6f7ac5dc8e8e673dab13a317aef876e9",
5
- "source_sha256": "3678a4ff49dda524b2b97e5a9d57249b64041f587da4db0c936b2f650647c6ec",
3
+ "release_version": "1.60.9",
4
+ "source_commit": "460663423af03d399c5b15a72a0a493bdb888813",
5
+ "source_sha256": "bb012b774ec1728801e48d3239f0a0aecd4e948d21fae5ab866d91935a4d566f",
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": "234246b7e1b8634420dbe2bceba35a4efa51a0ade206b09882f9c80a0aa66bfb",
11
+ "sha256": "64623b249bafec25024c52e1953382ee6d15e09652fd8450593275a0504059c0",
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": "c29069dc84ea8273ea4b560816eb130a82dfbba083a6d8149edecd8cc85b534c",
18
+ "sha256": "2b75efb4413d95344478fdf9506679e435f48ef2f048b44236f00e941bcad445",
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.8",
3
+ "version": "1.60.9",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  // success). It carries row COUNTS only — never paths, never prompt text, never
11
11
  // token totals. Fail-open: any failure exits 0 unless --strict.
12
12
  import { execFileSync } from "node:child_process";
13
+ import { randomUUID } from "node:crypto";
13
14
  import { existsSync, readFileSync } from "node:fs";
14
15
  import { readdir, stat } from "node:fs/promises";
15
16
  import { homedir, hostname as systemHostname } from "node:os";
@@ -90,16 +91,20 @@ export function transcriptRoots(env = process.env, home = homedir()) {
90
91
  * subdirectories) modified since `cutoffMs`. `shouldStop` is checked before
91
92
  * every directory read and stat, so a huge history or a slow filesystem cannot
92
93
  * outlive the run's time budget (the pre-push lane runs with 5 s).
93
- * @returns {Promise<{ files: string[], stopped: boolean }>}
94
+ * `errors` counts directories or files that exist but could not be read
95
+ * (EACCES, EPERM, …); a missing root or a file that vanished is not an error.
96
+ * @returns {Promise<{ files: string[], stopped: boolean, errors: number }>}
94
97
  */
95
98
  export async function listTranscripts(root, cutoffMs, { shouldStop = () => false, keep = () => false } = {}) {
96
99
  const out = [];
97
100
  let stopped = false;
101
+ let errors = 0;
102
+ const benign = (error) => error?.code === "ENOENT" || error?.code === "ENOTDIR";
98
103
  const halt = () => (stopped = stopped || shouldStop());
99
104
  async function walk(dir) {
100
105
  if (halt()) return;
101
106
  let entries;
102
- try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
107
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { if (!benign(error)) errors += 1; return; }
103
108
  entries.sort((a, b) => a.name.localeCompare(b.name));
104
109
  for (const entry of entries) {
105
110
  if (halt()) return;
@@ -109,12 +114,12 @@ export async function listTranscripts(root, cutoffMs, { shouldStop = () => false
109
114
  try {
110
115
  const info = await stat(path);
111
116
  if (info.mtimeMs >= cutoffMs || keep(path, info)) out.push(path);
112
- } catch { /* vanished between readdir and stat */ }
117
+ } catch (error) { if (!benign(error)) errors += 1; /* ENOENT: vanished between readdir and stat */ }
113
118
  }
114
119
  }
115
120
  }
116
121
  await walk(root);
117
- return { files: out, stopped };
122
+ return { files: out, stopped, errors };
118
123
  }
119
124
 
120
125
  /** Which adapter owns a single `--file`: under a Codex root, or a rollout whose first record is session_meta. */
@@ -373,19 +378,24 @@ export async function syncUsage(opts, deps = {}) {
373
378
  roots = transcriptRoots(env, home), repoFor = createRepoResolver(), branchFor = createBranchResolver({ env }), cliVersion = "0.0.0",
374
379
  tenantFor = createTenantResolver({ env }), resolveTarget = defaultResolveTarget, readLines = readCompleteLines,
375
380
  serverUrl = env.BOTBUDDY_SERVER_URL || DEFAULT_SERVER_URL, statePath = cursorPath(home),
381
+ newSyncId = randomUUID,
376
382
  } = deps;
377
383
  const startedMs = now();
378
384
  const deadlineMs = startedMs + opts.maxSeconds * 1000;
379
385
  const parseDeadlineMs = startedMs + opts.maxSeconds * 1000 * PARSE_BUDGET_FRACTION;
380
386
  const startedAt = new Date(startedMs).toISOString();
387
+ // BOT-2649: one id for this whole invocation, sent with every batch (both
388
+ // providers), so v_usage_sync_hosts judges the attempt as a whole: one
389
+ // refused batch fails the sync even when a later batch succeeded.
390
+ const syncId = newSyncId();
381
391
  const receipt = {
382
392
  schema_version: 1, command: "usage sync", outcome: "ok", trigger: opts.trigger, dry_run: opts.dryRun,
383
393
  providers: [], files_scanned: 0, files_read: 0, envelopes: 0, batches: 0,
384
394
  tenant: null, skipped_other_tenant: 0, skipped_unbound: 0,
385
395
  seen: 0, inserted: 0, superseded: 0, deduped: 0, refused: 0, refusal_reasons: {}, batches_all_refused: 0,
386
- refused_local: 0, parse_errors: 0,
396
+ refused_local: 0, parse_errors: 0, read_errors: 0,
387
397
  held_open_messages: 0, cost_state_withheld: 0,
388
- privacy: "pass", budget_exhausted: false, run_ids: [], started_at: startedAt, duration_ms: 0,
398
+ privacy: "pass", budget_exhausted: false, run_ids: [], started_at: startedAt, sync_id: syncId, duration_ms: 0,
389
399
  };
390
400
  const finish = (outcome, error) => {
391
401
  receipt.outcome = outcome;
@@ -441,6 +451,8 @@ export async function syncUsage(opts, deps = {}) {
441
451
  },
442
452
  });
443
453
  if (listed.stopped) receipt.budget_exhausted = true;
454
+ // BOT-2649: an unreadable transcript directory is a read failure, not an idle host.
455
+ receipt.read_errors += listed.errors ?? 0;
444
456
  plan.push({ provider, files: listed.stopped ? [] : listed.files });
445
457
  }
446
458
  }
@@ -466,7 +478,7 @@ export async function syncUsage(opts, deps = {}) {
466
478
  : createClaudeAdapter({ hmac, environment, repoFor, tenantFor });
467
479
  const run = {
468
480
  host: hostname(), trigger: opts.trigger, provider, cli_version: cliVersion,
469
- parser_version: adapter.parserVersion, started_at: startedAt,
481
+ parser_version: adapter.parserVersion, started_at: startedAt, sync_id: syncId,
470
482
  };
471
483
 
472
484
  // Envelopes are queued with a global sequence; a file's cursor commits only
@@ -535,7 +547,9 @@ export async function syncUsage(opts, deps = {}) {
535
547
  for (const path of files) {
536
548
  if (now() >= parseDeadlineMs) { receipt.budget_exhausted = true; break; }
537
549
  let info;
538
- try { info = await stat(path); } catch { continue; }
550
+ // A file that vanished since listing is benign; any other stat failure
551
+ // (e.g. EACCES) means this run could not read pending usage (BOT-2649).
552
+ try { info = await stat(path); } catch (error) { if (error?.code !== "ENOENT") receipt.read_errors += 1; continue; }
539
553
  const sourceHmac = hmac(resolve(path));
540
554
  // Cursors are per (tenant, file): a run skips other tenants' records, so a
541
555
  // shared per-file cursor would consume them for every later tenant's run.
@@ -560,6 +574,7 @@ export async function syncUsage(opts, deps = {}) {
560
574
  parser.parseRecord(record, offset);
561
575
  }, { shouldStop: () => now() >= parseDeadlineMs });
562
576
  } catch {
577
+ receipt.read_errors += 1; // counted: a run that could not read is not a clean idle run
563
578
  continue; // unreadable file: skip, cursor unchanged
564
579
  }
565
580
  if (result.stopped) receipt.budget_exhausted = true;
@@ -606,8 +621,43 @@ export async function syncUsage(opts, deps = {}) {
606
621
  }
607
622
  commitDelivered();
608
623
  }
624
+ // BOT-2649: a run that uploaded nothing still records one empty ok run, so an
625
+ // idle but healthy host is not aged into "stale" by v_usage_sync_hosts. Only a
626
+ // CLEAN idle run does: one that refused records locally, hit unparseable lines,
627
+ // could not read a transcript or ran out of budget did not prove usage lands, so it records nothing and the
628
+ // host ages as before rather than reading healthy (independent review on #1324).
629
+ if (!opts.dryRun && receipt.batches === 0) {
630
+ const withheld = receipt.budget_exhausted ? "budget_exhausted"
631
+ : receipt.refused_local > 0 ? "refused_local"
632
+ : receipt.parse_errors > 0 ? "parse_errors"
633
+ : receipt.read_errors > 0 ? "read_errors" : null;
634
+ if (withheld) {
635
+ receipt.empty_run_withheld = withheld;
636
+ } else {
637
+ const error = await postEmptyRun();
638
+ if (error === OUT_OF_TIME) return finalize(null);
639
+ if (error) return finalize(error);
640
+ }
641
+ }
609
642
  return finalize(null);
610
643
 
644
+ async function postEmptyRun() {
645
+ // Same deadline rules as a batch: never START a request past the budget.
646
+ if (now() >= deadlineMs) { receipt.budget_exhausted = true; return OUT_OF_TIME; }
647
+ const resolved = await ensureAuth();
648
+ if (resolved.error) return resolved.error;
649
+ if (now() >= deadlineMs) { receipt.budget_exhausted = true; return OUT_OF_TIME; }
650
+ const run = { host: hostname(), trigger: opts.trigger, cli_version: cliVersion, started_at: startedAt, sync_id: syncId };
651
+ const result = await postBatch({
652
+ endpoint: usageIngestEndpoint(serverUrl), tenant: resolved.tenant,
653
+ headers: resolved.headers, run, envelopes: [], fetchImpl, timeoutMs: deadlineMs - now(),
654
+ });
655
+ if (!result.ok) return result.error;
656
+ receipt.empty_run = true;
657
+ if (result.run_id) receipt.run_ids.push(result.run_id);
658
+ return null;
659
+ }
660
+
611
661
  async function finalize(error) {
612
662
  const outcome = error ? "error" : opts.dryRun ? "dry_run" : receipt.budget_exhausted ? "partial" : "ok";
613
663
  finish(outcome, error);