@botbuddy/cli 1.64.26 → 1.64.28

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.26",
3
- "source_commit": "3c0c0a5b3043740906a1d230d2e52bfb040af538",
4
- "source_sha256": "37ab46e615c285d102088320e3106cbc968d5c42f345e48051980173eaeffba2",
2
+ "release_version": "1.64.28",
3
+ "source_commit": "68e4229ab423ab6cb69a029a794cd6f9bbdab501",
4
+ "source_sha256": "e456c16060302ab7b6d5642d057985316384a24e806ba400dcdba74836a5c886",
5
5
  "target": "darwin-arm64",
6
- "sha256": "1c61bc84457da670166622fe9ef8fc7295a1f2286591e7cb8850aed78023858b"
6
+ "sha256": "0234fb211982fc176db2da07f2c8be94d5c33f93e11ad2fc07e22c32ed62a8bd"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.26",
3
- "source_commit": "3c0c0a5b3043740906a1d230d2e52bfb040af538",
4
- "source_sha256": "37ab46e615c285d102088320e3106cbc968d5c42f345e48051980173eaeffba2",
2
+ "release_version": "1.64.28",
3
+ "source_commit": "68e4229ab423ab6cb69a029a794cd6f9bbdab501",
4
+ "source_sha256": "e456c16060302ab7b6d5642d057985316384a24e806ba400dcdba74836a5c886",
5
5
  "target": "linux-x64",
6
- "sha256": "718ad319db010d21e1ac51da283f7d64d2cd9c9313abbc16912d1e560426be2e"
6
+ "sha256": "402e8e84890e6095422c1e0ffa85639e9842fa3ffd3f5abd6d28a80f664b34b2"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.64.26",
4
- "source_commit": "3c0c0a5b3043740906a1d230d2e52bfb040af538",
5
- "source_sha256": "37ab46e615c285d102088320e3106cbc968d5c42f345e48051980173eaeffba2",
3
+ "release_version": "1.64.28",
4
+ "source_commit": "68e4229ab423ab6cb69a029a794cd6f9bbdab501",
5
+ "source_sha256": "e456c16060302ab7b6d5642d057985316384a24e806ba400dcdba74836a5c886",
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": "1c61bc84457da670166622fe9ef8fc7295a1f2286591e7cb8850aed78023858b",
11
+ "sha256": "0234fb211982fc176db2da07f2c8be94d5c33f93e11ad2fc07e22c32ed62a8bd",
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": "718ad319db010d21e1ac51da283f7d64d2cd9c9313abbc16912d1e560426be2e",
18
+ "sha256": "402e8e84890e6095422c1e0ffa85639e9842fa3ffd3f5abd6d28a80f664b34b2",
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.26",
3
+ "version": "1.64.28",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -139,6 +139,16 @@ export const EXIT = Object.freeze({
139
139
  CLEANUP_FAILED: 8, // child completed but signed physical reap was not proven
140
140
  });
141
141
 
