@botbuddy/cli 1.33.6 → 1.33.8

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.33.6",
3
+ "version": "1.33.8",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/wait-core.mjs CHANGED
@@ -692,6 +692,49 @@ export function formatPrReviewSnapshotWarnings(snapshot, now = Date.now()) {
692
692
  return lines;
693
693
  }
694
694
 
695
+ /**
696
+ * BOT-1837 — the one-line arm-time notice for a `scope=next` ci wait, built from
697
+ * the relay's `arm_context.latest_run`. Report-only: it says what CI has ALREADY
698
+ * done on the branch, so an agent that armed after the run it meant can see that
699
+ * immediately instead of discovering it at the deadline. Returns null when there
700
+ * is nothing to report. A missing field is reported as unknown, never guessed.
701
+ *
702
+ * @param {{run_id?:string|number|null,workflow_name?:string|null,status?:string|null,conclusion?:string|null,created_at?:string|null}|null|undefined} latestRun
703
+ * @param {{branch?:string|null, now?:number}} [opts]
704
+ * @returns {string|null}
705
+ */
706
+ export function formatCiLatestRunNotice(latestRun, { branch = null, now = Date.now() } = {}) {
707
+ if (!latestRun || typeof latestRun !== "object") return null;
708
+ const runId = latestRun.run_id ?? "?";
709
+ const workflow = latestRun.workflow_name || "unknown workflow";
710
+ const status = latestRun.status || "unknown";
711
+ const conclusion = latestRun.conclusion || "none";
712
+ const createdMs = Date.parse(latestRun.created_at ?? "");
713
+ const age = Number.isFinite(createdMs)
714
+ ? `${Math.max(0, Math.round((now - createdMs) / 60000))} min ago`
715
+ : "age unknown";
716
+ return `latest run on ${branch || "?"}: ${workflow} #${runId} ${status}/${conclusion} ` +
717
+ `(${age}) — scope=next wakes only on a NEWER run`;
718
+ }
719
+
720
+ /**
721
+ * BOT-1837 — the stderr block for a relay `unknown_workflow` refusal: the
722
+ * server's detail, then one COPY-PASTEABLE `workflow=<name>` line per candidate
723
+ * so the fix is a paste, not a guess.
724
+ *
725
+ * @param {string} detail
726
+ * @param {string[]|null|undefined} candidates
727
+ * @returns {string[]}
728
+ */
729
+ export function formatUnknownWorkflowGuidance(detail, candidates) {
730
+ const lines = [String(detail)];
731
+ const named = Array.isArray(candidates) ? candidates.filter((c) => typeof c === "string" && c.trim() !== "") : [];
732
+ if (named.length === 0) return lines;
733
+ lines.push("closest workflow names this repo has actually reported:");
734
+ for (const name of named) lines.push(` workflow=${name}`);
735
+ return lines;
736
+ }
737
+
695
738
  // ---------------------------------------------------------------------------
696
739
  // Matching — does a spine signal frame satisfy a condition?
697
740
  // ---------------------------------------------------------------------------
@@ -1073,6 +1116,10 @@ export async function runWaitLoop({
1073
1116
  // wait capacity_source_stale even though the host recovered seconds after the stale
1074
1117
  // frame. 90s = 60s cron + margin.
1075
1118
  beaconConfirmMs = 90_000,
1119
+ // BOT-1837: the arm-time notice (if any) to carry into a TIMEOUT receipt's
1120
+ // `error`, so the receipt itself says what CI was already doing when this wait
1121
+ // armed. Purely descriptive — it never changes the outcome or the exit code.
1122
+ timeoutError = null,
1076
1123
  }) {
1077
1124
  const startedAt = typeof originalStartedAt === "string" && Number.isFinite(Date.parse(originalStartedAt))
1078
1125
  ? originalStartedAt : nowIso();
@@ -1206,7 +1253,7 @@ export async function runWaitLoop({
1206
1253
  // The terminal feed-freshness reprobe now lives in finalize() (the single
1207
1254
  // finalization path), so every terminal receipt gets it — not just the alarm path.
1208
1255
  if (!alarm) return null;
1209
- if (alarm.kind === "timeout") return finalize("timeout", { exitCode: EXIT.TIMEOUT });
1256
+ if (alarm.kind === "timeout") return finalize("timeout", { exitCode: EXIT.TIMEOUT, error: timeoutError ?? null });
1210
1257
  if (alarm.kind === "stale") {
1211
1258
  // Capacity source didn't deliver within the grace: degrade truthfully and
1212
1259
  // exit with an error receipt (not a silent hang, not a plain timeout).
package/src/wait.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // Usage: botbuddy wait [--any] <condition>... [options]
14
14
  // Run botbuddy wait --help for the condition grammar.
15
15
 
16
- import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings } from "./wait-core.mjs";
16
+ import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance } from "./wait-core.mjs";
17
17
  import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
@@ -352,11 +352,24 @@ async function registerWait(opts, conditions, deadlineIso) {
352
352
  "session_id_required",
353
353
  "wait_resume_identity_invalid",
354
354
  "wait_resume_identity_mismatch",
355
+ // BOT-1837: `workflow=` names a workflow this repo has never reported a
356
+ // wake-capable run for. The wait could only ever expire, and no amount of
357
+ // waiting repairs a typo — a hard stop, with the server's candidates.
358
+ "unknown_workflow",
359
+ // BOT-1612: a `chat:channel=<id>` wait whose bound agent is not one of the
360
+ // channel's participants could only ever park to timeout (the emitter never
361
+ // scopes a message to a non-participant). The relay refuses it at arm time;
362
+ // `chat_participant_ambiguous` means two of the caller's own live-session
363
+ // agents are participants and --session-id must pick one. Both are
364
+ // caller-actionable hard stops, not the untracked live-only fallback.
365
+ "chat_not_participant",
366
+ "chat_participant_ambiguous",
355
367
  ]);
356
368
  if (INVALID_CONDITION_CODES.has(body.error)) {
357
369
  const err = new Error(body.detail || body.error);
358
370
  err.invalidCondition = true;
359
371
  err.errorCode = body.error;
372
+ if (Array.isArray(body.candidates)) err.candidates = body.candidates;
360
373
  throw err;
361
374
  }
362
375
  }
