@botbuddy/cli 1.40.9 → 1.40.10
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 +300 -22
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -352,6 +352,30 @@ export function describeQueuePosition(d) {
|
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
/** BOT-2019: the queue fields a server returned for a queued lease, for a receipt. */
|
|
355
|
+
/**
|
|
356
|
+
* BOT-2099: the human lines for the server's unit ledger (`stack_lease_unit_ledger`) — who
|
|
357
|
+
* holds the host's capacity units and since when — printed whenever a lease parks, so a
|
|
358
|
+
* starved lease names its holders instead of only its position. Other tenants' leases are
|
|
359
|
+
* tenant + state + since; unleased stacks are 5-character prefixes (the server redacts).
|
|
360
|
+
*/
|
|
361
|
+
export function describeUnitLedger(ledger) {
|
|
362
|
+
if (!ledger || typeof ledger !== "object") return [];
|
|
363
|
+
const cap = ledger.capacity ?? {};
|
|
364
|
+
const lines = [`host ${ledger.host_key ?? "?"}: ${ledger.capacity_units ?? "?"} units — verdict ${ledger.verdict ?? "unknown"}`
|
|
365
|
+
+ ` (free ${cap.free_slots ?? "?"}, budget free ${cap.budget_free_slots ?? "?"}, unleased ${cap.observed_unmanaged_projects ?? "?"})`];
|
|
366
|
+
for (const l of Array.isArray(ledger.leases) ? ledger.leases : []) {
|
|
367
|
+
const who = l.lease_id ? `lease ${l.lease_id}` : `${l.tenant_id ?? "?"} lease`;
|
|
368
|
+
const what = [l.ticket_id, l.slot, l.project].filter(Boolean).join(" ");
|
|
369
|
+
const flags = [l.reap_reason ? `reap_reason ${l.reap_reason}` : null, l.no_containers === true ? "no containers" : null].filter(Boolean);
|
|
370
|
+
lines.push(` ${who} ${l.state} since ${l.since ?? "?"}${what ? ` [${what}]` : ""}${flags.length ? ` (${flags.join(", ")})` : ""}`);
|
|
371
|
+
}
|
|
372
|
+
for (const u of Array.isArray(ledger.unmanaged) ? ledger.unmanaged : []) {
|
|
373
|
+
lines.push(` unleased ${u.project_prefix ?? "?"} since ${u.since ?? "unknown"}${u.allowlisted ? " (allowlisted)" : ""}`);
|
|
374
|
+
}
|
|
375
|
+
if (ledger.permits) lines.push(` managed-container permits: ${ledger.permits}`);
|
|
376
|
+
return lines;
|
|
377
|
+
}
|
|
378
|
+
|
|
355
379
|
function queueReceiptFields(d) {
|
|
356
380
|
return {
|
|
357
381
|
queue_position: d?.queue_position ?? null,
|
|
@@ -679,6 +703,9 @@ export function retryingStackCall({
|
|
|
679
703
|
return r;
|
|
680
704
|
},
|
|
681
705
|
get auth() { return auth; },
|
|
706
|
+
// BOT-2099: a park that re-authenticated through an outage hands its fresh
|
|
707
|
+
// credential back, so every later RPC (get/touch/release) uses it.
|
|
708
|
+
adopt(fresh) { if (fresh) auth = fresh; },
|
|
682
709
|
};
|
|
683
710
|
}
|
|
684
711
|
|
|
@@ -919,7 +946,7 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl =
|
|
|
919
946
|
body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
|
|
920
947
|
signal,
|
|
921
948
|
});
|
|
922
|
-
if (!res.ok) throw new Error(`register responded ${res.status}`);
|
|
949
|
+
if (!res.ok) throw Object.assign(new Error(`register responded ${res.status}`), { status: res.status });
|
|
923
950
|
const body = await res.json();
|
|
924
951
|
return { waitSessionId: body.wait_session_id ?? null, cursorStart: body.cursor_start ?? null };
|
|
925
952
|
}
|
|
@@ -987,6 +1014,15 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
987
1014
|
const { result, status } = resultForAbortReason(ac.signal.reason);
|
|
988
1015
|
return finish(result, status);
|
|
989
1016
|
}
|
|
1017
|
+
// BOT-2099 (hop 6, 2026-10-06 01:54Z): a registration rejected by an auth or
|
|
1018
|
+
// availability blip is RETRIED by the park (parkOnLease), never downgraded to a
|
|
1019
|
+
// live-only park that is invisible on /waits and keeps nothing alive server-side.
|
|
1020
|
+
// Only a definite client-side rejection (another 4xx) keeps the old live-only path.
|
|
1021
|
+
const status = err?.status ?? null;
|
|
1022
|
+
if (status === 401 || status === 403) return finish({ auth: true, phase: "register" }, "error");
|
|
1023
|
+
if (status == null || status === 408 || status === 429 || status >= 500) {
|
|
1024
|
+
return finish({ error: `wait registration: ${err?.message ?? err}`, phase: "register" }, "error");
|
|
1025
|
+
}
|
|
990
1026
|
process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
|
|
991
1027
|
}
|
|
992
1028
|
const url = new URL(eventStreamBase());
|
|
@@ -1005,7 +1041,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
1005
1041
|
return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
|
|
1006
1042
|
}
|
|
1007
1043
|
if (res.status === 401 || res.status === 403) return finish({ auth: true }, "error");
|
|
1008
|
-
if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}
|
|
1044
|
+
if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}`, status: res.status }, "error");
|
|
1009
1045
|
const decoder = new TextDecoder();
|
|
1010
1046
|
let buf = "";
|
|
1011
1047
|
// BOT-1827: record a match/failure locally and `break` out of BOTH loops
|
|
@@ -1064,6 +1100,149 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
1064
1100
|
return finish({ timeout: true }, "timeout");
|
|
1065
1101
|
}
|
|
1066
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* BOT-2099 — the platform-incident reasons a park can record. A park NEVER ends with an
|
|
1105
|
+
* auth or mint error as its outcome: an outage is retried for the park's whole remaining
|
|
1106
|
+
* deadline and, if it outlasts it, the park ends as an ordinary `timeout` that carries
|
|
1107
|
+
* this incident (who to blame is the platform, not the lease).
|
|
1108
|
+
*/
|
|
1109
|
+
export const PARK_INCIDENT_REASONS = Object.freeze({
|
|
1110
|
+
AUTH: "platform_auth_unavailable", // 401/403 on the wait, or the session re-mint rejected
|
|
1111
|
+
UNREACHABLE: "platform_unreachable", // transport failure / 5xx / malformed relay response
|
|
1112
|
+
});
|
|
1113
|
+
export const PARK_BACKOFF = Object.freeze({ initialMs: 2_000, maxMs: 60_000 });
|
|
1114
|
+
|
|
1115
|
+
function abortableSleep(ms, signal) {
|
|
1116
|
+
return new Promise((resolve) => {
|
|
1117
|
+
if (signal?.aborted) return resolve();
|
|
1118
|
+
// Deliberately REFERENCED (Codex #1057 P1): while a park backs off, this timer may be the
|
|
1119
|
+
// only live handle; unreferencing it would let Node exit before the retry, the cleanup
|
|
1120
|
+
// or the receipt, leaving the lease behind.
|
|
1121
|
+
const timer = setTimeout(() => { signal?.removeEventListener?.("abort", onAbort); resolve(); }, Math.max(0, ms));
|
|
1122
|
+
const onAbort = () => { clearTimeout(timer); resolve(); };
|
|
1123
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* BOT-2099: re-establish the credential a parked lease waits with. On a transport failure
|
|
1129
|
+
* the credential is fine — keep it. On an auth rejection, first re-read (another process in
|
|
1130
|
+
* this worktree may already have re-minted, and minting again would supersede it), then
|
|
1131
|
+
* clear the per-worktree cache and self-heal once. Only a well-formed agent SESSION token
|
|
1132
|
+
* replaces a session credential: the operator fallback is a different principal that
|
|
1133
|
+
* cannot see the lease (BOT-1750 R8), so it is never adopted. Throws the mint error, which
|
|
1134
|
+
* the park treats as one more failed attempt.
|
|
1135
|
+
*/
|
|
1136
|
+
export async function reauthenticateParkedLease(current, kind, {
|
|
1137
|
+
cwd = process.cwd(), authProvider = stackAuthHeader, clear = clearAgentState,
|
|
1138
|
+
} = {}) {
|
|
1139
|
+
if (kind !== "auth") return current;
|
|
1140
|
+
const source = current?.__source ?? null;
|
|
1141
|
+
if (source !== "cache" && source !== "minted") return current;
|
|
1142
|
+
const isFreshSession = (h) => (h?.__source === "cache" || h?.__source === "minted")
|
|
1143
|
+
&& typeof h["x-agent-api-key"] === "string" && AGENT_KEY_RE.test(h["x-agent-api-key"])
|
|
1144
|
+
&& h["x-agent-api-key"] !== current["x-agent-api-key"];
|
|
1145
|
+
const reread = await authProvider();
|
|
1146
|
+
if (isFreshSession(reread)) return reread;
|
|
1147
|
+
await clear(cwd);
|
|
1148
|
+
const minted = await authProvider();
|
|
1149
|
+
return isFreshSession(minted) ? minted : current;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* BOT-2099: park on `lease:<id>` and SURVIVE control-plane auth/availability blips.
|
|
1154
|
+
*
|
|
1155
|
+
* `waitForLease` ends on the first 401/403, transport error or failed registration. Before
|
|
1156
|
+
* this, every caller turned that into the lease's outcome — releasing a queued lease at the
|
|
1157
|
+
* head of the queue over a one-second 401 (hop 6) and ending every parked lease in a
|
|
1158
|
+
* server-side auth outage with `session_mint_unauthorized` (hop 7). Here such a result is
|
|
1159
|
+
* retried with bounded exponential backoff (PARK_BACKOFF) until the park's OWN deadline:
|
|
1160
|
+
* re-authenticate on an auth failure (`reauth`), re-register the wait (a fresh registration
|
|
1161
|
+
* replays the lease's current state), and — after a recovery — read the lease directly
|
|
1162
|
+
* (`probe`) so a grant or reap that landed during the outage is never missed. Mint errors
|
|
1163
|
+
* thrown by `reauth` are attempts, not outcomes. The result is waitForLease's shape plus
|
|
1164
|
+
* `credential` (the headers that finally worked) and, when any blip occurred, `incident`.
|
|
1165
|
+
*/
|
|
1166
|
+
export async function parkOnLease(leaseId, isDone, isFailed, {
|
|
1167
|
+
timeoutSec, deadlineMs = null, auth, signal = null,
|
|
1168
|
+
wait = waitForLease, reauth = (current, kind) => reauthenticateParkedLease(current, kind),
|
|
1169
|
+
probe = null, sleep = (ms) => abortableSleep(ms, signal), now = () => Date.now(),
|
|
1170
|
+
backoff = PARK_BACKOFF, log = (line) => process.stderr.write(`${line}\n`),
|
|
1171
|
+
// Codex #1057 R3: a caller whose RPC layer can itself re-mint (stack run's
|
|
1172
|
+
// retryingStackCall) owns the credential; the park re-reads it before every attempt so a
|
|
1173
|
+
// credential minted during a probe is never overwritten by an older one.
|
|
1174
|
+
syncCredential = null,
|
|
1175
|
+
} = {}) {
|
|
1176
|
+
const deadline = deadlineMs ?? now() + timeoutSec * 1000;
|
|
1177
|
+
let credential = auth;
|
|
1178
|
+
let incident = null;
|
|
1179
|
+
let delay = backoff.initialMs;
|
|
1180
|
+
const note = (kind, detail, code = null) => {
|
|
1181
|
+
const at = new Date(now()).toISOString();
|
|
1182
|
+
if (!incident) incident = { reason: null, first_at: at, attempts: 0, recovered: false };
|
|
1183
|
+
incident.recovered = false; // a new failure re-opens a recovered incident
|
|
1184
|
+
incident.attempts += 1;
|
|
1185
|
+
incident.last_at = at;
|
|
1186
|
+
incident.reason = kind === "auth" ? PARK_INCIDENT_REASONS.AUTH : PARK_INCIDENT_REASONS.UNREACHABLE;
|
|
1187
|
+
incident.last_error = String(detail).slice(0, 300);
|
|
1188
|
+
incident.last_error_code = code;
|
|
1189
|
+
};
|
|
1190
|
+
const done = (result) => ({ ...result, credential, ...(incident ? { incident } : {}) });
|
|
1191
|
+
for (;;) {
|
|
1192
|
+
if (signal?.aborted) return done({ interrupted: true });
|
|
1193
|
+
const remainingMs = deadline - now();
|
|
1194
|
+
if (remainingMs <= 0) return done({ timeout: true });
|
|
1195
|
+
if (syncCredential) credential = syncCredential() ?? credential;
|
|
1196
|
+
let result;
|
|
1197
|
+
try {
|
|
1198
|
+
result = await wait(leaseId, isDone, isFailed, { timeoutSec: Math.ceil(remainingMs / 1000), deadlineMs: deadline, auth: credential, signal });
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
result = { error: `lease wait: ${err?.message ?? err}` };
|
|
1201
|
+
}
|
|
1202
|
+
// Codex #1057 R6: a permanent client error (400/404/… — a route, protocol or config
|
|
1203
|
+
// mismatch) is not a blip: fail at once. Only auth, transport (no status), 408/429 and
|
|
1204
|
+
// 5xx are retried.
|
|
1205
|
+
const permanent = result?.error && result.status != null && result.status !== 408 && result.status !== 429 && result.status < 500;
|
|
1206
|
+
if (permanent) return incident ? done(result) : result;
|
|
1207
|
+
if (!result?.auth && !result?.error) {
|
|
1208
|
+
// Codex #1057 R5: any attempt that did not fail on auth/transport (a match, a reap, or an
|
|
1209
|
+
// ordinary timeout on a healthy stream) means the platform recovered.
|
|
1210
|
+
if (incident) incident.recovered = true;
|
|
1211
|
+
// A wait that ends on its own timer is a real timeout; never the outage's last word.
|
|
1212
|
+
if (result?.timeout && incident && now() < deadline) continue;
|
|
1213
|
+
return incident ? done(result) : result;
|
|
1214
|
+
}
|
|
1215
|
+
const kind = result.auth ? "auth" : "transport";
|
|
1216
|
+
note(kind, result.auth ? `unauthorized (${result.phase ?? "stream"})` : result.error, result.auth ? "unauthorized" : null);
|
|
1217
|
+
log(`${yellow("⚠")} stack: lease ${leaseId} wait interrupted by a platform ${kind === "auth" ? "auth" : "availability"} blip (${incident.last_error}); still parked — retrying in ${Math.round(delay / 1000)}s (attempt ${incident.attempts}).`);
|
|
1218
|
+
// Back off (bounded) before re-authenticating, then retry; never past the deadline.
|
|
1219
|
+
for (;;) {
|
|
1220
|
+
await sleep(Math.min(delay, Math.max(0, deadline - now())));
|
|
1221
|
+
delay = Math.min(delay * 2, backoff.maxMs);
|
|
1222
|
+
if (signal?.aborted) return done({ interrupted: true });
|
|
1223
|
+
if (now() >= deadline) return done({ timeout: true });
|
|
1224
|
+
if (kind !== "auth") break; // a transport failure keeps its (valid) credential
|
|
1225
|
+
try {
|
|
1226
|
+
if (syncCredential) credential = syncCredential() ?? credential;
|
|
1227
|
+
credential = (await reauth(credential, kind)) ?? credential;
|
|
1228
|
+
break;
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
note("auth", err?.message ?? err, err?.code ?? null);
|
|
1231
|
+
log(`${yellow("⚠")} stack: lease ${leaseId} re-authentication failed (${err?.code ?? err?.message ?? err}); still parked — retrying in ${Math.round(delay / 1000)}s.`);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (probe) {
|
|
1235
|
+
try {
|
|
1236
|
+
const read = await probe(credential);
|
|
1237
|
+
if (syncCredential) credential = syncCredential() ?? credential;
|
|
1238
|
+
const state = read?.state ?? null;
|
|
1239
|
+
if (state != null && isFailed(state)) { incident.recovered = true; return done({ failed: true, state, ...(read?.reap_reason ? { reap_reason: read.reap_reason } : {}) }); }
|
|
1240
|
+
if (state != null && isDone(state)) { incident.recovered = true; return done({ woke: true, state }); }
|
|
1241
|
+
} catch { /* the next wait attempt reports the failure */ }
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1067
1246
|
const isActive = (s) => s === "active";
|
|
1068
1247
|
const nonQueued = (s) => s != null && s !== "queued";
|
|
1069
1248
|
// BOT-1822 (Codex round 3 P2): `reap_failed` is a distinct terminal wake
|
|
@@ -1534,6 +1713,9 @@ export async function cmdUp(opts, {
|
|
|
1534
1713
|
refreshAuth = refreshRejectedCacheAuth,
|
|
1535
1714
|
reportLaneStatus = writeLaneStatus,
|
|
1536
1715
|
waitCapacity = defaultWaitCapacity,
|
|
1716
|
+
reauth = (current, kind) => reauthenticateParkedLease(current, kind),
|
|
1717
|
+
sleep = null,
|
|
1718
|
+
now = null,
|
|
1537
1719
|
} = {}) {
|
|
1538
1720
|
let slot;
|
|
1539
1721
|
try { slot = deriveSlot(opts); } catch (e) {
|
|
@@ -1671,6 +1853,25 @@ export async function cmdUp(opts, {
|
|
|
1671
1853
|
}
|
|
1672
1854
|
let leaseId = d.lease_id;
|
|
1673
1855
|
let state = d.state;
|
|
1856
|
+
// BOT-2099: every lease wait in `stack up` (queue park, Helper provision, Helper takeover)
|
|
1857
|
+
// survives auth/availability blips (parkOnLease) and adopts the re-minted credential.
|
|
1858
|
+
const resilientWait = async (isDone, isFailed) => {
|
|
1859
|
+
const result = await parkOnLease(leaseId, isDone, isFailed, {
|
|
1860
|
+
timeoutSec: opts.timeout, auth, wait: waitFn, reauth, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}),
|
|
1861
|
+
probe: async () => { const read = await call("get_stack_lease", { lease_id: leaseId }); return read?.ok && read.data?.success ? read.data : null; },
|
|
1862
|
+
});
|
|
1863
|
+
if (result.credential && result.credential !== auth) {
|
|
1864
|
+
auth = result.credential;
|
|
1865
|
+
call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
|
|
1866
|
+
}
|
|
1867
|
+
if (result.incident) upIncident = result.incident;
|
|
1868
|
+
return result;
|
|
1869
|
+
};
|
|
1870
|
+
// Codex #1057 R2: a blip that occurred during any wait stays on EVERY terminal receipt
|
|
1871
|
+
// (recovered or not), so recoveries are not lost from the incident record.
|
|
1872
|
+
let upIncident = null;
|
|
1873
|
+
const emitPlain = emitResult;
|
|
1874
|
+
emitResult = (receipt, o, code) => emitPlain(upIncident && !receipt.platform_incident ? { ...receipt, platform_incident: upIncident } : receipt, o, code);
|
|
1674
1875
|
// BOT-1711 (Codex R13): request_stack_lease returned an EXISTING active lease (reused),
|
|
1675
1876
|
// rather than a freshly minted queued/provisioning one. A reused lease was not provisioned
|
|
1676
1877
|
// in this invocation, so its recorded metadata cannot be trusted for a local-exec target.
|
|
@@ -1682,12 +1883,13 @@ export async function cmdUp(opts, {
|
|
|
1682
1883
|
const where = describeQueuePosition(d);
|
|
1683
1884
|
reportLaneStatus({ phase: "waiting", reason: "queued_for_capacity", detail: where || "host at capacity" });
|
|
1684
1885
|
process.stderr.write(`stack: lease ${leaseId} queued — ${where}\n`);
|
|
1886
|
+
for (const line of describeUnitLedger(d.ledger)) process.stderr.write(`stack: ${line}\n`);
|
|
1685
1887
|
if (opts.noWait) {
|
|
1686
|
-
return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, ...queueReceiptFields(d), holders: d.holders }), opts, EXIT.QUEUED);
|
|
1888
|
+
return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, ...queueReceiptFields(d), holders: d.holders, ...(d.ledger ? { unit_ledger: d.ledger } : {}) }), opts, EXIT.QUEUED);
|
|
1687
1889
|
}
|
|
1688
1890
|
// Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
|
|
1689
1891
|
// BOT-2013: a lease reaped while queued (orphaned_agent, …) is lost, not "left the queue".
|
|
1690
|
-
const parked = await
|
|
1892
|
+
const parked = await resilientWait(nonQueued, isReaped);
|
|
1691
1893
|
if (parked.failed) {
|
|
1692
1894
|
return emitResult(buildReceipt({
|
|
1693
1895
|
command: "up", outcome: "error", lease_id: leaseId, state: parked.state, slot,
|
|
@@ -1695,7 +1897,13 @@ export async function cmdUp(opts, {
|
|
|
1695
1897
|
error: `lease was ${parked.state} while queued (reap_reason=${parked.reap_reason ?? "unknown"}) — request a new lease`,
|
|
1696
1898
|
}), opts, EXIT.LEASE_FAILED);
|
|
1697
1899
|
}
|
|
1698
|
-
if (parked.timeout)
|
|
1900
|
+
if (parked.timeout) {
|
|
1901
|
+
// Codex #1057 R2: say who held the units at the deadline (re-read; best effort).
|
|
1902
|
+
let ledger = d.ledger ?? null;
|
|
1903
|
+
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 */ }
|
|
1904
|
+
for (const line of describeUnitLedger(ledger)) process.stderr.write(`stack: ${line}\n`);
|
|
1905
|
+
return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity`, ...(ledger ? { unit_ledger: ledger } : {}) }), opts, EXIT.TIMEOUT);
|
|
1906
|
+
}
|
|
1699
1907
|
if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
1700
1908
|
if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
|
|
1701
1909
|
state = parked.state || "provisioning";
|
|
@@ -1793,8 +2001,8 @@ export async function cmdUp(opts, {
|
|
|
1793
2001
|
const helperAlreadyActive = reserved.data?.code === "LEASE_NOT_PROVISIONING" && reserved.data?.state === "active";
|
|
1794
2002
|
if (helperClaimed || helperAlreadyActive) {
|
|
1795
2003
|
if (!helperAlreadyActive) {
|
|
1796
|
-
const active = await
|
|
1797
|
-
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
|
|
2004
|
+
const active = await resilientWait(isActive, isReaped);
|
|
2005
|
+
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`, ...(active.incident ? { platform_incident: active.incident } : {}) }), opts, EXIT.TIMEOUT);
|
|
1798
2006
|
if (active.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
1799
2007
|
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);
|
|
1800
2008
|
if (active.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
|
|
@@ -1832,8 +2040,8 @@ export async function cmdUp(opts, {
|
|
|
1832
2040
|
}
|
|
1833
2041
|
}
|
|
1834
2042
|
} else {
|
|
1835
|
-
const active = await
|
|
1836
|
-
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)
|
|
2043
|
+
const active = await resilientWait(isActive, isReaped);
|
|
2044
|
+
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)`, ...(active.incident ? { platform_incident: active.incident } : {}) }), opts, EXIT.TIMEOUT);
|
|
1837
2045
|
if (active.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
1838
2046
|
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);
|
|
1839
2047
|
if (active.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
|
|
@@ -1900,11 +2108,14 @@ export async function cmdStatus(leaseId, opts, {
|
|
|
1900
2108
|
// ticket it is scored under; a null position is reported, never a crash.
|
|
1901
2109
|
const queued = g.state === "queued";
|
|
1902
2110
|
if (queued) stderr(`stack: lease ${leaseId} queued — ${describeQueuePosition(g)}`);
|
|
2111
|
+
if (queued) for (const line of describeUnitLedger(g.ledger)) stderr(`stack: ${line}`);
|
|
1903
2112
|
return emitResult(buildReceipt({
|
|
1904
2113
|
command: "status", outcome: g.state, lease_id: leaseId, state: g.state,
|
|
1905
2114
|
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
1906
2115
|
...(queued ? queueReceiptFields(g) : { queue_position: g.queue_position }),
|
|
1907
2116
|
idle_ttl_secs: g.idle_ttl_secs, queued: g.queued,
|
|
2117
|
+
// BOT-2099 (Codex #1057 R3): machine callers get the unit ledger of a queued lease too.
|
|
2118
|
+
...(queued && g.ledger ? { unit_ledger: g.ledger } : {}),
|
|
1908
2119
|
// BOT-1960: a lease fenced in `reaping` says since when, and — once the server gave up
|
|
1909
2120
|
// retrying its failed reap — that it is reap_failed and why.
|
|
1910
2121
|
...(g.reap_requested_at ? { reap_requested_at: g.reap_requested_at } : {}),
|
|
@@ -2257,6 +2468,14 @@ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connect
|
|
|
2257
2468
|
* without Docker or a live BotBuddy service.
|
|
2258
2469
|
*/
|
|
2259
2470
|
export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
2471
|
+
// BOT-2099 (Codex #1057 R4): a platform blip recorded by ANY lease wait stays on EVERY
|
|
2472
|
+
// terminal result (interrupted, unreadable, thrown, …), not only the paths that remember it.
|
|
2473
|
+
const incidentBox = { incident: null };
|
|
2474
|
+
const result = await runStackLifecycleOnce(opts, childArgv, adapters, incidentBox);
|
|
2475
|
+
return incidentBox.incident && !result?.incident ? { ...result, incident: incidentBox.incident } : result;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
2260
2479
|
// BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
|
|
2261
2480
|
// long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
|
|
2262
2481
|
// can be rotated server-side at ANY of those points, so EVERY RPC — not just the
|
|
@@ -2283,6 +2502,29 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2283
2502
|
};
|
|
2284
2503
|
const machineUuidFn = adapters.machineUuidFn ?? machineUuid;
|
|
2285
2504
|
const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
|
|
2505
|
+
// BOT-2099: the queue park and the provision wait survive auth/availability blips.
|
|
2506
|
+
// Production re-authenticates through the self-heal path; an injected-auth test double
|
|
2507
|
+
// without an explicit `reauth` keeps its credential (never re-mints a fake one).
|
|
2508
|
+
const reauth = adapters.reauth ?? (adapters.auth ? async (current) => current : (current, kind) => reauthenticateParkedLease(current, kind));
|
|
2509
|
+
let parkIncident = null;
|
|
2510
|
+
// BOT-2099: a best-effort diagnostic read (ledger, persisted Helper reason) never throws.
|
|
2511
|
+
const readLeaseQuietly = async (id) => { try { return await api.get?.(id) ?? null; } catch { return null; } };
|
|
2512
|
+
const resilientWait = async (leaseIdArg, done, failed, options) => {
|
|
2513
|
+
const result = await parkOnLease(leaseIdArg, done, failed, {
|
|
2514
|
+
...options, wait,
|
|
2515
|
+
// The RPC layer (retryingStackCall) is the single owner of the credential: a re-mint
|
|
2516
|
+
// is adopted there at once, and the park re-reads it before each attempt — so a
|
|
2517
|
+
// credential the RPC layer itself refreshed during the probe is never overwritten.
|
|
2518
|
+
reauth: async (_current, kind) => { rpc.adopt(await reauth(rpc.auth, kind)); return rpc.auth; },
|
|
2519
|
+
syncCredential: () => rpc.auth,
|
|
2520
|
+
probe: async () => { const read = await api.get(leaseIdArg); return read?.ok && read.data?.success ? read.data : null; },
|
|
2521
|
+
...(adapters.sleep ? { sleep: adapters.sleep } : {}),
|
|
2522
|
+
...(adapters.now ? { now: adapters.now } : {}),
|
|
2523
|
+
log: stderrLine,
|
|
2524
|
+
});
|
|
2525
|
+
if (result?.incident) { parkIncident = result.incident; incidentBox.incident = result.incident; }
|
|
2526
|
+
return result;
|
|
2527
|
+
};
|
|
2286
2528
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
|
|
2287
2529
|
const startChild = adapters.startChild ?? ((argv, env, cwd) => spawn(argv[0], argv.slice(1), { cwd, env, stdio: "inherit", detached: true }));
|
|
2288
2530
|
const writeConnection = adapters.writeConnection ?? writeConnectionFile;
|
|
@@ -2343,9 +2585,18 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2343
2585
|
// `stack_leases.error` (which `get_stack_lease` already returns) holds the
|
|
2344
2586
|
// most recent failure message regardless of which live signal was or
|
|
2345
2587
|
// wasn't seen.
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2588
|
+
// BOT-2099 (hop 4, lease 14dffd23): `error` on a `reaping` lease is NOT by itself a
|
|
2589
|
+
// failed reap — a provision the Helper rolled back is `reaping / provision_failed`
|
|
2590
|
+
// with the PROVISION failure in `error`, and its reap is still to come. A reap has
|
|
2591
|
+
// failed only when the server says so: `reap_failed_at` (BOT-1960) or the latest
|
|
2592
|
+
// reap_stack job `failed` (`last_reap_outcome`, BOT-2099). A server too old to
|
|
2593
|
+
// return `last_reap_outcome` keeps the previous conservative reading.
|
|
2594
|
+
const durableReapFailure = (read) => {
|
|
2595
|
+
if (!(read?.ok && read.data?.success && read.data.state === "reaping" && read.data.error)) return null;
|
|
2596
|
+
const d = read.data;
|
|
2597
|
+
const reapFailed = d.reap_failed_at != null || d.last_reap_outcome === "failed" || !("last_reap_outcome" in d);
|
|
2598
|
+
return reapFailed ? { ok: false, error: `the lease is still reaping and may still hold containers — ${d.error}` } : null;
|
|
2599
|
+
};
|
|
2349
2600
|
// BOT-1822 (Codex round 3 P2): check BEFORE opening the wait, not only after
|
|
2350
2601
|
// it times out — a failure already persisted must be reported at once
|
|
2351
2602
|
// instead of burning the entire --reap-timeout waiting for a wake that will
|
|
@@ -2357,7 +2608,8 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2357
2608
|
// state — instead of leaving this wait to park until --reap-timeout on a reap
|
|
2358
2609
|
// that already failed. Report that specific outcome instead of the generic
|
|
2359
2610
|
// "was not proven" timeout message.
|
|
2360
|
-
|
|
2611
|
+
// BOT-2099 (Codex #1057 R6): the signed-reap wait survives auth/availability blips too.
|
|
2612
|
+
const reaped = await resilientWait(leaseId, (state) => state === "reaped", (state) => state === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth });
|
|
2361
2613
|
if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
2362
2614
|
if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
|
|
2363
2615
|
// Belt-and-braces: the same race can still slip between the pre-check above
|
|
@@ -2505,14 +2757,26 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2505
2757
|
if (state === "queued") {
|
|
2506
2758
|
// BOT-2019: say where the lease sits (server dispatch rank, or why it has none) before parking.
|
|
2507
2759
|
stderrLine(`stack: lease ${leaseId} queued — ${describeQueuePosition(request.data)}`);
|
|
2760
|
+
for (const line of describeUnitLedger(request.data.ledger)) stderrLine(`stack: ${line}`);
|
|
2508
2761
|
activeWaitAbort = new AbortController();
|
|
2509
2762
|
// BOT-2013: every reap state (reaping / reaped / reap_failed) means the queued lease is lost.
|
|
2510
|
-
|
|
2763
|
+
// BOT-2099: an auth/availability blip is retried inside the park until its deadline;
|
|
2764
|
+
// it never releases a queued lease or becomes the lease's outcome.
|
|
2765
|
+
const parked = await resilientWait(leaseId, (s) => s !== "queued" && s != null, isReaped, { timeoutSec: opts.timeout, auth: rpc.auth, signal: activeWaitAbort.signal });
|
|
2511
2766
|
activeWaitAbort = null;
|
|
2512
|
-
|
|
2513
|
-
if (parked?.
|
|
2514
|
-
if (parked?.
|
|
2515
|
-
|
|
2767
|
+
const incident = parkIncident ? { incident: parkIncident } : {};
|
|
2768
|
+
if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup, ...incident }; }
|
|
2769
|
+
if (parked?.timeout) {
|
|
2770
|
+
// BOT-2099: say who held the units for the whole park, read before the release.
|
|
2771
|
+
const final = await readLeaseQuietly(leaseId);
|
|
2772
|
+
const ledger = final?.ok && final.data?.success ? final.data.ledger ?? null : null;
|
|
2773
|
+
for (const line of describeUnitLedger(ledger)) stderrLine(`stack: ${line}`);
|
|
2774
|
+
const cleanup = await requestCleanup();
|
|
2775
|
+
const during = parkIncident && !parkIncident.recovered ? ` (platform incident: ${parkIncident.reason} since ${parkIncident.first_at})` : "";
|
|
2776
|
+
return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity${during}`, cleanup, ...incident, ...(ledger ? { ledger } : {}) };
|
|
2777
|
+
}
|
|
2778
|
+
if (parked?.failed) { const cleanup = await requestCleanup(); return { exitCode: EXIT.LEASE_FAILED, outcome: "error", leaseId, error: `lease was ${parked.state} while queued (reap_reason=${parked.reap_reason ?? "unknown"})`, cleanup, ...incident }; }
|
|
2779
|
+
if (parked?.error || parked?.auth) { const cleanup = await requestCleanup(); return { exitCode: parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup, ...incident }; }
|
|
2516
2780
|
state = parked.state;
|
|
2517
2781
|
}
|
|
2518
2782
|
if (state !== "active") {
|
|
@@ -2524,7 +2788,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2524
2788
|
// provisionTimeout before the belt-and-braces recheck below ever notices
|
|
2525
2789
|
// the lease is no longer provisioning. isReaped (== "reaping" || "reaped")
|
|
2526
2790
|
// already covers both; cmdUp's equivalent waits use it for this reason.
|
|
2527
|
-
const active = await
|
|
2791
|
+
const active = await resilientWait(leaseId, (s) => s === "active", isReaped, { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
|
|
2528
2792
|
activeWaitAbort = null;
|
|
2529
2793
|
if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
|
|
2530
2794
|
if (active?.timeout) {
|
|
@@ -2536,10 +2800,20 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2536
2800
|
const recheck = await api.get(leaseId);
|
|
2537
2801
|
if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
|
|
2538
2802
|
const cleanup = await requestCleanup();
|
|
2539
|
-
return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup };
|
|
2803
|
+
return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
if (active?.failed || active?.error || active?.auth) {
|
|
2807
|
+
// BOT-2099 (hops 3–4): name the Helper's reason (e.g. `schema readiness rejected:
|
|
2808
|
+
// canonical_probe`) — it is persisted on the lease as `error` — instead of only the state.
|
|
2809
|
+
let why = "";
|
|
2810
|
+
if (active?.failed) {
|
|
2811
|
+
const read = await readLeaseQuietly(leaseId);
|
|
2812
|
+
if (read?.ok && read.data?.success && typeof read.data.error === "string" && read.data.error) why = ` — ${read.data.error}`;
|
|
2540
2813
|
}
|
|
2814
|
+
const cleanup = await requestCleanup();
|
|
2815
|
+
return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active${why}`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
|
|
2541
2816
|
}
|
|
2542
|
-
if (active?.failed || active?.error || active?.auth) { const cleanup = await requestCleanup(); return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active`, cleanup }; }
|
|
2543
2817
|
}
|
|
2544
2818
|
const current = await api.get(leaseId);
|
|
2545
2819
|
if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {
|
|
@@ -2606,7 +2880,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2606
2880
|
if (hardTimer) clock.clearTimeout(hardTimer);
|
|
2607
2881
|
const cleanup = await requestCleanup();
|
|
2608
2882
|
const exitCode = receivedSignal ? SIGNAL_EXIT[receivedSignal] : forcedStopCode ?? (childResult.code !== 0 ? childResult.code : !cleanup.ok ? EXIT.CLEANUP_FAILED : 0);
|
|
2609
|
-
return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, fenced: fencing };
|
|
2883
|
+
return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) };
|
|
2610
2884
|
} catch (error) {
|
|
2611
2885
|
const cleanup = await requestCleanup();
|
|
2612
2886
|
// BOT-1750 (Codex R10 P2): a thrown auth/self-heal failure (e.g. a refresh that
|
|
@@ -2646,6 +2920,10 @@ async function cmdRun(opts, childArgv) {
|
|
|
2646
2920
|
...(result.wait_exit_code != null ? { wait_exit_code: result.wait_exit_code, wait_receipt: result.wait_receipt ?? null } : {}),
|
|
2647
2921
|
child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
|
|
2648
2922
|
fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
|
|
2923
|
+
// BOT-2099: a platform auth/availability blip during the park is reported as an
|
|
2924
|
+
// incident (reason, window, attempts, recovered) — never as the lease's outcome.
|
|
2925
|
+
...(result.incident ? { platform_incident: result.incident } : {}),
|
|
2926
|
+
...(result.ledger ? { unit_ledger: result.ledger } : {}),
|
|
2649
2927
|
}), opts, result.exitCode);
|
|
2650
2928
|
}
|
|
2651
2929
|
|