@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 +1 -1
- package/src/stack.mjs +19 -4
- package/src/wait-core.mjs +79 -11
- package/src/wait.mjs +33 -7
package/package.json
CHANGED
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)) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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);
|