@botbuddy/cli 1.69.6 → 1.69.8
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/CHANGELOG.md +4 -0
- package/claude-plugin/.claude-plugin/marketplace.json +1 -1
- package/claude-plugin/bb-status/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/bb-status/README.md +5 -2
- package/claude-plugin/bb-status/hooks/register.js +20 -22
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/wait-core.mjs +92 -4
- package/src/wait-result.mjs +1 -1
- package/src/wait.mjs +76 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.69.8
|
|
4
|
+
|
|
5
|
+
- The bb-status band's context row is simpler: just `Context 45%`, right-aligned, without the token counts. It turns amber above 30% context use and red above 50%, on every surface, so a session running low on context stands out. The colour is judged on the rounded percent the row shows. The band's plugin version moves to 1.1.1, so `bb update` re-installs it.
|
|
6
|
+
|
|
3
7
|
## 1.69.6
|
|
4
8
|
|
|
5
9
|
- A new Claude Code session in a reused worktree binds its bb-status band even when the worktree's BotBuddy session was started by an earlier Claude session that has since gone. Before, the band said "BB identity belongs to another session" for as long as that BB session stayed live, and other sessions' `bb` commands in the worktree kept renewing it. The band now judges the holder by its Claude session: when the holder's transcript has been silent for 30 minutes, the band moves its cache aside to `.botbuddy/agent-state.superseded-<id>.json` and binds this session. The old BB session is not closed, so waits still running on it carry on, and it expires on its own. A holder whose Claude session is active is never replaced.
|
|
@@ -32,12 +32,15 @@ session. Once a surface draws the session again, the 5 s display tick does one
|
|
|
32
32
|
read and re-arms the relay.
|
|
33
33
|
|
|
34
34
|
**BOT-3211 context row.** The last row shows the session's context use as
|
|
35
|
-
Claude Code's status line has it, for example `Context 62
|
|
35
|
+
Claude Code's status line has it, right-aligned, for example `Context 62%`
|
|
36
|
+
(BOT-3217 dropped the token counts). The
|
|
36
37
|
mod reads it once with the free `$.session.usage()` (no `breakdown`) at
|
|
37
38
|
session start and resume, then only from the `session.measure` event the
|
|
38
39
|
engine pushes after each main-thread turn: no timer reads it. The row is hidden
|
|
39
40
|
while the runtime has no fill figure (a fresh or just-compacted session before
|
|
40
|
-
its next response), never shown as 0%.
|
|
41
|
+
its next response), never shown as 0%. It is dim up to 30%, amber (`warning`)
|
|
42
|
+
above 30% and red (`error`) above 50% (BOT-3217), judged on the rounded percent
|
|
43
|
+
it shows.
|
|
41
44
|
|
|
42
45
|
## Install
|
|
43
46
|
|
|
@@ -696,33 +696,31 @@ function bandLine(el, spec, widths, right) {
|
|
|
696
696
|
// Top-aligned: with a wrapped title, the id and status sit on its first line.
|
|
697
697
|
return el.Box({ key: spec.key, flexDirection: "row", alignItems: "flex-start", columnGap: 1, children });
|
|
698
698
|
}
|
|
699
|
-
// BOT-3211:
|
|
700
|
-
|
|
701
|
-
if (n >= 999500) return String(Math.round(n / 100000) / 10).replace(/\.0$/, "") + "M";
|
|
702
|
-
if (n >= 1000) return Math.round(n / 1000) + "k";
|
|
703
|
-
return String(n);
|
|
704
|
-
}
|
|
705
|
-
// The row's text, or null when the runtime has no fill figure (a fresh or
|
|
706
|
-
// just-compacted session): hidden, never "0%".
|
|
699
|
+
// BOT-3211/BOT-3217: "Context 62%", or null when the runtime has no fill
|
|
700
|
+
// figure (a fresh or just-compacted session): hidden, never "0%".
|
|
707
701
|
function contextText(c) {
|
|
708
|
-
if (
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
)
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
return Number.isSafeInteger(c.tokens) && c.tokens >= 0
|
|
719
|
-
? pct + " · " + tokens(c.tokens) + " of " + tokens(c.window)
|
|
720
|
-
: pct + " of " + tokens(c.window);
|
|
702
|
+
if (!c || !Number.isFinite(c.percent) || c.percent < 0 || c.percent > 100) return null;
|
|
703
|
+
return "Context " + Math.round(c.percent) + "%";
|
|
704
|
+
}
|
|
705
|
+
// BOT-3217: amber above 30%, red above 50%, judged on the rounded percent the
|
|
706
|
+
// row shows, so the colour always matches the number.
|
|
707
|
+
function contextColor(c) {
|
|
708
|
+
const pct = Math.round(c.percent);
|
|
709
|
+
if (pct > 50) return "error";
|
|
710
|
+
if (pct > 30) return "warning";
|
|
711
|
+
return null;
|
|
721
712
|
}
|
|
722
713
|
function contextLine(el, key) {
|
|
723
714
|
const text = contextText(context);
|
|
724
715
|
if (!text) return null;
|
|
725
|
-
|
|
716
|
+
const color = contextColor(context);
|
|
717
|
+
// Right-aligned on its own row (Jono, BOT-3217).
|
|
718
|
+
return el.Box({
|
|
719
|
+
key,
|
|
720
|
+
flexDirection: "row",
|
|
721
|
+
justifyContent: "flex-end",
|
|
722
|
+
children: [color ? el.Text({ color, children: [text] }) : dim(el, text)],
|
|
723
|
+
});
|
|
726
724
|
}
|
|
727
725
|
async function readContext($) {
|
|
728
726
|
try {
|
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.69.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.69.8",
|
|
3
|
+
"source_commit": "704da4fe10bcbc13f1ca3abc6b0d47d2260e2c62",
|
|
4
|
+
"source_sha256": "2465657c2fe4fa188d73f3b7efda0f608144afcfd8d7eba465ece55ccb1204f3",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "865f29aebd07e09de6c5b77fd5297db5a568c30843c4395e8ae4e927b0d4e7ad"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.69.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.69.8",
|
|
3
|
+
"source_commit": "704da4fe10bcbc13f1ca3abc6b0d47d2260e2c62",
|
|
4
|
+
"source_sha256": "2465657c2fe4fa188d73f3b7efda0f608144afcfd8d7eba465ece55ccb1204f3",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "6ad28e621587a5a697621c54c5a9b09f90c79b0687a4ae1f25f380ef02843d4b"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.69.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.69.8",
|
|
4
|
+
"source_commit": "704da4fe10bcbc13f1ca3abc6b0d47d2260e2c62",
|
|
5
|
+
"source_sha256": "2465657c2fe4fa188d73f3b7efda0f608144afcfd8d7eba465ece55ccb1204f3",
|
|
6
6
|
"targets": {
|
|
7
7
|
"darwin-arm64": {
|
|
8
8
|
"goos": "darwin",
|
|
9
9
|
"goarch": "arm64",
|
|
10
10
|
"binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "865f29aebd07e09de6c5b77fd5297db5a568c30843c4395e8ae4e927b0d4e7ad",
|
|
12
12
|
"mode": "0755"
|
|
13
13
|
},
|
|
14
14
|
"linux-x64": {
|
|
15
15
|
"goos": "linux",
|
|
16
16
|
"goarch": "amd64",
|
|
17
17
|
"binary": "daemon/bin/linux-x64/botbuddy-daemon",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "6ad28e621587a5a697621c54c5a9b09f90c79b0687a4ae1f25f380ef02843d4b",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/wait-core.mjs
CHANGED
|
@@ -39,6 +39,11 @@ export const EXIT = Object.freeze({
|
|
|
39
39
|
// BOT-2783: a Codex review was still not complete at its intervention deadline. The
|
|
40
40
|
// receipt's `error` carries the provenance (limit, its source, window, reason).
|
|
41
41
|
REVIEW_TIMEOUT: 13,
|
|
42
|
+
// BOT-3189: a staging incident ended this wait early (the server marked it abandoned
|
|
43
|
+
// and emitted a sequenced `wait_preempted` signal for it). The condition did NOT
|
|
44
|
+
// fire: the receipt carries the incident and the caller's role; re-arm from
|
|
45
|
+
// next_cursor (the wait's cursor_start) after acting on the incident.
|
|
46
|
+
PREEMPTED: 14,
|
|
42
47
|
});
|
|
43
48
|
|
|
44
49
|
// BOT-2013: the uuid spellings Postgres accepts for a lease id (uuid_in), mirrored by the SQL
|
|
@@ -91,6 +96,11 @@ function managedAllocationFailureDetail(signal, condition) {
|
|
|
91
96
|
// own-session terminal branch because its wait_session_id differs) would otherwise match
|
|
92
97
|
// that raw event condition and exit an unrelated wait as an ordinary match (Codex P2).
|
|
93
98
|
export const WAIT_SUPERSEDED_SIGNAL_TYPE = "wait_superseded";
|
|
99
|
+
// BOT-3189: the CONTROL signal a staging incident emits to end the suspect agent's armed
|
|
100
|
+
// waits early (exit 14). Like wait_superseded it targets one wait_session_id, is
|
|
101
|
+
// recipient-scoped at the relay, and is NOT in SIGNAL_TYPES, so it can never be armed
|
|
102
|
+
// through the raw `event:` escape hatch.
|
|
103
|
+
export const WAIT_PREEMPTED_SIGNAL_TYPE = "wait_preempted";
|
|
94
104
|
const HOST_APPROVAL_HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
|
|
95
105
|
|
|
96
106
|
// The USER-ARMABLE spine signal_type allowlist, for the `event:` escape hatch. The full
|
|
@@ -1029,6 +1039,21 @@ function compactCleanupState(state) {
|
|
|
1029
1039
|
* pointer (the `seq`, plus a `payload_truncated` marker); the caller re-fetches
|
|
1030
1040
|
* detail with the existing get_* tools if it needs the body.
|
|
1031
1041
|
*/
|
|
1042
|
+
function compactPreemptedState(state) {
|
|
1043
|
+
const st = state && typeof state === "object" ? state : {};
|
|
1044
|
+
const inc = st.incident && typeof st.incident === "object" ? st.incident : {};
|
|
1045
|
+
return {
|
|
1046
|
+
preempted: true, reason: st.reason ?? null, event: st.event ?? null, role: st.role ?? null,
|
|
1047
|
+
rm_seat_open: st.rm_seat_open ?? null,
|
|
1048
|
+
incident: {
|
|
1049
|
+
id: inc.id ?? null,
|
|
1050
|
+
repo: typeof inc.repo === "string" ? inc.repo.slice(0, 100) : null,
|
|
1051
|
+
suspect_pr_number: inc.suspect_pr_number ?? null,
|
|
1052
|
+
},
|
|
1053
|
+
truncated: true,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1032
1057
|
export function truncateReceipt(receipt, maxBytes = 10240) {
|
|
1033
1058
|
if (Buffer.byteLength(JSON.stringify(receipt)) <= maxBytes) return receipt;
|
|
1034
1059
|
const clone = JSON.parse(JSON.stringify(receipt));
|
|
@@ -1043,8 +1068,11 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
|
|
|
1043
1068
|
// fields, so it is kept compact instead (BOT-2013, Codex #1017 P2).
|
|
1044
1069
|
const leaseLostState = clone.outcome === "lease_lost" ? compactLeaseLostState(clone.state) : null;
|
|
1045
1070
|
const cleanupState = ["matched", "error"].includes(clone.outcome) ? compactCleanupState(clone.state) : null;
|
|
1071
|
+
// BOT-3189: a preempted receipt's state IS the instruction (role, seat, which incident);
|
|
1072
|
+
// keep it compact rather than dropping it. next_cursor is never touched here.
|
|
1073
|
+
const preemptedState = clone.outcome === "preempted" ? compactPreemptedState(clone.state) : null;
|
|
1046
1074
|
if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes) {
|
|
1047
|
-
clone.state = leaseLostState ?? cleanupState ?? { truncated: true };
|
|
1075
|
+
clone.state = leaseLostState ?? cleanupState ?? preemptedState ?? { truncated: true };
|
|
1048
1076
|
}
|
|
1049
1077
|
// A matched signal's subject key can be caller-controlled through the raw
|
|
1050
1078
|
// event escape hatch. Keep the durable sequence pointer, but not an
|
|
@@ -1123,6 +1151,35 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
|
|
|
1123
1151
|
}
|
|
1124
1152
|
return base;
|
|
1125
1153
|
}
|
|
1154
|
+
if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "preempted") {
|
|
1155
|
+
// BOT-3189: the bounded fallback keeps what the agent must act on (role, seat, which
|
|
1156
|
+
// incident) and the re-arm cursor, which makes the re-armed wait loss-free; optional
|
|
1157
|
+
// metadata (principal, client, web_url, repo) is added back only while it fits.
|
|
1158
|
+
const st = clone.state && typeof clone.state === "object" ? clone.state : {};
|
|
1159
|
+
const inc = st.incident && typeof st.incident === "object" ? st.incident : {};
|
|
1160
|
+
const base = {
|
|
1161
|
+
schema_version: clone.schema_version,
|
|
1162
|
+
outcome: "preempted",
|
|
1163
|
+
matched: [],
|
|
1164
|
+
state: {
|
|
1165
|
+
preempted: true, event: st.event ?? null, role: st.role ?? null, rm_seat_open: st.rm_seat_open ?? null,
|
|
1166
|
+
incident: { id: inc.id ?? null, suspect_pr_number: inc.suspect_pr_number ?? null },
|
|
1167
|
+
truncated: true,
|
|
1168
|
+
},
|
|
1169
|
+
truncated: true,
|
|
1170
|
+
...(clone.next_cursor != null ? { next_cursor: clone.next_cursor } : {}),
|
|
1171
|
+
};
|
|
1172
|
+
if (typeof inc.repo === "string") {
|
|
1173
|
+
const candidate = { ...base, state: { ...base.state, incident: { ...base.state.incident, repo: inc.repo.slice(0, 100) } } };
|
|
1174
|
+
if (Buffer.byteLength(JSON.stringify(candidate)) <= maxBytes) base.state = candidate.state;
|
|
1175
|
+
}
|
|
1176
|
+
for (const key of ["wait_session_id", "client", "web_url", "principal"]) {
|
|
1177
|
+
if (clone[key] == null) continue;
|
|
1178
|
+
const candidate = { ...base, [key]: clone[key] };
|
|
1179
|
+
if (Buffer.byteLength(JSON.stringify(candidate)) <= maxBytes) base[key] = clone[key];
|
|
1180
|
+
}
|
|
1181
|
+
return base;
|
|
1182
|
+
}
|
|
1126
1183
|
if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "review_timeout" &&
|
|
1127
1184
|
clone.error && typeof clone.error === "object") {
|
|
1128
1185
|
// BOT-2783: keep the actionable provenance (limit, its source, why, which
|
|
@@ -1927,6 +1984,25 @@ export async function runWaitLoop({
|
|
|
1927
1984
|
});
|
|
1928
1985
|
};
|
|
1929
1986
|
|
|
1987
|
+
const finalizePreempted = async (signal) => {
|
|
1988
|
+
// Re-arm from cursor_start, exactly like a supersession: anything that matched this
|
|
1989
|
+
// wait's conditions after it armed is replayed by the re-armed wait.
|
|
1990
|
+
const p = signal.payload ?? {};
|
|
1991
|
+
const cs = p.cursor_start;
|
|
1992
|
+
if (cs != null && /^\d+$/.test(String(cs))) cursor = String(cs);
|
|
1993
|
+
return finalize("preempted", {
|
|
1994
|
+
exitCode: EXIT.PREEMPTED,
|
|
1995
|
+
state: {
|
|
1996
|
+
preempted: true,
|
|
1997
|
+
reason: typeof p.reason === "string" ? p.reason : null,
|
|
1998
|
+
event: typeof p.event === "string" ? p.event : null,
|
|
1999
|
+
role: typeof p.role === "string" ? p.role : null,
|
|
2000
|
+
rm_seat_open: typeof p.rm_seat_open === "boolean" ? p.rm_seat_open : null,
|
|
2001
|
+
incident: p.incident && typeof p.incident === "object" ? p.incident : null,
|
|
2002
|
+
},
|
|
2003
|
+
});
|
|
2004
|
+
};
|
|
2005
|
+
|
|
1930
2006
|
// Fold the timer conditions and the --timeout cap into one earliest alarm.
|
|
1931
2007
|
// A timer condition firing is a MATCH (exit 0); the --timeout cap is a
|
|
1932
2008
|
// TIMEOUT (exit 2). With --any the earliest of the two always wins.
|
|
@@ -2233,6 +2309,13 @@ export async function runWaitLoop({
|
|
|
2233
2309
|
// BOT-2360: the relay refuses a stream bound to a wait the server already ended
|
|
2234
2310
|
// (prod a0cd7b11 parked 54 min on one). Never reconnect to it: exit typed, with the
|
|
2235
2311
|
// confirmed cursor so a re-arm replays everything after it.
|
|
2312
|
+
// BOT-3189: the relay's rollout-compat frame for a preempted wait. Pre-1.69.1
|
|
2313
|
+
// clients exit 11 on it; this client acts on the seq-bearing wait_preempted signal
|
|
2314
|
+
// (exit 14, incident in the receipt) that the relay sends next, so ignore it.
|
|
2315
|
+
if (parsed.error === "wait_abandoned" && parsed.reason === "preempted" &&
|
|
2316
|
+
(parsed.wait_session_id == null || parsed.wait_session_id === waitSessionId)) {
|
|
2317
|
+
continue;
|
|
2318
|
+
}
|
|
2236
2319
|
if (parsed.error === "wait_abandoned") {
|
|
2237
2320
|
return finalize("abandoned", {
|
|
2238
2321
|
exitCode: EXIT.ABANDONED,
|
|
@@ -2292,7 +2375,11 @@ export async function runWaitLoop({
|
|
|
2292
2375
|
if (restoredAlarm != null && Number.isFinite(candidateAtMs) && restoredAlarm.atMs < candidateAtMs) {
|
|
2293
2376
|
return fireAlarm(restoredAlarm);
|
|
2294
2377
|
}
|
|
2295
|
-
if (replayCandidate.kind === "superseded")
|
|
2378
|
+
if (replayCandidate.kind === "superseded") {
|
|
2379
|
+
return replayCandidate.signal.signal_type === WAIT_PREEMPTED_SIGNAL_TYPE
|
|
2380
|
+
? finalizePreempted(replayCandidate.signal)
|
|
2381
|
+
: finalizeSuperseded(replayCandidate.signal);
|
|
2382
|
+
}
|
|
2296
2383
|
const terminal = await finalizeMatch(replayCandidate.entry, replayCandidate.signal);
|
|
2297
2384
|
if (onFrame) {
|
|
2298
2385
|
await onFrame(replayCandidate.signal, {
|
|
@@ -2348,7 +2435,7 @@ export async function runWaitLoop({
|
|
|
2348
2435
|
// at the relay, so an agent's OTHER concurrent waits receive it too; this id check
|
|
2349
2436
|
// is why only the intended one treats it as terminal.
|
|
2350
2437
|
if (
|
|
2351
|
-
signal.signal_type === WAIT_SUPERSEDED_SIGNAL_TYPE &&
|
|
2438
|
+
(signal.signal_type === WAIT_SUPERSEDED_SIGNAL_TYPE || signal.signal_type === WAIT_PREEMPTED_SIGNAL_TYPE) &&
|
|
2352
2439
|
waitSessionId != null &&
|
|
2353
2440
|
signal.payload && signal.payload.wait_session_id === waitSessionId
|
|
2354
2441
|
) {
|
|
@@ -2356,7 +2443,8 @@ export async function runWaitLoop({
|
|
|
2356
2443
|
if (beforeReplayDeadline) pendingReplaySupersession ??= { signal, seqKey };
|
|
2357
2444
|
continue;
|
|
2358
2445
|
}
|
|
2359
|
-
|
|
2446
|
+
// BOT-3189: a staging-incident preemption ends the wait the same way (exit 14).
|
|
2447
|
+
return signal.signal_type === WAIT_PREEMPTED_SIGNAL_TYPE ? finalizePreempted(signal) : finalizeSuperseded(signal);
|
|
2360
2448
|
}
|
|
2361
2449
|
|
|
2362
2450
|
// BOT-1148/BOT-2100: every capacity frame for a pinned host participates in
|
package/src/wait-result.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const MAX_RECEIPT_BYTES = 64 * 1024;
|
|
|
12
12
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
13
|
const COMMAND_STATUSES = new Set(["success", "failure", "cancelled", "timed_out", "owner_lost"]);
|
|
14
14
|
// BOT-2783: review_timeout is terminal (exit 13, typed REVIEW_TIMEOUT provenance).
|
|
15
|
-
const TERMINAL_OUTCOMES = new Set(["matched", "timeout", "error", "superseded", "lease_lost", "abandoned", "cancelled", "review_timeout"]);
|
|
15
|
+
const TERMINAL_OUTCOMES = new Set(["matched", "timeout", "error", "superseded", "lease_lost", "abandoned", "cancelled", "review_timeout", "preempted"]);
|
|
16
16
|
|
|
17
17
|
// Persisted != delivered. Await the stream callback before exiting, including
|
|
18
18
|
// when a bounded receipt is larger than the pipe's immediately writable space.
|
package/src/wait.mjs
CHANGED
|
@@ -320,7 +320,10 @@ OUTPUT
|
|
|
320
320
|
was parked — reaped after 180 s without a live stream,
|
|
321
321
|
or cancelled; receipt outcome abandoned +
|
|
322
322
|
state.status/reason — re-arm with --since <next_cursor>)
|
|
323
|
-
· 12 named lease cleanup failed ·
|
|
323
|
+
· 12 named lease cleanup failed · 14 preempted (a
|
|
324
|
+
staging incident ended this wait; its condition did NOT
|
|
325
|
+
fire — act on the incident, then re-arm with the printed
|
|
326
|
+
command) · 13 review timeout
|
|
324
327
|
(REVIEW_TIMEOUT: a Codex review still incomplete at its
|
|
325
328
|
deadline; receipt error carries minutes/source/reason —
|
|
326
329
|
use the fallback reviewer, never infer approval).
|
|
@@ -885,14 +888,78 @@ export function waitAbandonedNotice(receipt) {
|
|
|
885
888
|
`reason=${st.reason ?? "unknown"}) — re-arm the same conditions with${since || " a fresh wait"} (exit ${EXIT.ABANDONED})`;
|
|
886
889
|
}
|
|
887
890
|
|
|
888
|
-
|
|
889
|
-
|
|
891
|
+
/**
|
|
892
|
+
* BOT-3189: the stderr warning for a `preempted` receipt (null for any other outcome): what
|
|
893
|
+
* happened, the caller's role in the incident, and the exact command that re-arms the same
|
|
894
|
+
* conditions from the wait's arm cursor, so nothing that matched meanwhile is lost.
|
|
895
|
+
*/
|
|
896
|
+
// BOT-3189: the original `bb wait` arguments to repeat in a re-arm command. Every option
|
|
897
|
+
// is kept as given (--url, --timeout, --allow-short-timeout, --heartbeat, --any, …) except
|
|
898
|
+
// secrets, the old --since, and internal recovery flags; the caller appends the new cursor.
|
|
899
|
+
const REARM_DROPPED_VALUE_FLAGS = new Set([
|
|
900
|
+
"--since", "--agent-session-token", "--agent-key", "--session-token", "--token", "--profile",
|
|
901
|
+
"--deadline-at", "--resume-local-wait",
|
|
902
|
+
]);
|
|
903
|
+
export function rearmArgs(argv = []) {
|
|
904
|
+
const out = [];
|
|
905
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
906
|
+
const a = String(argv[i]);
|
|
907
|
+
const eq = a.indexOf("=");
|
|
908
|
+
const flag = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
|
|
909
|
+
if (REARM_DROPPED_VALUE_FLAGS.has(flag)) {
|
|
910
|
+
if (flag === a) i += 1; // skip its separate value
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
out.push(a);
|
|
914
|
+
}
|
|
915
|
+
return out;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
export function waitPreemptedNotice(receipt, conditionArgs = [], options = {}) {
|
|
919
|
+
if (receipt?.outcome !== "preempted") return null;
|
|
920
|
+
const st = receipt.state ?? {};
|
|
921
|
+
const inc = st.incident ?? {};
|
|
922
|
+
const quote = (a) => `'${String(a).replace(/'/g, "'\\''")}'`;
|
|
923
|
+
const since = receipt.next_cursor != null ? ` --since ${receipt.next_cursor}` : "";
|
|
924
|
+
// Keep the non-secret wait options that change behaviour: --heartbeat keeps the agent
|
|
925
|
+
// (and a release-manager lock it may now hold) alive while it waits again.
|
|
926
|
+
const timeout = Number.isFinite(options.timeout) && options.timeout > 0 ? ` --timeout ${options.timeout}` : "";
|
|
927
|
+
const heartbeat = options.heartbeat ? " --heartbeat" : "";
|
|
928
|
+
// A cap under the floor only re-arms with the same documented exemption.
|
|
929
|
+
const shortReason = typeof options.shortTimeoutReason === "string" && options.shortTimeoutReason.trim()
|
|
930
|
+
? ` --allow-short-timeout ${quote(options.shortTimeoutReason.trim())}`
|
|
931
|
+
: "";
|
|
932
|
+
const exemption = Number.isFinite(options.timeout) && options.timeout < MIN_WAIT_TIMEOUT_SEC ? shortReason : "";
|
|
933
|
+
// With the original argv (a live wait) repeat it verbatim minus secrets; a resumed wait
|
|
934
|
+
// only knows its condition specs, so rebuild the behavioural options it does know.
|
|
935
|
+
const rearm = Array.isArray(options.argv)
|
|
936
|
+
? `bb wait ${rearmArgs(options.argv).map(quote).join(" ")}${since}`
|
|
937
|
+
: `bb wait ${conditionArgs.map(quote).join(" ")}${since}${timeout}${exemption}${heartbeat}`;
|
|
938
|
+
const what = `staging ${inc.repo ?? "?"} is red${inc.red_sha ? ` at ${String(inc.red_sha).slice(0, 10)}` : ""}` +
|
|
939
|
+
`${inc.suspect_pr_number != null ? `, suspect PR #${inc.suspect_pr_number}` : ""}` +
|
|
940
|
+
`${inc.run_url ? ` (${inc.run_url})` : ""}`;
|
|
941
|
+
let role;
|
|
942
|
+
if (st.role === "release_manager") {
|
|
943
|
+
role = "you are now the release manager — call get_staging_gate for the next steps";
|
|
944
|
+
} else if (st.rm_seat_open) {
|
|
945
|
+
role = "the release-manager seat is OPEN — call claim_release_manager, then get_staging_gate";
|
|
946
|
+
} else {
|
|
947
|
+
role = "call get_staging_gate for your role and the next steps";
|
|
948
|
+
}
|
|
949
|
+
return `bb-wait: this wait was ended by a staging incident; its condition did not fire — re-arm it.\n` +
|
|
950
|
+
`bb-wait: ${what}; ${role}.\n` +
|
|
951
|
+
`bb-wait: re-arm (exit ${EXIT.PREEMPTED}): ${rearm}`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function writeLeaseLostNotice(receipt, conditionArgs = [], options = {}) {
|
|
955
|
+
const notice = leaseLostNotice(receipt) ?? waitAbandonedNotice(receipt) ?? reviewTimeoutNotice(receipt) ??
|
|
956
|
+
waitPreemptedNotice(receipt, conditionArgs, options);
|
|
890
957
|
if (notice) process.stderr.write(`${notice}\n`);
|
|
891
958
|
}
|
|
892
959
|
|
|
893
960
|
// BOT-1228 / BOT-2360: outcomes whose wait_session the server already ended. The client
|
|
894
961
|
// never finalizes them (finalize_wait_session only transitions from active).
|
|
895
|
-
const SERVER_ENDED_OUTCOMES = new Set(["superseded", "abandoned"]);
|
|
962
|
+
const SERVER_ENDED_OUTCOMES = new Set(["superseded", "abandoned", "preempted"]);
|
|
896
963
|
|
|
897
964
|
// Record the terminal outcome. Best-effort AND bounded: the receipt has already
|
|
898
965
|
// been written to stdout before this runs, so a slow/failing finalize must never
|
|
@@ -1620,7 +1687,7 @@ async function resumeSavedWait(id) {
|
|
|
1620
1687
|
} finally {
|
|
1621
1688
|
await fence?.release();
|
|
1622
1689
|
}
|
|
1623
|
-
writeLeaseLostNotice(checkpoint.terminal_receipt);
|
|
1690
|
+
writeLeaseLostNotice(checkpoint.terminal_receipt, checkpoint.condition_specs ?? []);
|
|
1624
1691
|
await writeWaitReceipt(checkpoint.terminal_receipt);
|
|
1625
1692
|
process.exit(Number.isInteger(checkpoint.exit_code) ? checkpoint.exit_code : EXIT.INTERNAL);
|
|
1626
1693
|
}
|
|
@@ -1693,6 +1760,7 @@ export function recoveredExitCode(receipt) {
|
|
|
1693
1760
|
if (receipt?.outcome === "timeout") return EXIT.TIMEOUT;
|
|
1694
1761
|
if (receipt?.outcome === "superseded") return EXIT.SUPERSEDED;
|
|
1695
1762
|
if (receipt?.outcome === "abandoned") return EXIT.ABANDONED;
|
|
1763
|
+
if (receipt?.outcome === "preempted") return EXIT.PREEMPTED;
|
|
1696
1764
|
if (receipt?.error === "cursor_expired") return EXIT.CURSOR_EXPIRED;
|
|
1697
1765
|
if (receipt?.error === "unauthorized" || receipt?.error === "forbidden") return EXIT.AUTH;
|
|
1698
1766
|
return EXIT.BACKEND;
|
|
@@ -1885,7 +1953,9 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1885
1953
|
// BOT-2013 (Codex #1017 P2): every receipt this command prints — live, recovered, or
|
|
1886
1954
|
// replayed — carries the lease-lost stderr line when it is a lease_lost terminal.
|
|
1887
1955
|
const emitReceipt = (receipt, options = {}) => {
|
|
1888
|
-
writeLeaseLostNotice(receipt
|
|
1956
|
+
writeLeaseLostNotice(receipt, opts.conditions, {
|
|
1957
|
+
heartbeat: opts.heartbeat, timeout: opts.timeout, shortTimeoutReason: opts.shortTimeoutReason, argv,
|
|
1958
|
+
});
|
|
1889
1959
|
if (receipt?.outcome === "error") writeWaitFeedbackGuidance();
|
|
1890
1960
|
return emit(receipt, { ...options, maxBytes: opts.receiptMaxBytes });
|
|
1891
1961
|
};
|