@botbuddy/cli 1.4.1 → 1.4.2

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.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -46,7 +46,7 @@ export async function callTool(toolName, args = {}) {
46
46
  // * { ok:true, data } — tool result JSON (data.success may still be false)
47
47
  // * { ok:false, auth:true } — not authenticated / token expired
48
48
  // * { ok:false, status } — HTTP/JSON-RPC/transport error (status may be null)
49
- export async function callToolJson(toolName, args = {}, { fetchImpl = fetch } = {}) {
49
+ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal } = {}) {
50
50
  const cfg = getConfig();
51
51
  let auth;
52
52
  if (cfg.access_token) {
@@ -64,6 +64,7 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch } =
64
64
  method: "POST",
65
65
  headers: { "Content-Type": "application/json", ...auth },
66
66
  body: JSON.stringify(body),
67
+ signal,
67
68
  });
68
69
  } catch (err) {
69
70
  return { ok: false, status: null, error: `transport: ${err?.message ?? err}` };
@@ -13,7 +13,7 @@ test("BOT-1353: the published CLI identifies this profile-bootstrap release", as
13
13
  cwd: new URL("..", import.meta.url),
14
14
  });
15
15
 
16
- assert.equal(stdout.trim(), "botbuddy v1.4.1");
16
+ assert.equal(stdout.trim(), "botbuddy v1.4.2");
17
17
  });
18
18
 
