@botbuddy/cli 1.64.5 → 1.64.7

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.5",
3
- "source_commit": "74649b2c740e68076a3d2753d52c39baa5dd6041",
4
- "source_sha256": "7f55b0dc6c779a3bb000adaf31bcb8df85f8b58db4c2e58c3f24ba118ce7f869",
2
+ "release_version": "1.64.7",
3
+ "source_commit": "52077eb9393ab47e2a35b6ddba00a9a62955ca07",
4
+ "source_sha256": "9ac2dd0f2b02bdb4eac636e402b63620881c126c760d60b5313122323830b18a",
5
5
  "target": "darwin-arm64",
6
- "sha256": "40982758f67eb18207e820823b3f28d0d1ee35bd8c84a70d22262a4a0ce306d8"
6
+ "sha256": "7ab6917ca6dd35571ff58b30e3baa08c387b7395a61e8e75d833e86ba379bfea"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.5",
3
- "source_commit": "74649b2c740e68076a3d2753d52c39baa5dd6041",
4
- "source_sha256": "7f55b0dc6c779a3bb000adaf31bcb8df85f8b58db4c2e58c3f24ba118ce7f869",
2
+ "release_version": "1.64.7",
3
+ "source_commit": "52077eb9393ab47e2a35b6ddba00a9a62955ca07",
4
+ "source_sha256": "9ac2dd0f2b02bdb4eac636e402b63620881c126c760d60b5313122323830b18a",
5
5
  "target": "linux-x64",
