@botbuddy/cli 1.63.33 → 1.63.37

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.63.33",
3
- "source_commit": "70dd5a394a7180acebe24941d884cec93eb5f1cc",
4
- "source_sha256": "16eed9e08035023447c0c964ce608a2a7324a24fdbe21df4de3085fda26f3bfc",
2
+ "release_version": "1.63.37",
3
+ "source_commit": "503ca85897b8b844ee5a841addcbb383c563de68",
4
+ "source_sha256": "16d04abbbc7838ae352f1cde6e5b7f76d47c2ae3723d47a0d79f3b1ebcc74c63",
5
5
  "target": "darwin-arm64",
6
- "sha256": "41ea9976bf224497e18148142d93ae53b231e16d814ee0f7d406cc82d5a6d39f"
6
+ "sha256": "dc1147cf8c4554b973d31c14fe95d2edadf7623b23e9ac9d578d4e9a6de7f975"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.33",
3
- "source_commit": "70dd5a394a7180acebe24941d884cec93eb5f1cc",
4
- "source_sha256": "16eed9e08035023447c0c964ce608a2a7324a24fdbe21df4de3085fda26f3bfc",
2
+ "release_version": "1.63.37",
3
+ "source_commit": "503ca85897b8b844ee5a841addcbb383c563de68",
4
+ "source_sha256": "16d04abbbc7838ae352f1cde6e5b7f76d47c2ae3723d47a0d79f3b1ebcc74c63",
5
5
  "target": "linux-x64",
6
- "sha256": "94e4da81775defe0589d339d1eac3f6885ee529ec4df71aa35809cb6d498c5b3"
6
+ "sha256": "f8c16854c6324625c2284a3707edafa0c4f79a0560a8cc7a97a2f952014b1900"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.63.33",
4
- "source_commit": "70dd5a394a7180acebe24941d884cec93eb5f1cc",
5
- "source_sha256": "16eed9e08035023447c0c964ce608a2a7324a24fdbe21df4de3085fda26f3bfc",
3
+ "release_version": "1.63.37",
4
+ "source_commit": "503ca85897b8b844ee5a841addcbb383c563de68",
5
+ "source_sha256": "16d04abbbc7838ae352f1cde6e5b7f76d47c2ae3723d47a0d79f3b1ebcc74c63",
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": "41ea9976bf224497e18148142d93ae53b231e16d814ee0f7d406cc82d5a6d39f",
11
+ "sha256": "dc1147cf8c4554b973d31c14fe95d2edadf7623b23e9ac9d578d4e9a6de7f975",
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": "94e4da81775defe0589d339d1eac3f6885ee529ec4df71aa35809cb6d498c5b3",
18
+ "sha256": "f8c16854c6324625c2284a3707edafa0c4f79a0560a8cc7a97a2f952014b1900",
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.63.33",
3
+ "version": "1.63.37",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/run.mjs CHANGED
@@ -35,6 +35,11 @@ export const EXIT = Object.freeze({ OK: 0, AUTH: 3, INVALID: 4, BACKEND: 5, INTE
35
35
  export const DEFAULT_TIMEOUT_SECONDS = 8 * 60 * 60;
36
36
  export const TIMEOUT_GRACE_MS = 5_000;
37
37
  const MAX_CAPTURE_BYTES = 8_192;
38
+ // BOT-2530: a detached worker holds a server-clocked liveness lease on its run.
39
+ // It renews every 30 s; four missed renewals (120 s) let the server's reconciler
40
+ // terminalize the run as owner_lost so a `command-run:` waiter always ends.
41
+ export const WORKER_HEARTBEAT_TTL_SECONDS = 120;
42
+ export const WORKER_HEARTBEAT_INTERVAL_MS = 30_000;
38
43
  // BOT-1510: how often the worker flushes lane case verdicts to the backend.
39
44
  // Well under AC-3's 5 s so a begin/verdict is visible within the window.
40
45
  const LANE_FLUSH_INTERVAL_MS = 2_500;
@@ -583,6 +588,7 @@ export async function launchRun(argv, {
583
588
  command_hash: commandFingerprint, ...(launchSourceSha ? { source_sha: launchSourceSha } : {}), environment_hash: environmentHash(cwd, opts.environment),
584
589
  environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
585
590
  rerun_reason: opts.rerunReason,
591
+ worker_heartbeat_ttl_seconds: WORKER_HEARTBEAT_TTL_SECONDS,
586
592
  });
587
593
  let registration = await effectiveCall("register_command", registerArgs());
588
594
  // A cached session can be revoked server-side before its local expiry. The
@@ -610,7 +616,7 @@ export async function launchRun(argv, {
610
616
  // every callback is attributable to the same agent. It deliberately stays
611
617
  // outside child_env, receipts, and the workload's process environment.
612
618
  const knownSession = opts.sessionId ?? managedCredential?.identity?.sessionId ?? managedCredential?.sessionId;
613
- 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 } : {}) });
619
+ 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, worker_heartbeat_ttl_seconds: WORKER_HEARTBEAT_TTL_SECONDS, ...(opts.sessionToken ? { callback_session_token: opts.sessionToken } : {}), ...(knownSession ? { callback_session_id: knownSession } : {}), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
614
620
  const [file, args] = workerInvocation(context);
