@botbuddy/cli 1.63.8 → 1.63.12

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.8",
3
- "source_commit": "f26ae5e2838d2cd2bb8125f238e48ea9ae87c74c",
4
- "source_sha256": "4e4cf641d3c4675efbf172dfa759d31c016d30700ca195f7b3cbc399459ede98",
2
+ "release_version": "1.63.12",
3
+ "source_commit": "0999216ee4f46a7800e4bcfa901de357d73bfed2",
4
+ "source_sha256": "02eb455df56f57ca100c1e063642427bc1db6876665d72d9ef582b1627d22df1",
5
5
  "target": "darwin-arm64",
6
- "sha256": "cba79a67f58f0bde2d0f20b5c92c2734b468b0ca038ee83d020e325ab06c79fe"
6
+ "sha256": "bc6fb7a1e6f830a8eb7f3cbd9a593edeff9dcc404db6529546fa4ced95b3b86d"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.8",
3
- "source_commit": "f26ae5e2838d2cd2bb8125f238e48ea9ae87c74c",
4
- "source_sha256": "4e4cf641d3c4675efbf172dfa759d31c016d30700ca195f7b3cbc399459ede98",
2
+ "release_version": "1.63.12",
3
+ "source_commit": "0999216ee4f46a7800e4bcfa901de357d73bfed2",
4
+ "source_sha256": "02eb455df56f57ca100c1e063642427bc1db6876665d72d9ef582b1627d22df1",
5
5
  "target": "linux-x64",
