@botbuddy/cli 1.29.2 → 1.29.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/wait.mjs CHANGED
@@ -20,6 +20,8 @@ import { fileURLToPath } from "node:url";
20
20
  import { latestPublicCliCommand } from "./public-invocation.mjs";
21
21
  import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
22
22
  import { SETUP_BLOCK } from "./setup-block.mjs";
23
+ import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
24
+ import { realpath } from "node:fs/promises";
23
25
 
24
26
  // A protocol is deliberately distinct from package semver: compatible pinned
25
27
  // clients keep working until the server raises this minimum, while a stale
@@ -33,6 +35,7 @@ import { SETUP_BLOCK } from "./setup-block.mjs";
33
35
  export const WAIT_PROTOCOL_VERSION = 3;
34
36
  const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
35
37
  const MIN_RECEIPT_MAX_BYTES = 512;
38
+ const MAX_RECEIPT_MAX_BYTES = 64 * 1024;
36
39
  // BOT-1582: the server's session-token shape (bb_agent_ + 64 hex), plus the
37
40
  // BOT-1572 bb_sess_ legacy alias, both accepted for one release.
38
41
  const SESSION_TOKEN_RE = AGENT_KEY_RE;
@@ -116,7 +119,7 @@ OPTIONS
116
119
  --any wake on the first matching condition (default; only mode in v1)
117
120
  --timeout <seconds> max wait (default 7200; hard cap 28800). Timeout is exit 2, not an error.
118
121
  --since <seq> resume from a prior receipt's next_cursor (replay what you missed)
119
- --receipt-max-bytes <n> cap the receipt (minimum 512; default 10240; payloads truncate to pointers)
122
+ --receipt-max-bytes <n> cap the receipt (512..65536; default 10240; payloads truncate to pointers)
120
123
  --heartbeat keep this agent session alive while waiting (so it is not reaped)
121
124
  --url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
122
125
  --agent-key <token> the bb_agent_ session token to authenticate this wait — a one-liner
@@ -126,6 +129,9 @@ OPTIONS
126
129
  --session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID.
127
130
  Unnecessary when $BOTBUDDY_AGENT_KEY / --agent-key is set (the relay derives the session from the token).
128
131
  --help show this help
132
+ resume <local_wait_id> recover one explicitly named saved wait
133
+ cancel <local_wait_id> explicitly stop one saved wait
134
+ acknowledge <local_wait_id> mark one saved terminal receipt read
129
135
 
130
136
  AUTH
131
137
  A wait authenticates from the bb_agent_ session token (minted by register_agent):
@@ -184,6 +190,8 @@ function parseArgv(argv) {
184
190
  // is for tests/overrides.
185
191
  sessionToken: readAgentKeyEnv(process.env),
186
192
  help: false,
193
+ deadlineAt: null,
194
+ resumeLocalWait: null,
187
195
  };
188
196
  for (let i = 0; i < argv.length; i++) {
189
197
  const a = argv[i];
@@ -200,6 +208,7 @@ function parseArgv(argv) {
200
208
  else if (a === "--any") opts.mode = "any";
201
209
  else if (a === "--heartbeat") opts.heartbeat = true;
202
210
  else if (a === "--timeout") opts.timeout = Number(optionValue());
211
+ else if (a === "--deadline-at") opts.deadlineAt = optionValue(); // internal recovery argv; never shown as public API
203
212
  else if (a === "--since") opts.since = optionValue();
204
213
  else if (a === "--receipt-max-bytes") opts.receiptMaxBytes = Number(optionValue());
205
214
  else if (a === "--url") opts.url = optionValue();
@@ -217,6 +226,7 @@ function parseArgv(argv) {
217
226
  // alias for the same value.
218
227
  else if (a === "--agent-key" || a === "--session-token") opts.sessionToken = optionValue();
219
228
  else if (a === "--session-id") opts.sessionId = optionValue();
229
+ else if (a === "--resume-local-wait") opts.resumeLocalWait = optionValue();
220
230
  else if (a.startsWith("--")) opts.unknown = a;
221
231
  else opts.conditions.push(a);
222
232
  }
@@ -226,6 +236,28 @@ function parseArgv(argv) {
226
236
  // Register a wait_session on the relay (BOT-989 M2). Returns
227
237
  // {waitSessionId, cursorStart} on success. Profiled waits fail closed unless the
228
238
  // relay authenticates and attests their machine principal.
239
+ async function reserveClaimQueueBoundary(opts, conditions) {
240
+ const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
241
+ method: "POST",
242
+ headers: {
243
+ Authorization: `Bearer ${opts.token}`,
244
+ "x-agent-api-key": opts.token || "",
245
+ "Content-Type": "application/json",
246
+ },
247
+ body: JSON.stringify({
248
+ action: "reserve_claim_boundary",
249
+ client_version: VERSION,
250
+ wait_protocol_version: WAIT_PROTOCOL_VERSION,
251
+ local_wait_id: opts.localWaitId,
252
+ conditions,
253
+ }),
254
+ });
255
+ if (!res.ok) {
256
+ const body = await res.json().catch(() => ({}));
257
+ throw new Error(body.detail || body.error || `claim_boundary_failed_${res.status}`);
258
+ }
259
+ }
260
+
229
261
  async function registerWait(opts, conditions, deadlineIso) {
230
262
  // BOT-1608: a relay wait authenticates from the per-session `bb_agent_` token
231
263
  // ($BOTBUDDY_AGENT_KEY) — the relay derives agent, tenant, and session from it.
@@ -238,6 +270,11 @@ async function registerWait(opts, conditions, deadlineIso) {
238
270
  conditions,
239
271
  deadline: deadlineIso,
240
272
  mode: opts.mode,
273
+ ...(opts.localWaitId ? { local_wait_id: opts.localWaitId } : {}),
274
+ ...(opts.expectedAgentId ? {
275
+ expected_agent_id: opts.expectedAgentId,
276
+ expected_tenant_id: opts.expectedTenantId ?? null,
277
+ } : {}),
241
278
  };
242
279
  const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
243
280
  method: "POST",
@@ -298,6 +335,8 @@ async function registerWait(opts, conditions, deadlineIso) {
298
335
  // CLI fails fast before this, but a mismatched/forced body could reach it) —
299
336
  // a hard stop, never the untracked live-only fallback.
300
337
  "session_id_required",
338
+ "wait_resume_identity_invalid",
339
+ "wait_resume_identity_mismatch",
301
340
  ]);
302
341
  if (INVALID_CONDITION_CODES.has(body.error)) {
303
342
  const err = new Error(body.detail || body.error);
@@ -308,7 +347,7 @@ async function registerWait(opts, conditions, deadlineIso) {
308
347
  }
309
348
  if (res.status === 403) {
310
349
  const body = await res.json().catch(() => ({}));
311
- if (new Set(["wait_tenant_unresolved", "wait_cross_tenant", "wait_tenant_mismatch"]).has(body.error)) {
350
+ if (new Set(["wait_tenant_unresolved", "wait_cross_tenant", "wait_tenant_mismatch", "wait_resume_identity_mismatch"]).has(body.error)) {
312
351
  const err = new Error(body.detail || body.error);
313
352
  err.invalidCondition = true;
314
353
  err.errorCode = body.error;
@@ -399,6 +438,13 @@ async function registerWait(opts, conditions, deadlineIso) {
399
438
  // pr-review/pr-state target. Report-only — surfaced on stderr so a silent
400
439
  // edge-triggered park isn't mistaken for "no review yet".
401
440
  prReviewSnapshot: Array.isArray(body.pr_review_snapshot) ? body.pr_review_snapshot : null,
441
+ status: typeof body.status === "string" ? body.status : "active",
442
+ // A canonicalized alias wait was deliberately abandoned and already has a
443
+ // sequenced wait_superseded signal. Preserve that terminal state and replay
444
+ // the signal; treating it as a reaper abandonment would resurrect an
445
+ // unmatchable alias registration.
446
+ recoveryError: typeof body.recovery_error === "string" ? body.recovery_error : null,
447
+ terminalReceipt: body.terminal_receipt && typeof body.terminal_receipt === "object" ? body.terminal_receipt : null,
402
448
  };
403
449
  }
404
450
 
@@ -428,11 +474,15 @@ async function finalizeWait(opts, waitSessionId, receipt, timeoutMs = 5000) {
428
474
  }),
429
475
  signal: ac.signal,
430
476
  });
431
- if (!res.ok) {
477
+ const body = await res.json().catch(() => ({}));
478
+ if (!res.ok || body?.finalized !== true) {
432
479
  process.stderr.write(`bb-wait: finalize (non-fatal) returned ${res.status}\n`);
480
+ return { delivered: false, error: body?.error || (res.ok ? "finalize_not_applied" : `finalize_http_${res.status}`) };
433
481
  }
482
+ return { delivered: true };
434
483
  } catch (err) {
435
484
  process.stderr.write(`bb-wait: finalize (non-fatal) failed: ${err && err.message || err}\n`);
485
+ return { delivered: false, error: err?.name === "AbortError" ? "finalize_timeout" : "finalize_unavailable" };
436
486
  } finally {
437
487
  clearTimeout(timer);
438
488
  }
@@ -619,6 +669,34 @@ function withClientIdentity(receipt) {
619
669
  };
620
670
  }
