@botbuddy/cli 1.63.4 → 1.63.6
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/bin/botbuddy.mjs +10 -2
- 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/run.mjs +33 -1
- package/src/stack.mjs +38 -0
- package/src/wait-core.mjs +20 -5
- package/src/wait.mjs +39 -4
package/bin/botbuddy.mjs
CHANGED
|
@@ -4,11 +4,19 @@ import { ensureRunDelivery } from "../src/run-delivery.mjs";
|
|
|
4
4
|
|
|
5
5
|
const argv = process.argv.slice(2);
|
|
6
6
|
|
|
7
|
-
// BOT-2600:
|
|
7
|
+
// BOT-2600: normal `bb` invocations start the single per-machine delivery process
|
|
8
8
|
// when finished `bb run` commands still have an unsent terminal update. A stat
|
|
9
9
|
// and two small reads when nothing changed; it never fails the command.
|
|
10
10
|
const deliveryProcessArgs = argv[0] === "run" && (argv[1] === "--worker" || argv[1] === "--deliver");
|
|
11
|
-
|
|
11
|
+
// BOT-2766: help and inspection must remain read-only through the launcher,
|
|
12
|
+
// not just inside their handlers. Child help after -- still belongs to work.
|
|
13
|
+
const separator = argv.indexOf("--");
|
|
14
|
+
const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
15
|
+
const readOnly = flags.some((flag) => flag === "--help" || flag === "-h")
|
|
16
|
+
|| argv[0] === "help"
|
|
17
|
+
|| (argv[0] === "stack" && argv[1] === "help")
|
|
18
|
+
|| (argv[0] === "run" && (argv[1] === "inspect" || argv[1] === "help"));
|
|
19
|
+
const delivery = deliveryProcessArgs || readOnly ? Promise.resolve() : ensureRunDelivery().catch(() => {});
|
|
12
20
|
|
|
13
21
|
delivery.then(() => run(argv)).then((code) => {
|
|
14
22
|
if (typeof code === "number") process.exitCode = code;
|
|
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.6",
|
|
3
|
+
"source_commit": "5c72f5b01f85d06bd437e50a6d3515bbf9871230",
|
|
4
|
+
"source_sha256": "f96b0407034852d7a2c255176a4dcce9297fe6aaec8311f4b25bb70ffbfa5fa8",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "f31b5ae401c2b404d0b321f2ed7864afbeadb45080069daf6fd5a4830b1c0546"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.63.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.6",
|
|
3
|
+
"source_commit": "5c72f5b01f85d06bd437e50a6d3515bbf9871230",
|
|
4
|
+
"source_sha256": "f96b0407034852d7a2c255176a4dcce9297fe6aaec8311f4b25bb70ffbfa5fa8",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "ca5a27989617f81c17b84ef2b160a845b7f65017aada19ec032853d826eaf713"
|
|
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.6",
|
|
4
|
+
"source_commit": "5c72f5b01f85d06bd437e50a6d3515bbf9871230",
|
|
5
|
+
"source_sha256": "f96b0407034852d7a2c255176a4dcce9297fe6aaec8311f4b25bb70ffbfa5fa8",
|
|
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": "f31b5ae401c2b404d0b321f2ed7864afbeadb45080069daf6fd5a4830b1c0546",
|
|
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": "ca5a27989617f81c17b84ef2b160a845b7f65017aada19ec032853d826eaf713",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/run.mjs
CHANGED
|
@@ -280,7 +280,7 @@ function terminalOutcomesAgree(receipt, remote) {
|
|
|
280
280
|
// Reconcile only evidence the current OS user and owning BotBuddy agent can
|
|
281
281
|
// read. This is intentionally advisory: an inspector must never terminate a
|
|
282
282
|
// worker, replay a callback, or make a retry safe merely by observing a file.
|
|
283
|
-
|
|
283
|
+
async function inspectRunEvidence(runId, {
|
|
284
284
|
call = callToolJson,
|
|
285
285
|
read = readFile,
|
|
286
286
|
now = Date.now(),
|
|
@@ -292,6 +292,9 @@ export async function inspectRun(runId, {
|
|
|
292
292
|
return { schema_version: RUN_SCHEMA_VERSION, classification: "rejected", rerun_safe: false, cancellation_safe: false, error: "run_id_required" };
|
|
293
293
|
}
|
|
294
294
|
const id = runId.trim();
|
|
295
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(id)) {
|
|
296
|
+
return { schema_version: RUN_SCHEMA_VERSION, classification: "rejected", rerun_safe: false, cancellation_safe: false, error: "run_id_invalid" };
|
|
297
|
+
}
|
|
295
298
|
const localReceiptPath = receiptFile(id);
|
|
296
299
|
const [receiptRaw, contextRaw] = await Promise.all([
|
|
297
300
|
read(localReceiptPath, "utf8").catch(() => null),
|
|
@@ -384,6 +387,35 @@ export async function inspectRun(runId, {
|
|
|
384
387
|
remote,
|
|
385
388
|
};
|
|
386
389
|
}
|
|
390
|
+
|
|
391
|
+
/** Add advice only from the inspector's public evidence; never from raw output. */
|
|
392
|
+
export async function inspectRun(runId, options = {}) {
|
|
393
|
+
const result = await inspectRunEvidence(runId, options);
|
|
394
|
+
const terminal = result.source_bound === true;
|
|
395
|
+
const running = result.classification === "running";
|
|
396
|
+
return {
|
|
397
|
+
...result,
|
|
398
|
+
recovery: {
|
|
399
|
+
schema_version: 1,
|
|
400
|
+
...(terminal ? result.recovery : {}),
|
|
401
|
+
action: terminal ? "read_local_receipt" : running ? "await_original_result" : "preserve_and_escalate",
|
|
402
|
+
started: terminal && result.local.status === "success" ? "yes" : "unknown",
|
|
403
|
+
workload: terminal && ["success", "failure"].includes(result.local.status) ? "exited" : "unknown",
|
|
404
|
+
retry: "unsafe",
|
|
405
|
+
receipt_available: terminal,
|
|
406
|
+
evidence: terminal ? "source_bound_terminal_receipt" : running ? "fresh_local_owner_observation" : "insufficient_or_conflicting_evidence",
|
|
407
|
+
// An original pending tool handle is not a shell command. Do not invent
|
|
408
|
+
// a waiter or encourage re-inspection as a polling loop.
|
|
409
|
+
recovery_argv: null,
|
|
410
|
+
do_not: ["rerun", "cancel", "poll"],
|
|
411
|
+
...(!terminal ? {
|
|
412
|
+
message: running
|
|
413
|
+
? "Keep the original command result pending. A live owner does not prove that its child started or stalled. Consume that owner's terminal receipt; do not rerun or cancel to repair telemetry."
|
|
414
|
+
: "Preserve the original command and identifiers. Evidence is unknown or conflicts; report the gap without rerunning, cancelling, or polling. No safe recovery command can be derived from this observation.",
|
|
415
|
+
} : {}),
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
387
419
|
function cancellationPath(contextFile) { return `${contextFile}.cancelled`; }
|
|
388
420
|
|
|
389
421
|
async function markCancellation(contextFile, runId) {
|
package/src/stack.mjs
CHANGED
|
@@ -105,6 +105,15 @@ export const EXIT = Object.freeze({
|
|
|
105
105
|
CLEANUP_FAILED: 8, // child completed but signed physical reap was not proven
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
const STACK_READINESS_HELP = `${bold("READINESS")}
|
|
109
|
+
An active lease proves infrastructure activation. It does not prove application readiness:
|
|
110
|
+
the child may still be initializing its schema, runtime configuration, or worktree locks.
|
|
111
|
+
Consume the application's acknowledged ready receipt before starting another consumer.
|
|
112
|
+
--callback-port provides callback transport; the application must configure its callback URLs.
|
|
113
|
+
A retained idle lease is still active; idle_since/cleanup.idle distinguish reuse from live work.
|
|
114
|
+
Preserve the original stack-run result. Do not rerun or release a live workload to recover output.
|
|
115
|
+
Use bb run inspect <run_id> for a workload launched by bb run; its run ID differs from a lease ID.`;
|
|
116
|
+
|
|
108
117
|
export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch-scoped local stack lease (BOT-1218)
|
|
109
118
|
|
|
110
119
|
${bold("USAGE")}
|
|
@@ -311,10 +320,33 @@ ${bold("HOST_CAPACITY_SOURCE_STALE")} (BOT-2024)
|
|
|
311
320
|
sustained (no beacon ever or >= 10 min) → report BLOCKED quoting \`stale_since\`; a Helper
|
|
312
321
|
check/restart is the right ask. A single refusal is never evidence of an outage.
|
|
313
322
|
|
|
323
|
+
${STACK_READINESS_HELP}
|
|
324
|
+
|
|
314
325
|
${bold("EXAMPLE")}
|
|
315
326
|
# request a per-worktree stack for this batch, run a lane against it, then reap it
|
|
316
327
|
botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
|
|
317
328
|
|
|
329
|
+
const STACK_COMMANDS = new Set(["up", "run", "status", "touch", "done", "cancel"]);
|
|
330
|
+
|
|
331
|
+
/** Scoped help is assembled from the canonical text, so flags cannot drift. */
|
|
332
|
+
export function stackCommandHelp(command) {
|
|
333
|
+
const headings = {
|
|
334
|
+
up: ["up OPTIONS", "GLOBAL", "EXIT CODES", "IDLE LEASES", "HOST_CAPACITY_SOURCE_STALE"],
|
|
335
|
+
run: ["run OPTIONS", "GLOBAL", "EXIT CODES", "IDLE LEASES", "REMOTE LEASES", "HOST_CAPACITY_SOURCE_STALE"],
|
|
336
|
+
done: ["done OPTIONS", "GLOBAL", "EXIT CODES", "IDLE LEASES"],
|
|
337
|
+
cancel: ["GLOBAL", "EXIT CODES"],
|
|
338
|
+
status: ["GLOBAL", "EXIT CODES"],
|
|
339
|
+
touch: ["GLOBAL", "EXIT CODES", "IDLE LEASES"],
|
|
340
|
+
};
|
|
341
|
+
// The OPTIONS headings intentionally use lower-case command names.
|
|
342
|
+
const all = STACK_HELP.split(/\n(?=\u001b\[1m)/);
|
|
343
|
+
const selected = all.filter((section) => headings[command]?.some((heading) => section.startsWith(bold(heading))));
|
|
344
|
+
const usage = command === "run" ? "[options] -- <executable> [args...]"
|
|
345
|
+
: command === "up" ? "[options]" : "<lease_id> [options]";
|
|
346
|
+
const detail = command === "cancel" ? "\n Cancel gives up a queued lease's place (equivalent to done --reap).\n" : "";
|
|
347
|
+
return `${bold(`botbuddy stack ${command}`)} ${usage}${detail}\n\n${selected.join("\n")}\n\n${STACK_READINESS_HELP}\n Use bb stack --help for all commands and options.`;
|
|
348
|
+
}
|
|
349
|
+
|
|
318
350
|
// ── pure helpers (unit-tested) ───────────────────────────────────────────────
|
|
319
351
|
|
|
320
352
|
/** Derive the event-stream base URL from the MCP server URL. */
|
|
@@ -4658,6 +4690,12 @@ export async function cmdStack(argv, { report = reportStackRunOutcome, outcomeCh
|
|
|
4658
4690
|
process.stdout.write(STACK_HELP + "\n");
|
|
4659
4691
|
return EXIT.OK;
|
|
4660
4692
|
}
|
|
4693
|
+
const divider = argv.indexOf("--");
|
|
4694
|
+
const flags = divider === -1 ? argv.slice(1) : argv.slice(1, divider);
|
|
4695
|
+
if (STACK_COMMANDS.has(argv[0]) && flags.some((flag) => flag === "--help" || flag === "-h")) {
|
|
4696
|
+
process.stdout.write(stackCommandHelp(argv[0]) + "\n");
|
|
4697
|
+
return EXIT.OK;
|
|
4698
|
+
}
|
|
4661
4699
|
const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
|
|
4662
4700
|
if (errors.length) {
|
|
4663
4701
|
for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
|
package/src/wait-core.mjs
CHANGED
|
@@ -501,7 +501,7 @@ function parsePrTarget(p, label) {
|
|
|
501
501
|
// refuses at arm (invalid_conditions, exit 4). `reviewer=` is pr-review only.
|
|
502
502
|
const PR_CONDITION_KEYS = Object.freeze({
|
|
503
503
|
"pr-state": Object.freeze(["repo", "pr", "number", "sha"]),
|
|
504
|
-
"pr-review": Object.freeze(["repo", "pr", "number", "sha", "reviewer"]),
|
|
504
|
+
"pr-review": Object.freeze(["repo", "pr", "number", "sha", "reviewer", "stall_minutes"]),
|
|
505
505
|
});
|
|
506
506
|
|
|
507
507
|
// A GitHub login compared case-insensitively, with the App suffix `[bot]` stripped
|
|
@@ -531,14 +531,24 @@ function parsePrCondition(p, label) {
|
|
|
531
531
|
if (reviewer === "") throw new Error(`${label} reviewer= must be a GitHub login`);
|
|
532
532
|
out.reviewer = reviewer;
|
|
533
533
|
}
|
|
534
|
+
if (p.stall_minutes !== undefined) {
|
|
535
|
+
const minutes = Number(p.stall_minutes);
|
|
536
|
+
if (!/^\d+$/.test(String(p.stall_minutes)) || !Number.isInteger(minutes) || minutes < 10 || minutes > 1440) {
|
|
537
|
+
throw new Error("pr-review stall_minutes= must be an integer from 10 to 1440");
|
|
538
|
+
}
|
|
539
|
+
if (out.number == null || !out.sha || (out.reviewer && out.reviewer !== "chatgpt-codex-connector")) {
|
|
540
|
+
throw new Error("pr-review stall_minutes= requires pr= and sha= for the Codex reviewer");
|
|
541
|
+
}
|
|
542
|
+
out.stall_minutes = minutes;
|
|
543
|
+
}
|
|
534
544
|
return out;
|
|
535
545
|
}
|
|
536
546
|
|
|
537
547
|
// Does a spine subject_key (`owner/repo#number`) satisfy a pr target?
|
|
538
548
|
function prSubjectMatches(subjectKey, params) {
|
|
539
549
|
if (typeof subjectKey !== "string") return false;
|
|
540
|
-
if (params.number != null) return subjectKey === `${params.repo}#${params.number}
|
|
541
|
-
return subjectKey.startsWith(`${params.repo}
|
|
550
|
+
if (params.number != null) return subjectKey.toLowerCase() === `${params.repo}#${params.number}`.toLowerCase();
|
|
551
|
+
return subjectKey.toLowerCase().startsWith(`${params.repo}#`.toLowerCase());
|
|
542
552
|
}
|
|
543
553
|
|
|
544
554
|
// Does a commit field on a signal payload identify the same commit as the
|
|
@@ -560,8 +570,13 @@ function prStateFiltersMatch(signal, params) {
|
|
|
560
570
|
// BOT-1975 / BOT-1972: optional commit + reviewer scoping for pr-review. A
|
|
561
571
|
// `review_unavailable` signal (the Codex usage-limit decline) names no commit, so
|
|
562
572
|
// it always passes the sha filter — the waiter must learn no review is coming.
|
|
563
|
-
function prReviewFiltersMatch(signal, params) {
|
|
573
|
+
function prReviewFiltersMatch(signal, params, guard) {
|
|
564
574
|
const payload = signal.payload ?? {};
|
|
575
|
+
if (payload.guard_id && guard && (payload.guard_id !== guard.id || !(Date.parse(payload.started_at) >= Date.parse(guard.started_at)))) return false;
|
|
576
|
+
// Scoped Codex waits use the durable diagnostic. Legacy cron stall notices
|
|
577
|
+
// are informational and cannot pre-empt a call-specific intervention policy.
|
|
578
|
+
if (payload.transition === "review_stalled" && params.sha && params.number &&
|
|
579
|
+
(!params.reviewer || params.reviewer === "chatgpt-codex-connector") && !payload.guard_id) return false;
|
|
565
580
|
if (params.sha != null && payload.transition !== "review_unavailable" &&
|
|
566
581
|
!shaPrefixMatches(payload.commit_sha, params.sha)) {
|
|
567
582
|
return false;
|
|
@@ -1123,7 +1138,7 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
1123
1138
|
prStateFiltersMatch(signal, params);
|
|
1124
1139
|
case "pr-review":
|
|
1125
1140
|
return signal.signal_type === "pr_review" && prSubjectMatches(signal.subject_key, params) &&
|
|
1126
|
-
prReviewFiltersMatch(signal, params);
|
|
1141
|
+
prReviewFiltersMatch(signal, params, condition.review_guard);
|
|
1127
1142
|
case "test-run":
|
|
1128
1143
|
return signal.signal_type === "test_run" && signal.subject_key === `run:${params.runId}`;
|
|
1129
1144
|
case "stack-run":
|
package/src/wait.mjs
CHANGED
|
@@ -105,12 +105,15 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
105
105
|
that head, so a previous head's check result can't wake you.
|
|
106
106
|
A push alone emits nothing — the head rides on the next
|
|
107
107
|
transition.
|
|
108
|
-
pr-review:repo=<owner/repo>[,pr=<n>][,reviewer=<login>][,sha=<commit>]
|
|
108
|
+
pr-review:repo=<owner/repo>[,pr=<n>][,reviewer=<login>][,sha=<commit>][,stall_minutes=<10-1440>]
|
|
109
109
|
a submitted review, a new review comment, a thread
|
|
110
110
|
resolved/re-opened (payload carries the unresolved-thread
|
|
111
|
-
count), a Codex clean pass (payload.transition=clean_pass)
|
|
112
|
-
|
|
113
|
-
review_unavailable — no review is coming)
|
|
111
|
+
count), a Codex clean pass (payload.transition=clean_pass),
|
|
112
|
+
the Codex usage-limit decline (transition=
|
|
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
117
|
Edge-triggered: it wakes only on review activity AFTER
|
|
115
118
|
arm, so arm BEFORE requesting the review. If the PR ALREADY
|
|
116
119
|
has an outstanding review at arm, bb-wait prints a one-line
|
|
@@ -218,6 +221,12 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
218
221
|
tenant after current server-side approval revalidation.
|
|
219
222
|
|
|
220
223
|
OPTIONS
|
|
224
|
+
Review intervention: exact-head Codex waits have a durable one-shot diagnostic.
|
|
225
|
+
stall_minutes= overrides repository → tenant settings (initial default 10 min).
|
|
226
|
+
A missing/unbound backend guard is refused as review_guard_unsupported.
|
|
227
|
+
It needs pr= + sha=. A result wakes immediately; a deadline never means approval.
|
|
228
|
+
Reconnects/re-arms retain the deadline. This is separate from --timeout below.
|
|
229
|
+
|
|
221
230
|
--any wake on the first matching condition (default; only mode in v1)
|
|
222
231
|
--timeout <seconds> safety cap (default 86400 = 24 h; hard cap 259200 = 72 h). Timeout is exit 2,
|
|
223
232
|
not an error. The timeout is a SAFETY CAP measured in hours or days, never a
|
|
@@ -528,6 +537,7 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
528
537
|
const body = await res.json().catch(() => ({}));
|
|
529
538
|
const INVALID_CONDITION_CODES = new Set([
|
|
530
539
|
"no_signal_source",
|
|
540
|
+
"review_guard_registration_failed",
|
|
531
541
|
"unblocked_cross_tenant",
|
|
532
542
|
"unblocked_requires_tenant",
|
|
533
543
|
"wait_tenant_unresolved",
|
|
@@ -662,6 +672,24 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
662
672
|
throw err;
|
|
663
673
|
}
|
|
664
674
|
const body = await res.json();
|
|
675
|
+
const guardedReviews = conditions.filter((c) => c.type === "pr-review" && c.params?.sha && (c.params.number || c.params.pr) &&
|
|
676
|
+
String(c.params.reviewer ?? "chatgpt-codex-connector").toLowerCase().replace(/\[bot\]$/, "") === "chatgpt-codex-connector");
|
|
677
|
+
for (const condition of guardedReviews) {
|
|
678
|
+
const params = condition.params;
|
|
679
|
+
const guard = Array.isArray(body.review_guards) && body.review_guards.find((g) =>
|
|
680
|
+
typeof g?.repo === "string" && g.repo.toLowerCase() === params.repo.toLowerCase() &&
|
|
681
|
+
Number(g.pr_number) === Number(params.number ?? params.pr) && typeof g.commit_sha === "string" &&
|
|
682
|
+
/^[0-9a-f]{7,40}$/i.test(g.commit_sha) &&
|
|
683
|
+
(g.commit_sha.toLowerCase().startsWith(params.sha) || params.sha.startsWith(g.commit_sha.toLowerCase())));
|
|
684
|
+
const binding = Array.isArray(body.conditions) ? body.conditions.find((c) => c.id === condition.id)?.review_guard : null;
|
|
685
|
+
if (!guard || typeof guard.id !== "string" || !guard.id || binding?.id !== guard.id ||
|
|
686
|
+
!Number.isFinite(Date.parse(binding.started_at)) || !Number.isFinite(Date.parse(guard.started_at)) ||
|
|
687
|
+
!Number.isFinite(Date.parse(guard.deadline)) || !Number.isInteger(guard.minutes) || guard.minutes < 10 || guard.minutes > 1440 ||
|
|
688
|
+
!["call", "repository", "tenant"].includes(guard.source)) {
|
|
689
|
+
const err = new Error("relay did not bind the required review intervention policy; upgrade the backend before using this client");
|
|
690
|
+
err.invalidCondition = true; err.errorCode = "review_guard_unsupported"; throw err;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
665
693
|
// BOT-1572/1608: a session-token wait has no profile to attest; the relay
|
|
666
694
|
// returns the derived session_id + agent_id. Their absence means the relay did
|
|
667
695
|
// not honour the token as a session credential — fail closed.
|
|
@@ -694,6 +722,7 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
694
722
|
// BOT-1539: arm-time snapshot of already-outstanding code review for any
|
|
695
723
|
// pr-review/pr-state target. Report-only — surfaced on stderr so a silent
|
|
696
724
|
// edge-triggered park isn't mistaken for "no review yet".
|
|
725
|
+
reviewGuards: Array.isArray(body.review_guards) ? body.review_guards : [],
|
|
697
726
|
prReviewSnapshot: Array.isArray(body.pr_review_snapshot) ? body.pr_review_snapshot : null,
|
|
698
727
|
// BOT-2400: how often this session already armed this exact condition set (24 h), and
|
|
699
728
|
// the relay's warning once that reaches 3.
|
|
@@ -2101,6 +2130,9 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2101
2130
|
for (const line of formatPrReviewSnapshotWarnings(reg.prReviewSnapshot)) {
|
|
2102
2131
|
process.stderr.write(`bb-wait: ${line}\n`);
|
|
2103
2132
|
}
|
|
2133
|
+
for (const guard of reg.reviewGuards ?? []) {
|
|
2134
|
+
process.stderr.write(`bb-wait: review intervention ${guard.minutes} min (${guard.source}), deadline ${guard.deadline}${guard.diagnosed ? "; already diagnosed for this request" : ""}\n`);
|
|
2135
|
+
}
|
|
2104
2136
|
// BOT-2400: re-arming the same wait again and again burns tokens on every wake.
|
|
2105
2137
|
if (reg.rearmWarning) process.stderr.write(`bb-wait: ${reg.rearmWarning}\n`);
|
|
2106
2138
|
// BOT-1837: one line naming the branch's latest run at arm time. Report-only
|
|
@@ -2130,7 +2162,9 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2130
2162
|
// and refusing a recycled key). No `tenant` is stamped for `linear` — its cross-tenant
|
|
2131
2163
|
// isolation rides on the central guard's sessionTenant (BOT-1259), not a per-type param.
|
|
2132
2164
|
const linearScope = new Map();
|
|
2165
|
+
const reviewScope = new Map();
|
|
2133
2166
|
for (const rc of reg.conditions) {
|
|
2167
|
+
if (rc?.type === "pr-review" && rc.review_guard) reviewScope.set(rc.id, rc.review_guard);
|
|
2134
2168
|
if (rc && rc.type === "lock" && rc.params && typeof rc.params.host === "string") {
|
|
2135
2169
|
canonHost.set(rc.id, rc.params.host);
|
|
2136
2170
|
}
|
|
@@ -2142,6 +2176,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2142
2176
|
}
|
|
2143
2177
|
}
|
|
2144
2178
|
for (const c of conditions) {
|
|
2179
|
+
if (c.type === "pr-review" && reviewScope.has(c.id)) c.review_guard = reviewScope.get(c.id);
|
|
2145
2180
|
if (c.type === "lock" && canonHost.has(c.id)) c.params.host = canonHost.get(c.id);
|
|
2146
2181
|
if (c.type === "unblocked" && unblockedScope.has(c.id)) {
|
|
2147
2182
|
const s = unblockedScope.get(c.id);
|