@botbuddy/cli 1.51.0 → 1.51.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/wait-core.mjs +36 -1
- package/src/wait.mjs +72 -4
package/package.json
CHANGED
package/src/wait-core.mjs
CHANGED
|
@@ -1231,6 +1231,20 @@ const nowIso = () => new Date().toISOString();
|
|
|
1231
1231
|
// deadline race, so an unbounded probe would blow past a short --timeout.
|
|
1232
1232
|
const FEED_PROBE_BUDGET_MS = 2000;
|
|
1233
1233
|
|
|
1234
|
+
// BOT-2407: transport failures that cut an established event stream. undici reports a
|
|
1235
|
+
// socket closed mid-body as `TypeError: terminated` with the socket error as its cause
|
|
1236
|
+
// (ENT-5478 prod, 2026-10-15: three exit-7 deaths in 2 h). These are a stream END — the
|
|
1237
|
+
// relay is reconnected from the safe cursor — never an internal error.
|
|
1238
|
+
const STREAM_TRANSPORT_CODES = new Set(["UND_ERR_SOCKET", "UND_ERR_BODY_TIMEOUT", "ECONNRESET", "EPIPE", "ETIMEDOUT"]);
|
|
1239
|
+
export function isStreamTransportError(err) {
|
|
1240
|
+
if (!err || typeof err !== "object") return false;
|
|
1241
|
+
if (err.name === "AbortError") return true;
|
|
1242
|
+
for (let e = err, depth = 0; e && typeof e === "object" && depth < 4; e = e.cause, depth += 1) {
|
|
1243
|
+
if (STREAM_TRANSPORT_CODES.has(e.code)) return true;
|
|
1244
|
+
}
|
|
1245
|
+
return err instanceof TypeError && err.message === "terminated";
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1234
1248
|
/**
|
|
1235
1249
|
* Run one wait to a terminal outcome, driven by an injected `connect(since)`
|
|
1236
1250
|
* that returns an async iterable of parsed SSE frames.
|
|
@@ -1350,6 +1364,7 @@ export async function runWaitLoop({
|
|
|
1350
1364
|
let reconnects = 0;
|
|
1351
1365
|
let failures = 0;
|
|
1352
1366
|
const noteBackendUnreachable = () => { if (!degraded.includes("backend_unreachable")) degraded.push("backend_unreachable"); };
|
|
1367
|
+
const noteDegraded = (marker) => { if (!degraded.includes(marker)) degraded.push(marker); };
|
|
1353
1368
|
|
|
1354
1369
|
// Internal one-shot alarms (the deadline, the pre-deadline feed probe, reconnect backoff)
|
|
1355
1370
|
// are REAL timers via the default sleep. Each exists only to resolve a race — once the loop
|
|
@@ -1682,13 +1697,29 @@ export async function runWaitLoop({
|
|
|
1682
1697
|
// previous one has actually settled into a frame we consumed.
|
|
1683
1698
|
let pendingNext = null;
|
|
1684
1699
|
while (!streamEnded) {
|
|
1685
|
-
|
|
1700
|
+
// BOT-2407: a rejected read settles into a marker instead of escaping the loop. A
|
|
1701
|
+
// transport cut ends the stream (reconnect); anything else is a typed error receipt.
|
|
1702
|
+
if (!pendingNext) {
|
|
1703
|
+
pendingNext = iterator.next().catch((err) => (
|
|
1704
|
+
isStreamTransportError(err) ? { __streamDropped: err } : { __streamError: err }
|
|
1705
|
+
));
|
|
1706
|
+
}
|
|
1686
1707
|
const racers = [deadlinePromise, pendingNext];
|
|
1687
1708
|
if (staleConfirmPromise) racers.push(staleConfirmPromise);
|
|
1688
1709
|
if (preDeadlineProbePromise && !preDeadlineProbeDone) racers.push(preDeadlineProbePromise);
|
|
1689
1710
|
const raced = await Promise.race(racers);
|
|
1690
1711
|
|
|
1691
1712
|
if (raced && raced.__deadline) return fireReplayGraceExpired();
|
|
1713
|
+
if (raced && raced.__streamDropped) {
|
|
1714
|
+
pendingNext = null;
|
|
1715
|
+
noteDegraded("stream_dropped");
|
|
1716
|
+
streamEnded = true;
|
|
1717
|
+
break;
|
|
1718
|
+
}
|
|
1719
|
+
if (raced && raced.__streamError) {
|
|
1720
|
+
const err = raced.__streamError;
|
|
1721
|
+
return finalize("error", { exitCode: EXIT.INTERNAL, error: `stream_error: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}` });
|
|
1722
|
+
}
|
|
1692
1723
|
// BOT-1147 (Codex round-27 P2): the pre-deadline feed probe fired. Fold a
|
|
1693
1724
|
// mid-wait stall/recovery into `degraded` before the imminent timeout receipt,
|
|
1694
1725
|
// then keep waiting — pendingNext stays in flight (hoisted), re-raced next
|
|
@@ -1988,6 +2019,10 @@ export async function runWaitLoop({
|
|
|
1988
2019
|
if (!deliveredFrame) {
|
|
1989
2020
|
failures += 1;
|
|
1990
2021
|
if (failures > maxReconnects) {
|
|
2022
|
+
// BOT-2407 AC-4: only consecutive failures spend the budget, so a give-up is always
|
|
2023
|
+
// an outage — name it. A connection that ended before any frame is
|
|
2024
|
+
// `stream_ended_empty`; `backend_unreachable` / `stream_dropped` are already noted.
|
|
2025
|
+
noteDegraded("stream_ended_empty");
|
|
1991
2026
|
return finalize("error", { exitCode: EXIT.BACKEND, error: "reconnect budget exhausted" });
|
|
1992
2027
|
}
|
|
1993
2028
|
}
|
package/src/wait.mjs
CHANGED
|
@@ -2192,12 +2192,80 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2192
2192
|
}
|
|
2193
2193
|
process.exit(exitCode);
|
|
2194
2194
|
} catch (err) {
|
|
2195
|
-
|
|
2196
|
-
|
|
2195
|
+
// A checkpoint-store failure is its own typed outcome (wait_checkpoint_write_failed):
|
|
2196
|
+
// the store is what failed, so it is not settled as `internal` (Codex #1190 R2).
|
|
2197
|
+
if (err instanceof WaitCheckpointError) {
|
|
2198
|
+
try { if (checkpoint?.local_wait_id) await store.update(checkpoint.local_wait_id, { status: "interrupted" }); } catch { /* retain original diagnostic state */ }
|
|
2199
|
+
return emitSavedWaitError(err);
|
|
2200
|
+
}
|
|
2201
|
+
const settled = await settleInternalError({
|
|
2202
|
+
err, checkpoint, store, waitSessionId, buildPublicReceipt,
|
|
2203
|
+
finalize: (receipt) => finalizeWait(opts, waitSessionId, receipt),
|
|
2204
|
+
});
|
|
2197
2205
|
process.stderr.write(`bb-wait: ${err && err.stack || err}\n`);
|
|
2198
|
-
|
|
2199
|
-
|
|
2206
|
+
// Receipt first, stdout second, cloud finalization last — as on the normal path.
|
|
2207
|
+
emitReceipt(settled.receipt, { versioned: true });
|
|
2208
|
+
await settled.finalizeCloud();
|
|
2209
|
+
process.exit(settled.exitCode);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* BOT-2407 AC-2: the last-resort path for an unexpected throw (other than a
|
|
2215
|
+
* WaitCheckpointError, which the caller emits as its own typed receipt) while a wait runs. It
|
|
2216
|
+
* ends with a durable terminal receipt saying why — `interrupted` with no receipt is
|
|
2217
|
+
* reserved for a killed process — and returns the receipt to print, the exit code, and
|
|
2218
|
+
* `finalizeCloud` to call once the receipt is on stdout.
|
|
2219
|
+
* - The bounded public receipt is built once and is the same object that is persisted,
|
|
2220
|
+
* printed and finalized, so `bb wait resume` replays exactly what stdout carried.
|
|
2221
|
+
* - A registered wait is finalized in the cloud (best effort), so /waits learns why
|
|
2222
|
+
* instead of the row waiting for the reaper.
|
|
2223
|
+
* - A terminal receipt already saved (a match persisted from its frame) stays
|
|
2224
|
+
* authoritative: it is never overwritten by the internal error.
|
|
2225
|
+
* Never throws, and neither does `finalizeCloud`.
|
|
2226
|
+
*/
|
|
2227
|
+
export async function settleInternalError({ err, checkpoint, store, waitSessionId = null, buildPublicReceipt, finalize = null }) {
|
|
2228
|
+
if (checkpoint?.status === "terminal" && checkpoint.terminal_receipt != null) {
|
|
2229
|
+
return {
|
|
2230
|
+
receipt: checkpoint.terminal_receipt,
|
|
2231
|
+
exitCode: Number.isInteger(checkpoint.exit_code) ? checkpoint.exit_code : EXIT.INTERNAL,
|
|
2232
|
+
finalizeCloud: async () => {},
|
|
2233
|
+
};
|
|
2200
2234
|
}
|
|
2235
|
+
const internalReceipt = {
|
|
2236
|
+
schema_version: 1,
|
|
2237
|
+
outcome: "error",
|
|
2238
|
+
error: "internal",
|
|
2239
|
+
detail: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
|
|
2240
|
+
wait_session_id: waitSessionId ?? null,
|
|
2241
|
+
next_cursor: checkpoint?.safe_cursor ?? null,
|
|
2242
|
+
};
|
|
2243
|
+
let receipt = internalReceipt;
|
|
2244
|
+
try { receipt = buildPublicReceipt(internalReceipt); } catch { /* keep the raw receipt */ }
|
|
2245
|
+
const id = checkpoint?.local_wait_id;
|
|
2246
|
+
if (id) {
|
|
2247
|
+
try {
|
|
2248
|
+
await store.update(id, {
|
|
2249
|
+
status: "terminal",
|
|
2250
|
+
terminal_receipt: receipt,
|
|
2251
|
+
exit_code: EXIT.INTERNAL,
|
|
2252
|
+
safe_cursor: receipt.next_cursor ?? checkpoint.safe_cursor ?? null,
|
|
2253
|
+
cloud_finalize_pending: Boolean(waitSessionId),
|
|
2254
|
+
});
|
|
2255
|
+
} catch { /* retain original diagnostic state */ }
|
|
2256
|
+
}
|
|
2257
|
+
const finalizeCloud = async () => {
|
|
2258
|
+
if (!waitSessionId || !finalize) return;
|
|
2259
|
+
try {
|
|
2260
|
+
const finalized = await finalize(receipt);
|
|
2261
|
+
if (id) {
|
|
2262
|
+
await store.update(id, finalized?.delivered
|
|
2263
|
+
? { cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null }
|
|
2264
|
+
: { cloud_finalize_pending: true, cloud_finalize_error: finalized?.error ?? null });
|
|
2265
|
+
}
|
|
2266
|
+
} catch { /* the pending marker stays for recovery */ }
|
|
2267
|
+
};
|
|
2268
|
+
return { receipt, exitCode: EXIT.INTERNAL, finalizeCloud };
|
|
2201
2269
|
}
|
|
2202
2270
|
|
|
2203
2271
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|