@botbuddy/cli 1.33.5 → 1.33.7
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 +47 -9
- package/src/wait-core.mjs +48 -1
- package/src/wait.mjs +41 -2
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -76,10 +76,12 @@ ${bold("up OPTIONS")}
|
|
|
76
76
|
--host <host_key> Pin to a canonical host. Omit to auto-select a beacon-fresh host.
|
|
77
77
|
--repo <repo> Repository the batch is for (e.g. botbuddy-web).
|
|
78
78
|
--ticket <BOT-123> Ticket the batch is for (also used to derive the slot).
|
|
79
|
-
--stack-path <relative>
|
|
80
|
-
|
|
81
|
-
supabase/config.toml
|
|
82
|
-
|
|
79
|
+
--stack-path <relative> ISOLATED stack directory inside the registered worktree. Required:
|
|
80
|
+
the control plane refuses the worktree root (stack_path_root_refused,
|
|
81
|
+
BOT-1797) because its supabase/config.toml is the repo's SHARED
|
|
82
|
+
canonical project. Point it at a slot-derived stack whose
|
|
83
|
+
supabase/config.toml declares a DISTINCT project_id + remapped ports,
|
|
84
|
+
or use 'stack run', which materializes one for you.
|
|
83
85
|
--purpose <text> Free-text purpose recorded on the lease.
|
|
84
86
|
--idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
|
|
85
87
|
--timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
|
|
@@ -669,6 +671,39 @@ export function worktreeRegistrationHint(code, auth) {
|
|
|
669
671
|
+ `Run \`bb doctor --fix\` here to register its host + machine id, then retry.`;
|
|
670
672
|
}
|
|
671
673
|
|
|
674
|
+
// BOT-1797: the control plane refuses a Helper-backed lease whose stack_path is the
|
|
675
|
+
// worktree root — its supabase/config.toml is the repo's SHARED canonical project, so
|
|
676
|
+
// the Helper would start (and on reap destroy) the shared dev stack. This CLI always
|
|
677
|
+
// materializes an isolated stack (BOT-1798), so seeing this code means a stale binary,
|
|
678
|
+
// an explicit root --stack-path, or a wrapper that hand-rolls the lease call.
|
|
679
|
+
export const STACK_PATH_ROOT_REFUSED_CODE = "stack_path_root_refused";
|
|
680
|
+
|
|
681
|
+
// Only used when an older/leaner server sends the code with no message; the server's
|
|
682
|
+
// own message is always preferred so the two can never drift.
|
|
683
|
+
const STACK_PATH_ROOT_REFUSED_FALLBACK =
|
|
684
|
+
"the control plane refused a stack lease at the worktree root (the repo's shared stack). " +
|
|
685
|
+
"Upgrade with `npm i -g @botbuddy/cli@latest`, or pass --stack-path <isolated dir>.";
|
|
686
|
+
|
|
687
|
+
/** The server's root refusal, verbatim, as one operator-facing line (or null). */
|
|
688
|
+
export function stackPathRootRefusalNotice(code, message) {
|
|
689
|
+
if (code !== STACK_PATH_ROOT_REFUSED_CODE) return null;
|
|
690
|
+
return `${yellow("⚠")} stack: ${message || STACK_PATH_ROOT_REFUSED_FALLBACK}`;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Exit code for a lease the server REFUSED (`success: false`). A refused root path is a
|
|
695
|
+
* bad argument the caller must fix — INVALID (4) — not a backend fault, which automation
|
|
696
|
+
* reads as "the control plane is unhealthy, retry". Every other refusal keeps BACKEND (5).
|
|
697
|
+
*/
|
|
698
|
+
export function leaseRefusalExit(code) {
|
|
699
|
+
return code === STACK_PATH_ROOT_REFUSED_CODE ? EXIT.INVALID : EXIT.BACKEND;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** The refusal text for a receipt: the server's message, else its code. */
|
|
703
|
+
export function leaseRefusalError(data) {
|
|
704
|
+
return data?.message || data?.code || "lease request refused";
|
|
705
|
+
}
|
|
706
|
+
|
|
672
707
|
// The MCP lease endpoint accepts a session token in x-agent-api-key, whereas
|
|
673
708
|
// the event-stream relay requires that same token in its bearer form too. Keep
|
|
674
709
|
// the lease RPC shape unchanged and derive the relay-compatible form only at
|
|
@@ -1340,9 +1375,9 @@ export async function cmdUp(opts, {
|
|
|
1340
1375
|
}
|
|
1341
1376
|
const d = req.data;
|
|
1342
1377
|
if (!d.success) {
|
|
1343
|
-
const hint = worktreeRegistrationHint(d.code, auth);
|
|
1378
|
+
const hint = worktreeRegistrationHint(d.code, auth) ?? stackPathRootRefusalNotice(d.code, d.message);
|
|
1344
1379
|
if (hint) process.stderr.write(`${hint}\n`);
|
|
1345
|
-
return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d
|
|
1380
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: leaseRefusalError(d), slot }), opts, leaseRefusalExit(d.code));
|
|
1346
1381
|
}
|
|
1347
1382
|
let leaseId = d.lease_id;
|
|
1348
1383
|
let state = d.state;
|
|
@@ -2007,9 +2042,10 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2007
2042
|
return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
|
|
2008
2043
|
}
|
|
2009
2044
|
if (!request.data?.success) {
|
|
2010
|
-
const hint = worktreeRegistrationHint(request.data?.code, rpc.auth)
|
|
2045
|
+
const hint = worktreeRegistrationHint(request.data?.code, rpc.auth)
|
|
2046
|
+
?? stackPathRootRefusalNotice(request.data?.code, request.data?.message);
|
|
2011
2047
|
if (hint) process.stderr.write(`${hint}\n`);
|
|
2012
|
-
return { exitCode:
|
|
2048
|
+
return { exitCode: leaseRefusalExit(request.data?.code), outcome: "error", code: request.data?.code, error: leaseRefusalError(request.data) };
|
|
2013
2049
|
}
|
|
2014
2050
|
leaseId = request.data.lease_id;
|
|
2015
2051
|
if (request.data.reused) {
|
|
@@ -2154,7 +2190,9 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2154
2190
|
async function cmdRun(opts, childArgv) {
|
|
2155
2191
|
const result = await runStackLifecycle(opts, childArgv);
|
|
2156
2192
|
return emit(buildReceipt({
|
|
2157
|
-
|
|
2193
|
+
// BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
|
|
2194
|
+
// so automation can branch on it instead of matching the message text.
|
|
2195
|
+
command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
|
|
2158
2196
|
child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
|
|
2159
2197
|
fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
|
|
2160
2198
|
}), opts, result.exitCode);
|
package/src/wait-core.mjs
CHANGED
|
@@ -692,6 +692,49 @@ export function formatPrReviewSnapshotWarnings(snapshot, now = Date.now()) {
|
|
|
692
692
|
return lines;
|
|
693
693
|
}
|
|
694
694
|
|
|
695
|
+
/**
|
|
696
|
+
* BOT-1837 — the one-line arm-time notice for a `scope=next` ci wait, built from
|
|
697
|
+
* the relay's `arm_context.latest_run`. Report-only: it says what CI has ALREADY
|
|
698
|
+
* done on the branch, so an agent that armed after the run it meant can see that
|
|
699
|
+
* immediately instead of discovering it at the deadline. Returns null when there
|
|
700
|
+
* is nothing to report. A missing field is reported as unknown, never guessed.
|
|
701
|
+
*
|
|
702
|
+
* @param {{run_id?:string|number|null,workflow_name?:string|null,status?:string|null,conclusion?:string|null,created_at?:string|null}|null|undefined} latestRun
|
|
703
|
+
* @param {{branch?:string|null, now?:number}} [opts]
|
|
704
|
+
* @returns {string|null}
|
|
705
|
+
*/
|
|
706
|
+
export function formatCiLatestRunNotice(latestRun, { branch = null, now = Date.now() } = {}) {
|
|
707
|
+
if (!latestRun || typeof latestRun !== "object") return null;
|
|
708
|
+
const runId = latestRun.run_id ?? "?";
|
|
709
|
+
const workflow = latestRun.workflow_name || "unknown workflow";
|
|
710
|
+
const status = latestRun.status || "unknown";
|
|
711
|
+
const conclusion = latestRun.conclusion || "none";
|
|
712
|
+
const createdMs = Date.parse(latestRun.created_at ?? "");
|
|
713
|
+
const age = Number.isFinite(createdMs)
|
|
714
|
+
? `${Math.max(0, Math.round((now - createdMs) / 60000))} min ago`
|
|
715
|
+
: "age unknown";
|
|
716
|
+
return `latest run on ${branch || "?"}: ${workflow} #${runId} ${status}/${conclusion} ` +
|
|
717
|
+
`(${age}) — scope=next wakes only on a NEWER run`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* BOT-1837 — the stderr block for a relay `unknown_workflow` refusal: the
|
|
722
|
+
* server's detail, then one COPY-PASTEABLE `workflow=<name>` line per candidate
|
|
723
|
+
* so the fix is a paste, not a guess.
|
|
724
|
+
*
|
|
725
|
+
* @param {string} detail
|
|
726
|
+
* @param {string[]|null|undefined} candidates
|
|
727
|
+
* @returns {string[]}
|
|
728
|
+
*/
|
|
729
|
+
export function formatUnknownWorkflowGuidance(detail, candidates) {
|
|
730
|
+
const lines = [String(detail)];
|
|
731
|
+
const named = Array.isArray(candidates) ? candidates.filter((c) => typeof c === "string" && c.trim() !== "") : [];
|
|
732
|
+
if (named.length === 0) return lines;
|
|
733
|
+
lines.push("closest workflow names this repo has actually reported:");
|
|
734
|
+
for (const name of named) lines.push(` workflow=${name}`);
|
|
735
|
+
return lines;
|
|
736
|
+
}
|
|
737
|
+
|
|
695
738
|
// ---------------------------------------------------------------------------
|
|
696
739
|
// Matching — does a spine signal frame satisfy a condition?
|
|
697
740
|
// ---------------------------------------------------------------------------
|
|
@@ -1073,6 +1116,10 @@ export async function runWaitLoop({
|
|
|
1073
1116
|
// wait capacity_source_stale even though the host recovered seconds after the stale
|
|
1074
1117
|
// frame. 90s = 60s cron + margin.
|
|
1075
1118
|
beaconConfirmMs = 90_000,
|
|
1119
|
+
// BOT-1837: the arm-time notice (if any) to carry into a TIMEOUT receipt's
|
|
1120
|
+
// `error`, so the receipt itself says what CI was already doing when this wait
|
|
1121
|
+
// armed. Purely descriptive — it never changes the outcome or the exit code.
|
|
1122
|
+
timeoutError = null,
|
|
1076
1123
|
}) {
|
|
1077
1124
|
const startedAt = typeof originalStartedAt === "string" && Number.isFinite(Date.parse(originalStartedAt))
|
|
1078
1125
|
? originalStartedAt : nowIso();
|
|
@@ -1206,7 +1253,7 @@ export async function runWaitLoop({
|
|
|
1206
1253
|
// The terminal feed-freshness reprobe now lives in finalize() (the single
|
|
1207
1254
|
// finalization path), so every terminal receipt gets it — not just the alarm path.
|
|
1208
1255
|
if (!alarm) return null;
|
|
1209
|
-
if (alarm.kind === "timeout") return finalize("timeout", { exitCode: EXIT.TIMEOUT });
|
|
1256
|
+
if (alarm.kind === "timeout") return finalize("timeout", { exitCode: EXIT.TIMEOUT, error: timeoutError ?? null });
|
|
1210
1257
|
if (alarm.kind === "stale") {
|
|
1211
1258
|
// Capacity source didn't deliver within the grace: degrade truthfully and
|
|
1212
1259
|
// exit with an error receipt (not a silent hang, not a plain timeout).
|
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, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings } from "./wait-core.mjs";
|
|
16
|
+
import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance } 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";
|
|
@@ -352,11 +352,16 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
352
352
|
"session_id_required",
|
|
353
353
|
"wait_resume_identity_invalid",
|
|
354
354
|
"wait_resume_identity_mismatch",
|
|
355
|
+
// BOT-1837: `workflow=` names a workflow this repo has never reported a
|
|
356
|
+
// wake-capable run for. The wait could only ever expire, and no amount of
|
|
357
|
+
// waiting repairs a typo — a hard stop, with the server's candidates.
|
|
358
|
+
"unknown_workflow",
|
|
355
359
|
]);
|
|
356
360
|
if (INVALID_CONDITION_CODES.has(body.error)) {
|
|
357
361
|
const err = new Error(body.detail || body.error);
|
|
358
362
|
err.invalidCondition = true;
|
|
359
363
|
err.errorCode = body.error;
|
|
364
|
+
if (Array.isArray(body.candidates)) err.candidates = body.candidates;
|
|
360
365
|
throw err;
|
|
361
366
|
}
|
|
362
367
|
}
|
|
@@ -454,6 +459,13 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
454
459
|
// pr-review/pr-state target. Report-only — surfaced on stderr so a silent
|
|
455
460
|
// edge-triggered park isn't mistaken for "no review yet".
|
|
456
461
|
prReviewSnapshot: Array.isArray(body.pr_review_snapshot) ? body.pr_review_snapshot : null,
|
|
462
|
+
// BOT-1837: what CI had already done on the branch at arm time. An ABSENT
|
|
463
|
+
// arm_context (a pre-BOT-1837 relay, or a condition that isn't scope=next
|
|
464
|
+
// with a branch) and an explicit `latest_run: null` both collapse to null —
|
|
465
|
+
// there is simply nothing to report either way.
|
|
466
|
+
armLatestRun: body.arm_context && typeof body.arm_context === "object"
|
|
467
|
+
? (body.arm_context.latest_run ?? null)
|
|
468
|
+
: null,
|
|
457
469
|
status: typeof body.status === "string" ? body.status : "active",
|
|
458
470
|
// A canonicalized alias wait was deliberately abandoned and already has a
|
|
459
471
|
// sequenced wait_superseded signal. Preserve that terminal state and replay
|
|
@@ -1615,6 +1627,10 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1615
1627
|
// BOT-1467: the session agent the relay attributed the wait to (when overridden).
|
|
1616
1628
|
let registeredSessionAgentId = null;
|
|
1617
1629
|
let registeredSessionId = null;
|
|
1630
|
+
// BOT-1837: the arm-time "latest run on <branch>" notice, printed once on
|
|
1631
|
+
// register and reused as the timeout receipt's error. Null when there is
|
|
1632
|
+
// nothing to report (no ci scope=next+branch condition, or no run yet).
|
|
1633
|
+
let ciArmNotice = null;
|
|
1618
1634
|
let replayMatchedClaim = false;
|
|
1619
1635
|
let replayExpiredRecovery = false;
|
|
1620
1636
|
if (needsRelay) {
|
|
@@ -1719,6 +1735,13 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1719
1735
|
for (const line of formatPrReviewSnapshotWarnings(reg.prReviewSnapshot)) {
|
|
1720
1736
|
process.stderr.write(`bb-wait: ${line}\n`);
|
|
1721
1737
|
}
|
|
1738
|
+
// BOT-1837: one line naming the branch's latest run at arm time. Report-only
|
|
1739
|
+
// (arming before dispatch is the correct flow), and carried into a timeout
|
|
1740
|
+
// receipt below so the receipt explains the silence too.
|
|
1741
|
+
ciArmNotice = formatCiLatestRunNotice(reg.armLatestRun, {
|
|
1742
|
+
branch: conditions.find((c) => c.type === "ci" && c.params?.branch)?.params?.branch ?? null,
|
|
1743
|
+
});
|
|
1744
|
+
if (ciArmNotice) process.stderr.write(`bb-wait: ${ciArmNotice}\n`);
|
|
1722
1745
|
// BOT-1184: adopt the server's canonical host for each lock condition so the
|
|
1723
1746
|
// local matcher builds the same subject_key the availability/claim-grant
|
|
1724
1747
|
// signals carry (armed under an alias like 'jono-mac', the signal uses the
|
|
@@ -1893,8 +1916,21 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1893
1916
|
// A rejected condition set is a configuration error, not a wait — fail closed
|
|
1894
1917
|
// rather than arming an untracked wait that skips the server's initial
|
|
1895
1918
|
// evaluation (BOT-1066 no_signal_source, BOT-1247 unblocked_cross_tenant, …).
|
|
1919
|
+
if (err.errorCode === "unknown_workflow") {
|
|
1920
|
+
// BOT-1837: print the workflow names this repo HAS reported, one
|
|
1921
|
+
// copy-pasteable `workflow=<name>` per line, so the retry is a paste.
|
|
1922
|
+
for (const line of formatUnknownWorkflowGuidance(err.message, err.candidates)) {
|
|
1923
|
+
process.stderr.write(`bb-wait: ${line}\n`);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1896
1926
|
process.stderr.write(`bb-wait: invalid condition (${err.errorCode}) — ${err.message}\n`);
|
|
1897
|
-
emitReceipt({
|
|
1927
|
+
emitReceipt({
|
|
1928
|
+
schema_version: 1,
|
|
1929
|
+
outcome: "error",
|
|
1930
|
+
error: err.errorCode,
|
|
1931
|
+
detail: String(err.message),
|
|
1932
|
+
...(Array.isArray(err.candidates) && err.candidates.length > 0 ? { candidates: err.candidates } : {}),
|
|
1933
|
+
});
|
|
1898
1934
|
process.exit(EXIT.INVALID);
|
|
1899
1935
|
}
|
|
1900
1936
|
if (err && err.initialEval) {
|
|
@@ -1959,6 +1995,9 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1959
1995
|
feedLagProbe,
|
|
1960
1996
|
receiptMaxBytes: opts.receiptMaxBytes,
|
|
1961
1997
|
startedAt: checkpoint.created_at,
|
|
1998
|
+
// BOT-1837: a timeout receipt carries the arm-time CI notice, so a caller
|
|
1999
|
+
// reading only the receipt learns the run it meant had already finished.
|
|
2000
|
+
timeoutError: ciArmNotice,
|
|
1962
2001
|
// BOT-1259: fail-closed tenant scope for the central guard, plus a debug sink
|
|
1963
2002
|
// (opt-in via BB_WAIT_DEBUG) that surfaces each fail-closed drop without turning
|
|
1964
2003
|
// it into an error.
|