6
- "sha256": "a48a8775314b124b3fad95e5a2aabd44cf94d26af57bafe7ab01fe4d7aec6724"
6
+ "sha256": "0752327535efa03af48f49c97eca74b0df6050b46f16ef44ccc0ebdf042169d6"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.64.5",
4
- "source_commit": "74649b2c740e68076a3d2753d52c39baa5dd6041",
5
- "source_sha256": "7f55b0dc6c779a3bb000adaf31bcb8df85f8b58db4c2e58c3f24ba118ce7f869",
3
+ "release_version": "1.64.7",
4
+ "source_commit": "52077eb9393ab47e2a35b6ddba00a9a62955ca07",
5
+ "source_sha256": "9ac2dd0f2b02bdb4eac636e402b63620881c126c760d60b5313122323830b18a",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "40982758f67eb18207e820823b3f28d0d1ee35bd8c84a70d22262a4a0ce306d8",
11
+ "sha256": "7ab6917ca6dd35571ff58b30e3baa08c387b7395a61e8e75d833e86ba379bfea",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "a48a8775314b124b3fad95e5a2aabd44cf94d26af57bafe7ab01fe4d7aec6724",
18
+ "sha256": "0752327535efa03af48f49c97eca74b0df6050b46f16ef44ccc0ebdf042169d6",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.64.5",
3
+ "version": "1.64.7",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -81,18 +81,24 @@ function callbackReadiness(value,callbackPort){
81
81
 
82
82
  // Renewal cadence and acknowledgement budgets. The first renewal also runs the relay's
83
83
  // consuming-namespace probe (validation + docker exec), so it gets the larger budget.
84
- const GENERATION_MS=30_000,RENEW_INTERVAL_MS=10_000,FIRST_ACK_MS=15_000,RENEW_ACK_MS=6_000,STATUS_DEADLINE_MS=5_000,TRANSIENT_RETRY_MS=1_000;
85
- // A transient status read is retried after TRANSIENT_RETRY_MS only while that retry (read +
86
- // acknowledgement) can still land inside the current 30 s generation; otherwise the route closes.
87
- const TRANSIENT_GRACE_MS=GENERATION_MS-TRANSIENT_RETRY_MS-STATUS_DEADLINE_MS-RENEW_ACK_MS;
88
- export async function openNativeCallbackTransport({current,leaseId,hostKey,callbackPort,requesterMachineId,sshTarget,readCurrent,sshBin='ssh',env=process.env,run=execFile,spawnImpl=spawn,verifyRuntime=verifyInstalledCallbackRuntime,openGateway=openCallbackGateway,renewIntervalMs=RENEW_INTERVAL_MS,firstAckMs=FIRST_ACK_MS,renewAckMs=RENEW_ACK_MS,statusDeadlineMs=STATUS_DEADLINE_MS,transientRetryMs=TRANSIENT_RETRY_MS,transientGraceMs=TRANSIENT_GRACE_MS}){
84
+ const GENERATION_MS=30_000,RENEW_INTERVAL_MS=10_000,FIRST_ACK_MS=15_000,RENEW_ACK_MS=6_000,STATUS_DEADLINE_MS=5_000,REFUSAL_RETRY_MS=1_000;
85
+ // A definitive answer stops the child only if one re-read repeats it, and that re-read (read +
86
+ // acknowledgement) must still land inside the current 30 s generation.
87
+ const REFUSAL_GRACE_MS=GENERATION_MS-REFUSAL_RETRY_MS-STATUS_DEADLINE_MS-RENEW_ACK_MS;
88
+ // BOT-2957: the longest the route may live on its last positive answer while the control plane
89
+ // does not answer, counted from when that read was sent. A control-plane stall must not kill a
90
+ // healthy run, so the requester keeps renewing the 30 s generation for this long; this number is
91
+ // also the longest a callback revoked during such an outage can keep running. The relay and the
92
+ // gateway keep their own 30 s generation, so a requester that dies still loses the route at once.
93
+ export const CALLBACK_STALE_PROOF_MAX_MS=180_000;
94
+ export async function openNativeCallbackTransport({current,leaseId,hostKey,callbackPort,requesterMachineId,sshTarget,readCurrent,sshBin='ssh',env=process.env,run=execFile,spawnImpl=spawn,verifyRuntime=verifyInstalledCallbackRuntime,openGateway=openCallbackGateway,renewIntervalMs=RENEW_INTERVAL_MS,firstAckMs=FIRST_ACK_MS,renewAckMs=RENEW_ACK_MS,statusDeadlineMs=STATUS_DEADLINE_MS,refusalRetryMs=REFUSAL_RETRY_MS,refusalGraceMs=REFUSAL_GRACE_MS,staleProofMaxMs=CALLBACK_STALE_PROOF_MAX_MS,onOutage=()=>{}}){
89
95
  if(typeof readCurrent!=='function')fail('CALLBACK_AUTHORIZATION_LOST','current callback status reader required');
90
96
  const routeGeneration=randomUUID(),options={leaseId,hostKey,callbackPort,requesterMachineId,routeGeneration};
91
97
  const binding=currentCallbackBinding(current,options),snapshot=JSON.stringify(binding),target=assertSshTarget(sshTarget),d=binding.descriptor;
92
98
  await verifyRuntime({descriptor:d,sshTarget:target,sshBin,env,run});
93
99
  const capability=randomBytes(32).toString('hex'),readinessNonce=randomBytes(16).toString('hex'),directory='/tmp/bb-callback-'+randomUUID(),socket=directory+'/callback.sock';
94
100
  const sshArgs=['-T','-o','BatchMode=yes','-o','ControlMaster=no','-o','ControlPath=none','--',target];
95
- let gateway,child,terminal,refreshTimer,statusAbort,pendingReady,pendingAck,closed=false,refreshing=false,cleanupPromise,exitResult;const exits=new Set();
101
+ let gateway,child,terminal,refreshTimer,retryTimer,staleTimer,statusAbort,pendingReady,pendingAck,closed=false,refreshing=false,cleanupPromise,exitResult;const exits=new Set();
96
102
  const reportExit=exit=>{exitResult=exit;for(const listener of exits)listener(exit)};
97
103
  const remote=async command=>{try{return await run(sshBin,[...sshArgs,command],{env,timeout:15_000,maxBuffer:4096})}catch{fail('CALLBACK_REMOTE_OPERATION_FAILED','owned callback operation failed');}};
98
104
  const retire=async()=>{
@@ -102,7 +108,7 @@ export async function openNativeCallbackTransport({current,leaseId,hostKey,callb
102
108
  await remote(command);
103
109
  };
104
110
  const close=()=>cleanupPromise??=(async()=>{
105
- closed=true;clearInterval(refreshTimer);statusAbort?.abort();pendingReady?.reject(Error('callback closed'));pendingAck?.reject(Error('callback closed'));await gateway?.close();
111
+ closed=true;clearInterval(refreshTimer);clearTimeout(retryTimer);clearTimeout(staleTimer);statusAbort?.abort();pendingReady?.reject(Error('callback closed'));pendingAck?.reject(Error('callback closed'));await gateway?.close();
106
112
  if(child){child.stdin.end();const term=setTimeout(()=>child.kill('SIGTERM'),3000);const kill=setTimeout(()=>child.kill('SIGKILL'),6000);term.unref();kill.unref();try{await terminal}finally{clearTimeout(term);clearTimeout(kill)}}
107
113
  try{await retire()}catch{fail('CALLBACK_CLEANUP_UNCERTAIN','owned callback cleanup could not be verified');}
108
114
  })();
@@ -130,33 +136,55 @@ export async function openNativeCallbackTransport({current,leaseId,hostKey,callb
130
136
  child.stdin.write(JSON.stringify({binding,capability,socket_path:socket,readiness_nonce:readinessNonce})+'\n');
131
137
  });
132
138
  const callbackUrl=callbackReadiness(ready,callbackPort);
133
- gateway.onClose(()=>{if(!closed){reportExit({reason:'callback_gateway_closed',code:'CALLBACK_GATEWAY_EXPIRED'});void close().catch(()=>{})}});
134
- let renewedAt=null,refusalPending=false;
139
+ let renewedAt=null,refusal=null,provenAt=null,provenWall=null,lastReadFailure=null;
140
+ const outage={episodes:0,failed_reads:0,longest_ms:0,last_failure:null,recovered:0};let outageActive=false;
141
+ // BOT-2957: both clocks, because a sleeping requester stops one of them on some platforms.
142
+ const staleFor=()=>provenAt===null?Infinity:Math.max(performance.now()-provenAt,Date.now()-provenWall);
143
+ const noteOutage=active=>{outage.longest_ms=Math.max(outage.longest_ms,Math.round(staleFor()));if(active&&!outageActive)outage.episodes++;else if(!active&&outageActive)outage.recovered++;outageActive=active;onOutage({...outage});};
144
+ // Ends the route without a positive answer. A pending refusal keeps its own name.
145
+ const stopUnproven=()=>{
146
+ if(closed)return;
147
+ const lost=refusal?{code:refusal.code??'CALLBACK_AUTHORIZATION_LOST',authorization_reason:boundedAuthorizationReason(refusal.authorizationReason)??'refused'}:{code:'CALLBACK_STATUS_UNREADABLE',authorization_reason:boundedAuthorizationReason('status_unreadable:'+(lastReadFailure??'no_answer'))??'status_unreadable'};
148
+ if(outageActive)noteOutage(true);reportExit({reason:'callback_authorization_lost',...lost});void close().catch(()=>{});
149
+ };
150
+ const proven=(atMonotonic,atWall)=>{provenAt=atMonotonic;provenWall=atWall;refusal=null;clearTimeout(staleTimer);staleTimer=setTimeout(stopUnproven,Math.max(0,staleProofMaxMs-(performance.now()-atMonotonic)));staleTimer.unref();};
151
+ const rereadSoon=()=>{clearTimeout(retryTimer);retryTimer=setTimeout(()=>{void refresh()},refusalRetryMs);retryTimer.unref();};
152
+ gateway.onClose(()=>{if(closed)return;if(staleFor()>=staleProofMaxMs){stopUnproven();return;}reportExit({reason:'callback_gateway_closed',code:'CALLBACK_GATEWAY_EXPIRED'});void close().catch(()=>{})});
135
153
  const refresh=async(first=false)=>{
136
- if(closed||refreshing)return;refreshing=true;
154
+ if(closed||refreshing)return;refreshing=true;clearTimeout(retryTimer);
155
+ // BOT-2957: every failure below names the step it failed in.
156
+ let step='status_read';
137
157
  try{
138
- statusAbort=new AbortController();let statusTimeout,renewed;
158
+ statusAbort=new AbortController();let statusTimeout,renewed=binding;const readAt=performance.now(),readWall=Date.now();
139
159
  try{
140
- const status=await new Promise((resolve,reject)=>{statusTimeout=setTimeout(()=>{statusAbort.abort();reject(Object.assign(Error('callback status deadline'),{transient:true}));},statusDeadlineMs);statusTimeout.unref();Promise.resolve().then(()=>readCurrent(statusAbort.signal)).then(resolve,reject).finally(()=>clearTimeout(statusTimeout));});
141
- if(closed)return;renewed=currentCallbackBinding(status,options);
160
+ const status=await new Promise((resolve,reject)=>{statusTimeout=setTimeout(()=>{statusAbort.abort();reject(Object.assign(Error('callback status deadline'),{transient:true,readFailure:'deadline'}));},statusDeadlineMs);statusTimeout.unref();Promise.resolve().then(()=>readCurrent(statusAbort.signal)).then(resolve,reject).finally(()=>clearTimeout(statusTimeout));});
161
+ if(closed)return;step='status_answer';renewed=currentCallbackBinding(status,options);
142
162
  if(JSON.stringify(renewed)!==snapshot)refuse(tokenReason('binding_changed',changedBindingField(binding,renewed)),'callback context changed');
143
- refusalPending=false;
163
+ if(outageActive)noteOutage(false);proven(readAt,readWall);lastReadFailure=null;
144
164
  }catch(error){
145
- // A failed status READ is not an authorization answer: keep the live generation (relay and
146
- // gateway still expire it at 30 s) and retry on the next tick while inside the grace window.
147
- if(!first&&error?.transient===true&&renewedAt!==null&&performance.now()-renewedAt<transientGraceMs){const retry=setTimeout(()=>{void refresh()},transientRetryMs);retry.unref();return;}
148
- if(error?.transient===true&&!error.code&&!first)error.code='CALLBACK_STATUS_UNREADABLE';
149
- // BOT-2880: a definitive answer stops the child only if one re-read repeats it, while the live
150
- // generation can still carry that re-read. The opening renewal and an expired grace fail closed.
151
- if(!first&&error?.transient!==true&&!refusalPending&&renewedAt!==null&&performance.now()-renewedAt<transientGraceMs){refusalPending=true;const retry=setTimeout(()=>{void refresh()},transientRetryMs);retry.unref();return;}
152
- throw error;
165
+ // The opening renewal never rides on anything: it needs a fresh positive answer.
166
+ if(first){if(error?.transient===true&&!error.authorizationReason)error.authorizationReason=boundedAuthorizationReason('status_unreadable:'+(error.readFailure??'no_answer'))??'status_unreadable';throw error;}
167
+ if(error?.transient===true){
168
+ lastReadFailure=typeof error.readFailure==='string'?error.readFailure:'no_answer';
169
+ // An answered refusal is never extended on stale proof: its re-read has only the live generation.
170
+ if(refusal){if(performance.now()-renewedAt<refusalGraceMs){rereadSoon();return;}stopUnproven();return;}
171
+ // BOT-2957: an unanswered read is not an authorization answer. Renew on the last positive
172
+ // answer, only until the bound since that read, and never on any read after a refusal.
173
+ if(staleFor()>=staleProofMaxMs){stopUnproven();return;}
174
+ outage.failed_reads++;outage.last_failure=lastReadFailure;noteOutage(true);
175
+ }else{
176
+ // BOT-2880: a definitive answer stops the child only if one re-read repeats it, while the live
177
+ // generation can still carry that re-read. An expired grace fails closed.
178
+ if(!refusal&&performance.now()-renewedAt<refusalGraceMs){refusal=error;rereadSoon();return;}
179
+ throw error;
180
+ }
153
181
  }
154
- const sentAt=performance.now();
182
+ step='renewal_ack';const sentAt=performance.now();
155
183
  await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Object.assign(Error('callback renewal acknowledgement deadline'),first?{}:{code:'CALLBACK_RENEWAL_ACK_TIMEOUT',transport:true})),first?firstAckMs:renewAckMs);timeout.unref();pendingAck={resolve:value=>{clearTimeout(timeout);resolve(value)},reject:error=>{clearTimeout(timeout);reject(error)}};child.stdin.write(JSON.stringify({binding:renewed,renew:true})+'\n');});
156
- if(closed)return;renewedAt=sentAt;gateway.refresh(Object.freeze({snapshot}),30_000-(performance.now()-sentAt));
184
+ if(closed)return;renewedAt=sentAt;step='gateway_refresh';gateway.refresh(Object.freeze({snapshot}),Math.min(30_000-(performance.now()-sentAt),staleProofMaxMs-staleFor()));
157
185
  }
158
186
  // The first renewal carries the consuming-namespace probe: its uncoded failure is readiness.
159
- catch(error){if(!closed){const authorizationReason=boundedAuthorizationReason(error?.authorizationReason);reportExit({reason:error?.transport===true?'callback_renewal_unacknowledged':'callback_authorization_lost',code:error?.code??(first?'CALLBACK_NOT_READY':'CALLBACK_AUTHORIZATION_LOST'),...(authorizationReason?{authorization_reason:authorizationReason}:{})});void close().catch(()=>{});}}
187
+ catch(error){if(!closed){const authorizationReason=boundedAuthorizationReason(error?.authorizationReason)??(error?.transport===true?null:'renewal_failed:'+step);reportExit({reason:error?.transport===true?'callback_renewal_unacknowledged':'callback_authorization_lost',code:error?.code??(first?'CALLBACK_NOT_READY':'CALLBACK_AUTHORIZATION_LOST'),...(authorizationReason?{authorization_reason:authorizationReason}:{})});void close().catch(()=>{});}}
160
188
  finally{refreshing=false}
161
189
  };
162
190
  await refresh(true);if(closed||!gateway.isLive())throw Object.assign(Error('callback route not ready after readiness'),{code:exitResult?.code??'CALLBACK_NOT_READY',...(exitResult?.authorization_reason?{authorizationReason:exitResult.authorization_reason}:{})});
package/src/stack.mjs CHANGED
@@ -53,7 +53,7 @@ import { checkReadoptedStackMounts } from "./stack-mount-health.mjs";
53
53
  import { machineUuid } from "./machine-id.mjs";
54
54
  import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle, validateBundleIncludePath } from "./stack-bundle.mjs";
