@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 +1 -1
- package/src/docker-hygiene.mjs +158 -3
- package/src/stack.mjs +88 -17
package/package.json
CHANGED
package/src/docker-hygiene.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
|
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 =
|
|
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
|
}
|