@botbuddy/cli 1.60.2 → 1.60.3

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.60.2",
3
- "source_commit": "6c687a8220e66196ede9b560405ff1493d0752f0",
4
- "source_sha256": "2bb86426999a110ead51a17d7ab60b5cc816901b9cbd3c4426b6d02e75ab6288",
2
+ "release_version": "1.60.3",
3
+ "source_commit": "ce15cf76534b99c30468bdd541f2ab8e9340ee08",
4
+ "source_sha256": "962d9cae75ce935da9306a538440ccb1448270ea80b9c0d4ba76be0c3c3a6707",
5
5
  "target": "darwin-arm64",
6
- "sha256": "5a197ab76ecd57a479f26d364ad06b1f226a45ac6642a7f034ab725c432940e7"
6
+ "sha256": "989d317d944147c7ec86dc894c74b6a462e95fa85ee709e3e0c562783e231668"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.2",
3
- "source_commit": "6c687a8220e66196ede9b560405ff1493d0752f0",
4
- "source_sha256": "2bb86426999a110ead51a17d7ab60b5cc816901b9cbd3c4426b6d02e75ab6288",
2
+ "release_version": "1.60.3",
3
+ "source_commit": "ce15cf76534b99c30468bdd541f2ab8e9340ee08",
4
+ "source_sha256": "962d9cae75ce935da9306a538440ccb1448270ea80b9c0d4ba76be0c3c3a6707",
5
5
  "target": "linux-x64",
6
- "sha256": "f3c2ae3f7dd8c80ff439817d25e0577f236d532dafbc6714402945a84abce354"
6
+ "sha256": "90a9017148902699e54e26fddb940abc87d22ca96c3f415a75d4d172614dd281"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.2",
4
- "source_commit": "6c687a8220e66196ede9b560405ff1493d0752f0",
5
- "source_sha256": "2bb86426999a110ead51a17d7ab60b5cc816901b9cbd3c4426b6d02e75ab6288",
3
+ "release_version": "1.60.3",
4
+ "source_commit": "ce15cf76534b99c30468bdd541f2ab8e9340ee08",
5
+ "source_sha256": "962d9cae75ce935da9306a538440ccb1448270ea80b9c0d4ba76be0c3c3a6707",
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": "5a197ab76ecd57a479f26d364ad06b1f226a45ac6642a7f034ab725c432940e7",
11
+ "sha256": "989d317d944147c7ec86dc894c74b6a462e95fa85ee709e3e0c562783e231668",
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": "f3c2ae3f7dd8c80ff439817d25e0577f236d532dafbc6714402945a84abce354",
18
+ "sha256": "90a9017148902699e54e26fddb940abc87d22ca96c3f415a75d4d172614dd281",
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.60.2",
3
+ "version": "1.60.3",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -993,6 +993,7 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
993
993
 
994
994
  mkdir(registryDir, { recursive: true });
995
995
  const claimed = [];