621
671
 
672
+ function publicWaitReceipt(receipt, {
673
+ sessionTenant = null,
674
+ agentId = null,
675
+ sessionAgentId = null,
676
+ sessionId = null,
677
+ maxBytes = 10240,
678
+ } = {}) {
679
+ return truncateReceipt(withClientIdentity(withPrincipalReceipt(receipt, null, {
680
+ sessionTenant,
681
+ agentId,
682
+ sessionAgentId,
683
+ sessionId,
684
+ })), maxBytes);
685
+ }
686
+
687
+ // A recovered terminal receipt can originate from a server timeout before the
688
+ // client reconnects. Its cursor is only the server's registration frontier; a
689
+ // newer locally-persisted safe cursor is already known to be replayed and is
690
+ // therefore the correct continuation point for the next wait.
691
+ function preserveCheckpointCursor(receipt, safeCursor, createdAt = null) {
692
+ const saved = typeof safeCursor === "string" ? safeCursor : String(safeCursor ?? "");
693
+ const current = typeof receipt?.next_cursor === "string" ? receipt.next_cursor : String(receipt?.next_cursor ?? "");
694
+ const withCursor = /^\d+$/.test(saved) && /^\d+$/.test(current) && BigInt(saved) > BigInt(current)
695
+ ? { ...receipt, next_cursor: saved }
696
+ : receipt;
697
+ return !withCursor?.started_at && typeof createdAt === "string" ? { ...withCursor, started_at: createdAt } : withCursor;
698
+ }
699
+
622
700
  function emit(receipt, { versioned = false, maxBytes = null } = {}) {
623
701
  const enriched = versioned ? receipt : withClientIdentity(receipt);
624
702
  const bounded = Number.isSafeInteger(maxBytes) && maxBytes >= MIN_RECEIPT_MAX_BYTES
@@ -758,7 +836,486 @@ async function sendSetupErrorEvent(opts, { error, exitCode, conditions }, signal
758
836
  });
759
837
  }
760
838
 
