@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.
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/api.mjs +6 -4
- package/src/daemon/commands.mjs +8 -0
- package/src/daemon/handover.mjs +220 -0
- package/src/stack-isolation.mjs +1 -1
- package/src/wait-core.mjs +74 -1
- package/src/wait-result.mjs +2 -1
- package/src/wait.mjs +24 -13
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.63.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.12",
|
|
3
|
+
"source_commit": "0999216ee4f46a7800e4bcfa901de357d73bfed2",
|
|
4
|
+
"source_sha256": "02eb455df56f57ca100c1e063642427bc1db6876665d72d9ef582b1627d22df1",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "bc6fb7a1e6f830a8eb7f3cbd9a593edeff9dcc404db6529546fa4ced95b3b86d"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.63.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.12",
|
|
3
|
+
"source_commit": "0999216ee4f46a7800e4bcfa901de357d73bfed2",
|
|
4
|
+
"source_sha256": "02eb455df56f57ca100c1e063642427bc1db6876665d72d9ef582b1627d22df1",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "281a33d005250533d777aebac92417485907118a67f841f708e96e6b759d45b3"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.63.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
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": "
|
|
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": "
|
|
18
|
+
"sha256": "281a33d005250533d777aebac92417485907118a67f841f708e96e6b759d45b3",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
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
|
|
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
|
|
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
|
}
|
package/src/daemon/commands.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/stack-isolation.mjs
CHANGED
|
@@ -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
|
|
package/src/wait-result.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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
|