@botbuddy/cli 1.60.1 → 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.1",
3
- "source_commit": "ac9d46a93d57bb9d5af94cba238532767c1f19cc",
4
- "source_sha256": "667acf404a591bd0e09357645192040cba7a0b2085f33d89be6a4bbc84a6dd53",
2
+ "release_version": "1.60.3",
3
+ "source_commit": "ce15cf76534b99c30468bdd541f2ab8e9340ee08",
4
+ "source_sha256": "962d9cae75ce935da9306a538440ccb1448270ea80b9c0d4ba76be0c3c3a6707",
5
5
  "target": "darwin-arm64",
6
- "sha256": "9dd4af84ad4131cf69c94bbed69f3c51087ca8ac4d742df6e9367faadbeddb97"
6
+ "sha256": "989d317d944147c7ec86dc894c74b6a462e95fa85ee709e3e0c562783e231668"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.1",
3
- "source_commit": "ac9d46a93d57bb9d5af94cba238532767c1f19cc",
4
- "source_sha256": "667acf404a591bd0e09357645192040cba7a0b2085f33d89be6a4bbc84a6dd53",
2
+ "release_version": "1.60.3",
3
+ "source_commit": "ce15cf76534b99c30468bdd541f2ab8e9340ee08",
4
+ "source_sha256": "962d9cae75ce935da9306a538440ccb1448270ea80b9c0d4ba76be0c3c3a6707",
5
5
  "target": "linux-x64",