6
- "sha256": "bf0458ea4d5a2aa36d5de5b374283a3f21c677f42a9afe5f3dcaf1a74807809a"
6
+ "sha256": "281a33d005250533d777aebac92417485907118a67f841f708e96e6b759d45b3"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.63.8",
4
- "source_commit": "f26ae5e2838d2cd2bb8125f238e48ea9ae87c74c",
5
- "source_sha256": "4e4cf641d3c4675efbf172dfa759d31c016d30700ca195f7b3cbc399459ede98",
3
+ "release_version": "1.63.12",
4
+ "source_commit": "0999216ee4f46a7800e4bcfa901de357d73bfed2",
5
+ "source_sha256": "02eb455df56f57ca100c1e063642427bc1db6876665d72d9ef582b1627d22df1",
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": "cba79a67f58f0bde2d0f20b5c92c2734b468b0ca038ee83d020e325ab06c79fe",
11
+ "sha256": "bc6fb7a1e6f830a8eb7f3cbd9a593edeff9dcc404db6529546fa4ced95b3b86d",
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": "bf0458ea4d5a2aa36d5de5b374283a3f21c677f42a9afe5f3dcaf1a74807809a",
18
+ "sha256": "281a33d005250533d777aebac92417485907118a67f841f708e96e6b759d45b3",
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.8",
3
+ "version": "1.63.12",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -413,22 +413,24 @@ export function containerHostRegisterEndpoint(serverUrl = SERVER_URL) {
413
413
  * Programmatic client for the source-backed `POST mcp-server/container-hosts/register`
414
414
  * contract. It never adds a tenant pin and never manufactures an Edge user JWT.
415
415
  */
416
- export async function registerContainerHost(body, {
416
+ export function registerContainerHost(body, options = {}) { return postContainerHostControl(body, options, "register", "container_host_register"); }
417
+ export function daemonHandoverRequest(body, options = {}) { return postContainerHostControl(body, options, "handover", "daemon_handover"); }
418
+ async function postContainerHostControl(body, {
417
419
  fetchImpl = fetch,
418
420
  auth: pinnedAuth = null,
419
421
  resolveAuth = resolveCallAuth,
420
422
  serverUrl = SERVER_URL,
421
423
  signal,
422
- } = {}) {
424
+ } = {}, route, toolName) {
423
425
  if (!body || typeof body !== "object" || Array.isArray(body) || typeof fetchImpl !== "function") {
424
426
  return { ok: false, error: "invalid_request" };
425
427
  }
426
428
  let endpoint;
427
- try { endpoint = containerHostRegisterEndpoint(serverUrl); }
429
+ try { endpoint = containerHostRegisterEndpoint(serverUrl).replace(/\/register$/, `/${route}`); }
428
430
  catch { return { ok: false, error: "endpoint_invalid" }; }
429
431
  let auth = pinnedAuth;
430
432
  if (!auth) {
431
- const resolved = await resolveAuth({ toolName: "container_host_register", preferSession: false });
433
+ const resolved = await resolveAuth({ toolName, preferSession: false });
432
434
  if (resolved?.error || !resolved?.auth) return { ok: false, auth: true, error: resolved?.error?.error ?? "not_authenticated" };
433
435
  auth = resolved.auth;
434
436
  }
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { registerContainerHost } from "../api.mjs";
8
+ import { upgradeDaemonHost } from "./handover.mjs";
8
9
  import { awaitHostApproval } from "./approval-wait.mjs";
9
10
  import { bootstrapFreshDaemon } from "./bootstrap.mjs";
10
11
  import { createBootstrapResumeStore } from "./bootstrap-resume.mjs";
@@ -48,6 +49,7 @@ export const daemonHelp = `Usage:
48
49
  bb daemon config-render '<json host policy>'
49
50
  bb daemon enroll '<json host-bound enrollment spec>'
50
51
  bb daemon bootstrap '<json fresh-host bootstrap spec>'
52
+ bb daemon upgrade '<json controlled host upgrade spec>'
51
53
 
52
54
  The Go payload is verified from the extracted universal package before sudo.
53
55
  Install runs the Go runtime's --check-config receipt before loading a native,
@@ -290,6 +292,7 @@ export async function cmdDaemon(args, {
290
292
  verifyRemoteIdentity = verifyRemoteServiceIdentity,
291
293
  verifyRemoteHelperIdentity = verifyRemoteHelperIdentityOnTarget,
292
294
  bootstrap = bootstrapFreshDaemon,
295
+ upgrade = upgradeDaemonHost,
293
296
  registerHost = registerContainerHost,
294
297
  createBootstrapIdentity = createRemoteBootstrapIdentity,
295
298
  inspectBootstrapIdentity = inspectRemoteBootstrapIdentity,
@@ -302,6 +305,11 @@ export async function cmdDaemon(args, {
302
305
  log(daemonHelp);
303
306
  return 0;
304
307
  }
308
+ if(args[0]==="upgrade"){
309
+ if(args.length!==2)fail("Usage: bb daemon upgrade '<json controlled host upgrade spec>'");
310
+ let spec;try{spec=JSON.parse(args[1]);}catch{fail("daemon_handover_spec_invalid");}
311
+ log(JSON.stringify(await upgrade(spec)));return 0;
312
+ }
305
313
  if (args[0] === "config-render") {
306
314
  if (args.length !== 2) {
307
315
  fail("Usage: bb daemon config-render '<json host policy>'");
@@ -0,0 +1,220 @@
1
+ // BOT-2057: the requester coordinates an authenticated immutable plan. The
2
+ // target receives only the verified Go artifact and bounded native commands;
3
+ // credentials remain in the target's existing nonroot private profile.
4
+ import { spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
6
+ import {
7
+ callToolJson,
8
+ daemonHandoverRequest,
9
+ resolveCallAuth,
10
+ } from "../api.mjs";
11
+ import {
12
+ assertSshHost,
13
+ installedBinaryPath,
14
+ stageRemoteDaemonRelease,
15
+ } from "./installer.mjs";
16
+ import { validateDaemonRelease } from "./release.mjs";
17
+
18
+ const fail = (code) => {
19
+ throw new Error(code);
20
+ };
21
+ const hostRE = /^bbh-[0-9a-f]{24}$/;
22
+ const immutableBinaryRE =
23
+ /^\/opt\/botbuddy\/daemon\/releases\/[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?\/(?:linux-x64|darwin-arm64)\/botbuddy-daemon$/;
24
+
25
+ export function nativeHandoverCommand(
26
+ { host, binary, mode, input },
27
+ { spawnImpl = spawn } = {},
28
+ ) {
29
+ if (
30
+ !immutableBinaryRE.test(binary) || !["inspect", "submit"].includes(mode)
31
+ ) fail("daemon_handover_command_invalid");
32
+ const flag = mode === "inspect"
33
+ ? "--native-handover-inspect"
34
+ : "--native-handover-submit";
35
+ let command = "sudo", args = ["-n", binary, flag];
36
+ if (host) {
37
+ assertSshHost(host);
38
+ command = "ssh";
39
+ args = [
40
+ "-o",
41
+ "BatchMode=yes",
42
+ "-o",
43
+ "ForwardAgent=no",
44
+ "-o",
45
+ "StrictHostKeyChecking=yes",
46
+ host,
47
+ "sudo",
48
+ ...args,
49
+ ];
50
+ }
51
+ // Every remote argv has a strict non-shell alphabet. JSON travels only on
52
+ // stdin; OpenSSH cannot shell-join an operator's plan or credential into code.
53
+ return new Promise((resolve, reject) => {
54
+ const child = spawnImpl(command, args, { stdio: ["pipe", "pipe", "pipe"] });
55
+ let stdout = "", bounded = true;
56
+ const timer = setTimeout(() => {
57
+ child.kill("SIGTERM");
58
+ reject(new Error("daemon_handover_command_timeout"));
59
+ }, 60000);
60
+ child.stdout.on("data", (chunk) => {
61
+ stdout += String(chunk);
62
+ if (stdout.length > 65536) {
63
+ bounded = false;
64
+ child.kill("SIGTERM");
65
+ }
66
+ });
67
+ child.stderr.resume();
68
+ child.stdin.on("error", () => {});
69
+ child.on("error", () => {
70
+ clearTimeout(timer);
71
+ reject(new Error("daemon_handover_command_failed"));
72
+ });
73
+ child.on("close", (code) => {
74
+ clearTimeout(timer);
75
+ if (code !== 0 || !bounded) {
76
+ return reject(new Error("daemon_handover_command_failed"));
77
+ }
78
+ try {
79
+ resolve(JSON.parse(stdout));
80
+ } catch {
81
+ reject(new Error("daemon_handover_response_invalid"));
82
+ }
83
+ });
84
+ child.stdin.end(input ? JSON.stringify(input) : "");
85
+ });
86
+ }
87
+
88
+ export async function upgradeDaemonHost(spec, {
89
+ validateRelease = validateDaemonRelease,
90
+ stage = stageRemoteDaemonRelease,
91
+ resolveAuth = resolveCallAuth,
92
+ identity = callToolJson,
93
+ transition = daemonHandoverRequest,
94
+ native = nativeHandoverCommand,
95
+ uuid = randomUUID,
96
+ } = {}) {
97
+ if (
98
+ !spec || typeof spec !== "object" || Array.isArray(spec) ||
99
+ Object.keys(spec).some((key) =>
100
+ ![
101
+ "platform",
102
+ "arch",
103
+ "sshHost",
104
+ "serviceUser",
105
+ "releaseRoot",
106
+ "expectedHostKey",
107
+ "tenant",
108
+ "resume",
109
+ ].includes(key)
110
+ ) || !["linux", "darwin"].includes(spec.platform) ||
111
+ typeof spec.serviceUser !== "string" || spec.serviceUser === "root" ||
112
+ !/^[a-z_][a-z0-9_-]*$/i.test(spec.serviceUser) ||
113
+ !hostRE.test(spec.expectedHostKey) || typeof spec.tenant !== "string" ||
114
+ !spec.tenant
115
+ ) fail("daemon_handover_spec_invalid");
116
+ if (typeof spec.sshHost !== "string") {
117
+ fail("daemon_handover_ssh_host_required");
118
+ }
119
+ assertSshHost(spec.sshHost);
120
+ const release = await validateRelease({ releaseRoot: spec.releaseRoot }, {
121
+ platform: spec.platform,
122
+ arch: spec.arch,
123
+ });
124
+ const resolved = await resolveAuth({
125
+ toolName: "daemon_handover",
126
+ preferSession: false,
127
+ });
128
+ if (resolved?.error || !resolved?.auth) fail("daemon_handover_auth_required");
129
+ // Pin one authenticated principal across inspection, plan creation and begin.
130
+ const auth = resolved.auth;
131
+ const who = await identity("whoami", {}, { auth, tenant: spec.tenant });
132
+ if (
133
+ !who?.ok || who?.isError || typeof who.data?.owner_email !== "string" ||
134
+ !who.data.owner_email.includes("@") || who.data?.tenant_id !== spec.tenant
135
+ ) fail("daemon_handover_owner_identity_unavailable");
136
+ const staged = await stage({
137
+ platform: spec.platform,
138
+ host: spec.sshHost,
139
+ user: spec.serviceUser,
140
+ release,
141
+ });
142
+ const binary = installedBinaryPath(release);
143
+ if (staged?.binary !== binary) fail("daemon_handover_stage_invalid");
144
+ let plan;
145
+ if (spec.resume) {
146
+ if (spec.resume === true) {
147
+ const saved = await transition({
148
+ action: "lookup",
149
+ host_key: spec.expectedHostKey,
150
+ }, { auth });
151
+ if (!saved?.ok || saved.data?.success !== true || !saved.data.plan) {
152
+ fail("daemon_handover_resume_unavailable");
153
+ }
154
+ plan = saved.data.plan;
155
+ } else plan = spec.resume;
156
+ if (
157
+ plan.host_key !== spec.expectedHostKey ||
158
+ plan.owner_email !== who.data.owner_email ||
159
+ plan.tenant_id !== who.data.tenant_id ||
160
+ plan.candidate?.artifact_sha256 !== `sha256:${release.sha256}` ||
161
+ plan.candidate?.version !== release.releaseVersion ||
162
+ plan.candidate?.source_commit !== release.sourceCommit ||
163
+ plan.candidate?.target !== release.target
164
+ ) fail("daemon_handover_resume_mismatch");
165
+ } else {
166
+ const observed = await native({
167
+ host: spec.sshHost,
168
+ binary,
169
+ mode: "inspect",
170
+ });
171
+ if (
172
+ observed?.host_key !== spec.expectedHostKey ||
173
+ observed?.service_user !== spec.serviceUser ||
174
+ !Number.isSafeInteger(observed.service_uid) || observed.service_uid <= 0
175
+ ) fail("daemon_handover_host_mismatch");
176
+ plan = {
177
+ version: 1,
178
+ rollout_id: uuid(),
179
+ host_key: observed.host_key,
180
+ owner_email: who.data.owner_email,
181
+ tenant_id: who.data.tenant_id,
182
+ machine_id: observed.machine_id,
183
+ engine_id: observed.engine_id,
184
+ service_uid: observed.service_uid,
185
+ config_sha256: observed.config_sha256,
186
+ previous_instance_id: observed.previous_instance_id,
187
+ previous: observed.previous,
188
+ candidate: {
189
+ version: release.releaseVersion,
190
+ source_commit: release.sourceCommit,
191
+ artifact_sha256: `sha256:${release.sha256}`,
192
+ target: release.target,
193
+ },
194
+ };
195
+ }
196
+ const begun = await transition({ action: "begin", plan }, { auth });
197
+ if (!begun?.ok || begun.data?.success !== true || !begun.data.authority_key) {
198
+ fail("daemon_handover_begin_refused");
199
+ }
200
+ if (begun.data.result) {
201
+ return {
202
+ outcome: "completed",
203
+ rollout_id: plan.rollout_id,
204
+ result: begun.data.result,
205
+ };
206
+ }
207
+ const queued = await native({
208
+ host: spec.sshHost,
209
+ binary,
210
+ mode: "submit",
211
+ input: { plan, authority_key: begun.data.authority_key },
212
+ });
213
+ if (queued?.queued !== true) fail("daemon_handover_submission_refused");
214
+ return {
215
+ outcome: "queued",
216
+ host_key: plan.host_key,
217
+ rollout_id: plan.rollout_id,
218
+ plan,
219
+ };
220
+ }
@@ -1273,7 +1273,7 @@ function mutateBoundClaims(stackDir, registryDir, mutate) {
1273
1273
  * This never deletes a container, volume, stack directory or another invocation's claim. */
1274
1274
  export function recoverLeasedStackClaim(stackDir, leaseId, proof, { registryDir = defaultPortRegistryDir() } = {}) {
1275
1275
  if (proof?.lease_id !== leaseId || proof.state !== "reaped" || proof.cleanup_verified !== true ||
1276
- !["signed_reap", "never_provisioned", "local_exec_teardown", "pre_driver_absence"].includes(proof.cleanup_proof) ||
1276
+ !["signed_reap", "never_provisioned", "local_exec_teardown", "pre_driver_absence", "no_execution"].includes(proof.cleanup_proof) ||
1277
1277
  (proof.cleanup_proof === "signed_reap" && !proof.cleanup_receipt_id)) {
1278
1278
  return { released: false, reason: "accepted cleanup proof is unavailable" };
1279
1279
  }
package/src/wait-core.mjs CHANGED
@@ -36,6 +36,9 @@ export const EXIT = Object.freeze({
36
36
  // refuses the stream with a typed `wait_abandoned` error frame; re-arm from next_cursor.
37
37
  ABANDONED: 11,
38
38
  LEASE_CLEANUP_FAILED: 12,
39
+ // BOT-2783: a Codex review was still not complete at its intervention deadline. The
40
+ // receipt's `error` carries the provenance (limit, its source, window, reason).
41
+ REVIEW_TIMEOUT: 13,
39
42
  });
40
43
 
41
44
  // BOT-2013: the uuid spellings Postgres accepts for a lease id (uuid_in), mirrored by the SQL
@@ -823,6 +826,44 @@ export function buildReceipt({
823
826
  return r;
824
827
  }
825
828
 
829
+ // BOT-2783: the policy sources a review guard records, as an agent reads them.
830
+ const REVIEW_TIMEOUT_SOURCE_LABEL = Object.freeze({ call: "this wait", repository: "repository", tenant: "tenant default" });
831
+
832
+ /**
833
+ * BOT-2783: the typed REVIEW_TIMEOUT error for a pr-review frame that is a review
834
+ * guard's deadline diagnostic finding no review (`review_stalled` WITH a guard_id).
835
+ * Null for anything else: a recovered review or clean pass at the deadline is a real
836
+ * review (exit 0), and a legacy cron stall notice (no guard) keeps its old behaviour.
837
+ */
838
+ export function reviewTimeoutDetail(signal, condition) {
839
+ const p = signal?.payload;
840
+ if (condition?.type !== "pr-review" || signal?.signal_type !== "pr_review") return null;
841
+ if (!p || p.transition !== "review_stalled" || typeof p.guard_id !== "string") return null;
842
+ const text = (v) => (typeof v === "string" && v.length > 0 ? v.slice(0, 512) : null);
843
+ return {
844
+ code: "REVIEW_TIMEOUT",
845
+ minutes: Number.isInteger(p.threshold_minutes) ? p.threshold_minutes : null,
846
+ source: REVIEW_TIMEOUT_SOURCE_LABEL[p.policy_source] ? p.policy_source : null,
847
+ started_at: text(p.started_at),
848
+ deadline: text(p.deadline),
849
+ reason: text(p.reason),
850
+ next_action: text(p.next_action),
851
+ guard_id: p.guard_id,
852
+ repo: text(p.repo),
853
+ pr_number: Number.isInteger(p.pr_number) ? p.pr_number : null,
854
+ commit_sha: text(p.commit_sha),
855
+ };
856
+ }
857
+
858
+ /** BOT-2783: the one stderr line for a review_timeout receipt (null for any other outcome). */
859
+ export function reviewTimeoutNotice(receipt) {
860
+ if (receipt?.outcome !== "review_timeout" || !receipt.error || typeof receipt.error !== "object") return null;
861
+ const e = receipt.error;
862
+ const source = REVIEW_TIMEOUT_SOURCE_LABEL[e.source] ?? "unknown source";
863
+ const sha = typeof e.commit_sha === "string" ? e.commit_sha.slice(0, 7) : "?";
864
+ return `bb-wait: review timeout after ${e.minutes ?? "?"} min (${source}) on ${e.repo ?? "?"}#${e.pr_number ?? "?"}@${sha}: ${e.reason ?? "unknown"}`;
865
+ }
866
+
826
867
  /** BOT-2013: the three lease-lost fields, each bounded, or null when absent. */
827
868
  function compactLeaseLostState(state) {
828
869
  if (!state || typeof state !== "object") return null;
@@ -833,7 +874,7 @@ function compactLeaseLostState(state) {
833
874
  }
834
875
 
835
876
  function compactCleanupState(state) {
836
- if (!state || !["signed_reap", "never_provisioned", "local_exec_teardown", "pre_driver_absence"].includes(state.cleanup_proof)) return null;
877
+ if (!state || !["signed_reap", "never_provisioned", "local_exec_teardown", "pre_driver_absence", "no_execution"].includes(state.cleanup_proof)) return null;
837
878
  const short = (v) => typeof v === "string" ? v.slice(0, 64) : null;
838
879
  return Object.fromEntries(["lease_id", "lease_state", "reap_disposition", "cleanup_proof", "cleanup_receipt_id"]
839
880
  .filter((key) => state[key] != null).map((key) => [key, short(state[key])]));
@@ -938,6 +979,34 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
938
979
  }
939
980
  return base;
940
981
  }
982
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "review_timeout" &&
983
+ clone.error && typeof clone.error === "object") {
984
+ // BOT-2783: keep the actionable provenance (limit, its source, why, which
985
+ // guard/head) and add the window, repo and resume pointers only while they fit.
986
+ const e = clone.error;
987
+ const base = {
988
+ schema_version: clone.schema_version,
989
+ outcome: "review_timeout",
990
+ error: {
991
+ code: "REVIEW_TIMEOUT", minutes: e.minutes ?? null, source: e.source ?? null,
992
+ reason: typeof e.reason === "string" ? e.reason.slice(0, 64) : null,
993
+ guard_id: e.guard_id ?? null, pr_number: e.pr_number ?? null,
994
+ commit_sha: typeof e.commit_sha === "string" ? e.commit_sha.slice(0, 7) : null,
995
+ },
996
+ truncated: true,
997
+ };
998
+ for (const [key, value] of [["deadline", e.deadline], ["repo", e.repo], ["started_at", e.started_at]]) {
999
+ if (value == null) continue;
1000
+ const candidate = { ...base, error: { ...base.error, [key]: value } };
1001
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= maxBytes) base.error[key] = value;
1002
+ }
1003
+ for (const key of ["next_cursor", "wait_session_id"]) {
1004
+ if (clone[key] == null) continue;
1005
+ const candidate = { ...base, [key]: clone[key] };
1006
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= maxBytes) base[key] = clone[key];
1007
+ }
1008
+ return base;
1009
+ }
941
1010
  if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "error") {
942
1011
  const base = {
943
1012
  schema_version: clone.schema_version,
@@ -1657,6 +1726,10 @@ export async function runWaitLoop({
1657
1726
  if (allocationFailure) {
1658
1727
  return finalize("error", { exitCode: EXIT.MANAGED_ALLOCATION_FAILED, matched: [entry], state: allocationFailure, error: allocationFailure.cleanup_error });
1659
1728
  }
1729
+ const timeout = reviewTimeoutDetail(signal, condition);
1730
+ if (timeout) {
1731
+ return finalize("review_timeout", { exitCode: EXIT.REVIEW_TIMEOUT, matched: [entry], error: timeout });
1732
+ }
1660
1733
  return finalize("matched", { exitCode: EXIT.MATCHED, matched: [entry] });
1661
1734
  };
1662
1735
 
@@ -11,7 +11,8 @@ const MAX_ARTIFACT_BYTES = 256 * 1024;
11
11
  const MAX_RECEIPT_BYTES = 64 * 1024;
12
12
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
13
  const COMMAND_STATUSES = new Set(["success", "failure", "cancelled", "timed_out", "owner_lost"]);
14
- const TERMINAL_OUTCOMES = new Set(["matched", "timeout", "error", "superseded", "lease_lost", "abandoned", "cancelled"]);
14
+ // BOT-2783: review_timeout is terminal (exit 13, typed REVIEW_TIMEOUT provenance).
15
+ const TERMINAL_OUTCOMES = new Set(["matched", "timeout", "error", "superseded", "lease_lost", "abandoned", "cancelled", "review_timeout"]);
15
16
 
16
17
  // Persisted != delivered. Await the stream callback before exiting, including
17
18
  // when a bounded receipt is larger than the pipe's immediately writable space.
package/src/wait.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // Usage: botbuddy wait [--any] <condition>... [options]
14
14
  // Run botbuddy wait --help for the condition grammar.
15
15
 
16
- import { EXIT, describeConditionErrors, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance } from "./wait-core.mjs";
16
+ import { EXIT, describeConditionErrors, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance, reviewTimeoutNotice } from "./wait-core.mjs";
17
17
  import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
@@ -111,9 +111,11 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
111
111
  count), a Codex clean pass (payload.transition=clean_pass),
112
112
  the Codex usage-limit decline (transition=
113
113
  review_unavailable — no review is coming) or a Codex
114
- review needing intervention after one backend diagnostic
115
- (transition=review_stalled, review_available=false,
116
- reason + next_action). Initial default: 10 min.
114
+ review still incomplete at its deadline after one backend
115
+ diagnostic: exit 13 REVIEW_TIMEOUT, receipt outcome
116
+ review_timeout, error = {minutes, source (call|repository|
117
+ tenant), started_at, deadline, reason, next_action,
118
+ guard_id}. Default: 15 min (Settings → Waits shows p50/p95).
117
119
  Edge-triggered: it wakes only on review activity AFTER
118
120
  arm, so arm BEFORE requesting the review. If the PR ALREADY
119
121
  has an outstanding review at arm, bb-wait prints a one-line
@@ -222,7 +224,8 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
222
224
 
223
225
  OPTIONS
224
226
  Review intervention: exact-head Codex waits have a durable one-shot diagnostic.
225
- stall_minutes= overrides repository → tenant settings (initial default 10 min).
227
+ stall_minutes= overrides repository → tenant settings (default 15 min). At the
228
+ deadline with no review: exit 13 REVIEW_TIMEOUT (typed error + provenance).
226
229
  A missing/unbound backend guard is refused as review_guard_unsupported.
227
230
  It needs pr= + sha=. A result wakes immediately; a deadline never means approval.
228
231
  Reconnects/re-arms retain the deadline. This is separate from --timeout below.
@@ -280,7 +283,10 @@ OUTPUT
280
283
  was parked — reaped after 180 s without a live stream,
281
284
  or cancelled; receipt outcome abandoned +
282
285
  state.status/reason — re-arm with --since <next_cursor>)
283
- · 12 named lease cleanup failed.
286
+ · 12 named lease cleanup failed · 13 review timeout
287
+ (REVIEW_TIMEOUT: a Codex review still incomplete at its
288
+ deadline; receipt error carries minutes/source/reason —
289
+ use the fallback reviewer, never infer approval).
284
290
  If the receipt has
285
291
  reacquire_lock (claim=true), acquire_lock again FIRST and re-run
286
292
  only on a queued/busy result (granted = you hold it — just resume);
@@ -751,7 +757,11 @@ async function registerWait(opts, conditions, deadlineIso) {
751
757
  // BOT-2013: wait_session_status has no lease_lost value. The session ends as a terminal
752
758
  // `error` and its stored receipt carries the precise outcome.
753
759
  export function cloudStatusForOutcome(outcome) {
754
- return outcome === "lease_lost" ? "error" : outcome;
760
+ if (outcome === "lease_lost") return "error";
761
+ // BOT-2783: no review_timeout status either — the session ends `timeout` and the
762
+ // stored receipt (and the guard) carry the typed REVIEW_TIMEOUT provenance.
763
+ if (outcome === "review_timeout") return "timeout";
764
+ return outcome;
755
765
  }
756
766
 
757
767
  /** BOT-2013: the one stderr line for a lease_lost receipt (null for any other outcome). */
@@ -776,7 +786,7 @@ export function waitAbandonedNotice(receipt) {
776
786
  }
777
787
 
778
788
  function writeLeaseLostNotice(receipt) {
779
- const notice = leaseLostNotice(receipt) ?? waitAbandonedNotice(receipt);
789
+ const notice = leaseLostNotice(receipt) ?? waitAbandonedNotice(receipt) ?? reviewTimeoutNotice(receipt);
780
790
  if (notice) process.stderr.write(`${notice}\n`);
781
791
  }
782
792
 
@@ -1547,8 +1557,9 @@ async function emitSavedWaitError(error) {
1547
1557
  process.exit(exitCode);
1548
1558
  }
1549
1559
 
1550
- function exitForRecoveredOutcome(receipt) {
1560
+ export function recoveredExitCode(receipt) {
1551
1561
  if (receipt?.outcome === "matched") return EXIT.MATCHED;
1562
+ if (receipt?.outcome === "review_timeout") return EXIT.REVIEW_TIMEOUT;
1552
1563
  if (receipt?.outcome === "lease_lost") return EXIT.LEASE_LOST;
1553
1564
  if (receipt?.outcome === "cancelled") return EXIT.MATCHED;
1554
1565
  if (receipt?.outcome === "timeout") return EXIT.TIMEOUT;
@@ -1610,7 +1621,7 @@ async function cancelSavedWait(id) {
1610
1621
  current = await store.update(id, {
1611
1622
  status: "cancelled",
1612
1623
  terminal_receipt: recovered,
1613
- exit_code: exitForRecoveredOutcome(recovered),
1624
+ exit_code: recoveredExitCode(recovered),
1614
1625
  cancelled_at: new Date().toISOString(),
1615
1626
  cloud_cancel_pending: false,
1616
1627
  cloud_cancelled_at: new Date().toISOString(),
@@ -1621,7 +1632,7 @@ async function cancelSavedWait(id) {
1621
1632
  await store.update(id, {
1622
1633
  status: "terminal",
1623
1634
  terminal_receipt: recovered,
1624
- exit_code: exitForRecoveredOutcome(recovered),
1635
+ exit_code: recoveredExitCode(recovered),
1625
1636
  });
1626
1637
  const conflict = new WaitCheckpointError(
1627
1638
  "wait_not_cancellable",
@@ -2107,7 +2118,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2107
2118
  const recoveredCancellation = recovered.outcome === "cancelled";
2108
2119
  await store.update(checkpoint.local_wait_id, {
2109
2120
  status: recoveredCancellation ? "cancelled" : "terminal", terminal_receipt: recovered,
2110
- exit_code: exitForRecoveredOutcome(recovered), cloud_wait_session_id: waitSessionId,
2121
+ exit_code: recoveredExitCode(recovered), cloud_wait_session_id: waitSessionId,
2111
2122
  cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null,
2112
2123
  ...(recoveredCancellation ? {
2113
2124
  cancelled_at: new Date().toISOString(),
@@ -2117,7 +2128,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2117
2128
  } : {}),
2118
2129
  });
2119
2130
  await emitReceipt(recovered, { versioned: true });
2120
- process.exit(exitForRecoveredOutcome(recovered));
2131
+ process.exit(recoveredExitCode(recovered));
2121
2132
  }
2122
2133
  // BOT-2468: the wait's dashboard deeplink, once, at arm time (and again on
2123
2134
  // `bb wait resume`, which re-registers). An agent that armed this in the