@botbuddy/cli 1.37.0 → 1.38.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,22 @@
1
+ // BOT-1319 — report a phase to the BotBuddy lefthook lane wrapper that may be
2
+ // running this command (botbuddy-web `scripts/log-test-run.mjs`, the same file
3
+ // protocol as `scripts/lib/lane-status.mjs`). The wrapper exports
4
+ // $BOTBUDDY_LANE_STATUS_FILE and turns each atomic rewrite into one lifecycle
5
+ // progress event, so a lane parked here for Docker capacity shows as
6
+ // `waiting · queued_for_capacity` instead of as silent test time. Unset outside a
7
+ // wrapped lane → no-op. Never throws.
8
+
9
+ import { renameSync, writeFileSync } from "node:fs";
10
+
11
+ export function writeLaneStatus(status, env = process.env) {
12
+ const path = env.BOTBUDDY_LANE_STATUS_FILE;
13
+ if (!path) return false;
14
+ try {
15
+ const tmp = `${path}.${process.pid}.tmp`;
16
+ writeFileSync(tmp, JSON.stringify({ ...status, at: new Date().toISOString() }));
17
+ renameSync(tmp, path);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
package/src/stack.mjs CHANGED
@@ -21,6 +21,7 @@
21
21
  // labelled fallback for when no Helper is available: it runs `supabase start`/
22
22
  // `stop` itself and self-activates/finalizes via the fallback MCP tools.
23
23
 
24
+ import { writeLaneStatus } from "./lane-status.mjs";
24
25
  import { spawn, spawnSync } from "child_process";
25
26
  import { readFileSync, realpathSync } from "fs";
26
27
  import { open, unlink, readFile } from "fs/promises";
@@ -1497,6 +1498,7 @@ export async function cmdUp(opts, {
1497
1498
  machineUuidFn = machineUuid,
1498
1499
  assertIsolated = validateLocalExecTarget,
1499
1500
  refreshAuth = refreshRejectedCacheAuth,
1501
+ reportLaneStatus = writeLaneStatus,
1500
1502
  waitCapacity = defaultWaitCapacity,
1501
1503
  } = {}) {
1502
1504
  let slot;
@@ -1641,6 +1643,9 @@ export async function cmdUp(opts, {
1641
1643
  const reusedExisting = state === "active";
1642
1644
 
1643
1645
  if (state === "queued") {
1646
+ // BOT-1319: a wrapped lefthook lane records this park as waiting for capacity.
1647
+ const where = [d.queue_position != null ? `queue position ${d.queue_position}` : null, d.host_key ? `on ${d.host_key}` : null].filter(Boolean).join(" ");
1648
+ reportLaneStatus({ phase: "waiting", reason: "queued_for_capacity", detail: where || "host at capacity" });
1644
1649
  if (opts.noWait) {
1645
1650
  return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, queue_position: d.queue_position, holders: d.holders }), opts, EXIT.QUEUED);
1646
1651
  }
@@ -1651,6 +1656,7 @@ export async function cmdUp(opts, {
1651
1656
  if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
1652
1657
  state = parked.state || "provisioning";
1653
1658
  }
1659
+ reportLaneStatus({ phase: "testing", reason: "provisioning" });
1654
1660
 
1655
1661
  // Reach `active`: local-exec provisions itself; otherwise wait for the Helper.
1656
1662
  if (state !== "active") {
@@ -323,11 +323,19 @@ export async function createTelemetryOutbox({
323
323
  try { imported = JSON.parse(await readFile(legacyImportPath, "utf8")); } catch { /* first migration */ }
324
324
  let names = [];
325
325
  try { names = (await readdir(legacyDirectory)).filter((name) => name.endsWith(".json")); } catch (error) { if (error?.code === "ENOENT") return { imported: 0, skipped: 0 }; throw error; }
326
+ // BOT-1319: the test-ingest reporter archives what it posted under reported/
327
+ // before the next hook can recover it; a summary whose lifecycle was degraded
328
+ // is still eligible there (others under reported/ were already counted).
329
+ let archived = [];
330
+ try { archived = (await readdir(join(legacyDirectory, "reported"))).filter((name) => name.endsWith(".json")); } catch { /* none archived */ }
331
+ const entries = [
332
+ ...names.sort().map((name) => ({ name, file: join(legacyDirectory, name), archived: false })),
333
+ ...archived.sort().map((name) => ({ name, file: join(legacyDirectory, "reported", name), archived: true })),
334
+ ];
326
335
  let added = 0;
327
336
  let skipped = 0;
328
- for (const name of names.sort()) {
337
+ for (const { name, file, archived: fromArchive } of entries) {
329
338
  if (added >= maxReceipts || Date.now() >= deadline) break;
330
- const file = join(legacyDirectory, name);
331
339
  let raw;
332
340
  try { raw = await readFile(file, "utf8"); } catch { skipped += 1; continue; }
333
341
  const fingerprint = hash(`${name}\0${raw}`);
@@ -335,13 +343,30 @@ export async function createTelemetryOutbox({
335
343
  let summary;
336
344
  try { summary = JSON.parse(raw); } catch { skipped += 1; continue; }
337
345
  if (!summary || typeof summary !== "object" || typeof summary.runner !== "string") { skipped += 1; continue; }
346
+ if (fromArchive && summary.lifecycle?.telemetry !== "degraded") continue;
347
+ // BOT-1319: the lane wrapper (and anything nested in a wrapped lane) stamps
348
+ // `lifecycle` when a canonical execution already carries this run. Importing
349
+ // it too would count the same run twice; record it as seen and move on. A
350
+ // wrapper summary stamped `telemetry: "degraded"` never reached the outbox
351
+ // durably, so it stays eligible and imports like a historical summary.
352
+ const lifecycle = summary.lifecycle;
353
+ if (lifecycle && typeof lifecycle === "object" && (lifecycle.owned_by || lifecycle.telemetry === "durable")) {
354
+ imported[fingerprint] = { name, imported_at: now().toISOString(), lifecycle: true };
355
+ skipped += 1;
356
+ continue;
357
+ }
338
358
  const sourceRunId = `legacy-${fingerprint.slice(0, 32)}`;
339
359
  const started = typeof summary.started_at === "string" ? summary.started_at : now().toISOString();
340
360
  const finished = typeof summary.finished_at === "string" ? summary.finished_at : now().toISOString();
341
- const passed = summary.verdict === "pass" || summary.exit_code === 0;
342
- const context = { repo: repository, environment: "local", runner: summary.runner, phase: passed ? "complete" : "failed", health: "uninstrumented" };
361
+ // An explicit skip (BOT-1319 wrapper, verdict "skip", exit 0) is never green.
362
+ const isSkip = summary.verdict === "skip";
363
+ const passed = !isSkip && (summary.verdict === "pass" || summary.exit_code === 0);
364
+ const context = { repo: repository, environment: "local", runner: summary.runner, phase: passed || isSkip ? "complete" : "failed", health: "uninstrumented", ...(isSkip ? { skipped: 1 } : {}) };
365
+ const terminal = isSkip
366
+ ? { outcome: "canceled", diagnostic: { code: "skipped", category: "skip", message: String(summary.skipped_reason ?? "skipped").slice(0, 500) } }
367
+ : { outcome: passed ? "passed" : "failed" };
343
368
  await append({ schema_version: 1, action: "start", producer_kind: "hook", source_run_id: sourceRunId, attempt: 1, producer_event_id: `${sourceRunId}:start`, producer_sequence: 0, occurred_at: started, context });
344
- await append({ schema_version: 1, action: "terminal", producer_kind: "hook", source_run_id: sourceRunId, attempt: 1, producer_event_id: `${sourceRunId}:terminal`, producer_sequence: 1, occurred_at: finished, context, outcome: passed ? "passed" : "failed" });
369
+ await append({ schema_version: 1, action: "terminal", producer_kind: "hook", source_run_id: sourceRunId, attempt: 1, producer_event_id: `${sourceRunId}:terminal`, producer_sequence: 1, occurred_at: finished, context, ...terminal });
345
370
  imported[fingerprint] = { name, imported_at: now().toISOString() };
346
371
  added += 1;
347
372
  }