@botbuddy/cli 1.2.3 → 1.4.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.
@@ -0,0 +1,1266 @@
1
+ // BOT-989 / BOT-1344 — public `botbuddy wait` pure engine.
2
+ //
3
+ // Everything in this module is dependency-free (Node >= 20 stdlib only) and
4
+ // side-effect-free, so it runs in a fresh worktree with no `node_modules`
5
+ // (BOT-904 lesson) and is exhaustively unit-testable without a server, a
6
+ // database, or Docker. The network/main loop in scripts/bb-wait.mjs supplies a
7
+ // real `connect()`; the wait SEMANTICS live here and are driven by an injected
8
+ // `connect()` in tests.
9
+
10
+ export const SCHEMA_VERSION = 1;
11
+
12
+ // Stable process exit codes — the whole point of a silent-until-terminal wait
13
+ // is that the caller reads the exit code, not a stream of chatter.
14
+ export const EXIT = Object.freeze({
15
+ MATCHED: 0,
16
+ TIMEOUT: 2,
17
+ AUTH: 3,
18
+ INVALID: 4,
19
+ BACKEND: 5,
20
+ CURSOR_EXPIRED: 6,
21
+ INTERNAL: 7,
22
+ // BOT-1228: the wait was superseded server-side (a canonicalization / host rename
23
+ // marked its wait_session terminal). Distinct from a match or a plain timeout so the
24
+ // caller can tell "re-register under the new identity" apart from "your condition
25
+ // fired" — the harness re-invokes bb-wait on exit, which re-registers canonically.
26
+ SUPERSEDED: 8,
27
+ });
28
+
29
+ // BOT-1249: the internal CONTROL signal_type the reconciler emits when a wait's
30
+ // resource identity is canonicalized away. runWaitLoop treats a wait_superseded frame
31
+ // targeting its OWN wait_session as terminal (exit 8) before matchFrame sees it. It is
32
+ // deliberately NOT in SIGNAL_TYPES: that list is the USER-ARMABLE allowlist
33
+ // VALIDATORS.event checks, and a control signal must never be armable via
34
+ // `event:type=wait_superseded` — a sibling session's supersession (ignored by the
35
+ // own-session terminal branch because its wait_session_id differs) would otherwise match
36
+ // that raw event condition and exit an unrelated wait as an ordinary match (Codex P2).
37
+ export const WAIT_SUPERSEDED_SIGNAL_TYPE = "wait_superseded";
38
+
39
+ // The USER-ARMABLE spine signal_type allowlist, for the `event:` escape hatch. The full
40
+ // public.agent_signal_type enum is these PLUS the control types above
41
+ // (WAIT_SUPERSEDED_SIGNAL_TYPE) — control types are intentionally excluded here so they
42
+ // can't be armed on.
43
+ export const SIGNAL_TYPES = Object.freeze([
44
+ "ci_run",
45
+ "pr_review",
46
+ "pr_state",
47
+ "linear_issue",
48
+ "chat_message",
49
+ "lock_availability",
50
+ "container_capacity",
51
+ "test_run",
52
+ "guidance_response",
53
+ "staging_gate",
54
+ "snapshot",
55
+ "ticket_unblocked",
56
+ "stack_lease",
57
+ ]);
58
+
59
+ const LOCK_SUBTYPES = Object.freeze(["playwright_lane", "vite_port", "backend_port", "supabase_local"]);
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Condition grammar: `TYPE:key=val,key=val`
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function parseParams(raw) {
66
+ const params = {};
67
+ for (const pair of raw.split(",")) {
68
+ const trimmed = pair.trim();
69
+ if (trimmed === "") continue;
70
+ const eq = trimmed.indexOf("=");
71
+ if (eq === -1) {
72
+ params[trimmed] = true; // bare flag
73
+ continue;
74
+ }
75
+ params[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
76
+ }
77
+ return params;
78
+ }
79
+
80
+ function asBool(v) {
81
+ if (v === true) return true;
82
+ if (typeof v !== "string") return false;
83
+ return v === "true" || v === "1" || v === "yes";
84
+ }
85
+
86
+ // Validators return a normalized params object or throw an Error with a
87
+ // human-actionable message. One validator per condition type.
88
+ const VALIDATORS = {
89
+ timer(p) {
90
+ const hasDuration = p.duration !== undefined;
91
+ const hasDeadline = p.deadline !== undefined;
92
+ if (hasDuration === hasDeadline) {
93
+ throw new Error("timer needs exactly one of duration=<seconds> or deadline=<ISO>");
94
+ }
95
+ if (hasDuration) {
96
+ const n = Number(p.duration);
97
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
98
+ throw new Error("timer duration must be a positive integer number of seconds");
99
+ }
100
+ return { duration: n };
101
+ }
102
+ if (Number.isNaN(Date.parse(p.deadline))) {
103
+ throw new Error("timer deadline must be an ISO-8601 date-time");
104
+ }
105
+ return { deadline: p.deadline };
106
+ },
107
+ chat(p) {
108
+ if (!p.channel) throw new Error("chat needs channel=<id> or channel=*");
109
+ return { channel: String(p.channel), includeSelf: asBool(p.include_self) };
110
+ },
111
+ lock(p) {
112
+ if (!p.subtype) throw new Error("lock needs subtype=<" + LOCK_SUBTYPES.join("|") + ">");
113
+ if (!LOCK_SUBTYPES.includes(p.subtype)) {
114
+ throw new Error(`lock subtype must be one of ${LOCK_SUBTYPES.join(", ")}`);
115
+ }
116
+ if (!p.host) throw new Error("lock needs host=<hostname>");
117
+ // claim=true = ATOMIC claim-on-grant (BOT-1007): the server promotes the
118
+ // oldest live queued waiter to owner on release/reap and emits a grant signal
119
+ // targeted at that agent (payload.claim_granted=true), so the wait wakes
120
+ // already HOLDING the lock — no broadcast race. It presupposes the agent has
121
+ // already queued for the lock (acquire_lock while it was held); the grant
122
+ // promotes that queued row. claim=false (default) just waits for the lock to
123
+ // become free and leaves acquiring to the caller.
124
+ const claim = asBool(p.claim);
125
+ if (claim && p.slot === undefined) {
126
+ throw new Error("lock claim=true needs a specific slot=<n> (a grant hands you one named slot, not any-slot-on-host)");
127
+ }
128
+ return {
129
+ subtype: p.subtype,
130
+ host: String(p.host),
131
+ slot: p.slot !== undefined ? String(p.slot) : null,
132
+ claim,
133
+ };
134
+ },
135
+ "pr-state": (p) => parsePrTarget(p, "pr-state"),
136
+ "pr-review": (p) => parsePrTarget(p, "pr-review"),
137
+ // BOT-1063 — test-run: park on a test_run reaching a terminal status. The
138
+ // spine subject_key is `run:<id>` (mirrors the run:<id> convention noted on
139
+ // agent_signal_events.subject_key), so a condition just names the run id.
140
+ "test-run": (p) => {
141
+ if (!p.id) throw new Error("test-run needs id=<run_id>");
142
+ return { runId: String(p.id) };
143
+ },
144
+ // BOT-1063 — guidance: park on a command-post guidance RESPONSE to a request
145
+ // this agent raised (pairs with request_guidance). The signal is
146
+ // recipient-scoped to the requester and keyed by the request id.
147
+ guidance(p) {
148
+ if (!p.request) throw new Error("guidance needs request=<request_id>");
149
+ return { request: String(p.request) };
150
+ },
151
+ // BOT-1220 — lease: park on a batch stack lease (BOT-1218) leaving the queue /
152
+ // becoming active, so `botbuddy stack up` on a saturated host wakes zero-poll the
153
+ // instant it's granted in BOT-1187 order. The signal is recipient-scoped to the
154
+ // lease's owning agent and keyed `lease:<lease_id>` (mirrors the run:<id> shape).
155
+ lease(p) {
156
+ if (!p.id) throw new Error("lease needs id=<lease_id>");
157
+ return { id: String(p.id) };
158
+ },
159
+ // BOT-1064 — linear: wake on a logical state change of a named BOT-* issue.
160
+ // The spine subject_key is the issue identifier as stored on the tickets
161
+ // projection (upper-cased at write), so normalize to upper-case here too.
162
+ linear(p) {
163
+ if (!p.issue) throw new Error("linear needs issue=<KEY> (e.g. issue=BOT-123)");
164
+ const issue = String(p.issue).trim().toUpperCase();
165
+ if (!/^[A-Z][A-Z0-9]*-\d+$/.test(issue)) {
166
+ throw new Error("linear issue must be a ticket key like BOT-123");
167
+ }
168
+ return { issue };
169
+ },
170
+ // BOT-1065 — capacity: wake when a managed-container host reaches >= N free
171
+ // slots. Capacity is host-shared (no tenant), so `policy` is recorded for intent
172
+ // but matching is by free-slot threshold + optional host pin. `slots>=N` parses
173
+ // as the bare key `slots>` (parseParams splits on the first '='); `min_slots=N`
174
+ // is an alias. `stale_grace` bounds the wait: with no host beacon in the schema,
175
+ // if the threshold isn't reached within the grace the wait exits
176
+ // capacity_source_stale (degraded) rather than hanging forever.
177
+ capacity(p) {
178
+ if (!p.policy) throw new Error("capacity needs policy=<name>");
179
+ const rawMin = p["slots>"] ?? p.min_slots ?? p.slots;
180
+ if (rawMin === undefined) throw new Error("capacity needs slots>=<N> (e.g. slots>=1)");
181
+ const n = Number(rawMin);
182
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
183
+ throw new Error("capacity slots>= must be a positive integer");
184
+ }
185
+ const grace = p.stale_grace !== undefined ? Number(p.stale_grace) : 900;
186
+ if (!Number.isFinite(grace) || !Number.isInteger(grace) || grace <= 0) {
187
+ throw new Error("capacity stale_grace must be a positive integer number of seconds");
188
+ }
189
+ return {
190
+ policy: String(p.policy),
191
+ minSlots: n,
192
+ host: p.host !== undefined ? String(p.host) : null,
193
+ staleGraceSec: grace,
194
+ };
195
+ },
196
+ // BOT-1066 — ci: wake when a PR's CI reaches a terminal conclusion. Exactly one
197
+ // selector: scope=latest (follows the PR's latest run — needs pr=), run_id=<id>
198
+ // (pinned to one run), or sha=<sha> (pinned to a commit). repo= is always required.
199
+ ci(p) {
200
+ const repo = p.repo != null ? String(p.repo).trim() : null;
201
+ if (!repo || !repo.includes("/")) throw new Error("ci needs repo=<owner/repo>");
202
+ const selectors = ["scope", "run_id", "sha"].filter((k) => p[k] !== undefined);
203
+ if (selectors.length !== 1) {
204
+ throw new Error("ci needs exactly one of scope=latest | run_id=<id> | sha=<sha>");
205
+ }
206
+ if (p.scope !== undefined) {
207
+ if (p.scope !== "latest") throw new Error("ci scope must be 'latest' (or use run_id=/sha=)");
208
+ if (p.pr === undefined || !/^\d+$/.test(String(p.pr).trim())) {
209
+ throw new Error("ci scope=latest needs pr=<number>");
210
+ }
211
+ return { repo, scope: "latest", pr: String(p.pr).trim() };
212
+ }
213
+ if (p.run_id !== undefined) {
214
+ if (!String(p.run_id).trim()) throw new Error("ci run_id must be non-empty");
215
+ return { repo, scope: "run_id", runId: String(p.run_id).trim(), pr: p.pr != null ? String(p.pr).trim() : null };
216
+ }
217
+ if (!String(p.sha).trim()) throw new Error("ci sha must be non-empty");
218
+ return { repo, scope: "sha", sha: String(p.sha).trim(), pr: p.pr != null ? String(p.pr).trim() : null };
219
+ },
220
+ // BOT-1247 — unblocked: wake when a ticket's LAST open blocker clears (its
221
+ // open-blocker count transitions >0 → 0). Level-triggered: a ticket already
222
+ // unblocked (or never blocked) at registration is granted immediately with
223
+ // receipt reason `already_unblocked` (server-side eval_initial_wait_signals).
224
+ // Accepts the bare `unblocked:BOT-123` form the docs prescribe as well as the
225
+ // canonical `unblocked:ticket=BOT-123`. Tenant-scoped like `linear`.
226
+ unblocked(p) {
227
+ let ticket = p.ticket !== undefined ? String(p.ticket) : null;
228
+ if (ticket == null) {
229
+ // Bare form `unblocked:BOT-123` → parseParams yields { 'BOT-123': true }.
230
+ const bareKeys = Object.keys(p).filter((k) => p[k] === true);
231
+ if (bareKeys.length === 1) ticket = bareKeys[0];
232
+ }
233
+ if (!ticket) {
234
+ throw new Error("unblocked needs a ticket key, e.g. unblocked:BOT-123 (or unblocked:ticket=BOT-123)");
235
+ }
236
+ ticket = ticket.trim().toUpperCase();
237
+ if (!/^[A-Z][A-Z0-9]*-\d+$/.test(ticket)) {
238
+ throw new Error("unblocked ticket must be a ticket key like BOT-123");
239
+ }
240
+ return { ticket };
241
+ },
242
+ event(p) {
243
+ if (!p.type) throw new Error("event needs type=<signal_type>");
244
+ if (!SIGNAL_TYPES.includes(p.type)) {
245
+ throw new Error(`event type must be a known signal_type (${SIGNAL_TYPES.join(", ")})`);
246
+ }
247
+ return { type: p.type, subject: p.subject !== undefined ? String(p.subject) : null };
248
+ },
249
+ };
250
+
251
+ // pr-state / pr-review share a target grammar. The spine subject_key is
252
+ // `owner/repo#number` (github-webhook / pr_state read model convention), so a
253
+ // condition names an owner/repo and, optionally, a PR number. Accept three forms:
254
+ // repo=owner/repo,pr=123 — canonical
255
+ // pr=owner/repo#123 — combined convenience
256
+ // repo=owner/repo — repo wildcard: wake on ANY of your PRs there
257
+ // Omitting the number is the PR analogue of a lock condition without a slot.
258
+ function parsePrTarget(p, label) {
259
+ let repo = p.repo != null ? String(p.repo).trim() : null;
260
+ let number = p.pr != null ? String(p.pr).trim()
261
+ : (p.number != null ? String(p.number).trim() : null);
262
+ // A combined `owner/repo#123` may arrive via either key; split off the number.
263
+ const carrier = number != null && number.includes("#") ? "number"
264
+ : (repo != null && repo.includes("#") ? "repo" : null);
265
+ if (carrier === "number") {
266
+ const hash = number.indexOf("#");
267
+ if (!repo) repo = number.slice(0, hash).trim();
268
+ number = number.slice(hash + 1).trim();
269
+ } else if (carrier === "repo") {
270
+ const hash = repo.indexOf("#");
271
+ if (number == null) number = repo.slice(hash + 1).trim();
272
+ repo = repo.slice(0, hash).trim();
273
+ }
274
+ if (!repo || !repo.includes("/")) {
275
+ throw new Error(`${label} needs repo=<owner/repo> (optionally pr=<number>, or pr=<owner/repo#number>)`);
276
+ }
277
+ if (number != null && number !== "" && !/^\d+$/.test(number)) {
278
+ throw new Error(`${label} pr number must be an integer`);
279
+ }
280
+ return { repo, number: number != null && number !== "" ? number : null };
281
+ }
282
+
283
+ // Does a spine subject_key (`owner/repo#number`) satisfy a pr target?
284
+ function prSubjectMatches(subjectKey, params) {
285
+ if (typeof subjectKey !== "string") return false;
286
+ if (params.number != null) return subjectKey === `${params.repo}#${params.number}`;
287
+ return subjectKey.startsWith(`${params.repo}#`);
288
+ }
289
+
290
+ /**
291
+ * Parse and strictly validate a list of `TYPE:params` condition specs.
292
+ *
293
+ * On ANY validation failure, `conditions` is empty: partial arming is
294
+ * forbidden (a half-armed wait is worse than a rejected one). Each failure is
295
+ * reported with the offending spec and a message.
296
+ *
297
+ * @param {string[]} specs
298
+ * @returns {{conditions: object[], errors: {spec: string, message: string}[]}}
299
+ */
300
+ export function parseConditions(specs) {
301
+ const errors = [];
302
+ const conditions = [];
303
+
304
+ if (!Array.isArray(specs) || specs.length === 0) {
305
+ return { conditions: [], errors: [{ spec: "", message: "at least one condition is required" }] };
306
+ }
307
+
308
+ specs.forEach((spec, index) => {
309
+ const colon = String(spec).indexOf(":");
310
+ const type = (colon === -1 ? String(spec) : String(spec).slice(0, colon)).trim();
311
+ const rawParams = colon === -1 ? "" : String(spec).slice(colon + 1);
312
+
313
+ const validator = VALIDATORS[type];
314
+ if (!validator) {
315
+ errors.push({ spec, message: `unknown condition type: ${type || "(empty)"}` });
316
+ return;
317
+ }
318
+ try {
319
+ const params = validator(parseParams(rawParams));
320
+ conditions.push({ id: `c${index + 1}`, type, params });
321
+ } catch (err) {
322
+ errors.push({ spec, message: err.message });
323
+ }
324
+ });
325
+
326
+ // A claim=true lock must be the SOLE condition. Server-side, a grant atomically
327
+ // hands over the lock and marks the wait matched; but the client independently
328
+ // wakes on whichever frame arrives first, so an --any wait mixing a claim with a
329
+ // timer/chat/pr condition could wake (and exit) on the OTHER condition while the
330
+ // server has already granted the lock — leaving the agent silently holding it.
331
+ // There is no single arbitration point across the two deciders, so we forbid the
332
+ // combination: a claim wait blocks until it's handed the lock (with --timeout as
333
+ // the only escape). claim=false locks and every other type still combine freely.
334
+ if (errors.length === 0) {
335
+ const claims = conditions.filter((c) => c.type === "lock" && c.params.claim);
336
+ if (claims.length > 0 && conditions.length > 1) {
337
+ errors.push({
338
+ spec: claims[0] ? `${claims[0].type}:...claim=true` : "claim",
339
+ message: "a lock claim=true must be the only condition (it can't be raced against other conditions; use --timeout to bound the wait)",
340
+ });
341
+ }
342
+ }
343
+
344
+ // Fail-closed: never arm a subset.
345
+ if (errors.length > 0) return { conditions: [], errors };
346
+ return { conditions, errors };
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // SSE frame parsing — split-safe, keepalive-aware, multi-line data.
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /**
354
+ * Parse whatever complete SSE frames are present in `buffer`, returning them
355
+ * plus the unconsumed remainder (an incomplete trailing frame) to prepend to
356
+ * the next chunk. Comment/keepalive lines (starting `:`) are ignored.
357
+ *
358
+ * @param {string} buffer
359
+ * @returns {{frames: {id: string|null, event: string|null, data: string}[], rest: string}}
360
+ */
361
+ export function parseSseFrames(buffer) {
362
+ const frames = [];
363
+ // Frames are separated by a blank line.
364
+ let idx;
365
+ let remaining = buffer;
366
+ while ((idx = remaining.indexOf("\n\n")) !== -1) {
367
+ const block = remaining.slice(0, idx);
368
+ remaining = remaining.slice(idx + 2);
369
+
370
+ let id = null;
371
+ let event = null;
372
+ const dataLines = [];
373
+ for (const line of block.split("\n")) {
374
+ if (line === "" || line.startsWith(":")) continue; // keepalive / comment
375
+ const colon = line.indexOf(":");
376
+ const field = colon === -1 ? line : line.slice(0, colon);
377
+ let value = colon === -1 ? "" : line.slice(colon + 1);
378
+ if (value.startsWith(" ")) value = value.slice(1);
379
+ if (field === "id") id = value;
380
+ else if (field === "event") event = value;
381
+ else if (field === "data") dataLines.push(value);
382
+ }
383
+ if (dataLines.length > 0 || id !== null || event !== null) {
384
+ frames.push({ id, event, data: dataLines.join("\n") });
385
+ }
386
+ }
387
+ return { frames, rest: remaining };
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Cursor — the spine `seq` is a non-negative integer; reject garbage loudly.
392
+ // ---------------------------------------------------------------------------
393
+
394
+ /**
395
+ * @param {string|null|undefined} raw
396
+ * @returns {{kind: "absent"} | {kind: "ok", value: string} | {kind: "invalid", raw: string}}
397
+ */
398
+ export function normalizeSince(raw) {
399
+ if (raw == null) return { kind: "absent" };
400
+ const t = String(raw).trim();
401
+ if (t === "") return { kind: "absent" };
402
+ if (!/^\d+$/.test(t)) return { kind: "invalid", raw };
403
+ return { kind: "ok", value: String(BigInt(t)) };
404
+ }
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Backoff — exponential with full jitter, capped.
408
+ // ---------------------------------------------------------------------------
409
+
410
+ export function nextBackoff(attempt, { baseMs = 500, capMs = 15000, rng = Math.random } = {}) {
411
+ const exp = Math.min(capMs, baseMs * 2 ** Math.max(0, attempt - 1));
412
+ return Math.floor(rng() * (exp - baseMs)) + Math.min(baseMs, exp);
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Receipt — the single JSON line handed back at terminal.
417
+ // ---------------------------------------------------------------------------
418
+
419
+ export function buildReceipt({
420
+ waitSessionId,
421
+ outcome,
422
+ startedAt,
423
+ endedAt,
424
+ reconnects = 0,
425
+ degraded = [],
426
+ matched = [],
427
+ state = {},
428
+ nextCursor = "0",
429
+ error = null,
430
+ // BOT-1228 (Codex round-4 P2): the claim re-queue hint lives at the TOP LEVEL, not in
431
+ // `state`, so truncateReceipt (which can collapse `state` to a marker under a small
432
+ // --receipt-max-bytes) never strips the only machine-readable "re-acquire the lock"
433
+ // instruction. It is tiny — a claim=true lock must be the SOLE condition, so this is
434
+ // at most one {subtype,host,slot}.
435
+ reacquireLock = null,
436
+ }) {
437
+ const r = {
438
+ schema_version: SCHEMA_VERSION,
439
+ wait_session_id: waitSessionId ?? null,
440
+ outcome,
441
+ started_at: startedAt ?? null,
442
+ ended_at: endedAt ?? null,
443
+ reconnects,
444
+ degraded,
445
+ matched,
446
+ state,
447
+ next_cursor: nextCursor,
448
+ };
449
+ if (error) r.error = error;
450
+ if (Array.isArray(reacquireLock) && reacquireLock.length > 0) r.reacquire_lock = reacquireLock;
451
+ return r;
452
+ }
453
+
454
+ /**
455
+ * Enforce the receipt byte cap. Oversized matched-payloads collapse to a
456
+ * pointer (the `seq`, plus a `payload_truncated` marker); the caller re-fetches
457
+ * detail with the existing get_* tools if it needs the body.
458
+ */
459
+ export function truncateReceipt(receipt, maxBytes = 10240) {
460
+ if (Buffer.byteLength(JSON.stringify(receipt)) <= maxBytes) return receipt;
461
+ const clone = JSON.parse(JSON.stringify(receipt));
462
+ for (const m of clone.matched ?? []) {
463
+ if (m.payload !== undefined) {
464
+ delete m.payload;
465
+ m.payload_truncated = true;
466
+ }
467
+ }
468
+ // If still over cap, drop the (potentially large) state snapshot to a marker.
469
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes) {
470
+ clone.state = { truncated: true };
471
+ }
472
+ // A matched signal's subject key can be caller-controlled through the raw
473
+ // event escape hatch. Keep the durable sequence pointer, but not an
474
+ // unbounded subject string, in a receipt promised to a bounded harness.
475
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && Array.isArray(clone.matched)) {
476
+ for (const match of clone.matched) {
477
+ if (typeof match?.subject_key === "string" && Buffer.byteLength(match.subject_key, "utf8") > 128) {
478
+ delete match.subject_key;
479
+ match.subject_key_truncated = true;
480
+ }
481
+ }
482
+ }
483
+ // Metadata added by public consumers (for example the CLI identity and
484
+ // tenant-attested principal) can push a previously-capped receipt just over
485
+ // the caller's boundary. Trim only null/default diagnostics next, preserving
486
+ // the terminal outcome and match pointer.
487
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.next_cursor == null) delete clone.next_cursor;
488
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.wait_session_id == null) delete clone.wait_session_id;
489
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && Array.isArray(clone.degraded) && clone.degraded.length === 0) {
490
+ delete clone.degraded;
491
+ }
492
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.reconnects === 0) delete clone.reconnects;
493
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && Array.isArray(clone.matched)) {
494
+ clone.matched = clone.matched.map((match) => ({
495
+ condition_id: match?.condition_id ?? null,
496
+ signal_type: typeof match?.signal_type === "string" && Buffer.byteLength(match.signal_type, "utf8") <= 128
497
+ ? match.signal_type
498
+ : undefined,
499
+ seq: match?.seq ?? null,
500
+ match_truncated: true,
501
+ }));
502
+ }
503
+ // Argument and registration failures are receipts too. Their nested errors
504
+ // or server detail may contain arbitrary caller-controlled strings, so reduce
505
+ // those before handing one line back to a bounded agent harness.
506
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && Array.isArray(clone.errors)) {
507
+ delete clone.errors;
508
+ clone.errors_truncated = true;
509
+ }
510
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && typeof clone.detail === "string") {
511
+ delete clone.detail;
512
+ clone.detail_truncated = true;
513
+ }
514
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "error") {
515
+ return {
516
+ schema_version: clone.schema_version,
517
+ outcome: "error",
518
+ error: typeof clone.error === "string" ? clone.error.slice(0, 48) : "receipt_truncated",
519
+ truncated: true,
520
+ ...(clone.client ? { client: clone.client } : {}),
521
+ };
522
+ }
523
+ return clone;
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // BOT-1147 — feed-lag degradation marker (add/clear).
528
+ // ---------------------------------------------------------------------------
529
+
530
+ /**
531
+ * Fold a Linear-feed-freshness reading into the receipt's `degraded` array,
532
+ * IN PLACE. `linear_feed_lagging` is added when the ingest feed is stale and
533
+ * REMOVED when it recovers — so a long-lived wait reports a lagging feed while
534
+ * it lags and stops reporting it on recovery, WITHOUT re-registration (BOT-1147
535
+ * AC-3). Idempotent: repeated same-state readings don't duplicate or thrash.
536
+ * @param {string[]} degraded the receipt's degraded markers (mutated)
537
+ * @param {boolean|null|undefined} lagging true = stale, false = fresh, and
538
+ * null/undefined = INDETERMINATE (a failed probe): leave the last known state
539
+ * untouched rather than clearing it (Codex BOT-1147 P2 — a transient probe
540
+ * outage must not make a known-stale feed look healthy).
541
+ */
542
+ export function foldFeedLag(degraded, lagging) {
543
+ if (lagging !== true && lagging !== false) return degraded; // indeterminate: no change
544
+ const i = degraded.indexOf("linear_feed_lagging");
545
+ if (lagging && i === -1) degraded.push("linear_feed_lagging");
546
+ else if (!lagging && i !== -1) degraded.splice(i, 1);
547
+ return degraded;
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // Matching — does a spine signal frame satisfy a condition?
552
+ // ---------------------------------------------------------------------------
553
+
554
+ function decodeSignal(frame) {
555
+ try {
556
+ return JSON.parse(frame.data);
557
+ } catch {
558
+ return null;
559
+ }
560
+ }
561
+
562
+ function conditionMatchesSignal(condition, signal, waitSessionId) {
563
+ const { type, params } = condition;
564
+ switch (type) {
565
+ case "timer":
566
+ return false; // timers fire on the deadline, never on a frame
567
+ case "chat":
568
+ return signal.signal_type === "chat_message" &&
569
+ (params.channel === "*" || signal.subject_key === params.channel);
570
+ case "lock": {
571
+ if (signal.signal_type !== "lock_availability") return false;
572
+ // A grant signal (claim_granted=true) is the server handing this specific
573
+ // waiter the lock; a plain availability is a broadcast. claim=true wants
574
+ // ONLY the grant; claim=false wants ONLY the broadcast. The grant is also
575
+ // recipient-scoped at the relay, so a broadcast waiter never even receives
576
+ // another agent's grant — this is the defense-in-depth in-code check.
577
+ const isGrant = signal.payload?.claim_granted === true;
578
+ if (params.claim !== isGrant) return false;
579
+ // A grant belongs to the exact wait_session it was issued for. `--since`
580
+ // can replay an OLDER grant for the same agent+lock (a prior, now-terminal
581
+ // wait); accepting it would let this new registration exit "holding" a lock
582
+ // it was never handed. Require the grant's wait_session_id to equal THIS
583
+ // registration's before treating it as ownership (Codex P1).
584
+ if (isGrant && signal.payload?.wait_session_id !== waitSessionId) return false;
585
+ if (params.slot != null) {
586
+ return signal.subject_key === `${params.subtype}:${params.host}:${params.slot}`;
587
+ }
588
+ // No slot ⇒ any slot on that host+subtype.
589
+ return signal.subject_key.startsWith(`${params.subtype}:${params.host}:`);
590
+ }
591
+ case "pr-state":
592
+ return signal.signal_type === "pr_state" && prSubjectMatches(signal.subject_key, params);
593
+ case "pr-review":
594
+ return signal.signal_type === "pr_review" && prSubjectMatches(signal.subject_key, params);
595
+ case "test-run":
596
+ return signal.signal_type === "test_run" && signal.subject_key === `run:${params.runId}`;
597
+ case "guidance":
598
+ return signal.signal_type === "guidance_response" && signal.subject_key === params.request;
599
+ case "lease":
600
+ // BOT-1220: recipient-scoped stack-lease transition (queued→provisioning, or
601
+ // →active). The relay only delivers it to the lease's owning agent, so any
602
+ // stack_lease frame for this lease id is ours to wake on.
603
+ return signal.signal_type === "stack_lease" && signal.subject_key === `lease:${params.id}`;
604
+ case "linear": {
605
+ if (signal.signal_type !== "linear_issue") return false;
606
+ // Identity (BOT-1260): prefer the STABLE Linear issue UUID when BOTH the wait
607
+ // (stamped at registration from the current `tickets` row) and the signal (emit
608
+ // payload) carry it, so an in-flight wait survives an external_id rename / team move
609
+ // (rename-proof) and a NEW issue that recycled the old key can't wake it (recycle-proof
610
+ // — a UUID mismatch beats key equality and short-circuits). Fall back to subject_key
611
+ // equality for a legacy signal row without `payload.linear_issue_id` (a `--since` replay
612
+ // of pre-migration rows) or an unresolvable registration that stamped no UUID (the key is
613
+ // not yet mirrored in `tickets`) — additive and deploy-order independent. Cross-tenant
614
+ // isolation rides on the BOT-1259 central guard (sessionTenant), so — unlike `unblocked`
615
+ // — a `linear` wait is NEVER failed closed on a missing stamp (back-compat: it worked
616
+ // key-only before this ticket). Mirrors the `unblocked` matcher shape below.
617
+ if (params.linear_issue_id != null && signal.payload?.linear_issue_id != null) {
618
+ return signal.payload.linear_issue_id === params.linear_issue_id;
619
+ }
620
+ return signal.subject_key === params.issue;
621
+ }
622
+ case "capacity": {
623
+ if (signal.signal_type !== "container_capacity") return false;
624
+ // Optional host pin: match the subject (host_key) or the payload host_key.
625
+ if (params.host != null && signal.subject_key !== params.host && signal.payload?.host_key !== params.host) {
626
+ return false;
627
+ }
628
+ // BOT-1148: a beacon-driven staleness transition (source_stale:true) is a
629
+ // dead-host signal, never a grant — a silently-dead host's derived free_slots
630
+ // cannot actually be served. The wait loop handles it separately (fold the
631
+ // capacity_source_stale marker / fast exit); it must not read as capacity here.
632
+ if (signal.payload?.source_stale === true) return false;
633
+ const free = Number(signal.payload?.free_slots);
634
+ return Number.isFinite(free) && free >= params.minSlots;
635
+ }
636
+ case "ci": {
637
+ if (signal.signal_type !== "ci_run") return false;
638
+ // Repo is pinned via the payload (subject may be run:<id> for uncorrelated runs).
639
+ if (String(signal.payload?.repo ?? "").toLowerCase() !== params.repo.toLowerCase()) return false;
640
+ if (params.scope === "run_id") return String(signal.payload?.run_id) === params.runId;
641
+ if (params.scope === "sha") return signal.payload?.head_sha === params.sha;
642
+ // scope=latest: the PR's run (by subject owner/repo#pr or payload pr_number).
643
+ return signal.subject_key === `${params.repo}#${params.pr}` ||
644
+ (signal.payload?.pr_number != null && String(signal.payload.pr_number) === params.pr);
645
+ }
646
+ case "unblocked": {
647
+ // Wakes on the ticket_unblocked spine signal for this ticket (the server emits both
648
+ // the genuine >0→0 transition and the level-triggered `already_unblocked` echo).
649
+ if (signal.signal_type !== "ticket_unblocked") return false;
650
+ // Tenant scope, FAIL CLOSED (Codex round-9 P1 + round-10 P1): two workspaces can each
651
+ // hold a ticket keyed `BOT-123`, and the relay legitimately streams ticket_unblocked
652
+ // from BOTH (the caller is authorized for both). The server stamps the wait's resolved
653
+ // workspace onto `params.tenant`; require the signal's tenant to match it. If NO tenant
654
+ // was stamped — an unblocked wait that armed untracked after a transient register_failed,
655
+ // or an older relay that echoes none — do NOT fall back to subject-only matching (that
656
+ // would wake on the wrong workspace's unblock): no tenant ⇒ no match.
657
+ if (params.tenant == null || signal.tenant_id !== params.tenant) return false;
658
+ // Identity (Codex round-10 P2): prefer the STABLE Linear issue UUID when both the wait
659
+ // (stamped at registration) and the signal (genuine emit payload) carry it, so an
660
+ // in-flight wait survives an external_id rename (issue moved Linear teams). The
661
+ // initial-eval echo carries no linear_issue_id and has no rename risk (it is
662
+ // immediate), so it falls back to the ticket key.
663
+ if (params.linear_issue_id != null && signal.payload?.linear_issue_id != null) {
664
+ return signal.payload.linear_issue_id === params.linear_issue_id;
665
+ }
666
+ return signal.subject_key === params.ticket;
667
+ }
668
+ case "event":
669
+ return signal.signal_type === params.type &&
670
+ (params.subject == null || signal.subject_key === params.subject);
671
+ default:
672
+ return false;
673
+ }
674
+ }
675
+
676
+ // BOT-1259: the ONE central tenant guard, applied before per-type dispatch. A
677
+ // TENANTED spine signal (tenant_id set — linear_issue, ticket_unblocked, and any
678
+ // tenant-stamped pr_review/ci_run) may only match a wait session resolved to that
679
+ // SAME tenant; a session whose tenant is unknown (null) or different fails closed,
680
+ // so no signal type — current or future, including the `event:` escape hatch — can
681
+ // wake the wrong workspace. This centralizes what BOT-1247's `unblocked` matcher did
682
+ // per-type (that per-type check stays as belt-and-braces). Tenantless signals
683
+ // (host-shared lock_availability/container_capacity, owner/recipient-scoped
684
+ // chat/pr_state/test_run/guidance) are NEVER blocked — they keep today's semantics
685
+ // for any session (AC-5). `sessionTenant === undefined` opts a caller out of the
686
+ // central guard: a pure unit test asserting a per-type matcher in isolation. The CLI
687
+ // always threads the server-resolved tenant, so the guard is live for every real wait.
688
+ export function signalTenantBlocked(signal, sessionTenant) {
689
+ if (sessionTenant === undefined) return false;
690
+ if (signal.tenant_id == null) return false;
691
+ // Mirror the relay's precedence (recipient > owner > tenant): a signal that is
692
+ // owner- or recipient-scoped keeps THAT isolation and is never tenant-fenced, even
693
+ // though owner-scoped pr_review/ci_run also carry a tenant_id — otherwise a caller who
694
+ // owns PRs in two workspaces would lose the authorized event for the workspace the wait
695
+ // didn't bind to (Codex R2-F1). `scope` is stamped by the relay; when it is absent (a
696
+ // pre-BOT-1259 relay, deploy skew), fall back to fencing any tenanted signal — fail closed.
697
+ if (signal.scope === "owner" || signal.scope === "recipient") return false;
698
+ return signal.tenant_id !== sessionTenant;
699
+ }
700
+
701
+ /**
702
+ * Return the first condition satisfied by this signal frame, or null.
703
+ * `waitSessionId` scopes claim grants to the current registration.
704
+ * `sessionTenant` (BOT-1259) is the wait session's server-resolved tenant; a
705
+ * tenanted signal only matches when it equals it (fail closed on null/mismatch).
706
+ * Omit it (undefined) to skip the central guard — see `signalTenantBlocked`.
707
+ * @returns {object|null}
708
+ */
709
+ export function matchFrame(frame, conditions, waitSessionId, sessionTenant) {
710
+ const signal = decodeSignal(frame);
711
+ if (!signal) return null;
712
+ if (signalTenantBlocked(signal, sessionTenant)) return null;
713
+ for (const c of conditions) {
714
+ if (conditionMatchesSignal(c, signal, waitSessionId)) return c;
715
+ }
716
+ return null;
717
+ }
718
+
719
+ // ---------------------------------------------------------------------------
720
+ // The wait loop.
721
+ // ---------------------------------------------------------------------------
722
+
723
+ const nowIso = () => new Date().toISOString();
724
+
725
+ // BOT-1147 (Codex P2): hard bound on how long a single Linear feed-freshness probe may
726
+ // delay the wait — it runs before connecting and on every terminal finalize, outside the
727
+ // deadline race, so an unbounded probe would blow past a short --timeout.
728
+ const FEED_PROBE_BUDGET_MS = 2000;
729
+
730
+ /**
731
+ * Run one wait to a terminal outcome, driven by an injected `connect(since)`
732
+ * that returns an async iterable of parsed SSE frames.
733
+ *
734
+ * Guarantees exercised by the tests:
735
+ * - wakes on the first matching frame (MATCHED)
736
+ * - fires on the wall deadline with a snapshot receipt (TIMEOUT)
737
+ * - reconnects when the stream ends, resuming from the last seen seq, and
738
+ * de-duplicates a repeated seq across the reconnect (exactly-once)
739
+ * - maps server error frames to AUTH / CURSOR_EXPIRED
740
+ * - gives up with BACKEND after the reconnect budget is exhausted
741
+ *
742
+ * @returns {Promise<{receipt: object, exitCode: number}>}
743
+ */
744
+ /**
745
+ * Convert the `timer:` conditions into absolute-time alarms. A timer condition
746
+ * is a thing the caller ASKED to wake on, so it resolves to `matched` (exit 0) —
747
+ * distinct from the `--timeout` safety cap, which is `timeout` (exit 2).
748
+ * @returns {{conditionId: string, atMs: number}[]}
749
+ */
750
+ export function timerAlarms(conditions, now = Date.now) {
751
+ const alarms = [];
752
+ for (const c of conditions) {
753
+ if (c.type !== "timer") continue;
754
+ if (c.params.duration != null) alarms.push({ conditionId: c.id, atMs: now() + c.params.duration * 1000 });
755
+ else if (c.params.deadline != null) alarms.push({ conditionId: c.id, atMs: Date.parse(c.params.deadline) });
756
+ }
757
+ return alarms;
758
+ }
759
+
760
+ export async function runWaitLoop({
761
+ waitSessionId,
762
+ conditions,
763
+ deadlineMs = null,
764
+ timers = null,
765
+ since = null,
766
+ connect,
767
+ backoff = (attempt) => nextBackoff(attempt),
768
+ sleep = null,
769
+ onFrame = null,
770
+ // BOT-1259: the wait session's server-resolved tenant (from the register echo). A
771
+ // tenanted spine signal only matches a session resolved to that tenant; `null` (the
772
+ // session's tenant could not be resolved) fails closed, and `undefined` (the caller
773
+ // did not thread a tenant) leaves the central guard off — see `signalTenantBlocked`.
774
+ sessionTenant = undefined,
775
+ // BOT-1259 AC-4: optional sink (msg:string) => void for the fail-closed drop line —
776
+ // a debug diagnostic, never an error. The CLI wires a stderr writer gated on
777
+ // BB_WAIT_DEBUG; tests inject a collector to assert the line.
778
+ debug = null,
779
+ maxReconnects = 50,
780
+ receiptMaxBytes = 10240,
781
+ now = Date.now,
782
+ // BOT-1147 AC-3: a `linear` wait truthfully degrades when the Linear webhook
783
+ // ingest feed stalls (the BOT-1016 silent-ingest failure mode). `feedLagProbe`
784
+ // is an async () => boolean (true = feed stale). It is polled once at start and
785
+ // on every (re)connect, and its reading folded into `degraded` (add on stale,
786
+ // clear on recovery). Injected so the CLI wires the real freshness query and a
787
+ // test drives it clock-controlled; omitted (null) = no probing.
788
+ feedLagProbe = null,
789
+ // BOT-1148: after a beacon-driven source_stale:true transition on a HOST-PINNED
790
+ // capacity wait, wait this long for a recovery (source_stale:false) before exiting
791
+ // capacity_source_stale. Absorbs threshold flap on the client (the server latch is
792
+ // the primary hysteresis); 0 = exit immediately on the stale signal. Injectable so
793
+ // a clock-controlled test can drive it. Not a grammar surface (no new condition name).
794
+ //
795
+ // Default MUST exceed the beacon evaluator's worst-case cadence (Codex P2). The
796
+ // recovery transition is emitted by the `* * * * *` (60s) eval_container_beacon_staleness
797
+ // cron, and claim_container_job only refreshes last_beacon_at — it does not itself emit
798
+ // recovery — so a host that resumes polling right after a stale emission has its
799
+ // recovery frame arrive up to ~60s later. A confirm shorter than that would exit the
800
+ // wait capacity_source_stale even though the host recovered seconds after the stale
801
+ // frame. 90s = 60s cron + margin.
802
+ beaconConfirmMs = 90_000,
803
+ }) {
804
+ const startedAt = nowIso();
805
+ const seen = new Set();
806
+ // An omitted --since arms from the current high-water mark (live only): the
807
+ // relay must NOT replay history, or a brand-new wait would immediately match a
808
+ // long-past lock release. Only an explicit --since replays (cursor != null).
809
+ const sinceParsed = normalizeSince(since);
810
+ let cursor = sinceParsed.kind === "ok" ? sinceParsed.value : null;
811
+ const degraded = [];
812
+ let reconnects = 0;
813
+ let failures = 0;
814
+
815
+ // Internal one-shot alarms (the deadline, the pre-deadline feed probe, reconnect backoff)
816
+ // are REAL timers via the default sleep. Each exists only to resolve a race — once the loop
817
+ // terminates for any other reason (a granted frame, an error), a still-pending alarm timer
818
+ // would keep the event loop open until it expires. The CLI masks this with process.exit,
819
+ // but `pnpm test:bb-wait` and any library caller stay alive for the full window (Codex
820
+ // round-40 P2 measured ~92s vs ~6.5s). So track every default-sleep timer and clear the set
821
+ // at finalize. NOT unref'd: a pure --timeout wait (no signal, a test stream that refs
822
+ // nothing) relies on the deadline timer keeping the loop alive until it fires. An injected
823
+ // `sleep` (clock-controlled tests) resolves instantly and creates no timer to track.
824
+ const pendingAlarmTimers = new Set();
825
+ const sleepFn = sleep ?? ((ms) => new Promise((resolve) => {
826
+ const timer = setTimeout(() => { pendingAlarmTimers.delete(timer); resolve(); }, ms);
827
+ pendingAlarmTimers.add(timer);
828
+ }));
829
+ const clearPendingAlarmTimers = () => {
830
+ for (const timer of pendingAlarmTimers) clearTimeout(timer);
831
+ pendingAlarmTimers.clear();
832
+ };
833
+
834
+ const finalize = async (outcome, extra = {}) => {
835
+ // A terminal receipt supersedes any pending alarm/confirm window — cancel their timers so
836
+ // none outlives the wait and holds the event loop open (Codex round-40 P2). clearStale
837
+ // ConfirmTimer is defined further down but only reached at runtime, after setup completes.
838
+ clearPendingAlarmTimers();
839
+ clearStaleConfirmTimer?.();
840
+ // BOT-1147 (Codex P2): re-check Linear feed freshness before EVERY terminal receipt
841
+ // (timeout, capacity-stale, signal match, error) via this single finalization path —
842
+ // not just the alarm path — so a mixed wait (e.g. `linear` + `pr-review`) that
843
+ // terminates on the OTHER signal while Linear ingestion stalled still reports
844
+ // linear_feed_lagging. Cheap no-op unless a `linear` condition is armed; a failed
845
+ // probe leaves the last known lag state (foldFeedLag treats it as indeterminate).
846
+ await probeFeedLag();
847
+ const receipt = buildReceipt({
848
+ waitSessionId,
849
+ outcome,
850
+ startedAt,
851
+ endedAt: nowIso(),
852
+ reconnects,
853
+ degraded,
854
+ matched: extra.matched ?? [],
855
+ state: extra.state ?? { conditions: conditions.map((c) => ({ condition_id: c.id, type: c.type })) },
856
+ nextCursor: cursor,
857
+ error: extra.error ?? null,
858
+ reacquireLock: extra.reacquireLock ?? null,
859
+ });
860
+ return { receipt: truncateReceipt(receipt, receiptMaxBytes), exitCode: extra.exitCode };
861
+ };
862
+
863
+ // Fold the timer conditions and the --timeout cap into one earliest alarm.
864
+ // A timer condition firing is a MATCH (exit 0); the --timeout cap is a
865
+ // TIMEOUT (exit 2). With --any the earliest of the two always wins.
866
+ const alarms = (timers ?? timerAlarms(conditions, now))
867
+ .filter((a) => Number.isFinite(a.atMs))
868
+ .map((a) => ({ ...a, kind: "match" }));
869
+ if (deadlineMs != null) alarms.push({ kind: "timeout", atMs: deadlineMs });
870
+ // BOT-1065: a capacity condition degrades to a BOUNDED stale exit rather than
871
+ // hanging. There is no host self-report beacon in the schema (capacity is derived
872
+ // from permits), so we cannot observe silent helper death directly; the grace
873
+ // window is the truthful bound — if the requested free-slot threshold isn't
874
+ // reached within it, the wait exits with a capacity_source_stale error receipt
875
+ // (degraded) on an EXISTING exit code (5), never a new numeric code (AC 3).
876
+ for (const c of conditions) {
877
+ if (c.type === "capacity" && c.params.staleGraceSec != null) {
878
+ alarms.push({ kind: "stale", conditionId: c.id, atMs: now() + c.params.staleGraceSec * 1000 });
879
+ }
880
+ }
881
+ const earliest = alarms.length ? alarms.reduce((m, a) => (a.atMs < m.atMs ? a : m)) : null;
882
+
883
+ const fireAlarm = async () => {
884
+ // The terminal feed-freshness reprobe now lives in finalize() (the single
885
+ // finalization path), so every terminal receipt gets it — not just the alarm path.
886
+ if (!earliest) return null;
887
+ if (earliest.kind === "timeout") return finalize("timeout", { exitCode: EXIT.TIMEOUT });
888
+ if (earliest.kind === "stale") {
889
+ // Capacity source didn't deliver within the grace: degrade truthfully and
890
+ // exit with an error receipt (not a silent hang, not a plain timeout).
891
+ if (!degraded.includes("capacity_source_stale")) degraded.push("capacity_source_stale");
892
+ return finalize("error", { exitCode: EXIT.BACKEND, error: "capacity_source_stale" });
893
+ }
894
+ const condition = conditions.find((c) => c.id === earliest.conditionId);
895
+ return finalize("matched", {
896
+ exitCode: EXIT.MATCHED,
897
+ matched: [{
898
+ condition_id: earliest.conditionId,
899
+ signal_type: "timer",
900
+ seq: null,
901
+ subject_key: condition?.params?.deadline ?? `+${condition?.params?.duration}s`,
902
+ provenance: "timer",
903
+ }],
904
+ });
905
+ };
906
+
907
+ // A single promise that resolves when the earliest alarm passes.
908
+ const deadlinePromise = earliest == null
909
+ ? new Promise(() => {}) // no alarm: only a pushed signal can wake us
910
+ : sleepFn(Math.max(0, earliest.atMs - now())).then(() => ({ __deadline: true }));
911
+
912
+ // BOT-1147 AC-3: only probe when a linear condition is armed and a probe is
913
+ // wired — every other wait type is unaffected.
914
+ const feedLagActive = !!feedLagProbe && conditions.some((c) => c.type === "linear");
915
+
916
+ // BOT-1147 (Codex round-27 P2): the finalize() reprobe is deadline-relative and so is
917
+ // SKIPPED on a plain timeout (budget 0 at the deadline). If the feed is fresh at
918
+ // registration then stalls while the SSE connection stays open (no reconnect), the
919
+ // timeout receipt would keep the stale-unaware initial state. So schedule ONE probe
920
+ // FEED_PROBE_BUDGET_MS before the earliest terminal — it observes a mid-wait stall
921
+ // (or recovery) with a full budget and folds it, and the timeout still fires on time.
922
+ // Fires at most once (a single pre-deadline probe, not a re-arming timer, so an
923
+ // injected instant `sleep` in tests can't spin the race).
924
+ let preDeadlineProbeDone = false;
925
+ const preDeadlineProbePromise = (feedLagActive && earliest != null)
926
+ ? sleepFn(Math.max(0, earliest.atMs - now() - FEED_PROBE_BUDGET_MS)).then(() => ({ __feedProbe: true }))
927
+ : null;
928
+ // The specific issue key(s) the armed `linear` conditions target. Threaded into
929
+ // the probe so it scopes freshness to those issues' workspace(s) rather than the
930
+ // caller's whole membership union — a multi-tenant caller's fresh tenant A must
931
+ // not mask a stalled tenant B when the armed issue belongs to B (Codex round-22 P2).
932
+ const linearIssues = conditions
933
+ .filter((c) => c.type === "linear")
934
+ .map((c) => c.params?.issue)
935
+ .filter((x) => typeof x === "string" && x.length > 0);
936
+ const probeFeedLag = async () => {
937
+ if (!feedLagActive) return;
938
+ // Bound the probe DEADLINE-RELATIVELY (Codex P2): it is awaited before connecting AND on
939
+ // every terminal finalize, OUTSIDE the deadline race, so an unbounded/fixed-bounded probe
940
+ // could push even a --timeout 50ms receipt seconds past its deadline. Cap each probe to
941
+ // min(FEED_PROBE_BUDGET_MS, time remaining to the soonest terminal) and SKIP it entirely
942
+ // once the deadline has already passed (e.g. the terminal reprobe after a timeout). Uses a
943
+ // REAL timer (not the injected sleep) so clock-controlled tests with an instant probe are
944
+ // unaffected; on expiry the reading is indeterminate and the last known lag state is kept.
945
+ const target = earliest ? earliest.atMs : (deadlineMs != null ? deadlineMs : null);
946
+ const remaining = target != null ? target - now() : FEED_PROBE_BUDGET_MS;
947
+ const budgetMs = Math.max(0, Math.min(FEED_PROBE_BUDGET_MS, remaining));
948
+ if (budgetMs <= 0) return; // deadline reached — do not overrun it; leave the last state
949
+ let timer;
950
+ const budget = new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), budgetMs); });
951
+ try {
952
+ foldFeedLag(degraded, await Promise.race([feedLagProbe(linearIssues), budget]));
953
+ } catch {
954
+ // A probe failure must not break the wait — leave the last known state.
955
+ } finally {
956
+ clearTimeout(timer);
957
+ }
958
+ };
959
+
960
+ // BOT-1148: server-side, beacon-driven capacity staleness. On a container_capacity
961
+ // frame carrying source_stale:true whose host matches a HOST-PINNED capacity wait,
962
+ // fold capacity_source_stale and — unless a recovery (source_stale:false) arrives
963
+ // within beaconConfirmMs — exit 5 fast (server-driven) instead of grinding the full
964
+ // client stale_grace backstop. Un-pinned capacity waits ignore a single host's
965
+ // beacon signal: another live host may still serve them (AC-4), so they keep only
966
+ // the grace backstop. A recovery clears the marker and lets the wait continue
967
+ // without re-registration (AC-3), and may itself grant if it carries free_slots.
968
+ const hostPinnedCapacity = conditions.filter((c) => c.type === "capacity" && c.params.host != null);
969
+ // PER-HOST stale-confirm state (Codex P2): keyed by the pinned host_key. A recovery
970
+ // from host B must never cancel host A's pending confirm — with a single shared timer,
971
+ // an --any wait pinned to multiple hosts would miss A's fast-fail when B recovers.
972
+ const staleConfirm = new Map(); // hostKey -> atMs the confirm window elapses
973
+ let staleConfirmPromise = null; // races the EARLIEST pending confirm across hosts
974
+ let staleConfirmTimer = null; // handle for that race's real timer, so it can be cancelled
975
+ // Cancel any pending confirm timer. The race timer is a REAL setTimeout (not the injected
976
+ // `sleep`) precisely so it is cancellable: re-arming, a host recovery, or a terminal
977
+ // finalize supersedes the pending confirm, and an ORPHANED confirm timer would otherwise
978
+ // keep the process (and the checked-in `pnpm test:bb-wait` lane) alive for the full
979
+ // beaconConfirmMs window — the parent suite ran ~6.5s, this leak made it ~92s (Codex
980
+ // round-40 P2). Same "real timer, tracked so it can be cleared" discipline as the alarm
981
+ // timers above.
982
+ const clearStaleConfirmTimer = () => {
983
+ if (staleConfirmTimer != null) { clearTimeout(staleConfirmTimer); staleConfirmTimer = null; }
984
+ };
985
+ const beaconHostKey = (signal) => {
986
+ const match = hostPinnedCapacity.find(
987
+ (c) => c.params.host === signal.subject_key || c.params.host === signal.payload?.host_key,
988
+ );
989
+ return match ? match.params.host : null;
990
+ };
991
+ const armStaleConfirmRace = () => {
992
+ clearStaleConfirmTimer(); // supersede any prior pending confirm — never leak its timer
993
+ if (staleConfirm.size === 0) { staleConfirmPromise = null; return; }
994
+ const soonest = Math.min(...staleConfirm.values());
995
+ staleConfirmPromise = new Promise((resolve) => {
996
+ staleConfirmTimer = setTimeout(() => {
997
+ staleConfirmTimer = null;
998
+ resolve({ __staleConfirm: true });
999
+ }, Math.max(0, soonest - now()));
1000
+ });
1001
+ };
1002
+ // Returns "continue" (skip this frame), "fallthrough" (let matchFrame see it), or
1003
+ // "exit" (terminate immediately on a stale signal when beaconConfirmMs<=0).
1004
+ const applyBeaconStale = (signal) => {
1005
+ const stale = signal.payload.source_stale === true;
1006
+ const hostKey = beaconHostKey(signal);
1007
+ // Not a host this wait pins a `capacity` condition to (un-pinned / other host):
1008
+ // fall through to matchFrame instead of swallowing the frame. A raw
1009
+ // `event:type=container_capacity,subject=<host>` escape-hatch condition must be
1010
+ // able to observe beacon-stale transitions (Codex round-23 P2); the `capacity`
1011
+ // matcher already refuses to grant on a source_stale frame, so no false grant.
1012
+ if (hostKey == null) return "fallthrough";
1013
+ if (stale) {
1014
+ if (!degraded.includes("capacity_source_stale")) degraded.push("capacity_source_stale");
1015
+ if (beaconConfirmMs <= 0) return "exit";
1016
+ if (!staleConfirm.has(hostKey)) {
1017
+ staleConfirm.set(hostKey, now() + beaconConfirmMs);
1018
+ armStaleConfirmRace();
1019
+ }
1020
+ // Arm the confirm race, but DON'T consume the frame — fall through to matchFrame
1021
+ // (Codex round-23/24 P2). A wait that pins a `capacity` condition to this host AND
1022
+ // carries a raw `event:type=container_capacity,subject=<same-host>` must still let
1023
+ // the raw condition wake on the transition. The `capacity` matcher refuses to
1024
+ // grant on a source_stale frame (never a false grant), and a host-pinned-only wait
1025
+ // simply finds no match here and keeps waiting on the confirm race as before.
1026
+ return "fallthrough";
1027
+ }
1028
+ // Recovery for THIS host only: drop its confirm; keep the marker while any OTHER
1029
+ // pinned host is still pending-stale. Fall through so a recovery carrying
1030
+ // free_slots >= minSlots can grant normally.
1031
+ staleConfirm.delete(hostKey);
1032
+ if (staleConfirm.size === 0) {
1033
+ const i = degraded.indexOf("capacity_source_stale");
1034
+ if (i !== -1) degraded.splice(i, 1);
1035
+ }
1036
+ armStaleConfirmRace();
1037
+ return "fallthrough";
1038
+ };
1039
+
1040
+ while (true) {
1041
+ if (earliest != null && now() >= earliest.atMs) {
1042
+ return fireAlarm();
1043
+ }
1044
+
1045
+ // Refresh feed-lag on start + every (re)connect, so a stalled feed is flagged
1046
+ // and a recovered one clears — reflected in the terminal receipt / wait_session.
1047
+ await probeFeedLag();
1048
+
1049
+ let stream;
1050
+ try {
1051
+ stream = await connect(cursor);
1052
+ } catch (err) {
1053
+ failures += 1;
1054
+ degraded.push("backend_unreachable");
1055
+ if (failures > maxReconnects) {
1056
+ return finalize("error", { exitCode: EXIT.BACKEND, error: String(err && err.message || err) });
1057
+ }
1058
+ const waitMs = backoff(failures);
1059
+ const raced = await Promise.race([
1060
+ deadlinePromise,
1061
+ sleepFn(waitMs).then(() => ({ __retry: true })),
1062
+ ]);
1063
+ if (raced && raced.__deadline) return fireAlarm();
1064
+ continue;
1065
+ }
1066
+
1067
+ const iterator = stream[Symbol.asyncIterator]();
1068
+ let streamEnded = false;
1069
+ // BOT-1148 (Codex P2): the pending iterator.next() is HOISTED across loop
1070
+ // iterations. When a non-frame racer wins (a beacon-stale confirm that a recovery
1071
+ // then cancels), we must NOT abandon the in-flight read and start a fresh one —
1072
+ // the abandoned read would still consume the next frame and silently drop it (the
1073
+ // grant could be that frame, hanging the wait). We only start a new read once the
1074
+ // previous one has actually settled into a frame we consumed.
1075
+ let pendingNext = null;
1076
+ while (!streamEnded) {
1077
+ if (!pendingNext) pendingNext = iterator.next();
1078
+ const racers = [deadlinePromise, pendingNext];
1079
+ if (staleConfirmPromise) racers.push(staleConfirmPromise);
1080
+ if (preDeadlineProbePromise && !preDeadlineProbeDone) racers.push(preDeadlineProbePromise);
1081
+ const raced = await Promise.race(racers);
1082
+
1083
+ if (raced && raced.__deadline) {
1084
+ return fireAlarm();
1085
+ }
1086
+ // BOT-1147 (Codex round-27 P2): the pre-deadline feed probe fired. Fold a
1087
+ // mid-wait stall/recovery into `degraded` before the imminent timeout receipt,
1088
+ // then keep waiting — pendingNext stays in flight (hoisted), re-raced next
1089
+ // iteration, so no frame is dropped. Fires at most once.
1090
+ if (raced && raced.__feedProbe) {
1091
+ preDeadlineProbeDone = true;
1092
+ await probeFeedLag();
1093
+ continue;
1094
+ }
1095
+ // BOT-1148: the beacon-stale confirm window elapsed. If still stale (no recovery
1096
+ // cleared it), exit fast on the existing code 5; otherwise drop the settled
1097
+ // confirm promise and keep waiting — pendingNext stays in flight, re-raced next
1098
+ // iteration, so no frame is dropped.
1099
+ if (raced && raced.__staleConfirm) {
1100
+ // Any pinned host whose confirm window elapsed while STILL stale → fast-fail.
1101
+ // A recovery from a different host already removed its own entry, so it can't
1102
+ // suppress this one (Codex P2, per-host).
1103
+ const anyDue = [...staleConfirm.values()].some((atMs) => now() >= atMs);
1104
+ if (anyDue) {
1105
+ if (!degraded.includes("capacity_source_stale")) degraded.push("capacity_source_stale");
1106
+ return finalize("error", { exitCode: EXIT.BACKEND, error: "capacity_source_stale" });
1107
+ }
1108
+ // A settled promise whose host recovered before firing: re-arm for the next
1109
+ // pending host (if any) and keep waiting — pendingNext stays in flight.
1110
+ armStaleConfirmRace();
1111
+ continue;
1112
+ }
1113
+ // raced settled from pendingNext (a frame result) — consume it before the next read.
1114
+ pendingNext = null;
1115
+ const { value: frame, done } = raced;
1116
+ if (done) {
1117
+ streamEnded = true;
1118
+ break;
1119
+ }
1120
+ if (!frame) continue;
1121
+
1122
+ // BOT-1067: a degraded marker frame (e.g. the relay fell back to bounded
1123
+ // re-query polling during a Realtime outage) is folded into the receipt's
1124
+ // degraded array. The wait CONTINUES — delivery is just late and flagged —
1125
+ // so this is not terminal.
1126
+ if (frame.event === "degraded") {
1127
+ let parsed = {};
1128
+ try { parsed = JSON.parse(frame.data); } catch { /* ignore */ }
1129
+ const marker = parsed.degraded;
1130
+ if (typeof marker === "string" && !degraded.includes(marker)) degraded.push(marker);
1131
+ continue;
1132
+ }
1133
+
1134
+ // Server-side error frames are terminal.
1135
+ if (frame.event === "error") {
1136
+ let parsed = {};
1137
+ try { parsed = JSON.parse(frame.data); } catch { /* ignore */ }
1138
+ if (parsed.error === "cursor_expired") {
1139
+ return finalize("error", { exitCode: EXIT.CURSOR_EXPIRED, error: "cursor_expired" });
1140
+ }
1141
+ if (parsed.error === "unauthorized" || parsed.error === "forbidden") {
1142
+ return finalize("error", { exitCode: EXIT.AUTH, error: parsed.error });
1143
+ }
1144
+ return finalize("error", { exitCode: EXIT.INTERNAL, error: parsed.error || "stream_error" });
1145
+ }
1146
+
1147
+ const signal = decodeSignal(frame);
1148
+ if (!signal || signal.seq == null) continue;
1149
+ const seqKey = String(signal.seq);
1150
+ if (seen.has(seqKey)) continue; // exactly-once across reconnects
1151
+ seen.add(seqKey);
1152
+ cursor = seqKey;
1153
+ if (onFrame) onFrame(signal);
1154
+
1155
+ // BOT-1249: a `wait_superseded` spine signal targeting THIS registration is
1156
+ // terminal (exit 8). reconcile_waits_to_canonical() emits it — in seq order,
1157
+ // strictly AFTER any pre-supersession match — when this wait's resource identity
1158
+ // was canonicalized away (a BOT-1184 alias merge / BOT-1224 host rename), so its
1159
+ // in-memory (alias-keyed) conditions can never fire against the now-canonical
1160
+ // signal keys. Exiting lets the harness re-invoke bb-wait, which re-registers
1161
+ // under the canonical identity (the BOT-1184 register echo). This replaces the
1162
+ // BOT-1228 out-of-band `event: superseded` frame: same outcome/exit, now delivered
1163
+ // as a sequenced spine row so it can never overtake an earlier match. `cursor` is
1164
+ // already advanced past it (above), so the re-armed wait resumes without a gap.
1165
+ //
1166
+ // Scoped to payload.wait_session_id — the same wait_session_id scoping the claim-
1167
+ // grant path uses — so a `--since` replay of an OLDER supersession for a prior,
1168
+ // now-terminal registration (a fresh registration is a new wait_session_id) is
1169
+ // ignored rather than exiting this wait spuriously. The signal is recipient-scoped
1170
+ // at the relay, so an agent's OTHER concurrent waits receive it too; this id check
1171
+ // is why only the intended one treats it as terminal.
1172
+ if (
1173
+ signal.signal_type === WAIT_SUPERSEDED_SIGNAL_TYPE &&
1174
+ waitSessionId != null &&
1175
+ signal.payload && signal.payload.wait_session_id === waitSessionId
1176
+ ) {
1177
+ // Re-arm the re-registration from the wait's ORIGINAL arm cursor (cursor_start),
1178
+ // NOT this supersession's seq. `seq` is allocated at INSERT, not COMMIT, and a
1179
+ // live frame can overtake the replay backlog — so a supersession can be delivered
1180
+ // to us AHEAD of a match that was actually eligible (a chat/pr/ci signal for a
1181
+ // mixed `any` wait, or a canonical signal that arrived during the alias window).
1182
+ // If we re-armed from the supersession's (higher) seq we'd skip that match on
1183
+ // re-registration. cursor_start is the seq this wait armed from, so re-arming here
1184
+ // gives the re-registered wait EXACTLY the coverage the original registration had
1185
+ // — strictly better than re-arming from the last-seen (higher) cursor, which is
1186
+ // all a normal same-wait reconnect gets. (The one residual gap — a match whose row
1187
+ // was allocated a seq BELOW cursor_start but committed after registration read
1188
+ // max(seq) — is the pre-existing BOT-989 register-after-emit race that any
1189
+ // reconnect shares; it is out of scope here and tracked separately. We do NOT
1190
+ // re-arm from 0, which would spuriously re-match stale pre-arm signals.) Falls
1191
+ // back to the current cursor if the emitter didn't stamp cursor_start (defensive —
1192
+ // reconcile always does).
1193
+ const cs = signal.payload.cursor_start;
1194
+ if (cs != null && /^\d+$/.test(String(cs))) cursor = String(cs);
1195
+ // A superseded CLAIM wait lost its grantable queue entry: reconcile_waits_to_
1196
+ // canonical() dequeues the queued resource_locks row before abandoning the
1197
+ // session. A plain re-run re-registers the wait_session but does NOT recreate
1198
+ // that queue row, so grant_free_lock would have nothing to promote and the
1199
+ // re-armed claim would hang to --timeout. Surface the lock(s) to re-acquire so
1200
+ // the caller re-queues (acquire_lock) BEFORE re-parking (BOT-1228 Codex round-3
1201
+ // P1). Broadcast waits need no queue entry, so this is empty for them.
1202
+ const reacquire = conditions
1203
+ .filter((c) => c.type === "lock" && c.params.claim)
1204
+ .map((c) => ({ subtype: c.params.subtype, host: c.params.host, slot: c.params.slot }));
1205
+ return finalize("superseded", {
1206
+ exitCode: EXIT.SUPERSEDED,
1207
+ state: {
1208
+ superseded: true,
1209
+ status: typeof signal.payload.status === "string" ? signal.payload.status : null,
1210
+ },
1211
+ // Top-level (survives receipt truncation) — see buildReceipt.
1212
+ reacquireLock: reacquire,
1213
+ });
1214
+ }
1215
+
1216
+ // BOT-1148: a beacon-driven capacity staleness transition is handled before the
1217
+ // normal match — a stale signal is never itself a grant, and a fast exit / marker
1218
+ // fold happens here (host-pinned) rather than through matchFrame.
1219
+ if (signal.signal_type === "container_capacity" && signal.payload && "source_stale" in signal.payload) {
1220
+ const disposition = applyBeaconStale(signal);
1221
+ if (disposition === "exit") {
1222
+ return finalize("error", { exitCode: EXIT.BACKEND, error: "capacity_source_stale" });
1223
+ }
1224
+ if (disposition === "continue") continue;
1225
+ // "fallthrough": let matchFrame see the frame — a recovery may grant via
1226
+ // free_slots, and a raw `event` condition may match a stale frame (the
1227
+ // `capacity` matcher itself never grants on source_stale).
1228
+ }
1229
+
1230
+ // BOT-1259 AC-3/AC-4: central tenant guard. A tenanted signal whose tenant is not
1231
+ // this session's resolved tenant (or whose session tenant is unknown) never wakes
1232
+ // it — fail closed, and surfaced as a debug line, not an error (the wait simply
1233
+ // keeps waiting). Belt-and-braces with the server relay's authority-side filter.
1234
+ if (signalTenantBlocked(signal, sessionTenant)) {
1235
+ if (debug) {
1236
+ debug(`bb-wait: dropped ${signal.signal_type} signal (seq ${signal.seq}) — tenant ${signal.tenant_id} ≠ session tenant ${sessionTenant ?? "(unresolved)"} [BOT-1259 fail-closed]`);
1237
+ }
1238
+ continue;
1239
+ }
1240
+
1241
+ const matched = matchFrame(frame, conditions, waitSessionId, sessionTenant);
1242
+ if (matched) {
1243
+ return finalize("matched", {
1244
+ exitCode: EXIT.MATCHED,
1245
+ matched: [{
1246
+ condition_id: matched.id,
1247
+ signal_type: signal.signal_type,
1248
+ seq: signal.seq,
1249
+ subject_key: signal.subject_key,
1250
+ payload: signal.payload ?? null,
1251
+ provenance: "spine",
1252
+ }],
1253
+ });
1254
+ }
1255
+ }
1256
+
1257
+ // Stream ended without a match: reconnect from the cursor.
1258
+ reconnects += 1;
1259
+ if (reconnects > maxReconnects) {
1260
+ return finalize("error", { exitCode: EXIT.BACKEND, error: "reconnect budget exhausted" });
1261
+ }
1262
+ const waitMs = backoff(reconnects);
1263
+ const raced = await Promise.race([deadlinePromise, sleepFn(waitMs).then(() => ({ __retry: true }))]);
1264
+ if (raced && raced.__deadline) return fireAlarm();
1265
+ }
1266
+ }