@botbuddy/cli 1.42.1 → 1.43.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/stack.mjs +95 -3
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -60,6 +60,16 @@ const CALLBACK_PORT_REMOTE_REFUSAL =
|
|
|
60
60
|
"(pre-forward GatewayPorts probe). Use --local-only for a run that needs a callback port.";
|
|
61
61
|
export const DEFAULT_RECEIPT_MAX_BYTES = 10240;
|
|
62
62
|
export const DEFAULT_TIMEOUT_SEC = 3600;
|
|
63
|
+
/**
|
|
64
|
+
* BOT-2154: how long `stack up --local-exec` lets `supabase start` run before it gives up and
|
|
65
|
+
* releases the lease itself (`--provision-timeout` overrides). Deliberately below the server's
|
|
66
|
+
* 1200 s `local_exec_provision_ttl_secs` backstop, so the CLI's own clean `stack done` wins the
|
|
67
|
+
* race against the reaper.
|
|
68
|
+
*/
|
|
69
|
+
export const LOCAL_EXEC_PROVISION_TIMEOUT_SEC = 900;
|
|
70
|
+
/** BOT-2230: the bounds the control plane enforces on an owner place hold (hold_stack_lease_place). */
|
|
71
|
+
export const HOLD_ON_TIMEOUT_MIN_SEC = 60;
|
|
72
|
+
export const HOLD_ON_TIMEOUT_MAX_SEC = 14400;
|
|
63
73
|
/** How often a run renews its stack directory's retention while it still exists
|
|
64
74
|
* (BOT-1798): frequent enough to cover the pre-child queue/provision waits. */
|
|
65
75
|
const RETENTION_REFRESH_MS = 5 * 60 * 1000;
|
|
@@ -104,6 +114,13 @@ ${bold("up OPTIONS")}
|
|
|
104
114
|
--idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
|
|
105
115
|
--timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
|
|
106
116
|
--no-wait If the host is full, print the queue position and exit (don't park).
|
|
117
|
+
--hold-on-timeout <seconds>
|
|
118
|
+
BOT-2230: when the --timeout park runs out with the lease still queued,
|
|
119
|
+
keep its queue place for <seconds> (60–14400) instead of losing it: the
|
|
120
|
+
lease stays queued but is not dispatched, and re-running the same
|
|
121
|
+
\`stack up\` (same agent + slot) before then re-attaches to it. The exit-2
|
|
122
|
+
receipt carries queue_hold {held, until, queue_position}; \`stack done
|
|
123
|
+
<lease_id>\` gives the place up.
|
|
107
124
|
--claim-only LOCAL-EXEC only: wait for and hold a provisioning lease without starting
|
|
108
125
|
Supabase. Resume with the identical \`stack up --local-exec\` command.
|
|
109
126
|
--wait-capacity <dur> Opt-in (BOT-2024): on a TRANSIENT HOST_CAPACITY_SOURCE_STALE refusal, park
|
|
@@ -112,6 +129,10 @@ ${bold("up OPTIONS")}
|
|
|
112
129
|
deadline (exit 2) and a failed wait (its own exit code) never retry.
|
|
113
130
|
--local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
|
|
114
131
|
stack and self-activate. Refused at the worktree root (shared stack).
|
|
132
|
+
--provision-timeout N LOCAL-EXEC: seconds 'supabase start' may run (default ${LOCAL_EXEC_PROVISION_TIMEOUT_SEC}). On
|
|
133
|
+
timeout up runs 'stack done --local-exec' itself and exits 6 with
|
|
134
|
+
park_reason provision_timeout (BOT-2154). The server also fails a local-exec
|
|
135
|
+
lease still provisioning after local_exec_provision_ttl_secs (default 1200).
|
|
115
136
|
Refused (exit 4, ENGINE_PUBLISHES_BEYOND_LOOPBACK) when the Docker
|
|
116
137
|
engine would publish the stack's ports beyond loopback (BOT-2146).
|
|
117
138
|
--docker-context <name> Explicit Docker context for the mandatory local preflight
|
|
@@ -255,7 +276,7 @@ export function parseStackArgs(argv) {
|
|
|
255
276
|
slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
|
|
256
277
|
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
|
|
257
278
|
timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
|
|
258
|
-
connectionFile: null, noWait: false, claimOnly: false, localExec: false, waitCapacity: null,
|
|
279
|
+
connectionFile: null, noWait: false, claimOnly: false, localExec: false, waitCapacity: null, holdOnTimeout: null,
|
|
259
280
|
dockerContext: null, dockerEndpoint: null,
|
|
260
281
|
// BOT-2142: execution placement (null = command default), remote-lease callback + bundle cap.
|
|
261
282
|
execution: null, remote: false, localOnly: false, callbackPort: null, bundleMaxBytes: null,
|
|
@@ -356,6 +377,15 @@ export function parseStackArgs(argv) {
|
|
|
356
377
|
}
|
|
357
378
|
break;
|
|
358
379
|
}
|
|
380
|
+
case "--hold-on-timeout": {
|
|
381
|
+
if (need(a, rest[i + 1])) {
|
|
382
|
+
const n = Number(rest[++i]);
|
|
383
|
+
if (!Number.isInteger(n) || n < HOLD_ON_TIMEOUT_MIN_SEC || n > HOLD_ON_TIMEOUT_MAX_SEC) {
|
|
384
|
+
errors.push(`--hold-on-timeout must be a whole number of seconds from ${HOLD_ON_TIMEOUT_MIN_SEC} to ${HOLD_ON_TIMEOUT_MAX_SEC}`);
|
|
385
|
+
} else opts.holdOnTimeout = n;
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
359
389
|
case "--no-wait": opts.noWait = true; break;
|
|
360
390
|
case "--claim-only": opts.claimOnly = true; break;
|
|
361
391
|
case "--local-exec": opts.localExec = true; break;
|
|
@@ -410,6 +440,11 @@ export function parseStackArgs(argv) {
|
|
|
410
440
|
if (opts.waitCapacity != null && !["up", "run"].includes(command)) {
|
|
411
441
|
errors.push("--wait-capacity is valid only with `stack up` or `stack run`");
|
|
412
442
|
}
|
|
443
|
+
if (opts.holdOnTimeout != null && command !== "up") {
|
|
444
|
+
errors.push("--hold-on-timeout is valid only with `stack up`");
|
|
445
|
+
} else if (opts.holdOnTimeout != null && opts.noWait) {
|
|
446
|
+
errors.push("--hold-on-timeout cannot be combined with --no-wait: without a park there is no timeout to keep a place across");
|
|
447
|
+
}
|
|
413
448
|
if (opts.claimOnly && (command !== "up" || !opts.localExec)) {
|
|
414
449
|
errors.push("--claim-only is valid only with `stack up --local-exec`");
|
|
415
450
|
}
|
|
@@ -453,6 +488,22 @@ export function describeQueuePosition(d) {
|
|
|
453
488
|
return d?.scored_ticket_id ? `${where}, scored under ${d.scored_ticket_id}` : where;
|
|
454
489
|
}
|
|
455
490
|
|
|
491
|
+
/**
|
|
492
|
+
* BOT-2230: hold a timed-out queued lease's place for its owner (`hold_stack_lease_place`).
|
|
493
|
+
* Never throws: a refusal or a control plane without the tool is reported as `held: false`
|
|
494
|
+
* with the server's code, so the caller decides (the hermetic lane then cancels as before).
|
|
495
|
+
*/
|
|
496
|
+
export async function holdQueuePlace(call, leaseId, holdSecs) {
|
|
497
|
+
let res;
|
|
498
|
+
try { res = await call("hold_stack_lease_place", { lease_id: leaseId, hold_secs: holdSecs }); }
|
|
499
|
+
catch (err) { return { held: false, code: null, error: String(err?.message ?? err) }; }
|
|
500
|
+
const d = res?.data;
|
|
501
|
+
if (res?.ok && d?.success) {
|
|
502
|
+
return { held: true, until: d.queue_hold_until ?? null, queue_position: d.queue_position ?? null, hold_secs: d.hold_secs ?? holdSecs };
|
|
503
|
+
}
|
|
504
|
+
return { held: false, code: d?.code ?? null, error: d?.message || d?.error || res?.error || "hold refused" };
|
|
505
|
+
}
|
|
506
|
+
|
|
456
507
|
/** BOT-2019: the queue fields a server returned for a queued lease, for a receipt. */
|
|
457
508
|
/**
|
|
458
509
|
* BOT-2099: the human lines for the server's unit ledger (`stack_lease_unit_ledger`) — who
|
|
@@ -1811,7 +1862,16 @@ export function localProvision(opts, dockerTarget, execution, spawn = spawnSync,
|
|
|
1811
1862
|
const url = resolveIsolatedStackApiUrl(stackDir, env);
|
|
1812
1863
|
const spawnEnv = { ...dockerEnvForTarget(dockerTarget, env), VITE_SUPABASE_URL: url };
|
|
1813
1864
|
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in ${stackDir} (VITE_SUPABASE_URL=${url}).\n`);
|
|
1814
|
-
|
|
1865
|
+
// BOT-2154: never an unbounded start — a hung `supabase start` held the host slot for hours.
|
|
1866
|
+
const timeoutSec = opts?.provisionTimeout ?? LOCAL_EXEC_PROVISION_TIMEOUT_SEC;
|
|
1867
|
+
const start = spawn("supabase", ["start", "--workdir", stackDir], {
|
|
1868
|
+
encoding: "utf8", env: spawnEnv, timeout: timeoutSec * 1000, killSignal: "SIGTERM",
|
|
1869
|
+
});
|
|
1870
|
+
if (start.error?.code === "ETIMEDOUT") {
|
|
1871
|
+
const timedOut = new Error(`supabase start did not finish within ${timeoutSec}s (--provision-timeout); stopping it and releasing the lease`);
|
|
1872
|
+
timedOut.code = "PROVISION_TIMEOUT";
|
|
1873
|
+
throw timedOut;
|
|
1874
|
+
}
|
|
1815
1875
|
if (start.status !== 0) {
|
|
1816
1876
|
throw new Error(`supabase start failed (${start.status}): ${(start.stderr || start.stdout || "").slice(0, 400)}`);
|
|
1817
1877
|
}
|
|
@@ -1902,11 +1962,21 @@ function emit(receipt, opts, code) {
|
|
|
1902
1962
|
|
|
1903
1963
|
// ── command orchestration ────────────────────────────────────────────────────
|
|
1904
1964
|
|
|
1965
|
+
/** BOT-2154: run `stack done` in-process and capture its receipt instead of printing it, so
|
|
1966
|
+
* `stack up` still emits exactly one receipt. */
|
|
1967
|
+
async function runLocalDone(leaseId, opts, deps = {}) {
|
|
1968
|
+
let receipt = null;
|
|
1969
|
+
const exitCode = await cmdDone(leaseId, opts, { ...deps, emitResult: (r, _o, code) => { receipt = r; return code; } });
|
|
1970
|
+
return { exitCode, receipt };
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1905
1973
|
export async function cmdUp(opts, {
|
|
1906
1974
|
runPreflight = runLocalExecPreflight,
|
|
1907
1975
|
callTool = callToolJson,
|
|
1908
1976
|
authProvider = stackAuthHeader,
|
|
1909
1977
|
localProvisionFn = localProvision,
|
|
1978
|
+
// BOT-2154: the self-release after a timed-out local start (`stack done --local-exec`).
|
|
1979
|
+
localDoneFn = null,
|
|
1910
1980
|
waitFn = waitForLease,
|
|
1911
1981
|
emitResult = emit,
|
|
1912
1982
|
machineUuidFn = machineUuid,
|
|
@@ -2107,7 +2177,14 @@ export async function cmdUp(opts, {
|
|
|
2107
2177
|
let ledger = d.ledger ?? null;
|
|
2108
2178
|
try { const read = await call("get_stack_lease", { lease_id: leaseId }); if (read?.ok && read.data?.success && read.data.ledger) ledger = read.data.ledger; } catch { /* keep the request-time ledger */ }
|
|
2109
2179
|
for (const line of describeUnitLedger(ledger)) process.stderr.write(`stack: ${line}\n`);
|
|
2110
|
-
|
|
2180
|
+
// BOT-2230: keep the queue place for the owner's next attempt instead of losing it.
|
|
2181
|
+
const queueHold = opts.holdOnTimeout ? await holdQueuePlace(call, leaseId, opts.holdOnTimeout) : null;
|
|
2182
|
+
if (queueHold?.held) {
|
|
2183
|
+
process.stderr.write(`stack: lease ${leaseId} keeps queue place ${queueHold.queue_position ?? "?"} until ${queueHold.until} — re-run the same \`stack up\` before then to re-attach (\`stack done ${leaseId}\` gives it up)\n`);
|
|
2184
|
+
} else if (queueHold) {
|
|
2185
|
+
process.stderr.write(`${yellow("⚠")} stack: lease ${leaseId} could not keep its queue place (${queueHold.code ?? "error"}: ${queueHold.error})\n`);
|
|
2186
|
+
}
|
|
2187
|
+
return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity`, ...(ledger ? { unit_ledger: ledger } : {}), ...(queueHold ? { queue_hold: queueHold } : {}) }), opts, EXIT.TIMEOUT);
|
|
2111
2188
|
}
|
|
2112
2189
|
if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
2113
2190
|
if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
|
|
@@ -2231,6 +2308,21 @@ export async function cmdUp(opts, {
|
|
|
2231
2308
|
try {
|
|
2232
2309
|
conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget, execution), localDockerTarget);
|
|
2233
2310
|
} catch (e) {
|
|
2311
|
+
if (e?.code === "PROVISION_TIMEOUT") {
|
|
2312
|
+
// BOT-2154: a bounded start that ran out of time. Release the lease ourselves —
|
|
2313
|
+
// `stack done --local-exec` stops whatever the start left behind and reaps the lease
|
|
2314
|
+
// — instead of leaving it provisioning for the server's backstop to find.
|
|
2315
|
+
const done = await (localDoneFn ?? ((id, doneOpts) => runLocalDone(id, doneOpts, { callTool, authProvider })))(
|
|
2316
|
+
leaseId, { ...opts, localExec: true, disposition: "destroy", json: false });
|
|
2317
|
+
const doneReceipt = done?.receipt ?? {};
|
|
2318
|
+
const cleaned = done?.exitCode === 0;
|
|
2319
|
+
return emitResult(buildReceipt({
|
|
2320
|
+
command: "up", outcome: "timeout", code: "PROVISION_TIMEOUT", park_reason: "provision_timeout",
|
|
2321
|
+
lease_id: leaseId, slot, error: e.message, cleanup: cleaned,
|
|
2322
|
+
done: { outcome: doneReceipt.outcome ?? null, state: doneReceipt.state ?? null, exit_code: done?.exitCode ?? null },
|
|
2323
|
+
...(cleaned ? {} : { cleanup_error: doneReceipt.error ?? `stack done --local-exec exited ${done?.exitCode}` }),
|
|
2324
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2325
|
+
}
|
|
2234
2326
|
// Once the local provisioner has run, `supabase start` may have created
|
|
2235
2327
|
// (or fully started) containers even on a nonzero exit or a status-parse
|
|
2236
2328
|
// failure. Cancelling here would reap the lease and free the slot while
|