@botbuddy/cli 1.60.0 → 1.60.2

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.0",
3
- "source_commit": "2bde2741ba9158ce03147a2ff28f1c6a44f89e25",
4
- "source_sha256": "7091f785b4820eccd5f9596e04def9ef58a02cbb5130ccb84bd55ae77e71ec67",
2
+ "release_version": "1.60.2",
3
+ "source_commit": "6c687a8220e66196ede9b560405ff1493d0752f0",
4
+ "source_sha256": "2bb86426999a110ead51a17d7ab60b5cc816901b9cbd3c4426b6d02e75ab6288",
5
5
  "target": "darwin-arm64",
6
- "sha256": "b6fe4ed183d92a32e398cb9de3f82ac6b4634188409f51eadad457ca6b37c307"
6
+ "sha256": "5a197ab76ecd57a479f26d364ad06b1f226a45ac6642a7f034ab725c432940e7"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.0",
3
- "source_commit": "2bde2741ba9158ce03147a2ff28f1c6a44f89e25",
4
- "source_sha256": "7091f785b4820eccd5f9596e04def9ef58a02cbb5130ccb84bd55ae77e71ec67",
2
+ "release_version": "1.60.2",
3
+ "source_commit": "6c687a8220e66196ede9b560405ff1493d0752f0",
4
+ "source_sha256": "2bb86426999a110ead51a17d7ab60b5cc816901b9cbd3c4426b6d02e75ab6288",
5
5
  "target": "linux-x64",
6
- "sha256": "3a1839c5800e38bf9bb7a1465555e0cace01ef82d9c382fde1e036608c536fe8"
6
+ "sha256": "f3c2ae3f7dd8c80ff439817d25e0577f236d532dafbc6714402945a84abce354"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.0",
4
- "source_commit": "2bde2741ba9158ce03147a2ff28f1c6a44f89e25",
5
- "source_sha256": "7091f785b4820eccd5f9596e04def9ef58a02cbb5130ccb84bd55ae77e71ec67",
3
+ "release_version": "1.60.2",
4
+ "source_commit": "6c687a8220e66196ede9b560405ff1493d0752f0",
5
+ "source_sha256": "2bb86426999a110ead51a17d7ab60b5cc816901b9cbd3c4426b6d02e75ab6288",
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": "b6fe4ed183d92a32e398cb9de3f82ac6b4634188409f51eadad457ca6b37c307",
11
+ "sha256": "5a197ab76ecd57a479f26d364ad06b1f226a45ac6642a7f034ab725c432940e7",
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": "3a1839c5800e38bf9bb7a1465555e0cace01ef82d9c382fde1e036608c536fe8",
18
+ "sha256": "f3c2ae3f7dd8c80ff439817d25e0577f236d532dafbc6714402945a84abce354",
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.0",
3
+ "version": "1.60.2",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -483,12 +483,24 @@ does not prove that the workload is still running.
483
483
 
484
484
  ${bold("Safe recovery")}
485
485
  1. Preserve the command-run ID and the original result.
486
- 2. Prefer the owning runner's local terminal receipt for the workload verdict.
487
- 3. If the receipt is terminal, do not cancel or duplicate the command solely
486
+ 2. Run ${cyan("bb run inspect <command-run-id>")} from the owning checkout.
487
+ 3. Prefer the owning runner's local terminal receipt for the workload verdict.
488
+ 4. If the receipt is terminal, do not cancel or duplicate the command solely
488
489
  because remote state remains ${cyan("registered")} or ${cyan("running")}.
489
- 4. If the receipt is absent/non-terminal, retain the original runner result;
490
+ 5. If the receipt is absent/non-terminal, retain the original runner result;
490
491
  do not poll or infer completion from elapsed time.
