@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.
- package/bin/botbuddy.mjs +5 -1
- package/package.json +1 -1
- package/src/agent-credential-store.mjs +208 -0
- package/src/api.mjs +39 -0
- package/src/auth.mjs +169 -70
- package/src/auth.test.mjs +404 -0
- package/src/codex-bridge.mjs +2 -1
- package/src/commands.mjs +206 -30
- package/src/config.mjs +5 -1
- package/src/discovery.mjs +141 -0
- package/src/discovery.test.mjs +195 -0
- package/src/locks.mjs +154 -0
- package/src/locks.test.mjs +60 -0
- package/src/oauth-loopback.mjs +228 -0
- package/src/profile-bootstrap.mjs +104 -0
- package/src/profile-bootstrap.test.mjs +205 -0
- package/src/publish-equal.mjs +207 -0
- package/src/publish-equal.test.mjs +176 -0
- package/src/publish-workflow.test.mjs +122 -0
- package/src/quiet-runner.mjs +134 -0
- package/src/quiet-runner.test.mjs +109 -0
- package/src/run.mjs +239 -0
- package/src/run.test.mjs +173 -0
- package/src/stack.mjs +572 -0
- package/src/stack.test.mjs +196 -0
- package/src/wait-core.mjs +1266 -0
- package/src/wait-profile.mjs +84 -0
- package/src/wait-profile.test.mjs +30 -0
- package/src/wait.mjs +727 -0
- package/src/wait.test.mjs +266 -0
|
@@ -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
|
+
}
|