@botbuddy/cli 1.19.2 → 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.2",
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
@@ -765,6 +765,28 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
765
765
  // cannot actually be served. The wait loop handles it separately (fold the
766
766
  // capacity_source_stale marker / fast exit); it must not read as capacity here.
767
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
+ }
768
790
  const free = Number(signal.payload?.free_slots);
769
791
  return Number.isFinite(free) && free >= params.minSlots;
770
792
  }
@@ -901,6 +923,55 @@ export function matchFrame(frame, conditions, waitSessionId, sessionTenant) {
901
923
  return null;
902
924
  }
903
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
+
904
975
  // ---------------------------------------------------------------------------
905
976
  // The wait loop.
906
977
  // ---------------------------------------------------------------------------
@@ -1425,16 +1496,20 @@ export async function runWaitLoop({
1425
1496
 
1426
1497
  const matched = matchFrame(frame, conditions, waitSessionId, sessionTenant);
1427
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;
1428
1510
  return finalize("matched", {
1429
1511
  exitCode: EXIT.MATCHED,
1430
- matched: [{
1431
- condition_id: matched.id,
1432
- signal_type: signal.signal_type,
1433
- seq: signal.seq,
1434
- subject_key: signal.subject_key,
1435
- payload: signal.payload ?? null,
1436
- provenance: "spine",
1437
- }],
1512
+ matched: [entry],
1438
1513
  });
1439
1514
  }
1440
1515
  }
package/src/wait.mjs CHANGED
@@ -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);