@botbuddy/cli 1.19.1 → 1.20.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.19.1",
3
+ "version": "1.20.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/wait-core.mjs CHANGED
@@ -576,13 +576,27 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
576
576
  clone.detail_truncated = true;
577
577
  }
578
578
  if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "error") {
579
- return {
579
+ const base = {
580
580
  schema_version: clone.schema_version,
581
581
  outcome: "error",
582
582
  error: typeof clone.error === "string" ? clone.error.slice(0, 48) : "receipt_truncated",
583
583
  truncated: true,
584
584
  ...(clone.client ? { client: clone.client } : {}),
585
585
  };
586
+ // BOT-1590: the recovery hint is the whole point of an error receipt, so keep
587
+ // it (trimmed to whatever still fits the cap) rather than dropping it — a
588
+ // caller at the documented minimum cap must still get its one actionable line.
589
+ if (typeof clone.recovery === "string" && clone.recovery.length > 0) {
590
+ const room = maxBytes - Buffer.byteLength(JSON.stringify({ ...base, recovery: "" }));
591
+ if (room > 0) {
592
+ let recovery = clone.recovery;
593
+ while (recovery.length > 0 && Buffer.byteLength(recovery, "utf8") > room) {
594
+ recovery = recovery.slice(0, -1);
595
+ }
596
+ if (recovery.length > 0) base.recovery = recovery;
597
+ }
598
+ }
599
+ return base;
586
600
  }
587
601
  return clone;
588
602
  }
@@ -751,6 +765,28 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
751
765
  // cannot actually be served. The wait loop handles it separately (fold the
752
766
  // capacity_source_stale marker / fast exit); it must not read as capacity here.
753
767
  if (signal.payload?.source_stale === true) return false;
768
+ // BOT-1577: a RANKED grant restricts the host broadcast to named winners.
769
+ // When the payload carries granted_wait_session_ids, match ONLY if THIS
770
+ // wait's session id is in it — otherwise keep parking (the server already
771
+ // vetted the slot count and this wait's minSlots when it built the list).
772
+ // A payload WITHOUT the list is a legacy broadcast (older server, or the
773
+ // no-contention case) → fall back to the free_slots >= N threshold, so this
774
+ // stays backward compatible.
775
+ const granted = signal.payload?.granted_wait_session_ids;
776
+ if (Array.isArray(granted)) {
777
+ if (waitSessionId == null || !granted.includes(waitSessionId)) return false;
778
+ // BOT-1577: a ranked grant is only valid until its 120s hold expires. This
779
+ // signal is durable — on a reconnect from an old cursor (or a slow wake) after
780
+ // the hold lapsed and the slot was re-granted to another waiter, event-stream
781
+ // replays the original grant. Reject it once expired — and reserve
782
+ // GRANT_CONSUME_MARGIN_MS (finalize feed-lag reprobe + the up-to-5s finalizeWait
783
+ // that runs before `botbuddy wait` exits), since a grant that lapses in that window
784
+ // is re-granted before the caller can start — so the original client does not start
785
+ // too (double-provision). A payload without grant_expires_at (older server) is accepted;
786
+ // a present-but-malformed expiry fails closed (parks) rather than matching forever.
787
+ if (!grantUnexpired(signal.payload?.grant_expires_at, GRANT_CONSUME_MARGIN_MS)) return false;
788
+ return true;
789
+ }
754
790
  const free = Number(signal.payload?.free_slots);
755
791
  return Number.isFinite(free) && free >= params.minSlots;
756
792
  }
@@ -887,6 +923,55 @@ export function matchFrame(frame, conditions, waitSessionId, sessionTenant) {
887
923
  return null;
888
924
  }
889
925
 
