@botbuddy/cli 1.35.0 → 1.36.1
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/package.json +1 -1
- package/src/stack.mjs +31 -6
- package/src/wait-core.mjs +75 -4
- package/src/wait.mjs +33 -14
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -1587,19 +1587,24 @@ export async function cmdUp(opts, {
|
|
|
1587
1587
|
}), opts, EXIT.OK);
|
|
1588
1588
|
}
|
|
1589
1589
|
|
|
1590
|
-
async function cmdStatus(leaseId, opts, { rpc = retryingStackCall() } = {}) {
|
|
1590
|
+
export async function cmdStatus(leaseId, opts, { rpc = retryingStackCall(), emitResult = emit } = {}) {
|
|
1591
1591
|
const auth = await rpc.init();
|
|
1592
|
-
if (!auth) return
|
|
1592
|
+
if (!auth) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
1593
1593
|
const got = await rpc.call("get_stack_lease", { lease_id: leaseId });
|
|
1594
1594
|
if (!got.ok) return got.auth
|
|
1595
|
-
?
|
|
1596
|
-
:
|
|
1595
|
+
? emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
|
|
1596
|
+
: emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
|
|
1597
1597
|
const g = got.data;
|
|
1598
|
-
if (!g.success) return
|
|
1599
|
-
return
|
|
1598
|
+
if (!g.success) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, code: g.code, error: g.code }), opts, EXIT.BACKEND);
|
|
1599
|
+
return emitResult(buildReceipt({
|
|
1600
1600
|
command: "status", outcome: g.state, lease_id: leaseId, state: g.state,
|
|
1601
1601
|
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
1602
1602
|
queue_position: g.queue_position, idle_ttl_secs: g.idle_ttl_secs, queued: g.queued,
|
|
1603
|
+
// BOT-1960: a lease fenced in `reaping` says since when, and — once the server gave up
|
|
1604
|
+
// retrying its failed reap — that it is reap_failed and why.
|
|
1605
|
+
...(g.reap_requested_at ? { reap_requested_at: g.reap_requested_at } : {}),
|
|
1606
|
+
...(g.reap_failed_at ? { reap_failed: true, reap_failed_at: g.reap_failed_at } : {}),
|
|
1607
|
+
...(g.state === "reaping" && g.error ? { error: g.error } : {}),
|
|
1603
1608
|
}), opts, EXIT.OK);
|
|
1604
1609
|
}
|
|
1605
1610
|
|
|
@@ -1803,7 +1808,27 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1803
1808
|
if (fin.ok && fin.data?.success) state = fin.data.state;
|
|
1804
1809
|
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
1805
1810
|
}
|
|
1811
|
+
// BOT-1960: on a lease fenced in `reaping` by a FAILED reap, the server re-enqueues the
|
|
1812
|
+
// reap (the first time also resetting its retry budget) — or, once that budget is spent,
|
|
1813
|
+
// refuses. Say which, instead of a `released` that changed nothing.
|
|
1814
|
+
const retry = r.data.reap_retry;
|
|
1815
|
+
if (state === "reaping" && retry?.code === "REAP_RETRY_BUDGET_SPENT") {
|
|
1816
|
+
const since = r.data.reap_failed_at ? ` (since ${r.data.reap_failed_at})` : "";
|
|
1817
|
+
return emitResult(buildReceipt({ command: "done", outcome: "reap_failed", lease_id: leaseId, state, disposition,
|
|
1818
|
+
reap_failed_at: r.data.reap_failed_at ?? null, reap_retry: { enqueued: false, code: retry.code },
|
|
1819
|
+
error: `the reap failed on every retry${since}; no retries left — the lease stays reaping and its slot stays fenced. ` +
|
|
1820
|
+
"Remove the project's leftover containers (operator / `botbuddy docker hygiene`); the container census then finalizes the lease.",
|
|
1821
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
1822
|
+
}
|
|
1823
|
+
const retried = retry?.code === "REAP_RETRY_ENQUEUED"
|
|
1824
|
+
? {
|
|
1825
|
+
reap_retry: { enqueued: true, attempt: retry.attempt, budget_reset: retry.budget_reset === true },
|
|
1826
|
+
note: `the previous reap failed; a new reap job (attempt ${retry.attempt}) was enqueued` +
|
|
1827
|
+
(retry.budget_reset === true ? " and the reap retry budget was reset" : ""),
|
|
1828
|
+
}
|
|
1829
|
+
: {};
|
|
1806
1830
|
return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition,
|
|
1831
|
+
...retried,
|
|
1807
1832
|
...(helperTeardown ? { teardown: "helper" } : {}),
|
|
1808
1833
|
...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
|
|
1809
1834
|
}
|
package/src/wait-core.mjs
CHANGED
|
@@ -132,8 +132,8 @@ const VALIDATORS = {
|
|
|
132
132
|
claim,
|
|
133
133
|
};
|
|
134
134
|
},
|
|
135
|
-
"pr-state": (p) =>
|
|
136
|
-
"pr-review": (p) =>
|
|
135
|
+
"pr-state": (p) => parsePrCondition(p, "pr-state"),
|
|
136
|
+
"pr-review": (p) => parsePrCondition(p, "pr-review"),
|
|
137
137
|
// BOT-1063 — test-run: park on a test_run reaching a terminal status. The
|
|
138
138
|
// spine subject_key is `run:<id>` (mirrors the run:<id> convention noted on
|
|
139
139
|
// agent_signal_events.subject_key), so a condition just names the run id.
|
|
@@ -354,6 +354,46 @@ function parsePrTarget(p, label) {
|
|
|
354
354
|
return { repo, number: number != null && number !== "" ? number : null };
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
// BOT-1975 / BOT-1972 — the pr-state / pr-review key allowlist. Before this an
|
|
358
|
+
// unknown key (a typo'd `shaa=`, or `sha=` / `reviewer=` on a client that did not
|
|
359
|
+
// implement them) was silently discarded and the wait armed WIDER than asked —
|
|
360
|
+
// it then woke on another head's or another reviewer's signal. Now an unknown key
|
|
361
|
+
// refuses at arm (invalid_conditions, exit 4). `reviewer=` is pr-review only.
|
|
362
|
+
const PR_CONDITION_KEYS = Object.freeze({
|
|
363
|
+
"pr-state": Object.freeze(["repo", "pr", "number", "sha"]),
|
|
364
|
+
"pr-review": Object.freeze(["repo", "pr", "number", "sha", "reviewer"]),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// A GitHub login compared case-insensitively, with the App suffix `[bot]` stripped
|
|
368
|
+
// (the webhook reports `chatgpt-codex-connector[bot]`; humans type it either way).
|
|
369
|
+
function normalizeReviewerLogin(login) {
|
|
370
|
+
return String(login).trim().toLowerCase().replace(/\[bot\]$/, "");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parsePrCondition(p, label) {
|
|
374
|
+
const allowed = PR_CONDITION_KEYS[label];
|
|
375
|
+
const unknown = Object.keys(p).filter((key) => !allowed.includes(key));
|
|
376
|
+
if (unknown.length > 0) {
|
|
377
|
+
throw new Error(`${label} unknown key '${unknown[0]}' (allowed: ${allowed.join(", ")})`);
|
|
378
|
+
}
|
|
379
|
+
const out = parsePrTarget(p, label);
|
|
380
|
+
// Optional keys are added ONLY when given, so a plain wait's params (sent to the
|
|
381
|
+
// relay and saved in the local checkpoint) are byte-identical to before.
|
|
382
|
+
if (p.sha !== undefined) {
|
|
383
|
+
const sha = typeof p.sha === "string" ? p.sha.trim().toLowerCase() : "";
|
|
384
|
+
if (!/^[0-9a-f]{7,40}$/.test(sha)) {
|
|
385
|
+
throw new Error(`${label} sha= must be 7–40 hex characters (a commit SHA or its prefix)`);
|
|
386
|
+
}
|
|
387
|
+
out.sha = sha;
|
|
388
|
+
}
|
|
389
|
+
if (p.reviewer !== undefined) {
|
|
390
|
+
const reviewer = typeof p.reviewer === "string" ? normalizeReviewerLogin(p.reviewer) : "";
|
|
391
|
+
if (reviewer === "") throw new Error(`${label} reviewer= must be a GitHub login`);
|
|
392
|
+
out.reviewer = reviewer;
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
357
397
|
// Does a spine subject_key (`owner/repo#number`) satisfy a pr target?
|
|
358
398
|
function prSubjectMatches(subjectKey, params) {
|
|
359
399
|
if (typeof subjectKey !== "string") return false;
|
|
@@ -361,6 +401,35 @@ function prSubjectMatches(subjectKey, params) {
|
|
|
361
401
|
return subjectKey.startsWith(`${params.repo}#`);
|
|
362
402
|
}
|
|
363
403
|
|
|
404
|
+
// Does a commit field on a signal payload start with the condition's sha prefix?
|
|
405
|
+
// A missing / non-string field never matches a sha-scoped condition.
|
|
406
|
+
function shaPrefixMatches(value, sha) {
|
|
407
|
+
return typeof value === "string" && value.trim().toLowerCase().startsWith(sha);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// BOT-1975: optional head scoping for pr-state. pr_state signals carry the PR
|
|
411
|
+
// head as payload.head_ref_oid; a signal for a previous head does not wake.
|
|
412
|
+
function prStateFiltersMatch(signal, params) {
|
|
413
|
+
if (params.sha == null) return true;
|
|
414
|
+
return shaPrefixMatches(signal.payload?.head_ref_oid, params.sha);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// BOT-1975 / BOT-1972: optional commit + reviewer scoping for pr-review. A
|
|
418
|
+
// `review_unavailable` signal (the Codex usage-limit decline) names no commit, so
|
|
419
|
+
// it always passes the sha filter — the waiter must learn no review is coming.
|
|
420
|
+
function prReviewFiltersMatch(signal, params) {
|
|
421
|
+
const payload = signal.payload ?? {};
|
|
422
|
+
if (params.sha != null && payload.transition !== "review_unavailable" &&
|
|
423
|
+
!shaPrefixMatches(payload.commit_sha, params.sha)) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
if (params.reviewer != null) {
|
|
427
|
+
if (typeof payload.reviewer_login !== "string") return false;
|
|
428
|
+
if (normalizeReviewerLogin(payload.reviewer_login) !== params.reviewer) return false;
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
364
433
|
/**
|
|
365
434
|
* Parse and strictly validate a list of `TYPE:params` condition specs.
|
|
366
435
|
*
|
|
@@ -777,9 +846,11 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
777
846
|
return signal.subject_key.startsWith(`${params.subtype}:${params.host}:`);
|
|
778
847
|
}
|
|
779
848
|
case "pr-state":
|
|
780
|
-
return signal.signal_type === "pr_state" && prSubjectMatches(signal.subject_key, params)
|
|
849
|
+
return signal.signal_type === "pr_state" && prSubjectMatches(signal.subject_key, params) &&
|
|
850
|
+
prStateFiltersMatch(signal, params);
|
|
781
851
|
case "pr-review":
|
|
782
|
-
return signal.signal_type === "pr_review" && prSubjectMatches(signal.subject_key, params)
|
|
852
|
+
return signal.signal_type === "pr_review" && prSubjectMatches(signal.subject_key, params) &&
|
|
853
|
+
prReviewFiltersMatch(signal, params);
|
|
783
854
|
case "test-run":
|
|
784
855
|
return signal.signal_type === "test_run" && signal.subject_key === `run:${params.runId}`;
|
|
785
856
|
case "guidance":
|
package/src/wait.mjs
CHANGED
|
@@ -71,19 +71,35 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
71
71
|
Must be the ONLY condition (use --timeout to bound it); can't be
|
|
72
72
|
raced against other conditions. Omit claim to just wait for
|
|
73
73
|
availability and acquire it yourself.
|
|
74
|
-
pr-state:repo=<owner/repo>[,pr=<n>]
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
pr-state:repo=<owner/repo>[,pr=<n>][,sha=<head>]
|
|
75
|
+
a PR lifecycle or mergeable flip (draft<->ready, merged,
|
|
76
|
+
closed, conflict/clean flip) plus check-rollup, review-
|
|
77
|
+
decision and unresolved-thread-count changes, as projected
|
|
78
|
+
by the PR read model. Omit pr= to wake on ANY of your PRs
|
|
79
|
+
in the repo; or pass pr=<owner/repo#n> in one value. A
|
|
80
|
+
conflict/clean flip sets payload.mergeable_changed. The
|
|
81
|
+
payload carries head_ref_oid; sha=<head> (7-40 hex, a
|
|
82
|
+
prefix is fine) wakes only on signals for that head, so a
|
|
83
|
+
previous head's check result can't wake you. A push alone
|
|
84
|
+
emits nothing — the head rides on the next transition.
|
|
85
|
+
pr-review:repo=<owner/repo>[,pr=<n>][,reviewer=<login>][,sha=<commit>]
|
|
86
|
+
a submitted review, a new review comment, a thread
|
|
87
|
+
resolved/re-opened (payload carries the unresolved-thread
|
|
88
|
+
count), a Codex clean pass (payload.transition=clean_pass)
|
|
89
|
+
or the Codex usage-limit decline (transition=
|
|
90
|
+
review_unavailable — no review is coming).
|
|
82
91
|
Edge-triggered: it wakes only on review activity AFTER
|
|
83
|
-
arm
|
|
84
|
-
bb-wait prints a one-line
|
|
85
|
-
review_decision, age) so a
|
|
86
|
-
"no review yet" — the
|
|
92
|
+
arm, so arm BEFORE requesting the review. If the PR ALREADY
|
|
93
|
+
has an outstanding review at arm, bb-wait prints a one-line
|
|
94
|
+
stderr notice (unresolved count, review_decision, age) so a
|
|
95
|
+
silent park isn't mistaken for "no review yet" — the
|
|
96
|
+
existing review will not re-wake it.
|
|
97
|
+
reviewer=<login> wakes only on that reviewer (case-
|
|
98
|
+
insensitive; a trailing [bot] is ignored). sha=<commit>
|
|
99
|
+
wakes only on verdicts for that commit (submitted reviews
|
|
100
|
+
and clean passes carry commit_sha; thread comments do not,
|
|
101
|
+
so they never match sha=); the usage-limit decline always
|
|
102
|
+
passes sha=. Any other key is refused (exit 4).
|
|
87
103
|
test-run:id=<run_id> a test run reaching a terminal status (owner-scoped)
|
|
88
104
|
lease:id=<lease_id> a batch stack lease (BOT-1218) leaving the queue /
|
|
89
105
|
becoming active — the zero-poll wake for a parked
|
|
@@ -113,7 +129,8 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
113
129
|
the ranking (first-come). Needs cli >= 1.20.0.
|
|
114
130
|
ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
|
|
115
131
|
| scope=next[,sha=<sha>][,branch=<name>][,workflow=<name>]}
|
|
116
|
-
a
|
|
132
|
+
a run's terminal conclusion (owner-scoped) — not a
|
|
133
|
+
PR state or review change (use pr-state / pr-review);
|
|
117
134
|
GitHub Actions or an external check_suite provider.
|
|
118
135
|
A repo with no CI feed is rejected (no_signal_source).
|
|
119
136
|
scope=next is LIVE-ONLY: it wakes on the first terminal
|
|
@@ -154,7 +171,9 @@ AUTH
|
|
|
154
171
|
|
|
155
172
|
OUTPUT
|
|
156
173
|
Exactly one JSON receipt line on stdout at exit, with client semver and
|
|
157
|
-
protocol identity. Diagnostics go to stderr
|
|
174
|
+
protocol identity. Diagnostics go to stderr — including the arm-time notices
|
|
175
|
+
(outstanding review, latest CI run). Do not pipe bb wait through tail: the
|
|
176
|
+
notice is on stderr and you will not see it until the wait exits.
|
|
158
177
|
Exit codes: 0 matched · 2 timeout · 3 auth · 4 invalid conditions ·
|
|
159
178
|
5 backend unavailable · 6 cursor expired · 7 internal ·
|
|
160
179
|
8 superseded (wait replaced server-side). If the receipt has
|