6
- "sha256": "61db8eb2108fcf9407460e96f487316728e262927241b5ee672ffca3443a11d3"
6
+ "sha256": "90a9017148902699e54e26fddb940abc87d22ca96c3f415a75d4d172614dd281"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.1",
4
- "source_commit": "ac9d46a93d57bb9d5af94cba238532767c1f19cc",
5
- "source_sha256": "667acf404a591bd0e09357645192040cba7a0b2085f33d89be6a4bbc84a6dd53",
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": "9dd4af84ad4131cf69c94bbed69f3c51087ca8ac4d742df6e9367faadbeddb97",
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": "61db8eb2108fcf9407460e96f487316728e262927241b5ee672ffca3443a11d3",
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.1",
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`);
@@ -49,8 +49,8 @@ function boundedCode(value) {
49
49
  }
50
50
 
51
51
  /**
52
- * Classify one `bb stack run` terminal from its receipt fields (`runReceiptFields`
53
- * output, or the argument-error receipt) and its exit code. Pure.
52
+ * Classify one `bb stack run` terminal from its internal outcome fields
53
+ * (`stackRunOutcomeFields`, or the argument-error receipt) and exit code. Pure.
54
54
  *
55
55
  * script — the lease was active and the child's exit decided the result
56
56
  * (`--hard-ttl` stop → code HARD_TTL)
@@ -64,7 +64,9 @@ export function classifyStackRunOutcome(fields = {}, exitCode) {
64
64
  const outcome = typeof fields.outcome === "string" ? fields.outcome : null;
65
65
  if (outcome === "interrupted") return { category: "interrupted", code };
66
66
  if (code && PLATFORM_CODES.has(code)) return { category: "platform", code };
67
- if (fields.fenced === true) return { category: "platform", code: code ?? "LEASE_LOST" };
67
+ // The stdout fence also covers unresolved cleanup. The lifecycle supplies its
68
+ // original lease-fence fact separately so coincident child exits cannot hide it.
69
+ if ((fields.lease_fenced ?? fields.fenced) === true) return { category: "platform", code: code ?? "LEASE_LOST" };
68
70
  if (outcome === "completed" && exitCode === 0) return { category: "script", code: null };
69
71
  if (outcome === "failed" && code == null && child != null) {
70
72
  if (SIGNAL_EXITS.has(exitCode)) return { category: "interrupted", code: null };
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
  }
@@ -728,6 +732,10 @@ export function truncateReceipt(receipt, maxBytes = DEFAULT_RECEIPT_MAX_BYTES) {
728
732
  schema_version: r.schema_version, command: r.command, outcome: r.outcome,
729
733
  lease_id: r.lease_id ?? null, state: r.state ?? null, exit_code: r.exit_code,
730
734
  error: r.error ?? null, truncated: true,
735
+ ...(r.cleanup !== undefined ? { cleanup: r.cleanup } : {}),
736
+ ...(r.fenced !== undefined ? { fenced: r.fenced } : {}),
737
+ ...(r.cleanup_code ? { cleanup_code: r.cleanup_code } : {}),
738
+ ...(r.cleanup_error ? { cleanup_error: String(r.cleanup_error).slice(0, 160) } : {}),
731
739
  };
732
740
  }
733
741
 
@@ -840,6 +848,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
840
848
  // A prepared directory carries no retention marker, so its reservation is
841
849
  // renewed here on the batch's own beat (Codex #928 R21 P2).
842
850
  renew: (options) => ports.renew(options),
851
+ bindLease: (leaseId) => ports.bindLease(leaseId),
843
852
  // BOT-2237: returns the VERIFIED disposition the receipt reports.
844
853
  release: ({ reaped = false } = {}) => {
845
854
  let released = false;
@@ -867,6 +876,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
867
876
  )));
868
877
  return {
869
878
  ...execution,
879
+ bindLease: (leaseId) => bindLeasedStackClaim(stackDirFor(execution), leaseId, portRegistryDir ? { registryDir: portRegistryDir } : undefined),
870
880
  // Release is called once the batch's signed reap is done, so hand the host
871
881
  // its port block back immediately rather than holding it for the retention
872
882
  // window this batch no longer needs (Codex #928 R19 P2).
@@ -1156,6 +1166,11 @@ export const REMOTE_REFUSAL_GUIDANCE = Object.freeze({
1156
1166
 
1157
1167
  /** The refusal text for a receipt: the server's message, else the CLI's guidance, else the code. */
1158
1168
  export function leaseRefusalError(data) {
1169
+ if (data?.lease_id && ["STACK_CONTEXT_BIND_FAILED", "STACK_CONTEXT_ALREADY_BOUND"].includes(data?.code)) {
1170
+ return `${data.message || data.code}; lease ${data.lease_id} is ${data.state ?? "unknown"}. ` +
1171
+ `Inspect with \`bb stack status ${data.lease_id}\`; after the owning batch ends, release with \`bb stack done ${data.lease_id} --reap\`, ` +
1172
+ `then wait with \`bb wait 'lease:id=${data.lease_id},state=reaped' --heartbeat\`.`;
1173
+ }
1159
1174
  return data?.message || REMOTE_REFUSAL_GUIDANCE[data?.code] || data?.code || "lease request refused";
1160
1175
  }
1161
1176
 
@@ -1447,7 +1462,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
1447
1462
  decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
1448
1463
  break frameLoop;
1449
1464
  }
1450
- 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; }
1451
1466
  }
1452
1467
  }