926
+ /**
927
+ * BOT-1577: derive this wait's grant detail from a ranked container_capacity
928
+ * signal, for the receipt (`matched[].grant = {position, tier, score}`). Returns
929
+ * null when the signal is not a ranked capacity grant addressed to this wait
930
+ * (legacy broadcast, another host, or a payload without the grant list).
931
+ * `position` is 1-based in the server's grant order; tier/score come from the
932
+ * index-aligned granted_grants entry when present.
933
+ */
934
+ // BOT-1577: a ranked capacity grant is valid only until its 120s hold expires
935
+ // (payload.grant_expires_at, an ISO timestamp). `marginMs` reserves remaining-time before
936
+ // the expiry: a grant accepted with less than the margin left could lapse between the
937
+ // match and the terminal receipt — finalize() can spend up to FEED_PROBE_BUDGET_MS in the
938
+ // feed-lag reprobe for a mixed capacity/linear wait — after which the periodic evaluator
939
+ // re-grants the slot, so accepting it would double-provision (BOT-1577 P1). A payload
940
+ // without the field (older server) is treated as valid for backward compatibility; an
941
+ // unparseable value fails open (valid) rather than dropping a live grant.
942
+ // Minimum hold time a grant must have left when accepted, so it survives the whole path
943
+ // from acceptance to the caller actually PROVISIONING the slot (BOT-1577 P1):
944
+ // • finalize()'s feed-lag reprobe .......... FEED_PROBE_BUDGET_MS (2s)
945
+ // • finalizeWait() runWait() awaits ......... 5s (after the receipt, before exit)
946
+ // • the caller's docker preflight ........... 30s (a single Docker command budget)
947
+ // Below this, a delayed/replayed grant can lapse mid-preflight and be re-granted before
948
+ // the caller starts, double-provisioning the slot. The 120s hold's remainder (~120−37s)
949
+ // then covers the actual `supabase start`; a caller that provisions beyond that window is
950
+ // the consumer's responsibility to re-validate at start (the SG start gate, BOT-1576).
951
+ const GRANT_CONSUME_MARGIN_MS = 2000 + 5000 + 30000;
952
+ function grantUnexpired(grantExpiresAt, marginMs = 0) {
953
+ if (grantExpiresAt == null) return true; // absent (older server) ⇒ accept (legacy)
954
+ const t = Date.parse(grantExpiresAt);
955
+ if (!Number.isFinite(t)) return false; // present but MALFORMED ⇒ fail closed (park)
956
+ return Date.now() + marginMs < t;
957
+ }
958
+
959
+ export function capacityGrantForReceipt(signal, waitSessionId) {
960
+ if (!signal || signal.signal_type !== "container_capacity" || waitSessionId == null) return null;
961
+ const granted = signal.payload?.granted_wait_session_ids;
962
+ if (!Array.isArray(granted)) return null;
963
+ const idx = granted.indexOf(waitSessionId);
964
+ if (idx < 0) return null;
965
+ if (!grantUnexpired(signal.payload?.grant_expires_at)) return null; // BOT-1577: stale grant ⇒ no receipt
966
+ const grant = { position: idx + 1 };
967
+ const meta = Array.isArray(signal.payload?.granted_grants) ? signal.payload.granted_grants[idx] : null;
968
+ if (meta && typeof meta === "object") {
969
+ if (meta.tier != null) grant.tier = meta.tier;
970
+ if (meta.score != null) grant.score = meta.score;
971
+ }
972
+ return grant;
973
+ }
974
+
890
975
  // ---------------------------------------------------------------------------
891
976
  // The wait loop.
892
977
  // ---------------------------------------------------------------------------
@@ -1411,16 +1496,20 @@ export async function runWaitLoop({
1411
1496
 
1412
1497
  const matched = matchFrame(frame, conditions, waitSessionId, sessionTenant);
1413
1498
  if (matched) {
1499
+ const entry = {
1500
+ condition_id: matched.id,
1501
+ signal_type: signal.signal_type,
1502
+ seq: signal.seq,
1503
+ subject_key: signal.subject_key,
1504
+ payload: signal.payload ?? null,
1505
+ provenance: "spine",
1506
+ };
1507
+ // BOT-1577: a ranked capacity grant records this wait's position/tier/score.
1508
+ const grant = capacityGrantForReceipt(signal, waitSessionId);
1509
+ if (grant) entry.grant = grant;
1414
1510
  return finalize("matched", {
1415
1511
  exitCode: EXIT.MATCHED,
1416
- matched: [{
1417
- condition_id: matched.id,
1418
- signal_type: signal.signal_type,
1419
- seq: signal.seq,
1420
- subject_key: signal.subject_key,
1421
- payload: signal.payload ?? null,
1422
- provenance: "spine",
1423
- }],
1512
+ matched: [entry],
1424
1513
  });
1425
1514
  }
1426
1515
  }
package/src/wait.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  // Run botbuddy wait --help for the condition grammar.
15
15
 
16
16
  import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings } from "./wait-core.mjs";
17
- import { resolveAgentProfile, withPrincipalReceipt } from "./wait-profile.mjs";
17
+ import { resolveAgentProfile, withPrincipalReceipt, PROFILE_FILE } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { latestPublicCliCommand } from "./public-invocation.mjs";
@@ -90,6 +90,11 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
90
90
  Host-shared; a silently-dead host is detected server-side