491
- 5. Report the receipt and remote discrepancy as telemetry evidence.
492
+ 6. Report the receipt and remote discrepancy as telemetry evidence. A stale
493
+ test-run or command-run is never validation evidence.
494
+
495
+ ${bold("One controlled push retry")}
496
+ Only after inspection leaves the original push unresolved, arm an exact-SHA CI
497
+ wait before one new push, then verify the resulting CI run reports that SHA:
498
+
499
+ ${cyan("bb wait 'ci:repo=SupplySpark/supplyspark-ent,scope=next,sha=<full-sha>' --heartbeat")}
500
+
501
+ Do not substitute ${cyan("pr-state")}, a timer, or a status-polling loop for a bare
502
+ push. If the prior push has no terminal receipt, keep it unresolved rather than
503
+ counting it as validation evidence.
492
504
 
493
505
  ${bold("Boundary")}
494
506
  Command-run status is agent-owned. A different agent may be denied access; that
package/src/run.mjs CHANGED
@@ -11,7 +11,7 @@ import { stackLeaseIdFromEnv, withStackLeaseCorrelation } from "./stack-lease-co
11
11
  import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
12
12
  import { dirname, join } from "node:path";
13
13
  import { homedir } from "node:os";
14
- import { spawn } from "node:child_process";
14
+ import { execFileSync, spawn } from "node:child_process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { performance } from "node:perf_hooks";
17
17
  import { callToolJson } from "./api.mjs";
@@ -26,7 +26,7 @@ import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
26
26
  import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
27
27
  import { VERSION } from "./version.mjs";
28
28
  import {
29
- TERMINAL_RETRY_BUDGET_MS, DELIVERY_CALL_TIMEOUT_MS, callWithContextIdentity, classifyTerminalUpdateFailure,
29
+ TERMINAL_RETRY_BUDGET_MS, DELIVERY_CALL_TIMEOUT_MS, callWithContextIdentity, classifyTerminalUpdateFailure, contextServerUrl,
30
30
  ensureRunDelivery, parkedRecord, runDelivery, terminalUpdateAccepted,
31
31
  } from "./run-delivery.mjs";
32
32
 
@@ -221,6 +221,154 @@ export function commandHash(command) { return createHash("sha256").update(JSON.s
221
221
  export function environmentHash(cwd, environment) { return createHash("sha256").update(`${cwd}\0${environment}`).digest("hex"); }
222
222
  export function receiptPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.receipt.json`); }
223
223
  export function contextPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.context.json`); }
