@botbuddy/cli 1.60.2 → 1.60.4

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.4",
3
+ "source_commit": "06faf46d8438c66ae116ce47eb5f343dfaca0c73",
4
+ "source_sha256": "9c1d721c1f0cfe90208bbc0960675053901326b324c7014c730769247d9d8c1c",
5
5
  "target": "darwin-arm64",
6
- "sha256": "5a197ab76ecd57a479f26d364ad06b1f226a45ac6642a7f034ab725c432940e7"
6
+ "sha256": "ea3ec61a04791ef355abd7bb04e01ff0895f2603e57d9ef6e4a33614a140b12a"
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.4",
3
+ "source_commit": "06faf46d8438c66ae116ce47eb5f343dfaca0c73",
4
+ "source_sha256": "9c1d721c1f0cfe90208bbc0960675053901326b324c7014c730769247d9d8c1c",
5
5
  "target": "linux-x64",
6
- "sha256": "f3c2ae3f7dd8c80ff439817d25e0577f236d532dafbc6714402945a84abce354"
6
+ "sha256": "ebe08409a8d9ea7a7292af476ea9d3d0acf91b8b09834b12e38a6833c0bf2ac5"
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.4",
4
+ "source_commit": "06faf46d8438c66ae116ce47eb5f343dfaca0c73",
5
+ "source_sha256": "9c1d721c1f0cfe90208bbc0960675053901326b324c7014c730769247d9d8c1c",
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": "ea3ec61a04791ef355abd7bb04e01ff0895f2603e57d9ef6e4a33614a140b12a",
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": "ebe08409a8d9ea7a7292af476ea9d3d0acf91b8b09834b12e38a6833c0bf2ac5",
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.4",
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`);
@@ -134,13 +134,13 @@ export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = ran
134
134
  * credential the lease calls used); without one there is nothing to authenticate
135
135
  * with and nothing is sent. Never throws; resolves within `timeoutMs`.
136
136
  */
137
- export async function reportStackRunOutcome({ fields, exitCode, opts, env = process.env, durationMs = null, channel, timeoutMs = STACK_RUN_OUTCOME_TIMEOUT_MS }) {
137
+ export async function reportStackRunOutcome({ fields, exitCode, runId, opts, env = process.env, durationMs = null, channel, timeoutMs = STACK_RUN_OUTCOME_TIMEOUT_MS }) {
138
138
  // Test suites opt out so a spawned real CLI never records a fixture run.
139
139
  if (env?.[STACK_RUN_OUTCOME_OPT_OUT_ENV] === "off") return { sent: false, reason: "disabled" };
140
140
  if (!channel?.auth || typeof channel.callTool !== "function") return { sent: false, reason: "no_credential" };
141
141
  let payload;
142
142
  try {
143
- payload = buildStackRunOutcomePayload({ fields, exitCode, opts, env, durationMs });
143
+ payload = buildStackRunOutcomePayload({ fields, exitCode, runId, opts, env, durationMs });
144
144
  } catch {
145
145
  return { sent: false, reason: "payload_error" };
146
146
  }
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.
@@ -186,6 +188,8 @@ ${bold("done OPTIONS")}
186
188
  ${bold("run OPTIONS")}
187
189
  --repo <repo> Required approved repository name.
188
190
  --ticket <BOT-123> Required ticket for the batch.
191
+ --run-id <UUID> Optional caller-chosen run identity. Choose it before dispatch to arm
192
+ bb wait 'stack-run:id=<UUID>'; the completion signal carries this run's exit code.
189
193
  --stack-path <relative> Optional PREPARED isolated stack dir. Omit it (the norm): run
190
194
  materializes .botbuddy/stacks/<project_id>/ from this worktree's
191
195
  supabase/ tree with a distinct project_id + remapped ports and
@@ -356,7 +360,7 @@ export function parseStackArgs(argv) {
356
360
  const childArgv = divider === -1 ? [] : rawRest.slice(divider + 1);
357
361
  const opts = {
358
362
  slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
359
- prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
363
+ prId: null, prUrl: null, purpose: null, runId: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
360
364
  timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
361
365
  connectionFile: null, noWait: false, claimOnly: false, localExec: false, keepVolumes: false, waitCapacity: null, holdOnTimeout: null,
362
366
  dockerContext: null, dockerEndpoint: null,
@@ -388,6 +392,7 @@ export function parseStackArgs(argv) {
388
392
  case "--pr": if (need(a, rest[i + 1])) opts.prId = rest[++i]; break;
389
393
  case "--pr-url": if (need(a, rest[i + 1])) opts.prUrl = rest[++i]; break;
390
394
  case "--purpose": if (need(a, rest[i + 1])) opts.purpose = rest[++i]; break;
395
+ case "--run-id": if (need(a, rest[i + 1])) opts.runId = rest[++i]; break;
391
396
  case "--stack-path": if (need(a, rest[i + 1])) { opts.stackPath = rest[++i]; opts.stackPathExplicit = true; } break;
392
397
  case "--idle-ttl": {
393
398
  if (need(a, rest[i + 1])) {
@@ -488,6 +493,7 @@ export function parseStackArgs(argv) {
488
493
  case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
489
494
  case "--stop": opts.disposition = "stop"; break;
490
495
  case "--reap": opts.reap = true; break;
496
+ case "--release-port-claim": opts.releasePortClaim = true; break;
491
497
  case "--destroy": opts.disposition = "destroy"; break;
492
498
  case "--json": opts.json = true; break;
493
499
  default:
@@ -515,6 +521,15 @@ export function parseStackArgs(argv) {
515
521
  } else if (opts.bundleInclude.length && opts.localOnly) {
516
522
  errors.push("--bundle-include ships files in the remote-lease bundle, and --local-only builds no bundle; drop one of them");
517
523
  }
524
+ if (opts.runId != null) {
525
+ if (command !== "run") {
526
+ errors.push("--run-id is valid only with `stack run`");
527
+ } else if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(opts.runId))) {
528
+ errors.push("--run-id must be a canonical UUID");
529
+ } else {
530
+ opts.runId = String(opts.runId).toLowerCase();
531
+ }
532
+ }
518
533
  opts.bundleInclude = [...new Set(opts.bundleInclude)];
519
534
  opts.execution = opts.remote ? "remote" : opts.localOnly ? "local" : null;
520
535
  if (typeof opts.stackPath !== "string" || !opts.stackPath.trim() || opts.stackPath.startsWith("/") ||
@@ -537,6 +552,7 @@ export function parseStackArgs(argv) {
537
552
  "worktree root's default (shared canonical) project. Point --stack-path at a slot-derived stack directory with its " +
538
553
  "own supabase/config.toml (distinct project_id + remapped ports).");
539
554
  }
555
+ if (opts.releasePortClaim && (command !== "done" || !opts.reap || opts.localExec)) errors.push("--release-port-claim requires `stack done --reap` without --local-exec");
540
556
  if (opts.reap && !["done", "run"].includes(command)) {
541
557
  errors.push("--reap is valid only with `stack done` or `stack run`");
542
558
  }
@@ -844,6 +860,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
844
860
  // A prepared directory carries no retention marker, so its reservation is
845
861
  // renewed here on the batch's own beat (Codex #928 R21 P2).
846
862
  renew: (options) => ports.renew(options),
863
+ bindLease: (leaseId) => ports.bindLease(leaseId),
847
864
  // BOT-2237: returns the VERIFIED disposition the receipt reports.
848
865
  release: ({ reaped = false } = {}) => {
849
866
  let released = false;
@@ -871,6 +888,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
871
888
  )));
872
889
  return {
873
890
  ...execution,
891
+ bindLease: (leaseId) => bindLeasedStackClaim(stackDirFor(execution), leaseId, portRegistryDir ? { registryDir: portRegistryDir } : undefined),
874
892
  // Release is called once the batch's signed reap is done, so hand the host
875
893
  // its port block back immediately rather than holding it for the retention
876
894
  // window this batch no longer needs (Codex #928 R19 P2).
@@ -1456,7 +1474,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
1456
1474
  decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
1457
1475
  break frameLoop;
1458
1476
  }
1459
- if (isDone(st, sig.payload)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
1477
+ 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
1478
  }
1461
1479
  }
1462
1480
  } catch (err) {
@@ -3011,6 +3029,9 @@ export async function cmdDone(leaseId, opts, {
3011
3029
  localTeardownFn = localTeardown,
3012
3030
  emitResult = emit,
3013
3031
  refreshAuth = refreshRejectedCacheAuth,
3032
+ waitFn = waitForLease,
3033
+ recoverClaim = recoverLocalLeaseClaim,
3034
+ localClaimExists = hasLocalLeaseClaim,
3014
3035
  } = {}) {
3015
3036
  // BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
3016
3037
  // its own — otherwise an active lease can't be released until the cache is manually
@@ -3243,10 +3264,65 @@ export async function cmdDone(leaseId, opts, {
3243
3264
  (retry.budget_reset === true ? " and the reap retry budget was reset" : ""),
3244
3265
  }
3245
3266
  : {};
3246
- return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition,
3267
+ let portBlock = null;
3268
+ if (opts.releasePortClaim || (opts.reap && !opts.localExec && localClaimExists(leaseId))) {
3269
+ try {
3270
+ const current = await call("get_stack_lease", { lease_id: leaseId });
3271
+ if (!current.ok || !current.data?.success) {
3272
+ portBlock = { released: false, reason: "owned lease context could not be read" };
3273
+ } else {
3274
+ const verified = await waitFn(leaseId,
3275
+ (s, p) => s === "reaped" && p?.cleanup_verified === true,
3276
+ // BOT-1948: an accepted failed reap, or a reaped snapshot the server marks as never
3277
+ // provable (cleanup_verified strictly false), ends at once; a raw frame keeps waiting.
3278
+ (s, p) => (s === "reap_failed" && p?.cleanup_verified === true) || (s === "reaped" && p?.cleanup_verified === false),
3279
+ { timeoutSec: opts.reapTimeout ?? 300, auth: rpc.auth });
3280
+ if (verified.woke && verified.cleanupEvidence) {
3281
+ state = "reaped";
3282
+ portBlock = await recoverClaim({ ...current.data, lease_id: leaseId }, verified.cleanupEvidence);
3283
+ } else portBlock = { released: false, reason: "accepted cleanup proof is unavailable; local claim retained" };
3284
+ }
3285
+ } catch (error) {
3286
+ portBlock = { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
3287
+ }
3288
+ }
3289
+ return emitResult(buildReceipt({ command: "done", outcome: portBlock?.released === false ? "error" : "released", lease_id: leaseId, state, disposition,
3247
3290
  ...retried,
3291
+ ...(portBlock ? { port_block: portBlock } : {}),
3248
3292
  ...(helperTeardown ? { teardown: "helper" } : {}),
3249
- ...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
3293
+ ...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, portBlock?.released === false ? EXIT.CLEANUP_FAILED : EXIT.OK);
3294
+ }
3295
+
3296
+ /** Recover only in the original worktree, with its project lock and an exact lease-bound claim. */
3297
+ export async function recoverLocalLeaseClaim(lease, proof, { cwd = process.cwd(), recover = recoverLeasedStackClaim, lock = acquireStackLock, claimContext = leasedClaimContext } = {}) {
3298
+ let held;
3299
+ try {
3300
+ const root = realpathSync(cwd);
3301
+ const path = lease.stack_path;
3302
+ if (!lease.worktree_root || root !== realpathSync(lease.worktree_root) || typeof path !== "string" || !path || path === "." || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
3303
+ return { released: false, reason: "run recovery from the lease's original worktree with its isolated path" };
3304
+ }
3305
+ const context = claimContext(path, lease.lease_id, { cwd });
3306
+ let stackDir, project;
3307
+ try {
3308
+ stackDir = stackDirFor(resolveStackPath(cwd, path));
3309
+ project = projectIdUnder(stackDir);
3310
+ } catch (error) {
3311
+ // No path is reconstructed from remote input. A removed directory needs
3312
+ // an exact local lease/host/path binding; unknown or legacy claims stay kept.
3313
+ if (error.code !== "ENOENT" || !context) throw error;
3314
+ stackDir = context.stackDir;
3315
+ project = context.project;
3316
+ }
3317
+ if (typeof project !== "string" || !/^[A-Za-z0-9_-]+$/.test(project) ||
3318
+ (context && (project !== context.project || stackDir !== context.stackDir))) {
3319
+ return { released: false, reason: "local stack project identity is unavailable or invalid" };
3320
+ }
3321
+ held = await lock(lockPathForProject(project), { timeoutMs: 0 });
3322
+ return recover(stackDir, lease.lease_id, proof);
3323
+ } catch (error) {
3324
+ return { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
3325
+ } finally { held?.release(); }
3250
3326
  }
3251
3327
 
3252
3328
  const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
@@ -3968,6 +4044,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3968
4044
  }
3969
4045
 
3970
4046
  leaseProvisioned = true; // from here a Helper may act on this lease
4047
+ execution.bindLease?.(leaseId);
3971
4048
  let state = request.data.state;
3972
4049
  if (reusedIdle) {
3973
4050
  idleLease.reused = "idle";
@@ -4408,7 +4485,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4408
4485
  ? `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
