@botbuddy/cli 1.38.0 → 1.39.1
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 +57 -8
- package/src/wait-core.mjs +79 -11
- package/src/wait.mjs +33 -7
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -331,6 +331,29 @@ export function parseStackArgs(argv) {
|
|
|
331
331
|
return { command, leaseId, opts, errors, childArgv };
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/**
|
|
335
|
+
* BOT-2019: the human line for a queued lease's server position. `queue_position` is the
|
|
336
|
+
* server's dispatch rank (stack_lease_dispatch_queue — the order leases are considered in,
|
|
337
|
+
* not a guaranteed start order); a held / owner-offline lease has none and the server says
|
|
338
|
+
* why in `queue_position_reason`. `scored_ticket_id` is the ticket the lease is scored under
|
|
339
|
+
* (its own, or the requesting agent's when it has none). Older servers send neither field.
|
|
340
|
+
*/
|
|
341
|
+
export function describeQueuePosition(d) {
|
|
342
|
+
const reason = d?.queue_position_reason ? ` (${d.queue_position_reason})` : "";
|
|
343
|
+
const pos = d?.queue_position != null ? `queue position ${d.queue_position}` : `position: none${reason}`;
|
|
344
|
+
const where = [pos, d?.host_key ? `on ${d.host_key}` : null].filter(Boolean).join(" ");
|
|
345
|
+
return d?.scored_ticket_id ? `${where}, scored under ${d.scored_ticket_id}` : where;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** BOT-2019: the queue fields a server returned for a queued lease, for a receipt. */
|
|
349
|
+
function queueReceiptFields(d) {
|
|
350
|
+
return {
|
|
351
|
+
queue_position: d?.queue_position ?? null,
|
|
352
|
+
...(d?.queue_position_reason !== undefined ? { queue_position_reason: d.queue_position_reason } : {}),
|
|
353
|
+
...(d?.scored_ticket_id !== undefined ? { scored_ticket_id: d.scored_ticket_id } : {}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
334
357
|
/** Build the canonical receipt object (single JSON line to stdout). */
|
|
335
358
|
export function buildReceipt(fields) {
|
|
336
359
|
return { schema_version: STACK_SCHEMA_VERSION, ...fields };
|
|
@@ -998,7 +1021,12 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
998
1021
|
try { sig = JSON.parse(f.data); } catch { continue; }
|
|
999
1022
|
if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
|
|
1000
1023
|
const st = sig.payload?.state ?? null;
|
|
1001
|
-
if (isFailed(st)) {
|
|
1024
|
+
if (isFailed(st)) {
|
|
1025
|
+
// BOT-2013: carry the server's reap_reason so the receipt says WHY the lease is gone.
|
|
1026
|
+
const reapReason = typeof sig.payload?.reap_reason === "string" ? sig.payload.reap_reason : null;
|
|
1027
|
+
decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
|
|
1028
|
+
break frameLoop;
|
|
1029
|
+
}
|
|
1002
1030
|
if (isDone(st)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
|
|
1003
1031
|
}
|
|
1004
1032
|
}
|
|
@@ -1644,13 +1672,23 @@ export async function cmdUp(opts, {
|
|
|
1644
1672
|
|
|
1645
1673
|
if (state === "queued") {
|
|
1646
1674
|
// BOT-1319: a wrapped lefthook lane records this park as waiting for capacity.
|
|
1647
|
-
|
|
1675
|
+
// BOT-2019: the server's dispatch rank, or `position: none (<reason>)`, never a crash on null.
|
|
1676
|
+
const where = describeQueuePosition(d);
|
|
1648
1677
|
reportLaneStatus({ phase: "waiting", reason: "queued_for_capacity", detail: where || "host at capacity" });
|
|
1678
|
+
process.stderr.write(`stack: lease ${leaseId} queued — ${where}\n`);
|
|
1649
1679
|
if (opts.noWait) {
|
|
1650
|
-
return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true,
|
|
1680
|
+
return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, ...queueReceiptFields(d), holders: d.holders }), opts, EXIT.QUEUED);
|
|
1651
1681
|
}
|
|
1652
1682
|
// Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
|
|
1653
|
-
|
|
1683
|
+
// BOT-2013: a lease reaped while queued (orphaned_agent, …) is lost, not "left the queue".
|
|
1684
|
+
const parked = await waitFn(leaseId, nonQueued, isReaped, { timeoutSec: opts.timeout, auth });
|
|
1685
|
+
if (parked.failed) {
|
|
1686
|
+
return emitResult(buildReceipt({
|
|
1687
|
+
command: "up", outcome: "error", lease_id: leaseId, state: parked.state, slot,
|
|
1688
|
+
reap_reason: parked.reap_reason ?? null,
|
|
1689
|
+
error: `lease was ${parked.state} while queued (reap_reason=${parked.reap_reason ?? "unknown"}) — request a new lease`,
|
|
1690
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
1691
|
+
}
|
|
1654
1692
|
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
1693
|
if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
1656
1694
|
if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
|
|
@@ -1824,7 +1862,9 @@ export async function cmdUp(opts, {
|
|
|
1824
1862
|
}), opts, EXIT.OK);
|
|
1825
1863
|
}
|
|
1826
1864
|
|
|
1827
|
-
export async function cmdStatus(leaseId, opts, {
|
|
1865
|
+
export async function cmdStatus(leaseId, opts, {
|
|
1866
|
+
rpc = retryingStackCall(), emitResult = emit, stderr = (line) => process.stderr.write(`${line}\n`),
|
|
1867
|
+
} = {}) {
|
|
1828
1868
|
const auth = await rpc.init();
|
|
1829
1869
|
if (!auth) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
1830
1870
|
const got = await rpc.call("get_stack_lease", { lease_id: leaseId });
|
|
@@ -1833,10 +1873,15 @@ export async function cmdStatus(leaseId, opts, { rpc = retryingStackCall(), emit
|
|
|
1833
1873
|
: emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
|
|
1834
1874
|
const g = got.data;
|
|
1835
1875
|
if (!g.success) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, code: g.code, error: g.code }), opts, EXIT.BACKEND);
|
|
1876
|
+
// BOT-2019: a queued lease names its server dispatch rank (or why it has none) and the
|
|
1877
|
+
// ticket it is scored under; a null position is reported, never a crash.
|
|
1878
|
+
const queued = g.state === "queued";
|
|
1879
|
+
if (queued) stderr(`stack: lease ${leaseId} queued — ${describeQueuePosition(g)}`);
|
|
1836
1880
|
return emitResult(buildReceipt({
|
|
1837
1881
|
command: "status", outcome: g.state, lease_id: leaseId, state: g.state,
|
|
1838
1882
|
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
1839
|
-
|
|
1883
|
+
...(queued ? queueReceiptFields(g) : { queue_position: g.queue_position }),
|
|
1884
|
+
idle_ttl_secs: g.idle_ttl_secs, queued: g.queued,
|
|
1840
1885
|
// BOT-1960: a lease fenced in `reaping` says since when, and — once the server gave up
|
|
1841
1886
|
// retrying its failed reap — that it is reap_failed and why.
|
|
1842
1887
|
...(g.reap_requested_at ? { reap_requested_at: g.reap_requested_at } : {}),
|
|
@@ -2434,12 +2479,16 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2434
2479
|
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
2435
2480
|
}
|
|
2436
2481
|
if (state === "queued") {
|
|
2482
|
+
// BOT-2019: say where the lease sits (server dispatch rank, or why it has none) before parking.
|
|
2483
|
+
stderrLine(`stack: lease ${leaseId} queued — ${describeQueuePosition(request.data)}`);
|
|
2437
2484
|
activeWaitAbort = new AbortController();
|
|
2438
|
-
|
|
2485
|
+
// BOT-2013: every reap state (reaping / reaped / reap_failed) means the queued lease is lost.
|
|
2486
|
+
const parked = await wait(leaseId, (s) => s !== "queued" && s != null, isReaped, { timeoutSec: opts.timeout, auth: rpc.auth, signal: activeWaitAbort.signal });
|
|
2439
2487
|
activeWaitAbort = null;
|
|
2440
2488
|
if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
|
|
2441
2489
|
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
|
|
2490
|
+
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 }; }
|
|
2491
|
+
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
2492
|
state = parked.state;
|
|
2444
2493
|
}
|
|
2445
2494
|
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);
|