@botbuddy/cli 1.18.0 → 1.19.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.18.0",
3
+ "version": "1.19.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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];
@@ -657,6 +665,7 @@ export async function cmdUp(opts, {
657
665
  callTool = callToolJson,
658
666
  authProvider = stackAuthHeader,
659
667
  localProvisionFn = localProvision,
668
+ waitFn = waitForLease,
660
669
  emitResult = emit,
661
670
  } = {}) {
662
671
  let slot;
@@ -670,7 +679,7 @@ export async function cmdUp(opts, {
670
679
  let localPreflight = null;
671
680
  let localDockerTarget = null;
672
681
  if (opts.localExec) {
673
- const checked = runPreflight(opts);
682
+ const checked = await runPreflight(opts);
674
683
  localPreflight = compactPreflight(checked.receipt);
675
684
  localDockerTarget = dockerTargetFromPreflight(checked.receipt);
676
685
  if (checked.exitCode !== 0 || !localDockerTarget) {
@@ -712,7 +721,7 @@ export async function cmdUp(opts, {
712
721
  return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, queue_position: d.queue_position, holders: d.holders }), opts, EXIT.QUEUED);
713
722
  }
714
723
  // Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
715
- const parked = await waitForLease(leaseId, nonQueued, () => false, { timeoutSec: opts.timeout, auth });
724
+ const parked = await waitFn(leaseId, nonQueued, () => false, { timeoutSec: opts.timeout, auth });
716
725
  if (parked.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity` }), opts, EXIT.TIMEOUT);
717
726
  if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
718
727
  if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
@@ -725,7 +734,7 @@ export async function cmdUp(opts, {
725
734
  // Capacity may have changed while this command was queued. Re-run the
726
735
  // non-mutating gate immediately before `supabase start`; on refusal,
727
736
  // release the minted lease and never invoke the local provisioner.
728
- const checked = runPreflight(opts);
737
+ const checked = await runPreflight(opts);
729
738
  localPreflight = compactPreflight(checked.receipt);
730
739
  localDockerTarget = dockerTargetFromPreflight(checked.receipt);
731
740
  if (checked.exitCode !== 0 || !localDockerTarget) {
@@ -740,19 +749,81 @@ export async function cmdUp(opts, {
740
749
  lease_cancellation: leaseCancellation,
741
750
  }), opts, EXIT.LEASE_FAILED);
742
751
  }
743
- let conn;
744
- try {
745
- conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget), localDockerTarget);
746
- } catch (e) {
747
- return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
748
- }
749
- const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
750
- if (!act.ok || !act.data?.success) {
751
- return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
752
+ // Reserve the queued provision job for THIS agent BEFORE `supabase start`.
753
+ // Otherwise a Helper can claim it while the local provisioner runs, winning
754
+ // the later activation race and leaving an untracked local stack (two
755
+ // provisioners contending for one slot). If the reservation is LOST, nothing
756
+ // local has started yet, so it is safe to atomically release the minted lease
757
+ // and free the slot (BOT-1421 review).
758
+ // Persist the validated Docker target with the reservation so that if the
759
+ // provisioner partially starts a stack and then fails, the fenced lease can
760
+ // still be torn down by `stack done --local-exec` (BOT-1421 review).
761
+ const reserved = await callTool("reserve_stack_lease", {
762
+ lease_id: leaseId,
763
+ connection: connectionWithDockerTarget({}, localDockerTarget),
764
+ });
765
+ if (!reserved.ok || !reserved.data?.success) {
766
+ // A Helper can win the reservation race by taking the queued provision
767
+ // job between `request_stack_lease` and `reserve_stack_lease`. That is NOT
768
+ // a failure — nothing local has started, and the Helper owns (or is about
769
+ // to activate) a live stack for this same lease. Cancelling here would
770
+ // abandon that stack until idle reaping. Two shapes signal the takeover,
771
+ // both from `reserve_stack_lease` which checks lease state before job
772
+ // status (BOT-1587):
773
+ // • PROVISION_JOB_HELPER_CLAIMED — Helper claimed the job, lease still
774
+ // `provisioning`: wait for it to reach active.
775
+ // • LEASE_NOT_PROVISIONING with state `active` — the Helper both claimed
776
+ // AND activated before reserve acquired its locks: already live, so
777
+ // skip the wait (the edge-triggered stream would park until timeout on
778
+ // an already-active lease) and go straight to the read.
779
+ // Either way, follow the Helper to the shared authoritative final read
780
+ // below; only a genuine reserve failure is terminal.
781
+ const helperClaimed = reserved.data?.code === "PROVISION_JOB_HELPER_CLAIMED";
782
+ const helperAlreadyActive = reserved.data?.code === "LEASE_NOT_PROVISIONING" && reserved.data?.state === "active";
783
+ if (helperClaimed || helperAlreadyActive) {
784
+ if (!helperAlreadyActive) {
785
+ const active = await waitFn(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
786
+ if (active.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state, slot, error: `a Helper claimed the provision job but it did not reach active in ${opts.timeout}s` }), opts, EXIT.TIMEOUT);
787
+ if (active.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
788
+ if (active.failed) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: active.state, error: "lease was reaped before it became active" }), opts, EXIT.LEASE_FAILED);
789
+ if (active.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
790
+ }
791
+ // Fall through to the authoritative `get_stack_lease` read below — do
792
+ // NOT run the local provisioner; the Helper owns this stack.
793
+ } else {
794
+ const cancelled = await callTool("cancel_unclaimed_stack_lease", { lease_id: leaseId });
795
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
796
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
797
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
798
+ return emitResult(buildReceipt({
799
+ command: "up", outcome: "error", lease_id: leaseId, state, slot,
800
+ error: reserved.data?.code || reserved.error || "could not reserve the provision job for local execution",
801
+ lease_cancellation: leaseCancellation,
802
+ }), opts, EXIT.LEASE_FAILED);
803
+ }
804
+ } else {
805
+ let conn;
806
+ try {
807
+ conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget), localDockerTarget);
808
+ } catch (e) {
809
+ // Once the local provisioner has run, `supabase start` may have created
810
+ // (or fully started) containers even on a nonzero exit or a status-parse
811
+ // failure. Cancelling here would reap the lease and free the slot while
812
+ // that stack is still up — the exact orphaned/contended-slot hazard this
813
+ // change closes. Leave the lease FENCED (provisioning, reserved to us) so
814
+ // `botbuddy stack done` / the reaper tears the stack down first (BOT-1421
815
+ // review); do NOT cancel.
816
+ return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
817
+ }
818
+ const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
819
+ if (!act.ok || !act.data?.success) {
820
+ return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
821
+ }
752
822
  }
753
823
  } else {
754
- const active = await waitForLease(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
824
+ const active = await waitFn(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
755
825
  if (active.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state, slot, error: `provisioning did not reach active in ${opts.timeout}s (Helper may be down — retry with --local-exec)` }), opts, EXIT.TIMEOUT);
826
+ if (active.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
756
827
  if (active.failed) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: active.state, error: "lease was reaped before it became active" }), opts, EXIT.LEASE_FAILED);
757
828
  if (active.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
758
829
  }
@@ -795,7 +866,7 @@ async function cmdTouch(leaseId, opts) {
795
866
 
796
867
  export async function cmdDone(leaseId, opts, {
797
868
  callTool = callToolJson,
798
- runPreflight = runLocalExecPreflight,
869
+ runPreflight = runLocalExecTargetCheck,
799
870
  proveLegacyTarget = proveLegacyLocalExecTarget,
800
871
  localTeardownFn = localTeardown,
801
872
  emitResult = emit,
@@ -811,7 +882,7 @@ export async function cmdDone(leaseId, opts, {
811
882
  }
812
883
  const expected = current.data.connection?.botbuddy_docker_target;
813
884
  let checked;
814
- try { checked = runPreflight(opts); } catch (error) {
885
+ try { checked = await runPreflight(opts); } catch (error) {
815
886
  return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
816
887
  error: `could not validate teardown Docker target: ${error.message}` }), opts, EXIT.LEASE_FAILED);
817
888
  }