224
+
225
+ // A command receipt is evidence about one checkout revision, not a claim that
226
+ // its working tree was clean. Resolving this once at launch lets recovery
227
+ // distinguish a stale callback from an unrelated later command without ever
228
+ // returning the command line itself.
229
+ export function sourceSha(cwd, { exec = execFileSync } = {}) {
230
+ try {
231
+ const sha = String(exec("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })).trim();
232
+ return /^[0-9a-f]{40,64}$/i.test(sha) ? sha.toLowerCase() : null;
233
+ } catch { return null; }
234
+ }
235
+
236
+ const TERMINAL_COMMAND_STATUSES = new Set(["success", "failure", "cancelled", "timed_out", "owner_lost"]);
237
+ const TERMINAL_REMOTE_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out", "owner_lost", "invalidated"]);
238
+ const REMOTE_STATUS_FOR_COMMAND_STATUS = Object.freeze({
239
+ success: "succeeded",
240
+ failure: "failed",
241
+ cancelled: "cancelled",
242
+ timed_out: "timed_out",
243
+ owner_lost: "owner_lost",
244
+ });
245
+
246
+ function parseOwnedRunFile(raw, runId) {
247
+ try {
248
+ const value = JSON.parse(raw);
249
+ return value && value.run_id === runId ? value : null;
250
+ } catch { return null; }
251
+ }
252
+
253
+ function freshContext(context, now) {
254
+ const launchedAt = Date.parse(context?.launched_at ?? "");
255
+ const timeoutMs = Number(context?.timeout_seconds) * 1_000;
256
+ return Number.isFinite(launchedAt) && Number.isFinite(timeoutMs) && timeoutMs > 0
257
+ && now <= launchedAt + timeoutMs + TIMEOUT_GRACE_MS + 60_000;
258
+ }
259
+
260
+ function publicRemoteSummary(result) {
261
+ if (!result?.ok || result.isError || !result.data || typeof result.data !== "object") return { state: "unavailable" };
262
+ const status = typeof result.data.status === "string" ? result.data.status : null;
263
+ return {
264
+ state: status ? "available" : "unavailable",
265
+ ...(status ? { status } : {}),
266
+ ...(typeof result.data.exit_code === "number" ? { exit_code: result.data.exit_code } : {}),
267
+ ...(typeof result.data.ended_at === "string" ? { ended_at: result.data.ended_at } : {}),
268
+ };
269
+ }
270
+
271
+ function terminalOutcomesAgree(receipt, remote) {
272
+ if (!receipt || remote.state !== "available") return false;
273
+ if (REMOTE_STATUS_FOR_COMMAND_STATUS[receipt.status] !== remote.status) return false;
274
+ // A missing exit code is meaningful for cancellation, timeout, and
275
+ // owner-loss. When both sides do have one, a disagreement is never hidden.
276
+ return typeof receipt.exit_code !== "number" || typeof remote.exit_code !== "number"
277
+ || receipt.exit_code === remote.exit_code;
278
+ }
279
+
280
+ // Reconcile only evidence the current OS user and owning BotBuddy agent can
281
+ // read. This is intentionally advisory: an inspector must never terminate a
282
+ // worker, replay a callback, or make a retry safe merely by observing a file.
283
+ export async function inspectRun(runId, {
284
+ call = callToolJson,
285
+ read = readFile,
286
+ now = Date.now(),
287
+ processAlive = workerProcessGroupAlive,
288
+ receiptFile = receiptPath,
289
+ contextFile = contextPath,
290
+ } = {}) {
291
+ if (typeof runId !== "string" || !runId.trim()) {
292
+ return { schema_version: RUN_SCHEMA_VERSION, classification: "rejected", rerun_safe: false, cancellation_safe: false, error: "run_id_required" };
293
+ }
294
+ const id = runId.trim();
295
+ const [receiptRaw, contextRaw] = await Promise.all([
296
+ read(receiptFile(id), "utf8").catch(() => null),
297
+ read(contextFile(id), "utf8").catch(() => null),
298
+ ]);
299
+ const receipt = typeof receiptRaw === "string" ? parseOwnedRunFile(receiptRaw, id) : null;
300
+ const context = typeof contextRaw === "string" ? parseOwnedRunFile(contextRaw, id) : null;
301
+ // A durable run belongs to the agent that launched it. Prefer its sealed
302
+ // session identity; older contexts must use a current worktree session and
303
+ // never silently fall back to an unrelated durable machine credential.
304
+ const remoteResult = await callWithContextIdentity(context, call)("get_command_run", { run_id: id }, {
305
+ ...(context ? { serverUrl: contextServerUrl(context) } : {}),
306
+ }).catch(() => null);
307
+ const remote = publicRemoteSummary(remoteResult);
308
+ const receiptTerminal = Boolean(receipt && TERMINAL_COMMAND_STATUSES.has(receipt.status));
309
+ const receiptFingerprint = receipt && typeof receipt.command_hash === "string" && /^[a-f0-9]{64}$/i.test(receipt.command_hash)
310
+ && typeof receipt.source_sha === "string" && /^[a-f0-9]{40,64}$/i.test(receipt.source_sha);
311
+ const fingerprintMismatch = Boolean(context && (
312
+ (context.command_hash && context.command_hash !== receipt?.command_hash)
313
+ || (context.source_sha && context.source_sha !== receipt?.source_sha)
314
+ ));
315
+ const contextMatchesReceipt = !fingerprintMismatch;
316
+ const sourceBound = Boolean(receiptTerminal && receiptFingerprint && contextMatchesReceipt);
317
+
318
+ if (receiptTerminal) {
319
+ // A context that identifies a different command/revision must never be
320
+ // promoted to completed evidence, even if its receipt is terminal.
321
+ if (fingerprintMismatch) {
322
+ return {
323
+ schema_version: RUN_SCHEMA_VERSION,
324
+ run_id: id,
325
+ classification: "unknown",
326
+ rerun_safe: false,
327
+ cancellation_safe: false,
328
+ source_bound: false,
329
+ local: { status: null, exit_code: null },
330
+ remote,
331
+ };
332
+ }
333
+ const remoteTerminal = remote.state === "available" && TERMINAL_REMOTE_STATUSES.has(remote.status);
334
+ const outcomesAgree = remoteTerminal && terminalOutcomesAgree(receipt, remote);
335
+ return {
336
+ schema_version: RUN_SCHEMA_VERSION,
337
+ run_id: id,
338
+ classification: outcomesAgree ? "completed" : remoteTerminal ? "completed_locally_remote_conflict" : remote.status === "registered" || remote.status === "running" || remote.status === "approval_required" ? "completed_locally_remote_stale" : "completed_locally_remote_unknown",
339
+ rerun_safe: false,
340
+ cancellation_safe: false,
341
+ source_bound: sourceBound,
342
+ local: { status: receipt.status, exit_code: receipt.exit_code ?? null },
343
+ remote,
344
+ };
345
+ }
346
+
347
+ const pids = [context?.worker_pid, context?.workload_pid].filter((pid) => Number.isInteger(pid) && pid > 0);
348
+ if (context && freshContext(context, now) && pids.some((pid) => processAlive(pid))) {
349
+ return {
350
+ schema_version: RUN_SCHEMA_VERSION,
351
+ run_id: id,
352
+ classification: "running",
353
+ rerun_safe: false,
354
+ cancellation_safe: false,
355
+ source_bound: false,
356
+ local: { status: null, exit_code: null },
357
+ remote,
358
+ };
359
+ }
360
+
361
+ return {
362
+ schema_version: RUN_SCHEMA_VERSION,
363
+ run_id: id,
364
+ classification: "unknown",
365
+ rerun_safe: false,
366
+ cancellation_safe: false,
367
+ source_bound: false,
368
+ local: { status: null, exit_code: null },
369
+ remote,
370
+ };
371
+ }
224
372
  function cancellationPath(contextFile) { return `${contextFile}.cancelled`; }
225
373
 
226
374
  async function markCancellation(contextFile, runId) {
@@ -250,6 +398,8 @@ async function preWorkloadCancellationReceipt(context) {
250
398
  ended_at: now,
251
399
  exit_code: null,
252
400
  signal: "SIGTERM",
401
+ command_hash: context.command_hash ?? commandHash(context.command ?? []),
402
+ source_sha: context.source_sha ?? null,
253
403
  recovery_command: null,
254
404
  bounded_log: "",
255
405
  output_characters: 0,
@@ -347,7 +497,7 @@ function rejectedSessionReceipt(error, runId) {
347
497
 
348
498
  export async function launchRun(argv, {
349
499
  cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null,
350
- resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env,
500
+ resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env, getSourceSha = sourceSha,
351
501
  } = {}) {
352
502
  const parsed = parseRunArgs(argv, env);
353
503
  let managedCredential = null;
@@ -376,12 +526,14 @@ export async function launchRun(argv, {
376
526
  const effectiveCall = (t, a) => (call ?? callToolJson)(t, a,
377
527
  opts.sessionToken ? { auth: { "x-agent-api-key": opts.sessionToken } } : {});
378
528
  const runId = randomUUID();
529
+ const commandFingerprint = commandHash(command);
530
+ const launchSourceSha = getSourceSha(cwd);
379
531
  const registerArgs = () => ({
380
532
  // Omit session_id entirely when only a token is present — the backend derives
381
533
  // it. A plain id is still sent (and honoured) when supplied.
382
534
  ...(opts.sessionId ? { session_id: opts.sessionId } : {}),
383
535
  run_id: runId, tool_category: opts.category,
384
- command_hash: commandHash(command), environment_hash: environmentHash(cwd, opts.environment),
536
+ command_hash: commandFingerprint, ...(launchSourceSha ? { source_sha: launchSourceSha } : {}), environment_hash: environmentHash(cwd, opts.environment),
385
537
  environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
386
538
  rerun_reason: opts.rerunReason,
387
539
  });
@@ -411,7 +563,7 @@ export async function launchRun(argv, {
411
563
  // every callback is attributable to the same agent. It deliberately stays
412
564
  // outside child_env, receipts, and the workload's process environment.
413
565
  const knownSession = opts.sessionId ?? managedCredential?.identity?.sessionId ?? managedCredential?.sessionId;
414
- await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), server_url: SERVER_URL,...(opts.sessionToken ? { callback_session_token: opts.sessionToken } : {}), ...(knownSession ? { callback_session_id: knownSession } : {}), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
566
+ await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, command_hash: commandFingerprint, source_sha: launchSourceSha, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), server_url: SERVER_URL, ...(opts.sessionToken ? { callback_session_token: opts.sessionToken } : {}), ...(knownSession ? { callback_session_id: knownSession } : {}), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
415
567
  const [file, args] = workerInvocation(context);
416
568
  try {
417
569
  const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
@@ -804,7 +956,7 @@ export async function cancelRun(runId, {
804
956
  async function finalize(context, state, call, retry = {}) {
805
957
  const boundedLog = String(state.output ?? "").slice(-8192);
806
958
  const outputCharacters = state.outputCharacters ?? String(state.output ?? "").length;
807
- const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
959
+ const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, command_hash: context.command_hash ?? commandHash(context.command ?? []), source_sha: context.source_sha ?? null, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
808
960
  await writeJson(context.receipt_path, receipt);
809
961
  const bytes = await stat(context.receipt_path);
810
962
  const sha256 = createHash("sha256").update(await readFile(context.receipt_path)).digest("hex");
@@ -882,6 +1034,12 @@ export async function cmdRun(args) {
882
1034
  if (args.includes("--json")) oneLine({ schema_version: RUN_SCHEMA_VERSION, ...result }, false);
883
1035
  return;
884
1036
  }
1037
+ if (args[0] === "inspect") {
1038
+ const result = await inspectRun(args[1] ?? "");
1039
+ oneLine(result, false);
1040
+ process.exitCode = result.classification === "rejected" ? EXIT.INVALID : EXIT.OK;
1041
+ return;
1042
+ }
885
1043
  if (args[0] === "cancel") {
886
1044
  const result = await cancelRun(args[1] ?? "");
887
1045
  oneLine(result.receipt, false);
@@ -889,7 +1047,7 @@ export async function cmdRun(args) {
889
1047
  return;
890
1048
  }
891
1049
  if (args[0] === "help" || args[0] === "--help") {
892
- console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
1050
+ console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run inspect <run_id>\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
893
1051
  return;
894
1052
  }
895
1053
  // BOT-1701 (AC-6): one stderr nudge when a tier-3 legacy flag/env alias is used
@@ -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
@@ -728,6 +728,10 @@ export function truncateReceipt(receipt, maxBytes = DEFAULT_RECEIPT_MAX_BYTES) {
728
728
  schema_version: r.schema_version, command: r.command, outcome: r.outcome,
729
729
  lease_id: r.lease_id ?? null, state: r.state ?? null, exit_code: r.exit_code,
730
730
  error: r.error ?? null, truncated: true,
731
+ ...(r.cleanup !== undefined ? { cleanup: r.cleanup } : {}),
732
+ ...(r.fenced !== undefined ? { fenced: r.fenced } : {}),
733
+ ...(r.cleanup_code ? { cleanup_code: r.cleanup_code } : {}),
734
+ ...(r.cleanup_error ? { cleanup_error: String(r.cleanup_error).slice(0, 160) } : {}),
731
735
  };
732
736
  }
733
737
 
@@ -1156,6 +1160,11 @@ export const REMOTE_REFUSAL_GUIDANCE = Object.freeze({
1156
1160
 
1157
1161
  /** The refusal text for a receipt: the server's message, else the CLI's guidance, else the code. */
1158
1162
  export function leaseRefusalError(data) {
1163
+ if (data?.lease_id && ["STACK_CONTEXT_BIND_FAILED", "STACK_CONTEXT_ALREADY_BOUND"].includes(data?.code)) {
1164
+ return `${data.message || data.code}; lease ${data.lease_id} is ${data.state ?? "unknown"}. ` +
1165
+ `Inspect with \`bb stack status ${data.lease_id}\`; after the owning batch ends, release with \`bb stack done ${data.lease_id} --reap\`, ` +
1166
+ `then wait with \`bb wait 'lease:id=${data.lease_id},state=reaped' --heartbeat\`.`;
1167
+ }
1159
1168
  return data?.message || REMOTE_REFUSAL_GUIDANCE[data?.code] || data?.code || "lease request refused";
1160
1169
  }
1161
1170
 
@@ -3452,9 +3461,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3452
3461
  // BOT-2099: a best-effort diagnostic read (ledger, persisted Helper reason) never throws.
3453
3462
  const readLeaseQuietly = async (id) => { try { return await api.get?.(id) ?? null; } catch { return null; } };
3454
3463
  // 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.
3464
+ // capacity timeout. Keep AUTH after confirmed cleanup; unproven cleanup takes
3465
+ // CLEANUP_FAILED while preserving the primary credential diagnostic.
3456
3466
  const authUnavailableResult = (waited, phase, cleanup, waitedLeaseId = leaseId) => ({
3457
- exitCode: EXIT.AUTH, outcome: "error", code: PARK_INCIDENT_REASONS.AUTH, leaseId: waitedLeaseId ?? null, cleanup,
3467
+ exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : EXIT.AUTH,
3468
+ outcome: "error", code: PARK_INCIDENT_REASONS.AUTH, leaseId: waitedLeaseId ?? null, cleanup,
3458
3469
  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
3470
  ...(parkIncident ? { incident: parkIncident } : {}),
3460
3471
  });
@@ -3595,7 +3606,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3595
3606
  // state — instead of leaving this wait to park until --reap-timeout on a reap
3596
3607
  // that already failed. Report that specific outcome instead of the generic
3597
3608
  // "was not proven" timeout message.
3598
- // BOT-2099 (Codex #1057 R6): the signed-reap wait survives auth/availability blips too.
3609
+ // Cleanup owns one immutable deadline. Its signed reap wait may recover from a
3610
+ // transient relay/auth blip, but never repeats the release request. A persistent
3611
+ // failure remains explicit receipt data below; it never fabricates a reap.
3599
3612
  const reaped = await resilientWait(leaseId, (state) => state === "reaped", (state) => state === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth });
3600
3613
  if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
3601
3614
  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 +3625,21 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3612
3625
  if (recheck?.ok && recheck.data?.success && recheck.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
3613
3626
  const postCheck = durableReapFailure(recheck);
3614
3627
  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") };
3628
+ const persistentWaitFailure = reaped?.incident && reaped.incident.recovered === false
3629
+ ? reaped.incident.last_error
3630
+ : null;
3631
+ return cleanupResult = {
3632
+ ok: false,
3633
+ error: persistentWaitFailure ?? (reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven")),
3634
+ };
3616
3635
  })().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}` };
3636
+ // A release/read whose session renewal throws is a failed cleanup of THIS lease —
3637
+ // never an exception that escapes the lifecycle and loses its recovery receipt.
3638
+ return cleanupResult = {
3639
+ ok: false,
3640
+ code: error?.code ?? null,
3641
+ error: String(error?.message ?? error),
3642
+ };
3621
3643
  });
3622
3644
  return cleanupPromise;
3623
3645
  };
@@ -3906,11 +3928,14 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3906
3928
  ?? stackPathRootRefusalNotice(request.data?.code, request.data?.message)
3907
3929
  ?? remoteRefusalNotice(request.data?.code);
3908
3930
  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;
3931
+ // A refusal naming an admitted lease (including a failed context bind or
3932
+ // an idle mode mismatch) is not proof that its ports are free. Keep the
3933
+ // claim until owner-safe cleanup is verified; never reap a possibly live batch.
3934
+ if (request.data?.lease_id) retainBlock = true;
3911
3935
  return {
3912
3936
  exitCode: leaseRefusalExit(request.data?.code), outcome: "error", code: request.data?.code,
3913
3937
  ...capacityStaleReceiptFields(request.data), error: leaseRefusalError(request.data),
3938
+ ...(request.data.lease_id ? { leaseId: request.data.lease_id, state: request.data.state ?? null } : {}),
3914
3939
  };
3915
3940
  }
3916
3941
  leaseId = request.data.lease_id;
@@ -3934,7 +3959,12 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3934
3959
  // With a stable identity a live lease in THIS stack directory is bound to our block.
3935
3960
  if (request.data.stack_path != null && request.data.stack_path === execution.stackPath &&
3936
3961
  (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" };
3962
+ const blockedState = request.data.state ?? "unknown";
3963
+ const recovery = blockedState === "reaping"
3964
+ ? `wait with \`bb wait 'lease:id=${leaseId},state=reaped' --heartbeat\``
3965
+ : `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\``;
3966
+ return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, state: blockedState,
3967
+ error: `live lease already exists for this agent and stack slot: ${leaseId} is ${blockedState}; ${recovery}` };
3938
3968
  }
3939
3969
 
3940
3970
  leaseProvisioned = true; // from here a Helper may act on this lease
@@ -4112,7 +4142,13 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4112
4142
  };
4113
4143
  }
4114
4144
  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 }; }
4145
+ if (parked?.error || parked?.auth) {
4146
+ const cleanup = await requestCleanup();
4147
+ return {
4148
+ exitCode: parked?.auth && cleanup?.ok === false ? EXIT.CLEANUP_FAILED : (parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED),
4149
+ outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup, ...incident,
4150
+ };
4151
+ }
4116
4152
  state = parked.state;
4117
4153
  }
4118
4154
  if (state !== "active") {
@@ -4143,7 +4179,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4143
4179
  // BOT-1816's class of dropped wakes) even though the lease genuinely went
4144
4180
  // active or was deferred back to queued. One direct read before declaring
4145
4181
  // a timeout avoids reaping a healthy stack or destroying a kept queue place.
4146
- const recheck = await api.get(leaseId);
4182
+ // Production adapters always expose `get`. Focused lifecycle adapters may
4183
+ // intentionally model only admission and release; preserve their original
4184
+ // terminal cleanup receipt rather than replacing it with a test-only TypeError.
4185
+ const recheck = api.get ? await api.get(leaseId) : null;
4147
4186
  if (recheck?.ok && recheck.data?.success && recheck.data.state === "queued") {
4148
4187
  parkView = recheck.data;
4149
4188
  const why = parkView.provision_defer_reason ?? "host capacity";
@@ -4153,9 +4192,16 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4153
4192
  }
4154
4193
  if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
4155
4194
  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 } : {}) };
4195
+ const cleanupFailed = cleanup?.ok === false;
4196
+ return {
4197
+ exitCode: cleanupFailed ? EXIT.CLEANUP_FAILED : EXIT.TIMEOUT,
4198
+ outcome: "timeout", timeoutPhase: "provision", leaseId,
4199
+ // An unproven cleanup is more actionable than the generic timeout for an
4200
+ // admitted lease, because recovery must preserve its fenced claim.
4201
+ error: cleanupFailed ? cleanup.error : `physical provision did not reach active in ${provisionTimeout}s`,
4202
+ cleanup, ...(parkIncident ? { incident: parkIncident } : {}),
4203
+ };
4204
+
4159
4205
  }
4160
4206
  }
4161
4207
  if (active?.failed || active?.error || active?.auth) {
@@ -4167,7 +4213,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4167
4213
  if (read?.ok && read.data?.success && typeof read.data.error === "string" && read.data.error) why = ` — ${read.data.error}`;
4168
4214
  }
4169
4215
  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 } : {}) };
