@botbuddy/cli 1.29.2 → 1.29.4
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-checkpoint.mjs +410 -0
- package/src/wait-core.mjs +187 -63
- package/src/wait.mjs +807 -21
package/src/wait-core.mjs
CHANGED
|
@@ -1023,6 +1023,7 @@ export async function runWaitLoop({
|
|
|
1023
1023
|
backoff = (attempt) => nextBackoff(attempt),
|
|
1024
1024
|
sleep = null,
|
|
1025
1025
|
onFrame = null,
|
|
1026
|
+
startedAt: originalStartedAt = null,
|
|
1026
1027
|
// BOT-1259: the wait session's server-resolved tenant (from the register echo). A
|
|
1027
1028
|
// tenanted spine signal only matches a session resolved to that tenant; `null` (the
|
|
1028
1029
|
// session's tenant could not be resolved) fails closed, and `undefined` (the caller
|
|
@@ -1042,6 +1043,9 @@ export async function runWaitLoop({
|
|
|
1042
1043
|
// clear on recovery). Injected so the CLI wires the real freshness query and a
|
|
1043
1044
|
// test drives it clock-controlled; omitted (null) = no probing.
|
|
1044
1045
|
feedLagProbe = null,
|
|
1046
|
+
// On an expired recovery, replay only durable rows created before this original
|
|
1047
|
+
// deadline; the caller supplies a short transport grace via deadlineMs.
|
|
1048
|
+
replayOnlyThroughMs = null,
|
|
1045
1049
|
// BOT-1148: after a beacon-driven source_stale:true transition on a HOST-PINNED
|
|
1046
1050
|
// capacity wait, wait this long for a recovery (source_stale:false) before exiting
|
|
1047
1051
|
// capacity_source_stale. Absorbs threshold flap on the client (the server latch is
|
|
@@ -1057,13 +1061,28 @@ export async function runWaitLoop({
|
|
|
1057
1061
|
// frame. 90s = 60s cron + margin.
|
|
1058
1062
|
beaconConfirmMs = 90_000,
|
|
1059
1063
|
}) {
|
|
1060
|
-
const startedAt =
|
|
1064
|
+
const startedAt = typeof originalStartedAt === "string" && Number.isFinite(Date.parse(originalStartedAt))
|
|
1065
|
+
? originalStartedAt : nowIso();
|
|
1061
1066
|
const seen = new Set();
|
|
1062
1067
|
// An omitted --since arms from the current high-water mark (live only): the
|
|
1063
1068
|
// relay must NOT replay history, or a brand-new wait would immediately match a
|
|
1064
1069
|
// long-past lock release. Only an explicit --since replays (cursor != null).
|
|
1065
1070
|
const sinceParsed = normalizeSince(since);
|
|
1066
1071
|
let cursor = sinceParsed.kind === "ok" ? sinceParsed.value : null;
|
|
1072
|
+
// The last cursor whose replay range has been confirmed by replay_complete.
|
|
1073
|
+
// Live signals can overtake lower replay rows, so `cursor` is unsafe as a
|
|
1074
|
+
// reconnect cursor until that marker arrives.
|
|
1075
|
+
let replayFrontier = cursor;
|
|
1076
|
+
// A new relay advertises replay_complete in its connected frame. Until that
|
|
1077
|
+
// handshake arrives, retain legacy first-match behavior for an older relay
|
|
1078
|
+
// that cannot certify a replay frontier.
|
|
1079
|
+
let replayPending = false;
|
|
1080
|
+
// A replay can be interleaved with newer live rows. Hold matching rows until
|
|
1081
|
+
// replay_complete confirms the range, then choose the earliest eligible match.
|
|
1082
|
+
// This prevents a terminal receipt from either skipping lower replay matches or
|
|
1083
|
+
// replaying its own match on a chained wait.
|
|
1084
|
+
const pendingReplayMatches = [];
|
|
1085
|
+
let pendingReplaySupersession = null;
|
|
1067
1086
|
const degraded = [];
|
|
1068
1087
|
let reconnects = 0;
|
|
1069
1088
|
let failures = 0;
|
|
@@ -1109,13 +1128,34 @@ export async function runWaitLoop({
|
|
|
1109
1128
|
degraded,
|
|
1110
1129
|
matched: extra.matched ?? [],
|
|
1111
1130
|
state: extra.state ?? { conditions: conditions.map((c) => ({ condition_id: c.id, type: c.type })) },
|
|
1112
|
-
|
|
1131
|
+
// A live frame can overtake an unconfirmed replay backlog. Never publish
|
|
1132
|
+
// that speculative cursor in a terminal receipt: chained waits must resume
|
|
1133
|
+
// from the confirmed replay frontier until replay_complete promotes it.
|
|
1134
|
+
nextCursor: replayPending ? replayFrontier : cursor,
|
|
1113
1135
|
error: extra.error ?? null,
|
|
1114
1136
|
reacquireLock: extra.reacquireLock ?? null,
|
|
1115
1137
|
});
|
|
1116
1138
|
return { receipt: truncateReceipt(receipt, receiptMaxBytes), exitCode: extra.exitCode };
|
|
1117
1139
|
};
|
|
1118
1140
|
|
|
1141
|
+
const finalizeSuperseded = async (signal) => {
|
|
1142
|
+
// Re-arm from cursor_start rather than this high sequence. A matching row can
|
|
1143
|
+
// have been allocated earlier and arrive after the supersession on a replay.
|
|
1144
|
+
const cs = signal.payload?.cursor_start;
|
|
1145
|
+
if (cs != null && /^\d+$/.test(String(cs))) cursor = String(cs);
|
|
1146
|
+
const reacquire = conditions
|
|
1147
|
+
.filter((c) => c.type === "lock" && c.params.claim)
|
|
1148
|
+
.map((c) => ({ subtype: c.params.subtype, host: c.params.host, slot: c.params.slot }));
|
|
1149
|
+
return finalize("superseded", {
|
|
1150
|
+
exitCode: EXIT.SUPERSEDED,
|
|
1151
|
+
state: {
|
|
1152
|
+
superseded: true,
|
|
1153
|
+
status: typeof signal.payload?.status === "string" ? signal.payload.status : null,
|
|
1154
|
+
},
|
|
1155
|
+
reacquireLock: reacquire,
|
|
1156
|
+
});
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1119
1159
|
// Fold the timer conditions and the --timeout cap into one earliest alarm.
|
|
1120
1160
|
// A timer condition firing is a MATCH (exit 0); the --timeout cap is a
|
|
1121
1161
|
// TIMEOUT (exit 2). With --any the earliest of the two always wins.
|
|
@@ -1131,27 +1171,40 @@ export async function runWaitLoop({
|
|
|
1131
1171
|
// (degraded) on an EXISTING exit code (5), never a new numeric code (AC 3).
|
|
1132
1172
|
for (const c of conditions) {
|
|
1133
1173
|
if (c.type === "capacity" && c.params.staleGraceSec != null) {
|
|
1134
|
-
|
|
1174
|
+
const staleAtMs = Number.isFinite(c.params.staleDeadlineMs)
|
|
1175
|
+
? c.params.staleDeadlineMs
|
|
1176
|
+
: now() + c.params.staleGraceSec * 1000;
|
|
1177
|
+
alarms.push({ kind: "stale", conditionId: c.id, atMs: staleAtMs });
|
|
1135
1178
|
}
|
|
1136
1179
|
}
|
|
1137
1180
|
const earliest = alarms.length ? alarms.reduce((m, a) => (a.atMs < m.atMs ? a : m)) : null;
|
|
1138
|
-
|
|
1139
|
-
|
|
1181
|
+
// A recovered wait may have alarms that were already due at its original
|
|
1182
|
+
// deadline. Do not put those into the initial race: the durable replay must
|
|
1183
|
+
// establish whether an earlier spine match won. `deadlineMs` is the caller's
|
|
1184
|
+
// short transport grace, so it remains eligible to bound a stuck replay.
|
|
1185
|
+
const replayGraceAlarm = replayOnlyThroughMs == null ? earliest : alarms
|
|
1186
|
+
.filter((alarm) => alarm.atMs > replayOnlyThroughMs)
|
|
1187
|
+
.reduce((m, alarm) => (m == null || alarm.atMs < m.atMs ? alarm : m), null);
|
|
1188
|
+
const restoredAlarm = replayOnlyThroughMs == null ? null : alarms
|
|
1189
|
+
.filter((alarm) => alarm.atMs <= replayOnlyThroughMs)
|
|
1190
|
+
.reduce((m, alarm) => (m == null || alarm.atMs < m.atMs ? alarm : m), null);
|
|
1191
|
+
|
|
1192
|
+
const fireAlarm = async (alarm = earliest) => {
|
|
1140
1193
|
// The terminal feed-freshness reprobe now lives in finalize() (the single
|
|
1141
1194
|
// finalization path), so every terminal receipt gets it — not just the alarm path.
|
|
1142
|
-
if (!
|
|
1143
|
-
if (
|
|
1144
|
-
if (
|
|
1195
|
+
if (!alarm) return null;
|
|
1196
|
+
if (alarm.kind === "timeout") return finalize("timeout", { exitCode: EXIT.TIMEOUT });
|
|
1197
|
+
if (alarm.kind === "stale") {
|
|
1145
1198
|
// Capacity source didn't deliver within the grace: degrade truthfully and
|
|
1146
1199
|
// exit with an error receipt (not a silent hang, not a plain timeout).
|
|
1147
1200
|
if (!degraded.includes("capacity_source_stale")) degraded.push("capacity_source_stale");
|
|
1148
1201
|
return finalize("error", { exitCode: EXIT.BACKEND, error: "capacity_source_stale" });
|
|
1149
1202
|
}
|
|
1150
|
-
const condition = conditions.find((c) => c.id ===
|
|
1203
|
+
const condition = conditions.find((c) => c.id === alarm.conditionId);
|
|
1151
1204
|
return finalize("matched", {
|
|
1152
1205
|
exitCode: EXIT.MATCHED,
|
|
1153
1206
|
matched: [{
|
|
1154
|
-
condition_id:
|
|
1207
|
+
condition_id: alarm.conditionId,
|
|
1155
1208
|
signal_type: "timer",
|
|
1156
1209
|
seq: null,
|
|
1157
1210
|
subject_key: condition?.params?.deadline ?? `+${condition?.params?.duration}s`,
|
|
@@ -1160,10 +1213,18 @@ export async function runWaitLoop({
|
|
|
1160
1213
|
});
|
|
1161
1214
|
};
|
|
1162
1215
|
|
|
1216
|
+
// The recovery grace bounds one invocation's transport work; it must never
|
|
1217
|
+
// decide the original logical wait. The caller leaves this typed result out
|
|
1218
|
+
// of the durable terminal checkpoint so `bb wait resume` can try the same
|
|
1219
|
+
// replay again from its confirmed cursor.
|
|
1220
|
+
const fireReplayGraceExpired = async () => replayOnlyThroughMs != null
|
|
1221
|
+
? finalize("error", { exitCode: EXIT.BACKEND, error: "replay_incomplete" })
|
|
1222
|
+
: fireAlarm();
|
|
1223
|
+
|
|
1163
1224
|
// A single promise that resolves when the earliest alarm passes.
|
|
1164
|
-
const deadlinePromise =
|
|
1225
|
+
const deadlinePromise = replayGraceAlarm == null
|
|
1165
1226
|
? new Promise(() => {}) // no alarm: only a pushed signal can wake us
|
|
1166
|
-
: sleepFn(Math.max(0,
|
|
1227
|
+
: sleepFn(Math.max(0, replayGraceAlarm.atMs - now())).then(() => ({ __deadline: true }));
|
|
1167
1228
|
|
|
1168
1229
|
// BOT-1147 AC-3: only probe when a linear condition is armed and a probe is
|
|
1169
1230
|
// wired — every other wait type is unaffected.
|
|
@@ -1178,8 +1239,8 @@ export async function runWaitLoop({
|
|
|
1178
1239
|
// Fires at most once (a single pre-deadline probe, not a re-arming timer, so an
|
|
1179
1240
|
// injected instant `sleep` in tests can't spin the race).
|
|
1180
1241
|
let preDeadlineProbeDone = false;
|
|
1181
|
-
const preDeadlineProbePromise = (feedLagActive &&
|
|
1182
|
-
? sleepFn(Math.max(0,
|
|
1242
|
+
const preDeadlineProbePromise = (feedLagActive && replayGraceAlarm != null)
|
|
1243
|
+
? sleepFn(Math.max(0, replayGraceAlarm.atMs - now() - FEED_PROBE_BUDGET_MS)).then(() => ({ __feedProbe: true }))
|
|
1183
1244
|
: null;
|
|
1184
1245
|
// The specific issue key(s) the armed `linear` conditions target. Threaded into
|
|
1185
1246
|
// the probe so it scopes freshness to those issues' workspace(s) rather than the
|
|
@@ -1294,7 +1355,9 @@ export async function runWaitLoop({
|
|
|
1294
1355
|
};
|
|
1295
1356
|
|
|
1296
1357
|
while (true) {
|
|
1297
|
-
|
|
1358
|
+
// Expired recovery must first replay durable pre-deadline rows. A restored
|
|
1359
|
+
// timer or capacity-stale alarm may otherwise win before that replay begins.
|
|
1360
|
+
if (replayOnlyThroughMs == null && earliest != null && now() >= earliest.atMs) {
|
|
1298
1361
|
return fireAlarm();
|
|
1299
1362
|
}
|
|
1300
1363
|
|
|
@@ -1302,9 +1365,16 @@ export async function runWaitLoop({
|
|
|
1302
1365
|
// and a recovered one clears — reflected in the terminal receipt / wait_session.
|
|
1303
1366
|
await probeFeedLag();
|
|
1304
1367
|
|
|
1368
|
+
// Every reconnect starts a new replay/live overlap window. A live signal can
|
|
1369
|
+
// overtake the replay backlog, then the transport can die before its marker;
|
|
1370
|
+
// reconnect from the last confirmed frontier so lower replay matches remain
|
|
1371
|
+
// eligible instead of skipping straight to that live signal's cursor.
|
|
1372
|
+
const connectCursor = replayFrontier;
|
|
1373
|
+
const reconnectingIncompleteReplay = replayPending;
|
|
1374
|
+
replayPending = replayOnlyThroughMs != null && connectCursor != null;
|
|
1305
1375
|
let stream;
|
|
1306
1376
|
try {
|
|
1307
|
-
stream = await connect(
|
|
1377
|
+
stream = await connect(connectCursor);
|
|
1308
1378
|
} catch (err) {
|
|
1309
1379
|
failures += 1;
|
|
1310
1380
|
degraded.push("backend_unreachable");
|
|
@@ -1316,7 +1386,7 @@ export async function runWaitLoop({
|
|
|
1316
1386
|
deadlinePromise,
|
|
1317
1387
|
sleepFn(waitMs).then(() => ({ __retry: true })),
|
|
1318
1388
|
]);
|
|
1319
|
-
if (raced && raced.__deadline) return
|
|
1389
|
+
if (raced && raced.__deadline) return fireReplayGraceExpired();
|
|
1320
1390
|
continue;
|
|
1321
1391
|
}
|
|
1322
1392
|
|
|
@@ -1336,9 +1406,7 @@ export async function runWaitLoop({
|
|
|
1336
1406
|
if (preDeadlineProbePromise && !preDeadlineProbeDone) racers.push(preDeadlineProbePromise);
|
|
1337
1407
|
const raced = await Promise.race(racers);
|
|
1338
1408
|
|
|
1339
|
-
if (raced && raced.__deadline)
|
|
1340
|
-
return fireAlarm();
|
|
1341
|
-
}
|
|
1409
|
+
if (raced && raced.__deadline) return fireReplayGraceExpired();
|
|
1342
1410
|
// BOT-1147 (Codex round-27 P2): the pre-deadline feed probe fired. Fold a
|
|
1343
1411
|
// mid-wait stall/recovery into `degraded` before the imminent timeout receipt,
|
|
1344
1412
|
// then keep waiting — pendingNext stays in flight (hoisted), re-raced next
|
|
@@ -1375,6 +1443,28 @@ export async function runWaitLoop({
|
|
|
1375
1443
|
}
|
|
1376
1444
|
if (!frame) continue;
|
|
1377
1445
|
|
|
1446
|
+
if (frame.event === "connected") {
|
|
1447
|
+
let connected = {};
|
|
1448
|
+
try { connected = JSON.parse(frame.data); } catch { /* legacy relay */ }
|
|
1449
|
+
// Old relays emit connected without this capability and never send the
|
|
1450
|
+
// marker. Preserve their established first-match behavior rather than
|
|
1451
|
+
// buffering a live signal to timeout during a rolling deployment.
|
|
1452
|
+
if (replayOnlyThroughMs != null && connectCursor != null && connected.replay_complete_marker !== true) {
|
|
1453
|
+
return finalize("error", { exitCode: EXIT.BACKEND, error: "replay_marker_unsupported" });
|
|
1454
|
+
}
|
|
1455
|
+
if (connectCursor != null && connected.replay_complete_marker !== true && reconnectingIncompleteReplay) {
|
|
1456
|
+
// The connection that established this replay boundary died before its
|
|
1457
|
+
// marker. Restart legacy semantics from the last confirmed cursor: do
|
|
1458
|
+
// not retain buffered/seen rows that would discard the replayed match.
|
|
1459
|
+
pendingReplayMatches.length = 0;
|
|
1460
|
+
pendingReplaySupersession = null;
|
|
1461
|
+
seen.clear();
|
|
1462
|
+
cursor = replayFrontier;
|
|
1463
|
+
}
|
|
1464
|
+
replayPending = connectCursor != null && connected.replay_complete_marker === true;
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1378
1468
|
// BOT-1067: a degraded marker frame (e.g. the relay fell back to bounded
|
|
1379
1469
|
// re-query polling during a Realtime outage) is folded into the receipt's
|
|
1380
1470
|
// degraded array. The wait CONTINUES — delivery is just late and flagged —
|
|
@@ -1400,13 +1490,60 @@ export async function runWaitLoop({
|
|
|
1400
1490
|
return finalize("error", { exitCode: EXIT.INTERNAL, error: parsed.error || "stream_error" });
|
|
1401
1491
|
}
|
|
1402
1492
|
|
|
1493
|
+
if (frame.event === "replay_complete") {
|
|
1494
|
+
replayPending = false;
|
|
1495
|
+
replayFrontier = cursor;
|
|
1496
|
+
const pendingMatch = pendingReplayMatches
|
|
1497
|
+
.sort((a, b) => BigInt(a.seqKey) < BigInt(b.seqKey) ? -1 : BigInt(a.seqKey) > BigInt(b.seqKey) ? 1 : 0)[0];
|
|
1498
|
+
const replayCandidate = pendingMatch &&
|
|
1499
|
+
(!pendingReplaySupersession || BigInt(pendingMatch.seqKey) < BigInt(pendingReplaySupersession.seqKey))
|
|
1500
|
+
? { kind: "match", ...pendingMatch }
|
|
1501
|
+
: pendingReplaySupersession ? { kind: "superseded", ...pendingReplaySupersession } : null;
|
|
1502
|
+
if (replayCandidate) {
|
|
1503
|
+
const candidateAtMs = Date.parse(replayCandidate.signal.created_at);
|
|
1504
|
+
if (restoredAlarm != null && Number.isFinite(candidateAtMs) && restoredAlarm.atMs < candidateAtMs) {
|
|
1505
|
+
return fireAlarm(restoredAlarm);
|
|
1506
|
+
}
|
|
1507
|
+
if (replayCandidate.kind === "superseded") return finalizeSuperseded(replayCandidate.signal);
|
|
1508
|
+
const terminal = await finalize("matched", {
|
|
1509
|
+
exitCode: EXIT.MATCHED,
|
|
1510
|
+
matched: [replayCandidate.entry],
|
|
1511
|
+
});
|
|
1512
|
+
if (onFrame) {
|
|
1513
|
+
await onFrame(replayCandidate.signal, {
|
|
1514
|
+
cursor: replayCandidate.seqKey,
|
|
1515
|
+
terminalReceipt: terminal.receipt,
|
|
1516
|
+
exitCode: terminal.exitCode,
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
return terminal;
|
|
1520
|
+
}
|
|
1521
|
+
if (replayOnlyThroughMs != null) return fireAlarm();
|
|
1522
|
+
if (onFrame) await onFrame(null, { cursor, replayComplete: true });
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1403
1525
|
const signal = decodeSignal(frame);
|
|
1404
1526
|
if (!signal || signal.seq == null) continue;
|
|
1405
1527
|
const seqKey = String(signal.seq);
|
|
1528
|
+
// Replayed rows can arrive after a higher live row. Keep the highest
|
|
1529
|
+
// observed sequence so replay_complete can safely promote the whole
|
|
1530
|
+
// confirmed range, even when that live row is de-duplicated below.
|
|
1531
|
+
if (cursor == null || BigInt(seqKey) > BigInt(cursor)) cursor = seqKey;
|
|
1532
|
+
if (!replayPending) replayFrontier = cursor;
|
|
1406
1533
|
if (seen.has(seqKey)) continue; // exactly-once across reconnects
|
|
1407
1534
|
seen.add(seqKey);
|
|
1408
|
-
|
|
1409
|
-
|
|
1535
|
+
const checkpointNonterminalFrame = async () => {
|
|
1536
|
+
if (!replayPending && onFrame) await onFrame(signal, { cursor: seqKey });
|
|
1537
|
+
};
|
|
1538
|
+
const beforeReplayDeadline = replayOnlyThroughMs == null || (
|
|
1539
|
+
typeof signal.created_at === "string" &&
|
|
1540
|
+
Number.isFinite(Date.parse(signal.created_at)) &&
|
|
1541
|
+
Date.parse(signal.created_at) <= replayOnlyThroughMs
|
|
1542
|
+
);
|
|
1543
|
+
// Do not checkpoint the replay cursor yet. A crash after a pre-match
|
|
1544
|
+
// checkpoint would skip this very frame on recovery. Non-matches become
|
|
1545
|
+
// durable only after matching is complete; a match carries its terminal
|
|
1546
|
+
// receipt in the same checkpoint callback below (BOT-1656).
|
|
1410
1547
|
|
|
1411
1548
|
// BOT-1249: a `wait_superseded` spine signal targeting THIS registration is
|
|
1412
1549
|
// terminal (exit 8). reconcile_waits_to_canonical() emits it — in seq order,
|
|
@@ -1430,43 +1567,11 @@ export async function runWaitLoop({
|
|
|
1430
1567
|
waitSessionId != null &&
|
|
1431
1568
|
signal.payload && signal.payload.wait_session_id === waitSessionId
|
|
1432
1569
|
) {
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
// If we re-armed from the supersession's (higher) seq we'd skip that match on
|
|
1439
|
-
// re-registration. cursor_start is the seq this wait armed from, so re-arming here
|
|
1440
|
-
// gives the re-registered wait EXACTLY the coverage the original registration had
|
|
1441
|
-
// — strictly better than re-arming from the last-seen (higher) cursor, which is
|
|
1442
|
-
// all a normal same-wait reconnect gets. (The one residual gap — a match whose row
|
|
1443
|
-
// was allocated a seq BELOW cursor_start but committed after registration read
|
|
1444
|
-
// max(seq) — is the pre-existing BOT-989 register-after-emit race that any
|
|
1445
|
-
// reconnect shares; it is out of scope here and tracked separately. We do NOT
|
|
1446
|
-
// re-arm from 0, which would spuriously re-match stale pre-arm signals.) Falls
|
|
1447
|
-
// back to the current cursor if the emitter didn't stamp cursor_start (defensive —
|
|
1448
|
-
// reconcile always does).
|
|
1449
|
-
const cs = signal.payload.cursor_start;
|
|
1450
|
-
if (cs != null && /^\d+$/.test(String(cs))) cursor = String(cs);
|
|
1451
|
-
// A superseded CLAIM wait lost its grantable queue entry: reconcile_waits_to_
|
|
1452
|
-
// canonical() dequeues the queued resource_locks row before abandoning the
|
|
1453
|
-
// session. A plain re-run re-registers the wait_session but does NOT recreate
|
|
1454
|
-
// that queue row, so grant_free_lock would have nothing to promote and the
|
|
1455
|
-
// re-armed claim would hang to --timeout. Surface the lock(s) to re-acquire so
|
|
1456
|
-
// the caller re-queues (acquire_lock) BEFORE re-parking (BOT-1228 Codex round-3
|
|
1457
|
-
// P1). Broadcast waits need no queue entry, so this is empty for them.
|
|
1458
|
-
const reacquire = conditions
|
|
1459
|
-
.filter((c) => c.type === "lock" && c.params.claim)
|
|
1460
|
-
.map((c) => ({ subtype: c.params.subtype, host: c.params.host, slot: c.params.slot }));
|
|
1461
|
-
return finalize("superseded", {
|
|
1462
|
-
exitCode: EXIT.SUPERSEDED,
|
|
1463
|
-
state: {
|
|
1464
|
-
superseded: true,
|
|
1465
|
-
status: typeof signal.payload.status === "string" ? signal.payload.status : null,
|
|
1466
|
-
},
|
|
1467
|
-
// Top-level (survives receipt truncation) — see buildReceipt.
|
|
1468
|
-
reacquireLock: reacquire,
|
|
1469
|
-
});
|
|
1570
|
+
if (replayPending) {
|
|
1571
|
+
if (beforeReplayDeadline) pendingReplaySupersession ??= { signal, seqKey };
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
return finalizeSuperseded(signal);
|
|
1470
1575
|
}
|
|
1471
1576
|
|
|
1472
1577
|
// BOT-1148: a beacon-driven capacity staleness transition is handled before the
|
|
@@ -1477,7 +1582,10 @@ export async function runWaitLoop({
|
|
|
1477
1582
|
if (disposition === "exit") {
|
|
1478
1583
|
return finalize("error", { exitCode: EXIT.BACKEND, error: "capacity_source_stale" });
|
|
1479
1584
|
}
|
|
1480
|
-
if (disposition === "continue")
|
|
1585
|
+
if (disposition === "continue") {
|
|
1586
|
+
await checkpointNonterminalFrame();
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1481
1589
|
// "fallthrough": let matchFrame see the frame — a recovery may grant via
|
|
1482
1590
|
// free_slots, and a raw `event` condition may match a stale frame (the
|
|
1483
1591
|
// `capacity` matcher itself never grants on source_stale).
|
|
@@ -1491,11 +1599,12 @@ export async function runWaitLoop({
|
|
|
1491
1599
|
if (debug) {
|
|
1492
1600
|
debug(`bb-wait: dropped ${signal.signal_type} signal (seq ${signal.seq}) — tenant ${signal.tenant_id} ≠ session tenant ${sessionTenant ?? "(unresolved)"} [BOT-1259 fail-closed]`);
|
|
1493
1601
|
}
|
|
1602
|
+
await checkpointNonterminalFrame();
|
|
1494
1603
|
continue;
|
|
1495
1604
|
}
|
|
1496
1605
|
|
|
1497
1606
|
const matched = matchFrame(frame, conditions, waitSessionId, sessionTenant);
|
|
1498
|
-
if (matched) {
|
|
1607
|
+
if (matched && beforeReplayDeadline) {
|
|
1499
1608
|
const entry = {
|
|
1500
1609
|
condition_id: matched.id,
|
|
1501
1610
|
signal_type: signal.signal_type,
|
|
@@ -1507,11 +1616,26 @@ export async function runWaitLoop({
|
|
|
1507
1616
|
// BOT-1577: a ranked capacity grant records this wait's position/tier/score.
|
|
1508
1617
|
const grant = capacityGrantForReceipt(signal, waitSessionId);
|
|
1509
1618
|
if (grant) entry.grant = grant;
|
|
1510
|
-
|
|
1619
|
+
if (replayPending) {
|
|
1620
|
+
pendingReplayMatches.push({ entry, seqKey, signal });
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
const terminal = await finalize("matched", {
|
|
1511
1624
|
exitCode: EXIT.MATCHED,
|
|
1512
1625
|
matched: [entry],
|
|
1513
1626
|
});
|
|
1627
|
+
if (onFrame) {
|
|
1628
|
+
await onFrame(signal, {
|
|
1629
|
+
cursor: seqKey,
|
|
1630
|
+
terminalReceipt: terminal.receipt,
|
|
1631
|
+
exitCode: terminal.exitCode,
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
return terminal;
|
|
1514
1635
|
}
|
|
1636
|
+
// The frame was completely evaluated and cannot be an eligible terminal
|
|
1637
|
+
// result. It is now safe to advance the persisted replay floor.
|
|
1638
|
+
await checkpointNonterminalFrame();
|
|
1515
1639
|
}
|
|
1516
1640
|
|
|
1517
1641
|
// Stream ended without a match: reconnect from the cursor.
|
|
@@ -1521,6 +1645,6 @@ export async function runWaitLoop({
|
|
|
1521
1645
|
}
|
|
1522
1646
|
const waitMs = backoff(reconnects);
|
|
1523
1647
|
const raced = await Promise.race([deadlinePromise, sleepFn(waitMs).then(() => ({ __retry: true }))]);
|
|
1524
|
-
if (raced && raced.__deadline) return
|
|
1648
|
+
if (raced && raced.__deadline) return fireReplayGraceExpired();
|
|
1525
1649
|
}
|
|
1526
1650
|
}
|