761
- export async function runWait(argv) {
839
+ function checkpointStore() {
840
+ // State is intentionally bound to the current worktree. A caller may point
841
+ // cwd at another checkout only by explicitly starting there; we never follow
842
+ // an arbitrary state-file path supplied in argv.
843
+ return createWaitCheckpointStore({ root: process.cwd() });
844
+ }
845
+
846
+ function inventoryEntry(checkpoint) {
847
+ const id = checkpoint.local_wait_id;
848
+ const terminal = checkpoint.status === "terminal";
849
+ const pendingCancellation = checkpoint.status === "cancelled" && checkpoint.cloud_cancel_pending === true;
850
+ const pendingAcknowledgement = checkpoint.status === "acknowledged" && checkpoint.cloud_ack_pending === true;
851
+ return {
852
+ local_wait_id: id,
853
+ status: checkpoint.status,
854
+ conditions: checkpoint.conditions?.map((c) => ({ condition_id: c.id, type: c.type })) ?? [],
855
+ ticket: checkpoint.ticket ?? null,
856
+ created_at: checkpoint.created_at ?? null,
857
+ deadline_at: checkpoint.deadline_at ?? null,
858
+ receipt_available: terminal && checkpoint.terminal_receipt != null,
859
+ recovery_argv: pendingCancellation
860
+ ? ["wait", "cancel", id]
861
+ : pendingAcknowledgement ? ["wait", "acknowledge", id] : ["wait", "resume", id],
862
+ cancel_argv: terminal || pendingAcknowledgement ? null : ["wait", "cancel", id],
863
+ acknowledge_argv: terminal || pendingAcknowledgement ? ["wait", "acknowledge", id] : null,
864
+ };
865
+ }
866
+
867
+ async function inspectSavedWaits(argv = []) {
868
+ const rawOffset = argv[0] === "--offset" ? Number(argv[1]) : 0;
869
+ if (!Number.isSafeInteger(rawOffset) || rawOffset < 0 || argv.length > (argv[0] === "--offset" ? 2 : 0)) {
870
+ process.stderr.write("bb-wait: usage: bb wait [--offset <n>]\n");
871
+ process.exit(EXIT.INVALID);
872
+ }
873
+ const inventory = await checkpointStore().list({ offset: rawOffset });
874
+ // Never let a non-interactive agent accidentally acquire an owner or consume
875
+ // a result. The inventory deliberately omits receipt bodies and shell text.
876
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
877
+ process.stdout.write(`${JSON.stringify({
878
+ schema_version: 1,
879
+ outcome: "saved_waits",
880
+ waits: inventory.items.map(inventoryEntry),
881
+ has_more: inventory.has_more,
882
+ ...(inventory.next_offset != null ? { next_offset: inventory.next_offset, next_page_argv: ["wait", "--offset", String(inventory.next_offset)] } : {}),
883
+ })}\n`);
884
+ process.exit(EXIT.MATCHED);
885
+ }
886
+ if (inventory.items.length === 0) {
887
+ process.stdout.write(HELP);
888
+ process.exit(EXIT.MATCHED);
889
+ }
890
+ // A TTY gets discovery guidance without an implicit selection. This is a
891
+ // deliberately conservative menu: a restart must name an ID before mutation.
892
+ process.stdout.write("Saved bb waits:\n");
893
+ for (const item of inventory.items) {
894
+ process.stdout.write(` ${item.local_wait_id} ${item.status} ${item.deadline_at ?? ""}\n`);
895
+ }
896
+ process.stdout.write("Run `bb wait resume <local_wait_id>`, `bb wait cancel <local_wait_id>`, or `bb wait acknowledge <local_wait_id>`. Ctrl-C leaves saved waits unchanged.\n");
897
+ process.exit(EXIT.MATCHED);
898
+ }
899
+
900
+ export function resumedSpecs(checkpoint) {
901
+ const specs = Array.isArray(checkpoint.condition_specs) ? [...checkpoint.condition_specs] : [];
902
+ if (specs.length === 0) throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint has no recoverable condition arguments");
903
+ let timerIndex = 0;
904
+ return specs.map((spec) => {
905
+ if (!String(spec).startsWith("timer:")) return spec;
906
+ const fireAt = checkpoint.timer_deadlines?.[timerIndex++];
907
+ if (typeof fireAt !== "string") throw new WaitCheckpointError("wait_checkpoint_invalid", "timer checkpoint has no original fire time");
908
+ return `timer:deadline=${fireAt}`;
909
+ });
910
+ }
911
+
912
+ export function restoreCapacityStaleDeadlines(conditions, checkpoint) {
913
+ const capacity = conditions.filter((condition) => condition.type === "capacity");
914
+ if (capacity.length === 0) return conditions;
915
+ const deadlines = checkpoint.capacity_stale_deadlines;
916
+ const createdMs = Date.parse(checkpoint.created_at);
917
+ if (!Array.isArray(deadlines) || deadlines.length !== capacity.length || !Number.isFinite(createdMs)) {
918
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "capacity checkpoint has no original stale-grace deadlines");
919
+ }
920
+ for (let index = 0; index < capacity.length; index++) {
921
+ const deadlineMs = Date.parse(deadlines[index]);
922
+ const maximum = createdMs + capacity[index].params.staleGraceSec * 1000 + 1_000;
923
+ if (!Number.isFinite(deadlineMs) || deadlineMs > maximum) {
924
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "capacity stale-grace deadline exceeds its original budget");
925
+ }
926
+ capacity[index].params.staleDeadlineMs = deadlineMs;
927
+ }
928
+ return conditions;
929
+ }
930
+
931
+ async function deliverRecoveryAction(checkpoint, action) {
932
+ if (!checkpoint.cloud_wait_session_id) return { delivered: true };
933
+ const token = readAgentKeyEnv(process.env);
934
+ if (!token || !SESSION_TOKEN_RE.test(token)) {
935
+ return { delivered: false, error: "session_token_required" };
936
+ }
937
+ const baseUrl = checkpoint.relay_url || process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
938
+ const controller = new AbortController();
939
+ const timer = setTimeout(() => controller.abort(), 5_000);
940
+ try {
941
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/event-stream`, {
942
+ method: "POST",
943
+ headers: {
944
+ Authorization: `Bearer ${token}`,
945
+ "x-agent-api-key": token,
946
+ "Content-Type": "application/json",
947
+ },
948
+ body: JSON.stringify({ action, wait_session_id: checkpoint.cloud_wait_session_id }),
949
+ signal: controller.signal,
950
+ });
951
+ const body = await res.json().catch(() => ({}));
952
+ const resultKey = action === "acknowledge" ? "acknowledged" : "cancelled";
953
+ // The recovery endpoint is deliberately idempotent and uses a 200 response
954
+ // for a rejected state transition. Only its explicit boolean proves that
955
+ // the cloud row changed; otherwise retain the local delivery outbox.
956
+ if (!res.ok || body?.[resultKey] !== true) {
957
+ return { delivered: false, error: body?.error || `${action}_not_applied` };
958
+ }
959
+ return { delivered: true };
960
+ } catch (error) {
961
+ return { delivered: false, error: error?.name === "AbortError" ? `${action}_timeout` : `${action}_unavailable` };
962
+ } finally {
963
+ clearTimeout(timer);
964
+ }
965
+ }
966
+
967
+ function checkpointNeedsRelay(checkpoint) {
968
+ return checkpoint.conditions.some((condition) => condition.type !== "timer");
969
+ }
970
+
971
+ async function resolveRecoveryWaitSession(checkpoint, { reserveMissing = false } = {}) {
972
+ if (checkpoint.cloud_wait_session_id || !checkpointNeedsRelay(checkpoint)) {
973
+ return { registration: null };
974
+ }
975
+ const token = readAgentKeyEnv(process.env);
976
+ if (!token || !SESSION_TOKEN_RE.test(token)) {
977
+ return { error: "session_token_required" };
978
+ }
979
+ const url = checkpoint.relay_url || process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
980
+ const controller = new AbortController();
981
+ const timer = setTimeout(() => controller.abort(), 5_000);
982
+ try {
983
+ const res = await fetch(`${url.replace(/\/$/, "")}/event-stream`, {
984
+ method: "POST",
985
+ headers: {
986
+ Authorization: `Bearer ${token}`,
987
+ "x-agent-api-key": token,
988
+ "Content-Type": "application/json",
989
+ },
990
+ body: JSON.stringify({ action: reserveMissing ? "recover_cancel_absent" : "recover_lookup", local_wait_id: checkpoint.local_wait_id }),
991
+ signal: controller.signal,
992
+ });
993
+ const body = await res.json().catch(() => ({}));
994
+ if (!res.ok) return { error: body?.error || "wait_recovery_lookup_failed" };
995
+ if (body?.found !== true) return { missing: true };
996
+ if (typeof body.wait_session_id !== "string" || !body.wait_session_id) {
997
+ return { error: "wait_session_id_unavailable" };
998
+ }
999
+ return {
1000
+ registration: {
1001
+ waitSessionId: body.wait_session_id,
1002
+ cursorStart: body.cursor_start ?? null,
1003
+ sessionTenant: checkpoint.tenant_id ?? null,
1004
+ agentId: checkpoint.agent_id ?? null,
1005
+ sessionId: checkpoint.arming_session_id ?? null,
1006
+ sessionAgentId: null,
1007
+ status: typeof body.status === "string" ? body.status : "active",
1008
+ terminalReceipt: body.terminal_receipt && typeof body.terminal_receipt === "object" ? body.terminal_receipt : null,
1009
+ },
1010
+ };
1011
+ } catch (error) {
1012
+ return { error: error?.name === "AbortError" ? "wait_recovery_lookup_timeout" : "wait_recovery_lookup_unavailable" };
1013
+ } finally {
1014
+ clearTimeout(timer);
1015
+ }
1016
+ }
1017
+
1018
+ async function deliverTerminalFinalization(checkpoint) {
1019
+ if (!checkpoint.cloud_wait_session_id || !checkpoint.terminal_receipt) return { delivered: true };
1020
+ const token = readAgentKeyEnv(process.env);
1021
+ if (!token || !SESSION_TOKEN_RE.test(token)) return { delivered: false, error: "session_token_required" };
1022
+ const baseUrl = checkpoint.relay_url || process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
1023
+ const controller = new AbortController();
1024
+ const timer = setTimeout(() => controller.abort(), 5_000);
1025
+ try {
1026
+ const receipt = checkpoint.terminal_receipt;
1027
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/event-stream`, {
1028
+ method: "POST",
1029
+ headers: {
1030
+ Authorization: `Bearer ${token}`,
1031
+ "x-agent-api-key": token,
1032
+ "Content-Type": "application/json",
1033
+ },
1034
+ body: JSON.stringify({
1035
+ action: "recover_finalize",
1036
+ wait_session_id: checkpoint.cloud_wait_session_id,
1037
+ status: receipt.outcome,
1038
+ receipt,
1039
+ reconnects: receipt.reconnects ?? 0,
1040
+ degraded: receipt.degraded ?? [],
1041
+ }),
1042
+ signal: controller.signal,
1043
+ });
1044
+ const body = await res.json().catch(() => ({}));
1045
+ if (!res.ok || body?.finalized !== true) {
1046
+ return { delivered: false, error: body?.error || "finalize_not_applied" };
1047
+ }
1048
+ return { delivered: true };
1049
+ } catch (error) {
1050
+ return { delivered: false, error: error?.name === "AbortError" ? "finalize_timeout" : "finalize_unavailable" };
1051
+ } finally {
1052
+ clearTimeout(timer);
1053
+ }
1054
+ }
1055
+
1056
+ async function flushPendingFinalization(store, checkpoint) {
1057
+ if (!checkpoint.cloud_wait_session_id || !checkpoint.terminal_receipt ||
1058
+ (checkpoint.cloud_finalize_pending === false && checkpoint.cloud_finalized_at)) return checkpoint;
1059
+ if (checkpoint.terminal_receipt.outcome === "superseded") {
1060
+ return store.update(checkpoint.local_wait_id, {
1061
+ cloud_finalize_pending: false,
1062
+ cloud_finalized_at: new Date().toISOString(),
1063
+ cloud_finalize_error: null,
1064
+ });
1065
+ }
1066
+ const delivered = await deliverTerminalFinalization(checkpoint);
1067
+ return store.update(checkpoint.local_wait_id, delivered.delivered
1068
+ ? { cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null }
1069
+ : { cloud_finalize_pending: true, cloud_finalize_error: delivered.error });
1070
+ }
1071
+
1072
+ async function flushPendingAcknowledgement(store, checkpoint) {
1073
+ if (!checkpoint.cloud_ack_pending || !checkpoint.cloud_wait_session_id) return checkpoint;
1074
+ checkpoint = await flushPendingFinalization(store, checkpoint);
1075
+ if (checkpoint.cloud_finalize_pending) {
1076
+ return store.update(checkpoint.local_wait_id, { cloud_ack_pending: true, cloud_ack_error: "finalize_pending" });
1077
+ }
1078
+ const delivered = await deliverRecoveryAction(checkpoint, "acknowledge");
1079
+ if (delivered.delivered) {
1080
+ return store.update(checkpoint.local_wait_id, {
1081
+ cloud_ack_pending: false,
1082
+ cloud_acknowledged_at: new Date().toISOString(),
1083
+ cloud_ack_error: null,
1084
+ });
1085
+ }
1086
+ return store.update(checkpoint.local_wait_id, { cloud_ack_pending: true, cloud_ack_error: delivered.error });
1087
+ }
1088
+
1089
+ async function resumeSavedWait(id) {
1090
+ const store = checkpointStore();
1091
+ let checkpoint;
1092
+ try { checkpoint = await store.read(id); }
1093
+ catch (error) { return emitSavedWaitError(error); }
1094
+ if (!(await sameWorktree(checkpoint.worktree, process.cwd()))) {
1095
+ return emitSavedWaitError(new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different worktree"));
1096
+ }
1097
+ if (checkpoint.status === "terminal") {
1098
+ let fence;
1099
+ try {
1100
+ fence = await store.acquireFence(id);
1101
+ checkpoint = await store.read(id);
1102
+ if (checkpoint.status !== "terminal") {
1103
+ throw new WaitCheckpointError("wait_not_resumable", `saved wait ${id} is ${checkpoint.status}`);
1104
+ }
1105
+ checkpoint = await flushPendingFinalization(store, checkpoint);
1106
+ checkpoint = await flushPendingAcknowledgement(store, checkpoint);
1107
+ } catch (error) {
1108
+ return emitSavedWaitError(error);
1109
+ } finally {
1110
+ await fence?.release();
1111
+ }
1112
+ process.stdout.write(`${JSON.stringify(checkpoint.terminal_receipt)}\n`);
1113
+ process.exit(Number.isInteger(checkpoint.exit_code) ? checkpoint.exit_code : EXIT.INTERNAL);
1114
+ }
1115
+ if (["cancelled", "acknowledged"].includes(checkpoint.status)) {
1116
+ return emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", `saved wait ${id} is ${checkpoint.status}`));
1117
+ }
1118
+ const remaining = Date.parse(checkpoint.deadline_at) - Date.now();
1119
+ if (!Number.isFinite(remaining)) return emitSavedWaitError(new WaitCheckpointError("wait_checkpoint_invalid", "saved wait deadline is invalid"));
1120
+ // Preserve the original absolute deadline. A resumed wait is never granted a
1121
+ // fresh timeout budget; the core loop settles it immediately if already due.
1122
+ const args = [
1123
+ ...resumedSpecs(checkpoint),
1124
+ "--deadline-at", checkpoint.deadline_at,
1125
+ "--resume-local-wait", checkpoint.local_wait_id,
1126
+ "--receipt-max-bytes", String(checkpoint.receipt_max_bytes ?? 10240),
1127
+ ];
1128
+ if (checkpoint.safe_cursor != null) args.push("--since", String(checkpoint.safe_cursor));
1129
+ if (checkpoint.heartbeat) args.push("--heartbeat");
1130
+ if (checkpoint.relay_url) args.push("--url", checkpoint.relay_url);
1131
+ return runWait(args, { recoveryLocalWaitId: checkpoint.local_wait_id });
1132
+ }
1133
+
1134
+ async function sameWorktree(first, second) {
1135
+ try { return await realpath(first) === await realpath(second); }
1136
+ catch { return false; }
1137
+ }
1138
+
1139
+ function emitSavedWaitError(error) {
1140
+ const code = error?.code ?? "wait_checkpoint_invalid";
1141
+ const exitCode = code === "wait_checkpoint_write_failed" ? EXIT.INTERNAL : EXIT.INVALID;
1142
+ process.stderr.write(`bb-wait: ${error?.message ?? code}\n`);
1143
+ process.stdout.write(`${JSON.stringify({
1144
+ schema_version: 1,
1145
+ outcome: "error",
1146
+ error: code,
1147
+ ...(error?.localWaitId ? { local_wait_id: error.localWaitId } : {}),
1148
+ ...(Array.isArray(error?.recoveryArgv) ? { recovery_argv: error.recoveryArgv } : {}),
1149
+ ...(typeof error?.recovery === "string" ? { recovery: error.recovery } : {}),
1150
+ })}\n`);
1151
+ process.exit(exitCode);
1152
+ }
1153
+
1154
+ function exitForRecoveredOutcome(receipt) {
1155
+ if (receipt?.outcome === "matched") return EXIT.MATCHED;
1156
+ if (receipt?.outcome === "cancelled") return EXIT.MATCHED;
1157
+ if (receipt?.outcome === "timeout") return EXIT.TIMEOUT;
1158
+ if (receipt?.outcome === "superseded") return EXIT.SUPERSEDED;
1159
+ if (receipt?.error === "cursor_expired") return EXIT.CURSOR_EXPIRED;
1160
+ if (receipt?.error === "unauthorized" || receipt?.error === "forbidden") return EXIT.AUTH;
1161
+ return EXIT.BACKEND;
1162
+ }
1163
+
1164
+ async function cancelSavedWait(id) {
1165
+ try {
1166
+ const store = checkpointStore();
1167
+ let current = await store.read(id);
1168
+ if (current.status === "terminal" || current.status === "acknowledged") {
1169
+ const conflict = new WaitCheckpointError(
1170
+ "wait_not_cancellable",
1171
+ `saved wait ${id} is ${current.status} and cannot be cancelled`,
1172
+ );
1173
+ conflict.localWaitId = id;
1174
+ if (current.status === "terminal") conflict.recoveryArgv = ["wait", "resume", id];
1175
+ throw conflict;
1176
+ }
1177
+ // Refuse to cancel underneath a surviving waiter. A crashed owner is
1178
+ // reclaimed by the same process-instance fence used for resume.
1179
+ const fence = await store.acquireFence(id);
1180
+ try {
1181
+ current = await store.read(id);
1182
+ if (current.status === "terminal" || current.status === "acknowledged") {
1183
+ const conflict = new WaitCheckpointError(
1184
+ "wait_not_cancellable",
1185
+ `saved wait ${id} is ${current.status} and cannot be cancelled`,
1186
+ );
1187
+ conflict.localWaitId = id;
1188
+ if (current.status === "terminal") conflict.recoveryArgv = ["wait", "resume", id];
1189
+ throw conflict;
1190
+ }
1191
+ let delivered;
1192
+ const resolution = await resolveRecoveryWaitSession(current, { reserveMissing: true });
1193
+ if (resolution.registration) {
1194
+ const reg = resolution.registration;
1195
+ current = await store.update(id, {
1196
+ cloud_wait_session_id: reg.waitSessionId,
1197
+ cursor_start: reg.cursorStart,
1198
+ tenant_id: reg.sessionTenant ?? null,
1199
+ agent_id: reg.agentId ?? null,
1200
+ arming_session_id: reg.sessionId ?? null,
1201
+ });
1202
+ if (reg.terminalReceipt) {
1203
+ const recovered = preserveCheckpointCursor(publicWaitReceipt(reg.terminalReceipt, {
1204
+ sessionTenant: reg.sessionTenant,
1205
+ agentId: reg.agentId,
1206
+ sessionAgentId: reg.sessionAgentId,
1207
+ sessionId: reg.sessionId,
1208
+ maxBytes: current.receipt_max_bytes,
1209
+ }), current.safe_cursor, current.created_at);
1210
+ if (recovered.outcome === "cancelled") {
1211
+ current = await store.update(id, {
1212
+ status: "cancelled",
1213
+ terminal_receipt: recovered,
1214
+ exit_code: exitForRecoveredOutcome(recovered),
1215
+ cancelled_at: new Date().toISOString(),
1216
+ cloud_cancel_pending: false,
1217
+ cloud_cancelled_at: new Date().toISOString(),
1218
+ cloud_cancel_error: null,
1219
+ });
1220
+ delivered = { delivered: true };
1221
+ } else {
1222
+ await store.update(id, {
1223
+ status: "terminal",
1224
+ terminal_receipt: recovered,
1225
+ exit_code: exitForRecoveredOutcome(recovered),
1226
+ });
1227
+ const conflict = new WaitCheckpointError(
1228
+ "wait_not_cancellable",
1229
+ `saved wait ${id} is terminal and cannot be cancelled`,
1230
+ );
1231
+ conflict.localWaitId = id;
1232
+ conflict.recoveryArgv = ["wait", "resume", id];
1233
+ throw conflict;
1234
+ }
1235
+ }
1236
+ } else if (resolution.error) {
1237
+ delivered = { delivered: false, error: resolution.error };
1238
+ } else if (resolution.missing) {
1239
+ // The original process never reached registration. A lookup must never
1240
+ // create/evaluate a new claim merely to cancel it.
1241
+ delivered = { delivered: true };
1242
+ }
1243
+ delivered ??= await deliverRecoveryAction(current, "cancel");
1244
+ if (delivered.error === "wait_claim_grant_recovery_required") {
1245
+ // The grant committed before the former process could save its
1246
+ // receipt. Keep (or restore) a resumable checkpoint so the held
1247
+ // resource is visible and its grant can be replayed.
1248
+ current = await store.update(id, {
1249
+ status: "armed",
1250
+ cancelled_at: null,
1251
+ cloud_cancel_pending: false,
1252
+ cloud_cancel_error: delivered.error,
1253
+ });
1254
+ const conflict = new WaitCheckpointError(
1255
+ "wait_claim_grant_recovery_required",
1256
+ `saved wait ${id} already holds a committed claim; resume it to replay the grant`,
1257
+ );
1258
+ conflict.localWaitId = id;
1259
+ conflict.recoveryArgv = ["wait", "resume", id];
1260
+ throw conflict;
1261
+ }
1262
+ current = await store.update(id, {
1263
+ status: "cancelled",
1264
+ cancelled_at: new Date().toISOString(),
1265
+ cloud_cancel_pending: checkpointNeedsRelay(current) && !delivered.delivered,
1266
+ ...(delivered.delivered
1267
+ ? { cloud_cancelled_at: new Date().toISOString(), cloud_cancel_error: null }
1268
+ : { cloud_cancel_error: delivered.error }),
1269
+ });
1270
+ } finally {
1271
+ await fence.release();
1272
+ }
1273
+ process.stdout.write(`${JSON.stringify({
1274
+ schema_version: 1, outcome: "cancelled", local_wait_id: id,
1275
+ ...(current.cloud_cancel_pending ? { cloud_cancel_pending: true } : {}),
1276
+ })}\n`);
1277
+ process.exit(EXIT.MATCHED);
1278
+ } catch (error) { return emitSavedWaitError(error); }
1279
+ }
1280
+
1281
+ async function acknowledgeSavedWait(id) {
1282
+ let fence;
1283
+ try {
1284
+ const store = checkpointStore();
1285
+ // A terminal checkpoint can exist briefly while its original owner is still
1286
+ // responsible for emitting the receipt. Never hide or mutate that result
1287
+ // until the same process-instance fence used by resume proves ownership.
1288
+ fence = await store.acquireFence(id);
1289
+ let checkpoint = await store.read(id);
1290
+ if (checkpoint.status === "terminal") checkpoint = await flushPendingFinalization(store, checkpoint);
1291
+ checkpoint = await store.acknowledge(id, {
1292
+ cloudAckPending: Boolean(checkpoint.cloud_wait_session_id),
1293
+ });
1294
+ if (checkpoint.cloud_wait_session_id) {
1295
+ checkpoint = await flushPendingAcknowledgement(store, checkpoint);
1296
+ }
1297
+ process.stdout.write(`${JSON.stringify({
1298
+ schema_version: 1, outcome: "acknowledged", local_wait_id: id,
1299
+ ...(checkpoint.cloud_ack_pending ? { cloud_ack_pending: true } : {}),
1300
+ })}\n`);
1301
+ await fence.release();
1302
+ fence = null;
1303
+ process.exit(EXIT.MATCHED);
1304
+ } catch (error) {
1305
+ await fence?.release();
1306
+ return emitSavedWaitError(error);
1307
+ }
1308
+ }
1309
+
1310
+ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1311
+ if (argv.length === 0 || argv[0] === "--offset") return inspectSavedWaits(argv);
1312
+ if (argv[0] === "resume") {
1313
+ const id = argv[1];
1314
+ if (!id || argv.length > 2) return emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "select exactly one saved wait: bb wait resume <local_wait_id>"));
1315
+ return resumeSavedWait(id);
1316
+ }
1317
+ if (argv[0] === "cancel") return argv.length === 2 ? cancelSavedWait(argv[1]) : emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "usage: bb wait cancel <local_wait_id>"));
1318
+ if (argv[0] === "acknowledge") return argv.length === 2 ? acknowledgeSavedWait(argv[1]) : emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "usage: bb wait acknowledge <local_wait_id>"));
762
1319
  const opts = parseArgv(argv);