19
19
  test("BOT-1382: profile --help succeeds and documents the subcommands", async () => {
package/src/stack.mjs CHANGED
@@ -21,9 +21,12 @@
21
21
  // labelled fallback for when no Helper is available: it runs `supabase start`/
22
22
  // `stop` itself and self-activates/finalizes via the fallback MCP tools.
23
23
 
24
- import { spawnSync } from "child_process";
24
+ import { spawn, spawnSync } from "child_process";
25
25
  import { readFileSync, realpathSync } from "fs";
26
- import { join, relative, isAbsolute } from "path";
26
+ import { open, unlink, readFile } from "fs/promises";
27
+ import { tmpdir } from "os";
28
+ import { basename, dirname, join, relative, isAbsolute } from "path";
29
+ import { randomUUID } from "crypto";
27
30
  import { callToolJson } from "./api.mjs";
28
31
  import { SERVER_URL, getConfig } from "./config.mjs";
29
32
  import { bold, dim, yellow } from "./utils.mjs";
@@ -42,6 +45,7 @@ export const EXIT = Object.freeze({
42
45
  BACKEND: 5, // server/RPC/transport error, or the coordination request failed
43
46
  LEASE_FAILED: 6, // the lease was reaped / provision failed instead of going active
44
47
  INTERNAL: 7, // unexpected local error
48
+ CLEANUP_FAILED: 8, // child completed but signed physical reap was not proven
45
49
  });
46
50
 
47
51
  export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch-scoped local stack lease (BOT-1218)
@@ -51,6 +55,8 @@ ${bold("USAGE")}
51
55
  botbuddy stack status <lease_id> Show a lease's state + connection
52
56
  botbuddy stack touch <lease_id> Bump the idle clock so the reaper doesn't stop it
53
57
  botbuddy stack done <lease_id> Release (done): tear the stack down
58
+ botbuddy stack run [options] -- <executable> [args...]
59
+ Own one Helper-backed test/fix batch end-to-end
54
60
 
55
61
  ${bold("up OPTIONS")}
56
62
  --slot <slot> Stable stack identity (BOT-1186): the shared CLI stack DB port
@@ -70,6 +76,15 @@ ${bold("done OPTIONS")}
70
76
  --stop Keep volumes (cheap re-provision next batch). Default: destroy.
71
77
  --local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
72
78
 
79
+ ${bold("run OPTIONS")}
80
+ --repo <repo> Required approved repository name.
81
+ --ticket <BOT-123> Required ticket for the batch.
82
+ --provision-timeout N Seconds to wait for a physical Helper provision (default: --timeout).
83
+ --reap-timeout N Seconds to wait for signed physical reap proof (default: 300).
84
+ --connection-file P Optional empty file path for the mode-0600 connection JSON.
85
+ --hard-ttl N Bound the child runtime; timeout requests normal cleanup.
86
+ --local-exec Rejected: stack run never starts Docker directly.
87
+
73
88
  ${bold("GLOBAL")}
74
89
  --json Pretty-print the receipt (default is one compact JSON line).
75
90
  --receipt-max-bytes N Receipt size cap (default ${DEFAULT_RECEIPT_MAX_BYTES}).
@@ -77,13 +92,11 @@ ${bold("GLOBAL")}
77
92
  ${bold("EXIT CODES")}
78
93
  0 ok/held (or queued+--no-wait) 2 park timed out 3 not authenticated
79
94
  4 invalid arguments 5 backend/coordination 6 lease failed/reaped
80
- 7 internal error
95
+ 7 internal error 8 cleanup/reap proof failed
81
96
 
82
97
  ${bold("EXAMPLE")}
83
98
  # request a per-worktree stack for this batch, run a lane against it, then reap it
84
- id=$(botbuddy stack up --repo botbuddy-web --ticket BOT-1220 | jq -r .lease_id)
85
- pnpm test:integration
86
- botbuddy stack done "$id"`;
99
+ botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
87
100
 
88
101
  // ── pure helpers (unit-tested) ───────────────────────────────────────────────
89
102
 
@@ -124,11 +137,15 @@ export function deriveSlot({ slot, repo, ticket } = {}, env = process.env) {
124
137
  */
125
138
  export function parseStackArgs(argv) {
126
139
  const errors = [];
127
- const [command, ...rest] = argv;
140
+ const [command, ...rawRest] = argv;
141
+ const divider = rawRest.indexOf("--");
142
+ const rest = divider === -1 ? rawRest : rawRest.slice(0, divider);
143
+ const childArgv = divider === -1 ? [] : rawRest.slice(divider + 1);
128
144
  const opts = {
129
145
  slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
130
146
  prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".",
131
- timeout: DEFAULT_TIMEOUT_SEC, noWait: false, localExec: false,
147
+ timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
148
+ connectionFile: null, noWait: false, localExec: false,
132
149
  disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
133
150
  };
134
151
  const positionals = [];
@@ -161,6 +178,31 @@ export function parseStackArgs(argv) {
161
178
  }
162
179
  break;
163
180
  }
181
+ case "--provision-timeout": {
182
+ if (need(a, rest[i + 1])) {
183
+ const n = Number(rest[++i]);
184
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--provision-timeout must be a positive integer number of seconds");
185
+ else opts.provisionTimeout = n;
186
+ }
187
+ break;
188
+ }
189
+ case "--reap-timeout": {
190
+ if (need(a, rest[i + 1])) {
191
+ const n = Number(rest[++i]);
192
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--reap-timeout must be a positive integer number of seconds");
193
+ else opts.reapTimeout = n;
194
+ }
195
+ break;
196
+ }
197
+ case "--hard-ttl": {
198
+ if (need(a, rest[i + 1])) {
199
+ const n = Number(rest[++i]);
200
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--hard-ttl must be a positive integer number of seconds");
201
+ else opts.hardTtl = n;
202
+ }
203
+ break;
204
+ }
205
+ case "--connection-file": if (need(a, rest[i + 1])) opts.connectionFile = rest[++i]; break;
164
206
  case "--receipt-max-bytes": {
165
207
  if (need(a, rest[i + 1])) {
166
208
  const n = Number(rest[++i]);
@@ -187,7 +229,17 @@ export function parseStackArgs(argv) {
187
229
  if (command && ["status", "touch", "done"].includes(command) && !leaseId) {
188
230
  errors.push(`${command} needs a <lease_id>`);
189
231
  }
190
- return { command, leaseId, opts, errors };
232
+ if (command === "run") {
233
+ if (!opts.repo) errors.push("run needs --repo <approved-repo>");
234
+ if (!opts.ticket || !/^[A-Z][A-Z0-9]*-\d+$/i.test(opts.ticket)) errors.push("run needs --ticket <BOT-n|ENT-n>");
235
+ if (divider === -1 || childArgv.length === 0) errors.push("run needs an executable after --");
236
+ if (opts.noWait) errors.push("--no-wait is incompatible with run; the command owns its queue wait");
237
+ if (opts.localExec) errors.push("--local-exec is forbidden for run; Helper-backed physical lifecycle is required");
238
+ if (opts.connectionFile && !isAbsolute(opts.connectionFile)) errors.push("--connection-file must be an absolute path");
239
+ } else if (divider !== -1) {
240
+ errors.push("-- <executable> is only valid with stack run");
241
+ }
242
+ return { command, leaseId, opts, errors, childArgv };
191
243
  }
192
244
 
193
245
  /** Build the canonical receipt object (single JSON line to stdout). */
@@ -308,12 +360,13 @@ function parseSseFrames(buffer) {
308
360
  }
309
361
 
310
362
  /** Register a wait_session for this lease (so it shows on /waits) + return {cursorStart}. Best-effort. */
311
- async function registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl = fetch) {
363
+ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl = fetch) {
312
364
  const deadline = new Date(Date.now() + timeoutSec * 1000).toISOString();
313
365
  const res = await fetchImpl(`${eventStreamBase()}`, {
314
366
  method: "POST",
315
367
  headers: { ...auth, "Content-Type": "application/json" },
316
368
  body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
369
+ signal,
317
370
  });
318
371
  if (!res.ok) throw new Error(`register responded ${res.status}`);
319
372
  const body = await res.json();
@@ -326,29 +379,59 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl = fetch) {
326
379
  * Resolves { woke, state } on a matching frame, { timeout:true } on --timeout,
327
380
  * { failed, state } if the lease was reaped, or { error } on a transport failure.
328
381
  */
329
- async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth }, fetchImpl = fetch) {
382
+ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth, signal = null, deadlineMs = null }, fetchImpl = fetch) {
383
+ const absoluteDeadlineMs = deadlineMs ?? Date.now() + timeoutSec * 1000;
330
384
  let cursorStart = null;
385
+ let waitSessionId = null;
386
+ const ac = new AbortController();
387
+ const abortFromParent = () => ac.abort("interrupted");
388
+ if (signal?.aborted) ac.abort("interrupted");
389
+ else signal?.addEventListener?.("abort", abortFromParent, { once: true });
390
+ const timer = setTimeout(() => ac.abort("timeout"), Math.max(0, absoluteDeadlineMs - Date.now()));
391
+ const finish = async (result, status) => {
392
+ clearTimeout(timer);
393
+ signal?.removeEventListener?.("abort", abortFromParent);
394
+ if (waitSessionId) {
395
+ try {
396
+ const finalizer = new AbortController();
397
+ const finalizerTimer = setTimeout(() => finalizer.abort(), 5_000);
398
+ try {
399
+ const response = await fetchImpl(eventStreamBase(), {
400
+ method: "POST", headers: { ...auth, "Content-Type": "application/json" },
401
+ body: JSON.stringify({ action: "finalize", wait_session_id: waitSessionId, status, receipt: { outcome: status, lease_id: leaseId } }),
402
+ signal: finalizer.signal,
403
+ });
404
+ if (!response.ok) process.stderr.write(`${yellow("⚠")} stack: wait finalization returned ${response.status}\n`);
405
+ } finally {
406
+ clearTimeout(finalizerTimer);
407
+ }
408
+ } catch (err) {
409
+ process.stderr.write(`${yellow("⚠")} stack: wait finalization failed (${err?.message ?? err})\n`);
410
+ }
411
+ }
412
+ return result;
413
+ };
414
+ if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
331
415
  try {
332
- ({ cursorStart } = await registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl));
416
+ ({ cursorStart, waitSessionId } = await registerLeaseWait(leaseId, Math.max(0, Math.ceil((absoluteDeadlineMs - Date.now()) / 1000)), auth, ac.signal, fetchImpl));
333
417
  } catch (err) {
418
+ if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
334
419
  process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
335
420
  }
336
421
  const url = new URL(eventStreamBase());
337
422
  url.searchParams.set("tables", "agent_signal_events");
338
423
  if (cursorStart != null) url.searchParams.set("since", String(cursorStart));
424
+ if (waitSessionId) url.searchParams.set("wait_session", waitSessionId);
339
425
  url.searchParams.set("heartbeat", "1");
340
- const ac = new AbortController();
341
- const timer = setTimeout(() => ac.abort("timeout"), timeoutSec * 1000);
342
426
  let res;
343
427
  try {
344
428
  res = await fetchImpl(url, { headers: { ...auth, Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
345
429
  } catch (err) {
346
- clearTimeout(timer);
347
- if (ac.signal.aborted) return { timeout: true };
348
- return { error: `sse connect: ${err?.message ?? err}` };
430
+ if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
431
+ return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
349
432
  }
350
- if (res.status === 401 || res.status === 403) { clearTimeout(timer); return { auth: true }; }
351
- if (!res.ok || !res.body) { clearTimeout(timer); return { error: `sse responded ${res.status}` }; }
433
+ if (res.status === 401 || res.status === 403) return finish({ auth: true }, "error");
434
+ if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}` }, "error");
352
435
  const decoder = new TextDecoder();
