@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.44.0",
3
+ "version": "1.45.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
@@ -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 BOT-* issue.
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 = pendingMatch &&
1704
- (!pendingReplaySupersession || BigInt(pendingMatch.seqKey) < BigInt(pendingReplaySupersession.seqKey))
1705
- ? { kind: "match", ...pendingMatch }
1706
- : pendingReplaySupersession ? { kind: "superseded", ...pendingReplaySupersession } : null;
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: a beacon-driven capacity staleness transition is handled before the
1780
- // normal match — a stale signal is never itself a grant, and a fast exit / marker
1781
- // fold happens here (host-pinned) rather than through matchFrame.
1782
- if (signal.signal_type === "container_capacity" && signal.payload && "source_stale" in signal.payload) {
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> a BOT-* issue changing state, a new comment, or an
112
- assignee change (e.g. issue=BOT-123; tenant-scoped).
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 || res.status === 404) {
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