1453
1468
  } catch (err) {
@@ -3002,6 +3017,9 @@ export async function cmdDone(leaseId, opts, {
3002
3017
  localTeardownFn = localTeardown,
3003
3018
  emitResult = emit,
3004
3019
  refreshAuth = refreshRejectedCacheAuth,
3020
+ waitFn = waitForLease,
3021
+ recoverClaim = recoverLocalLeaseClaim,
3022
+ localClaimExists = hasLocalLeaseClaim,
3005
3023
  } = {}) {
3006
3024
  // BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
3007
3025
  // its own — otherwise an active lease can't be released until the cache is manually
@@ -3234,10 +3252,65 @@ export async function cmdDone(leaseId, opts, {
3234
3252
  (retry.budget_reset === true ? " and the reap retry budget was reset" : ""),
3235
3253
  }
3236
3254
  : {};
3237
- 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,
3238
3278
  ...retried,
3279
+ ...(portBlock ? { port_block: portBlock } : {}),
3239
3280
  ...(helperTeardown ? { teardown: "helper" } : {}),
3240
- ...(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(); }
3241
3314
  }
3242
3315
 
3243
3316
  const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
@@ -3452,9 +3525,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3452
3525
  // BOT-2099: a best-effort diagnostic read (ledger, persisted Helper reason) never throws.
3453
3526
  const readLeaseQuietly = async (id) => { try { return await api.get?.(id) ?? null; } catch { return null; } };
3454
3527
  // BOT-2237: the typed end of a park that cannot re-authenticate — names the lease, never a
3455
- // capacity timeout. Exit AUTH: the caller's credential, not the lease, is what failed.
3528
+ // capacity timeout. Keep AUTH after confirmed cleanup; unproven cleanup takes
3529
+ // CLEANUP_FAILED while preserving the primary credential diagnostic.
3456
3530
  const authUnavailableResult = (waited, phase, cleanup, waitedLeaseId = leaseId) => ({
3457
- exitCode: EXIT.AUTH, outcome: "error", code: PARK_INCIDENT_REASONS.AUTH, leaseId: waitedLeaseId ?? null, cleanup,
3531
+ exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : EXIT.AUTH,
3532
+ outcome: "error", code: PARK_INCIDENT_REASONS.AUTH, leaseId: waitedLeaseId ?? null, cleanup,
3458
3533
  error: `${waited.error} while the lease was ${phase}; its server state is unknown to this run — inspect it with \`bb stack status ${waitedLeaseId}\` after \`bb doctor --fix\``,
3459
3534
  ...(parkIncident ? { incident: parkIncident } : {}),
3460
3535
  });
@@ -3595,7 +3670,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3595
3670
  // state — instead of leaving this wait to park until --reap-timeout on a reap
3596
3671
  // that already failed. Report that specific outcome instead of the generic
3597
3672
  // "was not proven" timeout message.
3598
- // BOT-2099 (Codex #1057 R6): the signed-reap wait survives auth/availability blips too.
3673
+ // Cleanup owns one immutable deadline. Its signed reap wait may recover from a
3674
+ // transient relay/auth blip, but never repeats the release request. A persistent
3675
+ // failure remains explicit receipt data below; it never fabricates a reap.
3599
3676
  const reaped = await resilientWait(leaseId, (state) => state === "reaped", (state) => state === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth });
3600
3677
  if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
3601
3678
  if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
@@ -3612,12 +3689,21 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3612
3689
  if (recheck?.ok && recheck.data?.success && recheck.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
3613
3690
  const postCheck = durableReapFailure(recheck);
3614
3691
  if (postCheck) return cleanupResult = postCheck;
3615
- return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
3692
+ const persistentWaitFailure = reaped?.incident && reaped.incident.recovered === false
3693
+ ? reaped.incident.last_error
3694
+ : null;
3695
+ return cleanupResult = {
3696
+ ok: false,
3697
+ error: persistentWaitFailure ?? (reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven")),
3698
+ };
3616
3699
  })().catch((error) => {
3617
- // BOT-2237: a release/read whose session renewal THROWS (e.g. `session_mint_invalid`
3618
- // once the cache names another agent) is a failed cleanup of THIS lease — never an
3619
- // exception that escapes the lifecycle and loses the lease id from the receipt.
3620
- return cleanupResult = { ok: false, error: `cleanup could not run: ${error?.code ? `${error.code}: ` : ""}${error?.message ?? error}` };
3700
+ // A release/read whose session renewal throws is a failed cleanup of THIS lease —
3701
+ // never an exception that escapes the lifecycle and loses its recovery receipt.
3702
+ return cleanupResult = {
3703
+ ok: false,
3704
+ code: error?.code ?? null,
3705
+ error: String(error?.message ?? error),
3706
+ };
3621
3707
  });
3622
3708
  return cleanupPromise;
3623
3709
  };
@@ -3906,11 +3992,14 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3906
3992
  ?? stackPathRootRefusalNotice(request.data?.code, request.data?.message)
