@botbuddy/cli 1.54.0 → 1.56.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.54.0",
4
- "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
3
+ "version": "1.56.0",
4
+ "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "bb": "./bin/botbuddy.mjs",
@@ -0,0 +1,29 @@
1
+ {
2
+ "ticket": "BOT-2403",
3
+ "captured_at": "2026-10-16T20:43:49Z",
4
+ "provenance": "Actual Docker Desktop container inspect .Mounts and context inspect on owned MacBook lease 0c68ff71-3a1a-4d3c-b7d2-d06617609772; requester and container destination both contained43 entries; actual MCP readyz HTTP200/version2.97.1. No environment/credentials captured.",
5
+ "project_id": "bbbot240305b549",
6
+ "host_functions_dir": "/Users/jono/Developer/botbuddy-web/.claude/worktrees/bot-2402/.botbuddy/stacks/bbbot240305b549/supabase/functions",
7
+ "docker_context": "desktop-linux",
8
+ "docker_endpoint": "unix:///Users/jono/.docker/run/docker.sock",
9
+ "mounts": [
10
+ {
11
+ "Type": "volume",
12
+ "Name": "supabase_edge_runtime_bbbot240305b549",
13
+ "Source": "/var/lib/docker/volumes/supabase_edge_runtime_bbbot240305b549/_data",
14
+ "Destination": "/root/.cache/deno",
15
+ "Driver": "local",
16
+ "Mode": "rw",
17
+ "RW": true,
18
+ "Propagation": ""
19
+ },
20
+ {
21
+ "Type": "bind",
22
+ "Source": "/host_mnt/Users/jono/Developer/botbuddy-web/.claude/worktrees/bot-2402/.botbuddy/stacks/bbbot240305b549/supabase/functions",
23
+ "Destination": "/Users/jono/Developer/botbuddy-web/.claude/worktrees/bot-2402/.botbuddy/stacks/bbbot240305b549/supabase/functions",
24
+ "Mode": "ro",
25
+ "RW": false,
26
+ "Propagation": "rprivate"
27
+ }
28
+ ]
29
+ }
@@ -20,7 +20,7 @@
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 { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
23
+ import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
24
24
  import { homedir, hostname } from "node:os";
25
25
  import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
26
26
  import { lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
@@ -1108,7 +1108,7 @@ function selectPortBase(preferred, taken) {
1108
1108
  * (BOT-1798, Codex #928 R4 P1). The refresh prunes and copies BEFORE
1109
1109
  * `resolveStackPath` could notice an escape, so a stale or hostile symlink at
1110
1110
  * `.botbuddy/`, `.botbuddy/stacks/`, `<project_id>/` or its `supabase/` would
1111
- * make `prune()` delete data OUTSIDE the worktree. Every existing component of
1111
+ * make the refresh's deletions remove data OUTSIDE the worktree. Every existing component of
1112
1112
  * the path must therefore resolve to itself; the first one that does not is
1113
1113
  * fatal, and a component that does not exist yet ends the walk (nothing below it
1114
1114
  * can exist either). `worktreeRoot` is already canonical (realpath of the cwd).
@@ -1137,12 +1137,76 @@ function assertCanonicalTarget(worktreeRoot, relPath, realpath) {
1137
1137
  * worktree cannot linger in the leased stack (and be applied by `supabase start`).
1138
1138
  * The stack's own CLI scratch state (SKIP_ENTRIES) is left alone. A not-yet-created
1139
1139
  * directory is simply nothing to prune. */
1140
- function prune(stackSupabaseDir, { readdir, remove }) {
1141
- let entries;
1142
- try { entries = readdir(stackSupabaseDir); } catch { return; }
1143
- for (const entry of entries) {
1144
- if (SKIP_ENTRIES.has(entry)) continue;
1145
- remove(join(stackSupabaseDir, entry), { recursive: true, force: true });
1140
+ // BOT-2403 — the stack is refreshed IN PLACE. The edge runtime bind-mounts
1141
+ // `<stack>/supabase/functions` (a directory) and each BOT-1828 escaping import (single
1142
+ // files), and a bind mount pins the inode it resolved at container start. The old
1143
+ // prune-then-cpSync refresh removed and recreated every one of them on each run (also a
1144
+ // refused or queued one, which materializes before its lease request), so a live or idle
1145
+ // stack's runtime was left holding deleted directories: 0 functions, every function 503
1146
+ // BOOT_ERROR after re-adoption. Here an existing directory is kept and recursed into, an
1147
+ // unchanged file is not touched, a changed file is rewritten in place (copyFileSync keeps
1148
+ // the inode; cpSync onto an existing file does not), and only entries the source no longer
1149
+ // has — or whose type changed — are removed (rsync --delete semantics, as before).
1150
+
1151
+ /** True when two regular files have identical bytes. */
1152
+ function sameFileContent(a, b, { read, lstat }) {
1153
+ try {
1154
+ if (lstat(a).size !== lstat(b).size) return false;
1155
+ return Buffer.compare(read(a), read(b)) === 0;
1156
+ } catch {
1157
+ return false;
1158
+ }
1159
+ }
1160
+
1161
+ /** Bring one source entry to `dest` in place; `copy` only ever creates what is missing. */
1162
+ function syncEntryInPlace(source, dest, ops) {
1163
+ const { lstat, readlink, remove, copy, copyFile } = ops;
1164
+ const src = lstat(source);
1165
+ let existing = null;
1166
+ try { existing = lstat(dest); } catch { /* missing */ }
1167
+ if (src.isDirectory()) {
1168
+ if (existing?.isDirectory()) return syncTreeInPlace(source, dest, ops);
1169
+ if (existing) remove(dest, { recursive: true, force: true });
1170
+ copy(source, dest, { recursive: true, force: true, verbatimSymlinks: true, filter: (path) => !SKIP_ENTRIES.has(basename(path)) });
1171
+ return;
1172
+ }
1173
+ if (src.isSymbolicLink()) {
1174
+ if (existing?.isSymbolicLink() && readlink(dest) === readlink(source)) return;
1175
+ if (existing) remove(dest, { recursive: true, force: true });
1176
+ copy(source, dest, { force: true, verbatimSymlinks: true });
1177
+ return;
1178
+ }
1179
+ if (existing?.isFile()) {
1180
+ if (!sameFileContent(source, dest, ops)) copyFile(source, dest);
1181
+ return;
1182
+ }
1183
+ if (existing) remove(dest, { recursive: true, force: true });
1184
+ copy(source, dest, { force: true });
1185
+ }
1186
+
1187
+ /**
1188
+ * Make `dest` mirror `source` without replacing anything that already matches. `keep`
1189
+ * names top-level `dest` entries that are not the source's to manage (the Supabase CLI's
1190
+ * scratch state, the rewritten config.toml); `SKIP_ENTRIES` are never copied.
1191
+ */
1192
+ function syncTreeInPlace(source, dest, ops, { keep = new Set() } = {}) {
1193
+ const { readdir, remove, mkdir } = ops;
1194
+ mkdir(dest, { recursive: true });
1195
+ const wanted = new Set(readdir(source).filter((entry) => !SKIP_ENTRIES.has(entry) && !keep.has(entry)));
1196
+ for (const entry of readdir(dest)) {
1197
+ if (keep.has(entry) || wanted.has(entry)) continue;
1198
+ remove(join(dest, entry), { recursive: true, force: true });
1199
+ }
1200
+ for (const entry of wanted) syncEntryInPlace(join(source, entry), join(dest, entry), ops);
1201
+ }
1202
+
1203
+ /** The inode of the stack's functions directory, or null when it has none. */
1204
+ function functionsInode(stackDir, lstat) {
1205
+ try {
1206
+ const st = lstat(join(stackDir, "supabase", "functions"));
1207
+ return st.isDirectory() ? Number(st.ino) : null;
1208
+ } catch {
1209
+ return null;
1146
1210
  }
1147
1211
  }
1148
1212
 
@@ -1247,7 +1311,7 @@ function listMatchingFiles(dir, { exts = [], names = [] }, { readdir, stat }) {
1247
1311
  * file that does not exist, naming the importing file and the specifier. Returns the
1248
1312
  * sorted list of worktree-relative paths copied (possibly empty).
1249
1313
  */
1250
- function copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath }) {
1314
+ function copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath, sync = null }) {
1251
1315
  const functionsDir = join(worktreeRoot, "supabase", "functions");
1252
1316
  const visited = new Set(); // worktree-relative paths already queued or copied
1253
1317
  const queue = [];
@@ -1333,7 +1397,10 @@ function copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdi
1333
1397
  // whose text to scan for further imports.
1334
1398
  let isDir = false;
1335
1399
  try { isDir = stat(item.absPath).isDirectory(); } catch { /* copy() will report it */ }
1336
- copy(item.absPath, dest, isDir ? { recursive: true, force: true } : { force: true });
1400
+ // BOT-2403: each copied file is a single-file bind mount of a running edge runtime, so
1401
+ // it is refreshed in place (same inode), never replaced.
1402
+ if (sync) syncEntryInPlace(item.absPath, dest, sync);
1403
+ else copy(item.absPath, dest, isDir ? { recursive: true, force: true } : { force: true });
1337
1404
  extraFiles.push(item.relPath);
1338
1405
  if (!isDir && ESCAPING_IMPORT_SCAN_EXTENSIONS.some((ext) => item.relPath.endsWith(ext))) {
1339
1406
  scanForImports(item.absPath, item.relPath);
@@ -1354,9 +1421,11 @@ function copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdi
1354
1421
  * symlink that resolves on the host does not resolve inside Docker (the same
1355
1422
  * reason Supply Guard's isolated-stack script materializes function sources).
1356
1423
  * Copying every run also keeps the leased stack in step with the worktree — and
1357
- * the copy is a REPLACE (rsync --delete semantics, like the hermetic lane), so a
1424
+ * the copy mirrors the worktree (rsync --delete semantics, like the hermetic lane), so a
1358
1425
  * migration or function deleted in the worktree is not resurrected by the leased
1359
- * stack. Only the Supabase CLI's own scratch state survives the refresh.
1426
+ * stack. Only the Supabase CLI's own scratch state survives the refresh. BOT-2403: the
1427
+ * refresh is IN PLACE — a running (or idle) stack's edge runtime bind-mounts the
1428
+ * functions directory and the escaping-import files, so they are never replaced.
1360
1429
  */
1361
1430
  export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs } = {}, io = {}) {
1362
1431
  const read = io.readFile ?? readFileSync;
@@ -1369,6 +1438,14 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1369
1438
  const now = io.now ?? Date.now;
1370
1439
  const rename = io.rename ?? renameSync;
1371
1440
  const resolveRoot = io.realpath ?? realpathSync;
1441
+ const lstat = io.lstat ?? lstatSync;
1442
+ const warn = io.warn ?? (() => {});
1443
+ // BOT-2403: the in-place refresh's file operations (`copy` only creates what is missing).
1444
+ const sync = {
1445
+ read, mkdir, remove, copy, readdir, lstat,
1446
+ readlink: io.readlink ?? readlinkSync,
1447
+ copyFile: io.copyFile ?? copyFileSync,
1448
+ };
1372
1449
 
1373
1450
  const worktreeRoot = resolveRoot(cwd);
1374
1451
  const sourceDir = join(worktreeRoot, "supabase");
@@ -1412,7 +1489,7 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1412
1489
  // stable per-slot one, so the project id is exactly as specific (and keeps a
1413
1490
  // refresh idempotent).
1414
1491
  const invocationToken = nonce ? String(nonce) : identity.projectId;
1415
- const marker = (portBase, extraFiles = []) => JSON.stringify({
1492
+ const marker = (portBase, extraFiles = [], functionsIno = null) => JSON.stringify({
1416
1493
  schema: 1,
1417
1494
  project_id: identity.projectId,
1418
1495
  invocation_token: invocationToken,
@@ -1428,6 +1505,9 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1428
1505
  // outside supabase/ — recorded so an operator can see why this stack dir has
1429
1506
  // (e.g.) a src/ directory, instead of guessing at a `supabase start` failure.
1430
1507
  extra_files: extraFiles,
1508
+ // BOT-2403: the functions directory a running edge runtime bind-mounts; a later run
1509
+ // warns when another writer replaced it (the container would keep the old one).
1510
+ functions_inode: functionsIno,
1431
1511
  });
1432
1512
  const registryDir = io.portRegistryDir ?? defaultPortRegistryDir();
1433
1513
  const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
@@ -1490,29 +1570,35 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1490
1570
  const { text } = rewriteLeasedStackConfig(rootConfig, { projectId: identity.projectId, portBase });
1491
1571
  assertLeasedStackIsolated(rootConfig, text);
1492
1572
 
1493
- prune(join(stackDir, "supabase"), { readdir, remove });
1494
- copy(sourceDir, join(stackDir, "supabase"), {
1495
- recursive: true,
1496
- force: true,
1497
- // BOT-2142: keep relative symlinks relative (cpSync's default rewrites them to
1498
- // absolute links INTO the source worktree, which a remote bundle must refuse —
1499
- // Supply Guard's supabase/CLAUDE.md -> AGENTS.md).
1500
- verbatimSymlinks: true,
1501
- filter: (source) => !SKIP_ENTRIES.has(basename(source)),
1502
- });
1503
- write(join(stackDir, "supabase", "config.toml"), text);
1573
+ // BOT-2403: a replaced functions directory strands a running edge runtime's bind mount.
1574
+ // This refresh never replaces it; another writer may have, so say so once.
1575
+ const recordedIno = previous?.project_id === identity.projectId && Number.isInteger(previous?.functions_inode) ? previous.functions_inode : null;
1576
+ const currentIno = functionsInode(stackDir, lstat);
1577
+ if (recordedIno !== null && currentIno !== null && currentIno !== recordedIno) {
1578
+ warn(`stack: warning: ${stackPath}/supabase/functions was replaced since this stack was materialized (inode ${recordedIno} → ${currentIno}); ` +
1579
+ "a running edge runtime still sees the old directory. Update a leased stack directory in place — never rm + recreate it.");
1580
+ }
1581
+
1582
+ // BOT-2142: symlinks stay verbatim (relative), never rewritten to absolute links INTO
1583
+ // the source worktree, which a remote bundle must refuse (Supply Guard's
1584
+ // supabase/CLAUDE.md -> AGENTS.md).
1585
+ syncTreeInPlace(sourceDir, join(stackDir, "supabase"), sync, { keep: new Set([...SKIP_ENTRIES, "config.toml"]) });
1586
+ let currentConfig = null;
1587
+ try { currentConfig = read(join(stackDir, "supabase", "config.toml"), "utf8"); } catch { /* first materialization */ }
1588
+ if (currentConfig !== text) write(join(stackDir, "supabase", "config.toml"), text);
1504
1589
 
1505
1590
  // BOT-1828: bring along any file an edge function imports OUTSIDE supabase/
1506
1591
  // (Supply Guard's admin-api-keys/index.ts pulls in ../../../src/lib/…) —
1507
1592
  // `supabase start` resolves function imports when it starts the edge runtime, so
1508
1593
  // without this the Helper fails deep inside `supabase start` with a raw ENOENT.
1509
- const extraFiles = copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath: resolveRoot });
1594
+ const extraFiles = copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath: resolveRoot, sync });
1510
1595
 
1511
1596
  // Re-state the claim after the copy: the marker records who owns this directory,
1512
1597
  // which port block it holds, and how long a later run must keep it (Codex #928
1513
1598
  // R7/R12). It lives beside `supabase/`, which the refresh replaces wholesale.
1514
- atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase, extraFiles), { write, rename });
1515
- atomicWrite(portClaimPath(registryDir, portBase), marker(portBase, extraFiles), { write, rename });
1599
+ const functionsIno = functionsInode(stackDir, lstat);
1600
+ atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase, extraFiles, functionsIno), { write, rename });
1601
+ atomicWrite(portClaimPath(registryDir, portBase), marker(portBase, extraFiles, functionsIno), { write, rename });
1516
1602
  return stackPath;
1517
1603
  } catch (error) {
1518
1604
  try { remove(portClaimPath(registryDir, portBase), { force: true }); } catch { /* best effort */ }
@@ -0,0 +1,121 @@
1
+ // BOT-2403 — a re-adopted idle stack is never handed over with a stale functions mount.
2
+ //
3
+ // The edge runtime (`supabase_edge_runtime_<project>`) bind-mounts the stack's
4
+ // `supabase/functions` DIRECTORY. A bind mount pins the directory it resolved at
5
+ // container start: if that directory is later removed and recreated (a new inode — what
6
+ // `bb stack run`'s materializer did before BOT-2403, and what any rm + recreate writer
7
+ // does), the container keeps the deleted directory and lists 0 entries, so every function
8
+ // answers 503 BOOT_ERROR while the host directory looks complete. Re-adoption proves the
9
+ // files' CONTENT (the stack digest), not that the running container still sees them.
10
+ //
11
+ // So before a re-adopted CO-LOCATED stack is reported ready, `bb stack run` checks the
12
+ // mount from inside the container. Empty while the host directory is not → restart the
13
+ // edge runtime once (a restart resolves the bind source again) → still empty → the caller
14
+ // re-provisions. A remote lease is skipped: re-adoption uploads no bundle, so the Helper's
15
+ // copy of the stack directory is never rewritten under a running container. A stack with
16
+ // no functions, or with no edge runtime container (`No such container`), is skipped too; any
17
+ // other inspection failure fails closed.
18
+ //
19
+ // Dependency-free (Node stdlib only); docker is run with a fixed argv, never a shell.
20
+ import { execFile } from "node:child_process";
21
+ import { readdirSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { dockerSelectorArgs } from "./stack-usage.mjs";
24
+
25
+ export const MOUNT_CHECK_TIMEOUT_MS = 10_000;
26
+ export const RUNTIME_RESTART_TIMEOUT_MS = 60_000;
27
+ const CO_LOCATED_MODES = new Set(["local", "local_exec"]);
28
+ const PROJECT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/;
29
+
30
+ /** Default runner: `docker <args>` with a hard timeout; never a shell. */
31
+ export function runDocker(args, { timeoutMs = MOUNT_CHECK_TIMEOUT_MS } = {}) {
32
+ return new Promise((resolve) => {
33
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- fixed executable, validated argv, no shell.
34
+ execFile("docker", args, { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
35
+ resolve({ code: error ? (typeof error.code === "number" ? error.code : 1) : 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), timedOut: Boolean(error?.killed) });
36
+ });
37
+ });
38
+ }
39
+
40
+ /** The container path the functions directory is mounted at, or null (from `docker inspect … .Mounts`). */
41
+ export function functionsMountDestination(mountsJson, hostFunctionsDir, { dockerDesktop = false } = {}) {
42
+ let mounts;
43
+ try { mounts = JSON.parse(String(mountsJson ?? "").trim()); } catch { return null; }
44
+ if (!Array.isArray(mounts)) return null;
45
+ const mount = mounts.find((m) => m?.Type === "bind" && (
46
+ m?.Source === hostFunctionsDir || (
47
+ dockerDesktop === true && hostFunctionsDir.startsWith("/Users/") &&
48
+ m?.Source === `/host_mnt${hostFunctionsDir}`
49
+ )
50
+ ));
51
+ return typeof mount?.Destination === "string" && mount.Destination ? mount.Destination : null;
52
+ }
53
+
54
+ // Docker Desktop exposes a macOS bind through its VM's /host_mnt alias.
55
+ // Authorize that alias only for the selected engine, never an arbitrary remote
56
+ // daemon or another VM. Explicit context selection wins over endpoint metadata,
57
+ // exactly as dockerSelectorArgs does. A failed probe keeps the gate closed.
58
+ async function isSelectedDockerDesktop(target, run, platform) {
59
+ if (platform !== "darwin") return false;
60
+ const context = typeof target?.context === "string" && target.context ? target.context : null;
61
+ let endpoint = context ? null : target?.resolved_endpoint ?? target?.endpoint;
62
+ if (!endpoint) {
63
+ const result = await run(["context", "inspect", ...(context ? [context] : []), "--format", "{{.Endpoints.docker.Host}}"]);
64
+ if (result.code !== 0 || result.timedOut) return false;
65
+ endpoint = String(result.stdout ?? "").trim();
66
+ }
67
+ return typeof endpoint === "string" && /^unix:\/\/\/Users\/[^/]+\/\.docker\/run\/docker\.sock$/.test(endpoint);
68
+ }
69
+
70
+ /**
71
+ * Check (and once repair) the functions mount of a re-adopted idle lease.
72
+ * `lease` is the re-adoption answer: `{ execution_mode, connection: { project_id, botbuddy_docker_target? } }`.
73
+ * Returns `{ ok: true, skipped? , restarted? }` or `{ ok: false, restarted, reason }`. Never throws.
74
+ */
75
+ export async function checkReadoptedStackMounts(lease, stackDir, { run = runDocker, listHost = (dir) => readdirSync(dir), log = () => {}, platform = process.platform } = {}) {
76
+ if (!CO_LOCATED_MODES.has(lease?.execution_mode)) return { ok: true, skipped: "remote" };
77
+ const projectId = lease?.connection?.project_id;
78
+ if (typeof projectId !== "string" || !PROJECT_ID_RE.test(projectId)) return { ok: true, skipped: "no_project_id" };
79
+ if (typeof stackDir !== "string" || !stackDir) return { ok: true, skipped: "no_stack_dir" };
80
+ const hostFunctions = join(stackDir, "supabase", "functions");
81
+ let hostEntries;
82
+ try { hostEntries = listHost(hostFunctions); } catch { return { ok: true, skipped: "no_functions" }; }
83
+ if (!Array.isArray(hostEntries) || hostEntries.length === 0) return { ok: true, skipped: "no_functions" };
84
+
85
+ const docker = dockerSelectorArgs(lease.connection.botbuddy_docker_target ?? null);
86
+ const container = `supabase_edge_runtime_${projectId}`;
87
+ const inspect = await run([...docker, "container", "inspect", "--format", "{{json .Mounts}}", container]);
88
+ if (inspect.code !== 0) {
89
+ // Only a definitively absent runtime (edge_runtime disabled) is skipped. Any other
90
+ // failure — daemon/context down, permissions, a timeout — fails closed (Codex #1191 P2):
91
+ // the caller re-provisions rather than hand over a stack nobody could check.
92
+ // Real counterpart (Docker Desktop, 2026-10-16): "Error response from daemon: No such container: <name>".
93
+ if (!inspect.timedOut && /No such (container|object)/i.test(inspect.stderr ?? "")) return { ok: true, skipped: "no_edge_runtime" };
94
+ return { ok: false, restarted: false, reason: `could not inspect ${container} (exit ${inspect.code}${inspect.timedOut ? ", timed out" : ""})` };
95
+ }
96
+ let destination = functionsMountDestination(inspect.stdout, hostFunctions);
97
+ if (!destination) {
98
+ const desktopDestination = functionsMountDestination(inspect.stdout, hostFunctions, { dockerDesktop: true });
99
+ if (desktopDestination && await isSelectedDockerDesktop(lease.connection.botbuddy_docker_target, run, platform)) {
100
+ destination = desktopDestination;
101
+ }
102
+ }
103
+ if (!destination) return { ok: false, restarted: false, reason: `${container} does not bind-mount ${hostFunctions}` };
104
+
105
+ const mountedEntries = async () => {
106
+ const answer = await run([...docker, "exec", container, "ls", "-A", destination]);
107
+ if (answer.code !== 0) return null;
108
+ return answer.stdout.split(/\r?\n/).filter((line) => line.length > 0).length;
109
+ };
110
+ const first = await mountedEntries();
111
+ if (first) return { ok: true, restarted: false };
112
+
113
+ log(`stack: the re-adopted stack's edge runtime sees ${first === null ? "no readable" : "an empty"} functions mount (${hostEntries.length} entries on the host) — restarting ${container} once`);
114
+ const restart = await run([...docker, "restart", container], { timeoutMs: RUNTIME_RESTART_TIMEOUT_MS });
115
+ if (restart.code !== 0) {
116
+ return { ok: false, restarted: false, reason: `docker restart ${container} failed (exit ${restart.code}${restart.timedOut ? ", timed out" : ""})` };
117
+ }
118
+ const second = await mountedEntries();
119
+ if (second) return { ok: true, restarted: true };
120
+ return { ok: false, restarted: true, reason: `${container} still sees ${second === null ? "no readable" : "an empty"} functions mount after a restart` };
121
+ }
@@ -27,10 +27,29 @@ export const SUPABASE_INTERNAL_ROLES = Object.freeze([
27
27
 
28
28
  const ROLE_LIST = SUPABASE_INTERNAL_ROLES.map((r) => `'${r}'`).join(", ");
29
29
 
30
+ /**
31
+ * BOT-2402: Supabase's own internal database. Logflare (`supabase_analytics_<project>`)
32
+ * writes its log tables there as `postgres` every ~10 s, so on a stack with analytics
33
+ * enabled every observation looked like fresh client use. Nothing a client does to the
34
+ * project happens there; the Helper census excludes it too (helper 0.2.39).
35
+ */
36
+ export const SUPABASE_INTERNAL_DATABASE = "_supabase";
37
+
38
+ /**
39
+ * BOT-2402: a pg_cron job run is a libpq connection the scheduler opens to its own server
40
+ * (unix socket or loopback) as the job owner, usually `postgres`, every minute. It is the
41
+ * stack's own schedule, not a client (the Helper census rule, BOT-2219). A backend that only
42
+ * borrows the name from another address is still a client.
43
+ */
44
+ const NOT_CRON_RUN = "NOT (coalesce(application_name, '') = 'pg_cron' AND (client_addr IS NULL OR host(client_addr) IN ('127.0.0.1', '::1')))";
45
+ const NOT_PLATFORM = `datname IS DISTINCT FROM '${SUPABASE_INTERNAL_DATABASE}' AND ${NOT_CRON_RUN}`;
46
+
30
47
  /**
31
48
  * Two values, `|`-separated (psql -tA):
32
49
  * 1. client_connections — client backends doing work now (state <> 'idle'), excluding
33
- * this probe and every internal role except `authenticator`: a PostgREST request in
50
+ * this probe, anything on the internal `_supabase` database, pg_cron's own job runs
51
+ * (BOT-2402) and every
52
+ * internal role except `authenticator`: a PostgREST request in
34
53
  * flight is use now (Codex #1160 P1); its pooled sessions sitting idle are not.
35
54
  * 2. last_client_query_at — the latest state change of any client backend, counting
36
55
  * `authenticator` (PostgREST traffic IS use), ISO-8601 UTC; empty when none.
@@ -38,9 +57,11 @@ const ROLE_LIST = SUPABASE_INTERNAL_ROLES.map((r) => `'${r}'`).join(", ");
38
57
  export const USAGE_SQL =
39
58
  "SELECT " +
40
59
  "(SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend' AND pid <> pg_backend_pid() " +
60
+ `AND ${NOT_PLATFORM} ` +
41
61
  `AND (coalesce(usename, '') NOT IN (${ROLE_LIST}) OR usename = 'authenticator') AND state <> 'idle'), ` +
42
62
  "(SELECT to_char(max(state_change) AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') FROM pg_stat_activity " +
43
63
  "WHERE backend_type = 'client backend' AND pid <> pg_backend_pid() " +
64
+ `AND ${NOT_PLATFORM} ` +
44
65
  `AND (coalesce(usename, '') NOT IN (${ROLE_LIST}) OR usename = 'authenticator'))`;
45
66
 
46
67
  const CO_LOCATED_MODES = new Set(["local", "local_exec"]);
package/src/stack.mjs CHANGED
@@ -44,6 +44,7 @@ import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from
44
44
  import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
45
45
  import { LEASED_STACK_IDLE_RETENTION_SECS, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
46
46
  import { measureStackUsage } from "./stack-usage.mjs";
47
+ import { checkReadoptedStackMounts } from "./stack-mount-health.mjs";
47
48
  import { machineUuid } from "./machine-id.mjs";
48
49
  import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle, validateBundleIncludePath } from "./stack-bundle.mjs";
49
50
  import { assertSshTarget, establishSshTunnel } from "./stack-tunnel.mjs";
@@ -218,7 +219,11 @@ ${bold("EXIT CODES")}
218
219
  ${bold("IDLE LEASES")} (BOT-1803)
219
220
  stack run sends stack_digest (the deterministic sha256 of the materialised stack bundle, also
220
221
  with --local-only; omitted when it cannot be built) and the control plane answers with one of:
221
- reused_idle the slot's idle lease is re-adopted: no provision; receipt reused "idle"
222
+ reused_idle the slot's idle lease is re-adopted: no provision; receipt reused "idle".
223
+ A co-located one is handed over only when its edge runtime still sees the
224
+ functions (BOT-2403): an empty mount restarts the runtime once; still empty →
225
+ the lease is reaped and the request re-sent once (reprovisioned
226
+ "unhealthy_mount"); receipt readopt_health {ok, restarted|skipped, reason}
222
227
  IDLE_LEASE_REPROVISIONING / LEASE_PREEMPTED
223
228
  the idle lease is being reaped (content changed / capacity reclaimed): run
224
229
  waits for it to reach reaped (--reap-timeout), re-sends the same request
@@ -735,7 +740,7 @@ async function claimProjectLock(projectId, lock, subject) {
735
740
  }
736
741
  }
737
742
 
738
- export async function resolveRunExecution(cwd, opts, { materialize = materializeRunStackDir, lock = acquireStackLock, identity = leasedStackIdentity, reserveBlocks = reservePortBlocks, releaseClaim = releaseLeasedStackClaim, claimHeld = leasedStackClaimHeld, stableReusable = stableRunStackReusable, markReleased = markLeasedStackReleased, portRegistryDir = null } = {}) {
743
+ export async function resolveRunExecution(cwd, opts, { materialize = materializeRunStackDir, lock = acquireStackLock, identity = leasedStackIdentity, reserveBlocks = reservePortBlocks, releaseClaim = releaseLeasedStackClaim, claimHeld = leasedStackClaimHeld, stableReusable = stableRunStackReusable, markReleased = markLeasedStackReleased, portRegistryDir = null, warn = (line) => process.stderr.write(`${line}\n`) } = {}) {
739
744
  const refuseRoot = (execution) => {
740
745
  if (execution.stackPath === ".") {
741
746
  throw new Error(
@@ -813,7 +818,8 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
813
818
  const execution = refuseRoot(resolveStackPath(cwd, materialize(
814
819
  cwd,
815
820
  { slot: opts.slot, repo: opts.repo, ticket: opts.ticket, nonce, idleTtlSecs: opts.idleTtl ?? undefined },
816
- portRegistryDir ? { portRegistryDir } : {},
821
+ // BOT-2403: a functions directory another writer replaced under this stack is reported.
822
+ { ...(portRegistryDir ? { portRegistryDir } : {}), warn },
817
823
  )));
818
824
  return {
819
825
  ...execution,
@@ -2893,6 +2899,8 @@ export async function cmdDone(leaseId, opts, {
2893
2899
  const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
2894
2900
  /** BOT-1803: request answers meaning "this slot's idle lease is being reaped; re-request once it is". */
2895
2901
  const IDLE_REPROVISION_REASONS = Object.freeze({ IDLE_LEASE_REPROVISIONING: "digest_mismatch", LEASE_PREEMPTED: "preempted" });
2902
+ // BOT-2403: the re-adopted idle stack failed the functions-mount gate and was reaped.
2903
+ const READOPT_UNHEALTHY_REASON = "unhealthy_mount";
2896
2904
 
2897
2905
  function safeConnectionPath(requested, leaseId) {
2898
2906
  if (requested) {
@@ -3121,6 +3129,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3121
3129
  // stack directory; the default is the real materializing resolver.
3122
3130
  const resolveExecution = adapters.resolveExecution ?? ((runOpts) => resolveRunExecution(process.cwd(), runOpts));
3123
3131
  const refreshRetention = adapters.refreshRetention ?? refreshLeasedStackRetention;
3132
+ // BOT-2403: a re-adopted co-located idle stack must see its functions before it is handed over.
3133
+ const readoptHealth = adapters.readoptHealth ?? ((lease, stackDir) => checkReadoptedStackMounts(lease, stackDir, { log: stderrLine }));
3124
3134
  // BOT-2142: remote-lease seams (bundle build/upload, SSH forwards). Production uses the
3125
3135
  // real builders; the fake-ssh tests swap only the ssh binary (BOTBUDDY_STACK_SSH_BIN works too).
3126
3136
  const buildBundle = adapters.buildBundle ?? buildStackBundle;
@@ -3152,7 +3162,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3152
3162
  const placement = { mode: null, hostKey: null, requesterHost: null, transport: "none", forwards: null, bundle: null, placementReason: null, considered: null };
3153
3163
  incidentBox.execution = placement;
3154
3164
  // BOT-1803: idle-lease bookkeeping for the receipt.
3155
- const idleLease = { reused: null, reprovisioned: null, previousLeaseId: null, stackDigest: null };
3165
+ const idleLease = { reused: null, reprovisioned: null, previousLeaseId: null, stackDigest: null, readoptHealth: null };
3156
3166
  incidentBox.idleLease = idleLease;
3157
3167
  // BOT-1803: set when this run's port block must stay reserved although nothing of ours
3158
3168
  // was provisioned (the slot's idle/live stack in THIS directory is still bound to it).
@@ -3435,16 +3445,43 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3435
3445
  if (request?.status == null) leaseProvisioned = true;
3436
3446
  return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
3437
3447
  }
3438
- const why = attempt === 0 && request.data?.success === false && request.data?.lease_id ? IDLE_REPROVISION_REASONS[request.data.code] : null;
3448
+ let why = attempt === 0 && request.data?.success === false && request.data?.lease_id ? IDLE_REPROVISION_REASONS[request.data.code] : null;
3449
+ // BOT-2403: a re-adopted idle stack is handed over only when its edge runtime still sees
3450
+ // the functions (the gate restarts the runtime once). Still broken → reap it and send the
3451
+ // same request once more, exactly like a digest re-provision.
3452
+ if (!why && attempt === 0 && request.data?.success === true && request.data.reused === true && request.data.reused_idle === true) {
3453
+ let health;
3454
+ try { health = await readoptHealth(request.data, stackDirFor(execution)); } catch (error) { health = { ok: false, reason: String(error?.message ?? error) }; }
3455
+ idleLease.readoptHealth = health;
3456
+ if (!health?.ok) {
3457
+ const broken = request.data.lease_id;
3458
+ stderrLine(`stack: re-adopted idle lease ${broken} is not healthy (${health?.reason ?? "unknown"}) — reaping it and provisioning a fresh stack`);
3459
+ const released = await api.release(broken);
3460
+ if (!released?.ok || released.data?.success === false) {
3461
+ retainBlock = true;
3462
+ return {
3463
+ exitCode: EXIT.LEASE_FAILED, outcome: "error", code: "READOPT_UNHEALTHY", leaseId: broken,
3464
+ error: `re-adopted idle lease ${broken} is not healthy (${health?.reason ?? "unknown"}) and its reap could not be requested ` +
3465
+ `(${released?.data?.code ?? released?.error ?? "release failed"}); run \`bb stack done ${broken} --reap\` and re-run`,
3466
+ };
3467
+ }
3468
+ why = READOPT_UNHEALTHY_REASON;
3469
+ }
3470
+ }
3439
3471
  if (!why) break;
3440
3472
  const previous = request.data.lease_id;
3441
3473
  idleLease.previousLeaseId = previous;
3442
- stderrLine(`stack: this slot's idle lease ${previous} is being ${why === "preempted" ? "preempted" : "re-provisioned (the stack content changed)"} — waiting for it to reach reaped, then requesting again`);
3474
+ const becoming = why === "preempted" ? "preempted"
3475
+ : why === READOPT_UNHEALTHY_REASON ? "reaped (its edge runtime lost the functions mount)" : "re-provisioned (the stack content changed)";
3476
+ stderrLine(`stack: this slot's idle lease ${previous} is being ${becoming} — waiting for it to reach reaped, then requesting again`);
3443
3477
  activeWaitAbort = new AbortController();
3444
3478
  const gone = await resilientWait(previous, (st) => st === "reaped", (st) => st === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
3445
3479
  activeWaitAbort = null;
3446
3480
  if (gone?.interrupted || receivedSignal) {
3447
- return { exitCode: SIGNAL_EXIT[receivedSignal] ?? EXIT.INTERNAL, outcome: "interrupted", code: request.data.code };
3481
+ // Codex #1191 P2: the old lease's reap is not proven, so its containers may still hold
3482
+ // this stack's ports — keep the block (never double-booked by the next run).
3483
+ retainBlock = true;
3484
+ return { exitCode: SIGNAL_EXIT[receivedSignal] ?? EXIT.INTERNAL, outcome: "interrupted", code: request.data.code ?? "READOPT_UNHEALTHY" };
3448
3485
  }
3449
3486
  let reaped = Boolean(gone?.woke) && gone.state === "reaped";
3450
3487
  if (!reaped && !gone?.failed) {
@@ -3456,7 +3493,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3456
3493
  // Its containers may still hold this stack's ports.
3457
3494
  retainBlock = true;
3458
3495
  return {
3459
- exitCode: EXIT.LEASE_FAILED, outcome: "error", code: request.data.code,
3496
+ exitCode: EXIT.LEASE_FAILED, outcome: "error", code: request.data.code ?? "READOPT_UNHEALTHY",
3460
3497
  error: `this slot's idle lease ${previous} did not reach reaped ${gone?.failed ? "(its reap failed)" : `within ${opts.reapTimeout}s`} ` +
3461
3498
  `after ${why}; no new lease was requested — check \`bb stack status ${previous}\` and re-run once it is reaped`,
3462
3499
  ...(parkIncident ? { incident: parkIncident } : {}),
@@ -3892,6 +3929,7 @@ export function runReceiptFields(result) {
3892
3929
  ...(result.idleLease?.reprovisioned ? { reprovisioned: result.idleLease.reprovisioned } : {}),
3893
3930
  ...(result.idleLease?.previousLeaseId ? { previous_lease_id: result.idleLease.previousLeaseId } : {}),
3894
3931
  ...(result.idleLease?.stackDigest ? { stack_digest: result.idleLease.stackDigest } : {}),
3932
+ ...(result.idleLease?.readoptHealth ? { readopt_health: result.idleLease.readoptHealth } : {}),
3895
3933
  ...(result.parkReason ? { park_reason: result.parkReason } : {}),
3896
3934
  // BOT-2142: where the lease ran and how the child reached it.
3897
3935
  execution_mode: result.execution?.mode ?? null,