@botbuddy/cli 1.44.0 → 1.45.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 +64 -9
- package/src/wait.mjs +31 -3
package/package.json
CHANGED
package/src/wait-core.mjs
CHANGED
|
@@ -190,7 +190,8 @@ const VALIDATORS = {
|
|
|
190
190
|
const id = hex ? `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` : raw;
|
|
191
191
|
return { id };
|
|
192
192
|
},
|
|
193
|
-
// BOT-1064 — linear: wake on a logical state change of a named
|
|
193
|
+
// BOT-1064 / BOT-2112 — linear: wake on a logical state change of a named
|
|
194
|
+
// issue in any Linear team bound to the authenticated tenant.
|
|
194
195
|
// The spine subject_key is the issue identifier as stored on the tickets
|
|
195
196
|
// projection (upper-cased at write), so normalize to upper-case here too.
|
|
196
197
|
linear(p) {
|
|
@@ -1276,6 +1277,10 @@ export async function runWaitLoop({
|
|
|
1276
1277
|
// This prevents a terminal receipt from either skipping lower replay matches or
|
|
1277
1278
|
// replaying its own match on a chained wait.
|
|
1278
1279
|
const pendingReplayMatches = [];
|
|
1280
|
+
// Capacity frames also carry state: a lower-sequence fresh beacon can clear a
|
|
1281
|
+
// later stale confirmation. Defer their state transitions until replay_complete
|
|
1282
|
+
// so they are folded in spine sequence order, just like terminal matches.
|
|
1283
|
+
const pendingReplayBeaconFrames = [];
|
|
1279
1284
|
let pendingReplaySupersession = null;
|
|
1280
1285
|
const degraded = [];
|
|
1281
1286
|
let reconnects = 0;
|
|
@@ -1662,6 +1667,7 @@ export async function runWaitLoop({
|
|
|
1662
1667
|
// marker. Restart legacy semantics from the last confirmed cursor: do
|
|
1663
1668
|
// not retain buffered/seen rows that would discard the replayed match.
|
|
1664
1669
|
pendingReplayMatches.length = 0;
|
|
1670
|
+
pendingReplayBeaconFrames.length = 0;
|
|
1665
1671
|
pendingReplaySupersession = null;
|
|
1666
1672
|
seen.clear();
|
|
1667
1673
|
cursor = replayFrontier;
|
|
@@ -1698,18 +1704,60 @@ export async function runWaitLoop({
|
|
|
1698
1704
|
if (frame.event === "replay_complete") {
|
|
1699
1705
|
replayPending = false;
|
|
1700
1706
|
replayFrontier = cursor;
|
|
1707
|
+
let pendingBeaconExit = null;
|
|
1708
|
+
// A replay terminal is a point-in-time receipt. Beacon state after an
|
|
1709
|
+
// earlier match/supersession cannot affect that receipt.
|
|
1710
|
+
let terminalSeq = [
|
|
1711
|
+
...pendingReplayMatches.map(({ seqKey }) => seqKey),
|
|
1712
|
+
pendingReplaySupersession?.seqKey,
|
|
1713
|
+
].filter(Boolean).sort((a, b) => BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0)[0] ?? null;
|
|
1714
|
+
for (const pendingBeaconFrame of pendingReplayBeaconFrames
|
|
1715
|
+
.sort((a, b) => BigInt(a.seqKey) < BigInt(b.seqKey) ? -1 : BigInt(a.seqKey) > BigInt(b.seqKey) ? 1 : 0)) {
|
|
1716
|
+
if (terminalSeq != null && BigInt(pendingBeaconFrame.seqKey) >= BigInt(terminalSeq)) break;
|
|
1717
|
+
// Recovery replays may contain rows created after the original deadline.
|
|
1718
|
+
// They are only transport catch-up, never evidence for the terminal
|
|
1719
|
+
// receipt, so they must not alter stale confirmation state either.
|
|
1720
|
+
if (!pendingBeaconFrame.beforeReplayDeadline) continue;
|
|
1721
|
+
const disposition = applyBeaconStale(pendingBeaconFrame.signal);
|
|
1722
|
+
if (disposition === "exit") {
|
|
1723
|
+
pendingBeaconExit = pendingBeaconFrame;
|
|
1724
|
+
terminalSeq = pendingBeaconFrame.seqKey;
|
|
1725
|
+
break;
|
|
1726
|
+
}
|
|
1727
|
+
if (disposition !== "fallthrough" || !pendingBeaconFrame.beforeReplayDeadline) continue;
|
|
1728
|
+
if (signalTenantBlocked(pendingBeaconFrame.signal, sessionTenant)) continue;
|
|
1729
|
+
const matched = matchFrame(pendingBeaconFrame.frame, conditions, waitSessionId, sessionTenant);
|
|
1730
|
+
if (!matched) continue;
|
|
1731
|
+
const entry = {
|
|
1732
|
+
condition_id: matched.id,
|
|
1733
|
+
signal_type: pendingBeaconFrame.signal.signal_type,
|
|
1734
|
+
seq: pendingBeaconFrame.signal.seq,
|
|
1735
|
+
subject_key: pendingBeaconFrame.signal.subject_key,
|
|
1736
|
+
payload: pendingBeaconFrame.signal.payload ?? null,
|
|
1737
|
+
provenance: "spine",
|
|
1738
|
+
};
|
|
1739
|
+
const grant = capacityGrantForReceipt(pendingBeaconFrame.signal, waitSessionId);
|
|
1740
|
+
if (grant) entry.grant = grant;
|
|
1741
|
+
pendingReplayMatches.push({ ...pendingBeaconFrame, entry });
|
|
1742
|
+
terminalSeq = pendingBeaconFrame.seqKey;
|
|
1743
|
+
}
|
|
1744
|
+
pendingReplayBeaconFrames.length = 0;
|
|
1701
1745
|
const pendingMatch = pendingReplayMatches
|
|
1702
1746
|
.sort((a, b) => BigInt(a.seqKey) < BigInt(b.seqKey) ? -1 : BigInt(a.seqKey) > BigInt(b.seqKey) ? 1 : 0)[0];
|
|
1703
|
-
const replayCandidate =
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1747
|
+
const replayCandidate = [
|
|
1748
|
+
pendingMatch && { kind: "match", ...pendingMatch },
|
|
1749
|
+
pendingReplaySupersession && { kind: "superseded", ...pendingReplaySupersession },
|
|
1750
|
+
pendingBeaconExit && { kind: "beacon_stale", ...pendingBeaconExit },
|
|
1751
|
+
].filter(Boolean).sort((a, b) => BigInt(a.seqKey) < BigInt(b.seqKey) ? -1 : BigInt(a.seqKey) > BigInt(b.seqKey) ? 1 : 0)[0] ?? null;
|
|
1707
1752
|
if (replayCandidate) {
|
|
1708
1753
|
const candidateAtMs = Date.parse(replayCandidate.signal.created_at);
|
|
1709
1754
|
if (restoredAlarm != null && Number.isFinite(candidateAtMs) && restoredAlarm.atMs < candidateAtMs) {
|
|
1710
1755
|
return fireAlarm(restoredAlarm);
|
|
1711
1756
|
}
|
|
1712
1757
|
if (replayCandidate.kind === "superseded") return finalizeSuperseded(replayCandidate.signal);
|
|
1758
|
+
if (replayCandidate.kind === "beacon_stale") {
|
|
1759
|
+
return finalize("error", { exitCode: EXIT.BACKEND, error: "capacity_source_stale" });
|
|
1760
|
+
}
|
|
1713
1761
|
const terminal = await finalizeMatch(replayCandidate.entry, replayCandidate.signal);
|
|
1714
1762
|
if (onFrame) {
|
|
1715
1763
|
await onFrame(replayCandidate.signal, {
|
|
@@ -1776,10 +1824,17 @@ export async function runWaitLoop({
|
|
|
1776
1824
|
return finalizeSuperseded(signal);
|
|
1777
1825
|
}
|
|
1778
1826
|
|
|
1779
|
-
// BOT-1148:
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
|
|
1827
|
+
// BOT-1148/BOT-2100: every capacity frame for a pinned host participates in
|
|
1828
|
+
// beacon-staleness handling. `source_stale:true` arms the fast-fail, while a
|
|
1829
|
+
// later normal capacity frame proves the host is fresh again even when the
|
|
1830
|
+
// server omits the redundant `source_stale:false` field. Without that latter
|
|
1831
|
+
// path, an old stale transition could win its confirm timer despite fresh
|
|
1832
|
+
// beacons continuing to publish capacity frames.
|
|
1833
|
+
if (signal.signal_type === "container_capacity" && signal.payload) {
|
|
1834
|
+
if (replayPending) {
|
|
1835
|
+
pendingReplayBeaconFrames.push({ frame, signal, seqKey, beforeReplayDeadline });
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1783
1838
|
const disposition = applyBeaconStale(signal);
|
|
1784
1839
|
if (disposition === "exit") {
|
|
1785
1840
|
return finalize("error", { exitCode: EXIT.BACKEND, error: "capacity_source_stale" });
|
package/src/wait.mjs
CHANGED
|
@@ -108,8 +108,9 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
108
108
|
guidance:request=<post_id> a command-post guidance request being answered
|
|
109
109
|
(scoped to the asking agent; fetch the body with
|
|
110
110
|
get_guidance_response)
|
|
111
|
-
linear:issue=<KEY>
|
|
112
|
-
assignee change (e.g.
|
|
111
|
+
linear:issue=<KEY> an issue in any Linear team bound to your tenant;
|
|
112
|
+
wakes on a state change, new comment, or assignee change (e.g.
|
|
113
|
+
issue=BOT-123; tenant-scoped).
|
|
113
114
|
Flags linear_feed_lagging if the ingest feed has stalled.
|
|
114
115
|
unblocked:<KEY> a ticket's LAST open blocker clearing (open-blocker
|
|
115
116
|
count >0 → 0): a blocker moving to Done/Canceled/Duplicate
|
|
@@ -378,6 +379,10 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
378
379
|
// wake-capable run for. The wait could only ever expire, and no amount of
|
|
379
380
|
// waiting repairs a typo — a hard stop, with the server's candidates.
|
|
380
381
|
"unknown_workflow",
|
|
382
|
+
// BOT-2112: these are arm-time authorization/target errors. Retrying as
|
|
383
|
+
// an untracked live-only wait would silently park on a signal it cannot
|
|
384
|
+
// receive, so preserve the relay's typed error and exit 4.
|
|
385
|
+
"linear_team_not_bound",
|
|
381
386
|
// BOT-1612: a `chat:channel=<id>` wait whose bound agent is not one of the
|
|
382
387
|
// channel's participants could only ever park to timeout (the emitter never
|
|
383
388
|
// scopes a message to a non-participant). The relay refuses it at arm time;
|
|
@@ -395,6 +400,29 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
395
400
|
throw err;
|
|
396
401
|
}
|
|
397
402
|
}
|
|
403
|
+
if (res.status === 404) {
|
|
404
|
+
const body = await res.json().catch(() => ({}));
|
|
405
|
+
if (body.error === "linear_issue_not_found") {
|
|
406
|
+
const err = new Error(body.detail || body.error);
|
|
407
|
+
err.invalidCondition = true;
|
|
408
|
+
err.errorCode = body.error;
|
|
409
|
+
if (Array.isArray(body.candidates)) err.candidates = body.candidates;
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
// BOT-1523 retains its existing not-found contract. Keep every 404's body
|
|
413
|
+
// handling in one branch: parsing it above and falling through would consume
|
|
414
|
+
// the body before this typed unblocked refusal could see it.
|
|
415
|
+
if (body.error === "unblocked_ticket_not_found") {
|
|
416
|
+
const err = new Error(body.detail || body.error);
|
|
417
|
+
err.hydrationRegister = true;
|
|
418
|
+
err.errorCode = body.error;
|
|
419
|
+
err.ticket = body.ticket ?? null;
|
|
420
|
+
err.retryable = body.retryable ?? null;
|
|
421
|
+
err.hydration = body.hydration ?? null;
|
|
422
|
+
err.notFound = true;
|
|
423
|
+
throw err;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
398
426
|
if (res.status === 403) {
|
|
399
427
|
const body = await res.json().catch(() => ({}));
|
|
400
428
|
if (new Set(["wait_tenant_unresolved", "wait_cross_tenant", "wait_tenant_mismatch", "wait_resume_identity_mismatch"]).has(body.error)) {
|
|
@@ -427,7 +455,7 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
427
455
|
err.minimumProtocol = body.minimum_wait_protocol ?? null;
|
|
428
456
|
throw err;
|
|
429
457
|
}
|
|
430
|
-
if (res.status === 409
|
|
458
|
+
if (res.status === 409) {
|
|
431
459
|
// BOT-1523: `unblocked:<KEY>` hydration at registration could not arm the wait
|
|
432
460
|
// truthfully. These are NOT the untracked-live-only fallback: arming a live-only
|
|
433
461
|
// unblocked wait would skip the server's initial evaluation and could resume a
|