353
436
  let buf = "";
354
437
  try {
@@ -361,17 +444,23 @@ async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth }, fet
361
444
  try { sig = JSON.parse(f.data); } catch { continue; }
362
445
  if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
363
446
  const st = sig.payload?.state ?? null;
364
- if (isFailed(st)) { clearTimeout(timer); ac.abort(); return { failed: true, state: st }; }
365
- if (isDone(st)) { clearTimeout(timer); ac.abort(); return { woke: true, state: st }; }
447
+ if (isFailed(st)) { ac.abort(); return finish({ failed: true, state: st }, "error"); }
448
+ if (isDone(st)) { ac.abort(); return finish({ woke: true, state: st }, "matched"); }
366
449
  }
367
450
  }
368
451
  } catch (err) {
369
- clearTimeout(timer);
370
- if (ac.signal.aborted) return { timeout: true };
371
- return { error: `sse stream: ${err?.message ?? err}` };
452
+ if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
453
+ return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
454
+ }
455
+ // A clean relay EOF is not a timeout: the server intentionally closes on
456
+ // scope changes and proxies can recycle idle streams. Re-arm from a fresh
457
+ // cursor until the original deadline, never silently fall back to polling.
458
+ const remainingSec = Math.ceil((absoluteDeadlineMs - Date.now()) / 1000);
459
+ if (remainingSec > 0 && !ac.signal.aborted) {
460
+ await finish({ reconnected: true }, "error");
461
+ return waitForLease(leaseId, isDone, isFailed, { timeoutSec: remainingSec, auth, signal, deadlineMs: absoluteDeadlineMs }, fetchImpl);
372
462
  }
373
- clearTimeout(timer);
374
- return { timeout: true };
463
+ return finish({ timeout: true }, "timeout");
375
464
  }
376
465
 
377
466
  const isActive = (s) => s === "active";
@@ -540,13 +629,315 @@ async function cmdDone(leaseId, opts) {
540
629
  return emit(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition }), opts, EXIT.OK);
541
630
  }
542
631
 
