@botbuddy/cli 1.62.3 → 1.62.5

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.62.3",
3
- "source_commit": "f360d97780f33ea528695c412fe995921def79e4",
4
- "source_sha256": "f51d8ebb381b6f6ed5cdcaa820cc37b4b464c87f4f83057336e7db52c57b5d26",
2
+ "release_version": "1.62.5",
3
+ "source_commit": "abebdf325df18fd2d61b76971c59c2d57fd512a1",
4
+ "source_sha256": "c936de9003230430258fd3ee54cc3fc6aa74618312bbf875a16c47b2320975ce",
5
5
  "target": "darwin-arm64",
6
- "sha256": "5a19b567b8faf0e956bacaab84b5106cc79809fe0330924b4af1af91ce9c90f3"
6
+ "sha256": "3695d85ceebb29aeea8f764924286918409a1fe60c0da85d0e37dff20e893754"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.62.3",
3
- "source_commit": "f360d97780f33ea528695c412fe995921def79e4",
4
- "source_sha256": "f51d8ebb381b6f6ed5cdcaa820cc37b4b464c87f4f83057336e7db52c57b5d26",
2
+ "release_version": "1.62.5",
3
+ "source_commit": "abebdf325df18fd2d61b76971c59c2d57fd512a1",
4
+ "source_sha256": "c936de9003230430258fd3ee54cc3fc6aa74618312bbf875a16c47b2320975ce",
5
5
  "target": "linux-x64",
6
- "sha256": "89be3a9a5d26a7ba8ef5c7463c174be158ca7567efc0b2b736f3dbcdd50a1a0d"
6
+ "sha256": "f391c6f146dda4ea93ce60a856e4d3e63d64e467c0b928af0299a97b8df626f2"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.62.3",
4
- "source_commit": "f360d97780f33ea528695c412fe995921def79e4",
5
- "source_sha256": "f51d8ebb381b6f6ed5cdcaa820cc37b4b464c87f4f83057336e7db52c57b5d26",
3
+ "release_version": "1.62.5",
4
+ "source_commit": "abebdf325df18fd2d61b76971c59c2d57fd512a1",
5
+ "source_sha256": "c936de9003230430258fd3ee54cc3fc6aa74618312bbf875a16c47b2320975ce",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "5a19b567b8faf0e956bacaab84b5106cc79809fe0330924b4af1af91ce9c90f3",
11
+ "sha256": "3695d85ceebb29aeea8f764924286918409a1fe60c0da85d0e37dff20e893754",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "89be3a9a5d26a7ba8ef5c7463c174be158ca7567efc0b2b736f3dbcdd50a1a0d",
18
+ "sha256": "f391c6f146dda4ea93ce60a856e4d3e63d64e467c0b928af0299a97b8df626f2",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.62.3",
3
+ "version": "1.62.5",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,8 +20,9 @@
20
20
  // Supply Guard's `scripts/local-supabase-isolated.sh`, ported here because the
21
21
  // published CLI cannot import repo scripts and stays Node-stdlib only.
22
22
  import { createHash, randomUUID } from "node:crypto";
23
+ import { spawnSync } from "node:child_process";
23
24
  import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
24
- import { homedir, hostname } from "node:os";
25
+ import { homedir, hostname, platform } from "node:os";
25
26
  import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