91
91
  (capacity_source_stale) via a host beacon — the client
92
92
  grace (default 900s) is a backstop, no longer the only guard.
93
+ Grants are RANKED (BOT-1577): when several waiters
94
+ contend, a freed slot is handed to the highest-scored
95
+ one and its receipt carries grant:{position,tier,score};
96
+ the others keep parking. Older servers broadcast without
97
+ the ranking (first-come). Needs cli >= 1.20.0.
93
98
  ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
94
99
  | scope=next[,branch=<name>][,workflow=<name>]}
95
100
  a PR's CI reaching a terminal conclusion (owner-scoped);
@@ -643,10 +648,52 @@ function profileErrorReceipt(profile, error) {
643
648
  schema_version: 1,
644
649
  outcome: "error",
645
650
  error,
646
- recovery: profileRecovery(profile),
651
+ recovery: profileCredentialRecovery(profile),
647
652
  }, profile, { sessionTenant: profile.tenant, agentId: null });
648
653
  }
649
654
 
655
+ // BOT-1590: canonical one-line recoveries for every setup/auth/parameter error.
656
+ // Each names the exact command to run and the source of any missing/invalid
657
+ // value, so an agent (or a human) can self-serve without opening the docs. The
658
+ // SAME string is written to stderr and the receipt `recovery` field that /waits
659
+ // renders. Keep each to one line and never embed a secret value — names, slots,
660
+ // and env-var names only.
661
+ const RECOVERY = Object.freeze({
662
+ // $BOTBUDDY_SESSION_TOKEN (bb_sess_+64hex) is minted by register_agent; the
663
+ // harness exports it at session start.
664
+ sessionToken: "register_agent → export BOTBUDDY_SESSION_TOKEN=<session_token> (the harness exports it at session start)",
665
+ // $BOTBUDDY_SESSION_ID is the work-graph session id returned by register_agent.
666
+ sessionId: "register_agent → export BOTBUDDY_SESSION_ID=<session_id> (the work-graph session id from register_agent)",
667
+ // A --token / session-token contradiction: the session token is the whole
668
+ // credential, so drop --token (never advise also setting a session id here).
669
+ sessionTokenConflict: "unset --token — $BOTBUDDY_SESSION_TOKEN is the whole credential (protocol 3)",
670
+ // Grammar/condition errors point at the help and the canonical doc.
671
+ conditions: "check the condition grammar: botbuddy wait --help / docs/agent-wait.md",
672
+ });
673
+
674
+ const KNOWN_PROFILES = "botbuddy-dev, supplyguard-dev";
675
+
676
+ // profile_required / unknown_profile: name the file, a minimal example, and the
677
+ // known profile names so the fix is a single edit (AC-3).
678
+ function profileResolutionRecovery(errorCode) {
679
+ return errorCode === "unknown_profile"
680
+ ? `set a known profile in ${PROFILE_FILE} or pass --profile <name> — known profiles: ${KNOWN_PROFILES}`
681
+ : `add ${PROFILE_FILE} ({"schema_version":1,"profile":"botbuddy-dev"}) or pass --profile <name> — known profiles: ${KNOWN_PROFILES}`;
682
+ }
683
+
684
+ // A profile-credential error (missing/revoked/wrong-tenant): name the exact
685
+ // setup command and the credential's real source, then rule out human PATs,
686
+ // which are never valid for waits (AC-4). `botbuddy profile setup` stores the
687
+ // key in the macOS Keychain under the profile env-var's name (it REQUIRES the
688
+ // Keychain — ensureProfileCredentialBackend); on a non-Keychain host the
689
+ // supported path is exporting that same profile env var directly, which
690
+ // resolveAgentProfile reads. (The 0600 config.json store is the owner login
691
+ // token's, not a profile credential's — a wait cannot consume it.)
692
+ const HUMAN_PAT_NOTE = "human PATs (BOTBUDDY_AGENT_KEY / BOTBUDDY_AGENT_API_KEY) are not valid for waits";
693
+ function profileCredentialRecovery(profile) {
694
+ return `${profileRecovery(profile)} (stores $${profile.tokenEnv} in the macOS Keychain; on a non-Keychain host export $${profile.tokenEnv} directly); ${HUMAN_PAT_NOTE}`;
695
+ }
696
+
650
697
  export async function runWait(argv) {
651
698
  const opts = parseArgv(argv);
652
699
  const emitReceipt = (receipt, options = {}) => emit(receipt, {
@@ -671,7 +718,8 @@ export async function runWait(argv) {
671
718
  const { conditions, errors } = parseConditions(opts.conditions);
672
719
  if (errors.length > 0) {
673
720
  for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
674
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors });
721
+ process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
722
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
675
723
  process.exit(EXIT.INVALID);
676
724
  }
677
725
 
@@ -696,13 +744,13 @@ export async function runWait(argv) {
696
744
  // is a contradiction and is rejected.
697
745
  if (needsRelay && opts.sessionToken) {
698
746
  if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
699
- process.stderr.write("botbuddy wait: $BOTBUDDY_SESSION_TOKEN must match bb_sess_<64 hex>\n");
700
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token" });
747
+ process.stderr.write(`botbuddy wait: $BOTBUDDY_SESSION_TOKEN must match bb_sess_<64 hex>; ${RECOVERY.sessionToken}\n`);
748
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
701
749
  process.exit(EXIT.INVALID);
702
750
  }
703
751
  if (opts.token && opts.token !== opts.sessionToken) {
704
- process.stderr.write("botbuddy wait: --token conflicts with $BOTBUDDY_SESSION_TOKEN; pass only one\n");
705
- emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict" });
752
+ process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_SESSION_TOKEN; ${RECOVERY.sessionTokenConflict}\n`);
753
+ emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
706
754
  process.exit(EXIT.INVALID);
707
755
  }
708
756
  opts.useSessionToken = true;
@@ -714,19 +762,19 @@ export async function runWait(argv) {
714
762
  // timer-only wait needs no relay and no session id (handled by !needsRelay).
715
763
  if (!opts.sessionId) {
716
764
  process.stderr.write(
717
- "botbuddy wait: --session-id is required (or set $BOTBUDDY_SESSION_ID) — the work-graph session id returned by register_agent\n",
765
+ `botbuddy wait: --session-id is required (or set $BOTBUDDY_SESSION_ID) — the work-graph session id returned by register_agent; ${RECOVERY.sessionId}\n`,
718
766
  );
719
767
  emitReceipt({
720
768
  schema_version: 1,
721
769
  outcome: "error",
722
770
  error: "session_id_required",
723
- recovery: "register_agent → export BOTBUDDY_SESSION_ID=<session_id>",
771
+ recovery: RECOVERY.sessionId,
724
772
  });
725
773
  process.exit(EXIT.INVALID);
726
774
  }
727
775
  if (!SESSION_UUID.test(opts.sessionId)) {
728
- process.stderr.write(`botbuddy wait: --session-id must be a uuid (got '${opts.sessionId}')\n`);
729
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_agent", detail: "session_id must be a uuid" });
776
+ process.stderr.write(`botbuddy wait: --session-id must be a uuid (got '${opts.sessionId}'); ${RECOVERY.sessionId}\n`);
777
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_agent", detail: "session_id must be a uuid", recovery: RECOVERY.sessionId });
730
778
  process.exit(EXIT.INVALID);
731
779
  }
732
780
  try {
@@ -735,14 +783,16 @@ export async function runWait(argv) {
735
783
  explicitToken: opts.token,
736
784
  });
737
785
  } catch (err) {
738
- process.stderr.write(`bb-wait: ${err.message}\n`);
739
- emitReceipt({ schema_version: 1, outcome: "error", error: err.code || "invalid_profile" });
786
+ const code = err.code || "invalid_profile";
787
+ const recovery = profileResolutionRecovery(code);
788
+ process.stderr.write(`bb-wait: ${err.message}; ${recovery}\n`);
789
+ emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
740
790
  process.exit(EXIT.INVALID);
741
791
  }
742
792
  opts.token = opts.agentProfile.token;
743
793
  if (!opts.token) {
744
794
  process.stderr.write(
745
- `botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run '${profileRecovery(opts.agentProfile)}'\n`,
795
+ `botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run ${profileCredentialRecovery(opts.agentProfile)}\n`,
746
796
  );
747
797
  emitReceipt(profileErrorReceipt(opts.agentProfile, "profile_required"));
748
798
  process.exit(EXIT.AUTH);
@@ -897,7 +947,7 @@ export async function runWait(argv) {
897
947
  process.exit(EXIT.AUTH);
898
948
  }
899
949
  const error = typedProfileError(err.errorCode);
900
- process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run '${profileRecovery(opts.agentProfile)}'\n`);
950
+ process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run ${profileCredentialRecovery(opts.agentProfile)}\n`);
901
951
  emitReceipt(profileErrorReceipt(opts.agentProfile, error));
902
952
  process.exit(EXIT.AUTH);
903
953
  }