632
+ const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
633
+
634
+ function safeConnectionPath(requested, leaseId) {
635
+ if (requested) {
636
+ // `wx` below prevents clobbering; canonicalising the parent prevents a
637
+ // symlinked directory from redirecting the only credential-bearing file.
638
+ const filename = basename(requested);
639
+ if (!filename || filename === "." || filename === "..") throw new Error("--connection-file must name a file");
640
+ return `${realpathSync(dirname(requested))}/${filename}`;
641
+ }
642
+ return `${tmpdir()}/botbuddy-stack-${leaseId}-${randomUUID()}.json`;
643
+ }
644
+
645
+ /** Write credentials atomically without ever putting them into argv, output, or a receipt. */
646
+ async function writeConnectionFile(path, connection) {
647
+ return writePrivateTextFile(path, JSON.stringify(connection));
648
+ }
649
+
650
+ /** Write a credential-bearing text artifact (dotenv/TOML/JSON) mode 0600. */
651
+ async function writePrivateTextFile(path, text) {
652
+ const handle = await open(path, "wx", 0o600);
653
+ try {
654
+ await handle.writeFile(text);
655
+ } catch (error) {
656
+ await unlink(path).catch(() => {});
657
+ throw error;
658
+ } finally {
659
+ await handle.close();
660
+ }
661
+ return path;
662
+ }
663
+
664
+ function childGroupSignal(child, signal) {
665
+ if (!child?.pid) return;
666
+ try { process.kill(-child.pid, signal); }
667
+ catch { try { child.kill(signal); } catch { /* already gone */ } }
668
+ }
669
+
670
+ function waitForChild(child) {
671
+ return new Promise((resolveChild, rejectChild) => {
672
+ child.once("error", rejectChild);
673
+ child.once("exit", (code, signal) => resolveChild({ code: code ?? (signal ? SIGNAL_EXIT[signal] ?? 1 : 1), signal }));
674
+ });
675
+ }
676
+
677
+ function isDenoIntegrationLane(argv) {
678
+ // `test:all` is the supported package wrapper that transitively invokes
679
+ // `test:integration`; both must receive the leased runner configuration.
680
+ return argv.includes("test:integration") || argv.includes("test:integration:coverage") || argv.includes("test:all") || argv.includes("scripts/run-deno-integration.sh");
681
+ }
682
+
683
+ function requireConnectionText(connection, key) {
684
+ const value = connection?.[key];
685
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`active stack connection is missing ${key}`);
686
+ return value.trim();
687
+ }
688
+
689
+ /** Materialise the established integration runner inputs from a Helper receipt.
690
+ * The runner already understands BB_INTEGRATION_ENV_FILE / BB_STACK_CONFIG;
691
+ * never let its shared-stack defaults silently validate the wrong stack. */
692
+ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connection, { writePrivate = writePrivateTextFile, removePrivate = (path) => unlink(path).catch(() => {}) } = {}) {
693
+ const apiUrl = requireConnectionText(connection, "api_url");
694
+ const anonKey = requireConnectionText(connection, "anon_key");
695
+ const serviceRoleKey = requireConnectionText(connection, "service_role_key");
696
+ const projectId = requireConnectionText(connection, "project_id");
697
+ const parsedApi = new URL(apiUrl);
698
+ if (!parsedApi.port || !["127.0.0.1", "localhost"].includes(parsedApi.hostname)) {
699
+ throw new Error("active stack connection api_url must be a local host URL with an explicit port");
700
+ }
701
+ const dbPort = Number(connection.db_port ?? new URL(requireConnectionText(connection, "db_url")).port);
702
+ if (!Number.isInteger(dbPort) || dbPort <= 0 || dbPort > 65535) throw new Error("active stack connection is missing a valid db_port");
703
+
704
+ const basePath = `${worktreeRoot}/supabase/functions/_test/integration/.env.integration`;
705
+ const base = await readFile(basePath, "utf8");
706
+ const values = {
707
+ SUPABASE_URL: apiUrl,
708
+ VITE_SUPABASE_URL: apiUrl,
709
+ SUPABASE_ANON_KEY: anonKey,
710
+ VITE_SUPABASE_PUBLISHABLE_KEY: anonKey,
711
+ SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
712
+ INTEGRATION_DB_PORT: String(dbPort),
713
+ };
714
+ const seen = new Set();
715
+ const env = base.split(/\r?\n/).map((line) => {
716
+ const key = /^([A-Z0-9_]+)=/.exec(line)?.[1];
717
+ if (!key || !(key in values)) return line;
718
+ seen.add(key);
719
+ return `${key}=${values[key]}`;
720
+ });
721
+ for (const [key, value] of Object.entries(values)) if (!seen.has(key)) env.push(`${key}=${value}`);
722
+
723
+ const stamp = `${tmpdir()}/botbuddy-stack-${leaseId}-${randomUUID()}`;
724
+ const envFile = `${stamp}.env`;
725
+ const stackConfig = `${stamp}.toml`;
726
+ await writePrivate(envFile, env.join("\n"));
727
+ try {
728
+ await writePrivate(stackConfig, `project_id = "${projectId}"\n\n[api]\nport = ${parsedApi.port}\n\n[db]\nport = ${dbPort}\n`);
729
+ } catch (error) {
730
+ await removePrivate(envFile);
731
+ throw error;
732
+ }
733
+ return { envFile, stackConfig, edgeMountRoot: worktreeRoot };
734
+ }
735
+
736
+ /**
737
+ * The command-owned lifecycle. The optional adapters are intentionally narrow so
738
+ * its queue, heartbeat, process-group, and cleanup contract is spawn-testable
739
+ * without Docker or a live BotBuddy service.
740
+ */
741
+ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
742
+ const api = adapters.api ?? {
743
+ request: (args) => callToolJson("request_stack_lease", args),
744
+ get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }),
745
+ touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }),
746
+ release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
747
+ };
748
+ const auth = adapters.auth ?? stackAuthHeader();
749
+ const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
750
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
751
+ const startChild = adapters.startChild ?? ((argv, env, cwd) => spawn(argv[0], argv.slice(1), { cwd, env, stdio: "inherit", detached: true }));
752
+ const writeConnection = adapters.writeConnection ?? writeConnectionFile;
753
+ const removeConnection = adapters.removeConnection ?? ((path) => unlink(path).catch(() => {}));
754
+ const materializeTestConfig = adapters.materializeTestConfig ?? materializeLeasedTestConfig;
755
+ const clock = adapters.clock ?? { setInterval, clearInterval, setTimeout, clearTimeout };
756
+ const signals = adapters.signals ?? process;
757
+ const provisionTimeout = opts.provisionTimeout ?? opts.timeout;
758
+ let leaseId = null;
759
+ let child = null;
760
+ let childResult = null;
761
+ let connectionFile = null;
762
+ let heartbeat = null;
763
+ let hardTimer = null;
764
+ let cleanupResult = null;
765
+ let cleanupPromise = null;
766
+ let leasedTestConfig = null;
767
+ let receivedSignal = null;
768
+ let forcedStopCode = null;
769
+ let fencing = false;
770
+ let activeWaitAbort = null;
771
+
772
+ const requestCleanup = () => {
773
+ // A signal can arrive while the lease-request RPC is in flight. There is
774
+ // nothing to release yet, so do not cache this no-op; once the RPC returns
775
+ // the interruption branch must perform the real signed cleanup.
776
+ if (!leaseId) return Promise.resolve({ ok: true, state: null });
777
+ if (cleanupPromise) return cleanupPromise;
778
+ cleanupPromise = (async () => {
779
+ if (!leaseId) return cleanupResult = { ok: true, state: null };
780
+ const cleanupAbort = new AbortController();
781
+ const cleanupTimer = clock.setTimeout(() => cleanupAbort.abort("reap-timeout"), opts.reapTimeout * 1000);
782
+ let release;
783
+ try {
784
+ release = await api.release(leaseId, cleanupAbort.signal);
785
+ } finally {
786
+ clock.clearTimeout(cleanupTimer);
787
+ }
788
+ if (!release?.ok || !release?.data?.success) {
789
+ return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
790
+ }
791
+ if (release.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
792
+ const reaped = await wait(leaseId, (state) => state === "reaped", () => false, { timeoutSec: opts.reapTimeout, auth });
793
+ if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
794
+ return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
795
+ })();
796
+ return cleanupPromise;
797
+ };
798
+
799
+ const stopChild = (signal = "SIGTERM", forcedCode = null) => {
800
+ if (!child || childResult) return;
801
+ forcedStopCode ??= forcedCode;
802
+ childGroupSignal(child, signal);
803
+ const kill = clock.setTimeout(() => childGroupSignal(child, "SIGKILL"), 10_000);
804
+ kill.unref?.();
805
+ };
806
+
807
+ const onSignal = (signal) => {
808
+ if (receivedSignal) return;
809
+ receivedSignal = signal;
810
+ activeWaitAbort?.abort("interrupted");
811
+ stopChild(signal);
812
+ // If capacity is still queued/provisioning there is no child to hold open;
813
+ // release immediately so an interrupted invocation cannot later provision.
814
+ if (!child) void requestCleanup();
815
+ };
816
+ const signalHandlers = new Map();
817
+ for (const signal of Object.keys(SIGNAL_EXIT)) {
818
+ const handler = () => onSignal(signal);
819
+ signalHandlers.set(signal, handler);
820
+ signals.on?.(signal, handler);
821
+ }
822
+
823
+ try {
824
+ if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy profile setup botbuddy-dev" };
825
+ const slot = deriveSlot(opts);
826
+ const execution = resolveStackPath(process.cwd(), opts.stackPath);
827
+ const request = await api.request({
828
+ slot, host_key: opts.host || undefined, repo: opts.repo, ticket_id: opts.ticket,
829
+ ticket_url: opts.ticketUrl || undefined, pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
830
+ purpose: opts.purpose || "stack run", idle_ttl_secs: opts.idleTtl ?? undefined,
831
+ stack_path: execution.stackPath, worktree_root: execution.worktreeRoot,
832
+ });
833
+ if (!request?.ok) return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
834
+ if (!request.data?.success) return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
835
+ leaseId = request.data.lease_id;
836
+ if (request.data.reused) {
837
+ return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, error: "a live lease already exists for this agent and stack slot; wait for that batch to finish instead of sharing its stack" };
838
+ }
839
+
840
+ let state = request.data.state;
841
+ if (receivedSignal) {
842
+ const cleanup = await requestCleanup();
843
+ return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
844
+ }
845
+ if (state === "queued") {
846
+ activeWaitAbort = new AbortController();
847
+ const parked = await wait(leaseId, (s) => s !== "queued" && s != null, (s) => s === "reaped", { timeoutSec: opts.timeout, auth, signal: activeWaitAbort.signal });
848
+ activeWaitAbort = null;
849
+ if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
850
+ if (parked?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup }; }
851
+ if (parked?.failed || parked?.error || parked?.auth) { const cleanup = await requestCleanup(); return { exitCode: parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup }; }
852
+ state = parked.state;
853
+ }
854
+ if (state !== "active") {
855
+ activeWaitAbort = new AbortController();
856
+ const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth, signal: activeWaitAbort.signal });
857
+ activeWaitAbort = null;
858
+ if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
859
+ if (active?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup }; }
860
+ if (active?.failed || active?.error || active?.auth) { const cleanup = await requestCleanup(); return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || "lease was reaped before active", cleanup }; }
861
+ }
862
+ const current = await api.get(leaseId);
863
+ if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {
864
+ const cleanup = await requestCleanup();
865
+ return { exitCode: EXIT.LEASE_FAILED, outcome: "error", leaseId, error: current?.error || `lease is ${current?.data?.state ?? "unreadable"}, not active with a connection`, cleanup };
866
+ }
867
+ if (receivedSignal) {
868
+ const cleanup = await requestCleanup();
869
+ return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
870
+ }
871
+
872
+ connectionFile = await writeConnection(safeConnectionPath(opts.connectionFile, leaseId), current.data.connection);
873
+ if (receivedSignal) {
874
+ const cleanup = await requestCleanup();
875
+ return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
876
+ }
877
+ if (isDenoIntegrationLane(childArgv)) leasedTestConfig = await materializeTestConfig(execution.worktreeRoot, leaseId, current.data.connection);
878
+ if (receivedSignal) {
879
+ const cleanup = await requestCleanup();
880
+ return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
881
+ }
882
+ const childEnv = {
883
+ ...process.env,
884
+ BOTBUDDY_STACK_LEASE_ID: leaseId,
885
+ BOTBUDDY_STACK_CONNECTION_FILE: connectionFile,
886
+ ...(leasedTestConfig ? {
887
+ BB_INTEGRATION_ENV_FILE: leasedTestConfig.envFile,
888
+ BB_STACK_CONFIG: leasedTestConfig.stackConfig,
889
+ BB_EDGE_MOUNT_ROOT: leasedTestConfig.edgeMountRoot,
890
+ } : {}),
891
+ };
892
+ // resolveStackPath already canonicalised this bounded relative path.
893
+ const childCwd = execution.stackPath === "." ? execution.worktreeRoot : `${execution.worktreeRoot}/${execution.stackPath}`;
894
+ child = startChild(childArgv, childEnv, childCwd);
895
+ const cadenceMs = Math.max(1_000, Math.floor((current.data.idle_ttl_secs ?? opts.idleTtl ?? 1800) * 1000 / 3));
896
+ heartbeat = clock.setInterval(async () => {
897
+ if (fencing || childResult) return;
898
+ const touched = await api.touch(leaseId);
899
+ if (!touched?.ok || !touched?.data?.success) {
900
+ fencing = true;
901
+ stopChild("SIGTERM", EXIT.LEASE_FAILED);
902
+ }
903
+ }, cadenceMs);
904
+ heartbeat.unref?.();
905
+ if (opts.hardTtl) hardTimer = clock.setTimeout(() => stopChild("SIGTERM", EXIT.TIMEOUT), opts.hardTtl * 1000);
906
+ hardTimer?.unref?.();
907
+ childResult = await waitForChild(child);
908
+ if (heartbeat) clock.clearInterval(heartbeat);
909
+ if (hardTimer) clock.clearTimeout(hardTimer);
910
+ const cleanup = await requestCleanup();
911
+ const exitCode = receivedSignal ? SIGNAL_EXIT[receivedSignal] : forcedStopCode ?? (childResult.code !== 0 ? childResult.code : !cleanup.ok ? EXIT.CLEANUP_FAILED : 0);
912
+ return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, fenced: fencing };
913
+ } catch (error) {
914
+ const cleanup = await requestCleanup();
915
+ return { exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : EXIT.INTERNAL, outcome: "error", leaseId, error: String(error?.message ?? error), cleanup };
916
+ } finally {
917
+ if (heartbeat) clock.clearInterval(heartbeat);
918
+ if (hardTimer) clock.clearTimeout(hardTimer);
919
+ if (connectionFile) await removeConnection(connectionFile);
920
+ if (leasedTestConfig) await Promise.all([removeConnection(leasedTestConfig.envFile), removeConnection(leasedTestConfig.stackConfig)]);
921
+ for (const [signal, handler] of signalHandlers) signals.off?.(signal, handler);
922
+ }
923
+ }
924
+
925
+ async function cmdRun(opts, childArgv) {
926
+ const result = await runStackLifecycle(opts, childArgv);
927
+ return emit(buildReceipt({
928
+ command: "run", outcome: result.outcome, lease_id: result.leaseId ?? null,
929
+ child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
930
+ fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
931
+ }), opts, result.exitCode);
932
+ }
933
+
543
934
  /** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
544
935
  export async function cmdStack(argv) {
545
936
  if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
546
937
  process.stdout.write(STACK_HELP + "\n");
547
938
  return EXIT.OK;
548
939
  }
549
- const { command, leaseId, opts, errors } = parseStackArgs(argv);
940
+ const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
550
941
  if (errors.length) {
551
942
  for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
552
943
  const code = emit(buildReceipt({ command: command || "?", outcome: "error", error: errors[0] }), opts, EXIT.INVALID);
@@ -560,6 +951,7 @@ export async function cmdStack(argv) {
560
951
  case "status": code = await cmdStatus(leaseId, opts); break;
561
952
  case "touch": code = await cmdTouch(leaseId, opts); break;
562
953
  case "done": code = await cmdDone(leaseId, opts); break;
954
+ case "run": code = await cmdRun(opts, childArgv); break;
563
955
  default:
564
956
  process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
565
957
  code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
@@ -5,15 +5,17 @@
5
5
  import test from "node:test";
6
6
  import assert from "node:assert/strict";
7
7
  import { spawnSync } from "node:child_process";
8
+ import { EventEmitter } from "node:events";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { dirname, join } from "node:path";
10
- import { mkdtempSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
11
+ import { mkdtempSync, writeFileSync, mkdirSync, realpathSync, readFileSync } from "node:fs";
11
12
  import { tmpdir } from "node:os";
12
13
 
13
14
  import {
14
15
  EXIT, STACK_HELP, STACK_SCHEMA_VERSION, DEFAULT_RECEIPT_MAX_BYTES,
15
16
  deriveSlot, parseStackArgs, buildReceipt, truncateReceipt,
16
17
  parseSupabaseStatus, eventStreamBase, resolveLocalSupabaseUrl, resolveStackPath,
18
+ runStackLifecycle, materializeLeasedTestConfig, waitForLease,
17
19
  } from "./stack.mjs";
18
20
 
19
21
  const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "botbuddy.mjs");
@@ -99,6 +101,242 @@ test("parseStackArgs: --stop sets disposition stop (default destroy)", () => {
99
101
  assert.equal(parseStackArgs(["done", "id"]).opts.disposition, "destroy");
100
102
  assert.equal(parseStackArgs(["done", "id", "--stop"]).opts.disposition, "stop");
101
103
  });
104
+ test("BOT-1346: run requires an argv boundary and rejects direct Docker fallback", () => {
105
+ assert.ok(parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346"]).errors.some((e) => e.includes("executable")));
106
+ assert.ok(parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346", "--local-exec", "--", "echo", "ok"]).errors.some((e) => e.includes("forbidden")));
107
+ const parsed = parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346", "--", "node", "-e", "process.exit(0)"]);
108
+ assert.equal(parsed.errors.length, 0);
109
+ assert.deepEqual(parsed.childArgv, ["node", "-e", "process.exit(0)"]);
110
+ });
111
+
112
+ test("BOT-1346: a clean lease-stream EOF honours the original deadline", async () => {
113
+ const requests = [];
114
+ const expired = await waitForLease(
115
+ "lease-1346", () => false, () => false,
116
+ { timeoutSec: 5, deadlineMs: Date.now() - 1, auth: { Authorization: "Bearer test" } },
117
+ async (_url, options = {}) => {
118
+ requests.push(options.method ?? "GET");
119
+ if (options.method === "POST") return new Response(JSON.stringify({ cursor_start: null }), { status: 200 });
120
+ return new Response(new ReadableStream({ start(controller) { controller.close(); } }), { status: 200 });
121
+ },
122
+ );
123
+ assert.deepEqual(expired, { timeout: true });
124
+ assert.deepEqual(requests, ["POST", "GET"]);
125
+ });
126
+
127
+ test("BOT-1346: a stalled lease-wait registration is aborted at its deadline", async () => {
128
+ let registrationSignal = null;
129
+ const result = await waitForLease(
130
+ "lease-1346", () => false, () => false,
131
+ { timeoutSec: 0.02, auth: { Authorization: "Bearer test" } },
132
+ async (_url, options = {}) => {
133
+ registrationSignal = options.signal;
134
+ return await new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }));
135
+ },
136
+ );
137
+ assert.equal(registrationSignal.aborted, true);
138
+ assert.deepEqual(result, { timeout: true });
139
+ });
140
+
141
+ test("BOT-1346: stack run owns active → argv child → signed reap and never exposes connection", async () => {
142
+ const calls = [];
143
+ const signals = new EventEmitter();
144
+ const fakeChild = new EventEmitter(); fakeChild.pid = 4242;
145
+ let written = null; const removed = []; let childCall = null;
146
+ const result = await runStackLifecycle(
147
+ { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5, connectionFile: "/tmp/bot-1346-connection.json" },
148
+ ["pnpm", "test:all"],
149
+ {
150
+ auth: { Authorization: "Bearer test" }, signals,
151
+ api: {
152
+ request: async () => ({ ok: true, data: { success: true, lease_id: "lease-1346", state: "active" } }),
153
+ get: async () => ({ ok: true, data: { success: true, state: "active", idle_ttl_secs: 30, connection: { db_url: "postgres://secret" } } }),
154
+ touch: async () => ({ ok: true, data: { success: true } }),
155
+ release: async (id) => { calls.push(["release", id]); return { ok: true, data: { success: true, state: "reaping" } }; },
156
+ },
157
+ wait: async (_id, done) => done("reaped") ? { woke: true, state: "reaped" } : { woke: true, state: "active" },
158
+ writeConnection: async (path, connection) => { written = { path, connection }; return path; },
159
+ materializeTestConfig: async () => ({
160
+ envFile: "/tmp/bot-1346-integration.env",
161
+ stackConfig: "/tmp/bot-1346-stack.toml",
162
+ }),
163
+ removeConnection: async (path) => { removed.push(path); },
164
+ startChild: (argv, env, cwd) => { childCall = { argv, env, cwd }; queueMicrotask(() => fakeChild.emit("exit", 7, null)); return fakeChild; },
165
+ clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
166
+ },
167
+ );
168
+ assert.equal(result.exitCode, 7, "successful cleanup must not mask the child failure");
169
+ assert.deepEqual(childCall.argv, ["pnpm", "test:all"], "argv is forwarded without shell parsing");
170
+ assert.equal(childCall.env.BOTBUDDY_STACK_LEASE_ID, "lease-1346");
171
+ assert.equal(childCall.env.BOTBUDDY_STACK_CONNECTION_FILE, written.path);
172
+ assert.equal(childCall.env.BB_INTEGRATION_ENV_FILE, "/tmp/bot-1346-integration.env");
173
+ assert.equal(childCall.env.BB_STACK_CONFIG, "/tmp/bot-1346-stack.toml");
174
+ assert.equal(written.connection.db_url, "postgres://secret");
175
+ assert.deepEqual(removed.sort(), [written.path, "/tmp/bot-1346-integration.env", "/tmp/bot-1346-stack.toml"].sort());
176
+ assert.deepEqual(calls, [["release", "lease-1346"]]);
177
+ assert.doesNotMatch(JSON.stringify(result), /postgres:\/\/secret/);
178
+ });
179
+
180
+ test("BOT-1346: materialized integration environment uses the leased database port", async () => {
181
+ const root = mkdtempSync(join(tmpdir(), "bb-stack-integration-"));
182
+ const integrationDir = join(root, "supabase", "functions", "_test", "integration");
183
+ mkdirSync(integrationDir, { recursive: true });
184
+ writeFileSync(join(integrationDir, ".env.integration"), "SUPABASE_URL=http://127.0.0.1:56321\nINTEGRATION_DB_PORT=56322\n");
185
+ const config = await materializeLeasedTestConfig(root, "lease-port", {
186
+ api_url: "http://127.0.0.1:64321", db_port: 64322, anon_key: "anon", service_role_key: "service", project_id: "leased-project",
187
+ });
188
+ assert.match(readFileSync(config.envFile, "utf8"), /^INTEGRATION_DB_PORT=64322$/m);
189
+ assert.match(readFileSync(config.stackConfig, "utf8"), /port = 64322/);
190
+ });
191
+
192
+ test("BOT-1346: materialized integration config removes dotenv if its TOML write fails", async () => {
193
+ const root = mkdtempSync(join(tmpdir(), "bb-stack-integration-"));
194
+ const integrationDir = join(root, "supabase", "functions", "_test", "integration");
195
+ mkdirSync(integrationDir, { recursive: true });
196
+ writeFileSync(join(integrationDir, ".env.integration"), "SUPABASE_URL=http://127.0.0.1:56321\n");
197
+ const writes = []; const removed = [];
198
+ await assert.rejects(
199
+ materializeLeasedTestConfig(root, "lease-cleanup", {
200
+ api_url: "http://127.0.0.1:64321", db_port: 64322, anon_key: "anon", service_role_key: "service", project_id: "leased-project",
201
+ }, {
202
+ writePrivate: async (path) => { writes.push(path); if (writes.length === 2) throw new Error("ENOSPC"); },
203
+ removePrivate: async (path) => { removed.push(path); },
204
+ }),
205
+ /ENOSPC/,
206
+ );
207
+ assert.equal(writes.length, 2);
208
+ assert.deepEqual(removed, [writes[0]]);
209
+ });
210
+
211
+ test("BOT-1346: queue wait errors retain a failed signed-cleanup receipt", async () => {
212
+ const result = await runStackLifecycle(
213
+ { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 },
214
+ ["pnpm", "test:integration"],
215
+ {
216
+ auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
217
+ api: {
218
+ request: async () => ({ ok: true, data: { success: true, lease_id: "lease-queue-error", state: "queued" } }),
219
+ release: async (_leaseId, signal) => {
220
+ assert(signal instanceof AbortSignal, "cleanup release receives the reap deadline signal");
221
+ return { ok: false, error: "signed reap unavailable" };
222
+ },
223
+ },
224
+ wait: async () => ({ error: "lease stream broke" }),
225
+ },
226
+ );
227
+ assert.equal(result.exitCode, EXIT.LEASE_FAILED);
228
+ assert.equal(result.cleanup.ok, false);
229
+ assert.match(result.cleanup.error, /signed reap unavailable/);
230
+ });
231
+
232
+ test("BOT-1346: queue timeout receipt retains a failed cleanup result", async () => {
233
+ const result = await runStackLifecycle(
234
+ { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["pnpm", "test:integration"],
235
+ {
236
+ auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
237
+ api: {
238
+ request: async () => ({ ok: true, data: { success: true, lease_id: "lease-queued", state: "queued" } }),
239
+ release: async () => ({ ok: false, error: "release rejected" }),
240
+ },
241
+ wait: async () => ({ timeout: true }),
242
+ clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
243
+ },
244
+ );
245
+ assert.equal(result.exitCode, EXIT.TIMEOUT);
246
+ assert.equal(result.cleanup.ok, false);
247
+ assert.match(result.cleanup.error, /release rejected/);
248
+ });
249
+
250
+ test("BOT-1346: interruption during lease creation releases the lease once its ID arrives", async () => {
251
+ const signals = new EventEmitter();
252
+ let resolveRequest; let releases = 0;
253
+ const pending = runStackLifecycle(
254
+ { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["pnpm", "test:integration"],
255
+ {
256
+ auth: { Authorization: "Bearer test" }, signals,
257
+ api: {
258
+ request: () => new Promise((resolve) => { resolveRequest = resolve; }),
259
+ release: async () => { releases++; return { ok: true, data: { success: true, state: "reaping" } }; },
260
+ },
261
+ wait: async () => ({ woke: true, state: "reaped" }),
262
+ clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
263
+ },
264
+ );
265
+ signals.emit("SIGINT");
266
+ resolveRequest({ ok: true, data: { success: true, lease_id: "lease-interrupted-request", state: "queued" } });
267
+ const result = await pending;
268
+ assert.equal(result.exitCode, 130);
269
+ assert.equal(releases, 1);
270
+ assert.equal(result.cleanup.ok, true);
271
+ });
272
+
273
+ test("BOT-1346: forced hard TTL cannot turn an incomplete child run into success", async () => {
274
+ const fakeChild = new EventEmitter(); fakeChild.pid = 4244;
275
+ let hardTimeout;
276
+ const result = await runStackLifecycle(
277
+ { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5, hardTtl: 1 }, ["node", "-e", "process.exit(0)"],
278
+ {
279
+ auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
280
+ api: {
281
+ request: async () => ({ ok: true, data: { success: true, lease_id: "lease-ttl", state: "active" } }),
282
+ get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
283
+ touch: async () => ({ ok: true, data: { success: true } }),
284
+ release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
285
+ },
286
+ wait: async () => ({ woke: true, state: "reaped" }), writeConnection: async (path) => path, removeConnection: async () => {},
287
+ startChild: () => { queueMicrotask(() => { hardTimeout(); fakeChild.emit("exit", 0, null); }); return fakeChild; },
288
+ clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: (fn) => { hardTimeout = fn; return { unref() {} }; }, clearTimeout() {} },
289
+ },
290
+ );
291
+ assert.equal(result.exitCode, EXIT.TIMEOUT);
292
+ });
293
+
294
+ test("BOT-1346: a fenced lease cannot report a gracefully-stopped child as success", async () => {
295
+ const fakeChild = new EventEmitter(); fakeChild.pid = 4245;
296
+ let heartbeat;
297
+ const result = await runStackLifecycle(
298
+ { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["node", "-e", "process.exit(0)"],
299
+ {
300
+ auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
301
+ api: {
302
+ request: async () => ({ ok: true, data: { success: true, lease_id: "lease-fenced", state: "active" } }),
303
+ get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
304
+ touch: async () => ({ ok: false, error: "lease unavailable" }),
305
+ release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
306
+ },
307
+ wait: async () => ({ woke: true, state: "reaped" }), writeConnection: async (path) => path, removeConnection: async () => {},
308
+ startChild: () => { queueMicrotask(async () => { await heartbeat(); fakeChild.emit("exit", 0, null); }); return fakeChild; },
309
+ clock: { setInterval: (fn) => { heartbeat = fn; return { unref() {} }; }, clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
310
+ },
311
+ );
312
+ assert.equal(result.exitCode, EXIT.LEASE_FAILED);
313
+ assert.equal(result.fenced, true);
314
+ });
315
+
316
+ test("BOT-1346: stack run reports failed signed cleanup after a successful child", async () => {
317
+ const fakeChild = new EventEmitter(); fakeChild.pid = 4243;
318
+ const result = await runStackLifecycle(
319
+ { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 },
320
+ ["node", "-e", "process.exit(0)"],
321
+ {
322
+ auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
323
+ api: {
324
+ request: async () => ({ ok: true, data: { success: true, lease_id: "lease-unreaped", state: "active" } }),
325
+ get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
326
+ touch: async () => ({ ok: true, data: { success: true } }),
327
+ release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
328
+ },
329
+ wait: async () => ({ timeout: true }),
330
+ writeConnection: async (path) => path,
331
+ removeConnection: async () => {},
332
+ startChild: () => { queueMicrotask(() => fakeChild.emit("exit", 0, null)); return fakeChild; },
333
+ clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
334
+ },
335
+ );
336
+ assert.equal(result.childExitCode, 0);
337
+ assert.equal(result.exitCode, EXIT.CLEANUP_FAILED);
338
+ assert.equal(result.cleanup.ok, false);
339
+ });
102
340
 
103
341
  // ── receipts ─────────────────────────────────────────────────────────────────
104
342
  test("buildReceipt: stamps schema_version", () => {