@botbuddy/cli 1.29.1 → 1.29.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.29.1",
3
+ "version": "1.29.3",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -377,6 +377,16 @@ export async function stackAuthHeader() {
377
377
  return null;
378
378
  }
379
379
 
380
+ // The MCP lease endpoint accepts a session token in x-agent-api-key, whereas
381
+ // the event-stream relay requires that same token in its bearer form too. Keep
382
+ // the lease RPC shape unchanged and derive the relay-compatible form only at
383
+ // the wait boundary; never substitute an ambient OAuth credential.
384
+ function relayAuthHeaders(auth) {
385
+ const sessionToken = auth?.["x-agent-api-key"];
386
+ if (auth?.Authorization || !sessionToken || !AGENT_KEY_RE.test(sessionToken)) return auth;
387
+ return { ...auth, Authorization: `Bearer ${sessionToken}` };
388
+ }
389
+
380
390
  function parseSseFrames(buffer) {
381
391
  const frames = [];
382
392
  let idx, remaining = buffer;
@@ -402,7 +412,7 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl =
402
412
  const deadline = new Date(Date.now() + timeoutSec * 1000).toISOString();
403
413
  const res = await fetchImpl(`${eventStreamBase()}`, {
404
414
  method: "POST",
405
- headers: { ...auth, "Content-Type": "application/json" },
415
+ headers: { ...relayAuthHeaders(auth), "Content-Type": "application/json" },
406
416
  body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
407
417
  signal,
408
418
  });
@@ -435,7 +445,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
435
445
  const finalizerTimer = setTimeout(() => finalizer.abort(), 5_000);
436
446
  try {
437
447
  const response = await fetchImpl(eventStreamBase(), {
438
- method: "POST", headers: { ...auth, "Content-Type": "application/json" },
448
+ method: "POST", headers: { ...relayAuthHeaders(auth), "Content-Type": "application/json" },
439
449
  body: JSON.stringify({ action: "finalize", wait_session_id: waitSessionId, status, receipt: { outcome: status, lease_id: leaseId } }),
440
450
  signal: finalizer.signal,
441
451
  });
@@ -463,7 +473,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
463
473
  url.searchParams.set("heartbeat", "1");
464
474
  let res;
465
475
  try {
466
- res = await fetchImpl(url, { headers: { ...auth, Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
476
+ res = await fetchImpl(url, { headers: { ...relayAuthHeaders(auth), Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
467
477
  } catch (err) {
468
478
  if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
469
479
  return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
@@ -0,0 +1,410 @@
1
+ // BOT-1656 — crash-recovery state for `bb wait`.
2
+ //
3
+ // Checkpoints are deliberately small, worktree-local data. They never contain a
4
+ // credential, command string, or unbounded spine payload; they only preserve the
5
+ // information required to re-arm the *same* logical observation after a harness
6
+ // restarts.
7
+
8
+ import { chmod, link, lstat, mkdir, open, readFile, readdir, rename, rmdir, unlink } from "node:fs/promises";
9
+ import { readFileSync, unlinkSync } from "node:fs";
10
+ import { execFile as execFileCallback } from "node:child_process";
11
+ import { dirname, join, resolve, relative, sep } from "node:path";
12
+ import { promisify } from "node:util";
13
+
14
+ export const CHECKPOINT_SCHEMA_VERSION = 1;
15
+ export const CHECKPOINT_MAX_BYTES = 256 * 1024;
16
+ // `bb wait` caps a public receipt at 64 KiB. Keep another 8 KiB for the
17
+ // terminal status, ownership, cloud-delivery, and timestamp fields added after
18
+ // initial creation so every accepted checkpoint can durably save its result.
19
+ export const CHECKPOINT_TERMINAL_RESERVE_BYTES = 72 * 1024;
20
+ export const INVENTORY_MAX_ITEMS = 20;
21
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
22
+ const STATUSES = new Set(["preparing", "armed", "interrupted", "terminal", "cancelled", "acknowledged"]);
23
+ const LOCK_MAX_BYTES = 4 * 1024;
24
+ const execFile = promisify(execFileCallback);
25
+ const heldFenceLocks = new Map();
26
+ let installedFenceExitHook = false;
27
+
28
+ export class WaitCheckpointError extends Error {
29
+ constructor(code, message = code) {
30
+ super(message);
31
+ this.name = "WaitCheckpointError";
32
+ this.code = code;
33
+ }
34
+ }
35
+
36
+ function safeId(id) {
37
+ if (typeof id !== "string" || !ID_RE.test(id)) {
38
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "local_wait_id is invalid");
39
+ }
40
+ return id;
41
+ }
42
+
43
+ function assertUnderRoot(root, path) {
44
+ const rel = relative(root, path);
45
+ if (rel === "" || rel.startsWith(`..${sep}`) || rel === ".." || resolve(root, rel) !== path) {
46
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint is outside its worktree state directory");
47
+ }
48
+ }
49
+
50
+ function validateCheckpoint(value) {
51
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.schema_version !== CHECKPOINT_SCHEMA_VERSION) {
52
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint schema is unsupported or malformed");
53
+ }
54
+ safeId(value.local_wait_id);
55
+ if (!STATUSES.has(value.status) || typeof value.deadline_at !== "string" || !Number.isFinite(Date.parse(value.deadline_at))) {
56
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint has invalid status or deadline");
57
+ }
58
+ if (!Array.isArray(value.conditions)) {
59
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint conditions are invalid");
60
+ }
61
+ return value;
62
+ }
63
+
64
+ async function fsyncDirectory(directory) {
65
+ let handle;
66
+ try { handle = await open(directory, "r"); await handle.sync(); }
67
+ catch { /* Windows/filesystems that cannot fsync a directory still get atomic rename. */ }
68
+ finally { await handle?.close(); }
69
+ }
70
+
71
+ async function durableJson(path, value) {
72
+ const text = `${JSON.stringify(value)}\n`;
73
+ if (Buffer.byteLength(text, "utf8") > CHECKPOINT_MAX_BYTES) {
74
+ throw new WaitCheckpointError("wait_checkpoint_write_failed", "checkpoint exceeds 256 KiB");
75
+ }
76
+ const temp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
77
+ let handle;
78
+ try {
79
+ handle = await open(temp, "wx", 0o600);
80
+ await handle.writeFile(text);
81
+ await handle.sync();
82
+ await handle.close();
83
+ handle = null;
84
+ await chmod(temp, 0o600);
85
+ await rename(temp, path);
86
+ await chmod(path, 0o600);
87
+ await fsyncDirectory(dirname(path));
88
+ } catch (error) {
89
+ await handle?.close().catch(() => {});
90
+ await unlink(temp).catch(() => {});
91
+ if (error instanceof WaitCheckpointError) throw error;
92
+ throw new WaitCheckpointError("wait_checkpoint_write_failed", `could not persist wait checkpoint: ${error?.message ?? error}`);
93
+ }
94
+ }
95
+
96
+ async function processStartedAt(pid) {
97
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
98
+ try {
99
+ const { stdout } = await execFile("ps", ["-o", "lstart=", "-p", String(pid)], {
100
+ timeout: 1_000,
101
+ maxBuffer: LOCK_MAX_BYTES,
102
+ });
103
+ const value = String(stdout).trim();
104
+ return value || null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function installFenceExitHook() {
111
+ if (installedFenceExitHook) return;
112
+ installedFenceExitHook = true;
113
+ process.once("exit", () => {
114
+ for (const [path, token] of heldFenceLocks) {
115
+ try {
116
+ const lock = JSON.parse(readFileSync(path, "utf8"));
117
+ if (lock?.token === token) unlinkSync(path);
118
+ } catch { /* A SIGKILL or a replaced lock is intentionally left alone. */ }
119
+ }
120
+ });
121
+ }
122
+
123
+ export function createWaitCheckpointStore({ root = process.cwd(), now = () => new Date() } = {}) {
124
+ const worktree = resolve(root);
125
+ const directory = join(worktree, ".botbuddy", "waits");
126
+ const pathFor = (id) => {
127
+ const path = join(directory, `${safeId(id)}.json`);
128
+ assertUnderRoot(directory, path);
129
+ return path;
130
+ };
131
+ const lockPathFor = (id) => {
132
+ const path = join(directory, `${safeId(id)}.lock`);
133
+ assertUnderRoot(directory, path);
134
+ return path;
135
+ };
136
+ const recoveryPathFor = (id) => {
137
+ const path = join(directory, `${safeId(id)}.recovery`);
138
+ assertUnderRoot(directory, path);
139
+ return path;
140
+ };
141
+ const ensureDirectory = async () => {
142
+ try { await mkdir(directory, { recursive: true, mode: 0o700 }); await chmod(directory, 0o700); }
143
+ catch (error) { throw new WaitCheckpointError("wait_checkpoint_write_failed", `could not create wait state directory: ${error?.message ?? error}`); }
144
+ };
145
+ const read = async (id) => {
146
+ const path = pathFor(id);
147
+ let stat;
148
+ try { stat = await lstat(path); }
149
+ catch (error) {
150
+ if (error?.code === "ENOENT") throw new WaitCheckpointError("wait_checkpoint_missing", `saved wait ${id} was not found`);
151
+ throw new WaitCheckpointError("wait_checkpoint_invalid", `could not inspect saved wait: ${error?.message ?? error}`);
152
+ }
153
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > CHECKPOINT_MAX_BYTES) {
154
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint is not a regular bounded JSON file");
155
+ }
156
+ let parsed;
157
+ try { parsed = JSON.parse(await readFile(path, "utf8")); }
158
+ catch { throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint is not valid JSON"); }
159
+ return validateCheckpoint(parsed);
160
+ };
161
+ const write = async (value) => {
162
+ validateCheckpoint(value);
163
+ await ensureDirectory();
164
+ await durableJson(pathFor(value.local_wait_id), value);
165
+ return value;
166
+ };
167
+ const readLock = async (path) => {
168
+ let stat;
169
+ try { stat = await lstat(path); }
170
+ catch (error) {
171
+ if (error?.code === "ENOENT") return null;
172
+ throw new WaitCheckpointError("wait_owner_unknown", `could not inspect wait owner: ${error?.message ?? error}`);
173
+ }
174
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > LOCK_MAX_BYTES) {
175
+ throw new WaitCheckpointError("wait_owner_unknown", "wait ownership fence is invalid");
176
+ }
177
+ let lock;
178
+ try { lock = JSON.parse(await readFile(path, "utf8")); }
179
+ catch { throw new WaitCheckpointError("wait_owner_unknown", "wait ownership fence is malformed"); }
180
+ if (!lock || typeof lock.token !== "string" || !Number.isSafeInteger(lock.pid) ||
181
+ typeof lock.process_started_at !== "string" || !lock.process_started_at) {
182
+ throw new WaitCheckpointError("wait_owner_unknown", "wait ownership fence is malformed");
183
+ }
184
+ return lock;
185
+ };
186
+ const lockIsLive = async (lock) => {
187
+ try { process.kill(lock.pid, 0); }
188
+ catch (error) {
189
+ if (error?.code === "ESRCH") return false;
190
+ return true; // EPERM is deliberately conservative.
191
+ }
192
+ const startedAt = await processStartedAt(lock.pid);
193
+ // A live PID without a matching process-instance identity is never ownership proof.
194
+ return startedAt != null && startedAt === lock.process_started_at;
195
+ };
196
+ const removeRecoveryDirectory = async (path) => {
197
+ await unlink(join(path, "owner.json")).catch(() => {});
198
+ await rmdir(path).catch(() => {});
199
+ };
200
+ const acquireRecoveryMutex = async (id, owner) => {
201
+ const path = recoveryPathFor(id);
202
+ const temp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
203
+ await mkdir(temp, { mode: 0o700 });
204
+ const token = crypto.randomUUID();
205
+ await durableJson(join(temp, "owner.json"), { ...owner, token });
206
+ const retired = [];
207
+ for (let attempt = 0; attempt < 3; attempt += 1) {
208
+ try {
209
+ await rename(temp, path);
210
+ await fsyncDirectory(directory);
211
+ let released = false;
212
+ return async () => {
213
+ if (released) return;
214
+ released = true;
215
+ // Retire ownership with one atomic rename. A crash while deleting the
216
+ // retired directory leaves the fixed mutex path free for recovery.
217
+ const releasedPath = `${path}.${token}.${crypto.randomUUID()}.released`;
218
+ await rename(path, releasedPath).catch((error) => {
219
+ if (error?.code !== "ENOENT") throw error;
220
+ });
221
+ await removeRecoveryDirectory(releasedPath);
222
+ for (const stale of retired) await removeRecoveryDirectory(stale);
223
+ await fsyncDirectory(directory);
224
+ };
225
+ } catch (error) {
226
+ if (!["EEXIST", "ENOTEMPTY"].includes(error?.code)) {
227
+ await removeRecoveryDirectory(temp);
228
+ throw error;
229
+ }
230
+ }
231
+ let current;
232
+ try { current = await readLock(join(path, "owner.json")); }
233
+ catch (error) {
234
+ if (error?.code !== "wait_owner_unknown") throw error;
235
+ current = null;
236
+ }
237
+ if (current && await lockIsLive(current)) {
238
+ await removeRecoveryDirectory(temp);
239
+ throw new WaitCheckpointError("wait_owner_active", `saved wait ${id} recovery is already in progress`);
240
+ }
241
+ // A missing/malformed owner is also stale. The deterministic target keeps
242
+ // two contenders that observed the same generation from retiring a fresh
243
+ // mutex published after the first contender wins.
244
+ const stale = `${path}.${current?.token ?? "invalid"}.stale`;
245
+ assertUnderRoot(directory, stale);
246
+ try {
247
+ await rename(path, stale);
248
+ retired.push(stale);
249
+ } catch (error) {
250
+ if (!["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error?.code)) throw error;
251
+ }
252
+ }
253
+ await removeRecoveryDirectory(temp);
254
+ throw new WaitCheckpointError("wait_owner_active", `saved wait ${id} recovery ownership changed`);
255
+ };
256
+ return {
257
+ directory,
258
+ async create(input) {
259
+ const id = safeId(input.local_wait_id);
260
+ const timestamp = now().toISOString();
261
+ const checkpoint = validateCheckpoint({
262
+ schema_version: CHECKPOINT_SCHEMA_VERSION,
263
+ local_wait_id: id,
264
+ status: "preparing",
265
+ created_at: timestamp,
266
+ updated_at: timestamp,
267
+ deadline_at: input.deadline_at,
268
+ conditions: input.conditions,
269
+ mode: input.mode ?? "any",
270
+ ...input,
271
+ local_wait_id: id,
272
+ // Scope comes from the store, never caller-supplied checkpoint metadata.
273
+ worktree,
274
+ });
275
+ if (Buffer.byteLength(JSON.stringify(checkpoint), "utf8") >
276
+ CHECKPOINT_MAX_BYTES - CHECKPOINT_TERMINAL_RESERVE_BYTES) {
277
+ throw new WaitCheckpointError(
278
+ "wait_checkpoint_write_failed",
279
+ "checkpoint leaves insufficient space for its terminal receipt",
280
+ );
281
+ }
282
+ return write(checkpoint);
283
+ },
284
+ read,
285
+ async update(id, patch) {
286
+ const current = await read(id);
287
+ const next = validateCheckpoint({ ...current, ...patch, local_wait_id: current.local_wait_id, updated_at: now().toISOString() });
288
+ return write(next);
289
+ },
290
+ async acknowledge(id, { cloudAckPending = false } = {}) {
291
+ const current = await read(id);
292
+ if (current.status === "acknowledged") {
293
+ return cloudAckPending && current.cloud_ack_pending !== true
294
+ ? this.update(id, { cloud_ack_pending: true })
295
+ : current;
296
+ }
297
+ if (current.status !== "terminal") {
298
+ throw new WaitCheckpointError("wait_not_resumable", "only a saved terminal wait can be acknowledged");
299
+ }
300
+ // The local acknowledgement and its cloud-delivery outbox are one durable
301
+ // transition. A crash after hiding the checkpoint must still leave bare
302
+ // `bb wait` a retryable acknowledgement entry.
303
+ return this.update(id, {
304
+ status: "acknowledged",
305
+ acknowledged_at: now().toISOString(),
306
+ cloud_ack_pending: cloudAckPending,
307
+ });
308
+ },
309
+ async acquireFence(id) {
310
+ const localWaitId = safeId(id);
311
+ await ensureDirectory();
312
+ const path = lockPathFor(localWaitId);
313
+ const processStartedAtValue = await processStartedAt(process.pid);
314
+ if (!processStartedAtValue) {
315
+ throw new WaitCheckpointError("wait_owner_unknown", "could not establish this process identity for wait recovery");
316
+ }
317
+ const owner = {
318
+ pid: process.pid,
319
+ process_started_at: processStartedAtValue,
320
+ acquired_at: now().toISOString(),
321
+ };
322
+ let releaseRecovery;
323
+ try {
324
+ releaseRecovery = await acquireRecoveryMutex(localWaitId, owner);
325
+ for (let attempt = 0; attempt < 2; attempt += 1) {
326
+ const token = crypto.randomUUID();
327
+ const candidate = `${path}.${process.pid}.${token}.candidate`;
328
+ try {
329
+ // Write and fsync a private candidate before publishing it with an
330
+ // atomic no-clobber hard link. SIGKILL can leave a candidate behind,
331
+ // but never a partially written fence at the shared path.
332
+ await durableJson(candidate, { ...owner, token });
333
+ await link(candidate, path);
334
+ await unlink(candidate).catch(() => {});
335
+ await fsyncDirectory(directory);
336
+ heldFenceLocks.set(path, token);
337
+ installFenceExitHook();
338
+ let released = false;
339
+ return {
340
+ owner,
341
+ async release() {
342
+ if (released) return;
343
+ released = true;
344
+ heldFenceLocks.delete(path);
345
+ const current = await readLock(path);
346
+ if (current?.token === token) {
347
+ await unlink(path);
348
+ await fsyncDirectory(directory);
349
+ }
350
+ },
351
+ };
352
+ } catch (error) {
353
+ await unlink(candidate).catch(() => {});
354
+ if (error?.code !== "EEXIST") {
355
+ throw new WaitCheckpointError("wait_checkpoint_write_failed", `could not acquire wait fence: ${error?.message ?? error}`);
356
+ }
357
+ }
358
+ let current;
359
+ try { current = await readLock(path); }
360
+ catch (error) {
361
+ if (error?.code !== "wait_owner_unknown") throw error;
362
+ // The recovery mutex prevents a new generation from appearing here.
363
+ // A malformed/partial shared fence therefore belongs to a crashed
364
+ // publisher and is safe to retire before the next atomic attempt.
365
+ await unlink(path).catch((unlinkError) => {
366
+ if (unlinkError?.code !== "ENOENT") throw unlinkError;
367
+ });
368
+ await fsyncDirectory(directory);
369
+ continue;
370
+ }
371
+ if (!current) continue;
372
+ if (await lockIsLive(current)) {
373
+ throw new WaitCheckpointError("wait_owner_active", `saved wait ${localWaitId} already has a live owner`);
374
+ }
375
+ // The crash-reclaimable recovery mutex serializes this replacement, so
376
+ // no second resumer can unlink a fresh generation at the shared path.
377
+ await unlink(path).catch((error) => {
378
+ if (error?.code !== "ENOENT") throw error;
379
+ });
380
+ await fsyncDirectory(directory);
381
+ }
382
+ throw new WaitCheckpointError("wait_owner_active", `saved wait ${localWaitId} ownership changed while recovering`);
383
+ } finally {
384
+ await releaseRecovery?.();
385
+ }
386
+ },
387
+ async list({ includeInactive = false, offset = 0, limit = INVENTORY_MAX_ITEMS } = {}) {
388
+ let names = [];
389
+ try { names = (await readdir(directory)).filter((name) => name.endsWith(".json")).sort(); }
390
+ catch (error) { if (error?.code === "ENOENT") return { items: [], has_more: false, next_offset: null }; throw error; }
391
+ const entries = [];
392
+ for (const name of names) {
393
+ const id = name.slice(0, -5);
394
+ try {
395
+ const value = await read(id);
396
+ const pendingCancellation = value.status === "cancelled" && value.cloud_cancel_pending === true;
397
+ const pendingAcknowledgement = value.status === "acknowledged" && value.cloud_ack_pending === true;
398
+ if (includeInactive || pendingCancellation || pendingAcknowledgement ||
399
+ !["acknowledged", "cancelled"].includes(value.status)) entries.push(value);
400
+ } catch (error) {
401
+ entries.push({ local_wait_id: id, status: "invalid", error: error.code ?? "wait_checkpoint_invalid" });
402
+ }
403
+ }
404
+ entries.sort((a, b) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")));
405
+ const page = entries.slice(offset, offset + Math.min(Math.max(1, limit), INVENTORY_MAX_ITEMS));
406
+ const next = offset + page.length;
407
+ return { items: page, has_more: next < entries.length, next_offset: next < entries.length ? next : null };
408
+ },
409
+ };
410
+ }