3907
3993
  ?? remoteRefusalNotice(request.data?.code);
3908
3994
  if (hint) stderrLine(hint);
3909
- // BOT-1803: the slot's idle lease stays idle (another mode/host); keep its block.
3910
- if (request.data?.code === "STACK_EXECUTION_MODE_MISMATCH" && request.data?.lease_id) retainBlock = true;
3995
+ // A refusal naming an admitted lease (including a failed context bind or
3996
+ // an idle mode mismatch) is not proof that its ports are free. Keep the
3997
+ // claim until owner-safe cleanup is verified; never reap a possibly live batch.
3998
+ if (request.data?.lease_id) retainBlock = true;
3911
3999
  return {
3912
4000
  exitCode: leaseRefusalExit(request.data?.code), outcome: "error", code: request.data?.code,
3913
4001
  ...capacityStaleReceiptFields(request.data), error: leaseRefusalError(request.data),
4002
+ ...(request.data.lease_id ? { leaseId: request.data.lease_id, state: request.data.state ?? null } : {}),
3914
4003
  };
3915
4004
  }
3916
4005
  leaseId = request.data.lease_id;
@@ -3934,10 +4023,16 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3934
4023
  // With a stable identity a live lease in THIS stack directory is bound to our block.
3935
4024
  if (request.data.stack_path != null && request.data.stack_path === execution.stackPath &&
3936
4025
  (request.data.worktree_root == null || request.data.worktree_root === execution.worktreeRoot)) retainBlock = true;
3937
- return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, error: "a live lease already exists for this agent and stack slot; wait for that batch to finish instead of sharing its stack" };
4026
+ const blockedState = request.data.state ?? "unknown";
4027
+ const recovery = blockedState === "reaping"
4028
+ ? `wait with \`bb wait 'lease:id=${leaseId},state=reaped' --heartbeat\``
4029
+ : `inspect with \`bb stack status ${leaseId}\`; after the owning batch ends, release with \`bb stack done ${leaseId} --reap\`, then wait with \`bb wait 'lease:id=${leaseId},state=reaped' --heartbeat\``;
4030
+ return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, state: blockedState,
4031
+ error: `live lease already exists for this agent and stack slot: ${leaseId} is ${blockedState}; ${recovery}` };
3938
4032
  }
3939
4033
 
3940
4034
  leaseProvisioned = true; // from here a Helper may act on this lease
4035
+ execution.bindLease?.(leaseId);
3941
4036
  let state = request.data.state;
3942
4037
  if (reusedIdle) {
3943
4038
  idleLease.reused = "idle";
@@ -4112,7 +4207,13 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4112
4207
  };
4113
4208
  }
4114
4209
  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 }; }
4115
- if (parked?.error || parked?.auth) { const cleanup = await requestCleanup(); return { exitCode: parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup, ...incident }; }
4210
+ if (parked?.error || parked?.auth) {
4211
+ const cleanup = await requestCleanup();
4212
+ return {
4213
+ exitCode: parked?.auth && cleanup?.ok === false ? EXIT.CLEANUP_FAILED : (parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED),
4214
+ outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup, ...incident,
4215
+ };
4216
+ }
4116
4217
  state = parked.state;
4117
4218
  }