763
1320
  const emitReceipt = (receipt, options = {}) => emit(receipt, {
764
1321
  ...options,
@@ -795,6 +1352,22 @@ export async function runWait(argv) {
795
1352
  emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.missing });
796
1353
  process.exit(EXIT.INVALID);
797
1354
  }
1355
+ if ((opts.resumeLocalWait != null || opts.deadlineAt != null) &&
1356
+ (recoveryLocalWaitId == null || opts.resumeLocalWait !== recoveryLocalWaitId)) {
1357
+ process.stderr.write("bb-wait: saved wait recovery options are internal; use bb wait resume <local_wait_id>\n");
1358
+ emitReceipt({
1359
+ schema_version: 1,
1360
+ outcome: "error",
1361
+ error: "wait_not_resumable",
1362
+ recovery_argv: ["wait", "resume", opts.resumeLocalWait ?? "<local_wait_id>"],
1363
+ });
1364
+ process.exit(EXIT.INVALID);
1365
+ }
1366
+ if (opts.deadlineAt != null && !opts.resumeLocalWait) {
1367
+ process.stderr.write("bb-wait: --deadline-at is reserved for saved wait recovery\n");
1368
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: "--deadline-at" });
1369
+ process.exit(EXIT.INVALID);
1370
+ }
798
1371
 
