@botbuddy/cli 1.40.9 → 1.40.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.40.9",
3
+ "version": "1.40.11",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,6 @@
1
1
  // BOT-1649 — diagnose and repair a worktree's short-lived agent credential
2
2
  // without ever printing the token unless the operator explicitly requests an
3
3
  // export snippet for the current shell.
4
- import { hostname } from "node:os";
5
4
  import { readAgentBinding } from "./wait-profile.mjs";
6
5
  import { resolveOwnerToken, CLIENT_KEY_SERVICE, OWNER_TOKEN_SERVICE } from "./cli-credentials.mjs";
7
6
  import { readKeychainSecret } from "./agent-credential-store.mjs";
@@ -9,6 +8,7 @@ import { getConfig } from "./config.mjs";
9
8
  import { isAgentStateFresh, readAgentState } from "./agent-state.mjs";
10
9
  import { selfHealAgentSession } from "./agent-session.mjs";
11
10
  import { machineUuid } from "./machine-id.mjs";
11
+ import { machineHost } from "./machine-host.mjs";
12
12
 
13
13
  export function parseDoctorArgs(argv = []) {
14
14
  const parsed = { fix: false, printExports: false, json: false, errors: [] };
@@ -97,6 +97,16 @@ function applyStackLeaseCapability(report, capability) {
97
97
  if (!capability || typeof capability.status !== "string") return;
98
98
  report.stack_lease.capability = capability.status;
99
99
  if (capability.reason) report.stack_lease.reason = capability.reason;
100
+ if (capability.reported_host && capability.enrolled_host) {
101
+ report.stack_lease.hostname_drift = {
102
+ reported: capability.reported_host,
103
+ enrolled: capability.enrolled_host,
104
+ remedies: [
105
+ "use the macOS LocalHostName reported by `scutil --get LocalHostName`",
106
+ "or re-enrol the Helper if this is intentionally a different machine",
107
+ ],
108
+ };
109
+ }
100
110
  if (capability.status !== "established" && !report.recovery) {
101
111
  report.recovery = `stack leases need a device-consented Helper on this machine: ${capability.reason ?? "worktree not attested"}`;
102
112
  }
@@ -126,7 +136,7 @@ export async function doctorAgentAuth({
126
136
  // inside selfHeal, which the `--fix` gate skips when it reports the key missing.
127
137
  ownerToken = readClientKeyStrict,
128
138
  selfHeal = selfHealAgentSession,
129
- hostFn = hostname, machineUuidFn = machineUuid,
139
+ hostFn = machineHost, machineUuidFn = machineUuid,
130
140
  } = {}) {
131
141
  let owner = null;
132
142
  let ownerReadFailed = false;
@@ -140,6 +150,7 @@ export async function doctorAgentAuth({
140
150
  // LaunchAgent). Env only, exactly like selfHealAgentSession; never the value.
141
151
  const mcpEnv = typeof bound?.mcpEnv === "string" && bound.mcpEnv ? bound.mcpEnv : null;
142
152
  const mcpKeyInEnv = Boolean(mcpEnv && typeof env?.[mcpEnv] === "string" && env[mcpEnv]);
153
+ const resolvedHost = await hostFn();
143
154
  const report = {
144
155
  schema_version: 1,
145
156
  worktree: cwd,
@@ -156,7 +167,7 @@ export async function doctorAgentAuth({
156
167
  // is known only after a register call, so a plain `bb doctor` reports `unverified`
157
168
  // and `--fix` re-registers to (re)establish it (`established` | `unattested`).
158
169
  stack_lease: {
159
- host: hostFn(),
170
+ host: resolvedHost,
160
171
  machine_id_readable: Boolean(machineUuidFn()),
161
172
  capability: "unverified",
162
173
  },
Binary file
@@ -0,0 +1,26 @@
1
+ import { execFile } from "node:child_process";
2
+ import { hostname, platform } from "node:os";
3
+ import { promisify } from "node:util";
4
+ import { canonicalizeHostString } from "./pw/host.mjs";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Return the host identity used by Helper enrolment. macOS's kernel hostname can
10
+ * follow DHCP; Bonjour LocalHostName is stable and is what the Helper records.
11
+ */
12
+ export async function machineHost({
13
+ platformFn = platform,
14
+ hostnameFn = hostname,
15
+ execFileFn = execFileAsync,
16
+ } = {}) {
17
+ if (platformFn() !== "darwin") return hostnameFn();
18
+ try {
19
+ const { stdout } = await execFileFn("scutil", ["--get", "LocalHostName"]);
20
+ const localName = canonicalizeHostString(stdout);
21
+ if (localName) return `${localName}.local`;
22
+ } catch {
23
+ // Minimal shells can lack scutil; the existing hostname behaviour is safe.
24
+ }
25
+ return hostnameFn();
26
+ }
@@ -8,6 +8,8 @@ import { clearAgentSessionIfMatches, drainPendingCleanup, recordPendingCleanup }
8
8
  import { readAgentBinding } from "./wait-profile.mjs";
9
9
  import { resolveAgentKey, resolveOwnerToken } from "./cli-credentials.mjs";
10
10
  import { getConfig } from "./config.mjs";
11
+ import { machineHost } from "./machine-host.mjs";
12
+ import { machineUuid } from "./machine-id.mjs";
11
13
 
12
14
  // BOT-1650 → BOT-1649 reconciliation: the harness session lives in the ONE
13
15
  // unified client cache (agent-state.mjs), written in main's `agent_session_token`
@@ -29,7 +31,7 @@ export async function loadHarnessSession({ cwd = process.cwd() } = {}) {
29
31
  /** Persist a bootstrap-issued session into the unified cache. The bootstrap RPC
30
32
  * does not echo the tenant, so derive it from the committed binding exactly as
31
33
  * selfHealAgentSession does; host/worktree/minted_at are derived locally. */
32
- export async function saveHarnessSession(session, { cwd = process.cwd() } = {}) {
34
+ export async function saveHarnessSession(session, { cwd = process.cwd(), machineHostFn = machineHost } = {}) {
33
35
  const root = await worktreeRoot(cwd);
34
36
  let tenant;
35
37
  try { tenant = (await readAgentBinding(root))?.tenant; } catch { tenant = undefined; }
@@ -39,7 +41,7 @@ export async function saveHarnessSession(session, { cwd = process.cwd() } = {})
39
41
  agent_session_token: session.session_token,
40
42
  session_id: session.session_id ?? null,
41
43
  ...(tenant ? { tenant } : {}),
42
- host: os.hostname(),
44
+ host: await machineHostFn(),
43
45
  worktree: root,
44
46
  minted_at: new Date().toISOString(),
45
47
  expires_at: session.expires_at,
@@ -218,6 +220,8 @@ export async function bootstrapHarnessSession({
218
220
  model = null,
219
221
  effort = null,
220
222
  harness = type,
223
+ machineHostFn = machineHost,
224
+ machineUuidFn = machineUuid,
221
225
  call = callToolJson,
222
226
  callOptions = undefined,
223
227
  resolveCleanupOptions = durableCleanupOptions,
@@ -249,11 +253,14 @@ export async function bootstrapHarnessSession({
249
253
  // bootstrap straight through so the server reuses a still-live incarnation
250
254
  // or rotates a truly expired one; never revoke an active wait from a stale
251
255
  // local timestamp.
256
+ const host = await machineHostFn();
257
+ const hardwareUuid = machineUuidFn();
252
258
  const response = await call("bootstrap_agent_session", {
253
259
  name,
254
260
  type,
255
261
  harness_session_id: harnessSessionId,
256
- host: os.hostname(),
262
+ host,
263
+ ...(hardwareUuid ? { machine_uuid: hardwareUuid } : {}),
257
264
  worktree,
258
265
  ...(branch ? { branch } : {}),
259
266
  ...(ticketId ? { ticket_id: ticketId } : {}),
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}` }, "error");
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 waitFn(leaseId, nonQueued, isReaped, { timeoutSec: opts.timeout, auth });
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) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity` }), opts, EXIT.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 waitFn(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
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` }), opts, EXIT.TIMEOUT);
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 waitFn(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
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)` }), opts, EXIT.TIMEOUT);
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
- const durableReapFailure = (read) => (read?.ok && read.data?.success && read.data.state === "reaping" && read.data.error)
2347
- ? { ok: false, error: `the lease is still reaping and may still hold containers — ${read.data.error}` }
2348
- : null;
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
- const reaped = await wait(leaseId, (state) => state === "reaped", (state) => state === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth });
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
- const parked = await wait(leaseId, (s) => s !== "queued" && s != null, isReaped, { timeoutSec: opts.timeout, auth: rpc.auth, signal: activeWaitAbort.signal });
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
- if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
2513
- if (parked?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup }; }
2514
- 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 }; }
2515
- 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 }; }
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 wait(leaseId, (s) => s === "active", isReaped, { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
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