4486
  // BOT-2418: an idle release is a deliberate keep (BOT-1803), not an unproven reap.
4410
4487
  : 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)`
4488
+ ? `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
4489
  : `reap not proven (${cleanupResult?.error ?? "no cleanup ran"})${retained} so a stack the Helper may still run is never double-booked` }
4413
4490
  : releaseError || disposition?.released === false
4414
4491
  ? { released: false, reason: `the reap was proven but the claim could not be removed (${releaseError?.message ?? "it is still on disk"})${retained}` }
@@ -4481,12 +4558,16 @@ export function runReceiptFields(result) {
4481
4558
  export async function cmdRun(opts, childArgv, {
4482
4559
  lifecycle = runStackLifecycle, report = reportStackRunOutcome, now = Date.now, env = process.env,
4483
4560
  } = {}) {
4561
+ // A caller that needs a wait selects this immutable UUID before dispatch.
4562
+ // Ordinary foreground runs still receive a unique telemetry id, but it is not
4563
+ // retrospectively advertised as an armable completion contract.
4564
+ const runId = opts.runId ?? randomUUID();
4484
4565
  const startedAt = now();
4485
4566
  const result = await lifecycle(opts, childArgv);
4486
4567
  const fields = runReceiptFields(result);
4487
4568
  const code = emit(buildReceipt(fields), opts, result.exitCode);
4488
4569
  try {
4489
- await report({ fields: stackRunOutcomeFields(result), exitCode: code, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
4570
+ await report({ fields: stackRunOutcomeFields(result), exitCode: code, runId, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
4490
4571
  } catch { /* best-effort: the receipt and exit code are already final */ }
4491
4572
  return code;
4492
4573
  }
@@ -4535,7 +4616,7 @@ export async function cmdStack(argv, { report = reportStackRunOutcome, outcomeCh
4535
4616
  const code = emit(buildReceipt(fields), opts, EXIT.INVALID);
4536
4617
  // BOT-2467: a refused `stack run` is recorded as an `invocation` outcome (no lease).
4537
4618
  if (command === "run") {
4538
- try { await report({ fields, exitCode: code, opts, env: process.env, durationMs: 0, channel: await outcomeChannel(argv) }); } catch { /* best-effort */ }
4619
+ try { await report({ fields, exitCode: code, runId: opts.runId ?? undefined, opts, env: process.env, durationMs: 0, channel: await outcomeChannel(argv) }); } catch { /* best-effort */ }
4539
4620
  }
4540
4621
  process.exitCode = code;
4541
4622
  return code;
package/src/wait-core.mjs CHANGED
@@ -85,9 +85,9 @@ export const WAIT_SUPERSEDED_SIGNAL_TYPE = "wait_superseded";
85
85
  const HOST_APPROVAL_HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
86
86
 
87
87
  // The USER-ARMABLE spine signal_type allowlist, for the `event:` escape hatch. The full
88
- // public.agent_signal_type enum is these PLUS the control types above
89
- // (WAIT_SUPERSEDED_SIGNAL_TYPE) — control types are intentionally excluded here so they
90
- // can't be armed on.
88
+ // public.agent_signal_type enum also has control-only and dedicated-grammar types (for
89
+ // example `wait_superseded` and BOT-2629's `stack_run`); they are intentionally excluded
90
+ // here so raw event conditions cannot bypass their narrower matching contracts.
91
91
  export const SIGNAL_TYPES = Object.freeze([
92
92
  "ci_run",
93
93
  "pr_review",
@@ -110,6 +110,7 @@ export const SIGNAL_TYPES = Object.freeze([
110
110
  ]);
111
111
 
112
112
  const LOCK_SUBTYPES = Object.freeze(["playwright_lane", "vite_port", "backend_port", "supabase_local"]);
113
+ const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
113
114
 
114
115
  // ---------------------------------------------------------------------------
115
116
  // Condition grammar: `TYPE:key=val,key=val`
@@ -194,6 +195,16 @@ const VALIDATORS = {
194
195
  if (!p.id) throw new Error("test-run needs id=<run_id>");
195
196
  return { runId: String(p.id) };
196
197
  },
198
+ // BOT-2629: this is deliberately a dedicated condition rather than a raw
199
+ // event escape hatch. The UUID is chosen before dispatch and the exact subject
200
+ // prevents another run on the same idle/re-adopted lease waking this waiter.
201
+ "stack-run": (p) => {
202
+ const unexpected = Object.keys(p).filter((key) => key !== "id");
203
+ if (unexpected.length) throw new Error(`stack-run does not accept ${unexpected.join(", ")}`);
204
+ const id = String(p.id ?? "").trim().toLowerCase();
205
+ if (!CANONICAL_UUID_RE.test(id)) throw new Error("stack-run needs id=<canonical run UUID>");
206
+ return { id };
207
+ },
197
208
  // BOT-1063 — guidance: park on a command-post guidance RESPONSE to a request
198
209
  // this agent raised (pairs with request_guidance). The signal is
199
210
  // recipient-scoped to the requester and keyed by the request id.
@@ -1112,6 +1123,9 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1112
1123
  prReviewFiltersMatch(signal, params);
1113
1124
  case "test-run":
1114
1125
  return signal.signal_type === "test_run" && signal.subject_key === `run:${params.runId}`;
1126
+ case "stack-run":
1127
+ return signal.signal_type === "stack_run" && signal.subject_key === `stack-run:${params.id}` &&
1128
+ signal.payload?.run_id === params.id && Number.isInteger(signal.payload?.exit_code);
1115
1129
  case "guidance":
1116
1130
  return signal.signal_type === "guidance_response" && signal.subject_key === params.request;
1117
1131
  case "lease":