@botbuddy/cli 1.32.4 → 1.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/stack-isolation.mjs +1032 -0
- package/src/stack.mjs +239 -60
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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))
|
|
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
|
-
//
|
|
1831
|
-
|
|
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
|
-
|
|
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
|
}
|