4118
4219
  if (state !== "active") {
@@ -4143,7 +4244,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4143
4244
  // BOT-1816's class of dropped wakes) even though the lease genuinely went
4144
4245
  // active or was deferred back to queued. One direct read before declaring
4145
4246
  // a timeout avoids reaping a healthy stack or destroying a kept queue place.
4146
- const recheck = await api.get(leaseId);
4247
+ // Production adapters always expose `get`. Focused lifecycle adapters may
4248
+ // intentionally model only admission and release; preserve their original
4249
+ // terminal cleanup receipt rather than replacing it with a test-only TypeError.
4250
+ const recheck = api.get ? await api.get(leaseId) : null;
4147
4251
  if (recheck?.ok && recheck.data?.success && recheck.data.state === "queued") {
4148
4252
  parkView = recheck.data;
4149
4253
  const why = parkView.provision_defer_reason ?? "host capacity";
@@ -4153,9 +4257,16 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4153
4257
  }
4154
4258
  if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
4155
4259
  const cleanup = await requestCleanup();
4156
- // BOT-2467: `timeoutPhase` is for the outcome record only (never on the receipt):
4157
- // an admitted lease whose stack never started is not a capacity shortage.
4158
- return { exitCode: EXIT.TIMEOUT, outcome: "timeout", timeoutPhase: "provision", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
4260
+ const cleanupFailed = cleanup?.ok === false;
4261
+ return {
4262
+ exitCode: cleanupFailed ? EXIT.CLEANUP_FAILED : EXIT.TIMEOUT,
4263
+ outcome: "timeout", timeoutPhase: "provision", leaseId,
4264
+ // An unproven cleanup is more actionable than the generic timeout for an
4265
+ // admitted lease, because recovery must preserve its fenced claim.
4266
+ error: cleanupFailed ? cleanup.error : `physical provision did not reach active in ${provisionTimeout}s`,
4267
+ cleanup, ...(parkIncident ? { incident: parkIncident } : {}),
4268
+ };
4269
+
4159
4270
  }
4160
4271
  }
4161
4272
  if (active?.failed || active?.error || active?.auth) {
@@ -4167,7 +4278,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4167
4278
  if (read?.ok && read.data?.success && typeof read.data.error === "string" && read.data.error) why = ` — ${read.data.error}`;
4168
4279
  }
4169
4280
  const cleanup = await requestCleanup();
4170
- return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active${why}`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
4281
+ return {
4282
+ exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : (active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED),
4283
+ outcome: "error", leaseId, state: active?.state ?? null, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active${why}`, cleanup,
4284
+ ...(parkIncident ? { incident: parkIncident } : {}),
4285
+ };
4171
4286
  }
4172
4287
  }
4173
4288
  break;
