@botbuddy/cli 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -3,6 +3,7 @@ import { doLogin } from "./auth.mjs";
3
3
  import { runBridge } from "./codex-bridge.mjs";
4
4
  import { loadConfig, getConfig, clearConfig, getConfigPath, SERVER_URL } from "./config.mjs";
5
5
  import { resolveOwnerToken, resolveAgentKey, clearOwnerToken } from "./cli-credentials.mjs";
6
+ import { agentProfileKeyLabel } from "./credential-kinds.mjs";
6
7
  import { buildAcquireResourcesPayload, LocksUsageError } from "./locks.mjs";
7
8
  import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from "./discovery.mjs";
8
9
  import { cmdStack } from "./stack.mjs";
@@ -468,7 +469,11 @@ export async function cmdStatus({
468
469
  }
469
470
  const agentKey = await resolveAgent();
470
471
  if (agentKey) {
471
- log(`${green("✓")} Authenticated via agent key ${dim("(Keychain profile store)")}`);
472
+ // BOT-1573: name the credential by its prefix rather than always "agent key"
473
+ // — a profile slot can hold an agent, a carrier (legacy), or a CI key. A
474
+ // legacy bare-hex register_agent key (no prefix yet) still reads as an agent.
475
+ const label = agentProfileKeyLabel(agentKey);
476
+ log(`${green("✓")} Authenticated via ${label} ${dim("(Keychain profile store)")}`);
472
477
  if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
473
478
  await logServerStatus(call, { "x-agent-api-key": agentKey }, log);
474
479
  return;
@@ -0,0 +1,66 @@
1
+ // BOT-1573 — classify a stored secret by its prefix and name its kind, so
2
+ // `botbuddy status` can say WHAT credential it holds ("agent", "client key",
3
+ // "OAuth owner token") instead of a generic "agent key".
4
+ //
5
+ // This mirrors the UI single source `src/components/credentials/types.ts`
6
+ // (CREDENTIAL_TYPES). The front-end registry is TypeScript in the web bundle and
7
+ // can't be imported into this CLI package, so the prefix→kind table is restated
8
+ // here — kept honest by tests on both sides. Classification is recognition ONLY;
9
+ // it is NEVER used for authorization (auth is the server's hashed lookup).
10
+
11
+ /**
12
+ * Ordered longest-prefix-first so `bb_ci_` can't shadow a longer `bb_*` prefix,
13
+ * and the bare legacy `bb_` is matched last.
14
+ * @type {ReadonlyArray<{ prefix: string, kind: string, label: string }>}
15
+ */
16
+ export const CREDENTIAL_PREFIXES = [
17
+ { prefix: "mcp_at_", kind: "oauth", label: "OAuth owner token" },
18
+ { prefix: "bb_pat_", kind: "pat", label: "personal access token" },
19
+ { prefix: "bb_cli_", kind: "cli", label: "client key" },
20
+ // BOT-1573/1582 transition: TODAY every live bb_agent_ secret is an older
21
+ // shared service carrier (this PR keeps them authenticating), and the actual
22
+ // per-session agent token is bb_sess_ (BOT-1572). So bb_agent_ classifies as
23
+ // the carrier — matching the UI adapter — NOT as an agent. Once BOT-1582 makes
24
+ // register_agent MINT bb_agent_, this flips to the agent kind.
25
+ { prefix: "bb_agent_", kind: "svc", label: "carrier (legacy)" },
26
+ { prefix: "bb_sess_", kind: "agent", label: "agent (session)" },
27
+ { prefix: "bb_ci_", kind: "ci", label: "CI key" },
28
+ { prefix: "bb_svc_", kind: "svc", label: "carrier (legacy)" },
29
+ { prefix: "bb_ios_", kind: "ios", label: "device pairing token" },
30
+ { prefix: "bb_", kind: "legacy", label: "legacy personal key" },
31
+ ];
32
+
33
+ /**
34
+ * Classify a raw secret by its prefix.
35
+ * @param {unknown} secret
36
+ * @returns {{ prefix: string, kind: string, label: string } | null} null when it
37
+ * matches no known BotBuddy prefix.
38
+ */
39
+ export function classifyCredential(secret) {
40
+ if (typeof secret !== "string" || secret.length === 0) return null;
41
+ for (const entry of CREDENTIAL_PREFIXES) {
42
+ if (secret.startsWith(entry.prefix)) return entry;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** The operator-facing kind label for a secret, or a safe fallback. */
48
+ export function credentialKindLabel(secret) {
49
+ return classifyCredential(secret)?.label ?? "credential";
50
+ }
51
+
52
+ /** True for the legacy bare-hex agent api_key that `register_agent` still returns
53
+ * (64 lowercase hex, NO typed prefix). BOT-1574/1582 migrate profiles off it. */
54
+ export function isLegacyBareAgentKey(secret) {
55
+ return typeof secret === "string" && /^[0-9a-f]{64}$/.test(secret);
56
+ }
57
+
58
+ /** Kind label for a key resolved from the profile AGENT-key store. A typed
59
+ * prefix classifies normally; a legacy bare-hex api_key (register_agent, no
60
+ * prefix yet) is still an agent credential — not the generic fallback. */
61
+ export function agentProfileKeyLabel(secret) {
62
+ const classified = classifyCredential(secret);
63
+ if (classified) return classified.label;
64
+ if (isLegacyBareAgentKey(secret)) return "agent key (legacy profile)";
65
+ return "credential";
66
+ }
@@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
8
8
  import { hostname } from "node:os";
9
9
  import { callToolJson } from "./api.mjs";
10
10
  import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
11
+ import { VERSION } from "./version.mjs";
11
12
  import { machineUuid } from "./machine-id.mjs";
12
13
 
13
14
  export const SCHEMA_VERSION = 1;
@@ -18,6 +19,7 @@ const INSPECT_BATCH_SIZE = 10;
18
19
  const MAX_APPLY_BUDGET_MS = 5 * 60 * 1000;
19
20
  const MAX_DOCKER_COMMAND_MS = 30 * 1000;
20
21
  const LOCK_EXPIRY_RESERVE_MS = 60 * 1000;
22
+ const TELEMETRY_TIMEOUT_MS = 2 * 1000;
21
23
 
22
24
  export const EXIT = Object.freeze({
23
25
  OK: 0,
@@ -79,6 +81,18 @@ SAFETY
79
81
  • Re-inspects every candidate immediately before deletion.
80
82
  • Never force-removes a resource, never runs a prune command, and never removes volumes.
81
83
 
84
+ RELIABILITY MEASUREMENT
85
+ After the local safety decision is complete, the CLI makes one best-effort
86
+ aggregate reliability report. Docker IDs, names, endpoints, labels, paths,
87
+ commands, and raw errors are not uploaded. Reporting is observational only:
88
+ success, rejection, unavailability, or timeout never changes cleanup safety,
89
+ command arguments, or the local exit code.
90
+
91
+ LIFECYCLE
92
+ Use one worktree and one ticket-scoped managed stack for one test/fix batch;
93
+ finish or reap that stack before starting another. Run preflight before start,
94
+ and ticket-scoped dry-run hygiene when pressure warns or refuses admission.
95
+
82
96
  PERSISTENT ORBSTACK GHOSTS
83
97
  If an exact ID remains listed but OrbStack cannot inspect it, inventory is
84
98
  reported but cleanup refuses. Restart or repair OrbStack, then rerun the
@@ -329,6 +343,11 @@ function selected(runDocker, selector, args) {
329
343
  return runChecked(runDocker, [...selector, ...args]);
330
344
  }
331
345
 
346
+ function isDockerDaemonUnavailable(error) {
347
+ return /(?:cannot connect to (?:the )?docker daemon|docker daemon is not running|error during connect|dial unix[^\n]*(?:connection refused|no such file or directory)|orbstack[^\n]*daemon[^\n]*unavailable)/i
348
+ .test(String(error?.message ?? error ?? ""));
349
+ }
350
+
332
351
  function nonEmptyLines(text) {
333
352
  return text.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
334
353
  }
@@ -655,15 +674,26 @@ function readInventory(runDocker, selector) {
655
674
 
656
675
  function validateOrbStack(runDocker, opts) {
657
676
  let resolvedEndpoint = opts.endpoint;
677
+ let targetHintsOrbStack = /orbstack/i.test(String(opts.endpoint || ""));
658
678
  if (opts.context) {
659
679
  const inspected = parseJson(runChecked(runDocker, ["context", "inspect", opts.context]), "docker context inspect");
660
680
  const context = Array.isArray(inspected) ? inspected[0] : inspected;
661
681
  resolvedEndpoint = context?.Endpoints?.docker?.Host || null;
662
682
  if (!resolvedEndpoint) throw new Error(`Docker context ${opts.context} has no Docker endpoint`);
683
+ targetHintsOrbStack = /orbstack/i.test(`${context?.Name || opts.context} ${context?.Metadata?.Description || ""} ${resolvedEndpoint}`);
663
684
  }
664
685
 
665
686
  const selector = selectorArgs(opts);
666
- const info = parseJson(selected(runDocker, selector, ["info", "--format", "{{json .}}"]), "docker info");
687
+ let info;
688
+ try {
689
+ info = parseJson(selected(runDocker, selector, ["info", "--format", "{{json .}}"]), "docker info");
690
+ } catch (error) {
691
+ if (targetHintsOrbStack && isDockerDaemonUnavailable(error)) {
692
+ error.reliabilityEventKind = "orbstack_unavailable";
693
+ error.resolvedEndpoint = resolvedEndpoint;
694
+ }
695
+ throw error;
696
+ }
667
697
  const osIsOrbStack = info?.OperatingSystem === "OrbStack";
668
698
  const identity = `${info?.KernelVersion || ""} ${info?.Name || ""} ${info?.ServerVersion || ""}`;
669
699
  if (!osIsOrbStack || !/orbstack/i.test(identity)) {
@@ -741,6 +771,7 @@ function baseReceipt(command, opts, now) {
741
771
  warnings: [],
742
772
  errors: [],
743
773
  recommendation: null,
774
+ telemetry: { status: "not_attempted" },
744
775
  authority: {
745
776
  ticket: opts.ticket,
746
777
  worktree_branch: null,
@@ -751,6 +782,119 @@ function baseReceipt(command, opts, now) {
751
782
  };
752
783
  }
753
784
 
785
+ export function buildReliabilityTelemetry(receipt, { dedupeKey } = {}) {
786
+ const unavailable = receipt.context?.validation === "orbstack_unavailable";
787
+ const ghostInventory = (receipt.inventory_errors?.length ?? 0) > 0;
788
+ // A successful target check is not a capacity measurement. If discovery
789
+ // fails before evaluatePressure produces a decision, suppress the generic
790
+ // observation so the evidence view cannot count a zero-filled false pass.
791
+ // Classified daemon and persistent-inventory faults remain reportable.
792
+ if (receipt.command === "preflight" && !receipt.pressure && !unavailable && !ghostInventory) {
793
+ return null;
794
+ }
795
+ const eventKind = unavailable
796
+ ? "orbstack_unavailable"
797
+ : ghostInventory
798
+ ? "ghost_inventory"
799
+ : receipt.command === "preflight"
800
+ ? "capacity_observed"
801
+ : receipt.apply ? "hygiene_apply" : "hygiene_dry_run";
802
+ const event = {
803
+ schema_version: SCHEMA_VERSION,
804
+ event_kind: eventKind,
805
+ dedupe_key: dedupeKey,
806
+ observed_at: receipt.timestamp,
807
+ outcome: receipt.outcome,
808
+ active_supabase_projects: receipt.active_projects?.length ?? 0,
809
+ bridge_networks: receipt.inventory?.user_bridge_networks ?? 0,
810
+ attached_endpoints: receipt.inventory?.attached_endpoints ?? 0,
811
+ unknown_resources: (receipt.inventory_errors?.length ?? 0)
812
+ + (receipt.skipped?.filter((item) => item.reason === "missing_project_identity").length ?? 0),
813
+ candidate_count: receipt.candidates?.length ?? 0,
814
+ deleted_count: receipt.deleted?.length ?? 0,
815
+ skipped_count: receipt.skipped?.length ?? 0,
816
+ reclaimed_bytes: receipt.reclaimed_space_bytes ?? 0,
817
+ current_pressure_units: receipt.pressure?.current_pressure_units ?? null,
818
+ projected_pressure_units: receipt.pressure?.projected_pressure_units ?? null,
819
+ preflight_status: receipt.pressure
820
+ ? ({ ok: "admissible", warn: "warn", fail: "refused" }[receipt.pressure.status] ?? "unknown")
821
+ : null,
822
+ ...(unavailable || ghostInventory ? { fault_class: eventKind } : {}),
823
+ cli_version: VERSION,
824
+ // Docker exposes the OrbStack VM kernel build, not the OrbStack app
825
+ // release. Leaving the version absent is scientifically safer than
826
+ // stratifying observations on a mislabeled kernel dimension.
827
+ docker_version: receipt.context?.server?.version ?? null,
828
+ };
829
+ // The parser treats omitted and null optional values equivalently, while
830
+ // JSON Schema correctly declares only the concrete scalar types. Keep the
831
+ // official CLI payload schema-valid by omitting absence rather than sending
832
+ // explicit nulls through strict MCP gateways.
833
+ return Object.fromEntries(Object.entries(event).filter(([, value]) => value != null));
834
+ }
835
+
836
+ async function reportReliability(result, options) {
837
+ const reportableOutage = result?.receipt?.context?.validation === "orbstack_unavailable";
838
+ if (!result?.receipt?.context?.server && !reportableOutage) return result;
839
+ const callTool = options.callTool ?? callToolJson;
840
+ const createDedupeKey = options.telemetryDedupeKey
841
+ ?? (() => `cli:${result.receipt.command}:${crypto.randomUUID()}`);
842
+ const telemetryNow = options.telemetryNow ?? options.workflowOptions?.now ?? (() => new Date());
843
+ let observedAt;
844
+ try {
845
+ observedAt = telemetryNow().toISOString();
846
+ } catch {
847
+ result.receipt.telemetry = { status: "unavailable", code: "invalid_observation_time" };
848
+ return result;
849
+ }
850
+ const telemetry = buildReliabilityTelemetry(result.receipt, { dedupeKey: createDedupeKey() });
851
+ if (!telemetry) return result;
852
+ const event = {
853
+ ...telemetry,
854
+ // The receipt timestamp marks command start. Measurement correlation must
855
+ // instead use the instant the complete inventory/safety decision exists;
856
+ // otherwise a bounded but slow Docker census can age a just-in-time
857
+ // preflight outside the stack-attempt correlation window.
858
+ observed_at: observedAt,
859
+ };
860
+ const controller = new AbortController();
861
+ const timeoutMs = options.telemetryTimeoutMs ?? TELEMETRY_TIMEOUT_MS;
862
+ // Race the ENTIRE tool call — credential resolution (resolveCallAuth, which can
863
+ // block on a macOS Keychain prompt) AND the HTTP request — against the timeout.
864
+ // runLocalExecPreflight routes through this wrapper and cmdUp awaits it, so
865
+ // aborting only the eventual fetch would let a blocked Keychain read hang stack
866
+ // admission despite the timer (BOT-1421 review). The abort is still fired.
867
+ let timer;
868
+ const TIMED_OUT = Symbol("reliability-telemetry-timeout");
869
+ const timeout = new Promise((resolve) => {
870
+ timer = setTimeout(() => {
871
+ controller.abort(new Error("telemetry timeout"));
872
+ resolve(TIMED_OUT);
873
+ }, timeoutMs);
874
+ });
875
+ try {
876
+ const reported = await Promise.race([
877
+ callTool("report_host_reliability", event, { signal: controller.signal }),
878
+ timeout,
879
+ ]);
880
+ result.receipt.telemetry = reported === TIMED_OUT
881
+ ? { status: "unavailable", code: "report_timeout" }
882
+ : reported?.ok && !reported.isError && reported.data?.success !== false
883
+ ? { status: "reported", inserted: reported.data?.inserted !== false }
884
+ : reported?.ok
885
+ ? { status: "rejected", code: String(reported.data?.code || "report_rejected").slice(0, 80) }
886
+ : { status: "unavailable", code: String(reported?.error || "report_unavailable").slice(0, 80) };
887
+ } catch (error) {
888
+ result.receipt.telemetry = {
889
+ status: "unavailable",
890
+ code: error?.name === "AbortError" ? "report_timeout" : "report_unavailable",
891
+ };
892
+ } finally {
893
+ clearTimeout(timer);
894
+ }
895
+ return result;
896
+ }
897
+
754
898
  function addSkipOnce(receipt, item) {
755
899
  if (!receipt.skipped.some((existing) => existing.type === item.type && existing.id === item.id && existing.reason === item.reason)) {
756
900
  receipt.skipped.push(item);
@@ -833,6 +977,16 @@ export function runDockerWorkflow(argv, {
833
977
  } catch (error) {
834
978
  receipt.outcome = "error";
835
979
  receipt.errors = [error.message];
980
+ if (error.reliabilityEventKind === "orbstack_unavailable") {
981
+ receipt.context = {
982
+ requested: parsed.opts.context
983
+ ? { type: "context", value: parsed.opts.context }
984
+ : { type: "endpoint", value: parsed.opts.endpoint },
985
+ resolved_endpoint: error.resolvedEndpoint || parsed.opts.endpoint || null,
986
+ validation: "orbstack_unavailable",
987
+ server: null,
988
+ };
989
+ }
836
990
  return { exitCode: EXIT.DOCKER, receipt, json: parsed.opts.json };
837
991
  }
838
992
 
@@ -1025,6 +1179,7 @@ export function formatHumanReceipt(receipt) {
1025
1179
  if (receipt.pressure) {
1026
1180
  lines.push(`Pressure: ${receipt.pressure.projected_pressure_units} projected (${receipt.pressure.status}; warn ${receipt.pressure.warn_pressure}, fail ${receipt.pressure.fail_pressure})`);
1027
1181
  }
1182
+ lines.push(`Reliability telemetry: ${receipt.telemetry?.status || "not_attempted"}`);
1028
1183
  for (const warning of receipt.warnings) lines.push(`Warning: ${warning}`);
1029
1184
  for (const error of receipt.errors) lines.push(`Error: ${error}`);
1030
1185
  if (receipt.recommendation) lines.push(`Next: ${receipt.recommendation}`);
@@ -1080,7 +1235,7 @@ export async function runDockerCommand(argv, options = {}) {
1080
1235
  const runWorkflow = options.runWorkflow ?? runDockerWorkflow;
1081
1236
  const workflowOptions = options.workflowOptions ?? {};
1082
1237
  if (parsed.errors.length > 0 || parsed.command !== "hygiene" || !parsed.opts.apply) {
1083
- return runWorkflow(argv, workflowOptions);
1238
+ return reportReliability(await runWorkflow(argv, workflowOptions), options);
1084
1239
  }
1085
1240
 
1086
1241
  const resolveProjectId = options.resolveProjectId ?? resolveLocalProjectId;
@@ -1104,7 +1259,6 @@ export async function runDockerCommand(argv, options = {}) {
1104
1259
  if (result?.receipt?.authority) {
1105
1260
  result.receipt.authority.file_lock = { project_id: projectId, path: fileLockPath, held: true, released: false };
1106
1261
  }
1107
- return result;
1108
1262
  } finally {
1109
1263
  fileLock.release();
1110
1264
  if (result?.receipt?.authority?.file_lock) {
@@ -1112,6 +1266,7 @@ export async function runDockerCommand(argv, options = {}) {
1112
1266
  result.receipt.authority.file_lock.released = true;
1113
1267
  }
1114
1268
  }
1269
+ return reportReliability(result, options);
1115
1270
  }
1116
1271
 
1117
1272
  async function runDockerCommandWithBotBuddyLock(argv, {
package/src/stack.mjs CHANGED
@@ -30,7 +30,7 @@ import { randomUUID } from "crypto";
30
30
  import { callToolJson } from "./api.mjs";
31
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
32
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
33
- import { runDockerWorkflow } from "./docker-hygiene.mjs";
33
+ import { runDockerCommand, runDockerWorkflow } from "./docker-hygiene.mjs";
34
34
  import { bold, dim, yellow } from "./utils.mjs";
35
35
 
36
36
  export const STACK_SCHEMA_VERSION = 1;
@@ -497,7 +497,15 @@ const nonQueued = (s) => s != null && s !== "queued";
497
497
  const isReaped = (s) => s === "reaping" || s === "reaped";
498
498
 
499
499
  /** Read-only pressure gate used before local managed-stack provisioning. */
500
- export function runLocalExecPreflight(opts, runWorkflow = runDockerWorkflow) {
500
+ export async function runLocalExecPreflight(opts, runCommand = runDockerCommand) {
501
+ const selector = opts.dockerContext
502
+ ? ["--context", opts.dockerContext]
503
+ : ["--endpoint", opts.dockerEndpoint];
504
+ return runCommand(["preflight", ...selector, "--json"]);
505
+ }
506
+
507
+ /** Read-only daemon identity check for teardown; never report provision telemetry. */
508
+ export async function runLocalExecTargetCheck(opts, runWorkflow = runDockerWorkflow) {
501
509
  const selector = opts.dockerContext
502
510
  ? ["--context", opts.dockerContext]
503
511
  : ["--endpoint", opts.dockerEndpoint];
@@ -670,7 +678,7 @@ export async function cmdUp(opts, {
670
678
  let localPreflight = null;
671
679
  let localDockerTarget = null;
672
680
  if (opts.localExec) {
673
- const checked = runPreflight(opts);
681
+ const checked = await runPreflight(opts);
674
682
  localPreflight = compactPreflight(checked.receipt);
675
683
  localDockerTarget = dockerTargetFromPreflight(checked.receipt);
676
684
  if (checked.exitCode !== 0 || !localDockerTarget) {
@@ -725,7 +733,7 @@ export async function cmdUp(opts, {
725
733
  // Capacity may have changed while this command was queued. Re-run the
726
734
  // non-mutating gate immediately before `supabase start`; on refusal,
727
735
  // release the minted lease and never invoke the local provisioner.
728
- const checked = runPreflight(opts);
736
+ const checked = await runPreflight(opts);
729
737
  localPreflight = compactPreflight(checked.receipt);
730
738
  localDockerTarget = dockerTargetFromPreflight(checked.receipt);
731
739
  if (checked.exitCode !== 0 || !localDockerTarget) {
@@ -740,10 +748,41 @@ export async function cmdUp(opts, {
740
748
  lease_cancellation: leaseCancellation,
741
749
  }), opts, EXIT.LEASE_FAILED);
742
750
  }
751
+ // Reserve the queued provision job for THIS agent BEFORE `supabase start`.
752
+ // Otherwise a Helper can claim it while the local provisioner runs, winning
753
+ // the later activation race and leaving an untracked local stack (two
754
+ // provisioners contending for one slot). If the reservation is LOST, nothing
755
+ // local has started yet, so it is safe to atomically release the minted lease
756
+ // and free the slot (BOT-1421 review).
757
+ // Persist the validated Docker target with the reservation so that if the
758
+ // provisioner partially starts a stack and then fails, the fenced lease can
759
+ // still be torn down by `stack done --local-exec` (BOT-1421 review).
760
+ const reserved = await callTool("reserve_stack_lease", {
761
+ lease_id: leaseId,
762
+ connection: connectionWithDockerTarget({}, localDockerTarget),
763
+ });
764
+ if (!reserved.ok || !reserved.data?.success) {
765
+ const cancelled = await callTool("cancel_unclaimed_stack_lease", { lease_id: leaseId });
766
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
767
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
768
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
769
+ return emitResult(buildReceipt({
770
+ command: "up", outcome: "error", lease_id: leaseId, state, slot,
771
+ error: reserved.data?.code || reserved.error || "could not reserve the provision job for local execution",
772
+ lease_cancellation: leaseCancellation,
773
+ }), opts, EXIT.LEASE_FAILED);
774
+ }
743
775
  let conn;
744
776
  try {
745
777
  conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget), localDockerTarget);
746
778
  } catch (e) {
779
+ // Once the local provisioner has run, `supabase start` may have created
780
+ // (or fully started) containers even on a nonzero exit or a status-parse
781
+ // failure. Cancelling here would reap the lease and free the slot while
782
+ // that stack is still up — the exact orphaned/contended-slot hazard this
783
+ // change closes. Leave the lease FENCED (provisioning, reserved to us) so
784
+ // `botbuddy stack done` / the reaper tears the stack down first (BOT-1421
785
+ // review); do NOT cancel.
747
786
  return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
748
787
  }
749
788
  const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
@@ -795,7 +834,7 @@ async function cmdTouch(leaseId, opts) {
795
834
 
796
835
  export async function cmdDone(leaseId, opts, {
797
836
  callTool = callToolJson,
798
- runPreflight = runLocalExecPreflight,
837
+ runPreflight = runLocalExecTargetCheck,
799
838
  proveLegacyTarget = proveLegacyLocalExecTarget,
800
839
  localTeardownFn = localTeardown,
801
840
  emitResult = emit,
@@ -811,7 +850,7 @@ export async function cmdDone(leaseId, opts, {
811
850
  }
812
851
  const expected = current.data.connection?.botbuddy_docker_target;
813
852
  let checked;
814
- try { checked = runPreflight(opts); } catch (error) {
853
+ try { checked = await runPreflight(opts); } catch (error) {
815
854
  return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
816
855
  error: `could not validate teardown Docker target: ${error.message}` }), opts, EXIT.LEASE_FAILED);
817
856
  }