@botbuddy/cli 1.47.0 → 1.48.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.47.0",
3
+ "version": "1.48.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -353,7 +353,7 @@ export async function resolveCallAuth({
353
353
  if (typeof session.token !== "string" || !AGENT_KEY_RE.test(session.token)) {
354
354
  return { error: { ok: false, auth: true, error: "session_token_malformed", source: session.source ?? null } };
355
355
  }
356
- return { auth: { "x-agent-api-key": session.token }, source: session.source ?? null };
356
+ return { auth: { "x-agent-api-key": session.token }, source: session.source ?? null, sessionToken: true };
357
357
  }
358
358
  }
359
359
  const owner = await resolveOwner();
@@ -392,12 +392,20 @@ function toolEndpoint(tenant) {
392
392
  return url.toString();
393
393
  }
394
394
 
395
- export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null, resolvePin = resolveDefaultTenantPin } = {}) {
395
+ export async function callToolJson(toolName, args = {}, {
396
+ fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null, resolvePin = resolveDefaultTenantPin,
397
+ requireSessionToken = false, resolveAuth = resolveCallAuth,
398
+ } = {}) {
396
399
  let auth = pinnedAuth;
400
+ let sessionTokenSelected = false;
397
401
  if (!auth) {
398
- const resolved = await resolveCallAuth();
402
+ const resolved = await resolveAuth({ toolName, preferSession: requireSessionToken });
399
403
  if (resolved.error) return resolved.error;
400
404
  auth = resolved.auth;
405
+ sessionTokenSelected = resolved.sessionToken === true;
406
+ }
407
+ if (requireSessionToken && !sessionTokenSelected) {
408
+ return { ok: false, auth: true, error: "session_token_required" };
401
409
  }
402
410
  // BOT-1571 (Codex P1, round 2): `botbuddy stack` and every other
403
411
  // callToolJson caller that passes no explicit tenant would send a USER token
package/src/commands.mjs CHANGED
@@ -13,6 +13,7 @@ import { agentProfileKeyLabel } from "./credential-kinds.mjs";
13
13
  import { buildAcquireResourcesPayload, LocksUsageError } from "./locks.mjs";
14
14
  import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from "./discovery.mjs";
15
15
  import { cmdStack } from "./stack.mjs";
16
+ import { cmdManagedAllocation } from "./managed-allocation.mjs";
16
17
  import { cmdDocker } from "./docker-hygiene.mjs";
17
18
  import { cmdRun } from "./run.mjs";
18
19
  import { cmdTest } from "./test-lane.mjs";
@@ -59,6 +60,7 @@ export async function run(argv, {
59
60
  warnStale = maybeWarnStale,
60
61
  errorLog = (line) => console.error(line),
61
62
  bootstrap = cmdBootstrap,
63
+ managedAllocation = cmdManagedAllocation,
62
64
  } = {}) {
63
65
  const [command, ...args] = argv;
64
66
  // BOT-2069: `bootstrap close` can retire a live server session. Parse its
@@ -89,6 +91,7 @@ export async function run(argv, {
89
91
  case "unlock": return cmdUnlock(args);
90
92
  // BOT-1220: batch-scoped local stack leases (request/park/status/touch/done).
91
93
  case "stack": return cmdStack(args);
94
+ case "allocation": return managedAllocation(args);
92
95
  case "docker": return cmdDocker(args);
93
96
  case "run": return cmdRun(args);
94
97
  case "telemetry": return cmdTelemetry(args);
@@ -209,6 +212,7 @@ ${bold("STACK LEASES")}
209
212
  stack touch <lease_id> Bump the lease idle clock
210
213
  stack done <lease_id> Release (done) a lease — reap the stack
211
214
  stack help Full stack usage + exit codes
215
+ allocation status <id> <generation> <target_revision> Verify managed placement before Compose
212
216
 
213
217
  ${bold("DOCKER HYGIENE")}
214
218
  docker hygiene [options] Dry-run/apply safe stale Supabase cleanup on OrbStack
@@ -0,0 +1,77 @@
1
+ import { callToolJson, CALL_EXIT } from "./api.mjs";
2
+ import { readMachineUuid } from "./machine-id.mjs";
3
+
4
+ export function parseManagedAllocationArgs(args) {
5
+ if (args.length === 1 && ["help", "--help", "-h"].includes(args[0])) return { help: true };
6
+ if (args.length !== 4 || args[0] !== "status") {
7
+ return { error: "Usage: bb allocation status <allocation_uuid> <generation> <target_revision>" };
8
+ }
9
+ const [, id, rawGeneration, rawRevision] = args;
10
+ const uuid = String(id).trim().toLowerCase();
11
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid)) {
12
+ return { error: "allocation id must be a canonical UUID" };
13
+ }
14
+ const generation = Number(rawGeneration);
15
+ const targetRevision = Number(rawRevision);
16
+ if (!Number.isSafeInteger(generation) || generation < 1 || !Number.isSafeInteger(targetRevision) || targetRevision < 1) {
17
+ return { error: "generation and target_revision must be positive integers" };
18
+ }
19
+ return { command: "status", allocationId: uuid, generation, targetRevision };
20
+ }
21
+
22
+ export function evaluateLocalStartEligibility(receipt, localMachineUuid) {
23
+ const server = receipt?.server_start_eligibility;
24
+ if (server?.eligible !== true) return { eligible: false, reason: server?.reason ?? "server_placement_unverified" };
25
+ if (typeof localMachineUuid !== "string" || !localMachineUuid.trim()) {
26
+ return { eligible: false, reason: "local_machine_identity_unavailable" };
27
+ }
28
+ const selected = receipt?.placement?.hardware_uuid;
29
+ if (typeof selected !== "string" || !selected.trim()) {
30
+ return { eligible: false, reason: "machine_binding_unavailable" };
31
+ }
32
+ if (selected.trim().toLowerCase() !== localMachineUuid.trim().toLowerCase()) {
33
+ return { eligible: false, reason: "selected_machine_mismatch" };
34
+ }
35
+ return { eligible: true, reason: null };
36
+ }
37
+
38
+ export const MANAGED_ALLOCATION_HELP = `bb allocation status <allocation_uuid> <generation> <target_revision>
39
+
40
+ Read the exact managed-container placement and cleanup receipt using the current
41
+ bb_agent_ work-session token, then compare its enrolled machine binding with this
42
+ machine's OS-reported identity. An active registered session is required. This
43
+ command is read-only; it never starts Docker or Compose.`;
44
+
45
+ export async function cmdManagedAllocation(args, {
46
+ call = callToolJson,
47
+ machineUuid = () => readMachineUuid({ env: {} }),
48
+ log = (line) => process.stdout.write(`${line}\n`),
49
+ errorLog = (line) => process.stderr.write(`${line}\n`),
50
+ } = {}) {
51
+ const parsed = parseManagedAllocationArgs(args);
52
+ if (parsed.help) { log(MANAGED_ALLOCATION_HELP); return 0; }
53
+ if (parsed.error) { errorLog(parsed.error); return 4; }
54
+ const response = await call("get_managed_container_allocation_status", {
55
+ allocation_id: parsed.allocationId,
56
+ generation: parsed.generation,
57
+ target_revision: parsed.targetRevision,
58
+ }, { requireSessionToken: true });
59
+ if (!response?.ok || response.isError) {
60
+ const sessionRequired = response?.error === "session_token_required";
61
+ const payload = { schema_version: 1, outcome: "error",
62
+ code: response?.code ?? response?.error_code ?? response?.data?.code ?? (sessionRequired ? "session_token_required" : response?.isError ? "allocation_status_denied" : "status_unavailable"),
63
+ error: response?.error ?? response?.data?.error ?? (response?.isError ? "managed allocation status was refused by the server" : "managed allocation status unavailable") };
64
+ log(JSON.stringify(payload));
65
+ return response?.auth ? CALL_EXIT.AUTH : response?.isError ? CALL_EXIT.ERROR
66
+ : response?.status == null || response.status >= 500 ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
67
+ }
68
+ const receipt = response.data;
69
+ const result = {
70
+ ...receipt,
71
+ local_start_eligibility: evaluateLocalStartEligibility(receipt, machineUuid()),
72
+ };
73
+ log(JSON.stringify(result));
74
+ // Keep shell chaining safe: a status receipt that shows the wrong/unknown
75
+ // machine must not look like a successful preflight to `&&` callers.
76
+ return result.local_start_eligibility.eligible ? 0 : CALL_EXIT.ERROR;
77
+ }
package/src/wait-core.mjs CHANGED
@@ -28,6 +28,13 @@ export const EXIT = Object.freeze({
28
28
  // reap_failed) before it was granted — re-request the lease. BOT-1754 reserves the name
29
29
  // LEASE_LOST and forbids reusing 2-8; 9 is the first free code.
30
30
  LEASE_LOST: 9,
31
+ // BOT-2330: an exact managed-allocation cleanup failed. The waiter must not
32
+ // report a successful terminal match when the signed Helper outcome failed.
33
+ MANAGED_ALLOCATION_FAILED: 10,
34
+ // BOT-2360: the server ended this wait while the client was still parked on it — the
35
+ // liveness reaper abandoned it, it was cancelled, or its row is gone. The relay
36
+ // refuses the stream with a typed `wait_abandoned` error frame; re-arm from next_cursor.
37
+ ABANDONED: 11,
31
38
  });
32
39
 
33
40
  // BOT-2013: the uuid spellings Postgres accepts for a lease id (uuid_in), mirrored by the SQL
@@ -50,6 +57,16 @@ export function leaseLostDetail(signal, condition) {
50
57
  };
51
58
  }
52
59
 
60
+ function managedAllocationFailureDetail(signal, condition) {
61
+ if (condition?.type !== "managed-allocation" || signal?.signal_type !== "managed_allocation" || signal.payload?.state !== "cleanup_failed") return null;
62
+ return {
63
+ allocation_id: signal.payload.allocation_id,
64
+ generation: signal.payload.generation,
65
+ target_revision: signal.payload.target_revision,
66
+ cleanup_error: typeof signal.payload.cleanup_error === "string" ? signal.payload.cleanup_error : "cleanup_failed",
67
+ };
68
+ }
69
+
53
70
  // BOT-1249: the internal CONTROL signal_type the reconciler emits when a wait's
54
71
  // resource identity is canonicalized away. runWaitLoop treats a wait_superseded frame
55
72
  // targeting its OWN wait_session as terminal (exit 8) before matchFrame sees it. It is
@@ -190,6 +207,24 @@ const VALIDATORS = {
190
207
  const id = hex ? `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` : raw;
191
208
  return { id };
192
209
  },
210
+ // BOT-2330: terminal state for one owner/session-bound managed allocation.
211
+ // Both fences are required so a stale wait cannot observe a later reuse.
212
+ "managed-allocation"(p) {
213
+ const unexpected = Object.keys(p).filter((key) => !["id", "generation", "target_revision"].includes(key));
214
+ if (unexpected.length) throw new Error(`managed-allocation does not accept ${unexpected.join(", ")}`);
215
+ const id = String(p.id ?? "").trim().toLowerCase();
216
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) {
217
+ throw new Error("managed-allocation needs id=<canonical allocation UUID>");
218
+ }
219
+ const generation = Number(p.generation);
220
+ const targetRevision = Number(p.target_revision);
221
+ if (!Number.isSafeInteger(generation) || generation < 1 || !Number.isSafeInteger(targetRevision) || targetRevision < 1) {
222
+ throw new Error("managed-allocation needs positive integer generation=<n> and target_revision=<n>");
223
+ }
224
+ // Keep the condition parameters in the event-stream wire shape. The server
225
+ // validates `target_revision` before it normalizes this saved condition.
226
+ return { id, generation, target_revision: targetRevision };
227
+ },
193
228
  // BOT-1064 / BOT-2112 — linear: wake on a logical state change of a named
194
229
  // issue in any Linear team bound to the authenticated tenant.
195
230
  // The spine subject_key is the issue identifier as stored on the tickets
@@ -933,6 +968,14 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
933
968
  // →active). The relay only delivers it to the lease's owning agent, so any
934
969
  // stack_lease frame for this lease id is ours to wake on.
935
970
  return signal.signal_type === "stack_lease" && signal.subject_key === `lease:${params.id}`;
971
+ case "managed-allocation": {
972
+ const p = signal.payload;
973
+ return signal.signal_type === "managed_allocation" && signal.subject_key === `allocation:${params.id}` &&
974
+ p?.allocation_id === params.id && p?.generation === params.generation &&
975
+ p?.target_revision === params.target_revision &&
976
+ ((p?.state === "released" && p?.permit_count === 0 && p?.cleanup_receipt_accepted === true) ||
977
+ (p?.state === "cleanup_failed" && p?.cleanup_receipt_accepted === true));
978
+ }
936
979
  case "linear": {
937
980
  if (signal.signal_type !== "linear_issue") return false;
938
981
  // Identity (BOT-1260): prefer the STABLE Linear issue UUID when BOTH the wait
@@ -1355,10 +1398,15 @@ export async function runWaitLoop({
1355
1398
  // reaped: that wait ends lease_lost (BOT-2013) so the caller can re-request instead of
1356
1399
  // provisioning against a dead lease.
1357
1400
  const finalizeMatch = async (entry, signal) => {
1358
- const lost = leaseLostDetail(signal, conditions.find((c) => c.id === entry.condition_id));
1401
+ const condition = conditions.find((c) => c.id === entry.condition_id);
1402
+ const lost = leaseLostDetail(signal, condition);
1359
1403
  if (lost) {
1360
1404
  return finalize("lease_lost", { exitCode: EXIT.LEASE_LOST, matched: [entry], state: lost, error: "lease_lost" });
1361
1405
  }
1406
+ const allocationFailure = managedAllocationFailureDetail(signal, condition);
1407
+ if (allocationFailure) {
1408
+ return finalize("error", { exitCode: EXIT.MANAGED_ALLOCATION_FAILED, matched: [entry], state: allocationFailure, error: allocationFailure.cleanup_error });
1409
+ }
1362
1410
  return finalize("matched", { exitCode: EXIT.MATCHED, matched: [entry] });
1363
1411
  };
1364
1412
 
@@ -1712,6 +1760,20 @@ export async function runWaitLoop({
1712
1760
  if (parsed.error === "unauthorized" || parsed.error === "forbidden") {
1713
1761
  return finalize("error", { exitCode: EXIT.AUTH, error: parsed.error });
1714
1762
  }
1763
+ // BOT-2360: the relay refuses a stream bound to a wait the server already ended
1764
+ // (prod a0cd7b11 parked 54 min on one). Never reconnect to it: exit typed, with the
1765
+ // confirmed cursor so a re-arm replays everything after it.
1766
+ if (parsed.error === "wait_abandoned") {
1767
+ return finalize("abandoned", {
1768
+ exitCode: EXIT.ABANDONED,
1769
+ error: "wait_abandoned",
1770
+ state: {
1771
+ wait_session_id: typeof parsed.wait_session_id === "string" ? parsed.wait_session_id : waitSessionId ?? null,
1772
+ status: typeof parsed.status === "string" ? parsed.status : null,
1773
+ reason: typeof parsed.reason === "string" ? parsed.reason : null,
1774
+ },
1775
+ });
1776
+ }
1715
1777
  return finalize("error", { exitCode: EXIT.INTERNAL, error: parsed.error || "stream_error" });
1716
1778
  }
1717
1779
 
package/src/wait.mjs CHANGED
@@ -180,7 +180,12 @@ OUTPUT
180
180
  8 superseded (wait replaced server-side) · 9 lease lost (a
181
181
  lease:id= wait whose lease was reaped before it was granted;
182
182
  receipt outcome lease_lost + reap_reason — request a new
183
- lease). If the receipt has
183
+ lease) · 10 managed-allocation cleanup failed (BOT-2330)
184
+ · 11 abandoned (the server ended this wait while it
185
+ was parked — reaped after 180 s without a live stream,
186
+ or cancelled; receipt outcome abandoned +
187
+ state.status/reason — re-arm with --since <next_cursor>).
188
+ If the receipt has
184
189
  reacquire_lock (claim=true), acquire_lock again FIRST and re-run
185
190
  only on a queued/busy result (granted = you hold it — just resume);
186
191
  otherwise re-run from the receipt's next_cursor. docs/agent-wait.md.
@@ -548,11 +553,28 @@ export function leaseLostNotice(receipt) {
548
553
  `(reap_reason=${st.reap_reason ?? "unknown"}) — request a new lease (exit ${EXIT.LEASE_LOST})`;
549
554
  }
550
555
 
556
+ /**
557
+ * BOT-2360: the one stderr line for an `abandoned` receipt (null for any other outcome). The
558
+ * server ended this wait (its liveness reaper or a cancel) while the client was
559
+ * still parked; re-arming from next_cursor replays everything after the last frame consumed.
560
+ */
561
+ export function waitAbandonedNotice(receipt) {
562
+ if (receipt?.outcome !== "abandoned") return null;
563
+ const st = receipt.state ?? {};
564
+ const since = receipt.next_cursor != null ? ` --since ${receipt.next_cursor}` : "";
565
+ return `bb-wait: wait ${st.wait_session_id ?? "?"} was ended server-side (status=${st.status ?? "abandoned"}, ` +
566
+ `reason=${st.reason ?? "unknown"}) — re-arm the same conditions with${since || " a fresh wait"} (exit ${EXIT.ABANDONED})`;
567
+ }
568
+
551
569
  function writeLeaseLostNotice(receipt) {
552
- const notice = leaseLostNotice(receipt);
570
+ const notice = leaseLostNotice(receipt) ?? waitAbandonedNotice(receipt);
553
571
  if (notice) process.stderr.write(`${notice}\n`);
554
572
  }
555
573
 
574
+ // BOT-1228 / BOT-2360: outcomes whose wait_session the server already ended. The client
575
+ // never finalizes them (finalize_wait_session only transitions from active).
576
+ const SERVER_ENDED_OUTCOMES = new Set(["superseded", "abandoned"]);
577
+
556
578
  // Record the terminal outcome. Best-effort AND bounded: the receipt has already
557
579
  // been written to stdout before this runs, so a slow/failing finalize must never
558
580
  // delay the agent's wake or change the exit path. A short abort timeout caps how
@@ -596,7 +618,7 @@ async function finalizeWait(opts, waitSessionId, receipt, timeoutMs = 5000) {
596
618
  // A real SSE connection to the relay's spine tail. Returns an async iterable of
597
619
  // parsed frames. Server emits `id: <seq>` per signal and replays `seq > since`
598
620
  // on connect, so a reconnect never loses an event.
599
- function makeConnect(opts) {
621
+ export function makeConnect(opts) {
600
622
  // BOT-1649: mirror the relay's keepalive expiry roll into the 0600 cache. The
601
623
  // server bumps session_token_expires_at on connect and every keepalive, but
602
624
  // the cache keeps its register-time deadline — so a wait near the 8 h cap goes
@@ -633,16 +655,25 @@ function makeConnect(opts) {
633
655
  // signal tears the stalled socket down immediately, then the read rejects and
634
656
  // the stream ends so runWaitLoop reconnects.
635
657
  const ac = new AbortController();
636
- const res = await fetch(url, {
637
- headers: {
638
- Authorization: `Bearer ${opts.token}`,
639
- "x-agent-api-key": opts.token || "",
640
- Accept: "text/event-stream",
641
- // BOT-741: never let a proxy gzip-buffer an SSE stream.
642
- "Accept-Encoding": "identity",
643
- },
644
- signal: ac.signal,
645
- });
658
+ // BOT-2360: bound the wait for response headers. Unbounded, a connect that hangs on a
659
+ // loaded host or a stuck proxy sits silent past the wait reaper's grace (undici's own
660
+ // headers timeout is 300 s); bounded, the worst silent reconnect stays inside it.
661
+ const connectTimer = setTimeout(() => ac.abort(new Error("relay connect timed out")), waitConnectTimeoutMs());
662
+ let res;
663
+ try {
664
+ res = await fetch(url, {
665
+ headers: {
666
+ Authorization: `Bearer ${opts.token}`,
667
+ "x-agent-api-key": opts.token || "",
668
+ Accept: "text/event-stream",
669
+ // BOT-741: never let a proxy gzip-buffer an SSE stream.
670
+ "Accept-Encoding": "identity",
671
+ },
672
+ signal: ac.signal,
673
+ });
674
+ } finally {
675
+ clearTimeout(connectTimer);
676
+ }
646
677
  if (res.status === 401) return errorStream("unauthorized");
647
678
  if (res.status === 403) return errorStream("forbidden");
648
679
  if (!res.ok || !res.body) throw new Error(`relay responded ${res.status}`);
@@ -655,7 +686,7 @@ function makeConnect(opts) {
655
686
  // runs the global bb_wait_linear_feed_lagging RPC) whether the Linear ingest
656
687
  // feed is stale. Any failure -> false (non-fatal: the wait keeps its last known
657
688
  // degraded state rather than flapping on a probe blip).
658
- const FEED_LAG_PROBE_TIMEOUT_MS = 10_000;
689
+ export const FEED_LAG_PROBE_TIMEOUT_MS = 10_000;
659
690
 
660
691
  function makeFeedLagProbe(opts) {
661
692
  // `issues` = the armed `linear` conditions' target ticket keys. The relay
@@ -715,9 +746,20 @@ function makeFeedLagProbe(opts) {
715
746
  // runWaitLoop reconnects from the cursor, which re-bumps wait_sessions.last_seen_at
716
747
  // (and, with --heartbeat, agents.last_heartbeat) before the reaper's grace.
717
748
  // 70s ≈ 2.3 missed keepalives — long enough to never false-trip on jitter, short
718
- // enough that the reconnect lands inside the 90s last_seen reap window.
749
+ // enough that the reconnect lands inside the 180 s last_seen reap window (BOT-2360).
719
750
  export const DEFAULT_SSE_IDLE_TIMEOUT_MS = 70_000;
720
751
 
752
+ // BOT-2360: the longest a (re)connect may wait for the relay's response headers. With the
753
+ // idle watchdog (70 s), the backoff cap (15 s) and the feed-lag probe (10 s) it bounds a
754
+ // healthy client's worst silent gap at 125 s, under the 180 s wait-reaper grace with 55 s
755
+ // to spare (asserted in bot2360-abandoned-wait.test.mjs against the registered cron job).
756
+ export const WAIT_CONNECT_TIMEOUT_MS = 30_000;
757
+
758
+ function waitConnectTimeoutMs() {
759
+ const raw = Number(process.env.BOTBUDDY_WAIT_CONNECT_TIMEOUT_MS);
760
+ return Number.isFinite(raw) && raw > 0 ? raw : WAIT_CONNECT_TIMEOUT_MS;
761
+ }
762
+
721
763
  function sseIdleTimeoutMs() {
722
764
  const raw = Number(process.env.BOTBUDDY_WAIT_IDLE_TIMEOUT_MS);
723
765
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SSE_IDLE_TIMEOUT_MS;
@@ -1194,7 +1236,7 @@ async function deliverTerminalFinalization(checkpoint) {
1194
1236
  async function flushPendingFinalization(store, checkpoint) {
1195
1237
  if (!checkpoint.cloud_wait_session_id || !checkpoint.terminal_receipt ||
1196
1238
  (checkpoint.cloud_finalize_pending === false && checkpoint.cloud_finalized_at)) return checkpoint;
1197
- if (checkpoint.terminal_receipt.outcome === "superseded") {
1239
+ if (SERVER_ENDED_OUTCOMES.has(checkpoint.terminal_receipt.outcome)) {
1198
1240
  return store.update(checkpoint.local_wait_id, {
1199
1241
  cloud_finalize_pending: false,
1200
1242
  cloud_finalized_at: new Date().toISOString(),
@@ -1296,6 +1338,7 @@ function exitForRecoveredOutcome(receipt) {
1296
1338
  if (receipt?.outcome === "cancelled") return EXIT.MATCHED;
1297
1339
  if (receipt?.outcome === "timeout") return EXIT.TIMEOUT;
1298
1340
  if (receipt?.outcome === "superseded") return EXIT.SUPERSEDED;
1341
+ if (receipt?.outcome === "abandoned") return EXIT.ABANDONED;
1299
1342
  if (receipt?.error === "cursor_expired") return EXIT.CURSOR_EXPIRED;
1300
1343
  if (receipt?.error === "unauthorized" || receipt?.error === "forbidden") return EXIT.AUTH;
1301
1344
  return EXIT.BACKEND;
@@ -2095,7 +2138,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2095
2138
  terminal_receipt: terminalReceipt,
2096
2139
  exit_code: state.exitCode,
2097
2140
  safe_cursor: terminalReceipt.next_cursor ?? state.cursor ?? checkpoint.safe_cursor,
2098
- cloud_finalize_pending: Boolean(waitSessionId && terminalReceipt.outcome !== "superseded"),
2141
+ cloud_finalize_pending: Boolean(waitSessionId && !SERVER_ENDED_OUTCOMES.has(terminalReceipt.outcome)),
2099
2142
  });
2100
2143
  terminalPersistedFromFrame = true;
2101
2144
  return;
@@ -2125,7 +2168,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2125
2168
  terminal_receipt: terminalReceipt,
2126
2169
  exit_code: exitCode,
2127
2170
  safe_cursor: terminalReceipt.next_cursor ?? checkpoint.safe_cursor ?? null,
2128
- cloud_finalize_pending: Boolean(waitSessionId && terminalReceipt.outcome !== "superseded"),
2171
+ cloud_finalize_pending: Boolean(waitSessionId && !SERVER_ENDED_OUTCOMES.has(terminalReceipt.outcome)),
2129
2172
  });
2130
2173
  }
2131
2174
  emitReceipt(terminalReceipt, { versioned: true });
@@ -2134,7 +2177,8 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2134
2177
  // ended_at/updated_at stamped). Skip the client finalize: the row is already
2135
2178
  // terminal (finalize_wait_session only transitions from active, so it'd be a
2136
2179
  // no-op) and `superseded` isn't a wait_session_status enum value anyway.
2137
- if (waitSessionId && receipt.outcome !== "superseded") {
2180
+ // BOT-2360: likewise `abandoned` — the relay refused the stream because the row is ended.
2181
+ if (waitSessionId && !SERVER_ENDED_OUTCOMES.has(receipt.outcome)) {
2138
2182
  const finalized = await finalizeWait(opts, waitSessionId, terminalReceipt);
2139
2183
  try {
2140
2184
  checkpoint = await store.update(checkpoint.local_wait_id, finalized.delivered