@botbuddy/cli 1.34.3 → 1.35.0

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,997 @@
1
+ // BOT-1318 — bb pw as a live, durable browser-acceptance lifecycle producer.
2
+ //
3
+ // One real lane browser session = one canonical BOT-1317 execution with
4
+ // producer_kind=browser_acceptance, attempt=1 and an opaque source_run_id minted
5
+ // before the first browser operation. Every lifecycle event (start, per-action
6
+ // progress, verdict, heartbeat, terminal) is allocated a producer_event_id and
7
+ // a monotonically increasing producer_sequence under an exclusive per-lane state
8
+ // lock, appended durably to the BOT-1316 outbox BEFORE delivery, and delivered
9
+ // through execution-ingest. Retries resend the SAME event, so the server's
10
+ // per-execution idempotency collapses every replay/transport duplicate.
11
+ //
12
+ // Privacy: only verb, category, timing, result class and an origin+path route are
13
+ // recorded. Typed/filled values, @ENV secrets, query strings, fragments, raw
14
+ // Playwright output and error text never reach state, outbox or the server.
15
+ import { createHash, randomUUID } from "node:crypto";
16
+ import { chmod, link, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { containsSensitive, createTelemetryOutbox } from "../telemetry-outbox.mjs";
20
+
21
+ export const PRODUCER_KIND = "browser_acceptance";
22
+ // BOT-1317's reaper abandons an execution 90 s after its last accepted event; a
23
+ // live idle browser is kept fresh by a session-owned heartbeat at most every 20 s.
24
+ export const HEARTBEAT_INTERVAL_MS = 15_000;
25
+ // active_ms caps every idle gap between consecutive commands at 60 s.
26
+ export const IDLE_GAP_CAP_MS = 60_000;
27
+ export const ENVIRONMENTS = Object.freeze(["local", "preview", "staging", "production"]);
28
+ export const VERDICTS = Object.freeze(["pass", "fail"]);
29
+ const MAX_EVIDENCE_REFS = 32;
30
+ const MAX_ROUTES = 32;
31
+ const MAX_ROUTE_LENGTH = 256;
32
+ // Bounded, synchronous delivery windows. Pre-action delivery runs CONCURRENTLY
33
+ // with the browser command (no added latency); the post-action window is awaited
34
+ // before the command returns so each result is live while the session runs.
35
+ const PRE_ACTION_DELIVERY_MS = 2_000;
36
+ const POST_ACTION_DELIVERY_MS = 2_500;
37
+ const STATE_LOCK_ATTEMPTS = 400; // ≈10 s at 25 ms
38
+ const EXEC_LOCK_ATTEMPTS = 4_800; // ≈120 s: commands on one lane run serially
39
+
40
+ // A live closer holds the closing marker at most this long — under the 90 s reaper,
41
+ // so a stalled close can never silence the heartbeat into a false abandonment.
42
+ const CLOSING_GRACE_MS = 60_000;
43
+ // After the heartbeat ends a session it keeps delivering the queued terminal for up to
44
+ // this many ticks (~10 min at 15 s), since no lane command may ever run again.
45
+ const TERMINAL_DRAIN_TICKS = 40;
46
+
47
+ // A lock-reclaim guard is held for one read + unlink; older than this, its reclaimer died.
48
+ const RECLAIM_GUARD_STALE_MS = 5_000;
49
+
50
+ const CLEAN_CLOSE = Symbol("clean-close");
51
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
52
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
53
+ const safeSegment = (value) => String(value).toLowerCase().replace(/[^a-z0-9._-]/g, "_").slice(0, 128) || "_";
54
+
55
+ // ── Pure helpers ─────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * command_ms = Σ measured (monotonic) durations of unique finished commands.
59
+ * active_ms = union of command intervals + each idle gap between consecutive
60
+ * commands capped at 60 s; time before the first / after the last
61
+ * command is excluded. An effort proxy, NOT CPU time or attention.
62
+ * Commands [0,1000] and [121000,122000] → command 2000, active 62000, span 122000.
63
+ */
64
+ export function computeTiming(actions = []) {
65
+ const intervals = [];
66
+ let commandMs = 0;
67
+ for (const action of actions) {
68
+ const start = Date.parse(action?.started_at ?? "");
69
+ const finish = Date.parse(action?.finished_at ?? "");
70
+ if (!Number.isFinite(start) || !Number.isFinite(finish)) continue;
71
+ intervals.push([start, Math.max(start, finish)]);
72
+ commandMs += Math.max(0, Math.round(Number(action.command_ms) || 0));
73
+ }
74
+ if (!intervals.length) return { command_ms: 0, active_ms: 0, action_span_ms: 0, first_action_at: null, last_action_at: null };
75
+ intervals.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
76
+ let [currentStart, currentEnd] = intervals[0];
77
+ let active = 0;
78
+ let lastEnd = currentEnd;
79
+ for (const [start, end] of intervals.slice(1)) {
80
+ if (start <= currentEnd) { currentEnd = Math.max(currentEnd, end); }
81
+ else {
82
+ active += currentEnd - currentStart;
83
+ active += Math.min(start - currentEnd, IDLE_GAP_CAP_MS);
84
+ currentStart = start; currentEnd = end;
85
+ }
86
+ lastEnd = Math.max(lastEnd, end);
87
+ }
88
+ active += currentEnd - currentStart;
89
+ lastEnd = Math.max(lastEnd, currentEnd);
90
+ const first = intervals[0][0];
91
+ return {
92
+ command_ms: commandMs,
93
+ active_ms: active,
94
+ action_span_ms: Math.max(0, lastEnd - first),
95
+ first_action_at: new Date(first).toISOString(),
96
+ last_action_at: new Date(lastEnd).toISOString(),
97
+ };
98
+ }
99
+
100
+ // Route segments are an ALLOWLIST (Codex R38): a path segment may itself be the credential —
101
+ // a JWT, UUID, reset/OTP code or invitation token, however short — so a segment survives only
102
+ // as a plain lowercase word slug. Anything carrying a digit, an uppercase letter or other
103
+ // characters, a long unbroken run, and the segment right after a credential keyword are
104
+ // templated to `:id`. The server applies the same rule (executionLifecycle.ts isOpaqueSegment)
105
+ // and refuses a route that still carries one. Over-redacting a route is preferred to
106
+ // retaining a credential.
107
+ const OPAQUE_SEGMENT_MIN = 16;
108
+ const WORD_SLUG = /^[a-z]+(?:[-_.][a-z]+)*$/;
109
+ export const CREDENTIAL_KEYWORDS = new Set([
110
+ "reset", "reset-password", "password-reset", "forgot-password", "recover", "recovery",
111
+ "verify", "verify-email", "verification", "confirm", "confirm-email", "confirmation",
112
+ "invite", "invites", "invitation", "invitations", "accept-invite",
113
+ "magic", "magic-link", "login-link", "passwordless", "token", "tokens", "otp", "code", "codes",
114
+ "activate", "activation", "unlock", "unsubscribe", "share",
115
+ ]);
116
+ function isOpaqueSegment(segment, previous = "") {
117
+ if (segment === "") return false;
118
+ if (CREDENTIAL_KEYWORDS.has(previous.toLowerCase())) return true;
119
+ if (!WORD_SLUG.test(segment)) return true;
120
+ return segment.split(/[^a-z]+/).some((run) => run.length >= OPAQUE_SEGMENT_MIN);
121
+ }
122
+
123
+ // A decoded path the server accepts: every segment is `:id` or an allowed slug.
124
+ function segmentsAllowed(path) {
125
+ const segments = path.split("/");
126
+ return segments.every((segment, index) => segment === ":id" || !isOpaqueSegment(segment, index ? segments[index - 1] : ""));
127
+ }
128
+
129
+ function decodeStable(value) {
130
+ let decoded = value;
131
+ for (;;) {
132
+ let next;
133
+ try { next = decodeURIComponent(decoded); } catch { return null; }
134
+ if (next === decoded) return decoded;
135
+ decoded = next;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Origin + path only. @ENV references, non-http(s) URLs and unparsable input are dropped,
141
+ * and so is a path carrying a credential (reset links, signed or provider-token paths) or a
142
+ * character the server's route contract refuses — never persisted, never quarantined. An
143
+ * opaque segment that may be a credential is templated to `:id`.
144
+ */
145
+ export function sanitizeRoute(value) {
146
+ if (typeof value !== "string" || !value || value.startsWith("@ENV:")) return null;
147
+ let url;
148
+ try { url = new URL(value); } catch { return null; }
149
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
150
+ if (containsSensitive(`${url.origin}${url.pathname}`)) return null;
151
+ // Percent-encoding must not hide a credential: screen the path decoded until stable.
152
+ // Every changing decode shortens the string, so the loop ends within its length;
153
+ // an undecodable path is dropped, not trusted.
154
+ let decoded = url.pathname;
155
+ for (;;) {
156
+ let next;
157
+ try { next = decodeURIComponent(decoded); } catch { return null; }
158
+ if (containsSensitive(next)) return null;
159
+ if (next === decoded) break;
160
+ decoded = next;
161
+ }
162
+ const raw = url.pathname.split("/");
163
+ const plain = raw.map((segment) => decodeStable(segment) ?? "\u0000");
164
+ const path = raw.map((segment, index) => (isOpaqueSegment(plain[index], index ? plain[index - 1] : "") ? ":id" : segment)).join("/");
165
+ const route = `${url.origin}${path}`;
166
+ if (route.length > MAX_ROUTE_LENGTH || /[?#@\s]/.test(route)) return null;
167
+ // An encoded `/` can split a segment once decoded; the server screens the decoded path,
168
+ // so a route it would still refuse is dropped rather than quarantined.
169
+ const decodedPath = decodeStable(path);
170
+ if (decodedPath === null || !segmentsAllowed(decodedPath)) return null;
171
+ return route;
172
+ }
173
+
174
+ /** A bounded error class; the raw text is inspected, never stored. */
175
+ export function errorClassOf(text) {
176
+ const value = String(text ?? "").toLowerCase();
177
+ if (/timeout|timed out/.test(value)) return "timeout";
178
+ if (/daemon is not running|not running|socket is gone|connection (?:dropped|failed|refused)/.test(value)) return "browser_not_running";
179
+ if (/stale|ref .*not found|no element .*ref|unknown ref/.test(value)) return "stale_ref";
180
+ if (/net::err_|navigation|err_name_not_resolved|err_connection/.test(value)) return "navigation_error";
181
+ if (/strict mode violation/.test(value)) return "strict_mode_violation";
182
+ if (/not visible|not enabled|intercepts pointer|not attached|detached/.test(value)) return "element_not_actionable";
183
+ const exit = /^exit (\d{1,3})$/.exec(value.trim());
184
+ if (exit) return `exit_${exit[1]}`;
185
+ return "action_failed";
186
+ }
187
+
188
+ export function resolveEnvironment({ flag = null, env = {} } = {}) {
189
+ const raw = flag ?? (env.BB_PW_ENV ? env.BB_PW_ENV : null);
190
+ if (raw === null || raw === undefined || raw === "") return { environment: null };
191
+ if (!ENVIRONMENTS.includes(raw)) return { error: `bb-pw: environment must be one of ${ENVIRONMENTS.join("|")} (got "${raw}").` };
192
+ return { environment: raw };
193
+ }
194
+
195
+ export function sessionStatePath({ root, tenant, repository, host, lane }) {
196
+ return join(root, "pw-sessions", safeSegment(tenant), hash(String(repository).toLowerCase()).slice(0, 32), safeSegment(host), `lane-${lane}.json`);
197
+ }
198
+
199
+ function hasLiveInflight(state) {
200
+ return state.actions.some((action) => !action.result && pidAlive(action.owner_pid));
201
+ }
202
+
203
+ function pidAlive(pid) {
204
+ if (!Number.isInteger(pid) || pid <= 0) return false;
205
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; }
206
+ }
207
+
208
+ // An exclusive lock file holding the owner PID plus a per-acquisition nonce. A lock whose
209
+ // same-host owner is provably gone is reclaimed; an incomplete record is never age-reclaimed.
210
+ export async function withLock(lockPath, fn, { attempts = STATE_LOCK_ATTEMPTS, waitMs = 25 } = {}) {
211
+ await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
212
+ const token = JSON.stringify({ pid: process.pid, nonce: randomUUID() });
213
+ // The complete record is written to a private temp file first and then hard-linked into
214
+ // place (link fails with EEXIST if a lock exists), so the lock path is never visible
215
+ // empty or partial — a crash mid-publish can never strand an unprovable lock.
216
+ const staged = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
217
+ await writeFile(staged, token, { mode: 0o600 });
218
+ try {
219
+ for (let attempt = 0; ; attempt++) {
220
+ try {
221
+ await link(staged, lockPath);
222
+ break;
223
+ } catch (error) {
224
+ if (error?.code !== "EEXIST") throw error;
225
+ let raw = null;
226
+ let owner = null;
227
+ try { raw = await readFile(lockPath, "utf8"); owner = JSON.parse(raw); } catch { /* being written */ }
228
+ if (Number.isInteger(owner?.pid) && !pidAlive(owner.pid)) { await reclaimStaleLock(lockPath, raw); continue; }
229
+ if (attempt >= attempts) throw Object.assign(new Error("bb-pw: the lane's browser-session state is busy"), { code: "pw_session_lock_busy" });
230
+ await sleep(waitMs);
231
+ }
232
+ }
233
+ } finally { await unlink(staged).catch(() => {}); }
234
+ try { return await fn(); } finally { await releaseLock(lockPath, token); }
235
+ }
236
+
237
+ // Remove a dead owner's lock only if the file still holds THAT exact record, checked and
238
+ // removed under a short reclaim guard — so two reclaimers can never delete a new owner's lock.
239
+ async function reclaimStaleLock(lockPath, observed) {
240
+ const guard = `${lockPath}.reclaim`;
241
+ let handle;
242
+ try { handle = await open(guard, "wx", 0o600); } catch (error) {
243
+ if (error?.code !== "EEXIST") throw error;
244
+ // The guard is held only for one read + unlink; an old one belongs to a killed reclaimer.
245
+ try { const info = await stat(guard); if (Date.now() - info.mtimeMs > RECLAIM_GUARD_STALE_MS) await unlink(guard).catch(() => {}); } catch { /* gone */ }
246
+ await sleep(5);
247
+ return;
248
+ }
249
+ try {
250
+ let current = null;
251
+ try { current = await readFile(lockPath, "utf8"); } catch { /* already gone */ }
252
+ if (current !== null && current === observed) await unlink(lockPath).catch(() => {});
253
+ } finally {
254
+ await handle.close().catch(() => {});
255
+ await unlink(guard).catch(() => {});
256
+ }
257
+ }
258
+
259
+ async function releaseLock(lockPath, token) {
260
+ let current = null;
261
+ try { current = await readFile(lockPath, "utf8"); } catch { return; }
262
+ if (current === token) await unlink(lockPath).catch(() => {});
263
+ }
264
+
265
+ async function syncDirectoryEntry(directory) {
266
+ let handle;
267
+ try { handle = await open(directory, "r"); await handle.sync(); } finally { await handle?.close(); }
268
+ }
269
+
270
+ // The rename publishes the new state; syncing the directory makes that entry itself durable
271
+ // across a power loss, as the outbox's atomicJson does (Codex R34), so recovery can never load
272
+ // a previous state and reuse an already-appended event id or sequence.
273
+ export async function writeJsonAtomic(path, value, { syncDirectory = syncDirectoryEntry } = {}) {
274
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
275
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
276
+ const handle = await open(temp, "w", 0o600);
277
+ try { await handle.writeFile(`${JSON.stringify(value)}\n`); await handle.sync(); } finally { await handle.close(); }
278
+ await chmod(temp, 0o600);
279
+ await rename(temp, path);
280
+ await syncDirectory(dirname(path));
281
+ }
282
+
283
+ // ── Summary / outcome derivations (replacements, never additive rollups) ─────
284
+
285
+ function summarize(state) {
286
+ const finished = state.actions.filter((action) => action.result);
287
+ const measured = finished.filter((action) => action.result !== "interrupted");
288
+ const timing = computeTiming(measured);
289
+ const count = (predicate) => finished.filter(predicate).length;
290
+ // Only a navigation that SUCCEEDED exercised its route: readiness treats every listed
291
+ // path as verified, so a failed or interrupted open/goto must never appear here.
292
+ const routes = [...new Set(finished.filter((action) => action.result === "succeeded").map((action) => action.route).filter(Boolean))].slice(0, MAX_ROUTES);
293
+ return {
294
+ actions_total: finished.length,
295
+ actions_succeeded: count((action) => action.result === "succeeded"),
296
+ actions_failed: count((action) => action.result === "failed"),
297
+ actions_interrupted: count((action) => action.result === "interrupted"),
298
+ command_ms: timing.command_ms,
299
+ active_ms: timing.active_ms,
300
+ action_span_ms: timing.action_span_ms,
301
+ first_action_at: timing.first_action_at,
302
+ last_action_at: timing.last_action_at,
303
+ navigations: count((action) => action.category === "navigate" && action.result === "succeeded"),
304
+ screenshots: count((action) => action.category === "screenshot" && action.result === "succeeded"),
305
+ snapshots: count((action) => action.category === "snapshot" && action.result === "succeeded"),
306
+ interactions: count((action) => action.category === "interaction" && action.result === "succeeded"),
307
+ distinct_routes: routes,
308
+ acceptance_verdict: state.verdict ?? "not_asserted",
309
+ };
310
+ }
311
+
312
+ // Clean close: passed when no action failed/was interrupted and the verdict is
313
+ // not fail; otherwise failed. An explicit pass never erases a recorded failure.
314
+ function closeOutcome(state) {
315
+ // A session closed before any browser command ran (e.g. orphaned right after its start
316
+ // was appended) exercised nothing: it is abandoned, never a successful execution.
317
+ if (state.actions.length === 0) return "abandoned";
318
+ const failed = state.actions.some((action) => action.result === "failed" || action.result === "interrupted");
319
+ return failed || state.verdict === "fail" ? "failed" : "passed";
320
+ }
321
+
322
+ function actionView(action) {
323
+ return Object.fromEntries(Object.entries({
324
+ action_id: action.action_id, verb: action.verb, category: action.category,
325
+ phase: action.result ? "finished" : "started", started_at: action.started_at,
326
+ finished_at: action.finished_at ?? undefined, command_ms: action.command_ms ?? undefined,
327
+ result: action.result ?? undefined, error_class: action.error_class ?? undefined, route: action.route ?? undefined,
328
+ }).filter(([, value]) => value !== undefined && value !== null));
329
+ }
330
+
331
+ // ── Lane tracker ─────────────────────────────────────────────────────────────
332
+
333
+ function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended = null, onStateWrite = null }) {
334
+ const statePath = sessionStatePath({ root, tenant, repository, host, lane });
335
+ const stateLock = `${statePath}.lock`;
336
+ const execLock = `${statePath}.exec.lock`;
337
+ const iso = () => now().toISOString();
338
+
339
+ // Every write of this lane's state goes through here; `onStateWrite` is a test seam
340
+ // that can inject a storage failure portably (unlike chmod, which root ignores).
341
+ async function persistState(state) {
342
+ onStateWrite?.(state);
343
+ await writeJsonAtomic(statePath, state);
344
+ }
345
+
346
+ async function readState() {
347
+ try { return JSON.parse(await readFile(statePath, "utf8")); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
348
+ }
349
+
350
+ const contextFor = (state, phase) => ({ ...state.context, phase });
351
+
352
+ // Allocate + durably append under the state lock. The state is only
353
+ // persisted by the caller after every append in the step succeeded, so a
354
+ // failed append (e.g. full disk) never advances the sequence or the action.
355
+ async function emit(state, suffix, action, { phase = "testing", browser = {}, occurredAt = iso(), ...extra } = {}) {
356
+ const sequence = state.next_sequence;
357
+ const event = {
358
+ schema_version: 1, action, producer_kind: PRODUCER_KIND, source_run_id: state.source_run_id, attempt: 1,
359
+ producer_event_id: `${state.source_run_id}:${suffix}`, producer_sequence: sequence, occurred_at: occurredAt,
360
+ context: contextFor(state, phase), browser: { lane: String(lane), host, ...browser }, ...extra,
361
+ };
362
+ // Write-ahead: the event and its sequence are durable in state BEFORE the outbox
363
+ // append, so a crash between the append and the caller's state write can never
364
+ // let a later event reuse this id or sequence; recovery re-appends it unchanged.
365
+ state.pending_events = [...(state.pending_events ?? []), event];
366
+ state.next_sequence = sequence + 1;
367
+ await persistState(state);
368
+ await appendDurably(event);
369
+ onAppended?.(event);
370
+ state.pending_events = state.pending_events.filter((item) => item.producer_event_id !== event.producer_event_id);
371
+ return event;
372
+ }
373
+
374
+ async function appendDurably(event) {
375
+ const outbox = await getOutbox();
376
+ // The shared outbox lock is held while a bounded replay delivers; a busy
377
+ // lock is retried (bounded) rather than refusing the browser command.
378
+ for (let attempt = 0; ; attempt++) {
379
+ try { await outbox.append(event); return; } catch (error) {
380
+ if (error?.code !== "telemetry_outbox_lock_busy" || attempt >= 4) throw error;
381
+ }
382
+ }
383
+ }
384
+
385
+ // Read state for a locked step, first re-appending any write-ahead events a previous
386
+ // process made durable in state but may not have appended (idempotent by event id).
387
+ async function readLockedState() {
388
+ const state = await readState();
389
+ if (!state?.pending_events?.length) return state;
390
+ for (const event of state.pending_events) await appendDurably(event);
391
+ state.pending_events = [];
392
+ await persistState(state);
393
+ return state;
394
+ }
395
+
396
+ function evidenceRefs(state) { return state.evidence_refs.slice(0, MAX_EVIDENCE_REFS); }
397
+
398
+ async function emitActionFinish(state, action) {
399
+ return emit(state, `${action.action_id}:finish`, "progress", {
400
+ occurredAt: action.finished_at ?? iso(),
401
+ progress: { completed: state.actions.filter((item) => item.result).length, phase: "testing", current_item: action.verb },
402
+ browser: { action: actionView(action), summary: summarize(state) },
403
+ evidence_refs: evidenceRefs(state),
404
+ });
405
+ }
406
+
407
+ async function interruptUnfinished(state) {
408
+ for (const action of state.actions) {
409
+ if (action.result) continue;
410
+ action.result = "interrupted";
411
+ action.finished_at = null;
412
+ action.command_ms = null;
413
+ await emitActionFinish(state, action);
414
+ }
415
+ }
416
+
417
+ // `outcome` may be CLEAN_CLOSE: the clean-close outcome is then derived only
418
+ // AFTER unfinished actions are marked interrupted, so an interrupted action can
419
+ // never yield `passed` (Codex R2). No explicit outcome may be `passed` either
420
+ // once an action was interrupted or failed.
421
+ async function terminalize(state, requested, diagnostic = null) {
422
+ await interruptUnfinished(state);
423
+ let outcome = requested === CLEAN_CLOSE ? closeOutcome(state) : requested;
424
+ if (outcome === "passed" && closeOutcome(state) !== "passed") outcome = "failed";
425
+ const terminalAt = iso();
426
+ const fields = {
427
+ phase: outcome === "passed" ? "complete" : "failed", occurredAt: terminalAt, outcome,
428
+ browser: { summary: summarize(state) }, evidence_refs: evidenceRefs(state),
429
+ ...(diagnostic ? { diagnostic } : {}),
430
+ };
431
+ // The terminal state is durable in the SAME write-ahead step as the terminal event,
432
+ // so a crash after the outbox append can never leave the lane active (Codex R19):
433
+ // recovery re-appends the unchanged terminal and the closed execution is never
434
+ // extended or re-terminalized with a different outcome.
435
+ state.status = "terminal";
436
+ state.outcome = outcome;
437
+ state.terminal_at = terminalAt;
438
+ state.closing = false;
439
+ await emit(state, "terminal", "terminal", fields);
440
+ }
441
+
442
+ async function newSession(environment, { startedAt = iso() } = {}) {
443
+ const git = (await gitInfo?.()) ?? {};
444
+ const context = { repo: repository, environment, runner: "bb-pw", health: "verified" };
445
+ if (git.ticket) context.ticket = String(git.ticket);
446
+ if (git.branch && git.branch !== "HEAD") context.branch = String(git.branch);
447
+ if (git.prNumber !== null && git.prNumber !== undefined && git.prNumber !== "") context.pr = String(git.prNumber);
448
+ if (git.sha) context.sha = String(git.sha);
449
+ const state = {
450
+ schema_version: 1, tenant, repository, host, lane: String(lane), producer_kind: PRODUCER_KIND, attempt: 1,
451
+ source_run_id: randomUUID(), status: "active", environment, context, started_at: startedAt, next_sequence: 0,
452
+ actions: [], evidence_refs: [], verdict: null, daemon_generation: null, heartbeat_pid: null,
453
+ execution_id: null, delivery: { state: "pending", queued_count: null, last_error_class: null }, closing: false,
454
+ outcome: null, terminal_at: null, reconciliation: null,
455
+ };
456
+ await emit(state, "start", "start", { occurredAt: state.started_at, browser: {} });
457
+ return state;
458
+ }
459
+
460
+ /** Apply accepted receipts for this state's execution; true when anything changed. */
461
+ function applyReceipts(state, receipts) {
462
+ let changed = false;
463
+ for (const { event, result } of receipts) {
464
+ if (event.source_run_id !== state.source_run_id) continue;
465
+ if (typeof result.execution_id === "string" && result.execution_id && state.execution_id !== result.execution_id) { state.execution_id = result.execution_id; changed = true; }
466
+ if (result.receipt === "terminal_conflict_retired") {
467
+ // The server's terminal is canonical: adopt its outcome, keeping the locally derived
468
+ // one for diagnosis, so `bb pw status` never disagrees with the execution (Codex R27).
469
+ const canonical = typeof result.outcome === "string" && result.outcome ? result.outcome : null;
470
+ state.reconciliation = {
471
+ receipt: "terminal_conflict", producer_event_id: event.producer_event_id, observed_at: iso(),
472
+ ...(canonical ? { outcome: canonical, local_outcome: state.outcome ?? null } : {}),
473
+ };
474
+ if (canonical && state.status === "terminal") state.outcome = canonical;
475
+ changed = true;
476
+ }
477
+ // The server already ended this run (e.g. reaped it as abandoned after the machine
478
+ // slept past the grace): stop claiming it is live, so the next command starts fresh.
479
+ if (state.status === "active" && typeof result.outcome === "string" && result.outcome) {
480
+ state.status = "terminal";
481
+ state.outcome = result.outcome;
482
+ state.terminal_at = iso();
483
+ state.closing = false;
484
+ state.reconciliation = { receipt: "server_terminal", outcome: result.outcome, producer_event_id: event.producer_event_id, producer_sequence: event.producer_sequence, observed_at: iso() };
485
+ changed = true;
486
+ }
487
+ }
488
+ return changed;
489
+ }
490
+
491
+ /** The lane's delivery view after a replay; quarantine counts are sticky. */
492
+ function refreshDelivery(state, { queued, quarantined, status }) {
493
+ const priorQuarantined = Number(state.delivery?.quarantined_count ?? 0);
494
+ state.delivery = { state: queued || quarantined || priorQuarantined ? "degraded" : "delivered", queued_count: queued, quarantined_count: priorQuarantined + quarantined, last_error_class: quarantined ? "rejected_quarantined" : status?.last_error_class ?? null, last_attempt_at: iso() };
495
+ }
496
+
497
+ async function deliverPending(windowMs) {
498
+ const outbox = await getOutbox();
499
+ const receipts = [];
500
+ const rejected = [];
501
+ let quarantinedTotal = 0;
502
+ try {
503
+ const replayed = await outbox.replay(async (event) => {
504
+ const result = await deliver(event);
505
+ if (result?.accepted) receipts.push({ event, result });
506
+ else if (result?.permanent) rejected.push(event);
507
+ return result;
508
+ }, { maxEvents: 64, deadline: Date.now() + windowMs });
509
+ quarantinedTotal = replayed?.quarantined ?? 0;
510
+ } catch { /* a busy/unavailable outbox is degradation, never an action failure */ }
511
+ // Queued state is per owning lane: the outbox is shared, so its total is not any lane's backlog.
512
+ const ownerKey = (event) => (event?.browser?.lane === undefined || event?.browser?.lane === null ? null : `${event.browser.host ?? host}\u0000${event.browser.lane}`);
513
+ const status = await outbox.status({ ownerOf: ownerKey }).catch(() => null);
514
+ const queuedFor = (laneHost, laneId) => (status ? status.queued_by_owner?.[`${laneHost}\u0000${laneId}`] ?? 0 : null);
515
+ const queued = queuedFor(host, String(lane));
516
+ // The outbox is shared by every lane of this repository: a receipt or a permanent
517
+ // rejection for another lane's event is applied to THAT lane's state, never
518
+ // discarded or charged to the lane that happened to replay it.
519
+ const laneKey = (event) => {
520
+ const eventHost = event.browser?.host ?? host;
521
+ const eventLane = String(event.browser?.lane ?? lane);
522
+ return eventHost === host && eventLane === String(lane) ? null : sessionStatePath({ root, tenant, repository, host: eventHost, lane: eventLane });
523
+ };
524
+ const otherQueued = new Map();
525
+ const own = { receipts: [], rejected: [] };
526
+ const others = new Map();
527
+ const bucket = (event) => {
528
+ const otherPath = laneKey(event);
529
+ if (!otherPath) return own;
530
+ if (!others.has(otherPath)) {
531
+ others.set(otherPath, { receipts: [], rejected: [] });
532
+ otherQueued.set(otherPath, queuedFor(event.browser?.host ?? host, String(event.browser?.lane)));
533
+ }
534
+ return others.get(otherPath);
535
+ };
536
+ for (const receipt of receipts) bucket(receipt.event).receipts.push(receipt);
537
+ for (const event of rejected) bucket(event).rejected.push(event);
538
+ // A record the outbox could not even parse has no known owner; it stays with this lane.
539
+ const unowned = Math.max(0, quarantinedTotal - rejected.length);
540
+ // A rejection is charged only to the session (source_run_id) it belongs to — never to a
541
+ // later session on the same lane.
542
+ const quarantinedFor = (laneState, laneRejected) => laneRejected.filter((event) => event.source_run_id === laneState.source_run_id).length;
543
+ for (const [otherPath, other] of others) {
544
+ await withLock(`${otherPath}.lock`, async () => {
545
+ let otherState;
546
+ try { otherState = JSON.parse(await readFile(otherPath, "utf8")); } catch { return; }
547
+ applyReceipts(otherState, other.receipts);
548
+ // The owning lane may be terminal with no heartbeat left: its delivery view is refreshed here.
549
+ refreshDelivery(otherState, { queued: otherQueued.get(otherPath) ?? 0, quarantined: quarantinedFor(otherState, other.rejected), status });
550
+ await writeJsonAtomic(otherPath, otherState);
551
+ }).catch(() => { /* that lane's next delivery or status reconciles it */ });
552
+ }
553
+ let quarantined = unowned + own.rejected.length;
554
+ let serverEnded = null;
555
+ await withLock(stateLock, async () => {
556
+ const state = await readLockedState();
557
+ if (!state) return;
558
+ const wasActive = state.status === "active";
559
+ applyReceipts(state, own.receipts);
560
+ quarantined = unowned + quarantinedFor(state, own.rejected);
561
+ if (wasActive && state.reconciliation?.receipt === "server_terminal" && state.status === "terminal") serverEnded = state.outcome;
562
+ refreshDelivery(state, { queued, quarantined, status });
563
+ await persistState(state);
564
+ }).catch(() => {});
565
+ return { queued: queued ?? 0, quarantined, degraded: Boolean(queued) || quarantined > 0, server_ended: serverEnded };
566
+ }
567
+
568
+ // Actions the server never accepted because it had already ended this run (e.g. reaped it
569
+ // as abandoned after the machine slept past the grace): every finished action whose start
570
+ // was at or after the event that discovered the server's terminal (Codex R37).
571
+ function lostToServerEnd(state) {
572
+ const found = state?.reconciliation;
573
+ if (state?.status !== "terminal" || found?.receipt !== "server_terminal" || !Number.isInteger(found.producer_sequence)) return [];
574
+ return state.actions.filter((action) => action.result && action.result !== "interrupted"
575
+ && Number.isInteger(action.start_sequence) && action.start_sequence >= found.producer_sequence);
576
+ }
577
+
578
+ // Re-record lost actions, in order and with their measured intervals, in ONE fresh session
579
+ // for the same environment, so each real browser action reaches canonical telemetry once.
580
+ async function rehome(state, lost, probe) {
581
+ const fresh = await newSession(state.environment, { startedAt: lost[0].started_at });
582
+ const live = await probe();
583
+ if (live.alive) fresh.daemon_generation = live.generation;
584
+ fresh.reconciliation = { receipt: "rehomed", from_source_run_id: state.source_run_id, actions: lost.length, observed_at: iso() };
585
+ for (const original of lost) {
586
+ const action = {
587
+ action_id: `a${fresh.actions.length + 1}`, verb: original.verb, category: original.category, started_at: original.started_at,
588
+ finished_at: null, command_ms: null, result: null, error_class: null, route: original.route ?? null, owner_pid: process.pid,
589
+ };
590
+ fresh.actions.push(action);
591
+ const startEvent = await emit(fresh, `${action.action_id}:start`, "progress", {
592
+ occurredAt: action.started_at,
593
+ progress: { completed: fresh.actions.filter((item) => item.result).length, phase: "testing", current_item: action.verb },
594
+ browser: { action: actionView(action) },
595
+ });
596
+ action.start_sequence = startEvent.producer_sequence;
597
+ Object.assign(action, { finished_at: original.finished_at, command_ms: original.command_ms, result: original.result, error_class: original.error_class });
598
+ for (const ref of state.evidence_refs) {
599
+ const [kind, target] = ref.split(":");
600
+ if (target === `${state.source_run_id}/${original.action_id}` && fresh.evidence_refs.length < MAX_EVIDENCE_REFS) fresh.evidence_refs.push(`${kind}:${fresh.source_run_id}/${action.action_id}`);
601
+ }
602
+ await emitActionFinish(fresh, action);
603
+ }
604
+ // No live browser left: the recovered session can never close, so it ends as observed.
605
+ if (!live.alive) await terminalize(fresh, "crashed", { code: "browser_daemon_exited", category: "crash" });
606
+ await persistState(fresh);
607
+ return fresh;
608
+ }
609
+
610
+ function environmentGuard(state, environment) {
611
+ if (environment && state?.status === "active" && state.environment !== environment) {
612
+ return `bb-pw: lane ${lane}'s open browser session is tracked as environment "${state.environment}", not "${environment}". Close it before starting a ${environment} session.`;
613
+ }
614
+ return null;
615
+ }
616
+
617
+ // Returns { failed: true } when the session needs a heartbeat but none could be started
618
+ // (Codex R36): the caller reports it, since an idle session would otherwise be reaped
619
+ // as abandoned within the server's grace window. The next action retries the spawn.
620
+ async function ensureHeartbeat() {
621
+ let failed = false;
622
+ // A resolver without heartbeat machinery (no spawnHeartbeat) has nothing to start.
623
+ if (typeof spawnHeartbeat !== "function") return { failed };
624
+ await withLock(stateLock, async () => {
625
+ const state = await readLockedState();
626
+ // A live browser generation, or an in-flight first command (e.g. a long initial open,
627
+ // before any generation is known) whose owner is still alive, needs a heartbeat.
628
+ if (!state || state.status !== "active" || (!state.daemon_generation && !hasLiveInflight(state))) return;
629
+ if (state.heartbeat_pid && pidAlive(state.heartbeat_pid)) return;
630
+ let pid = null;
631
+ try { pid = await spawnHeartbeat({ root, tenant, repository, host, lane: String(lane), source_run_id: state.source_run_id }); } catch { pid = null; }
632
+ state.heartbeat_pid = Number.isInteger(pid) ? pid : null;
633
+ failed = state.heartbeat_pid === null;
634
+ await persistState(state);
635
+ }).catch(() => { failed = true; });
636
+ return { failed };
637
+ }
638
+
639
+ return {
640
+ statePath,
641
+
642
+ async status() {
643
+ const state = await readState().catch(() => null);
644
+ if (!state) return { tracked: true, active: false };
645
+ const summary = summarize(state);
646
+ const wallEnd = state.terminal_at ? Date.parse(state.terminal_at) : now().getTime();
647
+ return {
648
+ tracked: true, active: state.status === "active", status: state.status,
649
+ source_run_id: state.source_run_id, producer_kind: state.producer_kind, attempt: state.attempt,
650
+ execution_id: state.execution_id ?? null, environment: state.environment,
651
+ acceptance_verdict: summary.acceptance_verdict, outcome: state.outcome ?? null,
652
+ started_at: state.started_at, terminal_at: state.terminal_at ?? null,
653
+ wall_ms: Math.max(0, wallEnd - Date.parse(state.started_at)),
654
+ command_ms: summary.command_ms, active_ms: summary.active_ms,
655
+ actions_total: summary.actions_total, actions_succeeded: summary.actions_succeeded,
656
+ actions_failed: summary.actions_failed, actions_interrupted: summary.actions_interrupted,
657
+ delivery: state.delivery ?? null, reconciliation: state.reconciliation ?? null,
658
+ };
659
+ },
660
+
661
+ /**
662
+ * Begin one lane command. Holds the lane's exec lock until finish/release so
663
+ * commands on a lane (and their measured intervals) are strictly serial.
664
+ */
665
+ async begin({ verb, category, route, environment, probe }) {
666
+ let releaseExec;
667
+ const held = new Promise((resolve) => { releaseExec = resolve; });
668
+ let acquired;
669
+ const lockReady = new Promise((resolve, reject) => {
670
+ withLock(execLock, () => { resolve(); return held; }, { attempts: EXEC_LOCK_ATTEMPTS }).catch(reject);
671
+ });
672
+ try { await lockReady; acquired = true; } catch (error) { return { error: error.message, exitCode: 1 }; }
673
+ const release = () => { if (acquired) { acquired = false; releaseExec(); } };
674
+ let started;
675
+ let reconciled = false;
676
+ try {
677
+ started = await withLock(stateLock, async () => {
678
+ let state = await readLockedState();
679
+ if (state?.status === "active") {
680
+ const live = await probe();
681
+ if (!live.alive || (state.daemon_generation && live.generation !== state.daemon_generation)) {
682
+ // Observed daemon/owner death (or an open that never produced a browser).
683
+ await terminalize(state, state.daemon_generation ? "crashed" : CLEAN_CLOSE, state.daemon_generation ? { code: "browser_daemon_exited", category: "crash" } : null);
684
+ await persistState(state);
685
+ reconciled = true;
686
+ } else {
687
+ let changed = false;
688
+ if (state.actions.some((action) => !action.result)) {
689
+ await interruptUnfinished(state);
690
+ reconciled = true;
691
+ changed = true;
692
+ }
693
+ // An open that died after starting the browser never recorded it: adopt the live
694
+ // generation now so the recovered session owns a heartbeat.
695
+ if (!state.daemon_generation && live.generation) {
696
+ state.daemon_generation = live.generation;
697
+ changed = true;
698
+ }
699
+ if (changed) await persistState(state);
700
+ }
701
+ }
702
+ const mismatch = environmentGuard(state, environment);
703
+ if (mismatch) return { error: mismatch };
704
+ // An open always starts a new session, so its required environment is checked
705
+ // BEFORE the active session is closed: a refused open never ends it (Codex R25).
706
+ if (verb === "open" && !environment) return { error: `bb-pw: a tracked browser session needs an environment — pass --environment <${ENVIRONMENTS.join("|")}> before the lane, or set BB_PW_ENV.` };
707
+ if (verb === "open" && state?.status === "active") {
708
+ // A new explicit open ends the previous session with clean-close semantics.
709
+ await terminalize(state, CLEAN_CLOSE);
710
+ await persistState(state);
711
+ }
712
+ if (state?.status !== "active") {
713
+ let adopted = null;
714
+ if (verb !== "open") {
715
+ const live = await probe();
716
+ if (!live.alive) return { tracked: false };
717
+ adopted = live.generation ?? null;
718
+ }
719
+ if (!environment) return { error: `bb-pw: a tracked browser session needs an environment — pass --environment <${ENVIRONMENTS.join("|")}> before the lane, or set BB_PW_ENV.` };
720
+ state = await newSession(environment);
721
+ // Adopting an already-running browser: record it so the session owns a heartbeat.
722
+ if (adopted) state.daemon_generation = adopted;
723
+ }
724
+ const action = {
725
+ action_id: `a${state.actions.length + 1}`, verb, category, started_at: iso(), finished_at: null,
726
+ command_ms: null, result: null, error_class: null, route: sanitizeRoute(route),
727
+ owner_pid: process.pid,
728
+ };
729
+ state.actions.push(action);
730
+ const startEvent = await emit(state, `${action.action_id}:start`, "progress", {
731
+ progress: { completed: state.actions.filter((item) => item.result).length, phase: "testing", current_item: verb },
732
+ browser: { action: actionView(action) },
733
+ });
734
+ action.start_sequence = startEvent.producer_sequence;
735
+ await persistState(state);
736
+ return { tracked: true, sourceRunId: state.source_run_id, actionId: action.action_id };
737
+ });
738
+ } catch (error) {
739
+ release();
740
+ return { error: `bb-pw: browser telemetry could not durably record the action (${error?.code ?? error?.message ?? "error"}); nothing was run.`, exitCode: 1 };
741
+ }
742
+ if (!started.tracked) {
743
+ // A reconciliation (observed crash / interrupted action) is still reported.
744
+ if (reconciled) await deliverPending(POST_ACTION_DELIVERY_MS);
745
+ release();
746
+ return started.error ? { ...started, exitCode: 2 } : started;
747
+ }
748
+ // An adopted or recovered live browser owns its heartbeat BEFORE the command runs,
749
+ // so a long first command can never outlive the server's reaper window (idempotent).
750
+ await ensureHeartbeat();
751
+ const startMono = monotonic();
752
+ const pre = deliverPending(PRE_ACTION_DELIVERY_MS);
753
+ const { sourceRunId, actionId } = started;
754
+ return {
755
+ tracked: true, sourceRunId, actionId,
756
+ async finish({ ok, errorText = null, exitCode = null, evidenceKind = null }) {
757
+ const commandMs = Math.max(0, Math.round(monotonic() - startMono));
758
+ let persistFailed = null;
759
+ let rehomed = 0;
760
+ // Settle the concurrent pre-action delivery first: a server terminal it discovered
761
+ // must be known before this action's result is recorded (Codex R37).
762
+ const preDelivery = await pre;
763
+ try {
764
+ await withLock(stateLock, async () => {
765
+ const state = await readLockedState();
766
+ if (!state || state.source_run_id !== sourceRunId) return;
767
+ const serverEnded = state.status === "terminal" && state.reconciliation?.receipt === "server_terminal";
768
+ if (state.status !== "active" && !serverEnded) return;
769
+ const action = state.actions.find((item) => item.action_id === actionId);
770
+ if (!action || action.result) return;
771
+ action.finished_at = iso();
772
+ action.command_ms = commandMs;
773
+ action.result = ok ? "succeeded" : "failed";
774
+ action.error_class = ok ? null : errorClassOf(errorText ?? `exit ${exitCode}`);
775
+ if (ok && evidenceKind && state.evidence_refs.length < MAX_EVIDENCE_REFS) state.evidence_refs.push(`${evidenceKind}:${sourceRunId}/${actionId}`);
776
+ if (serverEnded) {
777
+ // The run ended server-side before this action was accepted: record it (and any
778
+ // other action the server never accepted) in a fresh session instead of dropping it.
779
+ const lost = lostToServerEnd(state);
780
+ if (lost.length) { await rehome(state, lost, probe); rehomed = lost.length; }
781
+ return;
782
+ }
783
+ if (action.verb === "open") {
784
+ const live = await probe();
785
+ // A failed open that still left a usable browser is recoverable: it keeps a heartbeat.
786
+ if (live.alive) state.daemon_generation = live.generation;
787
+ // An open that left no live browser can never recover or close: end it with what
788
+ // was observed now — failed if the open failed, crashed if it succeeded but the
789
+ // daemon is already gone — instead of letting the reaper call it abandoned.
790
+ if (!live.alive && !state.daemon_generation) {
791
+ await emitActionFinish(state, action);
792
+ await terminalize(state, ok ? "crashed" : "failed", ok ? { code: "browser_daemon_exited", category: "crash" } : { code: action.error_class, category: "open" });
793
+ await persistState(state);
794
+ return;
795
+ }
796
+ }
797
+ await emitActionFinish(state, action);
798
+ await persistState(state);
799
+ });
800
+ } catch (error) {
801
+ // The action already ran and keeps its exit code; the lost result must still be visible.
802
+ persistFailed = errorClassOf(error?.code ?? error?.message ?? "error");
803
+ }
804
+ let post = await deliverPending(POST_ACTION_DELIVERY_MS);
805
+ if (post.server_ended && !rehomed) {
806
+ // The server's terminal surfaced only now (e.g. this action's start was still queued
807
+ // when the pre-action delivery ran): re-record what it never accepted.
808
+ await withLock(stateLock, async () => {
809
+ const state = await readLockedState();
810
+ if (!state || state.source_run_id !== sourceRunId) return;
811
+ const lost = lostToServerEnd(state);
812
+ if (lost.length) { await rehome(state, lost, probe); rehomed = lost.length; }
813
+ }).catch(() => {});
814
+ if (rehomed) post = { ...(await deliverPending(POST_ACTION_DELIVERY_MS)), server_ended: post.server_ended };
815
+ }
816
+ // A rejection during the concurrent pre-action delivery is reported too.
817
+ const quarantined = post.quarantined + (preDelivery?.quarantined ?? 0);
818
+ const heartbeat = await ensureHeartbeat();
819
+ const delivery = {
820
+ ...post, quarantined, persist_failed: persistFailed, heartbeat_failed: heartbeat.failed, rehomed,
821
+ server_ended: post.server_ended ?? preDelivery?.server_ended ?? null,
822
+ degraded: post.degraded || quarantined > 0 || Boolean(persistFailed) || heartbeat.failed,
823
+ };
824
+ release();
825
+ return delivery;
826
+ },
827
+ async cancel(signal) {
828
+ try {
829
+ await withLock(stateLock, async () => {
830
+ const state = await readLockedState();
831
+ if (!state || state.source_run_id !== sourceRunId || state.status !== "active") return;
832
+ await terminalize(state, "canceled", { code: "signal", category: "signal", signal: String(signal) });
833
+ await persistState(state);
834
+ });
835
+ } catch { /* best effort on the way out */ }
836
+ await deliverPending(POST_ACTION_DELIVERY_MS);
837
+ },
838
+ release,
839
+ };
840
+ },
841
+
842
+ /** close: terminalize the active session once; repeated close is a no-op. */
843
+ /**
844
+ * close: terminalize the active session once; repeated close is a no-op.
845
+ * A clean close is passed/failed by closeOutcome. A browser daemon that was
846
+ * already gone (or replaced) before close is `crashed`; a close that exits
847
+ * non-zero with the browser still owned is `failed` with a bounded error
848
+ * class. A failed close never terminalizes as passed (Codex R1 P1).
849
+ * `run` returns `{ code, errorText }` (errorText is classified, never stored).
850
+ */
851
+ async close({ environment, run, probe }) {
852
+ return withLock(execLock, async () => {
853
+ const marked = await withLock(stateLock, async () => {
854
+ const state = await readLockedState();
855
+ if (!state || state.status !== "active") return { active: false };
856
+ const mismatch = environmentGuard(state, environment);
857
+ if (mismatch) return { error: mismatch };
858
+ state.closing = true;
859
+ state.closing_pid = process.pid;
860
+ state.closing_since = iso();
861
+ await persistState(state);
862
+ return { active: true, sourceRunId: state.source_run_id, generation: state.daemon_generation };
863
+ });
864
+ if (marked.error) return { error: marked.error };
865
+ const live = marked.active && probe ? await probe() : { alive: true, generation: marked.generation ?? null };
866
+ const ranAt = await run();
867
+ const code = typeof ranAt === "number" ? ranAt : ranAt?.code ?? 1;
868
+ const errorText = typeof ranAt === "number" ? null : ranAt?.errorText ?? null;
869
+ // No active session: a retried close still drains a terminal queued while offline.
870
+ if (!marked.active) return { code, delivery: await deliverPending(POST_ACTION_DELIVERY_MS) };
871
+ const daemonGone = !live.alive || Boolean(marked.generation && live.generation && live.generation !== marked.generation);
872
+ await withLock(stateLock, async () => {
873
+ const state = await readLockedState();
874
+ if (!state || state.source_run_id !== marked.sourceRunId || state.status !== "active") return;
875
+ if (daemonGone && state.daemon_generation) {
876
+ await terminalize(state, "crashed", { code: "browser_daemon_exited", category: "crash" });
877
+ } else if (code !== 0) {
878
+ await terminalize(state, "failed", { code: errorClassOf(errorText ?? `exit ${code}`), category: "close" });
879
+ } else {
880
+ await terminalize(state, CLEAN_CLOSE);
881
+ }
882
+ await persistState(state);
883
+ });
884
+ const delivery = await deliverPending(POST_ACTION_DELIVERY_MS);
885
+ return { code, delivery };
886
+ }, { attempts: EXEC_LOCK_ATTEMPTS });
887
+ },
888
+
889
+ async setVerdict(verdict, { environment } = {}) {
890
+ if (!VERDICTS.includes(verdict)) return { error: "bb-pw: usage: bb-pw <lane> verdict pass|fail" };
891
+ const result = await withLock(execLock, () => withLock(stateLock, async () => {
892
+ const state = await readLockedState();
893
+ if (!state || state.status !== "active") return { error: `bb-pw: lane ${lane} has no open tracked browser session to record a verdict on.` };
894
+ const mismatch = environmentGuard(state, environment);
895
+ if (mismatch) return { error: mismatch };
896
+ // A recorded fail is final for the session (the server refuses to overturn it too,
897
+ // Codex R29): re-test in a new session instead.
898
+ if (state.verdict === "fail" && verdict !== "fail") return { error: `bb-pw: lane ${lane}'s recorded fail verdict is final for this session — close it and open a new session to re-test.` };
899
+ state.verdict = verdict;
900
+ await emit(state, `verdict:${state.next_sequence}`, "progress", {
901
+ progress: { completed: state.actions.filter((item) => item.result).length, phase: "testing" },
902
+ browser: { summary: summarize(state) }, evidence_refs: evidenceRefs(state),
903
+ });
904
+ await persistState(state);
905
+ return { ok: true };
906
+ }), { attempts: EXEC_LOCK_ATTEMPTS });
907
+ if (result.error) return result;
908
+ return { ok: true, delivery: await deliverPending(POST_ACTION_DELIVERY_MS) };
909
+ },
910
+
911
+ /** One heartbeat tick for the session-owned heartbeat process. */
912
+ async heartbeatTick({ sourceRunId, daemonAlive }) {
913
+ const result = await withLock(stateLock, async () => {
914
+ const state = await readLockedState();
915
+ if (!state || state.source_run_id !== sourceRunId || state.status !== "active") return { stop: true, reason: "terminal" };
916
+ if (state.closing) {
917
+ const since = Date.parse(state.closing_since ?? "");
918
+ const closerLive = pidAlive(state.closing_pid) && Number.isFinite(since) && now().getTime() - since < CLOSING_GRACE_MS;
919
+ if (closerLive) return { stop: false, reason: "closing" };
920
+ // The closer died or stalled before terminalizing: clear the marker and resume liveness.
921
+ state.closing = false;
922
+ state.closing_pid = null;
923
+ state.closing_since = null;
924
+ if (!(await daemonAlive(state))) {
925
+ await terminalize(state, "failed", { code: "close_interrupted", category: "close" });
926
+ await persistState(state);
927
+ return { stop: true, reason: "close_interrupted" };
928
+ }
929
+ }
930
+ // Before any browser generation exists (a long first open), the in-flight command's
931
+ // live owner is the liveness proof — the daemon may not be up yet.
932
+ if (!state.daemon_generation && hasLiveInflight(state)) {
933
+ await emit(state, `hb:${state.next_sequence}`, "heartbeat", { browser: {} });
934
+ await persistState(state);
935
+ return { stop: false, reason: "heartbeat" };
936
+ }
937
+ if (!(await daemonAlive(state))) {
938
+ await terminalize(state, "crashed", { code: "browser_daemon_exited", category: "crash" });
939
+ await persistState(state);
940
+ return { stop: true, reason: "crashed" };
941
+ }
942
+ await emit(state, `hb:${state.next_sequence}`, "heartbeat", { browser: {} });
943
+ await persistState(state);
944
+ return { stop: false, reason: "heartbeat" };
945
+ });
946
+ if (result.reason === "closing") return result;
947
+ // Also on "terminal": a close may have terminalized while delivery was down, and
948
+ // this heartbeat may be the only process left to deliver that queued terminal.
949
+ const delivery = await deliverPending(POST_ACTION_DELIVERY_MS);
950
+ return { ...result, pending: delivery?.queued ?? 0 };
951
+ },
952
+
953
+ /** One bounded delivery attempt of this lane's queue; used to drain a terminal after the heartbeat stops. */
954
+ async drainPending() {
955
+ return deliverPending(POST_ACTION_DELIVERY_MS);
956
+ },
957
+ };
958
+ }
959
+
960
+ /**
961
+ * The injectable core: a resolver bound to one verified tenant/repository
962
+ * identity. `deliver(event)` is the BOT-1316 direct ingest delivery.
963
+ */
964
+ export function createBrowserTelemetryResolver({
965
+ root = join(homedir(), ".botbuddy"), tenant, repository, producerVersion, deliver,
966
+ now = () => new Date(), monotonic = () => performance.now(), gitInfo = () => ({}), spawnHeartbeat = null,
967
+ onAppended = null, // test seam: observe each durable append (e.g. to simulate a crash right after it)
968
+ onStateWrite = null, // test seam: inject a state-write failure
969
+ } = {}) {
970
+ let outboxPromise = null;
971
+ const getOutbox = () => (outboxPromise ??= createTelemetryOutbox({ root: join(root, "telemetry"), tenant, repository, producerVersion, now }));
972
+ const trackerFor = ({ host, lane }) => createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended, onStateWrite });
973
+ return {
974
+ async resolve({ host, lane }) { return { tracked: true, tracker: trackerFor({ host, lane }) }; },
975
+ async forHeartbeat(info) { return trackerFor({ host: info.host, lane: info.lane }); },
976
+ };
977
+ }
978
+
979
+ /** Heartbeat loop: ≤20 s ticks until the session terminalizes or its browser dies. */
980
+ export async function runHeartbeatLoop({ tracker, sourceRunId, daemonAlive, sleep: wait = sleep, intervalMs = HEARTBEAT_INTERVAL_MS, maxTicks = Infinity, maxDrainTicks = TERMINAL_DRAIN_TICKS }) {
981
+ for (let tick = 0; tick < maxTicks; tick++) {
982
+ await wait(Math.min(intervalMs, 20_000));
983
+ let result;
984
+ try { result = await tracker.heartbeatTick({ sourceRunId, daemonAlive }); } catch { continue; }
985
+ if (!result.stop) continue;
986
+ if (!result.pending) return { reason: result.reason, drained: true };
987
+ // The heartbeat observed the end (e.g. a crash) while delivery was down: it is the
988
+ // only process left to deliver that terminal, so it keeps retrying (bounded).
989
+ for (let drain = 0; drain < maxDrainTicks; drain++) {
990
+ await wait(Math.min(intervalMs, 20_000));
991
+ const delivery = await tracker.drainPending().catch(() => null);
992
+ if (delivery && !delivery.queued) return { reason: result.reason, drained: true };
993
+ }
994
+ return { reason: result.reason, drained: false };
995
+ }
996
+ return { reason: "max_ticks" };
997
+ }