4216
+ return {
4217
+ exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : (active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED),
4218
+ outcome: "error", leaseId, state: active?.state ?? null, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active${why}`, cleanup,
4219
+ ...(parkIncident ? { incident: parkIncident } : {}),
4220
+ };
4171
4221
  }
4172
4222
  }
4173
4223
  break;
@@ -4374,6 +4424,7 @@ export function runReceiptFields(result) {
4374
4424
  // BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
4375
4425
  // so automation can branch on it instead of matching the message text.
4376
4426
  command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
4427
+ ...(result.state ? { state: result.state } : {}),
4377
4428
  // BOT-2468: the lease's /docker?lease= deeplink (null when the server sent none).
4378
4429
  web_url: result.webUrl ?? null,
4379
4430
  // BOT-2024: a stale-capacity refusal carries its phase + armable wait on the receipt.
@@ -4382,6 +4433,13 @@ export function runReceiptFields(result) {
4382
4433
  child_exit_code: result.childExitCode ?? null,
4383
4434
  // BOT-1803: an idle release is `cleanup: { idle: true, idle_since }` (nothing was reaped).
4384
4435
  cleanup: result.cleanup?.idle ? { idle: true, idle_since: result.cleanup.idle_since ?? null } : result.cleanup?.ok ?? null,
4436
+ cleanup_code: result.cleanup?.code ?? null,
4437
+ cleanup_error: result.cleanup?.ok === false ? result.cleanup.error ?? "cleanup was not proven" : null,
4438
+ ...(result.leaseId && result.cleanup?.ok === false ? { recovery: {
4439
+ status: `bb stack status ${result.leaseId}`,
4440
+ wait: `bb wait 'lease:id=${result.leaseId},state=reaped' --heartbeat`,
4441
+ release: `bb stack done ${result.leaseId} --reap`,
4442
+ } } : {}),
4385
4443
  ...(result.volumesKept ? { volumes_kept: true } : {}),
4386
4444
  ...(result.idleLease?.reused ? { reused: result.idleLease.reused } : {}),
4387
4445
  ...(result.idleLease?.reprovisioned ? { reprovisioned: result.idleLease.reprovisioned } : {}),
@@ -4401,7 +4459,7 @@ export function runReceiptFields(result) {
4401
4459
  // BOT-2207: why it ran where it did (e.g. moved_remote_on_requeue) and every host weighed.
4402
4460
  ...(result.execution?.placementReason ? { placement_reason: result.execution.placementReason } : {}),
4403
4461
  ...(result.execution?.considered ? { considered: result.execution.considered } : {}),
4404
- fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
4462
+ fenced: result.fenced === true || result.cleanup?.ok === false, error: result.error ?? result.cleanup?.error ?? null,
4405
4463
  // BOT-2099: a platform auth/availability blip during the park is reported as an
4406
4464
  // incident (reason, window, attempts, recovered) — never as the lease's outcome.
4407
4465
  ...(result.incident ? { platform_incident: result.incident } : {}),
@@ -4440,10 +4498,14 @@ export async function cmdRun(opts, childArgv, {
4440
4498
  */
4441
4499
  /**
4442
4500
  * 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.
4501
+ * receipt deliberately does not carry (which wait a timeout ended and the original
4502
+ * lease fence). Never emitted.
4444
4503
  */
4445
4504
  export function stackRunOutcomeFields(result) {
4446
- return { ...runReceiptFields(result), ...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}) };
4505
+ return {
4506
+ ...runReceiptFields(result), lease_fenced: result?.fenced === true,
4507
+ ...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}),
4508
+ };
4447
4509
  }
4448
4510
 
4449
4511
  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);