@botbuddy/cli 1.63.29 → 1.63.31
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/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/api.mjs +3 -1
- package/src/stack-release-retry.mjs +81 -0
- package/src/stack.mjs +70 -10
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.63.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.31",
|
|
3
|
+
"source_commit": "3f7fe8fd0dfb2a66d2d1eedd7fa1a0e6852190a8",
|
|
4
|
+
"source_sha256": "acb5cf30dc9a9ecbfcddbbcae39c4c280a7da9c3f1186f9bafc232e1bebdb645",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "f0b5a4a0d1ee1d059dbad7c14f7f9bf99e9d1665e23e79a9a46dcdc0e8b65e8d"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.63.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.31",
|
|
3
|
+
"source_commit": "3f7fe8fd0dfb2a66d2d1eedd7fa1a0e6852190a8",
|
|
4
|
+
"source_sha256": "acb5cf30dc9a9ecbfcddbbcae39c4c280a7da9c3f1186f9bafc232e1bebdb645",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "1bb884654f83bbfd9c9083e6cd777297600fd473f7e22e8572610f002c433057"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.63.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.63.31",
|
|
4
|
+
"source_commit": "3f7fe8fd0dfb2a66d2d1eedd7fa1a0e6852190a8",
|
|
5
|
+
"source_sha256": "acb5cf30dc9a9ecbfcddbbcae39c4c280a7da9c3f1186f9bafc232e1bebdb645",
|
|
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": "
|
|
11
|
+
"sha256": "f0b5a4a0d1ee1d059dbad7c14f7f9bf99e9d1665e23e79a9a46dcdc0e8b65e8d",
|
|
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": "
|
|
18
|
+
"sha256": "1bb884654f83bbfd9c9083e6cd777297600fd473f7e22e8572610f002c433057",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -530,7 +530,9 @@ export async function callToolJson(toolName, args = {}, {
|
|
|
530
530
|
const text = payload.result?.content?.map((c) => c.text).filter(Boolean).join("\n") ?? "";
|
|
531
531
|
let data;
|
|
532
532
|
try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
|
|
533
|
-
|
|
533
|
+
// BOT-2847 (Codex #1434): keep the HTTP status, so a gateway 5xx whose body is JSON (but not a
|
|
534
|
+
// JSON-RPC answer) is not mistaken for a definite tool answer by a retrying caller.
|
|
535
|
+
return { ok: true, data, isError: payload.result?.isError === true, status: res.status };
|
|
534
536
|
}
|
|
535
537
|
|
|
536
538
|
export async function listResources({ fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, log = (line) => console.log(line) } = {}) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// BOT-2847: a transient backend failure on a stack lease release is retried, never leaked.
|
|
2
|
+
//
|
|
3
|
+
// The classification is BOT-2790 AC-6's (BOT-2828), shared with the native callback's
|
|
4
|
+
// status read: an UNANSWERED call is transient — a backend `RPC_ERROR` (for example a
|
|
5
|
+
// statement timeout), a transport failure, an HTTP 5xx/408/429 or an unreadable body. A
|
|
6
|
+
// 401/403 that survived the auth refresh, a typed JSON-RPC refusal or a typed tool refusal
|
|
7
|
+
// (`NOT_LEASE_OWNER`, `LEASE_NOT_FOUND`, ...) is a definite answer and is never retried.
|
|
8
|
+
//
|
|
9
|
+
// Retrying a release is safe: `release_stack_lease` is idempotent (a duplicate release is a
|
|
10
|
+
// no-op, an already-idle lease answers idle again), and an `RPC_ERROR` means the server's
|
|
11
|
+
// transaction rolled back.
|
|
12
|
+
|
|
13
|
+
/** Backoff between release attempts: 8 attempts within about 60 s. */
|
|
14
|
+
export const RELEASE_RETRY_DELAYS_MS = Object.freeze([1000, 2000, 4000, 8000, 15000, 15000, 15000]);
|
|
15
|
+
/** The wall-clock budget for one release's retries (the delays alone add up to it). */
|
|
16
|
+
export const RELEASE_RETRY_BUDGET_MS = 60_000;
|
|
17
|
+
/** Backoff for the handoff call made once the release's retries run out. */
|
|
18
|
+
export const RELEASE_HANDOFF_DELAYS_MS = Object.freeze([2000, 4000]);
|
|
19
|
+
/** The deadline for the handoff (its own: the release's deadline may already have passed). */
|
|
20
|
+
export const RELEASE_HANDOFF_TIMEOUT_MS = 20_000;
|
|
21
|
+
/** `cleanup.code` when the release never got an answer and the lease was handed off instead. */
|
|
22
|
+
export const CLEANUP_RETRY_EXHAUSTED = "CLEANUP_RETRY_EXHAUSTED";
|
|
23
|
+
|
|
24
|
+
/** True when a `callToolJson`-shaped result is an unanswered call (BOT-2790 AC-6). */
|
|
25
|
+
export function isTransientStackRpcResult(result) {
|
|
26
|
+
if (result == null || typeof result !== "object") return false;
|
|
27
|
+
const status = result.status;
|
|
28
|
+
const unansweredStatus = typeof status === "number" && (status >= 500 || status === 408 || status === 429);
|
|
29
|
+
// A gateway 5xx/408/429 whose body happens to be JSON still parses as `ok` (Codex #1434).
|
|
30
|
+
if (result.ok) return unansweredStatus || (result.data?.success !== true && result.data?.code === "RPC_ERROR");
|
|
31
|
+
if (result.auth) return false;
|
|
32
|
+
return status == null || unansweredStatus || result.error === "invalid_json";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One short line naming why a stack RPC failed, for a receipt. */
|
|
36
|
+
export function describeStackRpcFailure(result) {
|
|
37
|
+
if (result == null || typeof result !== "object") return "no response";
|
|
38
|
+
if (result.ok) {
|
|
39
|
+
const code = result.data?.code ?? (typeof result.status === "number" && result.status >= 300 ? `HTTP ${result.status}` : "failed");
|
|
40
|
+
const message = typeof result.data?.message === "string" && result.data.message ? `: ${result.data.message}` : "";
|
|
41
|
+
return `${code}${message}`.slice(0, 160);
|
|
42
|
+
}
|
|
43
|
+
if (result.error === "invalid_json") return `unreadable response (HTTP ${result.status ?? "?"})`;
|
|
44
|
+
if (result.status != null && result.status >= 300) return `HTTP ${result.status}${result.error ? ` ${result.error}` : ""}`.slice(0, 160);
|
|
45
|
+
return String(result.error ?? "no response").slice(0, 160);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A real-timer sleep that ends early when `signal` aborts. */
|
|
49
|
+
export function abortableSleep(ms, signal) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
if (signal?.aborted) { resolve(); return; }
|
|
52
|
+
let timer = null;
|
|
53
|
+
const done = () => { clearTimeout(timer); signal?.removeEventListener?.("abort", done); resolve(); };
|
|
54
|
+
timer = setTimeout(done, ms);
|
|
55
|
+
signal?.addEventListener?.("abort", done, { once: true });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run `invoke` until it returns something other than a transient failure, backing off
|
|
61
|
+
* between attempts. Stops when the delays or `budgetMs` run out, or when `signal` aborts.
|
|
62
|
+
* A thrown error propagates unchanged (the caller owns that path).
|
|
63
|
+
*/
|
|
64
|
+
export async function callWithTransientRetry(invoke, {
|
|
65
|
+
delaysMs = RELEASE_RETRY_DELAYS_MS, budgetMs = RELEASE_RETRY_BUDGET_MS,
|
|
66
|
+
sleep = abortableSleep, now = Date.now, signal,
|
|
67
|
+
} = {}) {
|
|
68
|
+
const started = now();
|
|
69
|
+
let attempts = 0;
|
|
70
|
+
let result;
|
|
71
|
+
for (;;) {
|
|
72
|
+
attempts += 1;
|
|
73
|
+
result = await invoke();
|
|
74
|
+
if (!isTransientStackRpcResult(result)) return { result, attempts, exhausted: false, elapsedMs: now() - started };
|
|
75
|
+
const delay = delaysMs[attempts - 1];
|
|
76
|
+
if (delay == null || signal?.aborted || now() - started + delay > budgetMs) break;
|
|
77
|
+
await sleep(delay, signal);
|
|
78
|
+
if (signal?.aborted) break;
|
|
79
|
+
}
|
|
80
|
+
return { result, attempts, exhausted: true, elapsedMs: now() - started };
|
|
81
|
+
}
|
package/src/stack.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from
|
|
|
45
45
|
import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
46
46
|
import { LEASED_STACK_IDLE_RETENTION_SECS, assertFreshRunStackAvailable, idleRequesterOriginPort, recordRequesterOrigin, stableRunStackIdle, recordAdmissionRequest, bindLeasedStackClaim, recoverLeasedStackClaim, hasLocalLeaseClaim, leasedClaimContext, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
|
|
47
47
|
import { reserveRequesterOrigin } from "./stack-requester-origin.mjs";
|
|
48
|
+
import { CLEANUP_RETRY_EXHAUSTED, RELEASE_HANDOFF_DELAYS_MS, RELEASE_HANDOFF_TIMEOUT_MS, callWithTransientRetry, describeStackRpcFailure, isTransientStackRpcResult } from "./stack-release-retry.mjs";
|
|
48
49
|
import { identifiedAdmissionAck, identifiedAdmissionDelivery, identifiedAdmissionRecovery } from "./stack-identified-admission.mjs";
|
|
49
50
|
import { patchRequesterAuthOrigin } from "./stack-requester-origin-config.mjs";
|
|
50
51
|
import { measureStackUsage } from "./stack-usage.mjs";
|
|
@@ -3278,6 +3279,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
3278
3279
|
waitFn = waitForLease,
|
|
3279
3280
|
recoverClaim = recoverLocalLeaseClaim,
|
|
3280
3281
|
localClaimExists = hasLocalLeaseClaim,
|
|
3282
|
+
releaseRetry = {},
|
|
3281
3283
|
} = {}) {
|
|
3282
3284
|
// BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
|
|
3283
3285
|
// its own — otherwise an active lease can't be released until the cache is manually
|
|
@@ -3420,9 +3422,14 @@ export async function cmdDone(leaseId, opts, {
|
|
|
3420
3422
|
// batch on this slot). `--reap` is today's eager release, and `--local-exec` implies it:
|
|
3421
3423
|
// its self-teardown below (and every hermetic lane / Supply Guard stop) needs the reap.
|
|
3422
3424
|
const idleRelease = !opts.reap && !opts.localExec;
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3425
|
+
// BOT-2847: an unanswered release (RPC_ERROR, transport, 5xx) is retried for about 60 s; a
|
|
3426
|
+
// definite refusal is reported at once. An exhausted retry names the cause and the attempts.
|
|
3427
|
+
const released = await callWithTransientRetry(
|
|
3428
|
+
() => call("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition, ...(idleRelease ? { idle: true } : {}) }), releaseRetry);
|
|
3429
|
+
const r = released.result;
|
|
3430
|
+
const unanswered = released.exhausted ? `${describeStackRpcFailure(r)} (no answer in ${released.attempts} attempts over ${Math.round(released.elapsedMs / 1000)}s)` : null;
|
|
3431
|
+
if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: unanswered ?? r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
3432
|
+
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);
|
|
3426
3433
|
if (idleRelease && r.data.idle === true) {
|
|
3427
3434
|
return emitResult(buildReceipt({
|
|
3428
3435
|
command: "done", outcome: "idle", lease_id: leaseId, state: r.data.state ?? "active",
|
|
@@ -3904,6 +3911,53 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3904
3911
|
}
|
|
3905
3912
|
};
|
|
3906
3913
|
|
|
3914
|
+
// BOT-2847: the release's retry policy (tests inject an instant `sleep`).
|
|
3915
|
+
const cleanupRetry = adapters.cleanupRetry ?? {};
|
|
3916
|
+
// BOT-1803: an idle lease's stack is still running — keep its directory and host port block
|
|
3917
|
+
// reserved well past any idle TTL so no other worktree takes them; the next run renews the
|
|
3918
|
+
// claim. BOT-2847: an idle handoff after an unanswered release needs the same retention.
|
|
3919
|
+
const retainIdleStack = () => {
|
|
3920
|
+
if (!execution) return;
|
|
3921
|
+
try { refreshRetention(stackDirFor(execution), { idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
|
|
3922
|
+
try { execution.renew?.({ idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
|
|
3923
|
+
};
|
|
3924
|
+
// BOT-2847 AC-2: the release never got an answer, so the lease must not stay active. Hand it
|
|
3925
|
+
// off through the other disposition, a different server path: an eager release that failed
|
|
3926
|
+
// goes idle (reclaimable — a queued lease preempts it, and it is reaped once its owner is
|
|
3927
|
+
// gone); an idle release that failed goes to the reaper. Neither is a proven cleanup, so the
|
|
3928
|
+
// result stays failed (CLEANUP_FAILED) and the receipt names the cause and the handoff.
|
|
3929
|
+
const handOffUnreleasedLease = async (idle, attempt, releaseAs) => {
|
|
3930
|
+
const cause = describeStackRpcFailure(attempt.result);
|
|
3931
|
+
const secs = Math.round(attempt.elapsedMs / 1000);
|
|
3932
|
+
const handoffAbort = new AbortController();
|
|
3933
|
+
const handoffTimer = clock.setTimeout(() => handoffAbort.abort("handoff-timeout"), cleanupRetry.handoffTimeoutMs ?? RELEASE_HANDOFF_TIMEOUT_MS);
|
|
3934
|
+
let handoff;
|
|
3935
|
+
try {
|
|
3936
|
+
handoff = await callWithTransientRetry(releaseAs(!idle, handoffAbort.signal), {
|
|
3937
|
+
...cleanupRetry, delaysMs: cleanupRetry.handoffDelaysMs ?? RELEASE_HANDOFF_DELAYS_MS, signal: handoffAbort.signal,
|
|
3938
|
+
});
|
|
3939
|
+
} catch (error) {
|
|
3940
|
+
handoff = { result: { ok: false, error: String(error?.message ?? error) } };
|
|
3941
|
+
} finally {
|
|
3942
|
+
clock.clearTimeout(handoffTimer);
|
|
3943
|
+
}
|
|
3944
|
+
const answer = handoff.result;
|
|
3945
|
+
const accepted = answer?.ok === true && answer.data?.success === true;
|
|
3946
|
+
const handedTo = !accepted ? null : answer.data.idle === true ? "idle" : "reaper";
|
|
3947
|
+
// Codex #1434 P2: the idle stack keeps running, so its directory and port claim must not expire.
|
|
3948
|
+
if (handedTo === "idle") retainIdleStack();
|
|
3949
|
+
const unanswered = `release_stack_lease${idle ? " (idle)" : ""} got no answer in ${attempt.attempts} attempts over ${secs}s (${cause})`;
|
|
3950
|
+
const outcome = handedTo === "idle"
|
|
3951
|
+
? "; the lease was handed off idle instead (reclaimable: a queued lease preempts it, and it is reaped once its owner is gone)"
|
|
3952
|
+
: handedTo === "reaper"
|
|
3953
|
+
? `; the lease was handed to the reaper instead (state ${answer.data.state ?? "unknown"}), its signed reap not awaited`
|
|
3954
|
+
: `; the ${idle ? "reaper" : "idle"} handoff also failed (${describeStackRpcFailure(answer)}), so the lease may still be active — release it with \`bb stack done ${leaseId} --reap\``;
|
|
3955
|
+
return {
|
|
3956
|
+
ok: false, code: CLEANUP_RETRY_EXHAUSTED, error: `${unanswered}${outcome}`,
|
|
3957
|
+
handoff: { handed_to: handedTo, state: accepted ? answer.data.state ?? null : null, release_attempts: attempt.attempts, handoff_attempts: handoff.attempts ?? 1 },
|
|
3958
|
+
};
|
|
3959
|
+
};
|
|
3960
|
+
|
|
3907
3961
|
// BOT-1803: `idle` (only the normal end of a run) asks the server to idle the ACTIVE lease
|
|
3908
3962
|
// — no reap job, no signed-reap wait. Any answer that is not an idle one (the lease was
|
|
3909
3963
|
// not active) continues into today's signed-reap wait unchanged.
|
|
@@ -3918,12 +3972,18 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3918
3972
|
if (!leaseId) return cleanupResult = { ok: true, state: null };
|
|
3919
3973
|
const cleanupAbort = new AbortController();
|
|
3920
3974
|
const cleanupTimer = clock.setTimeout(() => cleanupAbort.abort("reap-timeout"), opts.reapTimeout * 1000);
|
|
3975
|
+
const releaseAs = (asIdle, signal) => () => (asIdle ? api.release(leaseId, signal, { idle: true }) : api.release(leaseId, signal));
|
|
3921
3976
|
let release;
|
|
3977
|
+
let attempt;
|
|
3922
3978
|
try {
|
|
3923
|
-
|
|
3979
|
+
// BOT-2847: an unanswered release (RPC_ERROR, transport, 5xx) is retried with backoff for
|
|
3980
|
+
// about 60 s; a definite refusal is returned at once.
|
|
3981
|
+
attempt = await callWithTransientRetry(releaseAs(idle, cleanupAbort.signal), { ...cleanupRetry, signal: cleanupAbort.signal });
|
|
3982
|
+
release = attempt.result;
|
|
3924
3983
|
} finally {
|
|
3925
3984
|
clock.clearTimeout(cleanupTimer);
|
|
3926
3985
|
}
|
|
3986
|
+
if (attempt.exhausted) return cleanupResult = await handOffUnreleasedLease(idle, attempt, releaseAs);
|
|
3927
3987
|
if (!release?.ok || !release?.data?.success) {
|
|
3928
3988
|
return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
|
|
3929
3989
|
}
|
|
@@ -4839,11 +4899,12 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4839
4899
|
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) {
|
|
4840
4900
|
// Only an unanswered read is transient (transport, 5xx, 408/429, unreadable body). A 401/403
|
|
4841
4901
|
// that survived the auth refresh or a typed JSON-RPC refusal is a definitive answer.
|
|
4842
|
-
|
|
4902
|
+
// BOT-2847: the shared classification (stack-release-retry.mjs) the cleanup release retries on.
|
|
4903
|
+
const transient = observed == null || isTransientStackRpcResult(observed);
|
|
4843
4904
|
throw Object.assign(new Error(transient ? "current callback status unreadable" : "current callback authorization refused"), transient ? { transient: true } : { code: "CALLBACK_AUTHORIZATION_LOST" });
|
|
4844
4905
|
}
|
|
4845
4906
|
// BOT-2790 AC-6 (BOT-2828): a backend RPC_ERROR (e.g. a statement timeout) is an unanswered read.
|
|
4846
|
-
if (observed
|
|
4907
|
+
if (isTransientStackRpcResult(observed)) throw Object.assign(new Error("current callback status unreadable (RPC_ERROR)"), { transient: true });
|
|
4847
4908
|
if (observed.data?.success !== true) throw Object.assign(new Error("current callback authorization unavailable"), { code: "CALLBACK_AUTHORIZATION_LOST" });
|
|
4848
4909
|
return observed.data; },
|
|
4849
4910
|
});
|
|
@@ -4992,10 +5053,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4992
5053
|
const idleOnEnd = !opts.reap && !receivedSignal && !fencing && !tunnelLost && !callbackCleanupError && forcedStopCode == null;
|
|
4993
5054
|
const cleanup = await requestCleanup({ idle: idleOnEnd });
|
|
4994
5055
|
if (cleanup?.idle) {
|
|
4995
|
-
|
|
4996
|
-
// past any idle TTL so no other worktree takes them; the next run renews the claim.
|
|
4997
|
-
try { refreshRetention(stackDirFor(execution), { idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
|
|
4998
|
-
try { execution.renew?.({ idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
|
|
5056
|
+
retainIdleStack();
|
|
4999
5057
|
stderrLine(`stack: lease ${leaseId} is idle${cleanup.idle_since ? ` since ${cleanup.idle_since}` : ""} — its stack keeps running for the next run on this slot (\`bb stack done ${leaseId} --reap\` tears it down)`);
|
|
5000
5058
|
}
|
|
5001
5059
|
if (originLost && !tunnelLost && !receivedSignal) {
|
|
@@ -5094,6 +5152,8 @@ export function runReceiptFields(result) {
|
|
|
5094
5152
|
cleanup: result.cleanup?.idle ? { idle: true, idle_since: result.cleanup.idle_since ?? null } : result.cleanup?.ok ?? null,
|
|
5095
5153
|
cleanup_code: result.cleanup?.code ?? null,
|
|
5096
5154
|
cleanup_error: result.cleanup?.ok === false ? result.cleanup.error ?? "cleanup was not proven" : null,
|
|
5155
|
+
// BOT-2847: an unanswered release names where the lease was handed off (idle | reaper | null).
|
|
5156
|
+
...(result.cleanup?.handoff ? { cleanup_handoff: result.cleanup.handoff } : {}),
|
|
5097
5157
|
...(result.leaseId && result.cleanup?.ok === false ? { recovery: {
|
|
5098
5158
|
status: `bb stack status ${result.leaseId}`,
|
|
5099
5159
|
wait: `bb wait 'lease:id=${result.leaseId},state=reaped' --heartbeat`,
|