26
27
  import { lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
27
28
 
@@ -45,6 +46,75 @@ export const LEASED_PORT_STRIDE = 20;
45
46
  * never a silently shared stack. */
46
47
  export const LEASED_PORT_SLOTS = 200;
47
48
 
49
+ // BOT-2490: identical hint name, epoch-millisecond format and ten-minute window
50
+ // to Helper ServingPortAllocation.swift. Hints never override a live claim.
51
+ const RELEASE_COOLDOWN_PREFIX = ".helper-released-";
52
+ const RELEASE_COOLDOWN_MS = 600_000;
53
+
54
+ /** Actual host TCP listeners, not merely the managed claim registry. */
55
+ export function listeningTcpPorts({ run = spawnSync } = {}) {
56
+ const fail = (detail) => Object.assign(new Error(`refusing port allocation: lsof host TCP listener census failed (${detail}). Verify lsof is installed and can enumerate TCP listeners.`), { code: "stack_port_census_failed" });
57
+ let result;
58
+ try {
59
+ result = run(platform() === "darwin" ? "/usr/sbin/lsof" : "lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "n"], { encoding: "utf8", timeout: 10_000 });
60
+ } catch (error) { throw fail(error?.code ?? "lsof unavailable"); }
61
+ const { status, stdout, stderr, error } = result ?? {};
62
+ if (error) throw fail(error.code ?? "lsof execution error");
63
+ if (status === 1 && stdout === "" && stderr === "") return new Set();
64
+ if (status !== 0 || typeof stdout !== "string") throw fail(`lsof exit ${status ?? "unknown"}`);
65
+ // An exit-zero partial listing with diagnostics cannot establish that the
66
+ // omitted ports are free. Never turn it into a successful census.
67
+ if (typeof stderr !== "string" || stderr.trim()) throw fail("lsof diagnostics or unavailable stderr");
68
+ const ports = new Set();
69
+ for (const line of stdout.split(/\r?\n/)) {
70
+ if (!line) continue;
71
+ if (!line.startsWith("n")) {
72
+ // The captured `-F n` counterpart and lsof's field-output contract have
73
+ // mandatory process/file records in addition to the requested names.
74
+ if (!/^[pf]/.test(line)) throw fail("unexpected census record");
75
+ continue;
76
+ }
77
+ const colon = line.lastIndexOf(":");
78
+ if (colon < 2) throw fail("malformed listener record");
79
+ const token = line.slice(colon + 1);
80
+ const port = /^\d+$/.test(token) ? Number(token) : NaN;
81
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw fail("malformed listener record");
82
+ ports.add(port);
83
+ }
84
+ if (ports.size === 0) throw fail("incomplete listener census");
85
+ return ports;
86
+ }
87
+
88
+ function releaseCooldowns(registryDir, { readdir, read, now }) {
89
+ const cooling = new Map();
90
+ const fail = (operation, error) => Object.assign(new Error(`refusing port allocation: released-block cooldown ${operation} failed (${error?.code ?? "read error"}). Restore access to the port registry before retrying.`), { code: "stack_port_cooldown_failed" });
91
+ let names;
92
+ try { names = readdir(registryDir); } catch (error) { throw fail("enumeration", error); }
93
+ for (const name of names) {
94
+ if (!name.startsWith(RELEASE_COOLDOWN_PREFIX)) continue;
95
+ const token = name.slice(RELEASE_COOLDOWN_PREFIX.length);
96
+ const base = /^\d+$/.test(token) ? Number(token) : NaN;
97
+ // Atomic-write temporary files and non-candidate names are not cooldown
98
+ // evidence. Validate before I/O so irrelevant unreadable files cannot block.
99
+ if (!Number.isInteger(base) || base < LEASED_PORT_BASE || base >= LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE || (base - LEASED_PORT_BASE) % LEASED_PORT_STRIDE !== 0) continue;
100
+ let raw;
101
+ try { raw = read(join(registryDir, name), "utf8").trim(); } catch (error) { throw fail("record read", error); }
102
+ const released = raw === "" ? NaN : Number(raw);
103
+ if (Number.isInteger(base) && Number.isFinite(released) && now() - released < RELEASE_COOLDOWN_MS) cooling.set(base, released);
104
+ }
105
+ return cooling;
106
+ }
107
+
108
+ function recordReleaseCooldown(registryDir, base, { write, rename, now, warn }) {
109
+ try {
110
+ atomicWrite(join(registryDir, `${RELEASE_COOLDOWN_PREFIX}${base}`), String(now()), { write, rename });
111
+ return true;
112
+ } catch (error) {
113
+ warn(`stack: could not record released port-block cooldown ${base}: ${error?.code ?? "write failed"}`);
114
+ return false;
115
+ }
116
+ }
117
+
48
118
  /** How long a leased stack directory must have been untouched before another run
49
119
  * may collect it. Comfortably above the reaper's default 1800 s idle TTL, so a
50
120
  * batch whose owner process died but whose child/containers are still finishing is
@@ -930,6 +1000,12 @@ export function blocksForPorts(ports) {
930
1000
  export function releaseLeasedStackClaim(stackDir, { registryDir = defaultPortRegistryDir(), io = {} } = {}) {
931
1001
  const read = io.readFile ?? readFileSync;
932
1002
  const remove = io.remove ?? rmSync;
1003
+ const write = io.writeFile ?? writeFileSync;
1004
+ const rename = io.rename ?? renameSync;
1005
+ const now = io.now ?? Date.now;
1006
+ const alive = io.alive ?? pidIsAlive;
1007
+ const host = io.hostname ?? hostname;
1008
+ const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
933
1009
  const marker = readStackMarker(stackDir, read);
934
1010
  if (!marker || !Number.isInteger(marker.port_base)) return false;
935
1011
  const path = portClaimPath(registryDir, marker.port_base);
@@ -938,8 +1014,13 @@ export function releaseLeasedStackClaim(stackDir, { registryDir = defaultPortReg
938
1014
  const owner = marker.invocation_token ?? marker.project_id;
939
1015
  if (!claim || (claim.invocation_token ?? claim.project_id) !== owner) return false;
940
1016
  try {
941
- remove(path, { force: true });
942
- return true;
1017
+ return withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep: io.sleep }, () => {
1018
+ const current = JSON.parse(read(path, "utf8"));
1019
+ if ((current?.invocation_token ?? current?.project_id) !== owner) return false;
1020
+ if (!recordReleaseCooldown(registryDir, marker.port_base, { write, rename, now, warn: io.warn ?? console.warn })) return false;
1021
+ remove(path, { force: true });
1022
+ return true;
1023
+ });
943
1024
  } catch {
944
1025
  return false;
945
1026
  }
@@ -1077,14 +1158,28 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
1077
1158
  /** Returns whether none of this invocation's claims remain (BOT-2237: a verified result). */
1078
1159
  release: () => {
1079
1160
  let allReleased = true;
1161
+ // Do not create an allocation lock when the registry is unreadable: its
1162
+ // ownership could not be re-read for safe unlock either. Re-check under
1163
+ // the lock below before any unlink, so this is not an ownership shortcut.
1080
1164
  for (const block of claimed) {
1081
- let current = null;
1082
- // Codex #1168 R2 P2: a claim that is gone (ENOENT) is released; one that cannot be read
1083
- // or parsed is unverifiable — never unlinked blindly, and never reported as released.
1084
- try { current = JSON.parse(read(portClaimPath(registryDir, block), "utf8")); } catch (error) { if (error?.code !== "ENOENT") allReleased = false; continue; }
1085
- if (current?.invocation_token !== invocationToken) continue; // superseded: never unlink blindly
1086
- try { remove(portClaimPath(registryDir, block), { force: true }); } catch { allReleased = false; }
1165
+ try { JSON.parse(read(portClaimPath(registryDir, block), "utf8")); }
1166
+ catch (error) { if (error?.code !== "ENOENT") return false; }
1087
1167
  }
1168
+ try { withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep }, () => {
1169
+ for (const block of claimed) {
1170
+ let current = null;
1171
+ // A missing claim is released; unreadable/corrupt is unverifiable.
1172
+ try { current = JSON.parse(read(portClaimPath(registryDir, block), "utf8")); } catch (error) { if (error?.code !== "ENOENT") allReleased = false; continue; }
1173
+ if (current?.invocation_token !== invocationToken) continue; // never unlink a superseding claim
1174
+ try {
1175
+ if (!recordReleaseCooldown(registryDir, block, { write, rename: io.rename ?? renameSync, now, warn: io.warn ?? console.warn })) {
1176
+ allReleased = false;
1177
+ continue;
1178
+ }
1179
+ remove(portClaimPath(registryDir, block), { force: true });
1180
+ } catch { allReleased = false; }
1181
+ }
1182
+ }); } catch { allReleased = false; }
1088
1183
  return allReleased;
1089
1184
  },