55
55
  import { assertSshTarget, establishSshTunnel } from "./stack-tunnel.mjs";
56
- import { NATIVE_CALLBACK_TRANSPORT, boundedAuthorizationReason, openNativeCallbackTransport } from "./stack-native-callback.mjs";
56
+ import { CALLBACK_STALE_PROOF_MAX_MS, NATIVE_CALLBACK_TRANSPORT, boundedAuthorizationReason, openNativeCallbackTransport } from "./stack-native-callback.mjs";
57
57
  import { FORWARD_ATTESTATION_ENV, startForwardAttestation, readForwardAttestation } from "./stack-forward-attestation.mjs";
58
58
  import { stackServicesForForwards } from "./stack-service-discovery.mjs";
59
59
  import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
@@ -381,7 +381,13 @@ ${bold("REMOTE LEASES")} (BOT-2142, stack run)
381
381
  A refusal must repeat on one re-read before it stops the child (BOT-2880), and
382
382
  callback_authorization_reason names the refused check, e.g. requester_host_disabled
383
383
  (the requester machine's own Docker host was set to disabled: use maintenance),
384
- lease_state:reaping, binding_changed:descriptor.producer_pid
384
+ lease_state:reaping, binding_changed:descriptor.producer_pid.
385
+ A control plane that does not answer is not a refusal (BOT-2957): the route rides on
386
+ its last positive answer for up to 180 s. Past that, callback_cause is
387
+ CALLBACK_STATUS_UNREADABLE and the reason names the failed read, e.g.
388
+ status_unreadable:deadline, status_unreadable:http_503, status_unreadable:rpc_error.
389
+ callback_outage on the receipt counts the gaps a run rode through.
390
+ 6 LEASE_HEARTBEAT_UNANSWERED every heartbeat touch went unanswered for 180 s; child stopped, lease reaped
385
391
  5 CALLBACK_PORT_BUSY --callback-port is already listening on the serving host (another run or
386
392
  service), the host refused this run's -R because another run took the
387
393
  port first, or another listener shares the port; this run holds no -R —
@@ -408,6 +414,11 @@ ${bold("EXAMPLE")}
408
414
  botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
409
415
 
410
416
  const STACK_COMMANDS = new Set(["up", "run", "status", "touch", "done", "cancel"]);
417
+ // BOT-2957: a heartbeat touch that goes unanswered is retried this soon, each with its own deadline,
418
+ // for up to CALLBACK_STALE_PROOF_MAX_MS. Past that the run stops with this code.
419
+ export const STACK_HEARTBEAT_UNANSWERED_CODE = "LEASE_HEARTBEAT_UNANSWERED";
420
+ const HEARTBEAT_RETRY_MS = 15_000;
421
+ const HEARTBEAT_TOUCH_DEADLINE_MS = 15_000;
411
422
 
412
423
  /** Scoped help is assembled from the canonical text, so flags cannot drift. */
413
424
  export function stackCommandHelp(command) {
@@ -1249,6 +1260,23 @@ async function renewStackSession(auth, { cwd, renew }) {
1249
1260
  return withSource({ "x-agent-api-key": token }, credential.source, credential.identity);
1250
1261
  }
1251
1262
 
1263
+ // BOT-2957: how long one call waits on a credential renewal, shared or its own. The renewal itself
1264
+ // takes no signal, so without this a stalled one would hold cleanup past --reap-timeout. The call
1265
+ // then reports no answer (never a refusal), and the renewal keeps running for whoever comes next.
1266
+ const RENEWAL_WAIT_MS = 30_000;
1267
+ const RENEWAL_UNFINISHED = Symbol("renewal unfinished");
1268
+ function untilRenewalWaitEnds(renewal, signal, waitMs) {
1269
+ if (signal?.aborted) return Promise.resolve(RENEWAL_UNFINISHED);
1270
+ return new Promise((resolve, reject) => {
1271
+ let timer = null;
1272
+ const done = (settle, value) => { clearTimeout(timer); signal?.removeEventListener?.("abort", stop); settle(value); };
1273
+ const stop = () => done(resolve, RENEWAL_UNFINISHED);
1274
+ signal?.addEventListener?.("abort", stop, { once: true });
1275
+ timer = setTimeout(stop, waitMs);
1276
+ renewal.then((value) => done(resolve, value), (error) => done(reject, error));
1277
+ });
1278
+ }
1279
+
1252
1280
  /**
1253
1281
  * BOT-1750 (Codex R10 P2): a stack RPC caller that transparently recovers from a
1254
1282
  * server-rejected cached session ONCE — the owner-scoped management commands
@@ -1260,7 +1288,7 @@ async function renewStackSession(auth, { cwd, renew }) {
1260
1288
  export function retryingStackCall({
1261
1289
  callTool = callToolJson, authProvider = stackAuthHeader,
1262
1290
  refresh = refreshRejectedCacheAuth, cwd = () => process.cwd(),
1263
- auth: seedAuth,
1291
+ auth: seedAuth, renewalWaitMs = RENEWAL_WAIT_MS,
1264
1292
  } = {}) {
1265
1293
  // A pre-resolved `auth` seed lets a caller that already resolved the credential
1266
1294
  // (runStackLifecycle short-circuits its own await) skip init() — avoiding an extra
@@ -1272,13 +1300,30 @@ export function retryingStackCall({
1272
1300
  // rejected is a new rotation, not the same failure — so it may renew again. A renewal
1273
1301
  // whose credential is itself rejected stays disarmed (never a per-call mint loop).
1274
1302
  let renewalArmed = true;
1303
+ let pendingRenewal = null;
1275
1304
  return {
1276
1305
  async init() { if (auth === undefined) auth = await authProvider(); return auth; },
1277
1306
  async call(name, args, callOptions = {}) {
1278
1307
  let r = await callTool(name, args, { ...callOptions, auth });
1279
- if (renewalArmed && !r?.ok && r?.auth) {
1280
- renewalArmed = false;
1281
- const fresh = await refresh(auth, { cwd: cwd() });
1308
+ if (!r?.ok && r?.auth && (renewalArmed || pendingRenewal)) {
1309
+ // BOT-2957: a 401 while a renewal is still running says nothing yet: wait for that renewal
1310
+ // (the caller's own deadline bounds the wait). Reading it as an answer fenced a healthy run.
1311
+ if (!pendingRenewal) {
1312
+ renewalArmed = false;
1313
+ const rejected = auth;
1314
+ const renewal = (async () => {
1315
+ // A renewal the backend could not answer was not refused: keep it available, or the
1316
+ // first read after an outage is a dead credential's 401 that reads as a refusal.
1317
+ // The renewal adopts its own result, because every caller may have stopped waiting by then.
1318
+ try { const renewed = await refresh(rejected, { cwd: cwd() }); if (renewed) auth = renewed; return renewed; }
1319
+ catch (error) { if (classifyStackAuthError(error) === EXIT.BACKEND) renewalArmed = true; throw error; }
1320
+ })();
1321
+ pendingRenewal = renewal;
1322
+ const settled = () => { if (pendingRenewal === renewal) pendingRenewal = null; };
1323
+ renewal.then(settled, settled);
1324
+ }
1325
+ const fresh = await untilRenewalWaitEnds(pendingRenewal, callOptions.signal, renewalWaitMs);
1326
+ if (fresh === RENEWAL_UNFINISHED) return { ok: false, status: null, error: "transport: credential renewal still running" };
1282
1327
  if (fresh) { auth = fresh; r = await callTool(name, args, { ...callOptions, auth }); }
1283
1328
  }
1284
1329
  if (r?.ok) renewalArmed = true;
@@ -3756,7 +3801,8 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
3756
3801
  const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
3757
3802
  const withPortBlock = incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
3758
3803
  const withWebUrl = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3759
- const final = incidentBox.stopSignal && withWebUrl ? { ...withWebUrl, stopSignal: incidentBox.stopSignal } : withWebUrl;
3804
+ const withStopSignal = incidentBox.stopSignal && withWebUrl ? { ...withWebUrl, stopSignal: incidentBox.stopSignal } : withWebUrl;
3805
+ const final = incidentBox.callbackOutage && withStopSignal ? { ...withStopSignal, callbackOutage: incidentBox.callbackOutage } : withStopSignal;
3760
3806
  // BOT-2467: non-enumerable, so the receipt fields and result comparisons never see it.
3761
3807
  if (final && typeof final === "object" && incidentBox.outcomeChannel) {
3762
3808
  Object.defineProperty(final, OUTCOME_CHANNEL, { value: incidentBox.outcomeChannel, enumerable: false });
@@ -3795,7 +3841,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3795
3841
  getAdmission: (args) => rpc.call("get_stack_lease_admission", args),
3796
3842
  get: (leaseId, { signal } = {}) => rpc.call("get_stack_lease", { lease_id: leaseId }, { signal }),
3797
3843
  // BOT-1803: a heartbeat carries the measured client use when there is one.
3798
- touch: (leaseId, usage) => rpc.call("touch_stack_lease", { lease_id: leaseId, ...(usage ? { usage } : {}) }),
3844
+ touch: (leaseId, usage, { signal } = {}) => rpc.call("touch_stack_lease", { lease_id: leaseId, ...(usage ? { usage } : {}) }, { signal }),
3799
3845
  // BOT-1803: `{ idle: true }` idles an ACTIVE lease instead of reaping it.
3800
3846
  release: (leaseId, signal, { idle = false } = {}) => rpc.call("release_stack_lease", { lease_id: leaseId, disposition: "destroy", ...(idle ? { idle: true } : {}) }, { signal }),
3801
3847
  // BOT-2400: keep a queued lease's place when the park deadline passes.
@@ -3871,6 +3917,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3871
3917
  let childResult = null;
3872
3918
  let connectionFile = null;
3873
3919
  let heartbeat = null;
3920
+ let heartbeatRetry = null;
3921
+ let heartbeatUnanswered = false;
3874
3922
  let retention = null;
3875
3923
  let hardTimer = null;
3876
3924
  let cleanupResult = null;
@@ -4915,17 +4963,13 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4915
4963
  if (useNativeCallback) {
4916
4964
  try {
4917
4965
  nativeCallback = await openCallback({ current: current.data, leaseId, hostKey: placement.hostKey, callbackPort: opts.callbackPort, requesterMachineId: machineUuidFn(), sshTarget,
4918
- readCurrent: async (signal) => { let observed; try { observed = await api.get(leaseId, { signal }); } catch (error) { throw Object.assign(new Error(`current callback status unreadable (${error?.message ?? error})`), { transient: true }); } if (!observed?.ok) {
4919
- // Only an unanswered read is transient (transport, 5xx, 408/429, unreadable body). A 401/403
4920
- // that survived the auth refresh or a typed JSON-RPC refusal is a definitive answer.
4921
- // BOT-2847: the shared classification (stack-release-retry.mjs) the cleanup release retries on.
4922
- const transient = observed == null || isTransientStackRpcResult(observed);
4923
- throw Object.assign(new Error(transient ? "current callback status unreadable" : "current callback authorization refused"), transient ? { transient: true } : { code: "CALLBACK_AUTHORIZATION_LOST", authorizationReason: refusedRpcReason(observed) });
4924
- }
4925
- // BOT-2790 AC-6 (BOT-2828): a backend RPC_ERROR (e.g. a statement timeout) is an unanswered read.
4926
- if (isTransientStackRpcResult(observed)) throw Object.assign(new Error("current callback status unreadable (RPC_ERROR)"), { transient: true });
4927
- if (observed.data?.success !== true) throw Object.assign(new Error("current callback authorization unavailable"), { code: "CALLBACK_AUTHORIZATION_LOST", authorizationReason: refusedRpcReason(observed) });
4928
- return observed.data; },
4966
+ readCurrent: (signal) => readCallbackStatus(api, leaseId, signal),
4967
+ onOutage: (outage) => {
4968
+ const previous = incidentBox.callbackOutage;
4969
+ incidentBox.callbackOutage = outage;
4970
+ if (outage.episodes > (previous?.episodes ?? 0)) stderrLine(`stack: the control plane is not answering (${outage.last_failure}); the callback route stays on its last positive answer for up to ${CALLBACK_STALE_PROOF_MAX_MS / 1000} s`);
4971
+ if (outage.recovered > (previous?.recovered ?? 0)) stderrLine("stack: the control plane answered again; the callback route stayed up");
4972
+ },
4929
4973
  });
4930
4974
  nativeCallback.onExit((exit) => { if (childResult) return; callbackLost = exit; fencing = true; stopChild("SIGTERM", EXIT.LEASE_FAILED); });
4931
4975
  placement.callbackTransport = NATIVE_CALLBACK_TRANSPORT;
@@ -5036,35 +5080,80 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
5036
5080
  // BOT-1803 (AC-12): a co-located lease's heartbeat carries the measured client use; a
5037
5081
  // remote one (or an unreadable measurement) sends no usage at all.
5038
5082
  const usageLease = { ...current.data, execution_mode: current.data.execution_mode ?? (servedRemote ? "remote" : "local") };
5039
- heartbeat = clock.setInterval(async () => {
5040
- if (fencing || childResult) return;
5041
- let usage = null;
5042
- if (!servedRemote) { try { usage = await measureUsage(usageLease); } catch { usage = null; } }
5043
- if (fencing || childResult) return;
5044
- // BOT-2237: a touch whose renewal throws is a failed heartbeat (fence), never an
5045
- // unhandled rejection from a timer callback.
5046
- let touched;
5047
- try { touched = usage ? await api.touch(leaseId, usage) : await api.touch(leaseId); } catch (error) { touched = { ok: false, error: String(error?.message ?? error) }; }
5048
- if (touched?.ok && isPreemptedAnswer(touched.data)) {
5049
- // BOT-1803 (AC-11): the control plane reclaimed this lease's capacity. Stop the
5050
- // child and end typed — never the generic lost-lease wording.
5051
- preempted = { state: touched.data.state ?? "reaping", reap_reason: touched.data.reap_reason ?? "preempted" };
5052
- fencing = true;
5053
- stopChild("SIGTERM", EXIT.LEASE_FAILED);
5054
- return;
5055
- }
5056
- if (!touched?.ok || !touched?.data?.success) {
5057
- fencing = true;
5058
- stopChild("SIGTERM", EXIT.LEASE_FAILED);
5059
- return;
5083
+ const heartbeatNow = adapters.now ?? Date.now;
5084
+ let heartbeating = false;
5085
+ let touchUnansweredSince = null;
5086
+ const touchLease = async () => {
5087
+ if (fencing || childResult || heartbeating) return;
5088
+ heartbeating = true;
5089
+ try {
5090
+ let usage = null;
5091
+ if (!servedRemote) { try { usage = await measureUsage(usageLease); } catch { usage = null; } }
5092
+ if (fencing || childResult) return;
5093
+ // BOT-2237: a touch whose renewal throws is a failed heartbeat (fence), never an
5094
+ // unhandled rejection from a timer callback.
5095
+ let touched;
5096
+ let unanswered = false;
5097
+ const touchAbort = new AbortController();
5098
+ let touchTimer = null;
5099
+ // The deadline ends the whole operation, not only its fetch: a credential renewal inside the
5100
+ // touch takes no signal, and a touch that never settled would stop every later heartbeat.
5101
+ const touchDeadline = new Promise((resolve) => {
5102
+ touchTimer = clock.setTimeout(() => { touchAbort.abort("touch-deadline"); resolve({ ok: false, status: null, error: "transport: touch deadline" }); }, HEARTBEAT_TOUCH_DEADLINE_MS);
5103
+ touchTimer?.unref?.();
5104
+ });
5105
+ try {
5106
+ const touching = Promise.resolve(api.touch(leaseId, usage ?? undefined, { signal: touchAbort.signal }));
5107
+ touching.catch(() => {}); // a late failure after the deadline has nobody left to hear it
5108
+ touched = await Promise.race([touching, touchDeadline]);
5109
+ unanswered = isTransientStackRpcResult(touched);
5110
+ } catch (error) {
5111
+ touched = { ok: false, error: String(error?.message ?? error) };
5112
+ unanswered = error?.code === "session_mint_failed";
5113
+ } finally {
5114
+ clock.clearTimeout(touchTimer);
5115
+ }
5116
+ if (fencing || childResult) return;
5117
+ if (touched?.ok && isPreemptedAnswer(touched.data)) {
5118
+ // BOT-1803 (AC-11): the control plane reclaimed this lease's capacity. Stop the
5119
+ // child and end typed — never the generic lost-lease wording.
5120
+ preempted = { state: touched.data.state ?? "reaping", reap_reason: touched.data.reap_reason ?? "preempted" };
5121
+ fencing = true;
5122
+ stopChild("SIGTERM", EXIT.LEASE_FAILED);
5123
+ return;
5124
+ }
5125
+ if (!touched?.ok || !touched?.data?.success) {
5126
+ // BOT-2957: an unanswered touch says nothing about the lease, and one of them used to
5127
+ // stop a healthy child during a control-plane stall. Only a real answer fences at once.
5128
+ if (unanswered) {
5129
+ touchUnansweredSince ??= heartbeatNow();
5130
+ if (heartbeatNow() - touchUnansweredSince < CALLBACK_STALE_PROOF_MAX_MS) {
5131
+ clock.clearTimeout(heartbeatRetry);
5132
+ heartbeatRetry = clock.setTimeout(() => touchLease(), HEARTBEAT_RETRY_MS);
5133
+ heartbeatRetry?.unref?.();
5134
+ return;
5135
+ }
5136
+ heartbeatUnanswered = true;
5137
+ }
5138
+ fencing = true;
5139
+ stopChild("SIGTERM", EXIT.LEASE_FAILED);
5140
+ return;
5141
+ }
5142
+ touchUnansweredSince = null;
5143
+ clock.clearTimeout(heartbeatRetry);
5144
+ heartbeatRetry = null;
5145
+ keepAlive();
5146
+ } finally {
5147
+ heartbeating = false;
5060
5148
  }
5061
- keepAlive();
5062
- }, cadenceMs);
5149
+ };
5150
+ heartbeat = clock.setInterval(touchLease, cadenceMs);
5063
5151
  heartbeat.unref?.();
5064
5152
  if (opts.hardTtl) hardTimer = clock.setTimeout(() => stopChild("SIGTERM", EXIT.TIMEOUT), opts.hardTtl * 1000);
5065
5153
  hardTimer?.unref?.();
5066
5154
  childResult = await waitForChild(child);
5067
5155
  if (heartbeat) clock.clearInterval(heartbeat);
5156
+ if (heartbeatRetry) clock.clearTimeout(heartbeatRetry);
5068
5157
  if (retention) { clock.clearInterval(retention); retention = null; }
5069
5158
  if (hardTimer) clock.clearTimeout(hardTimer);
5070
5159
  await closeTunnel();
@@ -5101,7 +5190,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
5101
5190
  };
5102
5191
  }
5103
5192
  const exitCode = receivedSignal ? SIGNAL_EXIT[receivedSignal] : callbackCleanupError ? EXIT.CLEANUP_FAILED : forcedStopCode ?? (childResult.code !== 0 ? childResult.code : callbackCleanupError || !cleanup.ok ? EXIT.CLEANUP_FAILED : 0);
5104
- return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, ...(callbackCleanupError ? { code: callbackCleanupError } : {}), fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) };
5193
+ return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, ...(callbackCleanupError ? { code: callbackCleanupError } : heartbeatUnanswered ? { code: STACK_HEARTBEAT_UNANSWERED_CODE } : {}), fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) };
5105
5194
  } catch (error) {
5106
5195
  const cleanup = await requestCleanup();
5107
5196
  // BOT-1750 (Codex R10 P2): a thrown auth/self-heal failure (e.g. a refresh that
@@ -5203,6 +5292,8 @@ export function runReceiptFields(result) {
5203
5292
  ...(result.callbackCause ? { callback_cause: result.callbackCause } : {}),
5204
5293
  // BOT-2880: which check refused a CALLBACK_AUTHORIZATION_LOST (a bounded token, never a value).
5205
5294
  ...(result.callbackAuthorizationReason ? { callback_authorization_reason: result.callbackAuthorizationReason } : {}),
5295
+ // BOT-2957: the control-plane gaps this run's callback route rode through (never on a run that had none).
5296
+ ...(result.callbackOutage ? { callback_outage: result.callbackOutage } : {}),
5206
5297
  // BOT-2780: the reserved requester origin generated auth links name (never a credential).
5207
5298
  ...(result.execution?.requesterOrigin ? { requester_origin: result.execution.requesterOrigin } : {}),
5208
5299
  // BOT-2788: "previous" (an idle stack's origin re-reserved), "previous_port_busy" or "new".
@@ -5261,6 +5352,44 @@ export async function cmdRun(opts, childArgv, {
5261
5352
  * receipt deliberately does not carry (which wait a timeout ended and the original
5262
5353
  * lease fence). Never emitted.
5263
5354
  */
5355
+ // BOT-2957: what the callback status read could not get, as a bounded token for
5356
+ // callback_authorization_reason (status_unreadable:<token>).
5357
+ function callbackReadFailure(observed) {
5358
+ if (observed == null) return "no_answer";
5359
+ if (typeof observed.status === "number" && observed.status >= 300) return `http_${observed.status}`;
5360
+ if (observed.ok && observed.data?.code === "RPC_ERROR") return "rpc_error";
5361
+ if (observed.error === "invalid_json") return "invalid_json";
5362
+ if (!observed.ok && observed.status == null) return "transport";
5363
+ return "malformed_answer";
5364
+ }
5365
+ const unansweredCallbackRead = (failure) => Object.assign(new Error(`current callback status unreadable (${failure})`), { transient: true, readFailure: failure });
5366
+ const refusedCallbackRead = (observed) => Object.assign(new Error("current callback authorization refused"), { code: "CALLBACK_AUTHORIZATION_LOST", authorizationReason: refusedRpcReason(observed) });
5367
+
5368
+ // One callback status read. It throws one of two things. Only an answer is a refusal: a 401/403
5369
+ // that survived the auth refresh, a typed JSON-RPC or tool refusal, or a credential that cannot be
5370
+ // renewed. Everything else is unanswered (transport, 5xx/408/429, a backend RPC_ERROR such as a
5371
+ // statement timeout, an unreadable or untyped body; BOT-2790 AC-6, BOT-2847, BOT-2957).
5372
+ export async function readCallbackStatus(api, leaseId, signal) {
5373
+ let observed;
5374
+ try {
5375
+ observed = await api.get(leaseId, { signal });
5376
+ } catch (error) {
5377
+ const authClass = classifyStackAuthError(error);
5378
+ if (authClass === EXIT.AUTH) throw refusedCallbackRead({ code: error.code });
5379
+ throw unansweredCallbackRead(authClass === EXIT.BACKEND ? "session_mint" : "transport");
5380
+ }
5381
+ if (observed == null || isTransientStackRpcResult(observed)) throw unansweredCallbackRead(callbackReadFailure(observed));
5382
+ if (!observed.ok) {
5383
+ if (observed.auth || observed.code) throw refusedCallbackRead(observed);
5384
+ throw unansweredCallbackRead(callbackReadFailure(observed));
5385
+ }
5386
+ if (observed.data?.success !== true) {
5387
+ if (typeof observed.data?.code === "string" && observed.data.code) throw refusedCallbackRead(observed);
5388
+ throw unansweredCallbackRead(callbackReadFailure(observed));
5389
+ }
5390
+ return observed.data;
5391
+ }
5392
+
5264
5393
  // BOT-2880: a definitive RPC refusal of the callback status read names itself by the backend's
5265
5394
  // typed code or its HTTP status. The shared bound drops anything that is not a plain token.
5266
5395
  function refusedRpcReason(observed) {
@@ -155,7 +155,7 @@ export const ERROR_CODES = Object.freeze({
155
155
  "launchctl kickstart -k gui/$(id -u)/{label}"),
156
156
  mint_backing_off: entry("human", "repair",
157
157
  "Staging sign-in for {persona} keeps failing, so the job is backing off.",
158
- "The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
158
+ "The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; mint_retries_exhausted = a staging Auth outage, so wait for the next attempt; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
159
159
  "grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
160
160
  last_tick_failed: entry("human", "repair",
161
161
  "The pool job's last tick had sign-in (mint) failures.",
@@ -285,6 +285,10 @@ export const ERROR_CODES = Object.freeze({
285
285
  "Staging Auth rejected the persona password.",
286
286
  "Seed drift: the staging persona was re-seeded with another password, or the env file is stale.",
287
287
  "bb staging-pool mint-one --persona {persona} # after re-running the staging persona seed or updating the env file"),
288
+ password_grant_failed: entry("human", "repair",
289
+ "Staging Auth answered the first sign-in step with an unexpected status, which is not a credential refusal.",
290
+ "A blocked request, a banned or unconfirmed user, or a GoTrue change; {log} has the status and error code.",
291
+ "grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
288
292
  no_verified_totp_factor: entry("human", "repair",
289
293
  "The persona has no verified TOTP factor on staging.",
290
294
  "Seed drift: the staging seed job has not enrolled the factor.",
@@ -303,7 +307,7 @@ export const ERROR_CODES = Object.freeze({
303
307
  "grep mint_failed {log} | tail -1 # report this code"),
304
308
  mint_retries_exhausted: entry("human", "repair",
305
309
  "The sign-in gave up after its retries.",
306
- "Transient Auth or network failures; {log} names the last one.",
310
+ "A staging Auth outage (for example a Cloudflare 52x while staging restarts) or network failures; {log} names the last one.",
307
311
  "grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
308
312
  not_aal2: entry("human", "repair",
309
313
  "MFA verify returned a session that is not AAL2.",
@@ -25,7 +25,9 @@ export class SignInError extends Error {
25
25
  }
26
26
  }
27
27
 
28
- const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
28
+ // 520-524 are Cloudflare's own answers while the origin is down or unreachable (BOT-2947):
29
+ // Auth never saw the request or never answered it, so they say nothing about the credentials.
30
+ const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524]);
29
31
  const REVOKED_ERROR_CODES = new Set(["session_not_found", "bad_jwt", "refresh_token_not_found"]);
30
32
 
31
33
  function describeFailure(response) {
@@ -75,6 +77,7 @@ export function createGoTrueTotpSignIn({
75
77
  const isTransient = (response) => response.status === 0 || RETRYABLE_STATUSES.has(response.status);
76
78
  const isRevoked = (response) =>
77
79
  (response.status === 401 || response.status === 403) && REVOKED_ERROR_CODES.has(response.body?.error_code);
80
+ const isCredentialRefusal = (response) => response.status === 400 && response.body?.error_code === "invalid_credentials";
78
81
 
79
82
  function assertAal2ForPersona(storedSession, persona) {
80
83
  const claims = decodeJwtClaims(storedSession.access_token);
@@ -108,7 +111,12 @@ export function createGoTrueTotpSignIn({
108
111
  await retry(`password grant ${describeFailure(password)}`);
109
112
  continue;
110
113
  }
111
- throw new SignInError("password_rejected", `Password grant refused: ${describeFailure(password)}.`, {
114
+ if (isCredentialRefusal(password)) {
115
+ throw new SignInError("password_rejected", `Password grant refused: ${describeFailure(password)}.`, {
116
+ status: password.status,
117
+ });
118
+ }
119
+ throw new SignInError("password_grant_failed", `Password grant failed: ${describeFailure(password)}.`, {
112
120
  status: password.status,
113
121
  });
114
122
  }