@@ -454,6 +467,13 @@ async function registerWait(opts, conditions, deadlineIso) {
454
467
  // pr-review/pr-state target. Report-only — surfaced on stderr so a silent
455
468
  // edge-triggered park isn't mistaken for "no review yet".
456
469
  prReviewSnapshot: Array.isArray(body.pr_review_snapshot) ? body.pr_review_snapshot : null,
470
+ // BOT-1837: what CI had already done on the branch at arm time. An ABSENT
471
+ // arm_context (a pre-BOT-1837 relay, or a condition that isn't scope=next
472
+ // with a branch) and an explicit `latest_run: null` both collapse to null —
473
+ // there is simply nothing to report either way.
474
+ armLatestRun: body.arm_context && typeof body.arm_context === "object"
475
+ ? (body.arm_context.latest_run ?? null)
476
+ : null,
457
477
  status: typeof body.status === "string" ? body.status : "active",
458
478
  // A canonicalized alias wait was deliberately abandoned and already has a
459
479
  // sequenced wait_superseded signal. Preserve that terminal state and replay
@@ -1615,6 +1635,10 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1615
1635
  // BOT-1467: the session agent the relay attributed the wait to (when overridden).
1616
1636
  let registeredSessionAgentId = null;
1617
1637
  let registeredSessionId = null;
1638
+ // BOT-1837: the arm-time "latest run on <branch>" notice, printed once on
1639
+ // register and reused as the timeout receipt's error. Null when there is
1640
+ // nothing to report (no ci scope=next+branch condition, or no run yet).
1641
+ let ciArmNotice = null;
1618
1642
  let replayMatchedClaim = false;
1619
1643
  let replayExpiredRecovery = false;
1620
1644
  if (needsRelay) {
@@ -1719,6 +1743,13 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1719
1743
  for (const line of formatPrReviewSnapshotWarnings(reg.prReviewSnapshot)) {
1720
1744
  process.stderr.write(`bb-wait: ${line}\n`);
1721
1745
  }
1746
+ // BOT-1837: one line naming the branch's latest run at arm time. Report-only
1747
+ // (arming before dispatch is the correct flow), and carried into a timeout
1748
+ // receipt below so the receipt explains the silence too.
1749
+ ciArmNotice = formatCiLatestRunNotice(reg.armLatestRun, {
1750
+ branch: conditions.find((c) => c.type === "ci" && c.params?.branch)?.params?.branch ?? null,
1751
+ });
1752
+ if (ciArmNotice) process.stderr.write(`bb-wait: ${ciArmNotice}\n`);
1722
1753
  // BOT-1184: adopt the server's canonical host for each lock condition so the
1723
1754
  // local matcher builds the same subject_key the availability/claim-grant
1724
1755
  // signals carry (armed under an alias like 'jono-mac', the signal uses the
@@ -1893,8 +1924,21 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1893
1924
  // A rejected condition set is a configuration error, not a wait — fail closed
1894
1925
  // rather than arming an untracked wait that skips the server's initial
1895
1926
  // evaluation (BOT-1066 no_signal_source, BOT-1247 unblocked_cross_tenant, …).
1927
+ if (err.errorCode === "unknown_workflow") {
1928
+ // BOT-1837: print the workflow names this repo HAS reported, one
1929
+ // copy-pasteable `workflow=<name>` per line, so the retry is a paste.
1930
+ for (const line of formatUnknownWorkflowGuidance(err.message, err.candidates)) {
1931
+ process.stderr.write(`bb-wait: ${line}\n`);
1932
+ }
1933
+ }
1896
1934
  process.stderr.write(`bb-wait: invalid condition (${err.errorCode}) — ${err.message}\n`);
1897
- emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.message) });
1935
+ emitReceipt({
1936
+ schema_version: 1,
1937
+ outcome: "error",
1938
+ error: err.errorCode,
1939
+ detail: String(err.message),
1940
+ ...(Array.isArray(err.candidates) && err.candidates.length > 0 ? { candidates: err.candidates } : {}),
1941
+ });
1898
1942
  process.exit(EXIT.INVALID);
1899
1943
  }
1900
1944
  if (err && err.initialEval) {
@@ -1959,6 +2003,9 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1959
2003
  feedLagProbe,
1960
2004
  receiptMaxBytes: opts.receiptMaxBytes,
1961
2005
  startedAt: checkpoint.created_at,
2006
+ // BOT-1837: a timeout receipt carries the arm-time CI notice, so a caller
2007
+ // reading only the receipt learns the run it meant had already finished.
2008
+ timeoutError: ciArmNotice,
1962
2009
  // BOT-1259: fail-closed tenant scope for the central guard, plus a debug sink
1963
2010
  // (opt-in via BB_WAIT_DEBUG) that surfaces each fail-closed drop without turning
1964
2011
  // it into an error.