1090
1185
  };
@@ -1225,16 +1320,20 @@ function portClaimPath(registryDir, portBase) {
1225
1320
  return join(registryDir, `${portBase}.json`);
1226
1321
  }
1227
1322
 
1228
- /** The first free block at or after the identity's own hash-derived one. */
1229
- function selectPortBase(preferred, taken) {
1323
+ /** Prefer non-cooling free blocks in hash order; otherwise oldest release first. */
1324
+ function selectPortBase(preferred, taken, cooling = new Map()) {
1230
1325
  const start = Math.floor((preferred - LEASED_PORT_BASE) / LEASED_PORT_STRIDE);
1326
+ let oldest = null;
1231
1327
  for (let i = 0; i < LEASED_PORT_SLOTS; i++) {
1232
1328
  const candidate = LEASED_PORT_BASE + ((start + i) % LEASED_PORT_SLOTS) * LEASED_PORT_STRIDE;
1233
- if (!taken.has(candidate)) return candidate;
1329
+ if (taken.has(candidate)) continue;
1330
+ if (!cooling.has(candidate)) return candidate;
1331
+ if (oldest === null || cooling.get(candidate) < cooling.get(oldest)) oldest = candidate;
1234
1332
  }
1333
+ if (oldest !== null) return oldest;
1235
1334
  throw new Error(
1236
1335
  `refusing to materialize an isolated leased stack: all ${LEASED_PORT_SLOTS} leased port blocks ` +
1237
- `(${LEASED_PORT_BASE}-${LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE - 1}) are held by live or retained stacks. ` +
1336
+ `(${LEASED_PORT_BASE}-${LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE - 1}) are held by live or retained stacks or host listeners. ` +
1238
1337
  "Finish or reap a batch before starting another.",
1239
1338
  );
1240
1339
  }
@@ -1659,6 +1758,9 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1659
1758
  const previous = dirExisted ? readStackMarker(stackDir, read) : null;
1660
1759
  const previousBlock = previous?.project_id === identity.projectId && Number.isInteger(previous?.port_base) ? previous.port_base : null;
1661
1760
  const portBase = withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep }, () => {
1761
+ const listening = new Set(blocksForPorts((io.listeningPorts ?? listeningTcpPorts)()));
1762
+ const cooling = releaseCooldowns(registryDir, { readdir, read, now });
1763
+ const raced = new Set();
1662
1764
  let preferred = previousBlock ?? identity.portBase;
1663
1765
  for (let attempt = 0; attempt < LEASED_PORT_SLOTS; attempt++) {
1664
1766
  const stale = new Set();
@@ -1669,7 +1771,17 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1669
1771
  for (const block of stale) {
1670
1772
  try { remove(portClaimPath(registryDir, block), { force: true }); } catch { /* best effort */ }
1671
1773
  }
1672
- const candidate = selectPortBase(preferred, taken);
1774
+ // A running stack necessarily owns listeners. Only this exact retained
1775
+ // invocation may re-adopt its block; a merely matching project is not proof.
1776
+ let own = null;
1777
+ try { own = JSON.parse(read(portClaimPath(registryDir, preferred), "utf8")); } catch { /* new allocation */ }
1778
+ if (own?.invocation_token === invocationToken && own.project_id === identity.projectId) {
1779
+ write(portClaimPath(registryDir, preferred), marker(preferred));
1780
+ return preferred;
1781
+ }
1782
+ for (const block of listening) if (!taken.has(block)) taken.set(block, { projectId: "host TCP listener" });
1783
+ for (const block of raced) if (!taken.has(block)) taken.set(block, { projectId: "exclusive claim collision" });
1784
+ const candidate = selectPortBase(preferred, taken, cooling);
1673
1785
  try {
1674
1786
  writeExclusive(portClaimPath(registryDir, candidate), marker(candidate));
1675
1787
  return candidate;
@@ -1686,6 +1798,7 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1686
1798
  write(portClaimPath(registryDir, candidate), marker(candidate));
1687
1799
  return candidate;
1688
1800
  }
1801
+ raced.add(candidate); // cooldown ordering must not select this collision again
1689
1802
  preferred = candidate + LEASED_PORT_STRIDE; // a peer won this one
1690
1803
  }
1691
1804
  }
