@botbuddy/cli 1.36.3 → 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 +229 -5
- 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
|
|
@@ -1355,6 +1497,7 @@ export async function cmdUp(opts, {
|
|
|
1355
1497
|
machineUuidFn = machineUuid,
|
|
1356
1498
|
assertIsolated = validateLocalExecTarget,
|
|
1357
1499
|
refreshAuth = refreshRejectedCacheAuth,
|
|
1500
|
+
waitCapacity = defaultWaitCapacity,
|
|
1358
1501
|
} = {}) {
|
|
1359
1502
|
let slot;
|
|
1360
1503
|
try { slot = deriveSlot(opts); } catch (e) {
|
|
@@ -1437,11 +1580,58 @@ export async function cmdUp(opts, {
|
|
|
1437
1580
|
? emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
|
|
1438
1581
|
: emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, EXIT.BACKEND);
|
|
1439
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
|
+
}
|
|
1440
1628
|
const d = req.data;
|
|
1441
1629
|
if (!d.success) {
|
|
1442
1630
|
const hint = worktreeRegistrationHint(d.code, auth) ?? stackPathRootRefusalNotice(d.code, d.message);
|
|
1443
1631
|
if (hint) process.stderr.write(`${hint}\n`);
|
|
1444
|
-
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));
|
|
1445
1635
|
}
|
|
1446
1636
|
let leaseId = d.lease_id;
|
|
1447
1637
|
let state = d.state;
|
|
@@ -2030,6 +2220,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2030
2220
|
const clock = adapters.clock ?? { setInterval, clearInterval, setTimeout, clearTimeout };
|
|
2031
2221
|
const signals = adapters.signals ?? process;
|
|
2032
2222
|
const provisionTimeout = opts.provisionTimeout ?? opts.timeout;
|
|
2223
|
+
const stderrLine = adapters.stderr ?? ((line) => process.stderr.write(`${line}\n`));
|
|
2033
2224
|
let leaseId = null;
|
|
2034
2225
|
let child = null;
|
|
2035
2226
|
let childResult = null;
|
|
@@ -2176,7 +2367,34 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2176
2367
|
};
|
|
2177
2368
|
// The retrying caller (api → rpc.call) transparently clears+self-heals+retries the
|
|
2178
2369
|
// FIRST auth rejection among any of request/get/touch/release.
|
|
2179
|
-
|
|
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
|
+
}
|
|
2180
2398
|
if (!request?.ok) {
|
|
2181
2399
|
// Codex #928 R23 (P2): `status: null` (callToolJson's fetch-threw branch) means
|
|
2182
2400
|
// NO response was ever received — the request may have reached the backend and
|
|
@@ -2192,8 +2410,11 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2192
2410
|
if (!request.data?.success) {
|
|
2193
2411
|
const hint = worktreeRegistrationHint(request.data?.code, rpc.auth)
|
|
2194
2412
|
?? stackPathRootRefusalNotice(request.data?.code, request.data?.message);
|
|
2195
|
-
if (hint)
|
|
2196
|
-
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
|
+
};
|
|
2197
2418
|
}
|
|
2198
2419
|
leaseId = request.data.lease_id;
|
|
2199
2420
|
if (request.data.reused) {
|
|
@@ -2341,6 +2562,9 @@ async function cmdRun(opts, childArgv) {
|
|
|
2341
2562
|
// BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
|
|
2342
2563
|
// so automation can branch on it instead of matching the message text.
|
|
2343
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 } : {}),
|
|
2344
2568
|
child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
|
|
2345
2569
|
fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
|
|
2346
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
|