@botbuddy/cli 1.60.2 → 1.60.4
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/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/stack-isolation.mjs +136 -0
- package/src/stack-run-outcome.mjs +2 -2
- package/src/stack.mjs +89 -8
- package/src/wait-core.mjs +17 -3
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.60.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.4",
|
|
3
|
+
"source_commit": "06faf46d8438c66ae116ce47eb5f343dfaca0c73",
|
|
4
|
+
"source_sha256": "9c1d721c1f0cfe90208bbc0960675053901326b324c7014c730769247d9d8c1c",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "ea3ec61a04791ef355abd7bb04e01ff0895f2603e57d9ef6e4a33614a140b12a"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.60.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.4",
|
|
3
|
+
"source_commit": "06faf46d8438c66ae116ce47eb5f343dfaca0c73",
|
|
4
|
+
"source_sha256": "9c1d721c1f0cfe90208bbc0960675053901326b324c7014c730769247d9d8c1c",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "ebe08409a8d9ea7a7292af476ea9d3d0acf91b8b09834b12e38a6833c0bf2ac5"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.60.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.60.4",
|
|
4
|
+
"source_commit": "06faf46d8438c66ae116ce47eb5f343dfaca0c73",
|
|
5
|
+
"source_sha256": "9c1d721c1f0cfe90208bbc0960675053901326b324c7014c730769247d9d8c1c",
|
|
6
6
|
"targets": {
|
|
7
7
|
"darwin-arm64": {
|
|
8
8
|
"goos": "darwin",
|
|
9
9
|
"goarch": "arm64",
|
|
10
10
|
"binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "ea3ec61a04791ef355abd7bb04e01ff0895f2603e57d9ef6e4a33614a140b12a",
|
|
12
12
|
"mode": "0755"
|
|
13
13
|
},
|
|
14
14
|
"linux-x64": {
|
|
15
15
|
"goos": "linux",
|
|
16
16
|
"goarch": "amd64",
|
|
17
17
|
"binary": "daemon/bin/linux-x64/botbuddy-daemon",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "ebe08409a8d9ea7a7292af476ea9d3d0acf91b8b09834b12e38a6833c0bf2ac5",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/stack-isolation.mjs
CHANGED
|
@@ -993,6 +993,7 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
|
|
|
993
993
|
|
|
994
994
|
mkdir(registryDir, { recursive: true });
|
|
995
995
|
const claimed = [];
|
|
996
|
+
let boundLeaseId = null;
|
|
996
997
|
const record = (block, at = now(), ttlSecs = owner?.idleTtlSecs, idleRetained = false) => JSON.stringify({
|
|
997
998
|
schema: 1,
|
|
998
999
|
project_id: owner?.projectId ?? null,
|
|
@@ -1001,6 +1002,7 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
|
|
|
1001
1002
|
slot: owner?.slot == null ? null : String(owner.slot),
|
|
1002
1003
|
stack_dir: owner?.stackDir ?? null,
|
|
1003
1004
|
port_base: block,
|
|
1005
|
+
...(boundLeaseId ? { lease_id: boundLeaseId } : {}),
|
|
1004
1006
|
created_at: at,
|
|
1005
1007
|
retain_until: at + Math.max(LEASED_STACK_GC_MIN_AGE_MS, (Number(ttlSecs) || DEFAULT_IDLE_TTL_SECS) * 1000 + LEASED_STACK_RETENTION_GRACE_MS),
|
|
1006
1008
|
owner_pid: process.pid,
|
|
@@ -1053,6 +1055,10 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
|
|
|
1053
1055
|
return {
|
|
1054
1056
|
blocks: claimed,
|
|
1055
1057
|
invocationToken,
|
|
1058
|
+
bindLease: (leaseId) => {
|
|
1059
|
+
bindLeasedStackClaim(owner.stackDir, leaseId, { registryDir, invocationToken });
|
|
1060
|
+
boundLeaseId = leaseId;
|
|
1061
|
+
},
|
|
1056
1062
|
/** Keep the reservation alive while the batch runs (Codex #928 R21): a
|
|
1057
1063
|
* prepared `--stack-path` has no directory marker, so nothing else renews it,
|
|
1058
1064
|
* and an aged-out claim could be reclaimed under a still-live stack. */
|
|
@@ -1084,6 +1090,136 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
|
|
|
1084
1090
|
};
|
|
1085
1091
|
}
|
|
1086
1092
|
|
|
1093
|
+
/** Read-only candidate lookup; accepted server proof and exact identity checks still gate removal. */
|
|
1094
|
+
export function hasLocalLeaseClaim(leaseId, { cwd = process.cwd(), registryDir = defaultPortRegistryDir() } = {}) {
|
|
1095
|
+
try {
|
|
1096
|
+
const root = realpathSync(cwd);
|
|
1097
|
+
return readdirSync(registryDir).filter((entry) => entry.endsWith(".json")).some((entry) => {
|
|
1098
|
+
try {
|
|
1099
|
+
const claim = JSON.parse(readFileSync(join(registryDir, entry), "utf8"));
|
|
1100
|
+
const rel = typeof claim.stack_dir === "string" ? relative(root, claim.stack_dir) : "..";
|
|
1101
|
+
return claim.lease_id === leaseId && claim.host === hostname() && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
1102
|
+
} catch { return false; }
|
|
1103
|
+
});
|
|
1104
|
+
} catch { return false; }
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function claimInventory(registryDir) {
|
|
1108
|
+
return readdirSync(registryDir).filter((name) => name.endsWith(".json")).map((name) => {
|
|
1109
|
+
// Allocation counts every JSON claim. Never report absence after overlooking
|
|
1110
|
+
// a retained record under an unexpected name.
|
|
1111
|
+
if (!/^\d+\.json$/.test(name)) throw new Error("claim inventory contains an invalid file name");
|
|
1112
|
+
const path = join(registryDir, name);
|
|
1113
|
+
const claim = JSON.parse(readFileSync(path, "utf8"));
|
|
1114
|
+
if (!claim || typeof claim !== "object" || !Number.isInteger(claim.port_base) || name !== `${claim.port_base}.json`) throw new Error("claim inventory is invalid");
|
|
1115
|
+
return { path, claim };
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** Exact lease-bound metadata remains usable when its old stack directory is gone. */
|
|
1120
|
+
export function leasedClaimContext(stackPath, leaseId, { cwd = process.cwd(), registryDir = defaultPortRegistryDir() } = {}) {
|
|
1121
|
+
try {
|
|
1122
|
+
const root = realpathSync(cwd);
|
|
1123
|
+
const claims = claimInventory(registryDir).filter(({ claim }) => {
|
|
1124
|
+
if (claim.lease_id !== leaseId || claim.host !== hostname() || typeof claim.stack_dir !== "string" || !isAbsolute(claim.stack_dir)) return false;
|
|
1125
|
+
const rel = relative(root, claim.stack_dir);
|
|
1126
|
+
return rel === stackPath && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
1127
|
+
});
|
|
1128
|
+
const first = claims[0]?.claim;
|
|
1129
|
+
return first && typeof first.project_id === "string" && /^[A-Za-z0-9_-]+$/.test(first.project_id) &&
|
|
1130
|
+
claims.every(({ claim }) => claim.project_id === first.project_id && claim.stack_dir === first.stack_dir)
|
|
1131
|
+
? { project: first.project_id, stackDir: first.stack_dir } : null;
|
|
1132
|
+
} catch { return null; }
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Bind an admitted lease to its exact invocation while the caller holds the project lock. */
|
|
1136
|
+
export function bindLeasedStackClaim(stackDir, leaseId, { registryDir = defaultPortRegistryDir(), invocationToken = null } = {}) {
|
|
1137
|
+
const marker = readStackMarker(stackDir, readFileSync);
|
|
1138
|
+
const token = invocationToken ?? marker?.invocation_token;
|
|
1139
|
+
if (!token || !leaseId) throw new Error("local port claim has no invocation identity");
|
|
1140
|
+
return mutateBoundClaims(stackDir, registryDir, (claims) => {
|
|
1141
|
+
if (claims.some(({ claim }) => claim.invocation_token !== token)) throw new Error("local port claim was replaced");
|
|
1142
|
+
for (const { path, claim } of claims) atomicWrite(path, JSON.stringify({ ...claim, lease_id: leaseId }), { write: writeFileSync, rename: renameSync });
|
|
1143
|
+
// An authorized adoption (reservePortBlocks re-taking this project's idle reservation
|
|
1144
|
+
// under a fresh invocation, Codex #1248 P2) leaves an older invocation's marker behind.
|
|
1145
|
+
// The verified claims and both locks prove this invocation now owns the directory, so the
|
|
1146
|
+
// marker follows them; a marker for another project or directory is never rewritten.
|
|
1147
|
+
const project = claims[0]?.claim.project_id;
|
|
1148
|
+
const adoptable = marker && (marker.invocation_token === token ||
|
|
1149
|
+
((marker.project_id == null || marker.project_id === project) && (marker.stack_dir == null || marker.stack_dir === stackDir)));
|
|
1150
|
+
if (adoptable) atomicWrite(join(stackDir, LEASED_STACK_MARKER), JSON.stringify({ ...marker, invocation_token: token, lease_id: leaseId }), { write: writeFileSync, rename: renameSync });
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Allocation and recovery serialize the inspect/delete against replacement claims. */
|
|
1155
|
+
function mutateBoundClaims(stackDir, registryDir, mutate) {
|
|
1156
|
+
const config = readFileSync(join(stackDir, "supabase/config.toml"), "utf8");
|
|
1157
|
+
const project = projectIdFromConfig(config);
|
|
1158
|
+
const blocks = blocksForPorts([...portMapInConfig(config).values()]);
|
|
1159
|
+
if (!project || !/^[A-Za-z0-9_-]+$/.test(project) || !blocks.length) throw new Error("local stack port identity is unavailable");
|
|
1160
|
+
return withAllocationLock(registryDir, {
|
|
1161
|
+
writeExclusive: (path, data) => writeFileSync(path, data, { flag: "wx" }), read: readFileSync,
|
|
1162
|
+
remove: rmSync, now: Date.now, alive: pidIsAlive, host: hostname,
|
|
1163
|
+
}, () => {
|
|
1164
|
+
const claims = blocks.map((block) => {
|
|
1165
|
+
const path = portClaimPath(registryDir, block);
|
|
1166
|
+
const claim = JSON.parse(readFileSync(path, "utf8"));
|
|
1167
|
+
if (claim.stack_dir !== stackDir || claim.project_id !== project || claim.host !== hostname() || claim.port_base !== block) {
|
|
1168
|
+
throw new Error("local port claim identity does not match this stack");
|
|
1169
|
+
}
|
|
1170
|
+
return { path, claim };
|
|
1171
|
+
});
|
|
1172
|
+
return mutate(claims);
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** Physical proof alone cannot release a newer invocation's port reservation.
|
|
1177
|
+
* The command also holds the project lock; legacy claims without a lease binding stay retained.
|
|
1178
|
+
* This never deletes a container, volume, stack directory or another invocation's claim. */
|
|
1179
|
+
export function recoverLeasedStackClaim(stackDir, leaseId, proof, { registryDir = defaultPortRegistryDir() } = {}) {
|
|
1180
|
+
if (proof?.lease_id !== leaseId || proof.state !== "reaped" || proof.cleanup_verified !== true ||
|
|
1181
|
+
!["signed_reap", "never_provisioned", "local_exec_teardown", "pre_driver_absence"].includes(proof.cleanup_proof) ||
|
|
1182
|
+
(proof.cleanup_proof === "signed_reap" && !proof.cleanup_receipt_id)) {
|
|
1183
|
+
return { released: false, reason: "accepted cleanup proof is unavailable" };
|
|
1184
|
+
}
|
|
1185
|
+
try {
|
|
1186
|
+
return withAllocationLock(registryDir, {
|
|
1187
|
+
writeExclusive: (path, data) => writeFileSync(path, data, { flag: "wx" }), read: readFileSync,
|
|
1188
|
+
remove: rmSync, now: Date.now, alive: pidIsAlive, host: hostname,
|
|
1189
|
+
}, () => {
|
|
1190
|
+
const claims = claimInventory(registryDir).filter(({ claim }) => claim.stack_dir === stackDir);
|
|
1191
|
+
if (!claims.length) return { released: true, reason: "no local claim remains for this stack" };
|
|
1192
|
+
const token = claims[0].claim.invocation_token;
|
|
1193
|
+
const project = claims[0].claim.project_id;
|
|
1194
|
+
if (typeof token !== "string" || !token || typeof project !== "string" || !/^[A-Za-z0-9_-]+$/.test(project) || claims.some(({ claim }) =>
|
|
1195
|
+
claim.lease_id !== leaseId || claim.invocation_token !== token || claim.project_id !== project || claim.host !== hostname() ||
|
|
1196
|
+
!Number.isInteger(claim.owner_pid) || claim.owner_pid <= 0 || pidIsAlive(claim.owner_pid))) {
|
|
1197
|
+
return { released: false, reason: "local claim is live, replaced, or lacks this lease binding" };
|
|
1198
|
+
}
|
|
1199
|
+
// Strict read: only a genuinely absent marker (a prepared stack) skips the identity
|
|
1200
|
+
// fence; an unreadable or corrupt one cannot verify the invocation, so keep the claim.
|
|
1201
|
+
let marker = null;
|
|
1202
|
+
try {
|
|
1203
|
+
const raw = readFileSync(join(stackDir, LEASED_STACK_MARKER), "utf8");
|
|
1204
|
+
marker = JSON.parse(raw);
|
|
1205
|
+
if (!marker || typeof marker !== "object") throw new Error("invalid marker");
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
if (error?.code !== "ENOENT") return { released: false, reason: "stack marker is unreadable or corrupt; local claim retained" };
|
|
1208
|
+
marker = null;
|
|
1209
|
+
}
|
|
1210
|
+
if (marker && (marker.invocation_token !== token || marker.lease_id !== leaseId || marker.project_id !== project)) {
|
|
1211
|
+
return { released: false, reason: "stack marker was replaced" };
|
|
1212
|
+
}
|
|
1213
|
+
for (const { path } of claims) rmSync(path);
|
|
1214
|
+
if (claims.some(({ path }) => existsSync(path))) return { released: false, reason: "local claim removal was not verified" };
|
|
1215
|
+
if (marker) markLeasedStackReleased(stackDir);
|
|
1216
|
+
return { released: true, cleanup_proof: proof.cleanup_proof, cleanup_receipt_id: proof.cleanup_receipt_id ?? null };
|
|
1217
|
+
});
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
return { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1087
1223
|
/** The host-scoped reservation file for a block. */
|
|
1088
1224
|
function portClaimPath(registryDir, portBase) {
|
|
1089
1225
|
return join(registryDir, `${portBase}.json`);
|
|
@@ -134,13 +134,13 @@ export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = ran
|
|
|
134
134
|
* credential the lease calls used); without one there is nothing to authenticate
|
|
135
135
|
* with and nothing is sent. Never throws; resolves within `timeoutMs`.
|
|
136
136
|
*/
|
|
137
|
-
export async function reportStackRunOutcome({ fields, exitCode, opts, env = process.env, durationMs = null, channel, timeoutMs = STACK_RUN_OUTCOME_TIMEOUT_MS }) {
|
|
137
|
+
export async function reportStackRunOutcome({ fields, exitCode, runId, opts, env = process.env, durationMs = null, channel, timeoutMs = STACK_RUN_OUTCOME_TIMEOUT_MS }) {
|
|
138
138
|
// Test suites opt out so a spawned real CLI never records a fixture run.
|
|
139
139
|
if (env?.[STACK_RUN_OUTCOME_OPT_OUT_ENV] === "off") return { sent: false, reason: "disabled" };
|
|
140
140
|
if (!channel?.auth || typeof channel.callTool !== "function") return { sent: false, reason: "no_credential" };
|
|
141
141
|
let payload;
|
|
142
142
|
try {
|
|
143
|
-
payload = buildStackRunOutcomePayload({ fields, exitCode, opts, env, durationMs });
|
|
143
|
+
payload = buildStackRunOutcomePayload({ fields, exitCode, runId, opts, env, durationMs });
|
|
144
144
|
} catch {
|
|
145
145
|
return { sent: false, reason: "payload_error" };
|
|
146
146
|
}
|
package/src/stack.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
|
43
43
|
import { resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
44
44
|
import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
|
|
45
45
|
import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
46
|
-
import { LEASED_STACK_IDLE_RETENTION_SECS, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
|
|
46
|
+
import { LEASED_STACK_IDLE_RETENTION_SECS, bindLeasedStackClaim, recoverLeasedStackClaim, hasLocalLeaseClaim, leasedClaimContext, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
|
|
47
47
|
import { measureStackUsage } from "./stack-usage.mjs";
|
|
48
48
|
import { checkReadoptedStackMounts } from "./stack-mount-health.mjs";
|
|
49
49
|
import { machineUuid } from "./machine-id.mjs";
|
|
@@ -173,6 +173,8 @@ ${bold("done OPTIONS")}
|
|
|
173
173
|
capacity stays held, so the next \`stack run\`/\`stack up\` on this slot re-adopts
|
|
174
174
|
it without provisioning. Receipt outcome "idle" + idle_since, idle_reason.
|
|
175
175
|
A queued/provisioning/reaping/reaped lease is released exactly as --reap.
|
|
176
|
+
--release-port-claim With done --reap: explicitly reconcile a retained local port claim
|
|
177
|
+
after accepted cleanup; unknown, live and replaced claims stay retained.
|
|
176
178
|
--reap Release eagerly: reap (tear down) the stack now, as before BOT-1803.
|
|
177
179
|
--stop With --reap: keep volumes (cheap re-provision next batch). Default: destroy.
|
|
178
180
|
--local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
|
|
@@ -186,6 +188,8 @@ ${bold("done OPTIONS")}
|
|
|
186
188
|
${bold("run OPTIONS")}
|
|
187
189
|
--repo <repo> Required approved repository name.
|
|
188
190
|
--ticket <BOT-123> Required ticket for the batch.
|
|
191
|
+
--run-id <UUID> Optional caller-chosen run identity. Choose it before dispatch to arm
|
|
192
|
+
bb wait 'stack-run:id=<UUID>'; the completion signal carries this run's exit code.
|
|
189
193
|
--stack-path <relative> Optional PREPARED isolated stack dir. Omit it (the norm): run
|
|
190
194
|
materializes .botbuddy/stacks/<project_id>/ from this worktree's
|
|
191
195
|
supabase/ tree with a distinct project_id + remapped ports and
|
|
@@ -356,7 +360,7 @@ export function parseStackArgs(argv) {
|
|
|
356
360
|
const childArgv = divider === -1 ? [] : rawRest.slice(divider + 1);
|
|
357
361
|
const opts = {
|
|
358
362
|
slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
|
|
359
|
-
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
|
|
363
|
+
prId: null, prUrl: null, purpose: null, runId: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
|
|
360
364
|
timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
|
|
361
365
|
connectionFile: null, noWait: false, claimOnly: false, localExec: false, keepVolumes: false, waitCapacity: null, holdOnTimeout: null,
|
|
362
366
|
dockerContext: null, dockerEndpoint: null,
|
|
@@ -388,6 +392,7 @@ export function parseStackArgs(argv) {
|
|
|
388
392
|
case "--pr": if (need(a, rest[i + 1])) opts.prId = rest[++i]; break;
|
|
389
393
|
case "--pr-url": if (need(a, rest[i + 1])) opts.prUrl = rest[++i]; break;
|
|
390
394
|
case "--purpose": if (need(a, rest[i + 1])) opts.purpose = rest[++i]; break;
|
|
395
|
+
case "--run-id": if (need(a, rest[i + 1])) opts.runId = rest[++i]; break;
|
|
391
396
|
case "--stack-path": if (need(a, rest[i + 1])) { opts.stackPath = rest[++i]; opts.stackPathExplicit = true; } break;
|
|
392
397
|
case "--idle-ttl": {
|
|
393
398
|
if (need(a, rest[i + 1])) {
|
|
@@ -488,6 +493,7 @@ export function parseStackArgs(argv) {
|
|
|
488
493
|
case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
|
|
489
494
|
case "--stop": opts.disposition = "stop"; break;
|
|
490
495
|
case "--reap": opts.reap = true; break;
|
|
496
|
+
case "--release-port-claim": opts.releasePortClaim = true; break;
|
|
491
497
|
case "--destroy": opts.disposition = "destroy"; break;
|
|
492
498
|
case "--json": opts.json = true; break;
|
|
493
499
|
default:
|
|
@@ -515,6 +521,15 @@ export function parseStackArgs(argv) {
|
|
|
515
521
|
} else if (opts.bundleInclude.length && opts.localOnly) {
|
|
516
522
|
errors.push("--bundle-include ships files in the remote-lease bundle, and --local-only builds no bundle; drop one of them");
|
|
517
523
|
}
|
|
524
|
+
if (opts.runId != null) {
|
|
525
|
+
if (command !== "run") {
|
|
526
|
+
errors.push("--run-id is valid only with `stack run`");
|
|
527
|
+
} else if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(opts.runId))) {
|
|
528
|
+
errors.push("--run-id must be a canonical UUID");
|
|
529
|
+
} else {
|
|
530
|
+
opts.runId = String(opts.runId).toLowerCase();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
518
533
|
opts.bundleInclude = [...new Set(opts.bundleInclude)];
|
|
519
534
|
opts.execution = opts.remote ? "remote" : opts.localOnly ? "local" : null;
|
|
520
535
|
if (typeof opts.stackPath !== "string" || !opts.stackPath.trim() || opts.stackPath.startsWith("/") ||
|
|
@@ -537,6 +552,7 @@ export function parseStackArgs(argv) {
|
|
|
537
552
|
"worktree root's default (shared canonical) project. Point --stack-path at a slot-derived stack directory with its " +
|
|
538
553
|
"own supabase/config.toml (distinct project_id + remapped ports).");
|
|
539
554
|
}
|
|
555
|
+
if (opts.releasePortClaim && (command !== "done" || !opts.reap || opts.localExec)) errors.push("--release-port-claim requires `stack done --reap` without --local-exec");
|
|
540
556
|
if (opts.reap && !["done", "run"].includes(command)) {
|
|
541
557
|
errors.push("--reap is valid only with `stack done` or `stack run`");
|
|
542
558
|
}
|
|
@@ -844,6 +860,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
844
860
|
// A prepared directory carries no retention marker, so its reservation is
|
|
845
861
|
// renewed here on the batch's own beat (Codex #928 R21 P2).
|
|
846
862
|
renew: (options) => ports.renew(options),
|
|
863
|
+
bindLease: (leaseId) => ports.bindLease(leaseId),
|
|
847
864
|
// BOT-2237: returns the VERIFIED disposition the receipt reports.
|
|
848
865
|
release: ({ reaped = false } = {}) => {
|
|
849
866
|
let released = false;
|
|
@@ -871,6 +888,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
871
888
|
)));
|
|
872
889
|
return {
|
|
873
890
|
...execution,
|
|
891
|
+
bindLease: (leaseId) => bindLeasedStackClaim(stackDirFor(execution), leaseId, portRegistryDir ? { registryDir: portRegistryDir } : undefined),
|
|
874
892
|
// Release is called once the batch's signed reap is done, so hand the host
|
|
875
893
|
// its port block back immediately rather than holding it for the retention
|
|
876
894
|
// window this batch no longer needs (Codex #928 R19 P2).
|
|
@@ -1456,7 +1474,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
1456
1474
|
decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
|
|
1457
1475
|
break frameLoop;
|
|
1458
1476
|
}
|
|
1459
|
-
if (isDone(st, sig.payload)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
|
|
1477
|
+
if (isDone(st, sig.payload)) { decided = { reason: "matched", result: { woke: true, state: st, ...(sig.payload?.cleanup_verified === true ? { cleanupEvidence: sig.payload } : {}) }, status: "matched" }; break frameLoop; }
|
|
1460
1478
|
}
|
|
1461
1479
|
}
|
|
1462
1480
|
} catch (err) {
|
|
@@ -3011,6 +3029,9 @@ export async function cmdDone(leaseId, opts, {
|
|
|
3011
3029
|
localTeardownFn = localTeardown,
|
|
3012
3030
|
emitResult = emit,
|
|
3013
3031
|
refreshAuth = refreshRejectedCacheAuth,
|
|
3032
|
+
waitFn = waitForLease,
|
|
3033
|
+
recoverClaim = recoverLocalLeaseClaim,
|
|
3034
|
+
localClaimExists = hasLocalLeaseClaim,
|
|
3014
3035
|
} = {}) {
|
|
3015
3036
|
// BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
|
|
3016
3037
|
// its own — otherwise an active lease can't be released until the cache is manually
|
|
@@ -3243,10 +3264,65 @@ export async function cmdDone(leaseId, opts, {
|
|
|
3243
3264
|
(retry.budget_reset === true ? " and the reap retry budget was reset" : ""),
|
|
3244
3265
|
}
|
|
3245
3266
|
: {};
|
|
3246
|
-
|
|
3267
|
+
let portBlock = null;
|
|
3268
|
+
if (opts.releasePortClaim || (opts.reap && !opts.localExec && localClaimExists(leaseId))) {
|
|
3269
|
+
try {
|
|
3270
|
+
const current = await call("get_stack_lease", { lease_id: leaseId });
|
|
3271
|
+
if (!current.ok || !current.data?.success) {
|
|
3272
|
+
portBlock = { released: false, reason: "owned lease context could not be read" };
|
|
3273
|
+
} else {
|
|
3274
|
+
const verified = await waitFn(leaseId,
|
|
3275
|
+
(s, p) => s === "reaped" && p?.cleanup_verified === true,
|
|
3276
|
+
// BOT-1948: an accepted failed reap, or a reaped snapshot the server marks as never
|
|
3277
|
+
// provable (cleanup_verified strictly false), ends at once; a raw frame keeps waiting.
|
|
3278
|
+
(s, p) => (s === "reap_failed" && p?.cleanup_verified === true) || (s === "reaped" && p?.cleanup_verified === false),
|
|
3279
|
+
{ timeoutSec: opts.reapTimeout ?? 300, auth: rpc.auth });
|
|
3280
|
+
if (verified.woke && verified.cleanupEvidence) {
|
|
3281
|
+
state = "reaped";
|
|
3282
|
+
portBlock = await recoverClaim({ ...current.data, lease_id: leaseId }, verified.cleanupEvidence);
|
|
3283
|
+
} else portBlock = { released: false, reason: "accepted cleanup proof is unavailable; local claim retained" };
|
|
3284
|
+
}
|
|
3285
|
+
} catch (error) {
|
|
3286
|
+
portBlock = { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
return emitResult(buildReceipt({ command: "done", outcome: portBlock?.released === false ? "error" : "released", lease_id: leaseId, state, disposition,
|
|
3247
3290
|
...retried,
|
|
3291
|
+
...(portBlock ? { port_block: portBlock } : {}),
|
|
3248
3292
|
...(helperTeardown ? { teardown: "helper" } : {}),
|
|
3249
|
-
...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
|
|
3293
|
+
...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, portBlock?.released === false ? EXIT.CLEANUP_FAILED : EXIT.OK);
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
/** Recover only in the original worktree, with its project lock and an exact lease-bound claim. */
|
|
3297
|
+
export async function recoverLocalLeaseClaim(lease, proof, { cwd = process.cwd(), recover = recoverLeasedStackClaim, lock = acquireStackLock, claimContext = leasedClaimContext } = {}) {
|
|
3298
|
+
let held;
|
|
3299
|
+
try {
|
|
3300
|
+
const root = realpathSync(cwd);
|
|
3301
|
+
const path = lease.stack_path;
|
|
3302
|
+
if (!lease.worktree_root || root !== realpathSync(lease.worktree_root) || typeof path !== "string" || !path || path === "." || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
|
|
3303
|
+
return { released: false, reason: "run recovery from the lease's original worktree with its isolated path" };
|
|
3304
|
+
}
|
|
3305
|
+
const context = claimContext(path, lease.lease_id, { cwd });
|
|
3306
|
+
let stackDir, project;
|
|
3307
|
+
try {
|
|
3308
|
+
stackDir = stackDirFor(resolveStackPath(cwd, path));
|
|
3309
|
+
project = projectIdUnder(stackDir);
|
|
3310
|
+
} catch (error) {
|
|
3311
|
+
// No path is reconstructed from remote input. A removed directory needs
|
|
3312
|
+
// an exact local lease/host/path binding; unknown or legacy claims stay kept.
|
|
3313
|
+
if (error.code !== "ENOENT" || !context) throw error;
|
|
3314
|
+
stackDir = context.stackDir;
|
|
3315
|
+
project = context.project;
|
|
3316
|
+
}
|
|
3317
|
+
if (typeof project !== "string" || !/^[A-Za-z0-9_-]+$/.test(project) ||
|
|
3318
|
+
(context && (project !== context.project || stackDir !== context.stackDir))) {
|
|
3319
|
+
return { released: false, reason: "local stack project identity is unavailable or invalid" };
|
|
3320
|
+
}
|
|
3321
|
+
held = await lock(lockPathForProject(project), { timeoutMs: 0 });
|
|
3322
|
+
return recover(stackDir, lease.lease_id, proof);
|
|
3323
|
+
} catch (error) {
|
|
3324
|
+
return { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
|
|
3325
|
+
} finally { held?.release(); }
|
|
3250
3326
|
}
|
|
3251
3327
|
|
|
3252
3328
|
const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
|
|
@@ -3968,6 +4044,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3968
4044
|
}
|
|
3969
4045
|
|
|
3970
4046
|
leaseProvisioned = true; // from here a Helper may act on this lease
|
|
4047
|
+
execution.bindLease?.(leaseId);
|
|
3971
4048
|
let state = request.data.state;
|
|
3972
4049
|
if (reusedIdle) {
|
|
3973
4050
|
idleLease.reused = "idle";
|
|
@@ -4408,7 +4485,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4408
4485
|
? `a lease on this stack slot may still hold its ports (idle, unreaped, or another live batch)${retained} so its stack is never double-booked`
|
|
4409
4486
|
// BOT-2418: an idle release is a deliberate keep (BOT-1803), not an unproven reap.
|
|
4410
4487
|
: cleanupResult?.ok === true && cleanupResult.idle === true
|
|
4411
|
-
? `the lease is idle and keeps its stack for the next run on this slot, so its claim stays reserved with it (\`bb stack done ${leaseId} --reap\` reaps the lease
|
|
4488
|
+
? `the lease is idle and keeps its stack for the next run on this slot, so its claim stays reserved with it (\`bb stack done ${leaseId} --reap\` reaps the lease and releases its exact lease-bound local claim after accepted cleanup)`
|
|
4412
4489
|
: `reap not proven (${cleanupResult?.error ?? "no cleanup ran"})${retained} so a stack the Helper may still run is never double-booked` }
|
|
4413
4490
|
: releaseError || disposition?.released === false
|
|
4414
4491
|
? { released: false, reason: `the reap was proven but the claim could not be removed (${releaseError?.message ?? "it is still on disk"})${retained}` }
|
|
@@ -4481,12 +4558,16 @@ export function runReceiptFields(result) {
|
|
|
4481
4558
|
export async function cmdRun(opts, childArgv, {
|
|
4482
4559
|
lifecycle = runStackLifecycle, report = reportStackRunOutcome, now = Date.now, env = process.env,
|
|
4483
4560
|
} = {}) {
|
|
4561
|
+
// A caller that needs a wait selects this immutable UUID before dispatch.
|
|
4562
|
+
// Ordinary foreground runs still receive a unique telemetry id, but it is not
|
|
4563
|
+
// retrospectively advertised as an armable completion contract.
|
|
4564
|
+
const runId = opts.runId ?? randomUUID();
|
|
4484
4565
|
const startedAt = now();
|
|
4485
4566
|
const result = await lifecycle(opts, childArgv);
|
|
4486
4567
|
const fields = runReceiptFields(result);
|
|
4487
4568
|
const code = emit(buildReceipt(fields), opts, result.exitCode);
|
|
4488
4569
|
try {
|
|
4489
|
-
await report({ fields: stackRunOutcomeFields(result), exitCode: code, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
|
|
4570
|
+
await report({ fields: stackRunOutcomeFields(result), exitCode: code, runId, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
|
|
4490
4571
|
} catch { /* best-effort: the receipt and exit code are already final */ }
|
|
4491
4572
|
return code;
|
|
4492
4573
|
}
|
|
@@ -4535,7 +4616,7 @@ export async function cmdStack(argv, { report = reportStackRunOutcome, outcomeCh
|
|
|
4535
4616
|
const code = emit(buildReceipt(fields), opts, EXIT.INVALID);
|
|
4536
4617
|
// BOT-2467: a refused `stack run` is recorded as an `invocation` outcome (no lease).
|
|
4537
4618
|
if (command === "run") {
|
|
4538
|
-
try { await report({ fields, exitCode: code, opts, env: process.env, durationMs: 0, channel: await outcomeChannel(argv) }); } catch { /* best-effort */ }
|
|
4619
|
+
try { await report({ fields, exitCode: code, runId: opts.runId ?? undefined, opts, env: process.env, durationMs: 0, channel: await outcomeChannel(argv) }); } catch { /* best-effort */ }
|
|
4539
4620
|
}
|
|
4540
4621
|
process.exitCode = code;
|
|
4541
4622
|
return code;
|
package/src/wait-core.mjs
CHANGED
|
@@ -85,9 +85,9 @@ export const WAIT_SUPERSEDED_SIGNAL_TYPE = "wait_superseded";
|
|
|
85
85
|
const HOST_APPROVAL_HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
|
|
86
86
|
|
|
87
87
|
// The USER-ARMABLE spine signal_type allowlist, for the `event:` escape hatch. The full
|
|
88
|
-
// public.agent_signal_type enum
|
|
89
|
-
//
|
|
90
|
-
//
|
|
88
|
+
// public.agent_signal_type enum also has control-only and dedicated-grammar types (for
|
|
89
|
+
// example `wait_superseded` and BOT-2629's `stack_run`); they are intentionally excluded
|
|
90
|
+
// here so raw event conditions cannot bypass their narrower matching contracts.
|
|
91
91
|
export const SIGNAL_TYPES = Object.freeze([
|
|
92
92
|
"ci_run",
|
|
93
93
|
"pr_review",
|
|
@@ -110,6 +110,7 @@ export const SIGNAL_TYPES = Object.freeze([
|
|
|
110
110
|
]);
|
|
111
111
|
|
|
112
112
|
const LOCK_SUBTYPES = Object.freeze(["playwright_lane", "vite_port", "backend_port", "supabase_local"]);
|
|
113
|
+
const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
113
114
|
|
|
114
115
|
// ---------------------------------------------------------------------------
|
|
115
116
|
// Condition grammar: `TYPE:key=val,key=val`
|
|
@@ -194,6 +195,16 @@ const VALIDATORS = {
|
|
|
194
195
|
if (!p.id) throw new Error("test-run needs id=<run_id>");
|
|
195
196
|
return { runId: String(p.id) };
|
|
196
197
|
},
|
|
198
|
+
// BOT-2629: this is deliberately a dedicated condition rather than a raw
|
|
199
|
+
// event escape hatch. The UUID is chosen before dispatch and the exact subject
|
|
200
|
+
// prevents another run on the same idle/re-adopted lease waking this waiter.
|
|
201
|
+
"stack-run": (p) => {
|
|
202
|
+
const unexpected = Object.keys(p).filter((key) => key !== "id");
|
|
203
|
+
if (unexpected.length) throw new Error(`stack-run does not accept ${unexpected.join(", ")}`);
|
|
204
|
+
const id = String(p.id ?? "").trim().toLowerCase();
|
|
205
|
+
if (!CANONICAL_UUID_RE.test(id)) throw new Error("stack-run needs id=<canonical run UUID>");
|
|
206
|
+
return { id };
|
|
207
|
+
},
|
|
197
208
|
// BOT-1063 — guidance: park on a command-post guidance RESPONSE to a request
|
|
198
209
|
// this agent raised (pairs with request_guidance). The signal is
|
|
199
210
|
// recipient-scoped to the requester and keyed by the request id.
|
|
@@ -1112,6 +1123,9 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
1112
1123
|
prReviewFiltersMatch(signal, params);
|
|
1113
1124
|
case "test-run":
|
|
1114
1125
|
return signal.signal_type === "test_run" && signal.subject_key === `run:${params.runId}`;
|
|
1126
|
+
case "stack-run":
|
|
1127
|
+
return signal.signal_type === "stack_run" && signal.subject_key === `stack-run:${params.id}` &&
|
|
1128
|
+
signal.payload?.run_id === params.id && Number.isInteger(signal.payload?.exit_code);
|
|
1115
1129
|
case "guidance":
|
|
1116
1130
|
return signal.signal_type === "guidance_response" && signal.subject_key === params.request;
|
|
1117
1131
|
case "lease":
|