142
+ /**
143
+ * BOT-3094: BOT-2968's reap reasons for a queued lease whose bound session can never promote it.
144
+ * The place is lost either way, so a parked `stack up` / `stack run` requests a new lease ONCE
145
+ * from a freshly read session; the server binds it only to a current work session.
146
+ */
147
+ export const SESSION_REAP_REASONS = new Set(["session_ended", "session_stale", "session_invalid"]);
148
+
149
+ /** BOT-3094: cancel_unclaimed_stack_lease refusals that mean "claimed or not cancellable": reap nothing. */
150
+ const CANCEL_REFUSAL_CODES = new Set(["PROVISION_ALREADY_CLAIMED", "PROVISION_AUTHORITY_HISTORY_FENCE", "LEASE_NOT_PROVISIONING", "PROVISION_JOB_NOT_FOUND"]);
151
+
142
152
  const STACK_READINESS_HELP = `${bold("READINESS")}
143
153
  An active lease proves infrastructure activation. It does not prove application readiness:
144
154
  the child may still be initializing its schema, runtime configuration, or worktree locks.
@@ -162,7 +172,7 @@ ${bold("USAGE")}
162
172
  include the owner's attested local/remote forwards
163
173
  botbuddy stack touch <lease_id> Bump the idle clock (and report measured use) so the reaper doesn't stop it
164
174
  botbuddy stack done <lease_id> Release (done): idle the stack for re-adoption (--reap tears it down)
165
- botbuddy stack cancel <lease_id> Give a queued lease's place up (= done --reap; BOT-2400)
175
+ botbuddy stack cancel <lease_id> Give a queued lease's place up; never reaps a claimed lease (BOT-3094)
166
176
  botbuddy stack run [options] -- <executable> [args...]
167
177
  Own one Helper-backed test/fix batch end-to-end
168
178
 
@@ -443,7 +453,8 @@ export function stackCommandHelp(command) {
443
453
  const selected = all.filter((section) => headings[command]?.some((heading) => section.startsWith(bold(heading))));
444
454
  const usage = command === "run" ? "[options] -- <executable> [args...]"
445
455
  : command === "up" ? "[options]" : "<lease_id> [options]";
446
- const detail = command === "cancel" ? "\n Cancel gives up a queued lease's place (equivalent to done --reap).\n" : "";
456
+ const detail = command === "cancel"
457
+ ? "\n Cancel gives up a queued (or unclaimed provisioning) lease's place atomically. It never reaps a lease a\n Helper has claimed: release that with `stack done <lease_id> --reap`.\n" : "";
447
458
  return `${bold(`botbuddy stack ${command}`)} ${usage}${detail}\n\n${selected.join("\n")}\n\n${STACK_READINESS_HELP}\n Use bb stack --help for all commands and options.`;
448
459
  }
449
460
 
@@ -508,6 +519,8 @@ export function parseStackArgs(argv) {
508
519
  disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
509
520
  // BOT-1803: eager release (reap) instead of the default idle release (done/run).
510
521
  reap: cancel,
522
+ // BOT-3094: cancel goes through the atomic cancel_unclaimed_stack_lease, never a plain reap.
523
+ cancel,
511
524
  };
512
525
  const positionals = [];
513
526
  const need = (name, v) => {
@@ -718,6 +731,9 @@ export function parseStackArgs(argv) {
718
731
  "own supabase/config.toml (distinct project_id + remapped ports).");
719
732
  }
720
733
  if (opts.releasePortClaim && (command !== "done" || !opts.reap || opts.localExec)) errors.push("--release-port-claim requires `stack done --reap` without --local-exec");
734
+ // BOT-3094: the atomic cancel finalizes a self-reserved provisioning lease server-side, so a local-exec stack's
735
+ // own `supabase stop` would never run. A local-exec lease is released only through `stack done --local-exec`.
736
+ if (opts.cancel && opts.localExec) errors.push("`stack cancel` does not take --local-exec; release a local-exec lease with `stack done <lease_id> --local-exec`");
721
737
  if (opts.reap && !["done", "run"].includes(command)) {
722
738
  errors.push("--reap is valid only with `stack done` or `stack run`");
723
739
  }
@@ -2574,7 +2590,48 @@ async function runLocalDone(leaseId, opts, deps = {}) {
2574
2590
  return { exitCode, receipt };
2575
2591
  }
2576
2592
 
2577
- export async function cmdUp(opts, {
2593
+ /** BOT-3094: the one stderr line a session re-request prints, shared by `stack up` and `stack run`. */
2594
+ function sessionRequeueNotice(lost) {
2595
+ return `stack: lease ${lost.lease_id} was reaped while queued (reap_reason=${lost.reap_reason}): its session can no longer promote it, ` +
2596
+ "so its place is gone — requesting a new lease once from the current session";
2597
+ }
2598
+
2599
+ /**
2600
+ * `stack up`. BOT-3094: a queue park that ends in a session reap (SESSION_REAP_REASONS) requests a
2601
+ * new lease once, re-reading the session credential; every receipt of that attempt carries
2602
+ * `requeued_from`. A second session reap, or any other outcome, ends the command as before.
2603
+ */
2604
+ export async function cmdUp(opts, deps = {}) {
2605
+ const emitResult = deps.emitResult ?? emit;
2606
+ const clock = deps.now ?? Date.now;
2607
+ const startedAtMs = clock();
2608
+ const first = await cmdUpOnce(opts, deps, { allowRequeue: true });
2609
+ if (typeof first !== "object" || first === null || !first.requeue) return first;
2610
+ const requeuedFrom = first.requeue;
2611
+ process.stderr.write(`${sessionRequeueNotice(requeuedFrom)}\n`);
2612
+ // One QUEUE-park budget (--timeout) across both attempts, so an outer wait sized to it still holds.
2613
+ // Only the queue park is shortened: the Helper provision waits keep the full --timeout.
2614
+ const retryOpts = { ...opts, queueParkTimeout: remainingParkSecs(opts.timeout, startedAtMs, clock()) };
2615
+ return cmdUpOnce(retryOpts, { ...deps, emitResult: (receipt, o, code) => emitResult({ ...receipt, requeued_from: requeuedFrom }, o,
2616
+ // The command's outcome is still the lost lease: a refused re-request (no new lease) is LEASE_FAILED.
2617
+ requeueRefused(receipt.lease_id, receipt.code, code, receipt.outcome) ? EXIT.LEASE_FAILED : code) }, { allowRequeue: false });
2618
+ }
2619
+
2620
+ /** BOT-3094: seconds left of a --timeout park budget (at least 1). */
2621
+ function remainingParkSecs(timeoutSec, startedAtMs, nowMs) {
2622
+ return Math.max(1, Math.ceil(timeoutSec - (nowMs - startedAtMs) / 1000));
2623
+ }
2624
+
2625
+ /**
2626
+ * BOT-3094: the re-request was answered with a refusal and minted no lease. Only an `error` outcome
2627
+ * that would exit BACKEND or INVALID: a capacity-wait TIMEOUT, an interrupt and AUTH keep their exits.
2628
+ */
2629
+ export function requeueRefused(leaseId, code, exitCode, outcome) {
2630
+ return !leaseId && outcome === "error" && typeof code === "string" && code.length > 0 &&
2631
+ (exitCode === EXIT.BACKEND || exitCode === EXIT.INVALID);
2632
+ }
2633
+
2634
+ async function cmdUpOnce(opts, {
2578
2635
  runPreflight = runLocalExecPreflight,
2579
2636
  callTool = callToolJson,
2580
2637
  authProvider = stackAuthHeader,
@@ -2599,7 +2656,7 @@ export async function cmdUp(opts, {
2599
2656
  reauth = (current, kind) => reauthenticateParkedLease(current, kind),
2600
2657
  sleep = null,
2601
2658
  now = null,
2602
- } = {}) {
2659
+ } = {}, { allowRequeue = false } = {}) {
2603
2660
  let slot;
2604
2661
  try { slot = deriveSlot(opts); } catch (e) {
2605
2662
  return emitResult(buildReceipt({ command: "up", outcome: "error", error: e.message }), opts, EXIT.INVALID);
@@ -2747,9 +2804,9 @@ export async function cmdUp(opts, {
2747
2804
  if (linkNotice) process.stderr.write(`${linkNotice}\n`);
2748
2805
  // BOT-2099: every lease wait in `stack up` (queue park, Helper provision, Helper takeover)
2749
2806
  // survives auth/availability blips (parkOnLease) and adopts the re-minted credential.
2750
- const resilientWait = async (isDone, isFailed) => {
2807
+ const resilientWait = async (isDone, isFailed, { timeoutSec = opts.timeout } = {}) => {
2751
2808
  const result = await parkOnLease(leaseId, isDone, isFailed, {
2752
- timeoutSec: opts.timeout, auth, wait: waitFn, reauth, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}),
2809
+ timeoutSec, auth, wait: waitFn, reauth, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}),
2753
2810
  // Codex #1168 R2 P1: probe with the credential the park just re-minted — `auth`/`call`
2754
2811
  // are only updated after the park returns, and an old-credential read would 401 and
2755
2812
  // miss a grant or reap that landed during the outage.
@@ -2798,9 +2855,13 @@ export async function cmdUp(opts, {
2798
2855
  }
2799
2856
  // Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
2800
2857
  // BOT-2013: a lease reaped while queued (orphaned_agent, …) is lost, not "left the queue".
2801
- const parked = await resilientWait(nonQueued, isReaped);
2858
+ // BOT-3094: a session re-request parks only for what is left of --timeout.
2859
+ const parkSecs = opts.queueParkTimeout ?? opts.timeout;
2860
+ const parked = await resilientWait(nonQueued, isReaped, { timeoutSec: parkSecs });
2802
2861
  if (parked.authUnavailable) return emitAuthUnavailable(parked);
2803
2862
  if (parked.failed) {
2863
+ // BOT-3094: nothing was provisioned for a queued lease, so its loss leaves nothing to clean up.
2864
+ if (allowRequeue && SESSION_REAP_REASONS.has(parked.reap_reason)) return { requeue: { lease_id: leaseId, reap_reason: parked.reap_reason } };
2804
2865
  return emitResult(buildReceipt({
2805
2866
  command: "up", outcome: "error", lease_id: leaseId, state: parked.state, slot,
2806
2867
  reap_reason: parked.reap_reason ?? null,
@@ -2824,8 +2885,8 @@ export async function cmdUp(opts, {
2824
2885
  // BOT-2237: a park that ended inside an unrecovered platform incident never observed admission.
2825
2886
  const blind = parked.incident && !parked.incident.recovered;
2826
2887
  const why = blind
2827
- ? `parked ${opts.timeout}s inside a platform incident (${parked.incident.reason} since ${parked.incident.first_at}), so its admission was never observed`
2828
- : `parked ${opts.timeout}s without capacity`;
2888
+ ? `parked ${parkSecs}s inside a platform incident (${parked.incident.reason} since ${parked.incident.first_at}), so its admission was never observed`
2889
+ : `parked ${parkSecs}s without capacity`;
2829
2890
  // BOT-2400: a held place is PARK_DEADLINE; the BOT-2237 incident code still wins when the
2830
2891
  // park never observed admission (the place is held either way — see queue_hold).
2831
2892
  const code = blind ? parked.incident.reason : (queueHold.held ? PARK_DEADLINE : PARK_HOLD_UNCONFIRMED);
@@ -3516,11 +3577,28 @@ export async function cmdDone(leaseId, opts, {
3516
3577
  const idleRelease = !opts.reap && !opts.localExec;
3517
3578
  // BOT-2847: an unanswered release (RPC_ERROR, transport, 5xx) is retried for about 60 s; a
3518
3579
  // definite refusal is reported at once. An exhausted retry names the cause and the attempts.
3580
+ // BOT-3094: `stack cancel` is the atomic cancel_unclaimed_stack_lease, which gives up a queued
3581
+ // (or unclaimed provisioning) lease under the admission lock and refuses once a Helper claimed it.
3582
+ // A plain release here would reap whatever the lease became after the caller read it as queued.
3583
+ // An already-reaped lease answers a no-op success, so a retried cancel is safe.
3519
3584
  const released = await callWithTransientRetry(
3520
- () => call("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition, ...(idleRelease ? { idle: true } : {}) }), releaseRetry);
3585
+ () => (opts.cancel
3586
+ ? call("cancel_unclaimed_stack_lease", { lease_id: leaseId })
3587
+ : call("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition, ...(idleRelease ? { idle: true } : {}) })), releaseRetry);
3521
3588
  const r = released.result;
3522
3589
  const unanswered = released.exhausted ? `${describeStackRpcFailure(r)} (no answer in ${released.attempts} attempts over ${Math.round(released.elapsedMs / 1000)}s)` : null;
3523
3590
  if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: unanswered ?? r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
3591
+ if (!r.data.success && opts.cancel && !unanswered) {
3592
+ // Every definite refusal reaps nothing. Only a claimed / not-cancellable lease gets the explicit release hint.
3593
+ const state = typeof r.data.state === "string" ? r.data.state : null;
3594
+ const code = r.data.code ?? "CANCEL_REFUSED";
3595
+ const why = CANCEL_REFUSAL_CODES.has(code)
3596
+ ? `lease ${leaseId} ${state ? `is ${state} and ` : ""}is no longer an unclaimed queued lease, so nothing was reaped. ` +
3597
+ `Inspect it with \`bb stack status ${leaseId}\`; release it explicitly with \`bb stack done ${leaseId} --reap\`.`
3598
+ : `nothing was reaped (${r.data.message ?? "the control plane refused the cancel"}).`;
3599
+ return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId, code, ...(state ? { state } : {}),
3600
+ error: `cancel refused (${code}): ${why}` }), opts, EXIT.LEASE_FAILED);
3601
+ }
3524
3602
  if (!r.data.success) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: unanswered ?? r.data.code }), opts, EXIT.BACKEND);
