@botbuddy/cli 1.36.2 → 1.37.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 +1 -1
- package/src/stack.mjs +279 -14
- package/src/wait-core.mjs +7 -7
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { open, unlink, readFile } from "fs/promises";
|
|
|
27
27
|
import { tmpdir } from "os";
|
|
28
28
|
import { basename, dirname, join, relative, isAbsolute } from "path";
|
|
29
29
|
import { randomUUID } from "crypto";
|
|
30
|
+
import { fileURLToPath } from "url";
|
|
30
31
|
import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
|
|
31
32
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
32
33
|
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
@@ -86,6 +87,10 @@ ${bold("up OPTIONS")}
|
|
|
86
87
|
--idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
|
|
87
88
|
--timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
|
|
88
89
|
--no-wait If the host is full, print the queue position and exit (don't park).
|
|
90
|
+
--wait-capacity <dur> Opt-in (BOT-2024): on a TRANSIENT HOST_CAPACITY_SOURCE_STALE refusal, park
|
|
91
|
+
on the server's \`bb wait 'capacity:…'\` up to <dur> (90s, 10m, 1h) and
|
|
92
|
+
re-attempt admission once when it matches. Sustained refusals, the
|
|
93
|
+
deadline (exit 2) and a failed wait (its own exit code) never retry.
|
|
89
94
|
--local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
|
|
90
95
|
stack and self-activate. Refused at the worktree root (shared stack).
|
|
91
96
|
--docker-context <name> Explicit Docker context for the mandatory local preflight
|
|
@@ -114,6 +119,7 @@ ${bold("run OPTIONS")}
|
|
|
114
119
|
--connection-file P Optional empty file path for the mode-0600 connection JSON.
|
|
115
120
|
--hard-ttl N Bound the child runtime; timeout requests normal cleanup.
|
|
116
121
|
--local-exec Rejected: stack run never starts Docker directly.
|
|
122
|
+
--wait-capacity <dur> Same as up: park a TRANSIENT stale-capacity refusal, then re-admit once.
|
|
117
123
|
|
|
118
124
|
${bold("GLOBAL")}
|
|
119
125
|
--json Pretty-print the receipt (default is one compact JSON line).
|
|
@@ -124,6 +130,13 @@ ${bold("EXIT CODES")}
|
|
|
124
130
|
4 invalid arguments 5 backend/coordination 6 lease failed/reaped
|
|
125
131
|
7 internal error 8 cleanup/reap proof failed
|
|
126
132
|
|
|
133
|
+
${bold("HOST_CAPACITY_SOURCE_STALE")} (BOT-2024)
|
|
134
|
+
HOST_CAPACITY_SOURCE_STALE is a capacity flag, not a Helper diagnosis. Read \`phase\` on the
|
|
135
|
+
receipt. transient → arm the printed \`bb wait 'capacity:…'\` and re-run once it fires
|
|
136
|
+
(or pass --wait-capacity); never report blocked, never ask for a Helper restart.
|
|
137
|
+
sustained (no beacon ever or >= 10 min) → report BLOCKED quoting \`stale_since\`; a Helper
|
|
138
|
+
check/restart is the right ask. A single refusal is never evidence of an outage.
|
|
139
|
+
|
|
127
140
|
${bold("EXAMPLE")}
|
|
128
141
|
# request a per-worktree stack for this batch, run a lane against it, then reap it
|
|
129
142
|
botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
|
|
@@ -175,7 +188,7 @@ export function parseStackArgs(argv) {
|
|
|
175
188
|
slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
|
|
176
189
|
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
|
|
177
190
|
timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
|
|
178
|
-
connectionFile: null, noWait: false, localExec: false,
|
|
191
|
+
connectionFile: null, noWait: false, localExec: false, waitCapacity: null,
|
|
179
192
|
dockerContext: null, dockerEndpoint: null,
|
|
180
193
|
disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
181
194
|
};
|
|
@@ -248,6 +261,14 @@ export function parseStackArgs(argv) {
|
|
|
248
261
|
}
|
|
249
262
|
break;
|
|
250
263
|
}
|
|
264
|
+
case "--wait-capacity": {
|
|
265
|
+
if (need(a, rest[i + 1])) {
|
|
266
|
+
const secs = parseWaitCapacityDuration(rest[++i]);
|
|
267
|
+
if (secs == null) errors.push("--wait-capacity must be a positive duration: <N>, <N>s, <N>m or <N>h");
|
|
268
|
+
else opts.waitCapacity = secs;
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
251
272
|
case "--no-wait": opts.noWait = true; break;
|
|
252
273
|
case "--local-exec": opts.localExec = true; break;
|
|
253
274
|
case "--docker-context": if (need(a, rest[i + 1])) opts.dockerContext = rest[++i]; break;
|
|
@@ -280,6 +301,9 @@ export function parseStackArgs(argv) {
|
|
|
280
301
|
"worktree root's default (shared canonical) project. Point --stack-path at a slot-derived stack directory with its " +
|
|
281
302
|
"own supabase/config.toml (distinct project_id + remapped ports).");
|
|
282
303
|
}
|
|
304
|
+
if (opts.waitCapacity != null && !["up", "run"].includes(command)) {
|
|
305
|
+
errors.push("--wait-capacity is valid only with `stack up` or `stack run`");
|
|
306
|
+
}
|
|
283
307
|
if ((opts.dockerContext || opts.dockerEndpoint) && !(["up", "done"].includes(command) && opts.localExec)) {
|
|
284
308
|
errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
|
|
285
309
|
}
|
|
@@ -708,6 +732,124 @@ export function leaseRefusalError(data) {
|
|
|
708
732
|
return data?.message || data?.code || "lease request refused";
|
|
709
733
|
}
|
|
710
734
|
|
|
735
|
+
// ── BOT-2024: phase-aware HOST_CAPACITY_SOURCE_STALE ─────────────────────────
|
|
736
|
+
// The refusal is a capacity FLAG, not a Helper diagnosis. The server classifies it:
|
|
737
|
+
// `sustained` iff the host never beaconed or its last beacon is >= 600 s old, else
|
|
738
|
+
// `transient` (scheduler lag; the flag's own age is informational only). Agents escalated transient lag as an outage and asked the
|
|
739
|
+
// user to restart a healthy Helper (2026-10-03), so the CLI renders the phase.
|
|
740
|
+
export const HOST_CAPACITY_SOURCE_STALE_CODE = "HOST_CAPACITY_SOURCE_STALE";
|
|
741
|
+
const CAPACITY_STALE_RULE =
|
|
742
|
+
"HOST_CAPACITY_SOURCE_STALE is a capacity flag, not a Helper diagnosis. Read `phase`: transient → arm the printed " +
|
|
743
|
+
"`bb wait 'capacity:…'` and re-run once it fires (never report blocked, never ask for a Helper restart); " +
|
|
744
|
+
"sustained (no beacon ever or >= 10 min) → report BLOCKED quoting `stale_since`. A single refusal is never evidence of an outage.";
|
|
745
|
+
|
|
746
|
+
function humanSecs(secs) {
|
|
747
|
+
if (!Number.isFinite(secs)) return "an unknown time";
|
|
748
|
+
if (secs < 120) return `${secs}s`;
|
|
749
|
+
if (secs < 7200) return `${Math.floor(secs / 60)}m`;
|
|
750
|
+
return `${Math.floor(secs / 3600)}h${Math.floor((secs % 3600) / 60)}m`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Operator/agent-facing guidance for a stale-capacity refusal (or null for any other code). */
|
|
754
|
+
export function capacityStaleGuidance(data) {
|
|
755
|
+
if (data?.code !== HOST_CAPACITY_SOURCE_STALE_CODE) return null;
|
|
756
|
+
const host = data.host_key ? ` on ${data.host_key}` : "";
|
|
757
|
+
if (data.phase === "transient") {
|
|
758
|
+
const beacon = Number.isFinite(data.beacon_age_secs) ? `the Helper beaconed ${humanSecs(data.beacon_age_secs)} ago` : "no beacon recorded yet";
|
|
759
|
+
return `${yellow("⚠")} stack: capacity flag is stale${host} (${beacon}). Scheduler lag, not a Helper outage. ` +
|
|
760
|
+
"Do NOT report blocked or ask for a Helper restart.\n" +
|
|
761
|
+
(data.wait ? ` Wait for capacity, then re-run: bb wait '${data.wait}' (or pass --wait-capacity <dur>)` : ` ${CAPACITY_STALE_RULE}`);
|
|
762
|
+
}
|
|
763
|
+
if (data.phase === "sustained") {
|
|
764
|
+
const age = Number.isFinite(data.beacon_age_secs) ? `for ${humanSecs(data.beacon_age_secs)}` : "ever recorded";
|
|
765
|
+
return `${yellow("⚠")} stack: no Helper beacon ${age}${host} (stale since ${data.stale_since ?? "unknown"}) — ` +
|
|
766
|
+
"report BLOCKED quoting stale_since; check `launchctl print user/$UID/com.botbuddy.helper.worker` and the worker log, " +
|
|
767
|
+
"or restart BotBuddy Helper.";
|
|
768
|
+
}
|
|
769
|
+
// A pre-BOT-2024 server sends no phase: give the rule, never a restart ask.
|
|
770
|
+
return `${yellow("⚠")} stack: ${CAPACITY_STALE_RULE}`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** The refusal's phase fields worth keeping on a receipt (empty for any other refusal). */
|
|
774
|
+
export function capacityStaleReceiptFields(data) {
|
|
775
|
+
if (data?.code !== HOST_CAPACITY_SOURCE_STALE_CODE) return {};
|
|
776
|
+
const out = {};
|
|
777
|
+
for (const k of ["phase", "wait", "stale_since", "beacon_age_secs", "stale_for_secs"]) {
|
|
778
|
+
if (data[k] !== undefined) out[k] = data[k];
|
|
779
|
+
}
|
|
780
|
+
return out;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** `--wait-capacity <dur>`: `90`, `90s`, `10m`, `1h` → seconds (null when invalid). */
|
|
784
|
+
export function parseWaitCapacityDuration(raw) {
|
|
785
|
+
const m = /^([1-9]\d*)([smh]?)$/.exec(String(raw ?? "").trim());
|
|
786
|
+
if (!m) return null;
|
|
787
|
+
return Number(m[1]) * ({ "": 1, s: 1, m: 60, h: 3600 })[m[2]];
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const BOTBUDDY_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "botbuddy.mjs");
|
|
791
|
+
|
|
792
|
+
/** argv for the park: this CLI's own `bb wait` on the server's condition, bounded by --timeout. */
|
|
793
|
+
export function capacityWaitArgv(condition, timeoutSec) {
|
|
794
|
+
return [process.execPath, BOTBUDDY_BIN, "wait", condition, "--timeout", String(timeoutSec)];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Park on the server's capacity wait via the real `bb wait` (registration, ranked
|
|
799
|
+
* grants, beacon-stale handling, checkpoint) — never a shell sleep/poll loop. Its
|
|
800
|
+
* receipt is captured; its stderr (arm notices) passes through.
|
|
801
|
+
*/
|
|
802
|
+
export function defaultWaitCapacity(condition, timeoutSec, { signal } = {}) {
|
|
803
|
+
const [cmd, ...args] = capacityWaitArgv(condition, timeoutSec);
|
|
804
|
+
return new Promise((resolve) => {
|
|
805
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- fixed argv (this CLI's own bin), no shell.
|
|
806
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "inherit"], signal });
|
|
807
|
+
let stdout = "";
|
|
808
|
+
child.stdout.on("data", (d) => { stdout += d; });
|
|
809
|
+
const receiptOf = () => {
|
|
810
|
+
const last = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
811
|
+
try { return last ? JSON.parse(last) : null; } catch { return null; }
|
|
812
|
+
};
|
|
813
|
+
child.on("error", (error) => resolve({ exitCode: EXIT.INTERNAL, receipt: null, error: error?.message ?? String(error) }));
|
|
814
|
+
child.on("close", (code) => resolve({ exitCode: code ?? EXIT.INTERNAL, receipt: receiptOf() }));
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* BOT-2024: request a lease; on a TRANSIENT stale-capacity refusal with
|
|
820
|
+
* `--wait-capacity`, park on the server's `wait` condition and re-attempt admission
|
|
821
|
+
* exactly once — ONLY when the wait MATCHED (exit 0). Sustained (or phase-less)
|
|
822
|
+
* refusals never park. The deadline returns `capacityTimeout`; any other nonzero wait
|
|
823
|
+
* exit (auth/backend failure, the host-pinned capacity_source_stale exit after its
|
|
824
|
+
* beacon-confirm window, …) is the wait's own terminal outcome and returns
|
|
825
|
+
* `waitFailed` for the caller to propagate typed — retrying there would mask it
|
|
826
|
+
* (Codex #1018 P2).
|
|
827
|
+
*/
|
|
828
|
+
export async function requestWithCapacityWait(request, opts, { waitCapacity = defaultWaitCapacity, log, signal = null } = {}) {
|
|
829
|
+
let req = await request();
|
|
830
|
+
const stale = (r) => r?.ok && r.data && !r.data.success && r.data.code === HOST_CAPACITY_SOURCE_STALE_CODE;
|
|
831
|
+
if (!stale(req)) return { req };
|
|
832
|
+
const guidance = capacityStaleGuidance(req.data);
|
|
833
|
+
if (guidance) log(guidance);
|
|
834
|
+
if (!opts.waitCapacity || req.data.phase !== "transient" || !req.data.wait) return { req };
|
|
835
|
+
log(`${dim("…")} stack: --wait-capacity: parking up to ${opts.waitCapacity}s on bb wait '${req.data.wait}'`);
|
|
836
|
+
const parked = await waitCapacity(req.data.wait, opts.waitCapacity, { signal });
|
|
837
|
+
if (signal?.aborted) return { req, interrupted: true };
|
|
838
|
+
if (parked?.exitCode === EXIT.TIMEOUT) return { req, capacityTimeout: true, waitReceipt: parked.receipt ?? null };
|
|
839
|
+
if (parked?.exitCode !== 0) {
|
|
840
|
+
const exitCode = Number.isInteger(parked?.exitCode) ? parked.exitCode : EXIT.INTERNAL;
|
|
841
|
+
const detail = parked?.receipt?.error ?? parked?.error ?? parked?.receipt?.outcome ?? "no receipt";
|
|
842
|
+
log(`${yellow("⚠")} stack: --wait-capacity: bb wait '${req.data.wait}' exited ${exitCode} (${detail}); not retrying admission.`);
|
|
843
|
+
return { req, waitFailed: { exitCode, receipt: parked?.receipt ?? null, detail } };
|
|
844
|
+
}
|
|
845
|
+
req = await request();
|
|
846
|
+
if (stale(req)) {
|
|
847
|
+
const again = capacityStaleGuidance(req.data);
|
|
848
|
+
if (again) log(again);
|
|
849
|
+
}
|
|
850
|
+
return { req };
|
|
851
|
+
}
|
|
852
|
+
|
|
711
853
|
// The MCP lease endpoint accepts a session token in x-agent-api-key, whereas
|
|
712
854
|
// the event-stream relay requires that same token in its bearer form too. Keep
|
|
713
855
|
// the lease RPC shape unchanged and derive the relay-compatible form only at
|
|
@@ -1279,21 +1421,62 @@ export const STACK_DISPOSITIONS = Object.freeze(["destroy", "stop"]);
|
|
|
1279
1421
|
* the project's data volumes (`supabase_db_/storage_/edge_runtime_<project_id>`); `--workdir`
|
|
1280
1422
|
* scopes that to the stack's own project_id, never `--all`. `stop` keeps the volumes for a
|
|
1281
1423
|
* cheap re-provision. An unknown disposition throws before Docker is touched.
|
|
1424
|
+
*
|
|
1425
|
+
* BOT-2001: when `supabase stop` fails (2026-10-02: a transient exit 1 whose real error sat
|
|
1426
|
+
* behind the CLI's deprecation WARN), log the tail of its output and fall back to a
|
|
1427
|
+
* label-scoped teardown of exactly this project (`com.supabase.cli.project=<project_id>`),
|
|
1428
|
+
* on the same validated daemon. Success requires the post-check `docker ps -a` for that
|
|
1429
|
+
* label to be empty; no readable project_id → no fallback, never an unscoped `docker rm`.
|
|
1282
1430
|
*/
|
|
1283
|
-
export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawnSync, env = process.env
|
|
1431
|
+
export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawnSync, env = process.env, {
|
|
1432
|
+
readProjectId = projectIdUnder, log = (line) => process.stderr.write(line),
|
|
1433
|
+
} = {}) {
|
|
1284
1434
|
if (!STACK_DISPOSITIONS.includes(disposition)) {
|
|
1285
1435
|
throw new Error(`localTeardown: unknown disposition ${JSON.stringify(disposition)} (expected one of ${STACK_DISPOSITIONS.join(", ")})`);
|
|
1286
1436
|
}
|
|
1287
1437
|
const args = ["stop", "--workdir", stackDir, ...(disposition === "destroy" ? ["--no-backup"] : [])];
|
|
1288
|
-
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1438
|
+
log(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold(`supabase ${args.join(" ")}`)} (disposition ${disposition}).\n`);
|
|
1439
|
+
const dockerEnv = dockerEnvForTarget(dockerTarget, env);
|
|
1440
|
+
const res = spawn("supabase", args, { encoding: "utf8", env: dockerEnv });
|
|
1441
|
+
if (res.status === 0) return true;
|
|
1442
|
+
// The real error is LAST (WARN/upgrade notices lead), so keep the tail, not the head.
|
|
1443
|
+
const output = `${res.stderr || ""}${res.stdout || ""}`.trim();
|
|
1444
|
+
log(`${yellow("⚠")} stack: supabase stop returned ${res.status}:\n${output.slice(-2000)}\n`);
|
|
1445
|
+
const projectId = readProjectId(stackDir);
|
|
1446
|
+
if (!projectId) {
|
|
1447
|
+
log(`${yellow("⚠")} stack: no readable project_id under ${stackDir} — not attempting a label-scoped teardown.\n`);
|
|
1448
|
+
return false;
|
|
1449
|
+
}
|
|
1450
|
+
return labelScopedTeardown(projectId, disposition, spawn, dockerEnv, log);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/** BOT-2001: remove exactly one Supabase project's containers, network and (for `destroy`)
|
|
1454
|
+
* volumes by the CLI's project label; true iff a re-list finds NONE of the resource classes
|
|
1455
|
+
* this disposition removes (Codex #1012: a busy volume or network fails closed too). */
|
|
1456
|
+
function labelScopedTeardown(projectId, disposition, spawn, dockerEnv, log) {
|
|
1457
|
+
const filter = ["--filter", `label=com.supabase.cli.project=${projectId}`];
|
|
1458
|
+
const docker = (args) => spawn("docker", args, { encoding: "utf8", env: dockerEnv });
|
|
1459
|
+
const ids = (res) => (res.status === 0 ? (res.stdout || "").split(/\s+/).filter(Boolean) : null);
|
|
1460
|
+
const classes = [
|
|
1461
|
+
{ name: "container", list: ["ps", "-aq", ...filter], remove: ["rm", "-f"] },
|
|
1462
|
+
...(disposition === "destroy" ? [{ name: "volume", list: ["volume", "ls", "-q", ...filter], remove: ["volume", "rm", "-f"] }] : []),
|
|
1463
|
+
{ name: "network", list: ["network", "ls", "-q", ...filter], remove: ["network", "rm"] },
|
|
1464
|
+
];
|
|
1465
|
+
log(`${yellow("⚠")} stack: falling back to a label-scoped teardown of project ${bold(projectId)}.\n`);
|
|
1466
|
+
for (const c of classes) {
|
|
1467
|
+
const found = ids(docker(c.list));
|
|
1468
|
+
if (found?.length) {
|
|
1469
|
+
const rm = docker([...c.remove, ...found]);
|
|
1470
|
+
if (rm.status !== 0) log(`${yellow("⚠")} stack: removing ${c.name}(s) of ${projectId} failed: ${`${rm.stderr || rm.stdout || ""}`.trim().slice(-500)}\n`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
const left = classes.map((c) => ({ name: c.name, ids: ids(docker(c.list)) })).filter((c) => c.ids === null || c.ids.length);
|
|
1474
|
+
if (left.length) {
|
|
1475
|
+
log(`${yellow("⚠")} stack: project ${projectId} still has ${left.map((c) => (c.ids === null ? `unverifiable ${c.name}s` : `${c.ids.length} ${c.name}(s)`)).join(", ")} after the fallback — the slot stays fenced.\n`);
|
|
1476
|
+
return false;
|
|
1295
1477
|
}
|
|
1296
|
-
|
|
1478
|
+
log(`${yellow("⚠")} stack: label-scoped teardown of project ${projectId} succeeded.\n`);
|
|
1479
|
+
return true;
|
|
1297
1480
|
}
|
|
1298
1481
|
|
|
1299
1482
|
function emit(receipt, opts, code) {
|
|
@@ -1314,6 +1497,7 @@ export async function cmdUp(opts, {
|
|
|
1314
1497
|
machineUuidFn = machineUuid,
|
|
1315
1498
|
assertIsolated = validateLocalExecTarget,
|
|
1316
1499
|
refreshAuth = refreshRejectedCacheAuth,
|
|
1500
|
+
waitCapacity = defaultWaitCapacity,
|
|
1317
1501
|
} = {}) {
|
|
1318
1502
|
let slot;
|
|
1319
1503
|
try { slot = deriveSlot(opts); } catch (e) {
|
|
@@ -1396,11 +1580,58 @@ export async function cmdUp(opts, {
|
|
|
1396
1580
|
? emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
|
|
1397
1581
|
: emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, EXIT.BACKEND);
|
|
1398
1582
|
}
|
|
1583
|
+
// BOT-2024: phase-aware stale-capacity guidance; --wait-capacity parks a TRANSIENT
|
|
1584
|
+
// refusal on the server's capacity wait and re-admits once.
|
|
1585
|
+
if (req.data && !req.data.success && req.data.code === HOST_CAPACITY_SOURCE_STALE_CODE) {
|
|
1586
|
+
let first = req;
|
|
1587
|
+
// Codex #1018 R4: the park can outlive the pre-wait credential (expired/revoked
|
|
1588
|
+
// while parked; the spawned `bb wait` may itself have renewed the worktree
|
|
1589
|
+
// session). So the post-wait retry RE-LOADS the credential from disk, then takes
|
|
1590
|
+
// the standard refresh-once-on-auth-rejection path. A second rejection falls
|
|
1591
|
+
// through to the typed AUTH exit below — never a loop.
|
|
1592
|
+
const retryAfterWait = async () => {
|
|
1593
|
+
const reloaded = await authProvider();
|
|
1594
|
+
if (reloaded) auth = reloaded;
|
|
1595
|
+
let retried = await call("request_stack_lease", leaseArgs);
|
|
1596
|
+
if (!retried.ok && retried.auth) {
|
|
1597
|
+
const fresh = await refreshAuth(auth, { cwd: process.cwd() });
|
|
1598
|
+
if (fresh) {
|
|
1599
|
+
auth = fresh;
|
|
1600
|
+
retried = await call("request_stack_lease", leaseArgs);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return retried;
|
|
1604
|
+
};
|
|
1605
|
+
const admission = await requestWithCapacityWait(
|
|
1606
|
+
async () => { if (first) { const r = first; first = null; return r; } return retryAfterWait(); },
|
|
1607
|
+
opts, { waitCapacity, log: (line) => process.stderr.write(`${line}\n`) },
|
|
1608
|
+
);
|
|
1609
|
+
if (admission.capacityTimeout) {
|
|
1610
|
+
return emitResult(buildReceipt({
|
|
1611
|
+
command: "up", outcome: "timeout", code: admission.req.data.code, ...capacityStaleReceiptFields(admission.req.data),
|
|
1612
|
+
wait_exit_code: EXIT.TIMEOUT, wait_receipt: admission.waitReceipt,
|
|
1613
|
+
error: `capacity did not recover within --wait-capacity ${opts.waitCapacity}s (${admission.req.data.code})`, slot,
|
|
1614
|
+
}), opts, EXIT.TIMEOUT);
|
|
1615
|
+
}
|
|
1616
|
+
if (admission.waitFailed) {
|
|
1617
|
+
return emitResult(buildReceipt({
|
|
1618
|
+
command: "up", outcome: "error", code: admission.req.data.code, ...capacityStaleReceiptFields(admission.req.data),
|
|
1619
|
+
wait_exit_code: admission.waitFailed.exitCode, wait_receipt: admission.waitFailed.receipt,
|
|
1620
|
+
error: `--wait-capacity: bb wait exited ${admission.waitFailed.exitCode} (${admission.waitFailed.detail})`, slot,
|
|
1621
|
+
}), opts, admission.waitFailed.exitCode);
|
|
1622
|
+
}
|
|
1623
|
+
req = admission.req;
|
|
1624
|
+
if (!req.ok) {
|
|
1625
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, req.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1399
1628
|
const d = req.data;
|
|
1400
1629
|
if (!d.success) {
|
|
1401
1630
|
const hint = worktreeRegistrationHint(d.code, auth) ?? stackPathRootRefusalNotice(d.code, d.message);
|
|
1402
1631
|
if (hint) process.stderr.write(`${hint}\n`);
|
|
1403
|
-
return emitResult(buildReceipt({
|
|
1632
|
+
return emitResult(buildReceipt({
|
|
1633
|
+
command: "up", outcome: "error", code: d.code, ...capacityStaleReceiptFields(d), error: leaseRefusalError(d), slot,
|
|
1634
|
+
}), opts, leaseRefusalExit(d.code));
|
|
1404
1635
|
}
|
|
1405
1636
|
let leaseId = d.lease_id;
|
|
1406
1637
|
let state = d.state;
|
|
@@ -1989,6 +2220,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1989
2220
|
const clock = adapters.clock ?? { setInterval, clearInterval, setTimeout, clearTimeout };
|
|
1990
2221
|
const signals = adapters.signals ?? process;
|
|
1991
2222
|
const provisionTimeout = opts.provisionTimeout ?? opts.timeout;
|
|
2223
|
+
const stderrLine = adapters.stderr ?? ((line) => process.stderr.write(`${line}\n`));
|
|
1992
2224
|
let leaseId = null;
|
|
1993
2225
|
let child = null;
|
|
1994
2226
|
let childResult = null;
|
|
@@ -2135,7 +2367,34 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2135
2367
|
};
|
|
2136
2368
|
// The retrying caller (api → rpc.call) transparently clears+self-heals+retries the
|
|
2137
2369
|
// FIRST auth rejection among any of request/get/touch/release.
|
|
2138
|
-
|
|
2370
|
+
// BOT-2024: a stale-capacity refusal prints phase-aware guidance; with the opt-in
|
|
2371
|
+
// --wait-capacity a TRANSIENT one parks on the server's capacity wait and re-admits once.
|
|
2372
|
+
activeWaitAbort = new AbortController();
|
|
2373
|
+
const admission = await requestWithCapacityWait(() => api.request(leaseArgs), opts, {
|
|
2374
|
+
waitCapacity: adapters.waitCapacity ?? defaultWaitCapacity, log: stderrLine, signal: activeWaitAbort.signal,
|
|
2375
|
+
});
|
|
2376
|
+
activeWaitAbort = null;
|
|
2377
|
+
const request = admission.req;
|
|
2378
|
+
// Interrupted DURING the capacity park: the refused request created no lease, so
|
|
2379
|
+
// there is nothing to release. (A signal during a request that DID admit falls
|
|
2380
|
+
// through to the existing post-lease interruption branch, which cleans up.)
|
|
2381
|
+
if (admission.interrupted) {
|
|
2382
|
+
return { exitCode: SIGNAL_EXIT[receivedSignal] ?? EXIT.INTERNAL, outcome: "interrupted", code: request?.data?.code, ...capacityStaleReceiptFields(request?.data) };
|
|
2383
|
+
}
|
|
2384
|
+
if (admission.capacityTimeout) {
|
|
2385
|
+
return {
|
|
2386
|
+
exitCode: EXIT.TIMEOUT, outcome: "timeout", code: request.data.code, ...capacityStaleReceiptFields(request.data),
|
|
2387
|
+
wait_exit_code: EXIT.TIMEOUT, wait_receipt: admission.waitReceipt,
|
|
2388
|
+
error: `capacity did not recover within --wait-capacity ${opts.waitCapacity}s (${request.data.code})`,
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
if (admission.waitFailed) {
|
|
2392
|
+
return {
|
|
2393
|
+
exitCode: admission.waitFailed.exitCode, outcome: "error", code: request.data.code, ...capacityStaleReceiptFields(request.data),
|
|
2394
|
+
wait_exit_code: admission.waitFailed.exitCode, wait_receipt: admission.waitFailed.receipt,
|
|
2395
|
+
error: `--wait-capacity: bb wait exited ${admission.waitFailed.exitCode} (${admission.waitFailed.detail})`,
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2139
2398
|
if (!request?.ok) {
|
|
2140
2399
|
// Codex #928 R23 (P2): `status: null` (callToolJson's fetch-threw branch) means
|
|
2141
2400
|
// NO response was ever received — the request may have reached the backend and
|
|
@@ -2151,8 +2410,11 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2151
2410
|
if (!request.data?.success) {
|
|
2152
2411
|
const hint = worktreeRegistrationHint(request.data?.code, rpc.auth)
|
|
2153
2412
|
?? stackPathRootRefusalNotice(request.data?.code, request.data?.message);
|
|
2154
|
-
if (hint)
|
|
2155
|
-
return {
|
|
2413
|
+
if (hint) stderrLine(hint);
|
|
2414
|
+
return {
|
|
2415
|
+
exitCode: leaseRefusalExit(request.data?.code), outcome: "error", code: request.data?.code,
|
|
2416
|
+
...capacityStaleReceiptFields(request.data), error: leaseRefusalError(request.data),
|
|
2417
|
+
};
|
|
2156
2418
|
}
|
|
2157
2419
|
leaseId = request.data.lease_id;
|
|
2158
2420
|
if (request.data.reused) {
|
|
@@ -2300,6 +2562,9 @@ async function cmdRun(opts, childArgv) {
|
|
|
2300
2562
|
// BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
|
|
2301
2563
|
// so automation can branch on it instead of matching the message text.
|
|
2302
2564
|
command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
|
|
2565
|
+
// BOT-2024: a stale-capacity refusal carries its phase + armable wait on the receipt.
|
|
2566
|
+
...capacityStaleReceiptFields(result),
|
|
2567
|
+
...(result.wait_exit_code != null ? { wait_exit_code: result.wait_exit_code, wait_receipt: result.wait_receipt ?? null } : {}),
|
|
2303
2568
|
child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
|
|
2304
2569
|
fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
|
|
2305
2570
|
}), opts, result.exitCode);
|
package/src/wait-core.mjs
CHANGED
|
@@ -1179,13 +1179,13 @@ export async function runWaitLoop({
|
|
|
1179
1179
|
// the primary hysteresis); 0 = exit immediately on the stale signal. Injectable so
|
|
1180
1180
|
// a clock-controlled test can drive it. Not a grammar surface (no new condition name).
|
|
1181
1181
|
//
|
|
1182
|
-
// Default MUST exceed the beacon evaluator's worst-case cadence (Codex P2).
|
|
1183
|
-
// recovery transition
|
|
1184
|
-
// cron,
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1182
|
+
// Default MUST exceed the beacon evaluator's worst-case cadence (Codex P2). Before
|
|
1183
|
+
// BOT-2024 the recovery transition was emitted only by the `* * * * *` (60s)
|
|
1184
|
+
// eval_container_beacon_staleness cron, so a host that resumed polling right after a
|
|
1185
|
+
// stale emission had its recovery frame arrive up to ~60s later. Since BOT-2024 the
|
|
1186
|
+
// beacon itself (claim_container_job / attested registerHost) emits the recovery, so
|
|
1187
|
+
// the frame now lands on the Helper's next beacon; 90s still covers one ~45-60s
|
|
1188
|
+
// Helper beacon interval plus margin, and an older server's cron-only recovery.
|
|
1189
1189
|
beaconConfirmMs = 90_000,
|
|
1190
1190
|
// BOT-1837: the arm-time notice (if any) to carry into a TIMEOUT receipt's
|
|
1191
1191
|
// `error`, so the receipt itself says what CI was already doing when this wait
|