@@ -22,7 +22,7 @@
22
22
  // Dependency-free (Node stdlib only).
23
23
 
24
24
  import { spawn, spawnSync } from "node:child_process";
25
- import { connect, createServer } from "node:net";
25
+ import { connect, createServer, isIP } from "node:net";
26
26
 
27
27
  export const SSH_FORWARD_OPTIONS = Object.freeze([
28
28
  "-N", "-T",
@@ -299,7 +299,7 @@ export async function openSshForwards({
299
299
  * `GatewayPorts yes` silently binds it on every interface, exposing this machine's
300
300
  * callback service to the host's network. The CLI cannot read sshd_config, so after the
301
301
  * forward is up it asks the host which address the listener really holds and refuses
302
- * anything but loopback (fail closed: no lsof, no listener, unreadable → refused).
302
+ * anything but loopback (fail closed: no inventory, no listener, unreadable → refused).
303
303
  */
304
304
  export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.env, run = spawnSync, label = null }) {
305
305
  const what = label ?? `the reverse forward for --callback-port ${port}`;
@@ -307,18 +307,47 @@ export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.
307
307
  `${what} on ${sshTarget} could not be proven loopback-only (${why}). ` +
308
308
  "An sshd with GatewayPorts yes binds -R forwards on every interface; set GatewayPorts no (or clientspecified) on the host, " +
309
309
  "or run without --callback-port.", extra);
310
+ const checkedPort = validPort(port);
311
+ if (checkedPort == null) throw fail("invalid listener port");
312
+ // Linux lsof cannot see root-owned sshd sockets from an unprivileged login (BOT-2743).
313
+ // ss reads the socket table without process visibility; -p is deliberately omitted.
314
+ // Dispatch on the remote OS in the SAME SSH invocation, with no guessed fallback.
315
+ const command = `case "$(uname -s)" in Linux) printf 'bb-ss-v1\\n'; ss -H -ltn '( sport = :${checkedPort} )';; ` +
316
+ `Darwin) lsof -nP -iTCP:${checkedPort} -sTCP:LISTEN -Fn;; ` +
317
+ "*) printf 'unsupported listener inventory platform\\n' >&2; exit 69;; esac";
310
318
  // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- fixed argv; target validated; integer port.
311
319
  const r = run(sshBin, [
312
320
  "-T", "-o", "BatchMode=yes", "-o", "ControlMaster=no", "-o", "ControlPath=none", "--", sshTarget,
313
- `lsof -nP -iTCP:${Number(port)} -sTCP:LISTEN -Fn`,
321
+ command,
314
322
  ], { encoding: "utf8", env, timeout: 15_000 });
315
323
  if (r.error || r.status !== 0) {
316
324
  const tail = String(r.stderr || r.error?.message || "").trim().split("\n").slice(-1)[0];
317
325
  // lsof exits 1 with no output when nothing listens.
318
- if (r.status === 1 && !String(r.stdout || "").trim()) throw fail(`no listener on the host's port ${port}`, { noListener: true });
319
- throw fail(`could not run lsof on the host (exit ${r.status ?? "?"}${tail ? `: ${tail}` : ""})`);
326
+ if (!r.error && r.status === 1 && !String(r.stdout || "").trim() && !String(r.stderr || "").trim()) {
327
+ throw fail(`no listener on the host's port ${port}`, { noListener: true });
328
+ }
329
+ throw fail(`could not inspect host sockets with ss/lsof (exit ${r.status ?? "?"}${tail ? `: ${tail}` : ""})`);
330
+ }
331
+ const output = String(r.stdout || "");
332
+ let addresses;
333
+ if (output.startsWith("bb-ss-v1\n")) {
334
+ addresses = output.slice("bb-ss-v1\n".length).split("\n").filter((line) => line.trim()).map((line) => {
335
+ // ss -H -ltn: State Recv-Q Send-Q Local-Address:Port Peer-Address:Port.
336
+ // Reject the whole answer on any bad row: silently skipping a wildcard is unsafe.
337
+ const fields = line.trim().split(/\s+/);
338
+ const local = /^(.+):(\d+)$/.exec(fields[3] ?? "");
339
+ let address = local?.[1];
340
+ if (address?.startsWith("[") && address.endsWith("]")) address = address.slice(1, -1);
341
+ const family = isIP(address ?? "");
342
+ if (fields.length !== 5 || fields[0] !== "LISTEN" || !/^\d+$/.test(fields[1]) || !/^\d+$/.test(fields[2]) ||
343
+ !local || Number(local[2]) !== checkedPort || (!family && address !== "*")) {
344
+ throw fail("malformed ss listener inventory");
345
+ }
346
+ return `${family === 6 ? `[${address}]` : address}:${checkedPort}`;
347
+ });
348
+ } else {
349
+ addresses = output.split("\n").filter((l) => l.startsWith("n")).map((l) => l.slice(1).trim());
320
350
  }
321
- const addresses = String(r.stdout || "").split("\n").filter((l) => l.startsWith("n")).map((l) => l.slice(1).trim());
322
351
  if (!addresses.length) throw fail(`no listener on the host's port ${port}`, { noListener: true });
323
352
  const exposed = addresses.filter((a) => !(a.startsWith("127.") || a.startsWith("[::1]:") || a.startsWith("localhost:")));
324
353
  if (exposed.length) throw fail(`the host listens on ${exposed.join(", ")}`, { exposed, listeners: addresses });
package/src/stack.mjs CHANGED
@@ -3777,7 +3777,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3777
3777
  // A slot whose stack another live batch owns is a coordination refusal (the
3778
3778
  // same class as the server's duplicate-lease rejection), not bad arguments.
3779
3779
  const exitCode = error?.code === "stack_slot_busy" ? EXIT.BACKEND : EXIT.INVALID;
3780
- return { exitCode, outcome: "error", error: String(error?.message ?? error) };
3780
+ // BOT-2490: retain listener/cooldown refusals in the existing public code field;
3781
+ // keep the exit mapping and unrelated local-error semantics unchanged.
3782
+ const code = ["stack_port_census_failed", "stack_port_cooldown_failed"].includes(error?.code) ? error.code : null;
3783
+ return { exitCode, outcome: "error", ...(code ? { code } : {}), error: String(error?.message ?? error) };
3781
3784
  }
3782
3785
  // BOT-1798 (Codex #928 R13 P2): the queue and provisioning waits can outlast the
3783
3786
  // directory's initial retention, and a kill during them leaves a lease the