3525
3603
  if (idleRelease && r.data.idle === true) {
3526
3604
  return emitResult(buildReceipt({
@@ -3641,7 +3719,8 @@ export async function cmdDone(leaseId, opts, {
3641
3719
  portBlock = { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
3642
3720
  }
3643
3721
  }
3644
- return emitResult(buildReceipt({ command: "done", outcome: portBlock?.released === false ? "error" : "released", lease_id: leaseId, state, disposition,
3722
+ return emitResult(buildReceipt({ command: "done", outcome: portBlock?.released === false ? "error" : (opts.cancel ? "cancelled" : "released"), lease_id: leaseId, state, disposition,
3723
+ ...(opts.cancel && r.data.noop === true ? { noop: true } : {}),
3645
3724
  ...retried,
3646
3725
  ...(portBlock ? { port_block: portBlock } : {}),
3647
3726
  ...(helperTeardown ? { teardown: "helper" } : {}),
@@ -3827,13 +3906,34 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
3827
3906
  // A receipt always says what happened to a port-block claim. Before allocation
3828
3907
  // no claim exists, which is a safe, explicit disposition rather than omission.
3829
3908
  // BOT-1803: whether this run re-adopted / re-provisioned an idle lease, and its stack digest.
3830
- const incidentBox = { incident: null, execution: null, idleLease: null, webUrl: null, portBlock: { released: false, reason: "no port-block claim was created before stack allocation" } };
3831
- let result;
3832
- try {
3833
- result = await runStackLifecycleOnce(opts, childArgv, adapters, incidentBox);
3834
- } catch (error) {
3835
- result = { exitCode: classifyStackAuthError(error), outcome: "error", error: String(error?.message ?? error) };
3909
+ const freshBox = () => ({ incident: null, execution: null, idleLease: null, webUrl: null, portBlock: { released: false, reason: "no port-block claim was created before stack allocation" } });
3910
+ let incidentBox = freshBox();
3911
+ const startedAtMs = (adapters.now ?? Date.now)();
3912
+ const once = async (attemptOpts = opts) => {
3913
+ try {
3914
+ return await runStackLifecycleOnce(attemptOpts, childArgv, adapters, incidentBox);
3915
+ } catch (error) {
3916
+ return { exitCode: classifyStackAuthError(error), outcome: "error", error: String(error?.message ?? error) };
3917
+ }
3918
+ };
3919
+ let result = await once();
3920
+ // BOT-3094: a queue park lost to a session reap requests a new lease ONCE, from scratch: a new
3921
+ // Once re-reads the session credential, re-resolves the stack and claims fresh ports. The lost
3922
+ // lease's cleanup must be proven first, or its ports could still be held.
3923
+ let requeuedFrom = null;
3924
+ // A signal that landed during that cleanup means the caller is stopping: never start again.
3925
+ if (result?.exitCode === EXIT.LEASE_FAILED && result.lostWhileQueued === true && SESSION_REAP_REASONS.has(result.reapReason) &&
3926
+ result.cleanup?.ok !== false && !incidentBox.stopSignal) {
3927
+ requeuedFrom = { lease_id: result.leaseId, reap_reason: result.reapReason };
3928
+ process.stderr.write(`${sessionRequeueNotice(requeuedFrom)}\n`);
3929
+ const { incident } = incidentBox;
3930
+ incidentBox = { ...freshBox(), incident };
3931
+ // One park budget (--timeout) across both attempts.
3932
+ const remaining = remainingParkSecs(opts.timeout, startedAtMs, (adapters.now ?? Date.now)());
3933
+ result = await once({ ...opts, queueParkTimeout: remaining });
3934
+ if (result && typeof result === "object" && requeueRefused(result.leaseId, result.code, result.exitCode, result.outcome)) result = { ...result, exitCode: EXIT.LEASE_FAILED };
3836
3935
  }
3936
+ if (requeuedFrom && result && typeof result === "object") result = { ...result, requeuedFrom };
3837
3937
  const withIncident = incidentBox.incident && !result?.incident ? { ...result, incident: incidentBox.incident } : result;
3838
3938
  const withExecution = incidentBox.execution && !withIncident?.execution ? { ...withIncident, execution: incidentBox.execution } : withIncident;
3839
3939
  const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
@@ -3851,6 +3951,25 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
3851
3951
  /** BOT-2467: where a lifecycle result carries its outcome-record channel. */
3852
3952
  export const OUTCOME_CHANNEL = Symbol.for("botbuddy.stackRunOutcomeChannel");
3853
3953
 
3954
+ /** The lease calls `bb stack run` makes over its retrying caller (`rpc.call(name, args, opts)`). */
3955
+ export function defaultStackRunApi(rpc) {
3956
+ return {
3957
+ request: (args) => rpc.call("request_stack_lease", args),
3958
+ // BOT-2780 (beta requester origin): BOT-2499's identified admission, never a fallback.
3959
+ requestIdentified: (args) => rpc.call("request_stack_lease_identified", args),
3960
+ getAdmission: (args) => rpc.call("get_stack_lease_admission", args),
3961
+ get: (leaseId, { signal } = {}) => rpc.call("get_stack_lease", { lease_id: leaseId }, { signal }),
3962
+ // BOT-1803: a heartbeat carries the measured client use when there is one. BOT-3095: it is the
3963
+ // RUN's heartbeat, so it says the run is alive — the server then keeps a quiet live run out of
3964
+ // `no_use` idling (and preemption) up to its ceiling. `bb stack touch` never sends this.
3965
+ touch: (leaseId, usage, { signal } = {}) => rpc.call("touch_stack_lease", { lease_id: leaseId, ...(usage ? { usage } : {}), run_alive: true }, { signal }),
3966
+ // BOT-1803: `{ idle: true }` idles an ACTIVE lease instead of reaping it.
3967
+ release: (leaseId, signal, { idle = false } = {}) => rpc.call("release_stack_lease", { lease_id: leaseId, disposition: "destroy", ...(idle ? { idle: true } : {}) }, { signal }),
3968
+ // BOT-2400: keep a queued lease's place when the park deadline passes.
3969
+ hold: (leaseId, holdSecs) => holdQueuePlace((name, args) => rpc.call(name, args), leaseId, holdSecs),
3970
+ };
3971
+ }
3972
+
3854
3973
  async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3855
3974
  // BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
3856
3975
  // long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
@@ -3872,19 +3991,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3872
3991
  });
3873
3992
  // BOT-2467: cmdRun records the run's outcome with the credential this run ended on.
3874
3993
  incidentBox.outcomeChannel = { get auth() { return rpc.auth; }, callTool: adapters.callTool ?? callToolJson };
3875
- const api = adapters.api ?? {
3876
- request: (args) => rpc.call("request_stack_lease", args),
3877
- // BOT-2780 (beta requester origin): BOT-2499's identified admission, never a fallback.
3878
- requestIdentified: (args) => rpc.call("request_stack_lease_identified", args),
3879
- getAdmission: (args) => rpc.call("get_stack_lease_admission", args),
3880
- get: (leaseId, { signal } = {}) => rpc.call("get_stack_lease", { lease_id: leaseId }, { signal }),
3881
- // BOT-1803: a heartbeat carries the measured client use when there is one.
3882
- touch: (leaseId, usage, { signal } = {}) => rpc.call("touch_stack_lease", { lease_id: leaseId, ...(usage ? { usage } : {}) }, { signal }),
3883
- // BOT-1803: `{ idle: true }` idles an ACTIVE lease instead of reaping it.
3884
- release: (leaseId, signal, { idle = false } = {}) => rpc.call("release_stack_lease", { lease_id: leaseId, disposition: "destroy", ...(idle ? { idle: true } : {}) }, { signal }),
3885
- // BOT-2400: keep a queued lease's place when the park deadline passes.
3886
- hold: (leaseId, holdSecs) => holdQueuePlace((name, args) => rpc.call(name, args), leaseId, holdSecs),
3887
- };
3994
+ const api = adapters.api ?? defaultStackRunApi(rpc);
3888
3995
  // An injected api double without `hold` keeps the pre-BOT-2400 release unless the test also
3889
3996
  // injected the tool transport (never a real network call from a test).
3890
3997
  const holdPlace = api.hold ?? (adapters.callTool ? (leaseId, holdSecs) => holdQueuePlace((name, args) => rpc.call(name, args), leaseId, holdSecs) : null);
@@ -4800,7 +4907,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4800
4907
  // BOT-1630: ONE park budget (--timeout) across every park. A Helper that finds the host under
4801
4908
  // pressure mid-provision defers the lease back to `queued` (its place kept); run then parks
4802
4909
  // again against the same deadline instead of timing out on the provision clock.
4803
- const parkDeadlineMs = (adapters.now ?? Date.now)() + opts.timeout * 1000;
4910
+ // BOT-3094: a session re-request's queue park gets only what is left of --timeout (queueParkTimeout);
4911
+ // the provision and own-reap waits keep the full --timeout.
4912
+ const parkSecs = opts.queueParkTimeout ?? opts.timeout;
4913
+ const parkDeadlineMs = (adapters.now ?? Date.now)() + parkSecs * 1000;
4804
4914
  let parkView = request.data;
4805
4915
  let parkReason = null;
4806
4916
  for (;;) {
@@ -4818,7 +4928,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4818
4928
  // BOT-2013: every reap state (reaping / reaped / reap_failed) means the queued lease is lost.
4819
4929
  // BOT-2099: an auth/availability blip is retried inside the park until its deadline;
4820
4930
  // it never releases a queued lease or becomes the lease's outcome.
4821
- const parked = await resilientWait(leaseId, (s) => s !== "queued" && s != null, isReaped, { timeoutSec: opts.timeout, deadlineMs: parkDeadlineMs, auth: rpc.auth, signal: activeWaitAbort.signal });
4931
+ const parked = await resilientWait(leaseId, (s) => s !== "queued" && s != null, isReaped, { timeoutSec: parkSecs, deadlineMs: parkDeadlineMs, auth: rpc.auth, signal: activeWaitAbort.signal });
4822
4932
  activeWaitAbort = null;
4823
4933
  const incident = parkIncident ? { incident: parkIncident } : {};
4824
4934
  if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup, ...incident }; }
@@ -4835,8 +4945,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4835
4945
  // lease's admission, so it must not claim the host had no capacity.
4836
4946
  const blind = parkIncident && !parkIncident.recovered;
4837
4947
  const why = blind
4838
- ? `lease ${leaseId} parked ${opts.timeout}s inside a platform incident (${parkIncident.reason} since ${parkIncident.first_at}), so its admission was never observed`
4839
- : `parked ${opts.timeout}s without capacity`;
4948
+ ? `lease ${leaseId} parked ${parkSecs}s inside a platform incident (${parkIncident.reason} since ${parkIncident.first_at}), so its admission was never observed`
4949
+ : `parked ${parkSecs}s without capacity`;
4840
4950
  // BOT-2400 (item 1): the park deadline never gives the queue place up. Hold the lease
4841
4951
  // for the owner's next run (which re-attaches to it) and exit PARK_DEADLINE; only
4842
4952
  // `stack cancel` / `stack done` or the owner's death releases it.
@@ -4877,7 +4987,12 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4877
4987
  ...incident, ...(ledger ? { ledger } : {}), ...(queueHold ? { queueHold } : {}), ...(parkReason ? { parkReason } : {}),
4878
4988
  };
4879
4989
  }
4880
- 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 }; }
4990
+ if (parked?.failed) {
4991
+ const cleanup = await requestCleanup();
4992
+ // BOT-3094: lostWhileQueued + reapReason let runStackLifecycle re-request after a session reap.
4993
+ return { exitCode: EXIT.LEASE_FAILED, outcome: "error", leaseId, error: `lease was ${parked.state} while queued (reap_reason=${parked.reap_reason ?? "unknown"})`, cleanup,
4994
+ lostWhileQueued: true, ...(parked.reap_reason ? { reapReason: parked.reap_reason } : {}), ...incident };
4995
+ }
4881
4996
  if (parked?.error || parked?.auth) {
4882
4997
  const cleanup = await requestCleanup();
4883
4998
  return {
@@ -5188,6 +5303,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
5188
5303
  };
5189
5304
  heartbeat = clock.setInterval(touchLease, cadenceMs);
5190
5305
  heartbeat.unref?.();
5306
+ // BOT-3095: one heartbeat as the child starts, not a full cadence later, so a run that
5307
+ // re-adopted an idle stack says it is alive before the next minute's idle pass.
5308
+ const firstTouch = clock.setTimeout(() => touchLease(), 0);
5309
+ firstTouch?.unref?.();
5191
5310
  if (opts.hardTtl) hardTimer = clock.setTimeout(() => stopChild("SIGTERM", EXIT.TIMEOUT), opts.hardTtl * 1000);
5192
5311
  hardTimer?.unref?.();
5193
5312
  childResult = await waitForChild(child);
@@ -5320,6 +5439,9 @@ export function runReceiptFields(result) {
5320
5439
  ...(result.idleLease?.stackDigest ? { stack_digest: result.idleLease.stackDigest } : {}),
5321
5440
  ...(result.idleLease?.readoptHealth ? { readopt_health: result.idleLease.readoptHealth } : {}),
5322
5441
  ...(result.parkReason ? { park_reason: result.parkReason } : {}),
5442
+ // BOT-3094: why a queued lease was lost, and the lease a session re-request replaced.
5443
+ ...(result.reapReason ? { reap_reason: result.reapReason } : {}),
5444
+ ...(result.requeuedFrom ? { requeued_from: result.requeuedFrom } : {}),
5323
5445
  // BOT-2142: where the lease ran and how the child reached it.
5324
5446
  execution_mode: result.execution?.mode ?? null,
5325
5447
  execution_host_key: result.execution?.hostKey ?? null,