@botbuddy/cli 1.58.1 → 1.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.58.1",
3
+ "version": "1.59.1",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  // recorded. Typed/filled values, @ENV secrets, query strings, fragments, raw
14
14
  // Playwright output and error text never reach state, outbox or the server.
15
15
  import { createHash, randomUUID } from "node:crypto";
16
+ import { stackLeaseIdFromEnv } from "../stack-lease-correlation.mjs";
16
17
  import { chmod, link, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
17
18
  import { homedir } from "node:os";
18
19
  import { dirname, join } from "node:path";
@@ -330,7 +331,7 @@ function actionView(action) {
330
331
 
331
332
  // ── Lane tracker ─────────────────────────────────────────────────────────────
332
333
 
333
- function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended = null, onStateWrite = null }) {
334
+ function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended = null, onStateWrite = null, env = process.env }) {
334
335
  const statePath = sessionStatePath({ root, tenant, repository, host, lane });
335
336
  const stateLock = `${statePath}.lock`;
336
337
  const execLock = `${statePath}.exec.lock`;
@@ -446,6 +447,9 @@ function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, de
446
447
  if (git.branch && git.branch !== "HEAD") context.branch = String(git.branch);
447
448
  if (git.prNumber !== null && git.prNumber !== undefined && git.prNumber !== "") context.pr = String(git.prNumber);
448
449
  if (git.sha) context.sha = String(git.sha);
450
+ // BOT-2467: a browser session inside `bb stack run` is linked to that lease.
451
+ const leaseId = stackLeaseIdFromEnv(env);
452
+ if (leaseId) context.correlations = { stack_lease_id: leaseId };
449
453
  const state = {
450
454
  schema_version: 1, tenant, repository, host, lane: String(lane), producer_kind: PRODUCER_KIND, attempt: 1,
451
455
  source_run_id: randomUUID(), status: "active", environment, context, started_at: startedAt, next_sequence: 0,
@@ -966,10 +970,11 @@ export function createBrowserTelemetryResolver({
966
970
  now = () => new Date(), monotonic = () => performance.now(), gitInfo = () => ({}), spawnHeartbeat = null,
967
971
  onAppended = null, // test seam: observe each durable append (e.g. to simulate a crash right after it)
968
972
  onStateWrite = null, // test seam: inject a state-write failure
973
+ env = process.env, // BOT-2467: where BOTBUDDY_STACK_LEASE_ID is read from
969
974
  } = {}) {
970
975
  let outboxPromise = null;
971
976
  const getOutbox = () => (outboxPromise ??= createTelemetryOutbox({ root: join(root, "telemetry"), tenant, repository, producerVersion, now }));
972
- const trackerFor = ({ host, lane }) => createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended, onStateWrite });
977
+ const trackerFor = ({ host, lane }) => createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended, onStateWrite, env });
973
978
  return {
974
979
  async resolve({ host, lane }) { return { tracked: true, tracker: trackerFor({ host, lane }) }; },
975
980
  async forHeartbeat(info) { return trackerFor({ host: info.host, lane: info.lane }); },
package/src/run.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // mechanism, never the owner of a child process.
8
8
 
9
9
  import { createHash, randomUUID } from "node:crypto";
10
+ import { stackLeaseIdFromEnv, withStackLeaseCorrelation } from "./stack-lease-correlation.mjs";
10
11
  import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
11
12
  import { dirname, join } from "node:path";
12
13
  import { homedir } from "node:os";
@@ -105,7 +106,7 @@ function signalExitCode(signal) {
105
106
  // runner. It has exactly one durable pre-spawn boundary (the outbox start
106
107
  // event), then returns the child verdict even when terminal delivery degrades.
107
108
  export async function launchForegroundRun(argv, {
108
- cwd = process.cwd(), spawnImpl = spawn, identity = null, outbox = null, signalEmitter = process,
109
+ cwd = process.cwd(), spawnImpl = spawn, identity = null, outbox = null, signalEmitter = process, env = process.env,
109
110
  } = {}) {
110
111
  const { opts, command, errors } = parseRunArgs(argv);
111
112
  if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
@@ -138,13 +139,14 @@ export async function launchForegroundRun(argv, {
138
139
  ).catch(() => {});
139
140
  }
140
141
  const runId = randomUUID();
141
- const context = {
142
+ // BOT-2467: a run inside `bb stack run` is linked to that lease (uuid-validated).
143
+ const context = withStackLeaseCorrelation({
142
144
  repo: identity.repository,
143
145
  environment: opts.environment,
144
146
  runner: identity.appSlug,
145
147
  phase: opts.kind === "wait" ? "waiting" : "testing",
146
148
  health: "uninstrumented",
147
- };
149
+ }, stackLeaseIdFromEnv(env));
148
150
  const base = {
149
151
  schema_version: 1,
150
152
  producer_kind: opts.kind,
@@ -0,0 +1,31 @@
1
+ // BOT-2467 — link an execution to the `bb stack run` lease it ran under.
2
+ //
3
+ // `bb stack run` exports BOTBUDDY_STACK_LEASE_ID to its child. Every execution
4
+ // producer (lefthook lane telemetry, `bb run`, `bb pw`) stamps it on the lifecycle
5
+ // context as `correlations.stack_lease_id`, which execution-ingest stores on
6
+ // `execution_runs.stack_lease_id`. Only a UUID is forwarded; anything else is
7
+ // dropped (never sent for the server to reject the whole event). Dependency-free.
8
+
9
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+
11
+ /** The lease id when it is a UUID (lower-cased), else null. */
12
+ export function validStackLeaseId(value) {
13
+ return typeof value === "string" && UUID_RE.test(value) ? value.toLowerCase() : null;
14
+ }
15
+
16
+ /** The lease this process runs under, from `$BOTBUDDY_STACK_LEASE_ID`, or null. */
17
+ export function stackLeaseIdFromEnv(env = process.env) {
18
+ return validStackLeaseId(env?.BOTBUDDY_STACK_LEASE_ID);
19
+ }
20
+
21
+ /**
22
+ * `context` with `correlations.stack_lease_id` set to `leaseId` (other correlations
23
+ * kept). Returns `context` unchanged when the id is not a UUID.
24
+ */
25
+ export function withStackLeaseCorrelation(context, leaseId) {
26
+ const id = validStackLeaseId(leaseId);
27
+ if (!id) return context;
28
+ const correlations = context?.correlations && typeof context.correlations === "object" && !Array.isArray(context.correlations)
29
+ ? context.correlations : {};
30
+ return { ...context, correlations: { ...correlations, stack_lease_id: id } };
31
+ }
@@ -0,0 +1,164 @@
1
+ // BOT-2467 — one secret-free outcome record per `bb stack run`.
2
+ //
3
+ // When a push goes red under `bb stack run` the stdout receipt already says whether
4
+ // the stack broke (a lease/transport/auth failure) or the wrapped command failed
5
+ // (`child_exit_code` decided the exit). Nothing persisted that split. After the
6
+ // receipt is on stdout, `cmdRun` hands the receipt fields to `reportStackRunOutcome`,
7
+ // which makes ONE best-effort `record_stack_run_outcome` MCP call with the same
8
+ // credential the lease calls used. It is capped at 2 s and every failure (timeout,
9
+ // 4xx/5xx, unknown tool on an older server, a thrown error) is a silent drop: the
10
+ // receipt and the exit code are never touched.
11
+ //
12
+ // The payload is an allowlist built here — never the receipt itself — so no token,
13
+ // path, hostname, env value, argv or command output can reach the server. The
14
+ // server re-validates the same allowlist (supabase/functions/_shared/lib/stackRunOutcome.ts).
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import { VERSION } from "./version.mjs";
18
+ import { validStackLeaseId } from "./stack-lease-correlation.mjs";
19
+
20
+ export { validStackLeaseId };
21
+
22
+ export const STACK_RUN_OUTCOME_TOOL = "record_stack_run_outcome";
23
+ export const STACK_RUN_OUTCOME_TIMEOUT_MS = 2000;
24
+ /** `=off` disables the record (the CLI test suites set it; nothing else should). */
25
+ export const STACK_RUN_OUTCOME_OPT_OUT_ENV = "BOTBUDDY_STACK_RUN_OUTCOME";
26
+ export const STACK_RUN_OUTCOME_CATEGORIES = Object.freeze(["platform", "script", "interrupted", "invocation"]);
27
+
28
+ const CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
29
+ const TOKEN_RE = /^[a-z0-9_-]{1,64}$/;
30
+ const LANE_RE = /^[A-Za-z0-9:_.-]{1,128}$/;
31
+ const TICKET_RE = /^[A-Za-z][A-Za-z0-9]*-\d{1,9}$/;
32
+ const REPO_RE = /^[A-Za-z0-9._/-]{1,128}$/;
33
+ const VERSION_RE = /^[0-9A-Za-z.+-]{1,32}$/;
34
+
35
+ // Mirrors stack.mjs EXIT (kept local so this module stays dependency-free for tests).
36
+ const EXIT_TIMEOUT = 2;
37
+ const EXIT_AUTH = 3;
38
+ const EXIT_INVALID = 4;
39
+ const EXIT_BACKEND = 5;
40
+ const EXIT_LEASE_FAILED = 6;
41
+ const EXIT_INTERNAL = 7;
42
+ const EXIT_CLEANUP_FAILED = 8;
43
+ const SIGNAL_EXITS = new Set([129, 130, 143]);
44
+ // Typed codes that are the platform's fault whatever the exit code says.
45
+ const PLATFORM_CODES = new Set(["TUNNEL_LOST", "LEASE_PREEMPTED", "HOST_CAPACITY_SOURCE_STALE"]);
46
+
47
+ function boundedCode(value) {
48
+ return typeof value === "string" && CODE_RE.test(value) ? value : null;
49
+ }
50
+
51
+ /**
52
+ * Classify one `bb stack run` terminal from its receipt fields (`runReceiptFields`
53
+ * output, or the argument-error receipt) and its exit code. Pure.
54
+ *
55
+ * script — the lease was active and the child's exit decided the result
56
+ * (`--hard-ttl` stop → code HARD_TTL)
57
+ * platform — the stack, its transport, the credential or the backend failed
58
+ * interrupted — a signal ended the run (operator Ctrl-C / parent kill)
59
+ * invocation — exit 4: bad arguments or a refused request before any lease
60
+ */
61
+ export function classifyStackRunOutcome(fields = {}, exitCode) {
62
+ const code = boundedCode(fields.code);
63
+ const child = Number.isInteger(fields.child_exit_code) ? fields.child_exit_code : null;
64
+ const outcome = typeof fields.outcome === "string" ? fields.outcome : null;
65
+ if (outcome === "interrupted") return { category: "interrupted", code };
66
+ if (code && PLATFORM_CODES.has(code)) return { category: "platform", code };
67
+ if (fields.fenced === true) return { category: "platform", code: code ?? "LEASE_LOST" };
68
+ if (outcome === "completed" && exitCode === 0) return { category: "script", code: null };
69
+ if (outcome === "failed" && code == null && child != null) {
70
+ if (SIGNAL_EXITS.has(exitCode)) return { category: "interrupted", code: null };
71
+ // The child's exit decided the push; an unproven teardown on top of it is still
72
+ // named (CLEANUP_FAILED) so the platform half is never silently lost.
73
+ if (exitCode === child) return { category: "script", code: fields.cleanup === false ? "CLEANUP_FAILED" : null };
74
+ if (exitCode === EXIT_TIMEOUT) return { category: "script", code: "HARD_TTL" };
75
+ if (exitCode === EXIT_CLEANUP_FAILED) return { category: "platform", code: "CLEANUP_FAILED" };
76
+ }
77
+ if (exitCode === EXIT_INVALID) return { category: "invocation", code: code ?? "INVALID_ARGUMENTS" };
78
+ if (SIGNAL_EXITS.has(exitCode) && code == null) return { category: "interrupted", code: null };
79
+ switch (exitCode) {
80
+ // An admitted lease whose stack never reached active is a provisioning failure, not
81
+ // a capacity shortage (`timeout_phase` comes from stackRunOutcomeFields).
82
+ case EXIT_TIMEOUT: return { category: "platform", code: code ?? (fields.timeout_phase === "provision" ? "PROVISION_TIMEOUT" : "CAPACITY_TIMEOUT") };
83
+ case EXIT_AUTH: return { category: "platform", code: code ?? "AUTH" };
84
+ case EXIT_BACKEND: return { category: "platform", code: code ?? "BACKEND_UNREACHABLE" };
85
+ case EXIT_LEASE_FAILED: return { category: "platform", code: code ?? "LEASE_FAILED" };
86
+ case EXIT_CLEANUP_FAILED: return { category: "platform", code: "CLEANUP_FAILED" };
87
+ case EXIT_INTERNAL: return { category: "platform", code: code ?? "INTERNAL" };
88
+ default: return { category: "platform", code: code ?? "UNKNOWN" };
89
+ }
90
+ }
91
+
92
+ /** The lane id from `$BOTBUDDY_LANE_SOURCE_RUN_ID` (`lefthook:<invocation>:<lane>`), or null. */
93
+ export function laneFromSourceRunId(value) {
94
+ if (typeof value !== "string" || !value.startsWith("lefthook:")) return null;
95
+ const parts = value.split(":");
96
+ if (parts.length < 3) return null;
97
+ const lane = parts.slice(2).join(":");
98
+ return LANE_RE.test(lane) ? lane : null;
99
+ }
100
+
101
+ const pick = (value, re) => (typeof value === "string" && re.test(value) ? value : null);
102
+ const exitInt = (value) => (Number.isInteger(value) && value >= 0 && value <= 255 ? value : null);
103
+
104
+ /**
105
+ * The allowlisted payload for one run. Every field is bounded and shape-checked here
106
+ * so the call carries nothing the server would refuse — and nothing secret.
107
+ */
108
+ export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = randomUUID(), opts = {}, env = process.env, durationMs = null }) {
109
+ const { category, code } = classifyStackRunOutcome(fields, exitCode);
110
+ return {
111
+ run_id: runId,
112
+ lease_id: validStackLeaseId(fields.lease_id),
113
+ outcome: pick(fields.outcome, TOKEN_RE) ?? "unknown",
114
+ code,
115
+ category,
116
+ exit_code: exitInt(exitCode) ?? 255,
117
+ child_exit_code: exitInt(fields.child_exit_code),
118
+ park_reason: pick(fields.park_reason, TOKEN_RE),
119
+ execution_mode: pick(fields.execution_mode, TOKEN_RE),
120
+ transport: pick(fields.transport, TOKEN_RE),
121
+ fenced: fields.fenced === true,
122
+ lane: laneFromSourceRunId(env.BOTBUDDY_LANE_SOURCE_RUN_ID),
123
+ ticket: pick(opts.ticket, TICKET_RE)?.toUpperCase() ?? null,
124
+ repo: pick(opts.repo, REPO_RE),
125
+ duration_ms: Number.isFinite(durationMs) && durationMs >= 0 ? Math.round(durationMs) : null,
126
+ cli_version: pick(VERSION, VERSION_RE) ?? "unknown",
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Best-effort record. `channel` is `{ auth, callTool }` from the lifecycle (the
132
+ * credential the lease calls used); without one there is nothing to authenticate
133
+ * with and nothing is sent. Never throws; resolves within `timeoutMs`.
134
+ */
135
+ export async function reportStackRunOutcome({ fields, exitCode, opts, env = process.env, durationMs = null, channel, timeoutMs = STACK_RUN_OUTCOME_TIMEOUT_MS }) {
136
+ // Test suites opt out so a spawned real CLI never records a fixture run.
137
+ if (env?.[STACK_RUN_OUTCOME_OPT_OUT_ENV] === "off") return { sent: false, reason: "disabled" };
138
+ if (!channel?.auth || typeof channel.callTool !== "function") return { sent: false, reason: "no_credential" };
139
+ let payload;
140
+ try {
141
+ payload = buildStackRunOutcomePayload({ fields, exitCode, opts, env, durationMs });
142
+ } catch {
143
+ return { sent: false, reason: "payload_error" };
144
+ }
145
+ const ac = new AbortController();
146
+ let timer;
147
+ const TIMED_OUT = Symbol("stack-run-outcome-timeout");
148
+ const timeout = new Promise((resolve) => {
149
+ // Referenced on purpose: a never-settling call holds no handle of its own, and an
150
+ // unref'd timer would let the event loop end before the cap resolves the race.
151
+ // `finally` clears it, so a fast answer never waits on it.
152
+ timer = setTimeout(() => { ac.abort(); resolve(TIMED_OUT); }, timeoutMs);
153
+ });
154
+ try {
155
+ const call = Promise.resolve().then(() => channel.callTool(STACK_RUN_OUTCOME_TOOL, payload, { auth: channel.auth, signal: ac.signal }));
156
+ const result = await Promise.race([call.catch(() => null), timeout]);
157
+ if (result === TIMED_OUT) return { sent: false, reason: "timeout", payload };
158
+ return { sent: Boolean(result?.ok && !result?.isError), reason: result?.ok ? null : "refused", payload };
159
+ } catch {
160
+ return { sent: false, reason: "error", payload };
161
+ } finally {
162
+ clearTimeout(timer);
163
+ }
164
+ }
package/src/stack.mjs CHANGED
@@ -51,6 +51,7 @@ import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle, validate
51
51
  import { assertSshTarget, establishSshTunnel } from "./stack-tunnel.mjs";
52
52
  import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
53
53
  import { bold, dim, yellow } from "./utils.mjs";
54
+ import { reportStackRunOutcome } from "./stack-run-outcome.mjs";
54
55
 
55
56
  export const STACK_SCHEMA_VERSION = 1;
56
57
 
@@ -133,6 +134,8 @@ ${bold("up OPTIONS")}
133
134
  deadline (exit 2) and a failed wait (its own exit code) never retry.
134
135
  --local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
135
136
  stack and self-activate. Refused at the worktree root (shared stack).
137
+ --keep-volumes LOCAL-EXEC only: preserve a reaped stack's volumes; refuses when the
138
+ latest signed Helper receipt requires a one-time recovery stop.
136
139
  --provision-timeout N LOCAL-EXEC: seconds 'supabase start' may run (default ${LOCAL_EXEC_PROVISION_TIMEOUT_SEC}). On
137
140
  timeout up runs 'stack done --local-exec' itself and exits 6 with
138
141
  park_reason provision_timeout (BOT-2154). The server also fails a local-exec
@@ -327,7 +330,7 @@ export function parseStackArgs(argv) {
327
330
  slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
328
331
  prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
329
332
  timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
330
- connectionFile: null, noWait: false, claimOnly: false, localExec: false, waitCapacity: null, holdOnTimeout: null,
333
+ connectionFile: null, noWait: false, claimOnly: false, localExec: false, keepVolumes: false, waitCapacity: null, holdOnTimeout: null,
331
334
  dockerContext: null, dockerEndpoint: null,
332
335
  // BOT-2142: execution placement (null = command default), remote-lease callback + bundle cap.
333
336
  execution: null, remote: false, localOnly: false, callbackPort: null, bundleMaxBytes: null,
@@ -452,6 +455,7 @@ export function parseStackArgs(argv) {
452
455
  case "--no-wait": opts.noWait = true; break;
453
456
  case "--claim-only": opts.claimOnly = true; break;
454
457
  case "--local-exec": opts.localExec = true; break;
458
+ case "--keep-volumes": opts.keepVolumes = true; break;
455
459
  case "--docker-context": if (need(a, rest[i + 1])) opts.dockerContext = rest[++i]; break;
456
460
  case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
457
461
  case "--stop": opts.disposition = "stop"; break;
@@ -519,6 +523,9 @@ export function parseStackArgs(argv) {
519
523
  if (opts.claimOnly && (command !== "up" || !opts.localExec)) {
520
524
  errors.push("--claim-only is valid only with `stack up --local-exec`");
521
525
  }
526
+ if (opts.keepVolumes && (command !== "up" || !opts.localExec)) {
527
+ errors.push("--keep-volumes is valid only with `stack up --local-exec`");
528
+ }
522
529
  if ((opts.dockerContext || opts.dockerEndpoint) && !(["up", "done"].includes(command) && opts.localExec)) {
523
530
  errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
524
531
  }
@@ -1396,13 +1403,13 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
1396
1403
  try { sig = JSON.parse(f.data); } catch { continue; }
1397
1404
  if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
1398
1405
  const st = sig.payload?.state ?? null;
1399
- if (isFailed(st)) {
1406
+ if (isFailed(st, sig.payload)) {
1400
1407
  // BOT-2013: carry the server's reap_reason so the receipt says WHY the lease is gone.
1401
1408
  const reapReason = typeof sig.payload?.reap_reason === "string" ? sig.payload.reap_reason : null;
1402
1409
  decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
1403
1410
  break frameLoop;
1404
1411
  }
1405
- if (isDone(st)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
1412
+ if (isDone(st, sig.payload)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
1406
1413
  }
1407
1414
  }
1408
1415
  } catch (err) {
@@ -1602,8 +1609,8 @@ export async function parkOnLease(leaseId, isDone, isFailed, {
1602
1609
  // Codex #1168 P1: an authenticated read of the lease proves the outage is over, so a later,
1603
1610
  // independent rotation starts a fresh auth budget instead of inheriting this one.
1604
1611
  if (state != null) recovered();
1605
- if (state != null && isFailed(state)) return done({ failed: true, state, ...(read?.reap_reason ? { reap_reason: read.reap_reason } : {}) });
1606
- if (state != null && isDone(state)) return done({ woke: true, state });
1612
+ if (state != null && isFailed(state, read)) return done({ failed: true, state, ...(read?.reap_reason ? { reap_reason: read.reap_reason } : {}) });
1613
+ if (state != null && isDone(state, read)) return done({ woke: true, state });
1607
1614
  }
1608
1615
  }
1609
1616
  }
@@ -2096,6 +2103,60 @@ export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawn
2096
2103
  return labelScopedTeardown(projectId, disposition, spawn, dockerEnv, log);
2097
2104
  }
2098
2105
 
2106
+ /** BOT-2398: a prior signed `volumes_reusable=false` receipt permits one exact,
2107
+ * bounded Supabase recovery stop before a local-exec start. This deliberately
2108
+ * has no label-scoped fallback: the signed Helper receipt authorizes only this
2109
+ * Supabase project on this pinned daemon, and any uncertainty leaves the lease
2110
+ * fenced without starting. */
2111
+ export async function localVolumeRecoveryStop(opts, execution, dockerTarget, {
2112
+ targetCheck = runLocalExecTargetCheck,
2113
+ spawn = spawnSync,
2114
+ readProjectId = projectIdUnder,
2115
+ acquireLock = acquireStackLock,
2116
+ lockHeld = false,
2117
+ env = process.env,
2118
+ } = {}) {
2119
+ if (!opts?.localExec || !ADMITTED_DOCKER_VALIDATIONS.has(dockerTarget?.validation) ||
2120
+ !dockerTarget?.resolved_endpoint || !dockerTarget?.server_id) return false;
2121
+ const stackDir = stackDirFor(execution);
2122
+ const projectId = readProjectId(stackDir);
2123
+ if (typeof projectId !== "string" || !/^[a-z0-9][a-z0-9_-]{0,62}$/.test(projectId)) return false;
2124
+ let lock;
2125
+ if (!lockHeld) {
2126
+ try { lock = await acquireLock(lockPathForProject(projectId), { timeoutMs: 0 }); }
2127
+ catch { return false; }
2128
+ }
2129
+ try {
2130
+ const currentTarget = async () => {
2131
+ const checked = await targetCheck(opts);
2132
+ return checked?.exitCode === 0 ? dockerTargetFromPreflight(checked.receipt) : null;
2133
+ };
2134
+ const before = await currentTarget();
2135
+ if (!dockerTargetsMatch(dockerTarget, before)) return false;
2136
+ const dockerEnv = dockerEnvForTarget(dockerTarget, env);
2137
+ const stop = spawn("supabase", ["stop", "--workdir", stackDir, "--no-backup"], {
2138
+ encoding: "utf8", env: dockerEnv, timeout: 120_000, killSignal: "SIGTERM",
2139
+ stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1024 * 1024,
2140
+ });
2141
+ if (stop?.error || stop?.status !== 0) return false;
2142
+ const after = await currentTarget();
2143
+ if (!dockerTargetsMatch(dockerTarget, after)) return false;
2144
+ const listProjectResources = (args) => spawn("docker", args, {
2145
+ encoding: "utf8", env: dockerEnv, timeout: 30_000, killSignal: "SIGTERM",
2146
+ stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1024 * 1024,
2147
+ });
2148
+ const containers = listProjectResources(["ps", "-aq", "--filter", `label=com.supabase.cli.project=${projectId}`]);
2149
+ if (containers?.error || containers?.status !== 0 || String(containers.stdout || "").trim()) return false;
2150
+ const volumes = listProjectResources(["volume", "ls", "-q", "--filter", `label=com.supabase.cli.project=${projectId}`]);
2151
+ if (volumes?.error || volumes?.status !== 0 || String(volumes.stdout || "").trim()) return false;
2152
+ const networks = listProjectResources(["network", "ls", "-q", "--filter", `label=com.supabase.cli.project=${projectId}`]);
2153
+ if (networks?.error || networks?.status !== 0 || String(networks.stdout || "").trim()) return false;
2154
+ return true;
2155
+ } finally {
2156
+ lock?.release?.();
2157
+ }
2158
+ }
2159
+
2099
2160
  /** BOT-2001: remove exactly one Supabase project's containers, network and (for `destroy`)
2100
2161
  * volumes by the CLI's project label; true iff a re-list finds NONE of the resource classes
2101
2162
  * this disposition removes (Codex #1012: a busy volume or network fails closed too). */
@@ -2146,9 +2207,17 @@ export async function cmdUp(opts, {
2146
2207
  callTool = callToolJson,
2147
2208
  authProvider = stackAuthHeader,
2148
2209
  localProvisionFn = localProvision,
2210
+ // BOT-2398: exactly one fixed `supabase stop --no-backup` against the
2211
+ // validated stack directory/engine, only when a signed prior reap requires it.
2212
+ // Keep the fourth argument: the caller holds the project lock across this
2213
+ // stop and the immediately following start, so the recovery primitive must
2214
+ // not try to acquire the same lock recursively.
2215
+ recoveryStopFn = localVolumeRecoveryStop,
2149
2216
  // BOT-2154: the self-release after a timed-out local start (`stack done --local-exec`).
2150
2217
  localDoneFn = null,
2151
2218
  waitFn = waitForLease,
2219
+ // BOT-2398 focused seam; production uses the existing lease SSE park below.
2220
+ waitHandoffFn = null,
2152
2221
  emitResult = emit,
2153
2222
  machineUuidFn = machineUuid,
2154
2223
  assertIsolated = validateLocalExecTarget,
@@ -2459,10 +2528,181 @@ export async function cmdUp(opts, {
2459
2528
  const reservedProjectId = projectIdUnder(stackDirFor(execution));
2460
2529
  const reservedConnection = connectionWithDockerTarget(
2461
2530
  reservedProjectId ? { project_id: reservedProjectId } : {}, localDockerTarget);
2462
- const reserved = await call("reserve_stack_lease", {
2463
- lease_id: leaseId,
2464
- connection: reservedConnection,
2465
- });
2531
+ let volumeRecoveryRequired = false;
2532
+ let provisionReserved = false;
2533
+ if (d.volume_recovery_protocol === 1) {
2534
+ const priorRead = await call("get_stack_lease", { lease_id: leaseId });
2535
+ const current = priorRead?.ok && priorRead.data?.success ? priorRead.data : null;
2536
+ const exactCurrentContext = current && current.state === "provisioning" &&
2537
+ current.host_key === d.host_key && current.slot === slot &&
2538
+ current.worktree_root === execution.worktreeRoot && current.stack_path === execution.stackPath &&
2539
+ current.execution_mode === "local_exec";
2540
+ if (!exactCurrentContext) {
2541
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
2542
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
2543
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
2544
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
2545
+ return emitResult(buildReceipt({
2546
+ command: "up", outcome: "refused", code: "VOLUME_RECOVERY_CONTEXT_UNVERIFIED", lease_id: leaseId, slot,
2547
+ error: "could not verify the exact local-exec host/slot/worktree before checking volume-recovery state",
2548
+ lease_cancellation: leaseCancellation,
2549
+ }), opts, EXIT.LEASE_FAILED);
2550
+ }
2551
+ const hasPriorSource = Object.prototype.hasOwnProperty.call(current, "prior_volume_source_lease_id");
2552
+ const hasPriorDecision = Object.prototype.hasOwnProperty.call(current, "prior_volumes_reusable");
2553
+ const priorSource = current.prior_volume_source_lease_id;
2554
+ if (!hasPriorSource || !hasPriorDecision ||
2555
+ (priorSource !== null && (typeof priorSource !== "string" || !priorSource.trim()))) {
2556
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
2557
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
2558
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
2559
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
2560
+ return emitResult(buildReceipt({
2561
+ command: "up", outcome: "refused", code: "VOLUME_RECOVERY_HISTORY_UNVERIFIED", lease_id: leaseId, slot,
2562
+ error: "the server omitted or malformed prior local-exec history; no recovery or start was attempted",
2563
+ lease_cancellation: leaseCancellation,
2564
+ }), opts, EXIT.LEASE_FAILED);
2565
+ }
2566
+ const reuseState = current.prior_volumes_reusable;
2567
+ if (reuseState !== undefined && reuseState !== null && typeof reuseState !== "boolean") {
2568
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
2569
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
2570
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
2571
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
2572
+ return emitResult(buildReceipt({
2573
+ command: "up", outcome: "refused", code: "VOLUME_RECOVERY_STATE_INVALID", lease_id: leaseId, slot,
2574
+ error: "the server returned an invalid prior volume-reuse state; no recovery or start was attempted",
2575
+ lease_cancellation: leaseCancellation,
2576
+ }), opts, EXIT.LEASE_FAILED);
2577
+ }
2578
+ if (priorSource !== null && reuseState !== true && reuseState !== false) {
2579
+ // The Helper handoff is bound to the already-reserved ordinary
2580
+ // provision job. Reserve it before enqueueing the distinct control
2581
+ // action; neither job may race the local start.
2582
+ const handoffReservation = await call("reserve_stack_lease", {
2583
+ lease_id: leaseId, connection: reservedConnection,
2584
+ });
2585
+ if (!handoffReservation.ok || handoffReservation.data?.success !== true) {
2586
+ return emitResult(buildReceipt({ command: "up", outcome: "error", code: "PREDECESSOR_HANDOFF_RESERVATION_FAILED",
2587
+ lease_id: leaseId, slot, error: "the local provision job could not be reserved before requesting the Helper fence handoff; local start was not attempted" }),
2588
+ opts, EXIT.LEASE_FAILED);
2589
+ }
2590
+ provisionReserved = true;
2591
+ const requestedHandoff = await call("request_stack_lease_predecessor_handoff", { lease_id: leaseId });
2592
+ if (!requestedHandoff.ok || requestedHandoff.data?.success !== true ||
2593
+ requestedHandoff.data?.handoff_required !== true ||
2594
+ typeof requestedHandoff.data?.handoff_job_id !== "string" ||
2595
+ !requestedHandoff.data.handoff_job_id.trim()) {
2596
+ const cancelled = requestedHandoff.ok && requestedHandoff.data?.success === true &&
2597
+ requestedHandoff.data?.handoff_required === false
2598
+ ? await call("cancel_unclaimed_stack_lease", { lease_id: leaseId }) : null;
2599
+ const leaseCancellation = cancelled
2600
+ ? (cancelled.ok && cancelled.data?.success
2601
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
2602
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" })
2603
+ : null;
2604
+ return emitResult(buildReceipt({
2605
+ command: "up", outcome: "refused", code: "PREDECESSOR_HANDOFF_UNAVAILABLE", lease_id: leaseId, slot,
2606
+ error: "the owner-authenticated Helper could not prove an accepted exact schema-1 predecessor handoff; refusing recovery stop and local start",
2607
+ ...(leaseCancellation ? { lease_cancellation: leaseCancellation } : {}),
2608
+ }), opts, EXIT.LEASE_FAILED);
2609
+ }
2610
+ const handoffJobID = requestedHandoff.data.handoff_job_id;
2611
+ const isAcceptedHandoff = (value) => value?.state === "provisioning" &&
2612
+ value?.predecessor_handoff_job_id === handoffJobID &&
2613
+ value?.predecessor_handoff_status === "completed" &&
2614
+ value?.predecessor_handoff_outcome === "succeeded";
2615
+ const isFailedHandoff = (value) => value?.state === "provisioning" &&
2616
+ value?.predecessor_handoff_job_id === handoffJobID &&
2617
+ ["failed", "stale"].includes(value?.predecessor_handoff_status);
2618
+ let handoffState = await call("get_stack_lease", { lease_id: leaseId });
2619
+ if (!handoffState.ok || handoffState.data?.success !== true ||
2620
+ (handoffState.data.predecessor_handoff_job_id != null &&
2621
+ handoffState.data.predecessor_handoff_job_id !== handoffJobID)) {
2622
+ return emitResult(buildReceipt({ command: "up", outcome: "error", code: "PREDECESSOR_HANDOFF_STATE_UNVERIFIED",
2623
+ lease_id: leaseId, slot, error: "the exact Helper handoff job status could not be verified; local start was not attempted" }),
2624
+ opts, EXIT.LEASE_FAILED);
2625
+ }
2626
+ if (!isAcceptedHandoff(handoffState.data) && !isFailedHandoff(handoffState.data)) {
2627
+ const waited = waitHandoffFn
2628
+ ? await waitHandoffFn(leaseId, handoffJobID)
2629
+ : await resilientWait(
2630
+ (leaseState, payload) => leaseState === "provisioning" &&
2631
+ payload?.predecessor_handoff_job_id === handoffJobID &&
2632
+ payload?.predecessor_handoff_status === "completed" &&
2633
+ payload?.predecessor_handoff_outcome === "succeeded",
2634
+ (leaseState, payload) => leaseState === "reap_failed" ||
2635
+ (leaseState === "provisioning" && payload?.predecessor_handoff_job_id === handoffJobID &&
2636
+ ["failed", "stale"].includes(payload?.predecessor_handoff_status)),
2637
+ );
2638
+ if (!waited?.woke) {
2639
+ return emitResult(buildReceipt({ command: "up", outcome: waited?.timeout ? "timeout" : "error",
2640
+ code: waited?.timeout ? "PREDECESSOR_HANDOFF_TIMEOUT" : "PREDECESSOR_HANDOFF_FAILED",
2641
+ lease_id: leaseId, slot, error: "the Helper predecessor handoff did not reach accepted success; recovery stop and local start were not attempted" }),
2642
+ opts, waited?.timeout ? EXIT.TIMEOUT : EXIT.LEASE_FAILED);
2643
+ }
2644
+ handoffState = await call("get_stack_lease", { lease_id: leaseId });
2645
+ }
2646
+ if (!handoffState.ok || !isAcceptedHandoff(handoffState.data)) {
2647
+ return emitResult(buildReceipt({ command: "up", outcome: "error", code: "PREDECESSOR_HANDOFF_FAILED",
2648
+ lease_id: leaseId, slot, error: "the exact Helper handoff has no accepted successful terminal receipt; recovery stop and local start were not attempted" }),
2649
+ opts, EXIT.LEASE_FAILED);
2650
+ }
2651
+ // The signed Helper acknowledgement rebinds exact local custody,
2652
+ // but schema 1 did not record whether old volumes were reusable. A
2653
+ // recovery stop would be destructive, so honour --keep-volumes before
2654
+ // it can be attempted.
2655
+ if (opts.keepVolumes) {
2656
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
2657
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
2658
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
2659
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
2660
+ return emitResult(buildReceipt({
2661
+ command: "up", outcome: "refused", code: "VOLUME_RECOVERY_REQUIRED", lease_id: leaseId, slot,
2662
+ error: "the accepted predecessor handoff has no reusable-volume decision; --keep-volumes prevents the required recovery stop",
2663
+ lease_cancellation: leaseCancellation,
2664
+ }), opts, EXIT.LEASE_FAILED);
2665
+ }
2666
+ // Run the established bounded no-backup stop under the project lock
2667
+ // before starting this exact successor.
2668
+ volumeRecoveryRequired = true;
2669
+ }
2670
+ if (reuseState === false || reuseState === true) {
2671
+ const priorProject = current.prior_project_id;
2672
+ const priorTarget = current.prior_docker_target;
2673
+ const exactPriorTarget = priorSource !== null && priorProject === reservedProjectId &&
2674
+ dockerTargetsMatch(priorTarget, localDockerTarget);
2675
+ if (!exactPriorTarget) {
2676
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
2677
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
2678
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
2679
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
2680
+ return emitResult(buildReceipt({
2681
+ command: "up", outcome: "refused", code: "VOLUME_RECOVERY_TARGET_MISMATCH", lease_id: leaseId, slot,
2682
+ error: "the prior signed non-reusable-volume receipt is for a different or incomplete Docker target/project; refusing to start",
2683
+ lease_cancellation: leaseCancellation,
2684
+ }), opts, EXIT.LEASE_FAILED);
2685
+ }
2686
+ if (reuseState === false && opts.keepVolumes) {
2687
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
2688
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
2689
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
2690
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
2691
+ return emitResult(buildReceipt({
2692
+ command: "up", outcome: "refused", code: "VOLUME_RECOVERY_REQUIRED", lease_id: leaseId, slot,
2693
+ error: "the exact prior Helper receipt marked volumes non-reusable; --keep-volumes prevents the required recovery stop",
2694
+ lease_cancellation: leaseCancellation,
2695
+ }), opts, EXIT.LEASE_FAILED);
2696
+ }
2697
+ volumeRecoveryRequired = reuseState === false;
2698
+ }
2699
+ }
2700
+ const reserved = provisionReserved
2701
+ ? { ok: true, data: { success: true, reserved: true } }
2702
+ : await call("reserve_stack_lease", {
2703
+ lease_id: leaseId,
2704
+ connection: reservedConnection,
2705
+ });
2466
2706
  if (!reserved.ok || !reserved.data?.success) {
2467
2707
  // A Helper can win the reservation race by taking the queued provision
2468
2708
  // job between `request_stack_lease` and `reserve_stack_lease`. That is NOT
@@ -2504,10 +2744,46 @@ export async function cmdUp(opts, {
2504
2744
  }), opts, EXIT.LEASE_FAILED);
2505
2745
  }
2506
2746
  } else {
2747
+ let recoveryLock = null;
2748
+ if (volumeRecoveryRequired) {
2749
+ if (opts.keepVolumes) {
2750
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
2751
+ return emitResult(buildReceipt({
2752
+ command: "up", outcome: "refused", code: "VOLUME_RECOVERY_REQUIRED", lease_id: leaseId, slot,
2753
+ error: "the exact predecessor handoff requires a recovery stop; --keep-volumes prevents the required recovery",
2754
+ lease_cancellation: cancelled.ok && cancelled.data?.success
2755
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
2756
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" },
2757
+ }), opts, EXIT.LEASE_FAILED);
2758
+ }
2759
+ if (typeof reservedProjectId !== "string" || !/^[a-z0-9][a-z0-9_-]{0,62}$/.test(reservedProjectId)) {
2760
+ return emitResult(buildReceipt({
2761
+ command: "up", outcome: "error", code: "VOLUME_RECOVERY_PROJECT_UNVERIFIED", lease_id: leaseId, slot,
2762
+ error: "the isolated stack project id is unavailable; the recovery stop and supabase start were not attempted",
2763
+ }), opts, EXIT.LEASE_FAILED);
2764
+ }
2765
+ try { recoveryLock = await acquireStackLock(lockPathForProject(reservedProjectId), { timeoutMs: 0 }); }
2766
+ catch {
2767
+ return emitResult(buildReceipt({
2768
+ command: "up", outcome: "error", code: "VOLUME_RECOVERY_PROJECT_BUSY", lease_id: leaseId, slot,
2769
+ error: "the isolated stack project lock is held; the recovery stop and supabase start were not attempted",
2770
+ }), opts, EXIT.LEASE_FAILED);
2771
+ }
2772
+ let recovered = false;
2773
+ try { recovered = await recoveryStopFn(opts, execution, localDockerTarget, { lockHeld: true }) === true; } catch { recovered = false; }
2774
+ if (!recovered) {
2775
+ recoveryLock.release();
2776
+ return emitResult(buildReceipt({
2777
+ command: "up", outcome: "error", code: "VOLUME_RECOVERY_STOP_FAILED", lease_id: leaseId, slot,
2778
+ error: "the exact bounded `supabase stop --no-backup` recovery failed; the reserved lease remains fenced and `supabase start` was not attempted",
2779
+ }), opts, EXIT.LEASE_FAILED);
2780
+ }
2781
+ }
2507
2782
  let conn;
2508
2783
  try {
2509
2784
  conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget, execution), localDockerTarget);
2510
2785
  } catch (e) {
2786
+ recoveryLock?.release();
2511
2787
  if (e?.code === "PROVISION_TIMEOUT") {
2512
2788
  // BOT-2154: a bounded start that ran out of time. Release the lease ourselves —
2513
2789
  // `stack done --local-exec` stops whatever the start left behind and reaps the lease
@@ -2532,6 +2808,7 @@ export async function cmdUp(opts, {
2532
2808
  // review); do NOT cancel.
2533
2809
  return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
2534
2810
  }
2811
+ recoveryLock?.release();
2535
2812
  const act = await call("activate_stack_lease", { lease_id: leaseId, connection: conn });
2536
2813
  if (!act.ok || !act.data?.success) {
2537
2814
  return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
@@ -2690,6 +2967,8 @@ export async function cmdDone(leaseId, opts, {
2690
2967
  if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
2691
2968
  const call = rpc.call;
2692
2969
  let dockerTarget = null;
2970
+ let expectedDockerTarget = null;
2971
+ let provisionedProjectId = null;
2693
2972
  let legacyTargetProof = null;
2694
2973
  let teardownDir = process.cwd();
2695
2974
  let leaseBefore = null;
@@ -2750,6 +3029,7 @@ export async function cmdDone(leaseId, opts, {
2750
3029
  // old lease would still finalize. Refuse on project drift (the provisioned project_id is
2751
3030
  // persisted on the lease connection at `up`).
2752
3031
  const provisionedProject = current.data.connection?.project_id;
3032
+ provisionedProjectId = provisionedProject ?? null;
2753
3033
  if (provisionedProject) {
2754
3034
  const currentProject = projectIdUnder(teardownDir);
2755
3035
  if (currentProject && currentProject !== provisionedProject) {
@@ -2762,6 +3042,7 @@ export async function cmdDone(leaseId, opts, {
2762
3042
  }
2763
3043
  }
2764
3044
  const expected = current.data.connection?.botbuddy_docker_target;
3045
+ expectedDockerTarget = expected ?? null;
2765
3046
  // BOT-1711 (Codex R13 P1): a modern-target lease that lacks a persisted provisioned
2766
3047
  // project_id was NOT provisioned by BOT-1711+ local-exec (e.g. a pre-1.32 lease that
2767
3048
  // recorded a non-root stack_path but actually started the worktree ROOT). Its recorded
@@ -2876,7 +3157,17 @@ export async function cmdDone(leaseId, opts, {
2876
3157
  error: "local `supabase stop` failed — NOT finalizing; the slot stays fenced. Tear the stack down and re-run `stack done --local-exec`, or let the reaper reconcile.",
2877
3158
  }), opts, EXIT.LEASE_FAILED);
2878
3159
  }
2879
- const fin = await call("finalize_stack_lease", { lease_id: leaseId });
3160
+ // BOT-2398: a successful owner-local teardown is itself an authenticated
3161
+ // custody receipt. Carry the exact provisioned project, Docker daemon, and
3162
+ // disposition so the server can persist a reusable decision without waiting
3163
+ // for a Helper reap receipt that will never exist on this path.
3164
+ const localExecCompletion = opts.localExec && expectedDockerTarget && provisionedProjectId
3165
+ ? { project_id: provisionedProjectId, docker_target: dockerTarget, disposition }
3166
+ : undefined;
3167
+ const fin = await call("finalize_stack_lease", {
3168
+ lease_id: leaseId,
3169
+ ...(localExecCompletion ? { local_exec_completion: localExecCompletion } : {}),
3170
+ });
2880
3171
  if (fin.ok && fin.data?.success) state = fin.data.state;
2881
3172
  else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
2882
3173
  }
@@ -3061,9 +3352,17 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
3061
3352
  const withExecution = incidentBox.execution && !withIncident?.execution ? { ...withIncident, execution: incidentBox.execution } : withIncident;
3062
3353
  const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
3063
3354
  const withPortBlock = incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
3064
- return incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3355
+ const final = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3356
+ // BOT-2467: non-enumerable, so the receipt fields and result comparisons never see it.
3357
+ if (final && typeof final === "object" && incidentBox.outcomeChannel) {
3358
+ Object.defineProperty(final, OUTCOME_CHANNEL, { value: incidentBox.outcomeChannel, enumerable: false });
3359
+ }
3360
+ return final;
3065
3361
  }
3066
3362
 
3363
+ /** BOT-2467: where a lifecycle result carries its outcome-record channel. */
3364
+ export const OUTCOME_CHANNEL = Symbol.for("botbuddy.stackRunOutcomeChannel");
3365
+
3067
3366
  async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3068
3367
  // BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
3069
3368
  // long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
@@ -3083,6 +3382,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3083
3382
  refresh: refreshAuth,
3084
3383
  auth: initialAuth,
3085
3384
  });
3385
+ // BOT-2467: cmdRun records the run's outcome with the credential this run ended on.
3386
+ incidentBox.outcomeChannel = { get auth() { return rpc.auth; }, callTool: adapters.callTool ?? callToolJson };
3086
3387
  const api = adapters.api ?? {
3087
3388
  request: (args) => rpc.call("request_stack_lease", args),
3088
3389
  get: (leaseId) => rpc.call("get_stack_lease", { lease_id: leaseId }),
@@ -3713,7 +4014,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3713
4014
  }
3714
4015
  if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
3715
4016
  const cleanup = await requestCleanup();
3716
- return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
4017
+ // BOT-2467: `timeoutPhase` is for the outcome record only (never on the receipt):
4018
+ // an admitted lease whose stack never started is not a capacity shortage.
4019
+ return { exitCode: EXIT.TIMEOUT, outcome: "timeout", timeoutPhase: "provision", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
3717
4020
  }
3718
4021
  }
3719
4022
  if (active?.failed || active?.error || active?.auth) {
@@ -3969,13 +4272,54 @@ export function runReceiptFields(result) {
3969
4272
  };
3970
4273
  }
3971
4274
 
3972
- async function cmdRun(opts, childArgv) {
3973
- const result = await runStackLifecycle(opts, childArgv);
3974
- return emit(buildReceipt(runReceiptFields(result)), opts, result.exitCode);
4275
+ /**
4276
+ * `stack run`: the lifecycle, its one receipt, then (BOT-2467) one best-effort
4277
+ * `record_stack_run_outcome` call carrying the platform-vs-script classification.
4278
+ * The record runs strictly AFTER the receipt is on stdout and can never change it
4279
+ * or the returned exit code.
4280
+ */
4281
+ export async function cmdRun(opts, childArgv, {
4282
+ lifecycle = runStackLifecycle, report = reportStackRunOutcome, now = Date.now, env = process.env,
4283
+ } = {}) {
4284
+ const startedAt = now();
4285
+ const result = await lifecycle(opts, childArgv);
4286
+ const fields = runReceiptFields(result);
4287
+ const code = emit(buildReceipt(fields), opts, result.exitCode);
4288
+ try {
4289
+ await report({ fields: stackRunOutcomeFields(result), exitCode: code, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
4290
+ } catch { /* best-effort: the receipt and exit code are already final */ }
4291
+ return code;
4292
+ }
4293
+
4294
+ /**
4295
+ * BOT-2467: the credential an argument-refused `stack run` can record its outcome
4296
+ * with — env/flag/cached session only. Never self-heals (no mint, no Keychain read)
4297
+ * on a fail-fast path.
4298
+ */
4299
+ /**
4300
+ * BOT-2467: the fields the outcome classifier reads — the receipt's, plus facts the
4301
+ * receipt deliberately does not carry (which wait a timeout ended). Never emitted.
4302
+ */
4303
+ export function stackRunOutcomeFields(result) {
4304
+ return { ...runReceiptFields(result), ...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}) };
4305
+ }
4306
+
4307
+ async function parseErrorOutcomeChannel(argv, { resolveCredential = resolveAgentSessionCredential, callTool = callToolJson } = {}) {
4308
+ try {
4309
+ // Only this command's own options: a credential-looking flag in the child argv
4310
+ // (after `--`) is the child's, never ours.
4311
+ const end = argv.indexOf("--");
4312
+ const ownArgv = end === -1 ? argv : argv.slice(0, end);
4313
+ const credential = await resolveCredential({ argv: ownArgv, env: process.env, cwd: process.cwd(), allowSelfHeal: false });
4314
+ if (typeof credential?.token !== "string" || !AGENT_KEY_RE.test(credential.token)) return null;
4315
+ return { auth: { "x-agent-api-key": credential.token }, callTool };
4316
+ } catch {
4317
+ return null;
4318
+ }
3975
4319
  }
3976
4320
 
3977
4321
  /** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
3978
- export async function cmdStack(argv) {
4322
+ export async function cmdStack(argv, { report = reportStackRunOutcome, outcomeChannel = parseErrorOutcomeChannel } = {}) {
3979
4323
  if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
3980
4324
  process.stdout.write(STACK_HELP + "\n");
3981
4325
  return EXIT.OK;
@@ -3983,7 +4327,12 @@ export async function cmdStack(argv) {
3983
4327
  const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
3984
4328
  if (errors.length) {
3985
4329
  for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
3986
- const code = emit(buildReceipt({ command: command || "?", outcome: "error", error: errors[0] }), opts, EXIT.INVALID);
4330
+ const fields = { command: command || "?", outcome: "error", error: errors[0] };
4331
+ const code = emit(buildReceipt(fields), opts, EXIT.INVALID);
4332
+ // BOT-2467: a refused `stack run` is recorded as an `invocation` outcome (no lease).
4333
+ if (command === "run") {
4334
+ try { await report({ fields, exitCode: code, opts, env: process.env, durationMs: 0, channel: await outcomeChannel(argv) }); } catch { /* best-effort */ }
4335
+ }
3987
4336
  process.exitCode = code;
3988
4337
  return code;
3989
4338
  }
@@ -4001,7 +4350,7 @@ export async function cmdStack(argv) {
4001
4350
  case "status": code = await cmdStatus(leaseId, opts); break;
4002
4351
  case "touch": code = await cmdTouch(leaseId, opts); break;
4003
4352
  case "done": code = await cmdDone(leaseId, opts); break;
4004
- case "run": code = await cmdRun(opts, childArgv); break;
4353
+ case "run": code = await cmdRun(opts, childArgv, { report }); break;
4005
4354
  default:
4006
4355
  process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
4007
4356
  code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
package/src/wait-core.mjs CHANGED
@@ -568,6 +568,72 @@ export function parseConditions(specs) {
568
568
  return { conditions, errors };
569
569
  }
570
570
 
571
+ // ---------------------------------------------------------------------------
572
+ // BOT-2467: a secret-free description of grammar failures, for the
573
+ // `wait_setup_error` sensor event. Per failed spec: the attempted condition TYPE
574
+ // (only when it is a known type or within edit distance 2 of one — else
575
+ // "unknown") and a parse_error category derived from the validator's message.
576
+ // Never the raw spec, a param name or a param value.
577
+ // ---------------------------------------------------------------------------
578
+
579
+ export const CONDITION_PARSE_ERRORS = Object.freeze([
580
+ "unknown_type", "missing_param", "bad_param_value", "unknown_param", "param_conflict",
581
+ "condition_conflict", "empty", "syntax",
582
+ ]);
583
+ const MAX_DESCRIBED_CONDITION_ERRORS = 16;
584
+ const TYPE_TOKEN_RE = /^[a-z0-9_-]{1,32}$/;
585
+
586
+ function editDistanceAtMost(a, b, limit) {
587
+ if (Math.abs(a.length - b.length) > limit) return false;
588
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
589
+ for (let i = 1; i <= a.length; i++) {
590
+ const row = [i];
591
+ let best = i;
592
+ for (let j = 1; j <= b.length; j++) {
593
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
594
+ best = Math.min(best, row[j]);
595
+ }
596
+ if (best > limit) return false;
597
+ prev = row;
598
+ }
599
+ return prev[b.length] <= limit;
600
+ }
601
+
602
+ function attemptedConditionType(spec) {
603
+ const raw = String(spec ?? "");
604
+ const colon = raw.indexOf(":");
605
+ const token = (colon === -1 ? raw : raw.slice(0, colon)).trim();
606
+ if (Object.prototype.hasOwnProperty.call(VALIDATORS, token)) return token;
607
+ const lower = token.toLowerCase();
608
+ if (!TYPE_TOKEN_RE.test(lower)) return "unknown";
609
+ return Object.keys(VALIDATORS).some((known) => editDistanceAtMost(lower, known, 2)) ? lower : "unknown";
610
+ }
611
+
612
+ function conditionParseError(error) {
613
+ const spec = String(error?.spec ?? "");
614
+ const message = String(error?.message ?? "");
615
+ if (spec.trim() === "" || /at least one condition is required/.test(message)) return "empty";
616
+ if (/^unknown condition type/.test(message)) return "unknown_type";
617
+ if (/must be the only condition/.test(message)) return "condition_conflict";
618
+ if (/exactly one of|only valid with|takes no /.test(message)) return "param_conflict";
619
+ if (/unknown key|does not accept|accepts only/.test(message)) return "unknown_param";
620
+ if (/\bneeds\b/.test(message)) return "missing_param";
621
+ if (/\bmust\b/.test(message)) return "bad_param_value";
622
+ return "syntax";
623
+ }
624
+
625
+ /**
626
+ * `parseConditions(...).errors` → `[{ attempted_type, parse_error }]`, one per failed
627
+ * spec in order, at most 16. Pure; safe to send (no spec text, names or values).
628
+ */
629
+ export function describeConditionErrors(errors) {
630
+ if (!Array.isArray(errors)) return [];
631
+ return errors.slice(0, MAX_DESCRIBED_CONDITION_ERRORS).map((error) => {
632
+ const parseError = conditionParseError(error);
633
+ return { attempted_type: parseError === "empty" ? "unknown" : attemptedConditionType(error?.spec), parse_error: parseError };
634
+ });
635
+ }
636
+
571
637
  // ---------------------------------------------------------------------------
572
638
  // SSE frame parsing — split-safe, keepalive-aware, multi-line data.
573
639
  // ---------------------------------------------------------------------------
package/src/wait.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // Usage: botbuddy wait [--any] <condition>... [options]
14
14
  // Run botbuddy wait --help for the condition grammar.
15
15
 
16
- import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance } from "./wait-core.mjs";
16
+ import { EXIT, describeConditionErrors, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance } from "./wait-core.mjs";
17
17
  import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
@@ -915,7 +915,7 @@ export const RECOVERY = Object.freeze({
915
915
  // only error code, exit code, condition TYPES, auth path, profile name, cli version.
916
916
  const SENSOR_TELEMETRY_TIMEOUT_MS = 2000;
917
917
 
918
- async function reportSetupError(opts, { error, exitCode, conditions }) {
918
+ async function reportSetupError(opts, { error, exitCode, conditions, conditionErrors = null }) {
919
919
  // Never on the session-token path. A bb_agent_/bb_sess_ SESSION token cannot
920
920
  // authenticate at sensor-ingest (edgeAuth resolves only PATs / agent keys), so it
921
921
  // would only 401. Discriminate by the credential's SOURCE, not its shape: a
@@ -939,7 +939,7 @@ async function reportSetupError(opts, { error, exitCode, conditions }) {
939
939
  timer = setTimeout(() => { ac.abort(); resolve(TIMED_OUT); }, SENSOR_TELEMETRY_TIMEOUT_MS);
940
940
  });
941
941
  try {
942
- await Promise.race([sendSetupErrorEvent(opts, { error, exitCode, conditions }, ac.signal), timeout]);
942
+ await Promise.race([sendSetupErrorEvent(opts, { error, exitCode, conditions, conditionErrors }, ac.signal), timeout]);
943
943
  } catch {
944
944
  // Best-effort: an AbortError (2 s timeout) or any transport/lookup error drops.
945
945
  } finally {
@@ -947,7 +947,7 @@ async function reportSetupError(opts, { error, exitCode, conditions }) {
947
947
  }
948
948
  }
949
949
 
950
- async function sendSetupErrorEvent(opts, { error, exitCode, conditions }, signal) {
950
+ async function sendSetupErrorEvent(opts, { error, exitCode, conditions, conditionErrors }, signal) {
951
951
  // Resolve a bearer WITHOUT ever touching the Keychain. This telemetry is
952
952
  // best-effort and runs on fail-fast paths right before process.exit; a Keychain
953
953
  // read (readProfileCredential → `security find-generic-password`) can block/prompt
@@ -994,6 +994,7 @@ async function sendSetupErrorEvent(opts, { error, exitCode, conditions }, signal
994
994
  error,
995
995
  exit_code: exitCode,
996
996
  condition_types: conditionTypes,
997
+ ...(Array.isArray(conditionErrors) && conditionErrors.length ? { condition_errors: conditionErrors } : {}),
997
998
  auth_path: opts.token ? "explicit_token" : "binding",
998
999
  profile: null,
999
1000
  cli_version: VERSION,
@@ -1577,8 +1578,10 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1577
1578
  process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
1578
1579
  emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
1579
1580
  // BOT-1593: condition_types is [] on a grammar failure (the parsed set is
1580
- // partial/empty and is caller data regardless).
1581
- await reportSetupError(opts, { error: "invalid_conditions", exitCode: EXIT.INVALID, conditions: [] });
1581
+ // partial/empty and is caller data regardless). BOT-2467: instead, each failed
1582
+ // spec is described by its attempted TYPE and a parse_error category — never
1583
+ // the spec text, a param name or a value.
1584
+ await reportSetupError(opts, { error: "invalid_conditions", exitCode: EXIT.INVALID, conditions: [], conditionErrors: describeConditionErrors(errors) });
1582
1585
  process.exit(EXIT.INVALID);
1583
1586
  }
1584
1587