@botbuddy/cli 1.2.3 → 1.4.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/bin/botbuddy.mjs +5 -1
- package/package.json +1 -1
- package/src/agent-credential-store.mjs +208 -0
- package/src/api.mjs +39 -0
- package/src/auth.mjs +169 -70
- package/src/auth.test.mjs +404 -0
- package/src/codex-bridge.mjs +2 -1
- package/src/commands.mjs +206 -30
- package/src/config.mjs +5 -1
- package/src/discovery.mjs +141 -0
- package/src/discovery.test.mjs +195 -0
- package/src/locks.mjs +154 -0
- package/src/locks.test.mjs +60 -0
- package/src/oauth-loopback.mjs +228 -0
- package/src/profile-bootstrap.mjs +104 -0
- package/src/profile-bootstrap.test.mjs +205 -0
- package/src/publish-equal.mjs +207 -0
- package/src/publish-equal.test.mjs +176 -0
- package/src/publish-workflow.test.mjs +122 -0
- package/src/quiet-runner.mjs +134 -0
- package/src/quiet-runner.test.mjs +109 -0
- package/src/run.mjs +239 -0
- package/src/run.test.mjs +173 -0
- package/src/stack.mjs +572 -0
- package/src/stack.test.mjs +196 -0
- package/src/wait-core.mjs +1266 -0
- package/src/wait-profile.mjs +84 -0
- package/src/wait-profile.test.mjs +30 -0
- package/src/wait.mjs +727 -0
- package/src/wait.test.mjs +266 -0
package/src/wait.mjs
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// BOT-989 / BOT-1344 — `botbuddy wait`: the public aggregated agent wait interface.
|
|
3
|
+
//
|
|
4
|
+
// Park on a set of conditions (a lock coming free, a chat reply, a build
|
|
5
|
+
// finishing, a plain deadline) and wake EXACTLY ONCE with one machine-readable
|
|
6
|
+
// JSON receipt on stdout. Zero polling, zero tokens spent while waiting: the
|
|
7
|
+
// process blocks on a pushed SSE stream and the agent harness (Claude Code
|
|
8
|
+
// `run_in_background`, Codex background exec) is re-invoked when it exits.
|
|
9
|
+
//
|
|
10
|
+
// Dependency-free (Node >= 20 stdlib only). All wait semantics live in
|
|
11
|
+
// ./wait-core.mjs; this file is just argv + a real `connect()` + I/O.
|
|
12
|
+
//
|
|
13
|
+
// Usage: botbuddy wait [--any] <condition>... [options]
|
|
14
|
+
// Run botbuddy wait --help for the condition grammar.
|
|
15
|
+
|
|
16
|
+
import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt } from "./wait-core.mjs";
|
|
17
|
+
import { resolveAgentProfile, withPrincipalReceipt } from "./wait-profile.mjs";
|
|
18
|
+
import { VERSION } from "./version.mjs";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
// A protocol is deliberately distinct from package semver: compatible pinned
|
|
22
|
+
// clients keep working until the server raises this minimum, while a stale
|
|
23
|
+
// implementation gets a typed, safe upgrade instruction.
|
|
24
|
+
export const WAIT_PROTOCOL_VERSION = 1;
|
|
25
|
+
const CLI_UPGRADE_COMMAND = "npx --yes @botbuddy/cli@latest wait";
|
|
26
|
+
const MIN_RECEIPT_MAX_BYTES = 512;
|
|
27
|
+
|
|
28
|
+
const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
|
|
29
|
+
|
|
30
|
+
USAGE
|
|
31
|
+
botbuddy wait [--any] <condition>... [options]
|
|
32
|
+
|
|
33
|
+
CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the first)
|
|
34
|
+
timer:duration=<seconds> wake after N seconds (DB-clocked server-side)
|
|
35
|
+
timer:deadline=<ISO-8601> wake at an absolute time
|
|
36
|
+
chat:channel=<id>|* new Direct Chat message (or * = any channel you are in;
|
|
37
|
+
you are never woken by your own messages)
|
|
38
|
+
lock:subtype=<t>,host=<h>[,slot=<s>][,claim=true]
|
|
39
|
+
a resource lock becomes available
|
|
40
|
+
subtype ∈ playwright_lane|vite_port|backend_port|supabase_local
|
|
41
|
+
claim=true (needs slot=): ATOMIC claim-on-grant — queue for the
|
|
42
|
+
lock (acquire_lock while it's held), then wait; the server hands
|
|
43
|
+
you the lock and wakes you already HOLDING it (no acquire race).
|
|
44
|
+
Must be the ONLY condition (use --timeout to bound it); can't be
|
|
45
|
+
raced against other conditions. Omit claim to just wait for
|
|
46
|
+
availability and acquire it yourself.
|
|
47
|
+
pr-state:repo=<owner/repo>[,pr=<n>] a PR state transition (open/draft/merged/closed,
|
|
48
|
+
checks, review decision, unresolved threads, mergeable).
|
|
49
|
+
Omit pr= to wake on ANY of your PRs in the repo;
|
|
50
|
+
or pass pr=<owner/repo#n> in one value. A conflict/clean
|
|
51
|
+
flip sets payload.mergeable_changed (no new condition).
|
|
52
|
+
pr-review:repo=<owner/repo>[,pr=<n>]
|
|
53
|
+
a new review comment, or a thread resolved/re-opened
|
|
54
|
+
(payload carries the unresolved-thread count)
|
|
55
|
+
test-run:id=<run_id> a test run reaching a terminal status (owner-scoped)
|
|
56
|
+
lease:id=<lease_id> a batch stack lease (BOT-1218) leaving the queue /
|
|
57
|
+
becoming active — the zero-poll wake for a parked
|
|
58
|
+
\`botbuddy stack up\` (recipient-scoped to the owner).
|
|
59
|
+
Level-triggered: an already-granted lease wakes at once.
|
|
60
|
+
guidance:request=<post_id> a command-post guidance request being answered
|
|
61
|
+
(scoped to the asking agent; fetch the body with
|
|
62
|
+
get_guidance_response)
|
|
63
|
+
linear:issue=<KEY> a BOT-* issue changing state, a new comment, or an
|
|
64
|
+
assignee change (e.g. issue=BOT-123; tenant-scoped).
|
|
65
|
+
Flags linear_feed_lagging if the ingest feed has stalled.
|
|
66
|
+
unblocked:<KEY> a ticket's LAST open blocker clearing (open-blocker
|
|
67
|
+
count >0 → 0): a blocker moving to Done/Canceled/Duplicate
|
|
68
|
+
or the final blocks relation removed. Level-triggered —
|
|
69
|
+
already-unblocked (or never-blocked) at registration
|
|
70
|
+
grants immediately (payload.reason=already_unblocked).
|
|
71
|
+
Tenant-scoped. Also accepts unblocked:ticket=<KEY>.
|
|
72
|
+
capacity:policy=<p>,slots>=<N>[,host=<h>][,stale_grace=<sec>]
|
|
73
|
+
a managed-container host reaching >= N free slots.
|
|
74
|
+
Host-shared; a silently-dead host is detected server-side
|
|
75
|
+
(capacity_source_stale) via a host beacon — the client
|
|
76
|
+
grace (default 900s) is a backstop, no longer the only guard.
|
|
77
|
+
ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>}
|
|
78
|
+
a PR's CI reaching a terminal conclusion (owner-scoped);
|
|
79
|
+
GitHub Actions or an external check_suite provider.
|
|
80
|
+
A repo with no CI feed is rejected (no_signal_source).
|
|
81
|
+
event:type=<signal_type>[,subject=<key>]
|
|
82
|
+
raw signal-spine match (escape hatch)
|
|
83
|
+
|
|
84
|
+
OPTIONS
|
|
85
|
+
--any wake on the first matching condition (default; only mode in v1)
|
|
86
|
+
--timeout <seconds> max wait (default 7200; hard cap 28800). Timeout is exit 2, not an error.
|
|
87
|
+
--since <seq> resume from a prior receipt's next_cursor (replay what you missed)
|
|
88
|
+
--receipt-max-bytes <n> cap the receipt (minimum 512; default 10240; payloads truncate to pointers)
|
|
89
|
+
--heartbeat keep this agent session alive while waiting (so it is not reaped)
|
|
90
|
+
--url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
|
|
91
|
+
--profile <name> tenant-bound machine profile (normally read from .botbuddy-agent.json)
|
|
92
|
+
--token <key> explicit agent key override; otherwise the profile-specific env is used
|
|
93
|
+
--help show this help
|
|
94
|
+
|
|
95
|
+
OUTPUT
|
|
96
|
+
Exactly one JSON receipt line on stdout at exit, with client semver and
|
|
97
|
+
protocol identity. Diagnostics go to stderr.
|
|
98
|
+
Exit codes: 0 matched · 2 timeout · 3 auth · 4 invalid conditions ·
|
|
99
|
+
5 backend unavailable · 6 cursor expired · 7 internal ·
|
|
100
|
+
8 superseded (wait replaced server-side). If the receipt has
|
|
101
|
+
reacquire_lock (claim=true), acquire_lock again FIRST and re-run
|
|
102
|
+
only on a queued/busy result (granted = you hold it — just resume);
|
|
103
|
+
otherwise re-run from the receipt's next_cursor. docs/agent-wait.md.
|
|
104
|
+
|
|
105
|
+
EXAMPLES
|
|
106
|
+
# wait for a Playwright lane to free up, then acquire it yourself
|
|
107
|
+
botbuddy wait 'lock:subtype=playwright_lane,host=Jonos-MBP' --timeout 1800
|
|
108
|
+
|
|
109
|
+
# queue for a specific lane and be HANDED it when it frees (no acquire race):
|
|
110
|
+
# acquire_lock (gets queued) → bb-wait claim=true → wake already holding lane 3
|
|
111
|
+
botbuddy wait 'lock:subtype=playwright_lane,host=Jonos-MBP,slot=3,claim=true' --timeout 1800 --heartbeat
|
|
112
|
+
|
|
113
|
+
# wait for a chat reply on any channel, or 20 minutes, whichever first
|
|
114
|
+
botbuddy wait --any 'chat:channel=*' 'timer:duration=1200' --heartbeat
|
|
115
|
+
|
|
116
|
+
# after pushing review fixes, wait for the next review comment or a state change
|
|
117
|
+
botbuddy wait --any 'pr-review:repo=bot-buddy/botbuddy-webapp,pr=425' \\
|
|
118
|
+
'pr-state:repo=bot-buddy/botbuddy-webapp,pr=425' --timeout 3600
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
function parseArgv(argv) {
|
|
122
|
+
const opts = {
|
|
123
|
+
conditions: [],
|
|
124
|
+
mode: "any",
|
|
125
|
+
timeout: 7200,
|
|
126
|
+
since: null,
|
|
127
|
+
receiptMaxBytes: 10240,
|
|
128
|
+
heartbeat: false,
|
|
129
|
+
url: process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1",
|
|
130
|
+
token: null,
|
|
131
|
+
profile: null,
|
|
132
|
+
help: false,
|
|
133
|
+
};
|
|
134
|
+
for (let i = 0; i < argv.length; i++) {
|
|
135
|
+
const a = argv[i];
|
|
136
|
+
const optionValue = () => {
|
|
137
|
+
const value = argv[i + 1];
|
|
138
|
+
if (value === undefined || value.startsWith("--")) {
|
|
139
|
+
opts.missing ??= a;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
i += 1;
|
|
143
|
+
return value;
|
|
144
|
+
};
|
|
145
|
+
if (a === "--help" || a === "-h") opts.help = true;
|
|
146
|
+
else if (a === "--any") opts.mode = "any";
|
|
147
|
+
else if (a === "--heartbeat") opts.heartbeat = true;
|
|
148
|
+
else if (a === "--timeout") opts.timeout = Number(optionValue());
|
|
149
|
+
else if (a === "--since") opts.since = optionValue();
|
|
150
|
+
else if (a === "--receipt-max-bytes") opts.receiptMaxBytes = Number(optionValue());
|
|
151
|
+
else if (a === "--url") opts.url = optionValue();
|
|
152
|
+
else if (a === "--token") opts.token = optionValue();
|
|
153
|
+
else if (a === "--profile") opts.profile = optionValue();
|
|
154
|
+
else if (a.startsWith("--")) opts.unknown = a;
|
|
155
|
+
else opts.conditions.push(a);
|
|
156
|
+
}
|
|
157
|
+
return opts;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Register a wait_session on the relay (BOT-989 M2). Returns
|
|
161
|
+
// {waitSessionId, cursorStart} on success. Profiled waits fail closed unless the
|
|
162
|
+
// relay authenticates and attests their machine principal.
|
|
163
|
+
async function registerWait(opts, conditions, deadlineIso) {
|
|
164
|
+
const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
Authorization: `Bearer ${opts.token}`,
|
|
168
|
+
"x-agent-api-key": opts.token || "",
|
|
169
|
+
"Content-Type": "application/json",
|
|
170
|
+
},
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
action: "register",
|
|
173
|
+
profile: opts.agentProfile.name,
|
|
174
|
+
expected_tenant: opts.agentProfile.tenant,
|
|
175
|
+
client_version: VERSION,
|
|
176
|
+
wait_protocol_version: WAIT_PROTOCOL_VERSION,
|
|
177
|
+
conditions,
|
|
178
|
+
deadline: deadlineIso,
|
|
179
|
+
mode: opts.mode,
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
if (res.status === 429) {
|
|
183
|
+
const body = await res.json().catch(() => ({}));
|
|
184
|
+
const err = new Error(body.detail || "wait_session_cap_exceeded");
|
|
185
|
+
err.cap = true;
|
|
186
|
+
err.errorCode = body.error || "wait_session_cap_exceeded";
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
if (res.status === 401) {
|
|
190
|
+
const body = await res.json().catch(() => ({}));
|
|
191
|
+
const err = new Error(body.detail || body.message || body.error || "unauthorized agent key");
|
|
192
|
+
err.auth = true;
|
|
193
|
+
err.errorCode = body.error || "unauthorized";
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
if (res.status === 400) {
|
|
197
|
+
// Only a CALLER-ACTIONABLE condition rejection is a hard stop — arming an untracked
|
|
198
|
+
// live-only wait can never recover it, and it would skip the server's initial
|
|
199
|
+
// evaluation (silently missing an already-satisfied condition). These are the
|
|
200
|
+
// conditions the server refuses by design: BOT-1066 `no_signal_source` (uncovered
|
|
201
|
+
// CI repo) and BOT-1247 `unblocked_cross_tenant` / `unblocked_requires_tenant`
|
|
202
|
+
// (an unblocked:<TICKET> a single tenant-scoped wait session can't serve).
|
|
203
|
+
// A TRANSIENT backend 400 (e.g. `register_failed` from the RPC) is NOT
|
|
204
|
+
// caller-actionable — fall through to the existing live-only fallback so a valid
|
|
205
|
+
// wait still arms (Codex round-7 P2).
|
|
206
|
+
const body = await res.json().catch(() => ({}));
|
|
207
|
+
const INVALID_CONDITION_CODES = new Set([
|
|
208
|
+
"no_signal_source",
|
|
209
|
+
"unblocked_cross_tenant",
|
|
210
|
+
"unblocked_requires_tenant",
|
|
211
|
+
"wait_tenant_unresolved",
|
|
212
|
+
"wait_cross_tenant",
|
|
213
|
+
"wait_tenant_mismatch",
|
|
214
|
+
// BOT-1259: a `linear` condition set spanning multiple workspaces can't bind one
|
|
215
|
+
// tenant; the server rejects it (register one wait per workspace) — a hard stop, not
|
|
216
|
+
// a transient error, so don't fall back to an untracked live-only wait.
|
|
217
|
+
"linear_cross_tenant",
|
|
218
|
+
// BOT-1259: a multi-workspace caller arming a workspace-scoped condition the server
|
|
219
|
+
// can't bind to one workspace (ci / pr-review / bare tenant-primary event). A hard
|
|
220
|
+
// stop — arming it untracked would only ever park to timeout.
|
|
221
|
+
"tenant_ambiguous",
|
|
222
|
+
]);
|
|
223
|
+
if (INVALID_CONDITION_CODES.has(body.error)) {
|
|
224
|
+
const err = new Error(body.detail || body.error);
|
|
225
|
+
err.invalidCondition = true;
|
|
226
|
+
err.errorCode = body.error;
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (res.status === 403) {
|
|
231
|
+
const body = await res.json().catch(() => ({}));
|
|
232
|
+
if (new Set(["wait_tenant_unresolved", "wait_cross_tenant", "wait_tenant_mismatch"]).has(body.error)) {
|
|
233
|
+
const err = new Error(body.detail || body.error);
|
|
234
|
+
err.invalidCondition = true;
|
|
235
|
+
err.errorCode = body.error;
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
const err = new Error(body.detail || body.message || body.error || "forbidden");
|
|
239
|
+
err.auth = true;
|
|
240
|
+
err.errorCode = body.error || "forbidden";
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
if (res.status === 426) {
|
|
244
|
+
const body = await res.json().catch(() => ({}));
|
|
245
|
+
const err = new Error(body.detail || "this wait client is no longer supported");
|
|
246
|
+
err.clientOutdated = true;
|
|
247
|
+
err.errorCode = body.error || "client_outdated";
|
|
248
|
+
err.upgradeCommand = body.upgrade_command || CLI_UPGRADE_COMMAND;
|
|
249
|
+
err.minimumProtocol = body.minimum_wait_protocol ?? null;
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
const body = await res.json().catch(() => ({}));
|
|
254
|
+
const err = new Error(body.detail || body.message || body.error || `register responded ${res.status}`);
|
|
255
|
+
if (body.error === "initial_eval_failed") {
|
|
256
|
+
err.initialEval = true;
|
|
257
|
+
err.errorCode = body.error;
|
|
258
|
+
err.detail = body.detail || "initial evaluation failed";
|
|
259
|
+
}
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
const body = await res.json();
|
|
263
|
+
if (body.profile !== opts.agentProfile.name || typeof body.agent_id !== "string" || !body.agent_id) {
|
|
264
|
+
const err = new Error("relay did not attest the tenant-bound agent profile");
|
|
265
|
+
err.auth = true;
|
|
266
|
+
err.errorCode = "profile_not_enforced";
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
if (body.session_tenant !== opts.agentProfile.tenant) {
|
|
270
|
+
const err = new Error(
|
|
271
|
+
`profile expects ${opts.agentProfile.tenant} but registration resolved ${body.session_tenant ?? "no tenant"}`,
|
|
272
|
+
);
|
|
273
|
+
err.auth = true;
|
|
274
|
+
err.errorCode = "profile_tenant_mismatch";
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
waitSessionId: body.wait_session_id ?? null,
|
|
279
|
+
cursorStart: body.cursor_start ?? null,
|
|
280
|
+
// BOT-1184: the server canonicalizes lock-condition hosts on register and
|
|
281
|
+
// echoes them here so the client matches the canonical subject_key.
|
|
282
|
+
conditions: Array.isArray(body.conditions) ? body.conditions : null,
|
|
283
|
+
// BOT-1259: the wait session's server-resolved tenant. The client threads this
|
|
284
|
+
// into the central tenant guard so a tenanted signal only wakes a session resolved
|
|
285
|
+
// to that same workspace. `session_tenant: null` is a genuinely tenantless session
|
|
286
|
+
// (matches only tenantless signals); an ABSENT field (a pre-BOT-1259 relay) collapses
|
|
287
|
+
// to null too — fail closed on tenanted signals until the wait re-arms against the
|
|
288
|
+
// new relay (the deploy-skew case, acceptable because waits are short-lived).
|
|
289
|
+
sessionTenant: body.session_tenant ?? null,
|
|
290
|
+
agentId: body.agent_id ?? null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Record the terminal outcome. Best-effort AND bounded: the receipt has already
|
|
295
|
+
// been written to stdout before this runs, so a slow/failing finalize must never
|
|
296
|
+
// delay the agent's wake or change the exit path. A short abort timeout caps how
|
|
297
|
+
// long we linger, and a non-2xx response is reported (not silently dropped) so a
|
|
298
|
+
// session left `active` shows up in diagnostics rather than as a phantom wait.
|
|
299
|
+
async function finalizeWait(opts, waitSessionId, receipt, timeoutMs = 5000) {
|
|
300
|
+
const ac = new AbortController();
|
|
301
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
302
|
+
try {
|
|
303
|
+
const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: {
|
|
306
|
+
Authorization: `Bearer ${opts.token}`,
|
|
307
|
+
"x-agent-api-key": opts.token || "",
|
|
308
|
+
"Content-Type": "application/json",
|
|
309
|
+
},
|
|
310
|
+
body: JSON.stringify({
|
|
311
|
+
action: "finalize",
|
|
312
|
+
wait_session_id: waitSessionId,
|
|
313
|
+
status: receipt.outcome,
|
|
314
|
+
receipt,
|
|
315
|
+
reconnects: receipt.reconnects ?? 0,
|
|
316
|
+
degraded: receipt.degraded ?? [],
|
|
317
|
+
}),
|
|
318
|
+
signal: ac.signal,
|
|
319
|
+
});
|
|
320
|
+
if (!res.ok) {
|
|
321
|
+
process.stderr.write(`bb-wait: finalize (non-fatal) returned ${res.status}\n`);
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
process.stderr.write(`bb-wait: finalize (non-fatal) failed: ${err && err.message || err}\n`);
|
|
325
|
+
} finally {
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A real SSE connection to the relay's spine tail. Returns an async iterable of
|
|
331
|
+
// parsed frames. Server emits `id: <seq>` per signal and replays `seq > since`
|
|
332
|
+
// on connect, so a reconnect never loses an event.
|
|
333
|
+
function makeConnect(opts) {
|
|
334
|
+
return async function connect(since) {
|
|
335
|
+
const sinceParam = normalizeSince(since);
|
|
336
|
+
const url = new URL(`${opts.url.replace(/\/$/, "")}/event-stream`);
|
|
337
|
+
url.searchParams.set("tables", "agent_signal_events");
|
|
338
|
+
if (sinceParam.kind === "ok") url.searchParams.set("since", sinceParam.value);
|
|
339
|
+
// Bind the SSE to the registered wait_session so the relay bumps its
|
|
340
|
+
// last_seen_at on connect + keepalive (abandoned detection).
|
|
341
|
+
if (opts.waitSessionId) url.searchParams.set("wait_session", opts.waitSessionId);
|
|
342
|
+
// --heartbeat rides the live SSE connection itself: event-stream bumps
|
|
343
|
+
// agents.last_heartbeat on connect and every keepalive when heartbeat=1, so
|
|
344
|
+
// the agent (and its locks) aren't reaped during a long wait.
|
|
345
|
+
if (opts.heartbeat) url.searchParams.set("heartbeat", "1");
|
|
346
|
+
|
|
347
|
+
const res = await fetch(url, {
|
|
348
|
+
headers: {
|
|
349
|
+
Authorization: `Bearer ${opts.token}`,
|
|
350
|
+
"x-agent-api-key": opts.token || "",
|
|
351
|
+
Accept: "text/event-stream",
|
|
352
|
+
// BOT-741: never let a proxy gzip-buffer an SSE stream.
|
|
353
|
+
"Accept-Encoding": "identity",
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
if (res.status === 401) return errorStream("unauthorized");
|
|
357
|
+
if (res.status === 403) return errorStream("forbidden");
|
|
358
|
+
if (!res.ok || !res.body) throw new Error(`relay responded ${res.status}`);
|
|
359
|
+
|
|
360
|
+
return sseFrameStream(res.body);
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// BOT-1147 AC-3: the real Linear-feed freshness probe. Asks the relay (which
|
|
365
|
+
// runs the global bb_wait_linear_feed_lagging RPC) whether the Linear ingest
|
|
366
|
+
// feed is stale. Any failure -> false (non-fatal: the wait keeps its last known
|
|
367
|
+
// degraded state rather than flapping on a probe blip).
|
|
368
|
+
const FEED_LAG_PROBE_TIMEOUT_MS = 10_000;
|
|
369
|
+
|
|
370
|
+
function makeFeedLagProbe(opts) {
|
|
371
|
+
// `issues` = the armed `linear` conditions' target ticket keys. The relay
|
|
372
|
+
// resolves them to their owning workspace(s) (within the caller's tenant scope)
|
|
373
|
+
// so freshness reflects the targeted tenant, not the caller's membership union
|
|
374
|
+
// (Codex round-22 P2). Omitted/empty -> the relay falls back to the caller's
|
|
375
|
+
// tenant scope, then to the global feed.
|
|
376
|
+
return async function feedLagProbe(issues = []) {
|
|
377
|
+
// Return null (INDETERMINATE) on any failure — a non-2xx, invalid JSON, a
|
|
378
|
+
// transport error, or the relay's own {lagging:false, probe_error:true} RPC-error
|
|
379
|
+
// response — so foldFeedLag leaves the last known lag state untouched. Returning
|
|
380
|
+
// false here would clear a known linear_feed_lagging marker on a transient outage
|
|
381
|
+
// and make a stale feed look healthy right before a terminal receipt (Codex P2).
|
|
382
|
+
//
|
|
383
|
+
// BOUNDED (Codex P2): the wait loop awaits this before connecting AND on every
|
|
384
|
+
// terminal finalize, OUTSIDE the deadline race, so an unbounded probe that accepts
|
|
385
|
+
// the connection but stalls before responding would hang even an already-matched
|
|
386
|
+
// signal or an expired --timeout forever. A 10s abort caps it; expiry is treated as
|
|
387
|
+
// the same indeterminate result (leave the last known lag state).
|
|
388
|
+
const ac = new AbortController();
|
|
389
|
+
const timer = setTimeout(() => ac.abort(), FEED_LAG_PROBE_TIMEOUT_MS);
|
|
390
|
+
try {
|
|
391
|
+
const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
headers: {
|
|
394
|
+
Authorization: `Bearer ${opts.token}`,
|
|
395
|
+
"x-agent-api-key": opts.token || "",
|
|
396
|
+
"Content-Type": "application/json",
|
|
397
|
+
},
|
|
398
|
+
body: JSON.stringify({
|
|
399
|
+
action: "linear_feed_status",
|
|
400
|
+
...(Array.isArray(issues) && issues.length > 0 ? { issues } : {}),
|
|
401
|
+
}),
|
|
402
|
+
signal: ac.signal,
|
|
403
|
+
});
|
|
404
|
+
if (!res.ok) return null;
|
|
405
|
+
const body = await res.json();
|
|
406
|
+
if (body?.probe_error) return null;
|
|
407
|
+
return body?.lagging === true;
|
|
408
|
+
} catch {
|
|
409
|
+
return null; // transport error OR AbortError on the 10s timeout → indeterminate
|
|
410
|
+
} finally {
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function* sseFrameStream(body) {
|
|
417
|
+
const decoder = new TextDecoder();
|
|
418
|
+
let buf = "";
|
|
419
|
+
for await (const chunk of body) {
|
|
420
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
421
|
+
const { frames, rest } = parseSseFrames(buf);
|
|
422
|
+
buf = rest;
|
|
423
|
+
for (const f of frames) yield f;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function errorStream(error) {
|
|
428
|
+
return {
|
|
429
|
+
async *[Symbol.asyncIterator]() {
|
|
430
|
+
yield { id: "", event: "error", data: JSON.stringify({ error }) };
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// timer-only waits need no network; return a stream that never yields so the
|
|
436
|
+
// deadline is the only terminal.
|
|
437
|
+
function offlineStream() {
|
|
438
|
+
return { async *[Symbol.asyncIterator]() { await new Promise(() => {}); } };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function withClientIdentity(receipt) {
|
|
442
|
+
return {
|
|
443
|
+
...receipt,
|
|
444
|
+
client: { package: "@botbuddy/cli", version: VERSION, wait_protocol: WAIT_PROTOCOL_VERSION },
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function emit(receipt, { versioned = false, maxBytes = null } = {}) {
|
|
449
|
+
const enriched = versioned ? receipt : withClientIdentity(receipt);
|
|
450
|
+
const bounded = Number.isSafeInteger(maxBytes) && maxBytes >= MIN_RECEIPT_MAX_BYTES
|
|
451
|
+
? truncateReceipt(enriched, maxBytes)
|
|
452
|
+
: enriched;
|
|
453
|
+
process.stdout.write(JSON.stringify(bounded) + "\n");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function profileRecovery(profile) {
|
|
457
|
+
return `npx --yes @botbuddy/cli@latest profile setup ${profile.name}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function typedProfileError(errorCode) {
|
|
461
|
+
switch (String(errorCode ?? "").toLowerCase()) {
|
|
462
|
+
case "agent_key_required": return "profile_agent_required";
|
|
463
|
+
case "agent_tenant_required": return "profile_credential_unbound";
|
|
464
|
+
case "profile_tenant_mismatch": return "profile_credential_wrong_tenant";
|
|
465
|
+
case "unauthorized": return "profile_credential_revoked";
|
|
466
|
+
default: return errorCode;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function profileErrorReceipt(profile, error) {
|
|
471
|
+
return withPrincipalReceipt({
|
|
472
|
+
schema_version: 1,
|
|
473
|
+
outcome: "error",
|
|
474
|
+
error,
|
|
475
|
+
recovery: profileRecovery(profile),
|
|
476
|
+
}, profile, { sessionTenant: profile.tenant, agentId: null });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function runWait(argv) {
|
|
480
|
+
const opts = parseArgv(argv);
|
|
481
|
+
const emitReceipt = (receipt, options = {}) => emit(receipt, {
|
|
482
|
+
...options,
|
|
483
|
+
maxBytes: opts.receiptMaxBytes,
|
|
484
|
+
});
|
|
485
|
+
if (opts.help) {
|
|
486
|
+
process.stdout.write(HELP);
|
|
487
|
+
process.exit(0);
|
|
488
|
+
}
|
|
489
|
+
if (opts.unknown) {
|
|
490
|
+
process.stderr.write(`bb-wait: unknown option ${opts.unknown}\n`);
|
|
491
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.unknown });
|
|
492
|
+
process.exit(EXIT.INVALID);
|
|
493
|
+
}
|
|
494
|
+
if (opts.missing) {
|
|
495
|
+
process.stderr.write(`bb-wait: option ${opts.missing} requires a value\n`);
|
|
496
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.missing });
|
|
497
|
+
process.exit(EXIT.INVALID);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const { conditions, errors } = parseConditions(opts.conditions);
|
|
501
|
+
if (errors.length > 0) {
|
|
502
|
+
for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
|
|
503
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors });
|
|
504
|
+
process.exit(EXIT.INVALID);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (!Number.isFinite(opts.timeout) || opts.timeout <= 0) {
|
|
508
|
+
process.stderr.write("bb-wait: --timeout must be a positive number of seconds\n");
|
|
509
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_timeout" });
|
|
510
|
+
process.exit(EXIT.INVALID);
|
|
511
|
+
}
|
|
512
|
+
if (!Number.isSafeInteger(opts.receiptMaxBytes) || opts.receiptMaxBytes < MIN_RECEIPT_MAX_BYTES) {
|
|
513
|
+
process.stderr.write(`bb-wait: --receipt-max-bytes must be an integer of at least ${MIN_RECEIPT_MAX_BYTES}\n`);
|
|
514
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_receipt_max_bytes", minimum: MIN_RECEIPT_MAX_BYTES });
|
|
515
|
+
process.exit(EXIT.INVALID);
|
|
516
|
+
}
|
|
517
|
+
const timeoutSec = Math.min(opts.timeout, 28800); // 8h hard cap (session-budget gate)
|
|
518
|
+
const deadlineMs = Date.now() + timeoutSec * 1000;
|
|
519
|
+
|
|
520
|
+
const needsRelay = conditions.some((c) => c.type !== "timer");
|
|
521
|
+
if (needsRelay) {
|
|
522
|
+
try {
|
|
523
|
+
opts.agentProfile = await resolveAgentProfile({
|
|
524
|
+
explicitProfile: opts.profile,
|
|
525
|
+
explicitToken: opts.token,
|
|
526
|
+
});
|
|
527
|
+
} catch (err) {
|
|
528
|
+
process.stderr.write(`bb-wait: ${err.message}\n`);
|
|
529
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: err.code || "invalid_profile" });
|
|
530
|
+
process.exit(EXIT.INVALID);
|
|
531
|
+
}
|
|
532
|
+
opts.token = opts.agentProfile.token;
|
|
533
|
+
if (!opts.token) {
|
|
534
|
+
process.stderr.write(
|
|
535
|
+
`botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run '${profileRecovery(opts.agentProfile)}'\n`,
|
|
536
|
+
);
|
|
537
|
+
emitReceipt(profileErrorReceipt(opts.agentProfile, "profile_required"));
|
|
538
|
+
process.exit(EXIT.AUTH);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// A claim=true wait is meaningless without a tracked registration: the server
|
|
543
|
+
// only grants a lock to an agent with an ACTIVE matching wait_session, and the
|
|
544
|
+
// client only accepts a grant whose wait_session_id equals THIS registration's.
|
|
545
|
+
// So a null id could never match a grant — it could only time out — while the
|
|
546
|
+
// server may have registered and granted. Claim waits therefore fail CLOSED on
|
|
547
|
+
// any registration failure rather than using the untracked-wait fallback below.
|
|
548
|
+
const hasClaim = conditions.some((c) => c.type === "lock" && c.params.claim);
|
|
549
|
+
|
|
550
|
+
// Register the wait_session before connecting (BOT-989 M2). This records the
|
|
551
|
+
// wait for telemetry/abandoned-detection and returns the spine high-water mark
|
|
552
|
+
// to arm `since` from, closing the register-after-emit race. A cap rejection is
|
|
553
|
+
// a hard stop; for a non-claim wait, any other registration failure degrades to
|
|
554
|
+
// an untracked (but still functional) wait. Timer-only waits never touch the relay.
|
|
555
|
+
let waitSessionId = null;
|
|
556
|
+
let effectiveSince = opts.since;
|
|
557
|
+
// BOT-1259: the server-resolved tenant for this wait session, threaded into the
|
|
558
|
+
// central tenant guard (runWaitLoop). Undefined until a successful register echo;
|
|
559
|
+
// an untracked live-only fallback (no register) leaves the guard OFF, matching its
|
|
560
|
+
// pre-BOT-1259 behaviour for a wait the server never scoped.
|
|
561
|
+
let sessionTenant;
|
|
562
|
+
let registeredAgentId = null;
|
|
563
|
+
if (needsRelay) {
|
|
564
|
+
try {
|
|
565
|
+
const reg = await registerWait(opts, conditions, new Date(deadlineMs).toISOString());
|
|
566
|
+
waitSessionId = reg.waitSessionId;
|
|
567
|
+
opts.waitSessionId = waitSessionId;
|
|
568
|
+
sessionTenant = reg.sessionTenant;
|
|
569
|
+
registeredAgentId = reg.agentId;
|
|
570
|
+
// BOT-1184: adopt the server's canonical host for each lock condition so the
|
|
571
|
+
// local matcher builds the same subject_key the availability/claim-grant
|
|
572
|
+
// signals carry (armed under an alias like 'jono-mac', the signal uses the
|
|
573
|
+
// canonical 'jonos-mbp'). Patch host by condition id — preserving every
|
|
574
|
+
// other client-side param type — rather than wholesale-replacing the parsed
|
|
575
|
+
// conditions. Only 'lock' conditions are canonicalized server-side.
|
|
576
|
+
if (Array.isArray(reg.conditions)) {
|
|
577
|
+
const canonHost = new Map();
|
|
578
|
+
// BOT-1247: the server stamps the resolved workspace (round-9 P1) and the stable
|
|
579
|
+
// Linear issue UUID (round-10 P2) onto each `unblocked` condition; carry both onto
|
|
580
|
+
// the local condition so the matcher can require the signal's tenant to match (no
|
|
581
|
+
// waking on another workspace's identically-keyed unblock) and key off the
|
|
582
|
+
// immutable issue id (surviving an external_id rename mid-wait).
|
|
583
|
+
const unblockedScope = new Map();
|
|
584
|
+
// BOT-1260: the server stamps the stable Linear issue UUID onto each `linear`
|
|
585
|
+
// condition whose key is mirrored in `tickets`; carry it onto the local condition so
|
|
586
|
+
// the matcher keys off the immutable id (surviving an external_id rename mid-wait,
|
|
587
|
+
// and refusing a recycled key). No `tenant` is stamped for `linear` — its cross-tenant
|
|
588
|
+
// isolation rides on the central guard's sessionTenant (BOT-1259), not a per-type param.
|
|
589
|
+
const linearScope = new Map();
|
|
590
|
+
for (const rc of reg.conditions) {
|
|
591
|
+
if (rc && rc.type === "lock" && rc.params && typeof rc.params.host === "string") {
|
|
592
|
+
canonHost.set(rc.id, rc.params.host);
|
|
593
|
+
}
|
|
594
|
+
if (rc && rc.type === "unblocked" && rc.params && typeof rc.params.tenant === "string") {
|
|
595
|
+
unblockedScope.set(rc.id, { tenant: rc.params.tenant, linear_issue_id: rc.params.linear_issue_id ?? null });
|
|
596
|
+
}
|
|
597
|
+
if (rc && rc.type === "linear" && rc.params && typeof rc.params.linear_issue_id === "string") {
|
|
598
|
+
linearScope.set(rc.id, rc.params.linear_issue_id);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
for (const c of conditions) {
|
|
602
|
+
if (c.type === "lock" && canonHost.has(c.id)) c.params.host = canonHost.get(c.id);
|
|
603
|
+
if (c.type === "unblocked" && unblockedScope.has(c.id)) {
|
|
604
|
+
const s = unblockedScope.get(c.id);
|
|
605
|
+
c.params.tenant = s.tenant;
|
|
606
|
+
if (s.linear_issue_id) c.params.linear_issue_id = s.linear_issue_id;
|
|
607
|
+
}
|
|
608
|
+
if (c.type === "linear" && linearScope.has(c.id)) c.params.linear_issue_id = linearScope.get(c.id);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Arm from the registration high-water mark unless the caller pinned an
|
|
612
|
+
// explicit --since (resume). cursor_start replays the register→connect gap.
|
|
613
|
+
if (opts.since == null && reg.cursorStart != null) effectiveSince = String(reg.cursorStart);
|
|
614
|
+
if (hasClaim && waitSessionId == null) {
|
|
615
|
+
process.stderr.write("bb-wait: claim=true registration returned no wait_session_id; refusing to arm an unmatchable claim wait\n");
|
|
616
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
617
|
+
process.exit(EXIT.INTERNAL);
|
|
618
|
+
}
|
|
619
|
+
} catch (err) {
|
|
620
|
+
if (err && err.clientOutdated) {
|
|
621
|
+
process.stderr.write(`botbuddy wait: client protocol is no longer supported — upgrade with: ${err.upgradeCommand}\n`);
|
|
622
|
+
emitReceipt({
|
|
623
|
+
schema_version: 1,
|
|
624
|
+
outcome: "error",
|
|
625
|
+
error: err.errorCode,
|
|
626
|
+
detail: String(err.message),
|
|
627
|
+
minimum_wait_protocol: err.minimumProtocol,
|
|
628
|
+
upgrade_command: err.upgradeCommand,
|
|
629
|
+
});
|
|
630
|
+
process.exit(EXIT.INVALID);
|
|
631
|
+
}
|
|
632
|
+
if (err && err.auth) {
|
|
633
|
+
const error = typedProfileError(err.errorCode);
|
|
634
|
+
process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run '${profileRecovery(opts.agentProfile)}'\n`);
|
|
635
|
+
emitReceipt(profileErrorReceipt(opts.agentProfile, error));
|
|
636
|
+
process.exit(EXIT.AUTH);
|
|
637
|
+
}
|
|
638
|
+
if (err && err.cap) {
|
|
639
|
+
process.stderr.write(`bb-wait: too many active waits for this agent — ${err.message}\n`);
|
|
640
|
+
emitReceipt({
|
|
641
|
+
schema_version: 1,
|
|
642
|
+
outcome: "error",
|
|
643
|
+
error: err.errorCode || "wait_session_cap_exceeded",
|
|
644
|
+
detail: String(err.message),
|
|
645
|
+
});
|
|
646
|
+
process.exit(EXIT.INVALID);
|
|
647
|
+
}
|
|
648
|
+
if (err && err.invalidCondition) {
|
|
649
|
+
// A rejected condition set is a configuration error, not a wait — fail closed
|
|
650
|
+
// rather than arming an untracked wait that skips the server's initial
|
|
651
|
+
// evaluation (BOT-1066 no_signal_source, BOT-1247 unblocked_cross_tenant, …).
|
|
652
|
+
process.stderr.write(`bb-wait: invalid condition (${err.errorCode}) — ${err.message}\n`);
|
|
653
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.message) });
|
|
654
|
+
process.exit(EXIT.INVALID);
|
|
655
|
+
}
|
|
656
|
+
if (err && err.initialEval) {
|
|
657
|
+
process.stderr.write(`bb-wait: initial wait evaluation failed (${err.errorCode}) — ${err.detail}\n`);
|
|
658
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.detail) });
|
|
659
|
+
process.exit(EXIT.INTERNAL);
|
|
660
|
+
}
|
|
661
|
+
if (hasClaim) {
|
|
662
|
+
process.stderr.write(`bb-wait: claim=true wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
|
|
663
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
664
|
+
process.exit(EXIT.INTERNAL);
|
|
665
|
+
}
|
|
666
|
+
// BOT-1338: every non-timer wait is now a tenant-bound machine profile. If
|
|
667
|
+
// registration did not succeed, neither the server nor this client has verified
|
|
668
|
+
// that profile's tenant, so there is no safe untracked fallback for ANY condition.
|
|
669
|
+
process.stderr.write(`bb-wait: profiled wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
|
|
670
|
+
emitReceipt(withPrincipalReceipt(
|
|
671
|
+
{ schema_version: 1, outcome: "error", error: "register_failed", detail: String(err && err.message || err) },
|
|
672
|
+
opts.agentProfile,
|
|
673
|
+
{ sessionTenant: opts.agentProfile.tenant, agentId: null },
|
|
674
|
+
));
|
|
675
|
+
process.exit(EXIT.INTERNAL);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const connect = needsRelay ? makeConnect(opts) : async () => offlineStream();
|
|
680
|
+
// BOT-1147 AC-3: wire the feed-freshness probe only when a linear condition is
|
|
681
|
+
// armed against the relay (runWaitLoop no-ops the probe for other wait types).
|
|
682
|
+
const feedLagProbe = needsRelay && conditions.some((c) => c.type === "linear")
|
|
683
|
+
? makeFeedLagProbe(opts)
|
|
684
|
+
: null;
|
|
685
|
+
|
|
686
|
+
try {
|
|
687
|
+
const { receipt, exitCode } = await runWaitLoop({
|
|
688
|
+
waitSessionId,
|
|
689
|
+
conditions,
|
|
690
|
+
deadlineMs,
|
|
691
|
+
since: effectiveSince,
|
|
692
|
+
connect,
|
|
693
|
+
feedLagProbe,
|
|
694
|
+
receiptMaxBytes: opts.receiptMaxBytes,
|
|
695
|
+
// BOT-1259: fail-closed tenant scope for the central guard, plus a debug sink
|
|
696
|
+
// (opt-in via BB_WAIT_DEBUG) that surfaces each fail-closed drop without turning
|
|
697
|
+
// it into an error.
|
|
698
|
+
sessionTenant,
|
|
699
|
+
debug: process.env.BB_WAIT_DEBUG ? (msg) => process.stderr.write(`${msg}\n`) : null,
|
|
700
|
+
});
|
|
701
|
+
// Emit the wake receipt FIRST — it is the terminal result the harness reads.
|
|
702
|
+
// Finalization is best-effort telemetry and must never gate or delay it.
|
|
703
|
+
const terminalReceipt = truncateReceipt(
|
|
704
|
+
withClientIdentity(withPrincipalReceipt(receipt, opts.agentProfile, {
|
|
705
|
+
sessionTenant,
|
|
706
|
+
agentId: registeredAgentId,
|
|
707
|
+
})),
|
|
708
|
+
opts.receiptMaxBytes,
|
|
709
|
+
);
|
|
710
|
+
emitReceipt(terminalReceipt, { versioned: true });
|
|
711
|
+
// BOT-1228: a `superseded` outcome means an external actor already terminalized
|
|
712
|
+
// this wait_session server-side (reconcile_waits_to_canonical → abandoned, with
|
|
713
|
+
// ended_at/updated_at stamped). Skip the client finalize: the row is already
|
|
714
|
+
// terminal (finalize_wait_session only transitions from active, so it'd be a
|
|
715
|
+
// no-op) and `superseded` isn't a wait_session_status enum value anyway.
|
|
716
|
+
if (waitSessionId && receipt.outcome !== "superseded") await finalizeWait(opts, waitSessionId, terminalReceipt);
|
|
717
|
+
process.exit(exitCode);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
process.stderr.write(`bb-wait: ${err && err.stack || err}\n`);
|
|
720
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "internal" });
|
|
721
|
+
process.exit(EXIT.INTERNAL);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
726
|
+
runWait(process.argv.slice(2));
|
|
727
|
+
}
|