799
1372
  const { conditions, errors } = parseConditions(opts.conditions);
800
1373
  if (errors.length > 0) {
@@ -812,13 +1385,91 @@ export async function runWait(argv) {
812
1385
  emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_timeout" });
813
1386
  process.exit(EXIT.INVALID);
814
1387
  }
815
- if (!Number.isSafeInteger(opts.receiptMaxBytes) || opts.receiptMaxBytes < MIN_RECEIPT_MAX_BYTES) {
816
- process.stderr.write(`bb-wait: --receipt-max-bytes must be an integer of at least ${MIN_RECEIPT_MAX_BYTES}\n`);
817
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_receipt_max_bytes", minimum: MIN_RECEIPT_MAX_BYTES });
1388
+ if (!Number.isSafeInteger(opts.receiptMaxBytes) || opts.receiptMaxBytes < MIN_RECEIPT_MAX_BYTES ||
1389
+ opts.receiptMaxBytes > MAX_RECEIPT_MAX_BYTES) {
1390
+ process.stderr.write(`bb-wait: --receipt-max-bytes must be an integer from ${MIN_RECEIPT_MAX_BYTES} through ${MAX_RECEIPT_MAX_BYTES}\n`);
1391
+ emitReceipt({
1392
+ schema_version: 1, outcome: "error", error: "invalid_receipt_max_bytes",
1393
+ minimum: MIN_RECEIPT_MAX_BYTES, maximum: MAX_RECEIPT_MAX_BYTES,
1394
+ });
818
1395
  process.exit(EXIT.INVALID);
819
1396
  }
820
1397
  const timeoutSec = Math.min(opts.timeout, 28800); // 8h hard cap (session-budget gate)
821
- const deadlineMs = Date.now() + timeoutSec * 1000;
1398
+ const deadlineMs = opts.deadlineAt == null ? Date.now() + timeoutSec * 1000 : Date.parse(opts.deadlineAt);
1399
+ if (!Number.isFinite(deadlineMs)) {
1400
+ emitReceipt({ schema_version: 1, outcome: "error", error: "wait_checkpoint_invalid" });
1401
+ process.exit(EXIT.INVALID);
1402
+ }
1403
+
1404
+ // A valid wait gets a durable identity BEFORE its first remote side effect.
1405
+ // Credentials are intentionally absent: resume always obtains fresh session
1406
+ // credentials through register_agent/the normal environment bootstrap.
1407
+ const store = checkpointStore();
1408
+ let checkpoint;
1409
+ try {
1410
+ if (opts.resumeLocalWait) {
1411
+ checkpoint = await store.read(opts.resumeLocalWait);
1412
+ if (!(await sameWorktree(checkpoint.worktree, process.cwd()))) throw new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different worktree");
1413
+ if (!["preparing", "armed", "interrupted"].includes(checkpoint.status)) {
1414
+ throw new WaitCheckpointError("wait_not_resumable", `saved wait is ${checkpoint.status}`);
1415
+ }
1416
+ const createdMs = Date.parse(checkpoint.created_at);
1417
+ const savedDeadlineMs = Date.parse(checkpoint.deadline_at);
1418
+ if (opts.deadlineAt !== checkpoint.deadline_at || !Number.isFinite(createdMs) ||
1419
+ !Number.isFinite(savedDeadlineMs) || savedDeadlineMs > createdMs + 28_800_000) {
1420
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "saved wait deadline exceeds its original eight-hour cap");
1421
+ }
1422
+ const expectedSpecs = resumedSpecs(checkpoint);
1423
+ if (expectedSpecs.length !== opts.conditions.length ||
1424
+ expectedSpecs.some((spec, index) => spec !== opts.conditions[index])) {
1425
+ throw new WaitCheckpointError("wait_checkpoint_invalid", "saved wait conditions differ from the recovery invocation");
1426
+ }
1427
+ restoreCapacityStaleDeadlines(conditions, checkpoint);
1428
+ } else {
1429
+ let relayUrl = null;
1430
+ try {
1431
+ const parsedUrl = new URL(opts.url);
1432
+ // A bearer URL is a secret. Retain only the endpoint origin/path used for
1433
+ // recovery; query strings, fragments, and userinfo never touch disk.
1434
+ parsedUrl.username = ""; parsedUrl.password = ""; parsedUrl.search = ""; parsedUrl.hash = "";
1435
+ relayUrl = parsedUrl.toString().replace(/\/$/, "");
1436
+ } catch { throw new WaitCheckpointError("wait_checkpoint_write_failed", "relay URL cannot be stored safely"); }
1437
+ const timerDeadlines = conditions.filter((condition) => condition.type === "timer").map((condition) =>
1438
+ condition.params.deadline ?? new Date(Date.now() + condition.params.duration * 1000).toISOString());
1439
+ const capacityStaleDeadlines = conditions.filter((condition) => condition.type === "capacity").map((condition) =>
1440
+ new Date(Date.now() + condition.params.staleGraceSec * 1000).toISOString());
1441
+ checkpoint = await store.create({
1442
+ local_wait_id: `wait-${crypto.randomUUID()}`,
1443
+ deadline_at: new Date(deadlineMs).toISOString(),
1444
+ conditions,
1445
+ condition_specs: opts.conditions,
1446
+ timer_deadlines: timerDeadlines,
1447
+ capacity_stale_deadlines: capacityStaleDeadlines,
1448
+ heartbeat: opts.heartbeat,
1449
+ receipt_max_bytes: opts.receiptMaxBytes,
1450
+ relay_url: relayUrl,
1451
+ worktree: process.cwd(),
1452
+ });
1453
+ }
1454
+ // A live process retains this fence until exit. The file records a PID and
1455
+ // its start identity, so a reused PID cannot become evidence of ownership.
1456
+ // Recovery takes the fence before changing state or opening another SSE
1457
+ // subscriber for this logical wait.
1458
+ const fence = await store.acquireFence(checkpoint.local_wait_id);
1459
+ checkpoint = await store.update(checkpoint.local_wait_id, {
1460
+ owner_fence: fence.owner,
1461
+ ...(opts.resumeLocalWait ? { resumed_at: new Date().toISOString() } : {}),
1462
+ });
1463
+ opts.localWaitId = checkpoint.local_wait_id;
1464
+ if (opts.resumeLocalWait) {
1465
+ opts.expectedAgentId = typeof checkpoint.agent_id === "string" ? checkpoint.agent_id : null;
1466
+ opts.expectedTenantId = typeof checkpoint.tenant_id === "string" ? checkpoint.tenant_id : null;
1467
+ }
1468
+ } catch (error) {
1469
+ return emitSavedWaitError(error instanceof WaitCheckpointError
1470
+ ? error
1471
+ : new WaitCheckpointError("wait_checkpoint_write_failed", String(error?.message ?? error)));
1472
+ }
822
1473
 
823
1474
  const needsRelay = conditions.some((c) => c.type !== "timer");
824
1475
  // BOT-1572/1582/1608: a relay wait authenticates from the per-session
@@ -882,8 +1533,17 @@ export async function runWait(argv) {
882
1533
  // BOT-1467: the session agent the relay attributed the wait to (when overridden).
883
1534
  let registeredSessionAgentId = null;
884
1535
  let registeredSessionId = null;
1536
+ let replayMatchedClaim = false;
1537
+ let replayExpiredRecovery = false;
885
1538
  if (needsRelay) {
886
1539
  try {
1540
+ // The reservation is made while this checkpoint is still live, on the
1541
+ // database clock. An expired recovery with no reservation fails closed and
1542
+ // cannot release a queue re-created by another logical wait.
1543
+ if ((Date.now() < deadlineMs || recoveryLocalWaitId != null) &&
1544
+ conditions.some((condition) => condition.type === "lock" && condition.params?.claim === true)) {
1545
+ await reserveClaimQueueBoundary(opts, conditions);
1546
+ }
887
1547
  const reg = await registerWait(opts, conditions, new Date(deadlineMs).toISOString());
888
1548
  waitSessionId = reg.waitSessionId;
889
1549
  opts.waitSessionId = waitSessionId;
@@ -891,6 +1551,55 @@ export async function runWait(argv) {
891
1551
  registeredAgentId = reg.agentId;
892
1552
  registeredSessionAgentId = reg.sessionAgentId;
893
1553
  registeredSessionId = reg.sessionId;
1554
+ // A freshly minted session token may represent the same immutable agent,
1555
+ // but never a different tenant/agent or worktree. Check before exposing a
1556
+ // result or opening the SSE stream.
1557
+ if ((checkpoint.agent_id && checkpoint.agent_id !== registeredAgentId) ||
1558
+ (checkpoint.tenant_id != null && checkpoint.tenant_id !== sessionTenant)) {
1559
+ throw new WaitCheckpointError("wait_resume_identity_mismatch", "fresh session does not own this saved wait");
1560
+ }
1561
+ // A lock grant is committed as a durable spine signal and terminalizes the
1562
+ // cloud wait without a receipt. Replaying from cursor_start/safe_cursor is
1563
+ // therefore the recovery path for a receipt-less matched claim.
1564
+ replayMatchedClaim = reg.status === "matched" && !reg.terminalReceipt && hasClaim;
1565
+ replayExpiredRecovery = Boolean(
1566
+ opts.resumeLocalWait && Date.now() >= deadlineMs && (
1567
+ (!hasClaim && reg.status === "active") ||
1568
+ (reg.status === "abandoned" && reg.recoveryError === "wait_superseded")
1569
+ ),
1570
+ );
1571
+ if (["matched", "timeout", "error"].includes(reg.status) && !reg.terminalReceipt && !replayMatchedClaim) {
1572
+ throw new WaitCheckpointError("wait_resume_result_unavailable", "cloud wait is terminal but has no recoverable receipt");
1573
+ }
1574
+ const abandonedClaim = reg.status === "abandoned"
1575
+ ? checkpoint.conditions.find((condition) => condition.type === "lock" && condition.params?.claim === true)
1576
+ : null;
1577
+ if (abandonedClaim && reg.recoveryError !== "wait_superseded") {
1578
+ const error = new WaitCheckpointError("wait_claim_requeue_required", "reacquire the claimed resource before resuming this abandoned wait");
1579
+ error.localWaitId = checkpoint.local_wait_id;
1580
+ error.recovery = `call acquire_lock for ${abandonedClaim.params.subtype}:${abandonedClaim.params.host}:${abandonedClaim.params.slot}, then: bb wait resume ${checkpoint.local_wait_id}`;
1581
+ throw error;
1582
+ }
1583
+ if (reg.terminalReceipt) {
1584
+ const recovered = preserveCheckpointCursor(publicWaitReceipt(reg.terminalReceipt, {
1585
+ sessionTenant, agentId: registeredAgentId, sessionAgentId: registeredSessionAgentId,
1586
+ sessionId: registeredSessionId, maxBytes: opts.receiptMaxBytes,
1587
+ }), checkpoint.safe_cursor, checkpoint.created_at);
1588
+ const recoveredCancellation = recovered.outcome === "cancelled";
1589
+ await store.update(checkpoint.local_wait_id, {
1590
+ status: recoveredCancellation ? "cancelled" : "terminal", terminal_receipt: recovered,
1591
+ exit_code: exitForRecoveredOutcome(recovered), cloud_wait_session_id: waitSessionId,
1592
+ cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null,
1593
+ ...(recoveredCancellation ? {
1594
+ cancelled_at: new Date().toISOString(),
1595
+ cloud_cancel_pending: false,
1596
+ cloud_cancelled_at: new Date().toISOString(),
1597
+ cloud_cancel_error: null,
1598
+ } : {}),
1599
+ });
1600
+ emitReceipt(recovered, { versioned: true });
1601
+ process.exit(exitForRecoveredOutcome(recovered));
1602
+ }
894
1603
  // BOT-1539: if any pr-review/pr-state target already has an outstanding
895
1604
  // review, tell the operator now (stderr, one line per PR). Purely
896
1605
  // informational — it does not change arming or exit semantics.
@@ -941,6 +1650,16 @@ export async function runWait(argv) {
941
1650
  // Arm from the registration high-water mark unless the caller pinned an
942
1651
  // explicit --since (resume). cursor_start replays the register→connect gap.
943
1652
  if (opts.since == null && reg.cursorStart != null) effectiveSince = String(reg.cursorStart);
1653
+ checkpoint = await store.update(checkpoint.local_wait_id, {
1654
+ status: "armed",
1655
+ cloud_wait_session_id: waitSessionId,
1656
+ cursor_start: reg.cursorStart,
1657
+ safe_cursor: effectiveSince,
1658
+ tenant_id: sessionTenant ?? null,
1659
+ agent_id: registeredAgentId ?? null,
1660
+ arming_session_id: registeredSessionId ?? null,
1661
+ conditions,
1662
+ });
944
1663
  if (hasClaim && waitSessionId == null) {
945
1664
  process.stderr.write("bb-wait: claim=true registration returned no wait_session_id; refusing to arm an unmatchable claim wait\n");
946
1665
  emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
@@ -1066,6 +1785,7 @@ export async function runWait(argv) {
1066
1785
  emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.detail) });
1067
1786
  process.exit(EXIT.INTERNAL);
1068
1787
  }
1788
+ if (err instanceof WaitCheckpointError) return emitSavedWaitError(err);
1069
1789
  if (hasClaim) {
1070
1790
  process.stderr.write(`bb-wait: claim=true wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
1071
1791
  emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
@@ -1084,48 +1804,114 @@ export async function runWait(argv) {
1084
1804
  }
1085
1805
  }
1086
1806
 
1807
+ if (!needsRelay) {
1808
+ try {
1809
+ checkpoint = await store.update(checkpoint.local_wait_id, { status: "armed", safe_cursor: effectiveSince, conditions });
1810
+ } catch (error) { return emitSavedWaitError(error); }
1811
+ }
1812
+
1087
1813
  const connect = needsRelay ? makeConnect(opts) : async () => offlineStream();
1088
1814
  // BOT-1147 AC-3: wire the feed-freshness probe only when a linear condition is
1089
1815
  // armed against the relay (runWaitLoop no-ops the probe for other wait types).
1090
1816
  const feedLagProbe = needsRelay && conditions.some((c) => c.type === "linear")
1091
1817
  ? makeFeedLagProbe(opts)
1092
1818
  : null;
1819
+ const buildPublicReceipt = (receipt) => publicWaitReceipt(receipt, {
1820
+ sessionTenant,
1821
+ agentId: registeredAgentId,
1822
+ sessionAgentId: registeredSessionAgentId,
1823
+ sessionId: registeredSessionId,
1824
+ maxBytes: opts.receiptMaxBytes,
1825
+ });
1826
+ let terminalPersistedFromFrame = false;
1093
1827
 
1094
1828
  try {
1829
+ // A committed claim grant is authoritative even if recovery starts after the
1830
+ // original deadline. It must keep replaying until the durable grant arrives
1831
+ // (or the transport fails non-terminally); a fixed grace would persist a false
1832
+ // timeout while the resource remains held. Non-claim expired recovery retains
1833
+ // a bounded transport grace while it replays pre-deadline signals.
1834
+ const loopDeadlineMs = replayMatchedClaim ? null : replayExpiredRecovery ? Date.now() + 10_000 : deadlineMs;
1095
1835
  const { receipt, exitCode } = await runWaitLoop({
1096
1836
  waitSessionId,
1097
1837
  conditions,
1098
- deadlineMs,
1838
+ deadlineMs: loopDeadlineMs,
1839
+ replayOnlyThroughMs: replayExpiredRecovery ? deadlineMs : null,
1099
1840
  since: effectiveSince,
1100
1841
  connect,
1101
1842
  feedLagProbe,
1102
1843
  receiptMaxBytes: opts.receiptMaxBytes,
1844
+ startedAt: checkpoint.created_at,
1103
1845
  // BOT-1259: fail-closed tenant scope for the central guard, plus a debug sink
1104
1846
  // (opt-in via BB_WAIT_DEBUG) that surfaces each fail-closed drop without turning
1105
1847
  // it into an error.
1106
1848
  sessionTenant,
1849
+ onFrame: async (_signal, state) => {
1850
+ if (state?.terminalReceipt) {
1851
+ // The core has evaluated this frame as a match. Save its bounded result
1852
+ // before returning to the caller, so SIGKILL cannot lose a match between
1853
+ // decode and stdout.
1854
+ const terminalReceipt = buildPublicReceipt(state.terminalReceipt);
1855
+ checkpoint = await store.update(checkpoint.local_wait_id, {
1856
+ status: "terminal",
1857
+ terminal_receipt: terminalReceipt,
1858
+ exit_code: state.exitCode,
1859
+ safe_cursor: terminalReceipt.next_cursor ?? state.cursor ?? checkpoint.safe_cursor,
1860
+ cloud_finalize_pending: Boolean(waitSessionId && terminalReceipt.outcome !== "superseded"),
1861
+ });
1862
+ terminalPersistedFromFrame = true;
1863
+ return;
1864
+ }
1865
+ checkpoint = await store.update(checkpoint.local_wait_id, { safe_cursor: state?.cursor ?? checkpoint.safe_cursor });
1866
+ },
1107
1867
  debug: process.env.BB_WAIT_DEBUG ? (msg) => process.stderr.write(`${msg}\n`) : null,
1108
1868
  });
1109
- // Emit the wake receipt FIRST it is the terminal result the harness reads.
1110
- // Finalization is best-effort telemetry and must never gate or delay it.
1111
- const terminalReceipt = truncateReceipt(
1112
- withClientIdentity(withPrincipalReceipt(receipt, null, {
1113
- sessionTenant,
1114
- agentId: registeredAgentId,
1115
- sessionAgentId: registeredSessionAgentId,
1116
- sessionId: registeredSessionId,
1117
- })),
1118
- opts.receiptMaxBytes,
1119
- );
1869
+ // Build the same bounded public receipt used by the frame-time durable write.
1870
+ // Cloud finalization is best-effort telemetry and must never gate stdout.
1871
+ const terminalReceipt = buildPublicReceipt(receipt);
1872
+ if (replayExpiredRecovery && receipt.error === "replay_incomplete") {
1873
+ // A replay grace only limits this process, never the durable logical wait.
1874
+ // Keep the checkpoint resumable and leave the active cloud row untouched.
1875
+ checkpoint = await store.update(checkpoint.local_wait_id, {
1876
+ status: "interrupted",
1877
+ safe_cursor: terminalReceipt.next_cursor ?? checkpoint.safe_cursor ?? null,
1878
+ });
1879
+ emitReceipt(terminalReceipt, { versioned: true });
1880
+ process.exit(exitCode);
1881
+ }
1882
+ // Receipt first, stdout second, cloud finalization last. A killed harness can
1883
+ // always re-read this exact terminal result without opening a new wait.
1884
+ if (!terminalPersistedFromFrame) {
1885
+ checkpoint = await store.update(checkpoint.local_wait_id, {
1886
+ status: "terminal",
1887
+ terminal_receipt: terminalReceipt,
1888
+ exit_code: exitCode,
1889
+ safe_cursor: terminalReceipt.next_cursor ?? checkpoint.safe_cursor ?? null,
1890
+ cloud_finalize_pending: Boolean(waitSessionId && terminalReceipt.outcome !== "superseded"),
1891
+ });
1892
+ }
1120
1893
  emitReceipt(terminalReceipt, { versioned: true });
1121
1894
  // BOT-1228: a `superseded` outcome means an external actor already terminalized
1122
1895
  // this wait_session server-side (reconcile_waits_to_canonical → abandoned, with
1123
1896
  // ended_at/updated_at stamped). Skip the client finalize: the row is already
1124
1897
  // terminal (finalize_wait_session only transitions from active, so it'd be a
1125
1898
  // no-op) and `superseded` isn't a wait_session_status enum value anyway.
1126
- if (waitSessionId && receipt.outcome !== "superseded") await finalizeWait(opts, waitSessionId, terminalReceipt);
1899
+ if (waitSessionId && receipt.outcome !== "superseded") {
1900
+ const finalized = await finalizeWait(opts, waitSessionId, terminalReceipt);
1901
+ try {
1902
+ checkpoint = await store.update(checkpoint.local_wait_id, finalized.delivered
1903
+ ? { cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null }
1904
+ : { cloud_finalize_pending: true, cloud_finalize_error: finalized.error });
1905
+ } catch (error) {
1906
+ // The terminal receipt was already durable and emitted. Leave its
1907
+ // pending marker in place for recovery; never emit a second receipt.
1908
+ process.stderr.write(`bb-wait: finalize checkpoint update (non-fatal) failed: ${error?.message ?? error}\n`);
1909
+ }
1910
+ }
1127
1911
  process.exit(exitCode);
1128
1912
  } catch (err) {
1913
+ try { if (checkpoint?.local_wait_id) await store.update(checkpoint.local_wait_id, { status: "interrupted" }); } catch { /* retain original diagnostic state */ }
1914
+ if (err instanceof WaitCheckpointError) return emitSavedWaitError(err);
1129
1915
  process.stderr.write(`bb-wait: ${err && err.stack || err}\n`);
1130
1916
  emitReceipt({ schema_version: 1, outcome: "error", error: "internal" });
1131
1917
  process.exit(EXIT.INTERNAL);