@@ -4358,7 +4473,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4358
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`
4359
4474
  // BOT-2418: an idle release is a deliberate keep (BOT-1803), not an unproven reap.
4360
4475
  : cleanupResult?.ok === true && cleanupResult.idle === true
4361
- ? `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)`
4362
4477
  : `reap not proven (${cleanupResult?.error ?? "no cleanup ran"})${retained} so a stack the Helper may still run is never double-booked` }
4363
4478
  : releaseError || disposition?.released === false
4364
4479
  ? { released: false, reason: `the reap was proven but the claim could not be removed (${releaseError?.message ?? "it is still on disk"})${retained}` }
@@ -4374,6 +4489,7 @@ export function runReceiptFields(result) {
4374
4489
  // BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
4375
4490
  // so automation can branch on it instead of matching the message text.
4376
4491
  command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
4492
+ ...(result.state ? { state: result.state } : {}),
4377
4493
  // BOT-2468: the lease's /docker?lease= deeplink (null when the server sent none).
4378
4494
  web_url: result.webUrl ?? null,
4379
4495
  // BOT-2024: a stale-capacity refusal carries its phase + armable wait on the receipt.
@@ -4382,6 +4498,13 @@ export function runReceiptFields(result) {
4382
4498
  child_exit_code: result.childExitCode ?? null,
4383
4499
  // BOT-1803: an idle release is `cleanup: { idle: true, idle_since }` (nothing was reaped).
4384
4500
  cleanup: result.cleanup?.idle ? { idle: true, idle_since: result.cleanup.idle_since ?? null } : result.cleanup?.ok ?? null,
4501
+ cleanup_code: result.cleanup?.code ?? null,
4502
+ cleanup_error: result.cleanup?.ok === false ? result.cleanup.error ?? "cleanup was not proven" : null,
4503
+ ...(result.leaseId && result.cleanup?.ok === false ? { recovery: {
4504
+ status: `bb stack status ${result.leaseId}`,
4505
+ wait: `bb wait 'lease:id=${result.leaseId},state=reaped' --heartbeat`,
4506
+ release: `bb stack done ${result.leaseId} --reap`,
4507
+ } } : {}),
4385
4508
  ...(result.volumesKept ? { volumes_kept: true } : {}),
4386
4509
  ...(result.idleLease?.reused ? { reused: result.idleLease.reused } : {}),
4387
4510
  ...(result.idleLease?.reprovisioned ? { reprovisioned: result.idleLease.reprovisioned } : {}),
@@ -4401,7 +4524,7 @@ export function runReceiptFields(result) {
4401
4524
  // BOT-2207: why it ran where it did (e.g. moved_remote_on_requeue) and every host weighed.
4402
4525
  ...(result.execution?.placementReason ? { placement_reason: result.execution.placementReason } : {}),
4403
4526
  ...(result.execution?.considered ? { considered: result.execution.considered } : {}),
4404
- fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
4527
+ fenced: result.fenced === true || result.cleanup?.ok === false, error: result.error ?? result.cleanup?.error ?? null,
4405
4528
  // BOT-2099: a platform auth/availability blip during the park is reported as an
4406
4529
  // incident (reason, window, attempts, recovered) — never as the lease's outcome.
4407
4530
  ...(result.incident ? { platform_incident: result.incident } : {}),
@@ -4440,10 +4563,14 @@ export async function cmdRun(opts, childArgv, {
4440
4563
  */
4441
4564
  /**
4442
4565
  * BOT-2467: the fields the outcome classifier reads — the receipt's, plus facts the
4443
- * receipt deliberately does not carry (which wait a timeout ended). Never emitted.
4566
+ * receipt deliberately does not carry (which wait a timeout ended and the original
4567
+ * lease fence). Never emitted.
4444
4568
  */
4445
4569
  export function stackRunOutcomeFields(result) {
4446
- return { ...runReceiptFields(result), ...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}) };
4570
+ return {
4571
+ ...runReceiptFields(result), lease_fenced: result?.fenced === true,
4572
+ ...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}),
4573
+ };
4447
4574
  }
4448
4575
 
4449
4576
  async function parseErrorOutcomeChannel(argv, { resolveCredential = resolveAgentSessionCredential, callTool = callToolJson } = {}) {
package/src/wait-core.mjs CHANGED
@@ -35,6 +35,7 @@ export const EXIT = Object.freeze({
35
35
  // liveness reaper abandoned it, it was cancelled, or its row is gone. The relay
36
36
  // refuses the stream with a typed `wait_abandoned` error frame; re-arm from next_cursor.
37
37
  ABANDONED: 11,
38
+ LEASE_CLEANUP_FAILED: 12,
38
39
  });
39
40
 
40
41
  // BOT-2013: the uuid spellings Postgres accepts for a lease id (uuid_in), mirrored by the SQL
@@ -58,6 +59,7 @@ export function leaseLostDetail(signal, condition) {
58
59
  lease_id: signal.payload?.lease_id ?? condition.params?.id ?? null,
59
60
  lease_state: leaseState,
60
61
  reap_reason: typeof signal.payload?.reap_reason === "string" ? signal.payload.reap_reason : null,
62
+ ...(condition.params?.state === "active" && signal.payload?.error_class ? { error_class: signal.payload.error_class } : {}),
61
63
  };
62
64
  }
63
65
 
@@ -797,7 +799,15 @@ function compactLeaseLostState(state) {
797
799
  if (!state || typeof state !== "object") return null;
798
800
  const short = (v) => (typeof v === "string" ? v.slice(0, 64) : null);
799
801
  if (state.lease_id == null && state.reap_reason == null) return null;
800
- return { lease_id: short(state.lease_id), lease_state: short(state.lease_state), reap_reason: short(state.reap_reason) };
802
+ return { lease_id: short(state.lease_id), lease_state: short(state.lease_state), reap_reason: short(state.reap_reason),
803
+ ...(state.cleanup_unverified === true ? { cleanup_unverified: true } : {}) };
804
+ }
805
+
806
+ function compactCleanupState(state) {
807
+ if (!state || !["signed_reap", "never_provisioned", "local_exec_teardown", "pre_driver_absence"].includes(state.cleanup_proof)) return null;
808
+ const short = (v) => typeof v === "string" ? v.slice(0, 64) : null;
809
+ return Object.fromEntries(["lease_id", "lease_state", "reap_disposition", "cleanup_proof", "cleanup_receipt_id"]
810
+ .filter((key) => state[key] != null).map((key) => [key, short(state[key])]));
801
811
  }
802
812
 
803
813
  /**
@@ -818,8 +828,9 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
818
828
  // state is the whole point of that receipt (which lease, why) and is already three short
819
829
  // fields, so it is kept compact instead (BOT-2013, Codex #1017 P2).
820
830
  const leaseLostState = clone.outcome === "lease_lost" ? compactLeaseLostState(clone.state) : null;
831
+ const cleanupState = ["matched", "error"].includes(clone.outcome) ? compactCleanupState(clone.state) : null;
821
832
  if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes) {
822
- clone.state = leaseLostState ?? { truncated: true };
833
+ clone.state = leaseLostState ?? cleanupState ?? { truncated: true };
823
834
  }
824
835
  // A matched signal's subject key can be caller-controlled through the raw
825
836
  // event escape hatch. Keep the durable sequence pointer, but not an
@@ -863,6 +874,19 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
863
874
  delete clone.detail;
864
875
  clone.detail_truncated = true;
865
876
  }
877
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && cleanupState) {
878
+ const base = {
879
+ schema_version: clone.schema_version, outcome: clone.outcome,
880
+ ...(clone.error ? { error: String(clone.error).slice(0, 48) } : {}),
881
+ state: cleanupState, truncated: true,
882
+ };
883
+ for (const key of ["client", "next_cursor", "wait_session_id"]) {
884
+ if (clone[key] == null) continue;
885
+ const candidate = { ...base, [key]: clone[key] };
886
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= maxBytes) base[key] = clone[key];
887
+ }
888
+ return base;
889
+ }
866
890
  if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "lease_lost") {
867
891
  // BOT-2013: the bounded fallback keeps the lease-lost detail, then adds the resume
868
892
  // pointers only while they still fit.
@@ -1096,12 +1120,22 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1096
1120
  // stack_lease frame for this lease id is ours to wake on.
1097
1121
  if (signal.signal_type !== "stack_lease" || signal.subject_key !== `lease:${params.id}`) return false;
1098
1122
  // BOT-2400: with `state=`, only that state matches — or a terminal loss on the way
1099
- // (reap_failed for a reap wait; any reap state while waiting for provisioning/active),
1100
- // which finalizeMatch reports as lease_lost. `reaping` en route to `reaped` keeps waiting.
1123
+ // (any reap state while waiting for provisioning/active), which finalizeMatch reports
1124
+ // as lease_lost. `reaping` en route to `reaped` keeps waiting.
1101
1125
  if (params.state != null) {
1102
1126
  const leaseState = signal.payload?.state;
1127
+ if (params.state === "reaped") {
1128
+ // BOT-1948: a raw `reaped` row is not cleanup proof. Only the server's verified
1129
+ // evidence (an accepted signed reap receipt, or a verified never-provisioned no-op)
1130
+ // ends the reap wait as matched/cleanup-failed. An unverified reap_failed is still
1131
+ // terminal (BOT-2400 Codex #1194 P1) and ends lease_lost without claiming cleanup.
1132
+ if (signal.payload?.cleanup_verified === true) return leaseState === "reaped" || leaseState === "reap_failed";
1133
+ // The server marks a reaped registration snapshot it can never prove as
1134
+ // explicitly unverified (strictly false; an absent field keeps waiting).
1135
+ if (signal.payload?.cleanup_verified === false && leaseState === "reaped") return true;
1136
+ return leaseState === "reap_failed";
1137
+ }
1103
1138
  if (leaseState === params.state) return true;
1104
- if (params.state === "reaped") return leaseState === "reap_failed";
1105
1139
  return LEASE_LOST_STATES.has(leaseState);
1106
1140
  }
1107
1141
  return true;
@@ -1564,6 +1598,25 @@ export async function runWaitLoop({
1564
1598
  // provisioning against a dead lease.
1565
1599
  const finalizeMatch = async (entry, signal) => {
1566
1600
  const condition = conditions.find((c) => c.id === entry.condition_id);
1601
+ if (condition?.type === "lease" && condition.params.state === "reaped" && signal.payload?.cleanup_verified === true) {
1602
+ const p = signal.payload;
1603
+ const failed = p.state === "reap_failed";
1604
+ return finalize(failed ? "error" : "matched", {
1605
+ exitCode: failed ? EXIT.LEASE_CLEANUP_FAILED : EXIT.MATCHED, matched: [entry],
1606
+ state: { lease_id: condition.params.id, lease_state: p.state,
1607
+ reap_disposition: p.reap_disposition ?? null, cleanup_proof: p.cleanup_proof ?? null,
1608
+ cleanup_receipt_id: p.cleanup_receipt_id ?? null },
1609
+ error: failed ? "lease_cleanup_failed" : null,
1610
+ });
1611
+ }
1612
+ if (condition?.type === "lease" && condition.params.state === "reaped" && signal.payload?.cleanup_verified === false) {
1613
+ const p = signal.payload;
1614
+ return finalize("lease_lost", {
1615
+ exitCode: EXIT.LEASE_LOST, matched: [entry], error: "lease_lost",
1616
+ state: { lease_id: p.lease_id ?? condition.params.id, lease_state: p.state ?? null,
1617
+ reap_reason: typeof p.reap_reason === "string" ? p.reap_reason : null, cleanup_unverified: true },
1618
+ });
1619
+ }
1567
1620
  const lost = leaseLostDetail(signal, condition);
1568
1621
  if (lost) {
1569
1622
  return finalize("lease_lost", { exitCode: EXIT.LEASE_LOST, matched: [entry], state: lost, error: "lease_lost" });
package/src/wait.mjs CHANGED
@@ -108,9 +108,14 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
108
108
  \`botbuddy stack up\` (recipient-scoped to the owner).
109
109
  Level-triggered: an already-granted lease wakes at once.
110
110
  lease:id=<lease_id>,state=<s> BOT-2400: wait for that exact state (provisioning, active,
111
- reaped). state=reaped is the reap wait: an already-reaped
112
- lease matches at once and a reaping one keeps waiting
113
- (only reap_failed ends it, exit 9).
111
+ reaped). provisioning/active end on lease loss (exit 9,
112
+ with the provisioning failure's reason and class).
113
+ lease:id=<lease_id>,state=reaped proven cleanup of that owned lease (BOT-1948). Requires an
114
+ accepted successful reap receipt or a verified
115
+ never-provisioned no-op; preserves stop/destroy
116
+ disposition. An already-cleaned lease matches at once;
117
+ reaping keeps waiting. A verified failed reap exits 12;
118
+ an unverified gave-up reap exits 9 (lease_lost).
114
119
  guidance:request=<post_id> a command-post guidance request being answered
115
120
  (scoped to the asking agent; fetch the body with
116
121
  get_guidance_response)
@@ -200,7 +205,8 @@ OUTPUT
200
205
  · 11 abandoned (the server ended this wait while it
201
206
  was parked — reaped after 180 s without a live stream,
202
207
  or cancelled; receipt outcome abandoned +
203
- state.status/reason — re-arm with --since <next_cursor>).
208
+ state.status/reason — re-arm with --since <next_cursor>)
209
+ · 12 named lease cleanup failed.
204
210
  If the receipt has
205
211
  reacquire_lock (claim=true), acquire_lock again FIRST and re-run
206
212
  only on a queued/busy result (granted = you hold it — just resume);