615
621
  try {
616
622
  const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
@@ -623,7 +629,10 @@ export async function launchRun(argv, {
623
629
  }
624
630
  return { exitCode: EXIT.OK, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "launched", run_id: runId,
625
631
  ...(knownSession ? { session_id: knownSession } : { session_binding: "server_derived" }),
626
- durable_owner: "detached_worker", receipt_path: receipt, reentry: "update_command_run publishes the terminal callback for this run_id", exit_code: EXIT.OK } };
632
+ durable_owner: "detached_worker", receipt_path: receipt, reentry: "update_command_run publishes the terminal callback for this run_id",
633
+ // BOT-2318: the exact pending wait for this run. Keep its result pending in
634
+ // the same work session; its return is what resumes the caller (BOT-2501).
635
+ wait_command: `bb wait 'command-run:id=${runId}'`, exit_code: EXIT.OK } };
627
636
  }
628
637
 
629
638
  export async function runWorker(contextFile, {
@@ -633,11 +642,14 @@ export async function runWorker(contextFile, {
633
642
  terminate = terminateProcessGroup,
634
643
  // BOT-2600: the bounded terminal delivery (see retryTerminalUpdate).
635
644
  sleep, now, terminalBudgetMs, startDelivery,
645
+ // BOT-2530: worker liveness lease renewal cadence (tests inject a fake timer).
646
+ workerHeartbeatIntervalMs = WORKER_HEARTBEAT_INTERVAL_MS, setIntervalFn = setInterval,
636
647
  } = {}) {
637
648
  const retry = { sleep, now, budgetMs: terminalBudgetMs, startDelivery };
638
649
  const context = JSON.parse(await readFile(contextFile, "utf8"));
639
650
  const callbackCall = callWithContextIdentity(context, call);
640
651
  const ownerHeartbeat = startOwnerHeartbeat(context, callbackCall);
652
+ const workerLease = startWorkerLease(context, callbackCall, { intervalMs: workerHeartbeatIntervalMs, setIntervalFn });
641
653
  const startedAt = new Date().toISOString();
642
654
  let timedOut = false;
643
655
  let child;
@@ -738,11 +750,44 @@ export async function runWorker(contextFile, {
738
750
  const receipt = await finalize(context, { status, startedAt, endedAt, exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, callbackCall, retry);
739
751
  return receipt;
740
752
  } finally {
753
+ if (workerLease) await workerLease.stop();
741
754
  if (ownerHeartbeat) await ownerHeartbeat.stop();
742
755
  signalEmitter.removeListener("SIGTERM", cancellationHandler);
743
756
  }
744
757
  }
745
758
 
759
+ // BOT-2530: renew this run's liveness lease while the worker lives. The first
760
+ // renewal also moves a registered run to running. A rejected or failed renewal
761
+ // is not retried specially: the next tick tries again, and a terminal run
762
+ // refuses renewals server-side. Contexts sealed by an older launcher (no
763
+ // declared lease) send nothing.
764
+ export function startWorkerLease(context, call, { intervalMs = WORKER_HEARTBEAT_INTERVAL_MS, setIntervalFn = setInterval } = {}) {
765
+ if (!Number.isInteger(context.worker_heartbeat_ttl_seconds)) return null;
766
+ let stopped = false;
767
+ let chain = Promise.resolve();
768
+ const tick = () => {
769
+ chain = chain.then(async () => {
770
+ if (stopped) return;
771
+ try {
772
+ await call("update_command_run", { run_id: context.run_id, run_credential: context.run_credential, status: "running", worker_heartbeat: true }, {
773
+ signal: AbortSignal.timeout(DELIVERY_CALL_TIMEOUT_MS),
774
+ ...(typeof context.server_url === "string" && context.server_url ? { serverUrl: context.server_url } : {}),
775
+ });
776
+ } catch { /* the lease lapses only after several missed renewals */ }
777
+ });
778
+ };
779
+ tick();
780
+ const timer = setIntervalFn(tick, intervalMs);
781
+ timer.unref?.();
782
+ return { async stop() {
783
+ stopped = true;
784
+ clearInterval(timer);
785
+ let bound;
786
+ await Promise.race([chain, new Promise((resolve) => { bound = setTimeout(resolve, 2_000); bound.unref?.(); })]);
787
+ clearTimeout(bound);
788
+ } };
789
+ }
790
+
746
791
  function startOwnerHeartbeat(context, call, { intervalMs = 5 * 60_000, setIntervalFn = setInterval } = {}) {
747
792
  // A detached worker must never acquire liveness through its ambient default
748
793
  // identity. It can only refresh the bearer sealed for its owner.
package/src/wait-core.mjs CHANGED
@@ -46,6 +46,12 @@ export const EXIT = Object.freeze({
46
46
  // four, optionally wrapped in ONE pair of braces.
47
47
  const LEASE_UUID_SPELLING = /^(?:\{(?=.*\}$)|(?!.*\}$))((?:[0-9a-f]{4}-?){7}[0-9a-f]{4})\}?$/;
48
48
 
49
+ // BOT-2318: bb run generates a lower-case UUID before register_command.
50
+ const COMMAND_RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
51
+ export const COMMAND_RUN_TERMINAL_STATUSES = Object.freeze(new Set([
52
+ "succeeded", "failed", "cancelled", "timed_out", "owner_lost", "invalidated",
53
+ ]));
54
+
49
55
  // BOT-2013: stack_lease frame states that mean the lease is gone, not granted.
50
56
  export const LEASE_LOST_STATES = Object.freeze(new Set(["reaping", "reaped", "reap_failed"]));
51
57
  // BOT-2400: the lease states a `lease:id=…,state=<s>` wait may target.
@@ -198,6 +204,18 @@ const VALIDATORS = {
198
204
  if (!p.id) throw new Error("test-run needs id=<run_id>");
199
205
  return { runId: String(p.id) };
200
206
  },
207
+ // BOT-2318: server binds this selector to the registering tenant, agent and work session.
208
+ "command-run": (p, raw) => {
209
+ const pair = /^\s*id\s*=\s*([0-9A-Fa-f-]+)\s*$/.exec(raw ?? "");
210
+ if (!pair || Object.keys(p).length !== 1 || p.id !== pair[1]) {
211
+ throw new Error("command-run needs exactly one id=<registered run id>");
212
+ }
213
+ const runId = pair[1].toLowerCase();
214
+ if (!COMMAND_RUN_ID.test(runId)) {
215
+ throw new Error("command-run id must be the canonical UUID that bb run printed");
216
+ }
217
+ return { runId };
218
+ },
201
219
  // BOT-2629: this is deliberately a dedicated condition rather than a raw
202
220
  // event escape hatch. The UUID is chosen before dispatch and the exact subject
203
221
  // prevents another run on the same idle/re-adopted lease waking this waiter.
@@ -620,7 +638,7 @@ export function parseConditions(specs) {
620
638
  return;
621
639
  }
622
640
  try {
623
- const params = validator(parseParams(rawParams));
641
+ const params = validator(parseParams(rawParams), rawParams);
624
642
  conditions.push({ id: `c${index + 1}`, type, params });
625
643
  } catch (err) {
626
644
  errors.push({ spec, message: err.message });
@@ -1210,6 +1228,11 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1210
1228
  prReviewFiltersMatch(signal, params, condition.review_guard);
1211
1229
  case "test-run":
1212
1230
  return signal.signal_type === "test_run" && signal.subject_key === `run:${params.runId}`;
1231
+ case "command-run":
1232
+ return signal.signal_type === "command_run" &&
1233
+ signal.subject_key === `run:${params.runId}` &&
1234
+ signal.payload?.run_id === params.runId &&
1235
+ COMMAND_RUN_TERMINAL_STATUSES.has(signal.payload?.status);
1213
1236
  case "stack-run":
1214
1237
  return signal.signal_type === "stack_run" && signal.subject_key === `stack-run:${params.id}` &&
1215
1238
  signal.payload?.run_id === params.id && Number.isInteger(signal.payload?.exit_code);
package/src/wait.mjs CHANGED
@@ -26,6 +26,7 @@ import { SETUP_BLOCK } from "./setup-block.mjs";
26
26
  import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
27
27
  import { realpath } from "node:fs/promises";
28
28
  import { deepLinkNotice, safeWebUrl } from "./deep-link.mjs";
29
+ import { redactTelemetry } from "./telemetry-outbox.mjs";
29
30
  import { assertWaitObserver, readWaitResult, waitCompletionState, waitObserverId, writeWaitReceipt } from "./wait-result.mjs";
30
31
 
31
32
  // A protocol is deliberately distinct from package semver: compatible pinned
@@ -152,6 +153,14 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
152
153
  valid workflow=/workflow_id=/workflow_path= lines.
153
154
  test-run:id=<run_id> a test run reaching a terminal status (owner-scoped);
154
155
  bb test <lane> prints the exact line to arm.
156
+ command-run:id=<run_id> BOT-2318: the bb run you registered reaching a terminal
157
+ outcome (succeeded, failed, cancelled, timed_out,
158
+ owner_lost, invalidated). Exit 0 on any terminal outcome;
159
+ the receipt's status says how the command ended. Arm it
160
+ from the same work session that ran bb run, as the ONLY
161
+ condition, and keep this wait's result pending: its
162
+ return is what resumes your task. A run whose worker is
163
+ lost ends owner_lost (bb run inspect <run_id>).
155
164
  stack-run:id=<run UUID> one named bb stack run reaching its terminal outcome
156
165
  (BOT-2629). Choose the UUID BEFORE dispatch and pass it as
157
166
  bb stack run --run-id <UUID>; the receipt carries exit_code,
@@ -481,6 +490,25 @@ async function reserveClaimQueueBoundary(opts, conditions) {
481
490
  }
482
491
  }
483
492
 
493
+ // BOT-2855: the concrete next step for a typed registration refusal, or null when
494
+ // there is none beyond the relay's own detail. Built from the parsed conditions
495
+ // only (repo / PR number), never from a credential.
496
+ export function registerRefusalRecovery(errorCode, conditions) {
497
+ const release = Array.isArray(conditions) ? conditions.find((c) => c?.type === "staging-release") : null;
498
+ const repo = release?.params?.repo ?? "<owner/repo>";
499
+ const pr = release?.params?.pr ?? "<n>";
500
+ const arm = `bb wait 'staging-release:repo=${repo},pr=${pr}'`;
501
+ if (errorCode === "pr_not_merged") {
502
+ return "staging-release arms only after the PR merges. Before the merge, wait for it with "
503
+ + `bb wait 'pr-state:repo=${repo},pr=${pr}' (or for its CI with bb wait 'ci:repo=${repo},scope=latest,pr=${pr}'), `
504
+ + `then arm ${arm}`;
505
+ }
506
+ if (errorCode === "pr_merge_sha_unresolved") {
507
+ return `the merge commit is not visible yet; retry ${arm} in a minute`;
508
+ }
509
+ return null;
510
+ }
511
+
484
512
  async function registerWait(opts, conditions, deadlineIso) {
485
513
  // BOT-1649: a relay wait authenticates from the per-session `bb_sess_` token
486
514
  // ($BOTBUDDY_AGENT_SESSION_TOKEN) — the relay derives agent, tenant, and session from it.
@@ -516,15 +544,34 @@ async function registerWait(opts, conditions, deadlineIso) {
516
544
  },
517
545
  body: JSON.stringify(registerBody),
518
546
  });
547
+ // BOT-2855: read an error body ONCE. Each status branch below inspects it and may
548
+ // fall through to the generic `!res.ok` error; a second `res.json()` on the consumed
549
+ // body threw, the catch turned it into `{}`, and the relay's typed reason (e.g.
550
+ // `pr_not_merged`) collapsed to `register responded 400`. Redact any credential a
551
+ // relay detail might echo before it can reach stderr or the receipt.
552
+ let errorBody = null;
553
+ let errorBodyIsJson = false;
554
+ const readErrorBody = async () => {
555
+ if (errorBody) return errorBody;
556
+ const text = await res.text().catch(() => "");
557
+ try {
558
+ const parsed = JSON.parse(text);
559
+ errorBodyIsJson = Boolean(parsed) && typeof parsed === "object" && !Array.isArray(parsed);
560
+ errorBody = errorBodyIsJson ? redactTelemetry(parsed) : {};
561
+ } catch {
562
+ errorBody = {};
563
+ }
564
+ return errorBody;
565
+ };
519
566
  if (res.status === 429) {
520
- const body = await res.json().catch(() => ({}));
567
+ const body = await readErrorBody();
521
568
  const err = new Error(body.detail || "wait_session_cap_exceeded");
522
569
  err.cap = true;
523
570
  err.errorCode = body.error || "wait_session_cap_exceeded";
524
571
  throw err;
525
572
  }
526
573
  if (res.status === 401) {
527
- const body = await res.json().catch(() => ({}));
574
+ const body = await readErrorBody();
528
575
  const err = new Error(body.detail || body.message || body.error || "unauthorized agent key");
529
576
  err.auth = true;
530
577
  err.errorCode = body.error || "unauthorized";
@@ -540,7 +587,7 @@ async function registerWait(opts, conditions, deadlineIso) {
540
587
  // A TRANSIENT backend 400 (e.g. `register_failed` from the RPC) is NOT
541
588
  // caller-actionable — fall through to the existing live-only fallback so a valid
542
589
  // wait still arms (Codex round-7 P2).
543
- const body = await res.json().catch(() => ({}));
590
+ const body = await readErrorBody();
544
591
  const INVALID_CONDITION_CODES = new Set([
545
592
  "no_signal_source",
546
593
  "review_guard_registration_failed",
@@ -585,6 +632,15 @@ async function registerWait(opts, conditions, deadlineIso) {
585
632
  // caller-actionable hard stops, not the untracked live-only fallback.
586
633
  "chat_not_participant",
587
634
  "chat_participant_ambiguous",
635
+ // BOT-2855: BOT-1458 AC-3 — staging-release arms only for a merged PR. Before the
636
+ // merge the relay refuses it by design; the caller must wait for the merge first.
637
+ "pr_not_merged",
638
+ // BOT-2318: command-run targets are authorization-bound to the registering
639
+ // tenant, agent and work session, and the condition cannot be composed.
640
+ // Both refusals require the caller to change its target/conditions; falling
641
+ // back to an untracked wait would only hide that error behind a timeout.
642
+ "command_run_unavailable",
643
+ "command_run_single_condition_required",
588
644
  ]);
589
645
  if (INVALID_CONDITION_CODES.has(body.error)) {
590
646
  const err = new Error(body.detail || body.error);
@@ -595,7 +651,7 @@ async function registerWait(opts, conditions, deadlineIso) {
595
651
  }
596
652
  }
597
653
  if (res.status === 404) {
598
- const body = await res.json().catch(() => ({}));
654
+ const body = await readErrorBody();
599
655
  if (body.error === "linear_issue_not_found") {
600
656
  const err = new Error(body.detail || body.error);
601
657
  err.invalidCondition = true;
@@ -618,7 +674,7 @@ async function registerWait(opts, conditions, deadlineIso) {
618
674
  }
619
675
  }
620
676
  if (res.status === 403) {
621
- const body = await res.json().catch(() => ({}));
677
+ const body = await readErrorBody();
622
678
  if (new Set(["wait_tenant_unresolved", "wait_cross_tenant", "wait_tenant_mismatch", "wait_resume_identity_mismatch"]).has(body.error)) {
623
679
  const err = new Error(body.detail || body.error);
624
680
  err.invalidCondition = true;
@@ -641,7 +697,7 @@ async function registerWait(opts, conditions, deadlineIso) {
641
697
  throw err;
642
698
  }
643
699
  if (res.status === 426) {
644
- const body = await res.json().catch(() => ({}));
700
+ const body = await readErrorBody();
645
701
  const err = new Error(body.detail || "this wait client is no longer supported");
646
702
  err.clientOutdated = true;
647
703
  err.errorCode = body.error || "client_outdated";
@@ -655,7 +711,7 @@ async function registerWait(opts, conditions, deadlineIso) {
655
711
  // unblocked wait would skip the server's initial evaluation and could resume a
656
712
  // coding session on a false already_unblocked. Surface the typed receipt and a
657
713
  // concrete exit code (5=backend/retryable, 4=invalid/not-found).
658
- const body = await res.json().catch(() => ({}));
714
+ const body = await readErrorBody();
659
715
  if (body.error === "unblocked_projection_unresolved" || body.error === "unblocked_ticket_not_found") {
660
716
  const err = new Error(body.detail || body.error);
661
717
  err.hydrationRegister = true;
@@ -668,12 +724,20 @@ async function registerWait(opts, conditions, deadlineIso) {
668
724
  }
669
725
  }
670
726
  if (!res.ok) {
671
- const body = await res.json().catch(() => ({}));
672
- const err = new Error(body.detail || body.message || body.error || `register responded ${res.status}`);
727
+ const body = await readErrorBody();
728
+ const typedCode = typeof body.error === "string" && body.error ? body.error : null;
729
+ const detail = [body.detail, body.message].find((v) => typeof v === "string" && v) ?? null;
730
+ const err = new Error(detail || typedCode ||
731
+ `register responded ${res.status}${errorBodyIsJson ? "" : " with a non-JSON body"}`);
732
+ // BOT-2855: keep the relay's typed reason for the caller and the receipt.
733
+ err.errorCode = typedCode || "register_failed";
734
+ err.httpStatus = res.status;
673
735
  if (body.error === "initial_eval_failed") {
674
736
  err.initialEval = true;
675
- err.errorCode = body.error;
676
737
  err.detail = body.detail || "initial evaluation failed";
738
+ } else if (body.error === "pr_merge_sha_unresolved") {
739
+ // Merged, but neither the projection nor GitHub returned the merge commit yet.
740
+ err.retryableRegister = true;
677
741
  }
678
742
  throw err;
679
743
  }
@@ -2338,15 +2402,35 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2338
2402
  }
2339
2403
  }
2340
2404
  process.stderr.write(`bb-wait: invalid condition (${err.errorCode}) — ${err.message}\n`);
2405
+ const recovery = registerRefusalRecovery(err.errorCode, conditions);
2406
+ if (recovery) process.stderr.write(`bb-wait: next step: ${recovery}\n`);
2341
2407
  await emitReceipt({
2342
2408
  schema_version: 1,
2343
2409
  outcome: "error",
2344
2410
  error: err.errorCode,
2411
+ error_code: err.errorCode,
2345
2412
  detail: String(err.message),
2346
2413
  ...(Array.isArray(err.candidates) && err.candidates.length > 0 ? { candidates: err.candidates } : {}),
2414
+ ...(recovery ? { recovery } : {}),
2347
2415
  });
2348
2416
  process.exit(EXIT.INVALID);
2349
2417
  }
2418
+ if (err && err.retryableRegister) {
2419
+ // BOT-2855: a merged PR whose merge commit is not visible yet — retryable.
2420
+ const recovery = registerRefusalRecovery(err.errorCode, conditions);
2421
+ process.stderr.write(`bb-wait: wait-session registration failed (${err.errorCode}) — ${err.message}\n`);
2422
+ if (recovery) process.stderr.write(`bb-wait: next step: ${recovery}\n`);
2423
+ await emitReceipt({
2424
+ schema_version: 1,
2425
+ outcome: "error",
2426
+ error: err.errorCode,
2427
+ error_code: err.errorCode,
2428
+ detail: String(err.message),
2429
+ http_status: err.httpStatus ?? null,
2430
+ ...(recovery ? { recovery } : {}),
2431
+ });
2432
+ process.exit(EXIT.BACKEND);
2433
+ }
2350
2434
  if (err && err.initialEval) {
2351
2435
  process.stderr.write(`bb-wait: initial wait evaluation failed (${err.errorCode}) — ${err.detail}\n`);
2352
2436
  await emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.detail) });
@@ -2361,9 +2445,19 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2361
2445
  // BOT-1338/1608: every non-timer wait is a relay-tracked, session-token wait.
2362
2446
  // If registration did not succeed, neither the server nor this client has
2363
2447
  // verified the session's tenant, so there is no safe untracked fallback.
2364
- process.stderr.write(`bb-wait: wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
2448
+ // BOT-2855: name the relay's typed code (or register_failed when it sent none)
2449
+ // and its status, so the caller can act on it instead of a bare status line.
2450
+ const errorCode = (err && err.errorCode) || "register_failed";
2451
+ process.stderr.write(`bb-wait: wait-session registration failed (${errorCode}) — failing closed, not arming untracked: ${err && err.message || err}\n`);
2365
2452
  await emitReceipt(withPrincipalReceipt(
2366
- { schema_version: 1, outcome: "error", error: "register_failed", detail: String(err && err.message || err) },
2453
+ {
2454
+ schema_version: 1,
2455
+ outcome: "error",
2456
+ error: "register_failed",
2457
+ error_code: errorCode,
2458
+ detail: String(err && err.message || err),
2459
+ ...(err && Number.isInteger(err.httpStatus) ? { http_status: err.httpStatus } : {}),
2460
+ },
2367
2461
  null,
2368
2462
  { sessionTenant: sessionTenant ?? null, agentId: null },
2369
2463
  ));