@botbuddy/cli 1.38.0 → 1.39.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.38.0",
3
+ "version": "1.39.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -998,7 +998,12 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
998
998
  try { sig = JSON.parse(f.data); } catch { continue; }
999
999
  if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
1000
1000
  const st = sig.payload?.state ?? null;
1001
- if (isFailed(st)) { decided = { reason: "failed", result: { failed: true, state: st }, status: "error" }; break frameLoop; }
1001
+ if (isFailed(st)) {
1002
+ // BOT-2013: carry the server's reap_reason so the receipt says WHY the lease is gone.
1003
+ const reapReason = typeof sig.payload?.reap_reason === "string" ? sig.payload.reap_reason : null;
1004
+ decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
1005
+ break frameLoop;
1006
+ }
1002
1007
  if (isDone(st)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
1003
1008
  }
1004
1009
  }
@@ -1650,7 +1655,15 @@ export async function cmdUp(opts, {
1650
1655
  return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, queue_position: d.queue_position, holders: d.holders }), opts, EXIT.QUEUED);
1651
1656
  }
1652
1657
  // Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
1653
- const parked = await waitFn(leaseId, nonQueued, () => false, { timeoutSec: opts.timeout, auth });
1658
+ // BOT-2013: a lease reaped while queued (orphaned_agent, …) is lost, not "left the queue".
1659
+ const parked = await waitFn(leaseId, nonQueued, isReaped, { timeoutSec: opts.timeout, auth });
1660
+ if (parked.failed) {
1661
+ return emitResult(buildReceipt({
1662
+ command: "up", outcome: "error", lease_id: leaseId, state: parked.state, slot,
1663
+ reap_reason: parked.reap_reason ?? null,
1664
+ error: `lease was ${parked.state} while queued (reap_reason=${parked.reap_reason ?? "unknown"}) — request a new lease`,
1665
+ }), opts, EXIT.LEASE_FAILED);
1666
+ }
1654
1667
  if (parked.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity` }), opts, EXIT.TIMEOUT);
1655
1668
  if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
1656
1669
  if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
@@ -2435,11 +2448,13 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
2435
2448
  }
2436
2449
  if (state === "queued") {
2437
2450
  activeWaitAbort = new AbortController();
2438
- const parked = await wait(leaseId, (s) => s !== "queued" && s != null, (s) => s === "reaped", { timeoutSec: opts.timeout, auth: rpc.auth, signal: activeWaitAbort.signal });
2451
+ // BOT-2013: every reap state (reaping / reaped / reap_failed) means the queued lease is lost.
2452
+ const parked = await wait(leaseId, (s) => s !== "queued" && s != null, isReaped, { timeoutSec: opts.timeout, auth: rpc.auth, signal: activeWaitAbort.signal });
2439
2453
  activeWaitAbort = null;
2440
2454
  if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
2441
2455
  if (parked?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup }; }
2442
- if (parked?.failed || parked?.error || parked?.auth) { const cleanup = await requestCleanup(); return { exitCode: parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup }; }
2456
+ if (parked?.failed) { const cleanup = await requestCleanup(); return { exitCode: EXIT.LEASE_FAILED, outcome: "error", leaseId, error: `lease was ${parked.state} while queued (reap_reason=${parked.reap_reason ?? "unknown"})`, cleanup }; }
2457
+ if (parked?.error || parked?.auth) { const cleanup = await requestCleanup(); return { exitCode: parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup }; }
2443
2458
  state = parked.state;
2444
2459
  }
2445
2460
  if (state !== "active") {
package/src/wait-core.mjs CHANGED
@@ -24,8 +24,32 @@ export const EXIT = Object.freeze({
24
24
  // caller can tell "re-register under the new identity" apart from "your condition
25
25
  // fired" — the harness re-invokes bb-wait on exit, which re-registers canonically.
26
26
  SUPERSEDED: 8,
27
+ // BOT-2013: a `lease:id=` wait whose lease left the grantable path (reaping / reaped /
28
+ // reap_failed) before it was granted — re-request the lease. BOT-1754 reserves the name
29
+ // LEASE_LOST and forbids reusing 2-8; 9 is the first free code.
30
+ LEASE_LOST: 9,
27
31
  });
28
32
 
33
+ // BOT-2013: the uuid spellings Postgres accepts for a lease id (uuid_in), mirrored by the SQL
34
+ // liveness check and the /waits UI: 32 hex digits with an optional hyphen after any group of
35
+ // four, optionally wrapped in ONE pair of braces.
36
+ const LEASE_UUID_SPELLING = /^(?:\{(?=.*\}$)|(?!.*\}$))((?:[0-9a-f]{4}-?){7}[0-9a-f]{4})\}?$/;
37
+
38
+ // BOT-2013: stack_lease frame states that mean the lease is gone, not granted.
39
+ export const LEASE_LOST_STATES = Object.freeze(new Set(["reaping", "reaped", "reap_failed"]));
40
+
41
+ /** The lease-lost detail for a matched lease frame, or null when the frame is a grant. */
42
+ export function leaseLostDetail(signal, condition) {
43
+ if (condition?.type !== "lease" || signal?.signal_type !== "stack_lease") return null;
44
+ const leaseState = signal.payload?.state;
45
+ if (!LEASE_LOST_STATES.has(leaseState)) return null;
46
+ return {
47
+ lease_id: signal.payload?.lease_id ?? condition.params?.id ?? null,
48
+ lease_state: leaseState,
49
+ reap_reason: typeof signal.payload?.reap_reason === "string" ? signal.payload.reap_reason : null,
50
+ };
51
+ }
52
+
29
53
  // BOT-1249: the internal CONTROL signal_type the reconciler emits when a wait's
30
54
  // resource identity is canonicalized away. runWaitLoop treats a wait_superseded frame
31
55
  // targeting its OWN wait_session as terminal (exit 8) before matchFrame sees it. It is
@@ -154,7 +178,17 @@ const VALIDATORS = {
154
178
  // lease's owning agent and keyed `lease:<lease_id>` (mirrors the run:<id> shape).
155
179
  lease(p) {
156
180
  if (!p.id) throw new Error("lease needs id=<lease_id>");
157
- return { id: String(p.id) };
181
+ // BOT-2013: the server keys frames on the canonical hyphenated lowercase uuid
182
+ // (`lease:<id>`). Any other spelling Postgres accepts (upper case, braces, no or
183
+ // extra hyphens) would arm a wait that never matches, so canonicalize it here.
184
+ const raw = String(p.id).trim().toLowerCase();
185
+ if (!raw) throw new Error("lease needs id=<lease_id>");
186
+ // Only an accepted spelling is repaired (Codex #1017 R9); anything else passes through
187
+ // unchanged and simply matches nothing.
188
+ const m = LEASE_UUID_SPELLING.exec(raw);
189
+ const hex = m ? m[1].replace(/-/g, "") : null;
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
+ return { id };
158
192
  },
159
193
  // BOT-1064 — linear: wake on a logical state change of a named BOT-* issue.
160
194
  // The spine subject_key is the issue identifier as stored on the tickets
@@ -594,6 +628,14 @@ export function buildReceipt({
594
628
  return r;
595
629
  }
596
630
 
631
+ /** BOT-2013: the three lease-lost fields, each bounded, or null when absent. */
632
+ function compactLeaseLostState(state) {
633
+ if (!state || typeof state !== "object") return null;
634
+ const short = (v) => (typeof v === "string" ? v.slice(0, 64) : null);
635
+ if (state.lease_id == null && state.reap_reason == null) return null;
636
+ return { lease_id: short(state.lease_id), lease_state: short(state.lease_state), reap_reason: short(state.reap_reason) };
637
+ }
638
+
597
639
  /**
598
640
  * Enforce the receipt byte cap. Oversized matched-payloads collapse to a
599
641
  * pointer (the `seq`, plus a `payload_truncated` marker); the caller re-fetches
@@ -608,9 +650,12 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
608
650
  m.payload_truncated = true;
609
651
  }
610
652
  }
611
- // If still over cap, drop the (potentially large) state snapshot to a marker.
653
+ // If still over cap, drop the (potentially large) state snapshot to a marker. A lease_lost
654
+ // state is the whole point of that receipt (which lease, why) and is already three short
655
+ // fields, so it is kept compact instead (BOT-2013, Codex #1017 P2).
656
+ const leaseLostState = clone.outcome === "lease_lost" ? compactLeaseLostState(clone.state) : null;
612
657
  if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes) {
613
- clone.state = { truncated: true };
658
+ clone.state = leaseLostState ?? { truncated: true };
614
659
  }
615
660
  // A matched signal's subject key can be caller-controlled through the raw
616
661
  // event escape hatch. Keep the durable sequence pointer, but not an
@@ -654,6 +699,24 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
654
699
  delete clone.detail;
655
700
  clone.detail_truncated = true;
656
701
  }
702
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "lease_lost") {
703
+ // BOT-2013: the bounded fallback keeps the lease-lost detail, then adds the resume
704
+ // pointers only while they still fit.
705
+ const base = {
706
+ schema_version: clone.schema_version,
707
+ outcome: "lease_lost",
708
+ error: "lease_lost",
709
+ state: leaseLostState ?? { truncated: true },
710
+ truncated: true,
711
+ ...(clone.client ? { client: clone.client } : {}),
712
+ };
713
+ for (const key of ["next_cursor", "wait_session_id"]) {
714
+ if (clone[key] == null) continue;
715
+ const candidate = { ...base, [key]: clone[key] };
716
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= maxBytes) base[key] = clone[key];
717
+ }
718
+ return base;
719
+ }
657
720
  if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "error") {
658
721
  const base = {
659
722
  schema_version: clone.schema_version,
@@ -1269,6 +1332,17 @@ export async function runWaitLoop({
1269
1332
  return { receipt: truncateReceipt(receipt, receiptMaxBytes), exitCode: extra.exitCode };
1270
1333
  };
1271
1334
 
1335
+ // A matched spine frame is a MATCH, except a lease frame reporting that the lease was
1336
+ // reaped: that wait ends lease_lost (BOT-2013) so the caller can re-request instead of
1337
+ // provisioning against a dead lease.
1338
+ const finalizeMatch = async (entry, signal) => {
1339
+ const lost = leaseLostDetail(signal, conditions.find((c) => c.id === entry.condition_id));
1340
+ if (lost) {
1341
+ return finalize("lease_lost", { exitCode: EXIT.LEASE_LOST, matched: [entry], state: lost, error: "lease_lost" });
1342
+ }
1343
+ return finalize("matched", { exitCode: EXIT.MATCHED, matched: [entry] });
1344
+ };
1345
+
1272
1346
  const finalizeSuperseded = async (signal) => {
1273
1347
  // Re-arm from cursor_start rather than this high sequence. A matching row can
1274
1348
  // have been allocated earlier and arrive after the supersession on a replay.
@@ -1636,10 +1710,7 @@ export async function runWaitLoop({
1636
1710
  return fireAlarm(restoredAlarm);
1637
1711
  }
1638
1712
  if (replayCandidate.kind === "superseded") return finalizeSuperseded(replayCandidate.signal);
1639
- const terminal = await finalize("matched", {
1640
- exitCode: EXIT.MATCHED,
1641
- matched: [replayCandidate.entry],
1642
- });
1713
+ const terminal = await finalizeMatch(replayCandidate.entry, replayCandidate.signal);
1643
1714
  if (onFrame) {
1644
1715
  await onFrame(replayCandidate.signal, {
1645
1716
  cursor: replayCandidate.seqKey,
@@ -1751,10 +1822,7 @@ export async function runWaitLoop({
1751
1822
  pendingReplayMatches.push({ entry, seqKey, signal });
1752
1823
  continue;
1753
1824
  }
1754
- const terminal = await finalize("matched", {
1755
- exitCode: EXIT.MATCHED,
1756
- matched: [entry],
1757
- });
1825
+ const terminal = await finalizeMatch(entry, signal);
1758
1826
  if (onFrame) {
1759
1827
  await onFrame(signal, {
1760
1828
  cursor: seqKey,
package/src/wait.mjs CHANGED
@@ -176,7 +176,10 @@ OUTPUT
176
176
  notice is on stderr and you will not see it until the wait exits.
177
177
  Exit codes: 0 matched · 2 timeout · 3 auth · 4 invalid conditions ·
178
178
  5 backend unavailable · 6 cursor expired · 7 internal ·
179
- 8 superseded (wait replaced server-side). If the receipt has
179
+ 8 superseded (wait replaced server-side) · 9 lease lost (a
180
+ lease:id= wait whose lease was reaped before it was granted;
181
+ receipt outcome lease_lost + reap_reason — request a new
182
+ lease). If the receipt has
180
183
  reacquire_lock (claim=true), acquire_lock again FIRST and re-run
181
184
  only on a queued/busy result (granted = you hold it — just resume);
182
185
  otherwise re-run from the receipt's next_cursor. docs/agent-wait.md.
@@ -503,6 +506,25 @@ async function registerWait(opts, conditions, deadlineIso) {
503
506
  };
504
507
  }
505
508
 
509
+ // BOT-2013: wait_session_status has no lease_lost value. The session ends as a terminal
510
+ // `error` and its stored receipt carries the precise outcome.
511
+ export function cloudStatusForOutcome(outcome) {
512
+ return outcome === "lease_lost" ? "error" : outcome;
513
+ }
514
+
515
+ /** BOT-2013: the one stderr line for a lease_lost receipt (null for any other outcome). */
516
+ export function leaseLostNotice(receipt) {
517
+ if (receipt?.outcome !== "lease_lost") return null;
518
+ const st = receipt.state ?? {};
519
+ return `bb-wait: lease ${st.lease_id ?? "?"} was ${st.lease_state ?? "reaped"} before it was granted ` +
520
+ `(reap_reason=${st.reap_reason ?? "unknown"}) — request a new lease (exit ${EXIT.LEASE_LOST})`;
521
+ }
522
+
523
+ function writeLeaseLostNotice(receipt) {
524
+ const notice = leaseLostNotice(receipt);
525
+ if (notice) process.stderr.write(`${notice}\n`);
526
+ }
527
+
506
528
  // Record the terminal outcome. Best-effort AND bounded: the receipt has already
507
529
  // been written to stdout before this runs, so a slow/failing finalize must never
508
530
  // delay the agent's wake or change the exit path. A short abort timeout caps how
@@ -522,7 +544,7 @@ async function finalizeWait(opts, waitSessionId, receipt, timeoutMs = 5000) {
522
544
  body: JSON.stringify({
523
545
  action: "finalize",
524
546
  wait_session_id: waitSessionId,
525
- status: receipt.outcome,
547
+ status: cloudStatusForOutcome(receipt.outcome),
526
548
  receipt,
527
549
  reconnects: receipt.reconnects ?? 0,
528
550
  degraded: receipt.degraded ?? [],
@@ -1128,7 +1150,7 @@ async function deliverTerminalFinalization(checkpoint) {
1128
1150
  const result = await postRecovery(checkpoint.relay_url, {
1129
1151
  action: "recover_finalize",
1130
1152
  wait_session_id: checkpoint.cloud_wait_session_id,
1131
- status: receipt.outcome,
1153
+ status: cloudStatusForOutcome(receipt.outcome),
1132
1154
  receipt,
1133
1155
  reconnects: receipt.reconnects ?? 0,
1134
1156
  degraded: receipt.degraded ?? [],
@@ -1197,6 +1219,7 @@ async function resumeSavedWait(id) {
1197
1219
  } finally {
1198
1220
  await fence?.release();
1199
1221
  }
1222
+ writeLeaseLostNotice(checkpoint.terminal_receipt);
1200
1223
  process.stdout.write(`${JSON.stringify(checkpoint.terminal_receipt)}\n`);
1201
1224
  process.exit(Number.isInteger(checkpoint.exit_code) ? checkpoint.exit_code : EXIT.INTERNAL);
1202
1225
  }
@@ -1241,6 +1264,7 @@ function emitSavedWaitError(error) {
1241
1264
 
1242
1265
  function exitForRecoveredOutcome(receipt) {
1243
1266
  if (receipt?.outcome === "matched") return EXIT.MATCHED;
1267
+ if (receipt?.outcome === "lease_lost") return EXIT.LEASE_LOST;
1244
1268
  if (receipt?.outcome === "cancelled") return EXIT.MATCHED;
1245
1269
  if (receipt?.outcome === "timeout") return EXIT.TIMEOUT;
1246
1270
  if (receipt?.outcome === "superseded") return EXIT.SUPERSEDED;
@@ -1415,10 +1439,12 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1415
1439
  if (argv[0] === "cancel") return argv.length === 2 ? cancelSavedWait(argv[1]) : emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "usage: bb wait cancel <local_wait_id>"));
1416
1440
  if (argv[0] === "acknowledge") return argv.length === 2 ? acknowledgeSavedWait(argv[1]) : emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "usage: bb wait acknowledge <local_wait_id>"));
1417
1441
  const opts = parseArgv(argv);
1418
- const emitReceipt = (receipt, options = {}) => emit(receipt, {
1419
- ...options,
1420
- maxBytes: opts.receiptMaxBytes,
1421
- });
1442
+ // BOT-2013 (Codex #1017 P2): every receipt this command prints — live, recovered, or
1443
+ // replayed — carries the lease-lost stderr line when it is a lease_lost terminal.
1444
+ const emitReceipt = (receipt, options = {}) => {
1445
+ writeLeaseLostNotice(receipt);
1446
+ return emit(receipt, { ...options, maxBytes: opts.receiptMaxBytes });
1447
+ };
1422
1448
  if (opts.help) {
1423
1449
  process.stdout.write(HELP);
1424
1450
  process.exit(0);