996
+ let boundLeaseId = null;
996
997
  const record = (block, at = now(), ttlSecs = owner?.idleTtlSecs, idleRetained = false) => JSON.stringify({
997
998
  schema: 1,
998
999
  project_id: owner?.projectId ?? null,
@@ -1001,6 +1002,7 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
1001
1002
  slot: owner?.slot == null ? null : String(owner.slot),
1002
1003
  stack_dir: owner?.stackDir ?? null,
1003
1004
  port_base: block,
1005
+ ...(boundLeaseId ? { lease_id: boundLeaseId } : {}),
1004
1006
  created_at: at,
1005
1007
  retain_until: at + Math.max(LEASED_STACK_GC_MIN_AGE_MS, (Number(ttlSecs) || DEFAULT_IDLE_TTL_SECS) * 1000 + LEASED_STACK_RETENTION_GRACE_MS),
1006
1008
  owner_pid: process.pid,
@@ -1053,6 +1055,10 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
1053
1055
  return {
1054
1056
  blocks: claimed,
1055
1057
  invocationToken,
1058
+ bindLease: (leaseId) => {
1059
+ bindLeasedStackClaim(owner.stackDir, leaseId, { registryDir, invocationToken });
1060
+ boundLeaseId = leaseId;
1061
+ },
1056
1062
  /** Keep the reservation alive while the batch runs (Codex #928 R21): a
1057
1063
  * prepared `--stack-path` has no directory marker, so nothing else renews it,
1058
1064
  * and an aged-out claim could be reclaimed under a still-live stack. */
@@ -1084,6 +1090,136 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
1084
1090
  };
1085
1091
  }
1086
1092
 
1093
+ /** Read-only candidate lookup; accepted server proof and exact identity checks still gate removal. */
1094
+ export function hasLocalLeaseClaim(leaseId, { cwd = process.cwd(), registryDir = defaultPortRegistryDir() } = {}) {
1095
+ try {
1096
+ const root = realpathSync(cwd);
1097
+ return readdirSync(registryDir).filter((entry) => entry.endsWith(".json")).some((entry) => {
1098
+ try {
1099
+ const claim = JSON.parse(readFileSync(join(registryDir, entry), "utf8"));
1100
+ const rel = typeof claim.stack_dir === "string" ? relative(root, claim.stack_dir) : "..";
1101
+ return claim.lease_id === leaseId && claim.host === hostname() && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
1102
+ } catch { return false; }
1103
+ });
1104
+ } catch { return false; }
1105
+ }
1106
+
1107
+ function claimInventory(registryDir) {
1108
+ return readdirSync(registryDir).filter((name) => name.endsWith(".json")).map((name) => {
1109
+ // Allocation counts every JSON claim. Never report absence after overlooking
1110
+ // a retained record under an unexpected name.
1111
+ if (!/^\d+\.json$/.test(name)) throw new Error("claim inventory contains an invalid file name");
1112
+ const path = join(registryDir, name);
1113
+ const claim = JSON.parse(readFileSync(path, "utf8"));
1114
+ if (!claim || typeof claim !== "object" || !Number.isInteger(claim.port_base) || name !== `${claim.port_base}.json`) throw new Error("claim inventory is invalid");
1115
+ return { path, claim };
1116
+ });
1117
+ }
1118
+
1119
+ /** Exact lease-bound metadata remains usable when its old stack directory is gone. */
1120
+ export function leasedClaimContext(stackPath, leaseId, { cwd = process.cwd(), registryDir = defaultPortRegistryDir() } = {}) {
1121
+ try {
1122
+ const root = realpathSync(cwd);
1123
+ const claims = claimInventory(registryDir).filter(({ claim }) => {
1124
+ if (claim.lease_id !== leaseId || claim.host !== hostname() || typeof claim.stack_dir !== "string" || !isAbsolute(claim.stack_dir)) return false;
1125
+ const rel = relative(root, claim.stack_dir);
1126
+ return rel === stackPath && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
1127
+ });
1128
+ const first = claims[0]?.claim;
1129
+ return first && typeof first.project_id === "string" && /^[A-Za-z0-9_-]+$/.test(first.project_id) &&
1130
+ claims.every(({ claim }) => claim.project_id === first.project_id && claim.stack_dir === first.stack_dir)
1131
+ ? { project: first.project_id, stackDir: first.stack_dir } : null;
1132
+ } catch { return null; }
1133
+ }
1134
+
1135
+ /** Bind an admitted lease to its exact invocation while the caller holds the project lock. */
1136
+ export function bindLeasedStackClaim(stackDir, leaseId, { registryDir = defaultPortRegistryDir(), invocationToken = null } = {}) {
1137
+ const marker = readStackMarker(stackDir, readFileSync);
1138
+ const token = invocationToken ?? marker?.invocation_token;
1139
+ if (!token || !leaseId) throw new Error("local port claim has no invocation identity");
1140
+ return mutateBoundClaims(stackDir, registryDir, (claims) => {
1141
+ if (claims.some(({ claim }) => claim.invocation_token !== token)) throw new Error("local port claim was replaced");
1142
+ for (const { path, claim } of claims) atomicWrite(path, JSON.stringify({ ...claim, lease_id: leaseId }), { write: writeFileSync, rename: renameSync });
1143
+ // An authorized adoption (reservePortBlocks re-taking this project's idle reservation
1144
+ // under a fresh invocation, Codex #1248 P2) leaves an older invocation's marker behind.
1145
+ // The verified claims and both locks prove this invocation now owns the directory, so the
1146
+ // marker follows them; a marker for another project or directory is never rewritten.
1147
+ const project = claims[0]?.claim.project_id;
1148
+ const adoptable = marker && (marker.invocation_token === token ||
1149
+ ((marker.project_id == null || marker.project_id === project) && (marker.stack_dir == null || marker.stack_dir === stackDir)));
1150
+ if (adoptable) atomicWrite(join(stackDir, LEASED_STACK_MARKER), JSON.stringify({ ...marker, invocation_token: token, lease_id: leaseId }), { write: writeFileSync, rename: renameSync });
1151
+ });
1152
+ }
1153
+
1154
+ /** Allocation and recovery serialize the inspect/delete against replacement claims. */
1155
+ function mutateBoundClaims(stackDir, registryDir, mutate) {
1156
+ const config = readFileSync(join(stackDir, "supabase/config.toml"), "utf8");
1157
+ const project = projectIdFromConfig(config);
1158
+ const blocks = blocksForPorts([...portMapInConfig(config).values()]);
1159
+ if (!project || !/^[A-Za-z0-9_-]+$/.test(project) || !blocks.length) throw new Error("local stack port identity is unavailable");
1160
+ return withAllocationLock(registryDir, {
1161
+ writeExclusive: (path, data) => writeFileSync(path, data, { flag: "wx" }), read: readFileSync,
1162
+ remove: rmSync, now: Date.now, alive: pidIsAlive, host: hostname,
1163
+ }, () => {
1164
+ const claims = blocks.map((block) => {
1165
+ const path = portClaimPath(registryDir, block);
1166
+ const claim = JSON.parse(readFileSync(path, "utf8"));
1167
+ if (claim.stack_dir !== stackDir || claim.project_id !== project || claim.host !== hostname() || claim.port_base !== block) {
1168
+ throw new Error("local port claim identity does not match this stack");
1169
+ }
1170
+ return { path, claim };
1171
+ });
1172
+ return mutate(claims);
1173
+ });
1174
+ }
1175
+
1176
+ /** Physical proof alone cannot release a newer invocation's port reservation.
1177
+ * The command also holds the project lock; legacy claims without a lease binding stay retained.
1178
+ * This never deletes a container, volume, stack directory or another invocation's claim. */
1179
+ export function recoverLeasedStackClaim(stackDir, leaseId, proof, { registryDir = defaultPortRegistryDir() } = {}) {
1180
+ if (proof?.lease_id !== leaseId || proof.state !== "reaped" || proof.cleanup_verified !== true ||
1181
+ !["signed_reap", "never_provisioned", "local_exec_teardown", "pre_driver_absence"].includes(proof.cleanup_proof) ||
1182
+ (proof.cleanup_proof === "signed_reap" && !proof.cleanup_receipt_id)) {
1183
+ return { released: false, reason: "accepted cleanup proof is unavailable" };
1184
+ }
1185
+ try {
1186
+ return withAllocationLock(registryDir, {
1187
+ writeExclusive: (path, data) => writeFileSync(path, data, { flag: "wx" }), read: readFileSync,
1188
+ remove: rmSync, now: Date.now, alive: pidIsAlive, host: hostname,
1189
+ }, () => {
1190
+ const claims = claimInventory(registryDir).filter(({ claim }) => claim.stack_dir === stackDir);
1191
+ if (!claims.length) return { released: true, reason: "no local claim remains for this stack" };
1192
+ const token = claims[0].claim.invocation_token;
1193
+ const project = claims[0].claim.project_id;
1194
+ if (typeof token !== "string" || !token || typeof project !== "string" || !/^[A-Za-z0-9_-]+$/.test(project) || claims.some(({ claim }) =>
1195
+ claim.lease_id !== leaseId || claim.invocation_token !== token || claim.project_id !== project || claim.host !== hostname() ||
1196
+ !Number.isInteger(claim.owner_pid) || claim.owner_pid <= 0 || pidIsAlive(claim.owner_pid))) {
1197
+ return { released: false, reason: "local claim is live, replaced, or lacks this lease binding" };
1198
+ }
1199
+ // Strict read: only a genuinely absent marker (a prepared stack) skips the identity
1200
+ // fence; an unreadable or corrupt one cannot verify the invocation, so keep the claim.
1201
+ let marker = null;
1202
+ try {
1203
+ const raw = readFileSync(join(stackDir, LEASED_STACK_MARKER), "utf8");
1204
+ marker = JSON.parse(raw);
1205
+ if (!marker || typeof marker !== "object") throw new Error("invalid marker");
1206
+ } catch (error) {
1207
+ if (error?.code !== "ENOENT") return { released: false, reason: "stack marker is unreadable or corrupt; local claim retained" };
1208
+ marker = null;
1209
+ }
1210
+ if (marker && (marker.invocation_token !== token || marker.lease_id !== leaseId || marker.project_id !== project)) {
1211
+ return { released: false, reason: "stack marker was replaced" };
1212
+ }
1213
+ for (const { path } of claims) rmSync(path);
1214
+ if (claims.some(({ path }) => existsSync(path))) return { released: false, reason: "local claim removal was not verified" };
1215
+ if (marker) markLeasedStackReleased(stackDir);
1216
+ return { released: true, cleanup_proof: proof.cleanup_proof, cleanup_receipt_id: proof.cleanup_receipt_id ?? null };
1217
+ });
1218
+ } catch (error) {
1219
+ return { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
1220
+ }
1221
+ }
1222
+
1087
1223
  /** The host-scoped reservation file for a block. */
1088
1224
  function portClaimPath(registryDir, portBase) {
1089
1225
  return join(registryDir, `${portBase}.json`);
package/src/stack.mjs CHANGED
@@ -43,7 +43,7 @@ import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
43
43
  import { resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
44
44
  import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
45
45
  import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
46
- import { LEASED_STACK_IDLE_RETENTION_SECS, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
46
+ import { LEASED_STACK_IDLE_RETENTION_SECS, bindLeasedStackClaim, recoverLeasedStackClaim, hasLocalLeaseClaim, leasedClaimContext, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
47
47
  import { measureStackUsage } from "./stack-usage.mjs";
48
48
  import { checkReadoptedStackMounts } from "./stack-mount-health.mjs";
49
49
  import { machineUuid } from "./machine-id.mjs";
@@ -173,6 +173,8 @@ ${bold("done OPTIONS")}
173
173
  capacity stays held, so the next \`stack run\`/\`stack up\` on this slot re-adopts
174
174
  it without provisioning. Receipt outcome "idle" + idle_since, idle_reason.
175
175
  A queued/provisioning/reaping/reaped lease is released exactly as --reap.
176
+ --release-port-claim With done --reap: explicitly reconcile a retained local port claim
177
+ after accepted cleanup; unknown, live and replaced claims stay retained.
176
178
  --reap Release eagerly: reap (tear down) the stack now, as before BOT-1803.
177
179
  --stop With --reap: keep volumes (cheap re-provision next batch). Default: destroy.
178
180
  --local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
@@ -488,6 +490,7 @@ export function parseStackArgs(argv) {
488
490
  case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
489
491
  case "--stop": opts.disposition = "stop"; break;
490
492
  case "--reap": opts.reap = true; break;
493
+ case "--release-port-claim": opts.releasePortClaim = true; break;
491
494
  case "--destroy": opts.disposition = "destroy"; break;
492
495
  case "--json": opts.json = true; break;
493
496
  default:
@@ -537,6 +540,7 @@ export function parseStackArgs(argv) {
537
540
  "worktree root's default (shared canonical) project. Point --stack-path at a slot-derived stack directory with its " +
538
541
  "own supabase/config.toml (distinct project_id + remapped ports).");
539
542
  }
543
+ if (opts.releasePortClaim && (command !== "done" || !opts.reap || opts.localExec)) errors.push("--release-port-claim requires `stack done --reap` without --local-exec");
540
544
  if (opts.reap && !["done", "run"].includes(command)) {
541
545
  errors.push("--reap is valid only with `stack done` or `stack run`");
542
546
  }
@@ -844,6 +848,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
844
848
  // A prepared directory carries no retention marker, so its reservation is
845
849
  // renewed here on the batch's own beat (Codex #928 R21 P2).
846
850
  renew: (options) => ports.renew(options),
851
+ bindLease: (leaseId) => ports.bindLease(leaseId),
847
852
  // BOT-2237: returns the VERIFIED disposition the receipt reports.
848
853
  release: ({ reaped = false } = {}) => {
849
854
  let released = false;
@@ -871,6 +876,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
871
876
  )));
872
877
  return {
873
878
  ...execution,
879
+ bindLease: (leaseId) => bindLeasedStackClaim(stackDirFor(execution), leaseId, portRegistryDir ? { registryDir: portRegistryDir } : undefined),
874
880
  // Release is called once the batch's signed reap is done, so hand the host
875
881
  // its port block back immediately rather than holding it for the retention
876
882
  // window this batch no longer needs (Codex #928 R19 P2).
@@ -1456,7 +1462,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
1456
1462
  decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
1457
1463
  break frameLoop;
1458
1464
  }
1459
- if (isDone(st, sig.payload)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
1465
+ if (isDone(st, sig.payload)) { decided = { reason: "matched", result: { woke: true, state: st, ...(sig.payload?.cleanup_verified === true ? { cleanupEvidence: sig.payload } : {}) }, status: "matched" }; break frameLoop; }
1460
1466
  }
1461
1467
  }
1462
1468
  } catch (err) {
@@ -3011,6 +3017,9 @@ export async function cmdDone(leaseId, opts, {
3011
3017
  localTeardownFn = localTeardown,
3012
3018
  emitResult = emit,
3013
3019
  refreshAuth = refreshRejectedCacheAuth,
3020
+ waitFn = waitForLease,
3021
+ recoverClaim = recoverLocalLeaseClaim,
3022
+ localClaimExists = hasLocalLeaseClaim,
3014
3023
  } = {}) {
3015
3024
  // BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
3016
3025
  // its own — otherwise an active lease can't be released until the cache is manually
@@ -3243,10 +3252,65 @@ export async function cmdDone(leaseId, opts, {
3243
3252
  (retry.budget_reset === true ? " and the reap retry budget was reset" : ""),
3244
3253
  }
3245
3254
  : {};
3246
- return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition,
3255
+ let portBlock = null;
3256
+ if (opts.releasePortClaim || (opts.reap && !opts.localExec && localClaimExists(leaseId))) {
3257
+ try {
3258
+ const current = await call("get_stack_lease", { lease_id: leaseId });
3259
+ if (!current.ok || !current.data?.success) {
3260
+ portBlock = { released: false, reason: "owned lease context could not be read" };
3261
+ } else {
3262
+ const verified = await waitFn(leaseId,
3263
+ (s, p) => s === "reaped" && p?.cleanup_verified === true,
3264
+ // BOT-1948: an accepted failed reap, or a reaped snapshot the server marks as never
3265
+ // provable (cleanup_verified strictly false), ends at once; a raw frame keeps waiting.
3266
+ (s, p) => (s === "reap_failed" && p?.cleanup_verified === true) || (s === "reaped" && p?.cleanup_verified === false),
3267
+ { timeoutSec: opts.reapTimeout ?? 300, auth: rpc.auth });
3268
+ if (verified.woke && verified.cleanupEvidence) {
3269
+ state = "reaped";
3270
+ portBlock = await recoverClaim({ ...current.data, lease_id: leaseId }, verified.cleanupEvidence);
3271
+ } else portBlock = { released: false, reason: "accepted cleanup proof is unavailable; local claim retained" };
3272
+ }
3273
+ } catch (error) {
3274
+ portBlock = { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
3275
+ }
3276
+ }
3277
+ return emitResult(buildReceipt({ command: "done", outcome: portBlock?.released === false ? "error" : "released", lease_id: leaseId, state, disposition,
3247
3278
  ...retried,
3279
+ ...(portBlock ? { port_block: portBlock } : {}),
3248
3280
  ...(helperTeardown ? { teardown: "helper" } : {}),
3249
- ...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
3281
+ ...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, portBlock?.released === false ? EXIT.CLEANUP_FAILED : EXIT.OK);
3282
+ }
3283
+
3284
+ /** Recover only in the original worktree, with its project lock and an exact lease-bound claim. */
3285
+ export async function recoverLocalLeaseClaim(lease, proof, { cwd = process.cwd(), recover = recoverLeasedStackClaim, lock = acquireStackLock, claimContext = leasedClaimContext } = {}) {
3286
+ let held;
3287
+ try {
3288
+ const root = realpathSync(cwd);
3289
+ const path = lease.stack_path;
3290
+ if (!lease.worktree_root || root !== realpathSync(lease.worktree_root) || typeof path !== "string" || !path || path === "." || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
3291
+ return { released: false, reason: "run recovery from the lease's original worktree with its isolated path" };
3292
+ }
3293
+ const context = claimContext(path, lease.lease_id, { cwd });
3294
+ let stackDir, project;
3295
+ try {
3296
+ stackDir = stackDirFor(resolveStackPath(cwd, path));
3297
+ project = projectIdUnder(stackDir);
3298
+ } catch (error) {
3299
+ // No path is reconstructed from remote input. A removed directory needs
3300
+ // an exact local lease/host/path binding; unknown or legacy claims stay kept.
3301
+ if (error.code !== "ENOENT" || !context) throw error;
3302
+ stackDir = context.stackDir;
3303
+ project = context.project;
3304
+ }
3305
+ if (typeof project !== "string" || !/^[A-Za-z0-9_-]+$/.test(project) ||
3306
+ (context && (project !== context.project || stackDir !== context.stackDir))) {
3307
+ return { released: false, reason: "local stack project identity is unavailable or invalid" };
3308
+ }
3309
+ held = await lock(lockPathForProject(project), { timeoutMs: 0 });
3310
+ return recover(stackDir, lease.lease_id, proof);
3311
+ } catch (error) {
3312
+ return { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
3313
+ } finally { held?.release(); }
3250
3314
  }
3251
3315
 
3252
3316
  const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
@@ -3968,6 +4032,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3968
4032
  }
3969
4033
 
3970
4034
  leaseProvisioned = true; // from here a Helper may act on this lease
4035
+ execution.bindLease?.(leaseId);
3971
4036
  let state = request.data.state;
3972
4037
  if (reusedIdle) {
3973
4038
  idleLease.reused = "idle";
@@ -4408,7 +4473,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4408
4473
  ? `a lease on this stack slot may still hold its ports (idle, unreaped, or another live batch)${retained} so its stack is never double-booked`
4409
4474
  // BOT-2418: an idle release is a deliberate keep (BOT-1803), not an unproven reap.
4410
4475
  : cleanupResult?.ok === true && cleanupResult.idle === true
4411
- ? `the lease is idle and keeps its stack for the next run on this slot, so its claim stays reserved with it (\`bb stack done ${leaseId} --reap\` reaps the lease; it does not release this host's claim)`
4476
+ ? `the lease is idle and keeps its stack for the next run on this slot, so its claim stays reserved with it (\`bb stack done ${leaseId} --reap\` reaps the lease and releases its exact lease-bound local claim after accepted cleanup)`
4412
4477
  : `reap not proven (${cleanupResult?.error ?? "no cleanup ran"})${retained} so a stack the Helper may still run is never double-booked` }
4413
4478
  : releaseError || disposition?.released === false
4414
4479
  ? { released: false, reason: `the reap was proven but the claim could not be removed (${releaseError?.message ?? "it is still on disk"})${retained}` }