@botbuddy/cli 1.59.9 → 1.60.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,496 @@
1
+ // BOT-2600 — bounded terminal delivery for durable `bb run` commands.
2
+ //
3
+ // A detached `bb run` worker owns its workload and writes exactly one receipt.
4
+ // Once that receipt is durable, publishing it (update_command_run) is only a
5
+ // delivery problem, and it must not keep a resident Node process per run alive:
6
+ // on 2026-10-20 one Mac had 142 finished workers (6 GB) retrying rejections
7
+ // that could never succeed. So:
8
+ //
9
+ // - the worker retries transient failures within a bounded monotonic budget
10
+ // and parks permanent rejections immediately, recording
11
+ // `terminal_update.parked` in its private context, then exits;
12
+ // - a single per-machine delivery process (`bb run --deliver`, guarded by a
13
+ // PID lock under ~/.botbuddy/) retries what is still retryable and exits
14
+ // when nothing retryable remains. Any `bb` invocation starts it on demand;
15
+ // - workers left by older CLI versions (which retry forever) are adopted:
16
+ // their delivery is taken over and the worker is stopped only when it is
17
+ // provably finished (exact command line, same user, terminal receipt, no
18
+ // child processes, already in its terminal retry loop).
19
+ //
20
+ // The receipt and context files stay authoritative; nothing is deleted except a
21
+ // context whose delivery was accepted (exactly what the worker always did).
22
+ // The identity model is unchanged (BOT-2052): a delivery is only ever sent with
23
+ // the session token sealed for the run's owner, never an ambient credential.
24
+
25
+ import { createHash, randomUUID } from "node:crypto";
26
+ import { chmod, link, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
27
+ import { execFile, spawn } from "node:child_process";
28
+ import { dirname, join } from "node:path";
29
+ import { homedir } from "node:os";
30
+ import { fileURLToPath } from "node:url";
31
+ import { performance } from "node:perf_hooks";
32
+ import { AGENT_KEY_RE } from "./agent-key.mjs";
33
+ import { DEFAULT_SERVER_URL } from "./config.mjs";
34
+
35
+ /** The worker's own retry budget for transient failures (AC-1: ≤ 10 minutes). */
36
+ export const TERMINAL_RETRY_BUDGET_MS = 10 * 60_000;
37
+ /** One update_command_run attempt never waits longer than this. */
38
+ export const DELIVERY_CALL_TIMEOUT_MS = 30_000;
39
+ /** Transient attempts the delivery process makes before parking for good. */
40
+ export const DELIVERY_MAX_ATTEMPTS = 50;
41
+ /** The delivery process exits after this long even with retryable work left. */
42
+ export const DELIVERY_LIFETIME_MS = 30 * 60_000;
43
+ /** A non-forced `bb` invocation re-scans at most this often. */
44
+ export const DELIVERY_RESCAN_COOLDOWN_MS = 60_000;
45
+ const DELIVERY_FIRST_BACKOFF_MS = 30_000;
46
+ const DELIVERY_MAX_BACKOFF_MS = 5 * 60_000;
47
+ const STOP_GRACE_MS = 3_000;
48
+
49
+ export const TERMINAL_RECEIPT_STATUSES = new Set(["success", "failure", "cancelled", "timed_out", "owner_lost"]);
50
+ const API_STATUS = { success: "succeeded", failure: "failed", cancelled: "cancelled", timed_out: "timed_out", owner_lost: "owner_lost" };
51
+ const MAX_CAPTURE_BYTES = 8_192;
52
+ // The update_command_run arguments a persisted terminal_update may carry.
53
+ const UPDATE_FIELDS = ["run_id", "run_credential", "status", "exit_code", "invalidated_reason", "receipt_path", "receipt_content_type", "receipt_byte_size", "receipt_sha256", "output_characters", "truncated"];
54
+
55
+ // Never retried: decided locally without a request reaching the server (no
56
+ // usable session token), or a definitive server state (a different terminal
57
+ // status is already recorded; the payload itself is refused).
58
+ const PERMANENT_ERRORS = new Set([
59
+ "session_token_required", "session_token_malformed", "not_authenticated", "token_expired",
60
+ "command_terminal_immutable", "artifact_required",
61
+ ]);
62
+ // Trust boundary for server rejections. The server can answer with an auth or
63
+ // credential rejection when its own lookup failed transiently (it discards the
64
+ // read error: BOT-2611), so none of these is proof that a retry cannot
65
+ // succeed. The worker parks every one at once and exits (it never spends its
66
+ // retry budget on them), and the delivery process gives each a few spaced
67
+ // attempts before parking it for good, so a recovered server recovers the
68
+ // terminal result while a dead credential costs at most a handful of calls.
69
+ const AMBIGUOUS_ERRORS = new Set([
70
+ "agent_identity_required", "authentication_required", "unauthorized", "run_credential_invalid",
71
+ "session_token_revoked", "credential_unknown",
72
+ ]);
73
+ /** Delivery-process attempts for an ambiguous rejection before parking for good. */
74
+ export const DELIVERY_AMBIGUOUS_MAX_ATTEMPTS = 5;
75
+ const LOCAL_ENVIRONMENT_RE =/uv_os_get_passwd|\bENOENT\b|\bEACCES\b|\bEPERM\b/;
76
+
77
+ export function commandRunsDir(home = homedir()) { return join(home, ".botbuddy", "command-runs"); }
78
+ export function deliveryLockPath(home = homedir()) { return join(home, ".botbuddy", "run-delivery.lock"); }
79
+ export function deliveryStatePath(home = homedir()) { return join(home, ".botbuddy", "run-delivery.json"); }
80
+
81
+ function errorText(result) {
82
+ const value = result?.data?.error ?? result?.error ?? result?.code ?? null;
83
+ return typeof value === "string" && value ? value : "terminal_update_rejected";
84
+ }
85
+
86
+ export function terminalUpdateAccepted(result) {
87
+ return Boolean(result?.ok && !result.isError && result.data?.error == null && result.data?.accepted !== false);
88
+ }
89
+
90
+ /**
91
+ * Classify a failed update_command_run attempt.
92
+ * `parkNow` marks a retryable failure the worker must not spend its own
93
+ * budget on: it parks at once and only the delivery process retries it.
94
+ * @returns {{ reason: string, retryable: boolean, kind: string, parkNow?: boolean }}
95
+ */
96
+ export function classifyTerminalUpdateFailure(result, thrown = null) {
97
+ if (thrown) {
98
+ const message = thrown instanceof Error ? thrown.message : String(thrown);
99
+ if (thrown?.code === "ERR_SYSTEM_ERROR" || LOCAL_ENVIRONMENT_RE.test(message)) {
100
+ return { reason: message, retryable: false, kind: "local_environment" };
101
+ }
102
+ return { reason: message, retryable: true, kind: "transient" };
103
+ }
104
+ const reason = errorText(result);
105
+ const codes = [result?.data?.error, result?.error, result?.code, result?.error_code].filter((v) => typeof v === "string");
106
+ if (codes.some((code) => PERMANENT_ERRORS.has(code))) return { reason, retryable: false, kind: "permanent_rejection" };
107
+ if (result?.auth === true || codes.some((code) => AMBIGUOUS_ERRORS.has(code))) return { reason, retryable: true, kind: "ambiguous_rejection", parkNow: true };
108
+ if (codes.some((code) => code.startsWith("usage:"))) return { reason, retryable: false, kind: "local_environment" };
109
+ // Transport failures, timeouts, 5xx/429 and anything unrecognised stay
110
+ // retryable; the budget (worker) or attempt cap (delivery process) bounds them.
111
+ return { reason, retryable: true, kind: "transient" };
112
+ }
113
+
114
+ /** The sealed owner's identity, never an ambient or machine-level credential. */
115
+ export function callWithContextIdentity(context, call) {
116
+ const token = sealedToken(context);
117
+ // Old contexts cannot safely fall back to the machine-level owner key. Ask
118
+ // the API layer to require a session instead; the retained receipt/context
119
+ // makes recovery possible without attributing a callback to the wrong agent.
120
+ return (name, args, extra = {}) => call(name, args, token
121
+ ? { ...extra, auth: { "x-agent-api-key": token } }
122
+ : { ...extra, requireSessionToken: true });
123
+ }
124
+
125
+ function sealedToken(context) {
126
+ return typeof context?.callback_session_token === "string" && AGENT_KEY_RE.test(context.callback_session_token)
127
+ ? context.callback_session_token : null;
128
+ }
129
+
130
+ export async function writeJsonAtomic(path, value) {
131
+ await mkdir(dirname(path), { recursive: true });
132
+ // Write-then-rename so a concurrent reader never observes a torn file.
133
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
134
+ await writeFile(tmp, JSON.stringify(value), { mode: 0o600 });
135
+ await chmod(tmp, 0o600);
136
+ await rename(tmp, path);
137
+ }
138
+
139
+ async function readJson(path) {
140
+ try { return JSON.parse(await readFile(path, "utf8")); } catch (error) {
141
+ if (error?.code === "ENOENT") return null;
142
+ if (error instanceof SyntaxError) return undefined;
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ function withTimeout(extra = {}) {
148
+ return { ...extra, signal: AbortSignal.timeout(DELIVERY_CALL_TIMEOUT_MS) };
149
+ }
150
+
151
+ /**
152
+ * The endpoint a run was registered with. A sealed token is only ever sent
153
+ * back there, whatever endpoint the delivery process itself was started with.
154
+ * Contexts written before BOT-2600 did not record it; the CLI's default
155
+ * (production) endpoint is the only one a context without an override could
156
+ * have been registered with by default, so it is used for those.
157
+ */
158
+ export function contextServerUrl(context) {
159
+ return typeof context?.server_url === "string" && /^https?:\/\//.test(context.server_url) ? context.server_url : DEFAULT_SERVER_URL;
160
+ }
161
+
162
+ /** One update_command_run attempt with the sealed identity and a bounded wait. */
163
+ export async function attemptTerminalUpdate(context, update, call) {
164
+ const identified = callWithContextIdentity(context, call);
165
+ try {
166
+ const result = await identified("update_command_run", update, withTimeout({ serverUrl: contextServerUrl(context) }));
167
+ if (terminalUpdateAccepted(result)) return { accepted: true, result };
168
+ return { accepted: false, result, failure: classifyTerminalUpdateFailure(result) };
169
+ } catch (error) {
170
+ return { accepted: false, result: null, failure: classifyTerminalUpdateFailure(null, error) };
171
+ }
172
+ }
173
+
174
+ export function parkedRecord(failure, by) {
175
+ return { reason: failure.reason, retryable: failure.retryable, kind: failure.kind, by, parked_at: new Date().toISOString() };
176
+ }
177
+
178
+ /** Rebuild the terminal update from a durable receipt (a worker that crashed first). */
179
+ export async function terminalUpdateFromReceipt(context, receipt) {
180
+ const bytes = await readFile(context.receipt_path);
181
+ const outputCharacters = Number.isInteger(receipt.output_characters) ? receipt.output_characters : 0;
182
+ const reason = receipt.reason ?? (receipt.status === "timed_out" ? "timeout" : receipt.status === "owner_lost" ? "owner_lost" : undefined);
183
+ return {
184
+ run_id: context.run_id, run_credential: context.run_credential, status: API_STATUS[receipt.status],
185
+ exit_code: receipt.exit_code ?? undefined, invalidated_reason: reason ?? undefined,
186
+ receipt_path: context.receipt_upload, receipt_content_type: "application/json",
187
+ receipt_byte_size: bytes.length, receipt_sha256: createHash("sha256").update(bytes).digest("hex"),
188
+ output_characters: outputCharacters, truncated: outputCharacters > MAX_CAPTURE_BYTES,
189
+ };
190
+ }
191
+
192
+ function updateFromPersisted(terminalUpdate) {
193
+ const update = {};
194
+ for (const key of UPDATE_FIELDS) if (terminalUpdate[key] !== undefined) update[key] = terminalUpdate[key];
195
+ return update;
196
+ }
197
+
198
+ // ── processes ────────────────────────────────────────────────────────────────
199
+
200
+ export function parseProcessList(text) {
201
+ const rows = [];
202
+ for (const line of String(text).split("\n")) {
203
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s(.*)$/.exec(line);
204
+ if (match) rows.push({ pid: Number(match[1]), ppid: Number(match[2]), uid: Number(match[3]), args: match[4].trim() });
205
+ }
206
+ return rows;
207
+ }
208
+
209
+ export function listProcessesWithPs() {
210
+ return new Promise((resolve, reject) => {
211
+ execFile("ps", ["-ww", "-A", "-o", "pid=,ppid=,uid=,args="], { maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => {
212
+ if (error) reject(error); else resolve(parseProcessList(stdout));
213
+ });
214
+ });
215
+ }
216
+
217
+ export function pidAlive(pid) {
218
+ if (!Number.isInteger(pid) || pid <= 0) return false;
219
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; }
220
+ }
221
+
222
+ // Every process whose argument string names this context as its worker.
223
+ function workersFor(processes, contextFile) {
224
+ const suffix = ` run --worker ${contextFile}`;
225
+ return processes.filter((proc) => proc.args.endsWith(suffix) || proc.args.includes(`${suffix} `));
226
+ }
227
+
228
+ // AC-4: exactly `<node> <…>botbuddy.mjs run --worker <this context>`, owned by
229
+ // this user, and with no child process. The receipt/terminal checks are the
230
+ // caller's, made on the same context read.
231
+ export function isStoppableLegacyWorker(proc, { contextFile, uid, processes }) {
232
+ const suffix = ` run --worker ${contextFile}`;
233
+ if (!proc.args.endsWith(suffix)) return false;
234
+ if (!/^\S+\s+\S*botbuddy\.mjs$/.test(proc.args.slice(0, -suffix.length))) return false;
235
+ if (proc.uid !== uid) return false;
236
+ return !processes.some((other) => other.ppid === proc.pid);
237
+ }
238
+
239
+ async function stopLegacyWorker(pid, { contextFile, uid, listProcesses, killProcess, isAlive, sleep }) {
240
+ // Re-verify on a fresh listing right before signalling (PID reuse, a new child).
241
+ const fresh = await listProcesses();
242
+ const proc = fresh.find((candidate) => candidate.pid === pid);
243
+ if (!proc || !isStoppableLegacyWorker(proc, { contextFile, uid, processes: fresh })) return !proc;
244
+ killProcess(pid, "SIGTERM");
245
+ for (let waited = 0; waited < STOP_GRACE_MS; waited += 100) {
246
+ if (!isAlive(pid)) return true;
247
+ await sleep(100);
248
+ }
249
+ return !isAlive(pid);
250
+ }
251
+
252
+ // ── one drain pass ────────────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * Deliver every pending terminal update once.
256
+ * @returns {Promise<{ delivered: number, parked: number, pending: number, stopped: number, owned: number, wrote: boolean }>}
257
+ */
258
+ export async function drainOnce({
259
+ home = homedir(), call, listProcesses = listProcessesWithPs, killProcess = (pid, signal) => process.kill(pid, signal),
260
+ isAlive = pidAlive, uid = process.getuid?.() ?? -1, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
261
+ listContexts = async () => (await readdir(commandRunsDir(home)).catch((error) => { if (error?.code === "ENOENT") return []; throw error; }))
262
+ .filter((name) => name.endsWith(".context.json")).sort(),
263
+ maxAttempts = DELIVERY_MAX_ATTEMPTS,
264
+ } = {}) {
265
+ const totals = { delivered: 0, parked: 0, pending: 0, stopped: 0, owned: 0, wrote: false };
266
+ const names = await listContexts();
267
+ if (!names.length) return totals;
268
+ let processes = null;
269
+ for (const name of names) {
270
+ const contextFile = join(commandRunsDir(home), name);
271
+ const context = await readJson(contextFile);
272
+ // Deleted by a successful delivery mid-scan, or unreadable: not ours.
273
+ if (!context || typeof context.run_id !== "string" || typeof context.receipt_path !== "string") continue;
274
+ // A pre-workload cancellation is owned by the cancel handoff path. A
275
+ // marker written by a cancel that raced workload start leaves the worker
276
+ // owning a normal terminal update, which stays eligible here.
277
+ if (!context.terminal_update && await readFile(`${contextFile}.cancelled`).then(() => true, () => false)) continue;
278
+ const receipt = await readJson(context.receipt_path);
279
+ // Still executing (no terminal receipt yet): never touched.
280
+ if (!receipt || receipt.run_id !== context.run_id || !TERMINAL_RECEIPT_STATUSES.has(receipt.status)) continue;
281
+ const persisted = context.terminal_update ?? null;
282
+ if (persisted?.parked && persisted.parked.retryable === false) continue;
283
+ processes ??= await listProcesses();
284
+ const workers = workersFor(processes, contextFile).filter((proc) => isAlive(proc.pid));
285
+ if (workers.length && !persisted?.parked) {
286
+ // A bounded (current) worker owns its delivery until it parks it. A
287
+ // legacy worker is adopted only once it is provably in its terminal
288
+ // retry loop and stopping it cannot touch running work.
289
+ const legacyLoop = persisted && persisted.retry_budget_ms === undefined;
290
+ const stoppable = legacyLoop && workers.every((proc) => isStoppableLegacyWorker(proc, { contextFile, uid, processes }));
291
+ if (!stoppable) { totals.owned += 1; continue; }
292
+ let allStopped = true;
293
+ for (const proc of workers) {
294
+ if (await stopLegacyWorker(proc.pid, { contextFile, uid, listProcesses, killProcess, isAlive, sleep })) totals.stopped += 1;
295
+ else allStopped = false;
296
+ }
297
+ processes = null;
298
+ if (!allStopped) { totals.owned += 1; continue; }
299
+ }
300
+ // Re-read: a stopped legacy worker may have rewritten the context last.
301
+ const current = await readJson(contextFile);
302
+ if (!current) continue;
303
+ const update = current.terminal_update ? updateFromPersisted(current.terminal_update) : await terminalUpdateFromReceipt(current, receipt);
304
+ const previous = current.terminal_update ?? {};
305
+ const attempts = (Number.isInteger(previous.attempts) ? previous.attempts : 0) + 1;
306
+ const deliveryAttempts = (Number.isInteger(previous.delivery_attempts) ? previous.delivery_attempts : 0) + 1;
307
+ if (!sealedToken(current)) {
308
+ // No sealed owner: a delivery from this process could only use an
309
+ // ambient identity, which BOT-2052 forbids. Park it visibly instead.
310
+ const failure = { reason: "callback_session_token_missing", retryable: false, kind: "callback_session_token_missing" };
311
+ await writeJsonAtomic(contextFile, { ...current, terminal_update: { ...previous, ...update, parked: parkedRecord(failure, "delivery") } });
312
+ totals.parked += 1; totals.wrote = true;
313
+ continue;
314
+ }
315
+ const outcome = await attemptTerminalUpdate(current, update, call);
316
+ if (outcome.accepted) {
317
+ try { await unlink(contextFile); } catch { /* already removed */ }
318
+ totals.delivered += 1; totals.wrote = true;
319
+ continue;
320
+ }
321
+ let failure = outcome.failure;
322
+ const cap = failure.kind === "ambiguous_rejection" ? Math.min(maxAttempts, DELIVERY_AMBIGUOUS_MAX_ATTEMPTS) : maxAttempts;
323
+ if (failure.retryable && deliveryAttempts >= cap) failure = { ...failure, retryable: false, kind: "delivery_attempts_exhausted" };
324
+ await writeJsonAtomic(contextFile, { ...current, terminal_update: {
325
+ ...previous, ...update, attempts, delivery_attempts: deliveryAttempts, last_error: failure.reason,
326
+ parked: failure.retryable ? { ...(previous.parked ?? parkedRecord(failure, "delivery")), reason: failure.reason, retryable: true } : parkedRecord(failure, "delivery"),
327
+ } });
328
+ totals.wrote = true;
329
+ if (failure.retryable) totals.pending += 1; else totals.parked += 1;
330
+ }
331
+ return totals;
332
+ }
333
+
334
+ // ── the per-machine lock ──────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * Take the per-machine delivery lock. A lock whose holder PID is gone is stale
338
+ * and is taken over.
339
+ */
340
+ export async function acquireDeliveryLock({ home = homedir(), pid = process.pid, isAlive = pidAlive } = {}) {
341
+ const lockFile = deliveryLockPath(home);
342
+ await mkdir(dirname(lockFile), { recursive: true });
343
+ const candidate = `${lockFile}.${pid}.${randomUUID()}.tmp`;
344
+ await writeFile(candidate, JSON.stringify({ pid, started_at: new Date().toISOString() }), { mode: 0o600 });
345
+ try {
346
+ for (let round = 0; round < 3; round++) {
347
+ try {
348
+ await link(candidate, lockFile); // atomic create-if-absent
349
+ return { acquired: true, holderPid: pid, release: async () => {
350
+ const holder = await readJson(lockFile);
351
+ if (holder?.pid === pid) await unlink(lockFile).catch(() => {});
352
+ } };
353
+ } catch (error) {
354
+ if (error?.code !== "EEXIST") throw error;
355
+ }
356
+ const holder = await readJson(lockFile);
357
+ if (holder === null) continue; // released between link and read
358
+ const holderPid = holder?.pid;
359
+ if (Number.isInteger(holderPid) && isAlive(holderPid)) return { acquired: false, holderPid, release: async () => {} };
360
+ // Stale. Only the holder of the reclaim mutex may remove the lock file,
361
+ // so it can never move aside a lock another contender just acquired:
362
+ // while the lock file exists, nobody else can replace it (link fails),
363
+ // and no other reclaimer can run, so what we re-read is what we remove.
364
+ const reclaim = await acquireReclaimMutex(lockFile, candidate, isAlive);
365
+ if (!reclaim) return { acquired: false, holderPid: null, release: async () => {} };
366
+ try {
367
+ const current = await readJson(lockFile);
368
+ if (current && !(Number.isInteger(current.pid) && !isAlive(current.pid))) {
369
+ return { acquired: false, holderPid: current?.pid ?? null, release: async () => {} };
370
+ }
371
+ if (current) await unlink(lockFile).catch((error) => { if (error?.code !== "ENOENT") throw error; });
372
+ } finally {
373
+ await reclaim.release();
374
+ }
375
+ }
376
+ const holder = await readJson(lockFile);
377
+ return { acquired: false, holderPid: holder?.pid ?? null, release: async () => {} };
378
+ } finally {
379
+ await unlink(candidate).catch(() => {});
380
+ }
381
+ }
382
+
383
+ // The reclaim mutex serialises stale-lock removal. Its critical section is a
384
+ // read and an unlink, so a reclaim file left by a crashed contender is taken
385
+ // over only once its holder is dead AND it is older than RECLAIM_STALE_MS.
386
+ // Trust boundary: two contenders racing to clear a crashed reclaimer's mutex
387
+ // in the same instant is not defended further (it needs a crash inside a
388
+ // sub-millisecond section plus concurrent contention).
389
+ const RECLAIM_STALE_MS = 10_000;
390
+ async function acquireReclaimMutex(lockFile, candidate, isAlive) {
391
+ const mutex = `${lockFile}.reclaim`;
392
+ for (let attempt = 0; attempt < 2; attempt++) {
393
+ try {
394
+ await link(candidate, mutex);
395
+ return { release: () => unlink(mutex).catch(() => {}) };
396
+ } catch (error) {
397
+ if (error?.code !== "EEXIST") throw error;
398
+ }
399
+ const holder = await readJson(mutex);
400
+ if (holder === null) continue;
401
+ let ageMs = 0;
402
+ try { ageMs = Date.now() - (await stat(mutex)).mtimeMs; } catch { continue; }
403
+ const dead = !(Number.isInteger(holder?.pid) && isAlive(holder.pid));
404
+ if (!dead || ageMs < RECLAIM_STALE_MS) return null; // another contender is reclaiming
405
+ await unlink(mutex).catch(() => {});
406
+ }
407
+ return null;
408
+ }
409
+
410
+ async function dirMtimeMs(home) {
411
+ try { return (await stat(commandRunsDir(home))).mtimeMs; } catch (error) {
412
+ if (error?.code === "ENOENT") return null;
413
+ throw error;
414
+ }
415
+ }
416
+
417
+ /**
418
+ * The delivery process: hold the lock, drain until nothing retryable remains
419
+ * (or the lifetime cap), record the settled directory state, release, exit.
420
+ */
421
+ export async function runDelivery({
422
+ home = homedir(), call, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => performance.now(),
423
+ lifetimeMs = DELIVERY_LIFETIME_MS, ...drainOptions
424
+ } = {}) {
425
+ const lock = await acquireDeliveryLock({ home });
426
+ if (!lock.acquired) return { outcome: "held", holder_pid: lock.holderPid, delivered: 0, parked: 0, pending: 0, stopped: 0 };
427
+ const totals = { delivered: 0, parked: 0, pending: 0, stopped: 0, owned: 0 };
428
+ const started = now();
429
+ let backoff = DELIVERY_FIRST_BACKOFF_MS;
430
+ let settlePasses = 0;
431
+ try {
432
+ for (;;) {
433
+ const mtime = await dirMtimeMs(home);
434
+ const pass = await drainOnce({ home, call, sleep, ...drainOptions });
435
+ for (const key of ["delivered", "parked", "stopped"]) totals[key] += pass[key];
436
+ totals.pending = pass.pending;
437
+ totals.owned = pass.owned;
438
+ if (pass.pending === 0) {
439
+ // Our own writes move the directory mtime; one more pass confirms the
440
+ // directory is settled so `bb` invocations stop re-starting us.
441
+ if (pass.wrote && settlePasses++ < 3) continue;
442
+ // A delivery still owned by a live worker is not settled: if that
443
+ // worker dies before it writes again, the directory never changes, so
444
+ // leave the state open for a re-scan after the cooldown.
445
+ await writeJsonAtomic(deliveryStatePath(home), { dir_mtime_ms: pass.owned > 0 ? null : mtime, settled_at: new Date().toISOString(), owned: pass.owned });
446
+ return { outcome: "drained", ...totals };
447
+ }
448
+ settlePasses = 0;
449
+ if (now() - started + backoff > lifetimeMs) {
450
+ // Retryable work remains: leave the state unsettled so the next `bb`
451
+ // invocation starts a fresh delivery process.
452
+ await writeJsonAtomic(deliveryStatePath(home), { dir_mtime_ms: null, settled_at: null, pending: pass.pending });
453
+ return { outcome: "lifetime_exhausted", ...totals };
454
+ }
455
+ await sleep(backoff);
456
+ backoff = Math.min(backoff * 2, DELIVERY_MAX_BACKOFF_MS);
457
+ }
458
+ } finally {
459
+ await lock.release();
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Called by every `bb` invocation (and by a worker that parked a retryable
465
+ * delivery): start the delivery process when the run directory changed since
466
+ * it last settled and no live process holds the lock. Cheap: a stat and two
467
+ * small reads. Never throws into the caller's command.
468
+ */
469
+ export async function ensureRunDelivery({
470
+ home = homedir(), env = process.env, spawnImpl = spawn, isAlive = pidAlive, force = false, cooldownMs = DELIVERY_RESCAN_COOLDOWN_MS,
471
+ } = {}) {
472
+ try {
473
+ if (env.BOTBUDDY_RUN_DELIVERY === "off") return { started: false, reason: "disabled" };
474
+ const mtime = await dirMtimeMs(home);
475
+ if (mtime === null) return { started: false, reason: "no_runs" };
476
+ const state = await readJson(deliveryStatePath(home));
477
+ if (!force && state && state.dir_mtime_ms === mtime) return { started: false, reason: "up_to_date" };
478
+ // A worker still inside its own retry budget rewrites its context on every
479
+ // attempt. Re-scanning on each of those changes is wasted work: the worker
480
+ // starts us itself (force) when it parks, so otherwise scan at most once
481
+ // per cooldown. Both times are this machine's filesystem clock.
482
+ if (!force && state) {
483
+ const stateAge = Date.now() - (await stat(deliveryStatePath(home))).mtimeMs;
484
+ if (stateAge >= 0 && stateAge < cooldownMs) return { started: false, reason: "cooldown" };
485
+ }
486
+ const holder = await readJson(deliveryLockPath(home));
487
+ if (Number.isInteger(holder?.pid) && isAlive(holder.pid)) return { started: false, reason: "held" };
488
+ const bin = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "botbuddy.mjs");
489
+ const child = spawnImpl(process.execPath, [bin, "run", "--deliver"], { detached: true, stdio: "ignore", cwd: home, windowsHide: true });
490
+ child.on?.("error", () => { /* best effort: the next invocation retries */ });
491
+ child.unref?.();
492
+ return { started: true, reason: "pending" };
493
+ } catch (error) {
494
+ return { started: false, reason: `error:${error instanceof Error ? error.message : String(error)}` };
495
+ }
496
+ }