@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 +1 -1
- package/src/wait-core.mjs +83 -8
- package/src/wait.mjs +5 -0
package/package.json
CHANGED
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);
|