@botbuddy/cli 1.32.3 → 1.33.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/src/stack.mjs CHANGED
@@ -34,13 +34,17 @@ import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
34
34
  import { resolveAgentSessionCredential } from "./agent-session.mjs";
35
35
  import { clearAgentState } from "./agent-state.mjs";
36
36
  import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
37
- import { projectIdFromConfig } from "./stack-file-lock.mjs";
37
+ import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
38
+ import { assertLeasedStackIsolated, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
38
39
  import { machineUuid } from "./machine-id.mjs";
39
40
  import { bold, dim, yellow } from "./utils.mjs";
40
41
 
41
42
  export const STACK_SCHEMA_VERSION = 1;
42
43
  export const DEFAULT_RECEIPT_MAX_BYTES = 10240;
43
44
  export const DEFAULT_TIMEOUT_SEC = 3600;
45
+ /** How often a run renews its stack directory's retention while it still exists
46
+ * (BOT-1798): frequent enough to cover the pre-child queue/provision waits. */
47
+ const RETENTION_REFRESH_MS = 5 * 60 * 1000;
44
48
 
45
49
  // Meaningful, documented exit codes (mirrors bb-wait's taxonomy).
46
50
  export const EXIT = Object.freeze({
@@ -98,6 +102,11 @@ ${bold("done OPTIONS")}
98
102
  ${bold("run OPTIONS")}
99
103
  --repo <repo> Required approved repository name.
100
104
  --ticket <BOT-123> Required ticket for the batch.
105
+ --stack-path <relative> Optional PREPARED isolated stack dir. Omit it (the norm): run
106
+ materializes .botbuddy/stacks/<project_id>/ from this worktree's
107
+ supabase/ tree with a distinct project_id + remapped ports and
108
+ leases that. "." is REFUSED — the worktree root is the shared
109
+ canonical project and the Helper would start/clobber it.
101
110
  --provision-timeout N Seconds to wait for a physical Helper provision (default: --timeout).
102
111
  --reap-timeout N Seconds to wait for signed physical reap proof (default: 300).
103
112
  --connection-file P Optional empty file path for the mode-0600 connection JSON.
@@ -162,7 +171,7 @@ export function parseStackArgs(argv) {
162
171
  const childArgv = divider === -1 ? [] : rawRest.slice(divider + 1);
163
172
  const opts = {
164
173
  slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
165
- prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".",
174
+ prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
166
175
  timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
167
176
  connectionFile: null, noWait: false, localExec: false,
168
177
  dockerContext: null, dockerEndpoint: null,
@@ -187,7 +196,7 @@ export function parseStackArgs(argv) {
187
196
  case "--pr": if (need(a, rest[i + 1])) opts.prId = rest[++i]; break;
188
197
  case "--pr-url": if (need(a, rest[i + 1])) opts.prUrl = rest[++i]; break;
189
198
  case "--purpose": if (need(a, rest[i + 1])) opts.purpose = rest[++i]; break;
190
- case "--stack-path": if (need(a, rest[i + 1])) opts.stackPath = rest[++i]; break;
199
+ case "--stack-path": if (need(a, rest[i + 1])) { opts.stackPath = rest[++i]; opts.stackPathExplicit = true; } break;
191
200
  case "--idle-ttl": {
192
201
  if (need(a, rest[i + 1])) {
193
202
  const n = Number(rest[++i]);
@@ -273,6 +282,16 @@ export function parseStackArgs(argv) {
273
282
  errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
274
283
  }
275
284
  if (command === "run") {
285
+ // BOT-1798: `run` is Helper-backed — the Helper runs `supabase start` in
286
+ // <worktree_root>/<stack_path>, so "." is the SHARED canonical project. The
287
+ // DEFAULT is not an error (run materializes its own isolated stack); asking
288
+ // for the worktree root explicitly is, and says so like the BOT-1711 refusal.
289
+ if (opts.stackPathExplicit && opts.stackPath === ".") {
290
+ errors.push("run requires --stack-path <isolated-stack-dir>, not \".\": the worktree root declares the shared canonical " +
291
+ "project, so a lease there makes the Helper start (and clobber) the shared dev stack. Omit --stack-path to let " +
292
+ "`stack run` materialize an isolated stack, or point it at a directory with its own supabase/config.toml " +
293
+ "(distinct project_id + remapped ports).");
294
+ }
276
295
  if (!opts.repo) errors.push("run needs --repo <approved-repo>");
277
296
  if (!opts.ticket || !/^[A-Z][A-Z0-9]*-\d+$/i.test(opts.ticket)) errors.push("run needs --ticket <BOT-n|ENT-n>");
278
297
  if (divider === -1 || childArgv.length === 0) errors.push("run needs an executable after --");
@@ -324,6 +343,137 @@ export function resolveStackPath(cwd = process.cwd(), stackPath = ".") {
324
343
  return { worktreeRoot: root, stackPath: inside || "." };
325
344
  }
326
345
 
346
+ /**
347
+ * BOT-1798 — resolve the stack directory a Helper-backed `stack run` will lease.
348
+ *
349
+ * With no `--stack-path` (the default, and what every caller uses) the CLI
350
+ * MATERIALIZES an isolated stack inside the worktree and leases that, instead of
351
+ * handing the Helper the worktree root — where `supabase start` operates the
352
+ * SHARED canonical project, collides with the running shared stack, fails
353
+ * provisioning with an unusable `supabase status`, and leaves an unreapable
354
+ * lease. An explicitly prepared isolated directory is honoured verbatim.
355
+ *
356
+ * Fail closed: whatever produced the path, a `run` lease at `"."` is refused.
357
+ *
358
+ * Each invocation gets its OWN stack directory and project id (BOT-1798, Codex
359
+ * #928 R3/R5): a second `run` on the same worktree+slot must never delete and
360
+ * recopy the `supabase/functions` tree a live batch's edge runtime is
361
+ * bind-mounting — and that batch stays live even when its OWNER process is gone
362
+ * (the child is detached, and the containers and server lease survive to the idle
363
+ * TTL), so no liveness check on this side could make a shared directory safe. The
364
+ * per-project lock is still taken and held for the whole batch, so the directory
365
+ * is visibly claimed while it is in use; a run's lease is released with `destroy`,
366
+ * so nothing is lost by not reusing the previous invocation's volume.
367
+ */
368
+ async function claimProjectLock(projectId, lock, subject) {
369
+ try {
370
+ return await lock(lockPathForProject(projectId), { timeoutMs: 0 });
371
+ } catch (error) {
372
+ const busy = new Error(
373
+ `refusing to lease ${subject}: its project lock is already held — ${error?.message ?? error}. ` +
374
+ "Wait for that batch to finish, or re-run to get a fresh stack identity.",
375
+ );
376
+ busy.code = "stack_slot_busy";
377
+ throw busy;
378
+ }
379
+ }
380
+
381
+ export async function resolveRunExecution(cwd, opts, { materialize = materializeRunStackDir, lock = acquireStackLock, identity = leasedStackIdentity, reserveBlocks = reservePortBlocks, releaseClaim = releaseLeasedStackClaim, portRegistryDir = null } = {}) {
382
+ const refuseRoot = (execution) => {
383
+ if (execution.stackPath === ".") {
384
+ throw new Error(
385
+ 'refusing to request a stack lease with stack_path "." — the Helper would run `supabase start` at the worktree root, ' +
386
+ "which declares the shared canonical project. Pass --stack-path <isolated-stack-dir>, or let `stack run` materialize one.",
387
+ );
388
+ }
389
+ return execution;
390
+ };
391
+ // An explicitly prepared directory is the caller's to manage, but it is NOT
392
+ // exempt from the isolation contract (BOT-1798, Codex #928 R8 P1): a copied root
393
+ // config — same project_id, or merely the same ports — would make the Helper
394
+ // start the SHARED canonical project from that directory, exactly the accident
395
+ // this ticket exists to close. Prove it before anything is leased.
396
+ if (opts.stackPath && opts.stackPath !== ".") {
397
+ const execution = refuseRoot(resolveStackPath(cwd, opts.stackPath));
398
+ const configOf = (dir) => {
399
+ try {
400
+ return readFileSync(`${dir}/supabase/config.toml`, "utf8");
401
+ } catch (error) {
402
+ throw new Error(
403
+ `refusing to lease --stack-path ${opts.stackPath}: ${dir}/supabase/config.toml is missing or unreadable ` +
404
+ `(${error?.code ?? error?.message ?? error}), so its isolation from the shared canonical stack cannot be proven.`,
405
+ );
406
+ }
407
+ };
408
+ const stackConfig = configOf(stackDirFor(execution));
409
+ assertLeasedStackIsolated(configOf(execution.worktreeRoot), stackConfig);
410
+ // Claim the prepared stack for this batch too (Codex #928 R17 P2): two runs on
411
+ // DIFFERENT tickets pointed at one directory are not serialized by their server
412
+ // slots, so both Helpers would `supabase start` the same project and ports.
413
+ const prepared = projectIdFromConfig(stackConfig);
414
+ const held = await claimProjectLock(prepared, lock, `--stack-path ${opts.stackPath}`);
415
+ // …and reserve the HOST blocks its ports occupy (Codex #928 R18 P2): the
416
+ // project lock only serializes runs that share a project id, so a second
417
+ // prepared directory with a different id but overlapping ports — or one
418
+ // overlapping a materialized stack — would still collide on the machine.
419
+ let ports;
420
+ try {
421
+ ports = reserveBlocks([...portMapInConfig(stackConfig).values()], {
422
+ ...(portRegistryDir ? { registryDir: portRegistryDir } : {}),
423
+ owner: {
424
+ projectId: prepared, ticket: opts.ticket, slot: opts.slot,
425
+ stackDir: stackDirFor(execution), idleTtlSecs: opts.idleTtl, invocationToken: randomUUID(),
426
+ },
427
+ });
428
+ } catch (error) {
429
+ held.release();
430
+ throw error;
431
+ }
432
+ return {
433
+ ...execution,
434
+ // A prepared directory carries no retention marker, so its reservation is
435
+ // renewed here on the batch's own beat (Codex #928 R21 P2).
436
+ renew: (options) => ports.renew(options),
437
+ release: ({ reaped = false } = {}) => {
438
+ if (reaped) ports.release();
439
+ held.release();
440
+ },
441
+ };
442
+ }
443
+ const worktreeRoot = realpathSync(cwd);
444
+ // One nonce per invocation, shared by the identity and the materialization below.
445
+ const nonce = opts.stackNonce ?? randomUUID();
446
+ const { projectId } = identity({ worktreeRoot, slot: opts.slot, ticket: opts.ticket, nonce });
447
+ const held = await claimProjectLock(projectId, lock, `this batch's isolated stack (${projectId})`);
448
+ try {
449
+ const execution = refuseRoot(resolveStackPath(cwd, materialize(
450
+ cwd,
451
+ { slot: opts.slot, repo: opts.repo, ticket: opts.ticket, nonce, idleTtlSecs: opts.idleTtl ?? undefined },
452
+ portRegistryDir ? { portRegistryDir } : {},
453
+ )));
454
+ return {
455
+ ...execution,
456
+ // Release is called once the batch's signed reap is done, so hand the host
457
+ // its port block back immediately rather than holding it for the retention
458
+ // window this batch no longer needs (Codex #928 R19 P2).
459
+ // The host block goes back only on a PROVEN reap (Codex #928 R20 P2): if the
460
+ // signed cleanup failed or timed out, the Helper may still be provisioning
461
+ // or running containers, so the reservation must stand until its retention
462
+ // expires. The project lock is always released — it guards this directory,
463
+ // which no other invocation can target.
464
+ release: ({ reaped = false } = {}) => {
465
+ if (reaped) {
466
+ try { releaseClaim(stackDirFor(execution), portRegistryDir ? { registryDir: portRegistryDir } : undefined); } catch { /* advisory */ }
467
+ }
468
+ held.release();
469
+ },
470
+ };
471
+ } catch (error) {
472
+ held.release();
473
+ throw error;
474
+ }
475
+ }
476
+
327
477
  /** Parse `supabase status -o json` (or the plain key/value fallback) into a connection block. */
328
478
  export function parseSupabaseStatus(text) {
329
479
  const t = String(text || "");
@@ -868,55 +1018,11 @@ function projectIdUnder(dir, read = readFileSync) {
868
1018
  catch { return null; }
869
1019
  }
870
1020
 
871
- /** Every port a `config.toml` allocates, as a SECTION-QUALIFIED map `"<section>.<key>" ->
872
- * "<port>"` (e.g. `api.port`, `db.shadow_port`, `inbucket.pop3_port`). Section-qualified so
873
- * the SAME `port` key under [api]/[db]/[studio]/[inbucket] stays distinct, which lets a
874
- * target be checked for BOTH completeness (declares every port the root does) and
875
- * disjointness (shares no port value). */
876
- // Normalize any valid TOML integer literal to its decimal string: decimal (with `_`
877
- // separators), or `0x`/`0o`/`0b` radix forms. Returns null for a non-integer. Without this
878
- // a hex/octal port (`0xdc01` == 56321) or a separated one (`56_321`) would parse as a
879
- // truncated value and bypass the port-collision checks while Supabase binds the full port
880
- // (BOT-1711 Codex R11/R14).
881
- function tomlIntToDecimal(token) {
882
- const cleaned = String(token).replace(/_/g, "");
883
- const n = Number(cleaned);
884
- return Number.isInteger(n) && n >= 0 ? String(n) : null;
885
- }
886
-
887
- // TOML decimal integers may carry a leading sign (`+56321`); radix forms may not. A
888
- // negative value is rejected downstream by tomlIntToDecimal (BOT-1711 Codex R15).
889
- const TOML_INT_PORT = "(0[xX][0-9A-Fa-f_]+|0[oO][0-7_]+|0[bB][01_]+|[+-]?[0-9][0-9_]*)";
890
-
891
- function portMapInConfig(toml) {
892
- const map = new Map();
893
- let section = "";
894
- const bare = new RegExp(`^((?:[A-Za-z0-9]+_)?port)\\s*=\\s*${TOML_INT_PORT}`);
895
- // Dotted TOML key form (BOT-1711 R16): `api.port = N` / `db.shadow_port = N`, section-
896
- // qualified inline instead of under a `[section]` header. Normalizes to the same key.
897
- const dotted = new RegExp(`^([A-Za-z0-9_]+)\\.((?:[A-Za-z0-9]+_)?port)\\s*=\\s*${TOML_INT_PORT}`);
898
- for (const raw of String(toml).split(/\r?\n/)) {
899
- const line = raw.trim();
900
- if (line.startsWith("#")) continue; // comments are not configuration
901
- const sec = /^\[([^\]]+)\]/.exec(line);
902
- if (sec) { section = sec[1].trim(); continue; }
903
- const dot = dotted.exec(line);
904
- if (dot) {
905
- const dec = tomlIntToDecimal(dot[3]);
906
- if (dec != null) map.set(`${dot[1]}.${dot[2]}`, dec);
907
- continue;
908
- }
909
- const m = bare.exec(line);
910
- if (m) {
911
- const dec = tomlIntToDecimal(m[2]);
912
- if (dec != null) map.set(`${section}.${m[1]}`, dec);
913
- }
914
- }
915
- return map;
916
- }
1021
+ // BOT-1798: `portMapInConfig` (the section-qualified port map these isolation
1022
+ // checks read) now lives in ./stack-isolation.mjs, shared with the `stack run`
1023
+ // materializer that WRITES an isolated config one parser, so what the
1024
+ // materializer emits is exactly what these guards read back.
917
1025
 
918
- /** Read a stack directory's identity — project_id + its COMPLETE, section-qualified port
919
- * allocation — from its `supabase/config.toml`, in one read. Empty/null when unreadable. */
920
1026
  function readStackIdentity(dir, read = readFileSync) {
921
1027
  try {
922
1028
  const toml = read(`${dir}/supabase/config.toml`, "utf8");
@@ -1593,8 +1699,11 @@ function requireConnectionText(connection, key) {
1593
1699
 
1594
1700
  /** Materialise the established integration runner inputs from a Helper receipt.
1595
1701
  * The runner already understands BB_INTEGRATION_ENV_FILE / BB_STACK_CONFIG;
1596
- * never let its shared-stack defaults silently validate the wrong stack. */
1597
- export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connection, { writePrivate = writePrivateTextFile, removePrivate = (path) => unlink(path).catch(() => {}) } = {}) {
1702
+ * never let its shared-stack defaults silently validate the wrong stack.
1703
+ * `worktreeRoot` is where the committed env template is read from; `edgeMountRoot`
1704
+ * (default: the same) is the directory whose supabase/functions the running stack
1705
+ * bind-mounts — the provisioning dir for a materialized leased stack. */
1706
+ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connection, { writePrivate = writePrivateTextFile, removePrivate = (path) => unlink(path).catch(() => {}), edgeMountRoot = worktreeRoot } = {}) {
1598
1707
  const apiUrl = requireConnectionText(connection, "api_url");
1599
1708
  const anonKey = requireConnectionText(connection, "anon_key");
1600
1709
  const serviceRoleKey = requireConnectionText(connection, "service_role_key");
@@ -1635,7 +1744,12 @@ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connect
1635
1744
  await removePrivate(envFile);
1636
1745
  throw error;
1637
1746
  }
1638
- return { envFile, stackConfig, edgeMountRoot: worktreeRoot };
1747
+ // BOT-1798 (Codex #928 R2 P1): the edge-mount root is the directory whose
1748
+ // supabase/functions the stack actually bind-mounts — the PROVISIONING dir for a
1749
+ // materialized leased stack (like the hermetic lane's scratch root), not the
1750
+ // worktree root the env template is read from. Passing the bare root would make
1751
+ // the integration lane's drift guard abort before a single test ran.
1752
+ return { envFile, stackConfig, edgeMountRoot };
1639
1753
  }
1640
1754
 
1641
1755
  /**
@@ -1675,6 +1789,10 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1675
1789
  const writeConnection = adapters.writeConnection ?? writeConnectionFile;
1676
1790
  const removeConnection = adapters.removeConnection ?? ((path) => unlink(path).catch(() => {}));
1677
1791
  const materializeTestConfig = adapters.materializeTestConfig ?? materializeLeasedTestConfig;
1792
+ // BOT-1798: injectable so the lifecycle contract is testable without writing a
1793
+ // stack directory; the default is the real materializing resolver.
1794
+ const resolveExecution = adapters.resolveExecution ?? ((runOpts) => resolveRunExecution(process.cwd(), runOpts));
1795
+ const refreshRetention = adapters.refreshRetention ?? refreshLeasedStackRetention;
1678
1796
  const clock = adapters.clock ?? { setInterval, clearInterval, setTimeout, clearTimeout };
1679
1797
  const signals = adapters.signals ?? process;
1680
1798
  const provisionTimeout = opts.provisionTimeout ?? opts.timeout;
@@ -1683,10 +1801,13 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1683
1801
  let childResult = null;
1684
1802
  let connectionFile = null;
1685
1803
  let heartbeat = null;
1804
+ let retention = null;
1686
1805
  let hardTimer = null;
1687
1806
  let cleanupResult = null;
1688
1807
  let cleanupPromise = null;
1689
1808
  let leasedTestConfig = null;
1809
+ let execution = null;
1810
+ let leaseProvisioned = false;
1690
1811
  let receivedSignal = null;
1691
1812
  let forcedStopCode = null;
1692
1813
  let fencing = false;
@@ -1753,7 +1874,27 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1753
1874
  return { exitCode: EXIT.BACKEND, outcome: "error", error: "could not determine this machine's hardware id (needed to dispatch the stack lease to the right machine)" };
1754
1875
  }
1755
1876
  const slot = deriveSlot(opts);
1756
- const execution = resolveStackPath(process.cwd(), opts.stackPath);
1877
+ // BOT-1798: never "." — materialize/validate an ISOLATED stack dir first.
1878
+ try {
1879
+ execution = await resolveExecution({ ...opts, slot });
1880
+ } catch (error) {
1881
+ // A slot whose stack another live batch owns is a coordination refusal (the
1882
+ // same class as the server's duplicate-lease rejection), not bad arguments.
1883
+ const exitCode = error?.code === "stack_slot_busy" ? EXIT.BACKEND : EXIT.INVALID;
1884
+ return { exitCode, outcome: "error", error: String(error?.message ?? error) };
1885
+ }
1886
+ // BOT-1798 (Codex #928 R13 P2): the queue and provisioning waits can outlast the
1887
+ // directory's initial retention, and a kill during them leaves a lease the
1888
+ // Helper may still be provisioning. Keep the claim fresh from the moment the
1889
+ // stack exists, not from the moment the child starts.
1890
+ let leaseIdleTtlSecs = opts.idleTtl ?? 1800;
1891
+ const keepAlive = () => {
1892
+ refreshRetention(stackDirFor(execution), { idleTtlSecs: leaseIdleTtlSecs });
1893
+ execution.renew?.({ idleTtlSecs: leaseIdleTtlSecs });
1894
+ };
1895
+ keepAlive();
1896
+ retention = clock.setInterval(keepAlive, RETENTION_REFRESH_MS);
1897
+ retention?.unref?.();
1757
1898
  const leaseArgs = {
1758
1899
  slot, host_key: opts.host || undefined, repo: opts.repo, ticket_id: opts.ticket,
1759
1900
  ticket_url: opts.ticketUrl || undefined, pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
@@ -1764,7 +1905,18 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1764
1905
  // The retrying caller (api → rpc.call) transparently clears+self-heals+retries the
1765
1906
  // FIRST auth rejection among any of request/get/touch/release.
1766
1907
  const request = await api.request(leaseArgs);
1767
- if (!request?.ok) return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
1908
+ if (!request?.ok) {
1909
+ // Codex #928 R23 (P2): `status: null` (callToolJson's fetch-threw branch) means
1910
+ // NO response was ever received — the request may have reached the backend and
1911
+ // created a lease we simply never heard back about, so a Helper could already be
1912
+ // provisioning it. Any OTHER failure carries a real HTTP status: the backend was
1913
+ // reached and definitely refused (or errored) before creating anything of ours.
1914
+ // Only the definite case is safe to treat as unprovisioned and free the port
1915
+ // claim immediately below; an ambiguous transport failure retains it instead — it
1916
+ // still expires on its own TTL if nothing was actually provisioned.
1917
+ if (request?.status == null) leaseProvisioned = true;
1918
+ return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
1919
+ }
1768
1920
  if (!request.data?.success) {
1769
1921
  const hint = worktreeRegistrationHint(request.data?.code, rpc.auth);
1770
1922
  if (hint) process.stderr.write(`${hint}\n`);
@@ -1775,6 +1927,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1775
1927
  return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, error: "a live lease already exists for this agent and stack slot; wait for that batch to finish instead of sharing its stack" };
1776
1928
  }
1777
1929
 
1930
+ leaseProvisioned = true; // from here a Helper may act on this lease
1778
1931
  let state = request.data.state;
1779
1932
  if (receivedSignal) {
1780
1933
  const cleanup = await requestCleanup();
@@ -1812,7 +1965,9 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1812
1965
  const cleanup = await requestCleanup();
1813
1966
  return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
1814
1967
  }
1815
- if (isDenoIntegrationLane(childArgv)) leasedTestConfig = await materializeTestConfig(execution.worktreeRoot, leaseId, current.data.connection);
1968
+ if (isDenoIntegrationLane(childArgv)) {
1969
+ leasedTestConfig = await materializeTestConfig(execution.worktreeRoot, leaseId, current.data.connection, { edgeMountRoot: stackDirFor(execution) });
1970
+ }
1816
1971
  if (receivedSignal) {
1817
1972
  const cleanup = await requestCleanup();
1818
1973
  return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
@@ -1827,23 +1982,36 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1827
1982
  BB_EDGE_MOUNT_ROOT: leasedTestConfig.edgeMountRoot,
1828
1983
  } : {}),
1829
1984
  };
1830
- // resolveStackPath already canonicalised this bounded relative path.
1831
- const childCwd = execution.stackPath === "." ? execution.worktreeRoot : `${execution.worktreeRoot}/${execution.stackPath}`;
1985
+ // BOT-1798 (Codex #928 R1 P1): the lease's stack_path is the PROVISIONING
1986
+ // directory where the Helper runs `supabase start` — never the command's
1987
+ // working directory. The child is a repo command (`pnpm test:integration`),
1988
+ // and the materialized stack dir holds only a `supabase/` tree, so running it
1989
+ // there would fail on a missing package.json AFTER the stack was provisioned.
1990
+ // The child therefore always runs in the (canonicalised) worktree root.
1991
+ const childCwd = execution.worktreeRoot;
1832
1992
  child = startChild(childArgv, childEnv, childCwd);
1833
- const cadenceMs = Math.max(1_000, Math.floor((current.data.idle_ttl_secs ?? opts.idleTtl ?? 1800) * 1000 / 3));
1993
+ leaseIdleTtlSecs = current.data.idle_ttl_secs ?? opts.idleTtl ?? 1800;
1994
+ const cadenceMs = Math.max(1_000, Math.floor(leaseIdleTtlSecs * 1000 / 3));
1995
+ // BOT-1798 (Codex #928 R8 P2): the directory's retention tracks the LEASE's
1996
+ // last activity, not its creation, so a batch that outlives the default window
1997
+ // and then loses its owner process is never collected while it is still running.
1998
+ keepAlive();
1834
1999
  heartbeat = clock.setInterval(async () => {
1835
2000
  if (fencing || childResult) return;
1836
2001
  const touched = await api.touch(leaseId);
1837
2002
  if (!touched?.ok || !touched?.data?.success) {
1838
2003
  fencing = true;
1839
2004
  stopChild("SIGTERM", EXIT.LEASE_FAILED);
2005
+ return;
1840
2006
  }
2007
+ keepAlive();
1841
2008
  }, cadenceMs);
1842
2009
  heartbeat.unref?.();
1843
2010
  if (opts.hardTtl) hardTimer = clock.setTimeout(() => stopChild("SIGTERM", EXIT.TIMEOUT), opts.hardTtl * 1000);
1844
2011
  hardTimer?.unref?.();
1845
2012
  childResult = await waitForChild(child);
1846
2013
  if (heartbeat) clock.clearInterval(heartbeat);
2014
+ if (retention) { clock.clearInterval(retention); retention = null; }
1847
2015
  if (hardTimer) clock.clearTimeout(hardTimer);
1848
2016
  const cleanup = await requestCleanup();
1849
2017
  const exitCode = receivedSignal ? SIGNAL_EXIT[receivedSignal] : forcedStopCode ?? (childResult.code !== 0 ? childResult.code : !cleanup.ok ? EXIT.CLEANUP_FAILED : 0);
@@ -1858,9 +2026,20 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1858
2026
  return { exitCode, outcome: "error", leaseId, error: String(error?.message ?? error), cleanup };
1859
2027
  } finally {
1860
2028
  if (heartbeat) clock.clearInterval(heartbeat);
2029
+ if (retention) clock.clearInterval(retention);
1861
2030
  if (hardTimer) clock.clearTimeout(hardTimer);
1862
2031
  if (connectionFile) await removeConnection(connectionFile);
1863
2032
  if (leasedTestConfig) await Promise.all([removeConnection(leasedTestConfig.envFile), removeConnection(leasedTestConfig.stackConfig)]);
2033
+ // Release the materialization lock only once the batch (and its signed reap)
2034
+ // is finished — it is what keeps a second run off this stack directory. The
2035
+ // host port block is handed back only when the reap was actually PROVEN
2036
+ // (a null state means no lease was ever created, so nothing can be running).
2037
+ // A request the backend refused, or one that only re-attached to somebody
2038
+ // else's live lease, provisioned nothing of ours — its block must go back at
2039
+ // once, or repeated refusals would exhaust the host (Codex #928 R21 P2).
2040
+ const provenReap = !leaseProvisioned
2041
+ || (cleanupResult?.ok === true && (cleanupResult.state === "reaped" || cleanupResult.state == null));
2042
+ execution?.release?.({ reaped: provenReap });
1864
2043
  for (const [signal, handler] of signalHandlers) signals.off?.(signal, handler);
1865
2044
  }
1866
2045
  }
package/src/wait.mjs CHANGED
@@ -55,7 +55,13 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
55
55
  timer:duration=<seconds> wake after N seconds (DB-clocked server-side)
56
56
  timer:deadline=<ISO-8601> wake at an absolute time
57
57
  chat:channel=<id>|* new Direct Chat message (or * = any channel you are in;
58
- you are never woken by your own messages)
58
+ you are never woken by your own messages). Level-
59
+ triggered at arm: an unread reply from another
60
+ participant that already exists when you arm wakes
61
+ the wait immediately (payload.reason=unread_at_arm),
62
+ so send-then-arm never misses a fast reply. Respects
63
+ your read marker (mark_direct_chat_read) and your own
64
+ last message; a sibling session's reply still wakes.
59
65
  lock:subtype=<t>,host=<h>[,slot=<s>][,claim=true]
60
66
  a resource lock becomes available
61
67
  subtype ∈ playwright_lane|vite_port|backend_port|supabase_local
@@ -273,6 +279,12 @@ async function registerWait(opts, conditions, deadlineIso) {
273
279
  conditions,
274
280
  deadline: deadlineIso,
275
281
  mode: opts.mode,
282
+ // BOT-1753: always sent so the relay knows this client reports its replay
283
+ // boundary (a pre-1.32.3 client omits the key and the relay then skips the chat
284
+ // arm snapshot to avoid a chained-resume duplicate). "-1" = a fresh arm; a
285
+ // resume sends its `--since` cursor. The relay only arm-grants messages this
286
+ // wait will NOT replay itself.
287
+ since: String(opts.since ?? -1),
276
288
  ...(opts.localWaitId ? { local_wait_id: opts.localWaitId } : {}),
277
289
  ...(opts.expectedAgentId ? {
278
290
  expected_agent_id: opts.expectedAgentId,
@@ -422,6 +434,7 @@ async function registerWait(opts, conditions, deadlineIso) {
422
434
  return {
423
435
  waitSessionId: body.wait_session_id ?? null,
424
436
  cursorStart: body.cursor_start ?? null,
437
+ chatReplayFloor: body.chat_replay_floor ?? null,
425
438
  // BOT-1184: the server canonicalizes lock-condition hosts on register and
426
439
  // echoes them here so the client matches the canonical subject_key.
427
440
  conditions: Array.isArray(body.conditions) ? body.conditions : null,
@@ -1059,6 +1072,7 @@ async function resolveRecoveryWaitSession(checkpoint, { reserveMissing = false }
1059
1072
  registration: {
1060
1073
  waitSessionId: body.wait_session_id,
1061
1074
  cursorStart: body.cursor_start ?? null,
1075
+ chatReplayFloor: body.chat_replay_floor ?? null,
1062
1076
  sessionTenant: checkpoint.tenant_id ?? null,
1063
1077
  agentId: checkpoint.agent_id ?? null,
1064
1078
  sessionId: checkpoint.arming_session_id ?? null,
@@ -1749,6 +1763,15 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1749
1763
  // Arm from the registration high-water mark unless the caller pinned an
1750
1764
  // explicit --since (resume). cursor_start replays the register→connect gap.
1751
1765
  if (opts.since == null && reg.cursorStart != null) effectiveSince = String(reg.cursorStart);
1766
+ // BOT-1753 (Codex R7 F2): a still-unread chat arm grant a dead/sibling wait left
1767
+ // below our replay boundary. Replay from just before it so it isn't stranded —
1768
+ // for a fresh arm AND a --since resume. The relay only reports a floor for a
1769
+ // message that is genuinely still unread, so this never re-delivers a read reply.
1770
+ if (reg.chatReplayFloor != null && /^\d+$/.test(String(reg.chatReplayFloor))) {
1771
+ if (effectiveSince == null || BigInt(reg.chatReplayFloor) < BigInt(effectiveSince)) {
1772
+ effectiveSince = String(reg.chatReplayFloor);
1773
+ }
1774
+ }
1752
1775
  checkpoint = await store.update(checkpoint.local_wait_id, {
1753
1776
  status: "armed",
1754
1777
  cloud_wait_session_id: waitSessionId,