@botbuddy/cli 1.60.2 → 1.60.3
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.mjs +70 -5
|
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.3",
|
|
3
|
+
"source_commit": "ce15cf76534b99c30468bdd541f2ab8e9340ee08",
|
|
4
|
+
"source_sha256": "962d9cae75ce935da9306a538440ccb1448270ea80b9c0d4ba76be0c3c3a6707",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "989d317d944147c7ec86dc894c74b6a462e95fa85ee709e3e0c562783e231668"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.60.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.3",
|
|
3
|
+
"source_commit": "ce15cf76534b99c30468bdd541f2ab8e9340ee08",
|
|
4
|
+
"source_sha256": "962d9cae75ce935da9306a538440ccb1448270ea80b9c0d4ba76be0c3c3a6707",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "90a9017148902699e54e26fddb940abc87d22ca96c3f415a75d4d172614dd281"
|
|
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.3",
|
|
4
|
+
"source_commit": "ce15cf76534b99c30468bdd541f2ab8e9340ee08",
|
|
5
|
+
"source_sha256": "962d9cae75ce935da9306a538440ccb1448270ea80b9c0d4ba76be0c3c3a6707",
|
|
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": "989d317d944147c7ec86dc894c74b6a462e95fa85ee709e3e0c562783e231668",
|
|
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": "90a9017148902699e54e26fddb940abc87d22ca96c3f415a75d4d172614dd281",
|
|
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`);
|
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.
|
|
@@ -488,6 +490,7 @@ export function parseStackArgs(argv) {
|
|
|
488
490
|
case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
|
|
489
491
|
case "--stop": opts.disposition = "stop"; break;
|
|
490
492
|
case "--reap": opts.reap = true; break;
|
|
493
|
+
case "--release-port-claim": opts.releasePortClaim = true; break;
|
|
491
494
|
case "--destroy": opts.disposition = "destroy"; break;
|
|
492
495
|
case "--json": opts.json = true; break;
|
|
493
496
|
default:
|
|
@@ -537,6 +540,7 @@ export function parseStackArgs(argv) {
|
|
|
537
540
|
"worktree root's default (shared canonical) project. Point --stack-path at a slot-derived stack directory with its " +
|
|
538
541
|
"own supabase/config.toml (distinct project_id + remapped ports).");
|
|
539
542
|
}
|
|
543
|
+
if (opts.releasePortClaim && (command !== "done" || !opts.reap || opts.localExec)) errors.push("--release-port-claim requires `stack done --reap` without --local-exec");
|
|
540
544
|
if (opts.reap && !["done", "run"].includes(command)) {
|
|
541
545
|
errors.push("--reap is valid only with `stack done` or `stack run`");
|
|
542
546
|
}
|
|
@@ -844,6 +848,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
844
848
|
// A prepared directory carries no retention marker, so its reservation is
|
|
845
849
|
// renewed here on the batch's own beat (Codex #928 R21 P2).
|
|
846
850
|
renew: (options) => ports.renew(options),
|
|
851
|
+
bindLease: (leaseId) => ports.bindLease(leaseId),
|
|
847
852
|
// BOT-2237: returns the VERIFIED disposition the receipt reports.
|
|
848
853
|
release: ({ reaped = false } = {}) => {
|
|
849
854
|
let released = false;
|
|
@@ -871,6 +876,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
871
876
|
)));
|
|
872
877
|
return {
|
|
873
878
|
...execution,
|
|
879
|
+
bindLease: (leaseId) => bindLeasedStackClaim(stackDirFor(execution), leaseId, portRegistryDir ? { registryDir: portRegistryDir } : undefined),
|
|
874
880
|
// Release is called once the batch's signed reap is done, so hand the host
|
|
875
881
|
// its port block back immediately rather than holding it for the retention
|
|
876
882
|
// window this batch no longer needs (Codex #928 R19 P2).
|
|
@@ -1456,7 +1462,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
1456
1462
|
decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
|
|
1457
1463
|
break frameLoop;
|
|
1458
1464
|
}
|
|
1459
|
-
if (isDone(st, sig.payload)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
|
|
1465
|
+
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
1466
|
}
|
|
1461
1467
|
}
|
|
1462
1468
|
} catch (err) {
|
|
@@ -3011,6 +3017,9 @@ export async function cmdDone(leaseId, opts, {
|
|
|
3011
3017
|
localTeardownFn = localTeardown,
|
|
3012
3018
|
emitResult = emit,
|
|
3013
3019
|
refreshAuth = refreshRejectedCacheAuth,
|
|
3020
|
+
waitFn = waitForLease,
|
|
3021
|
+
recoverClaim = recoverLocalLeaseClaim,
|
|
3022
|
+
localClaimExists = hasLocalLeaseClaim,
|
|
3014
3023
|
} = {}) {
|
|
3015
3024
|
// BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
|
|
3016
3025
|
// its own — otherwise an active lease can't be released until the cache is manually
|
|
@@ -3243,10 +3252,65 @@ export async function cmdDone(leaseId, opts, {
|
|
|
3243
3252
|
(retry.budget_reset === true ? " and the reap retry budget was reset" : ""),
|
|
3244
3253
|
}
|
|
3245
3254
|
: {};
|
|
3246
|
-
|
|
3255
|
+
let portBlock = null;
|
|
3256
|
+
if (opts.releasePortClaim || (opts.reap && !opts.localExec && localClaimExists(leaseId))) {
|
|
3257
|
+
try {
|
|
3258
|
+
const current = await call("get_stack_lease", { lease_id: leaseId });
|
|
3259
|
+
if (!current.ok || !current.data?.success) {
|
|
3260
|
+
portBlock = { released: false, reason: "owned lease context could not be read" };
|
|
3261
|
+
} else {
|
|
3262
|
+
const verified = await waitFn(leaseId,
|
|
3263
|
+
(s, p) => s === "reaped" && p?.cleanup_verified === true,
|
|
3264
|
+
// BOT-1948: an accepted failed reap, or a reaped snapshot the server marks as never
|
|
3265
|
+
// provable (cleanup_verified strictly false), ends at once; a raw frame keeps waiting.
|
|
3266
|
+
(s, p) => (s === "reap_failed" && p?.cleanup_verified === true) || (s === "reaped" && p?.cleanup_verified === false),
|
|
3267
|
+
{ timeoutSec: opts.reapTimeout ?? 300, auth: rpc.auth });
|
|
3268
|
+
if (verified.woke && verified.cleanupEvidence) {
|
|
3269
|
+
state = "reaped";
|
|
3270
|
+
portBlock = await recoverClaim({ ...current.data, lease_id: leaseId }, verified.cleanupEvidence);
|
|
3271
|
+
} else portBlock = { released: false, reason: "accepted cleanup proof is unavailable; local claim retained" };
|
|
3272
|
+
}
|
|
3273
|
+
} catch (error) {
|
|
3274
|
+
portBlock = { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
return emitResult(buildReceipt({ command: "done", outcome: portBlock?.released === false ? "error" : "released", lease_id: leaseId, state, disposition,
|
|
3247
3278
|
...retried,
|
|
3279
|
+
...(portBlock ? { port_block: portBlock } : {}),
|
|
3248
3280
|
...(helperTeardown ? { teardown: "helper" } : {}),
|
|
3249
|
-
...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
|
|
3281
|
+
...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, portBlock?.released === false ? EXIT.CLEANUP_FAILED : EXIT.OK);
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
/** Recover only in the original worktree, with its project lock and an exact lease-bound claim. */
|
|
3285
|
+
export async function recoverLocalLeaseClaim(lease, proof, { cwd = process.cwd(), recover = recoverLeasedStackClaim, lock = acquireStackLock, claimContext = leasedClaimContext } = {}) {
|
|
3286
|
+
let held;
|
|
3287
|
+
try {
|
|
3288
|
+
const root = realpathSync(cwd);
|
|
3289
|
+
const path = lease.stack_path;
|
|
3290
|
+
if (!lease.worktree_root || root !== realpathSync(lease.worktree_root) || typeof path !== "string" || !path || path === "." || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
|
|
3291
|
+
return { released: false, reason: "run recovery from the lease's original worktree with its isolated path" };
|
|
3292
|
+
}
|
|
3293
|
+
const context = claimContext(path, lease.lease_id, { cwd });
|
|
3294
|
+
let stackDir, project;
|
|
3295
|
+
try {
|
|
3296
|
+
stackDir = stackDirFor(resolveStackPath(cwd, path));
|
|
3297
|
+
project = projectIdUnder(stackDir);
|
|
3298
|
+
} catch (error) {
|
|
3299
|
+
// No path is reconstructed from remote input. A removed directory needs
|
|
3300
|
+
// an exact local lease/host/path binding; unknown or legacy claims stay kept.
|
|
3301
|
+
if (error.code !== "ENOENT" || !context) throw error;
|
|
3302
|
+
stackDir = context.stackDir;
|
|
3303
|
+
project = context.project;
|
|
3304
|
+
}
|
|
3305
|
+
if (typeof project !== "string" || !/^[A-Za-z0-9_-]+$/.test(project) ||
|
|
3306
|
+
(context && (project !== context.project || stackDir !== context.stackDir))) {
|
|
3307
|
+
return { released: false, reason: "local stack project identity is unavailable or invalid" };
|
|
3308
|
+
}
|
|
3309
|
+
held = await lock(lockPathForProject(project), { timeoutMs: 0 });
|
|
3310
|
+
return recover(stackDir, lease.lease_id, proof);
|
|
3311
|
+
} catch (error) {
|
|
3312
|
+
return { released: false, reason: `local claim retained: ${error.code ?? error.message}` };
|
|
3313
|
+
} finally { held?.release(); }
|
|
3250
3314
|
}
|
|
3251
3315
|
|
|
3252
3316
|
const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
|
|
@@ -3968,6 +4032,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3968
4032
|
}
|
|
3969
4033
|
|
|
3970
4034
|
leaseProvisioned = true; // from here a Helper may act on this lease
|
|
4035
|
+
execution.bindLease?.(leaseId);
|
|
3971
4036
|
let state = request.data.state;
|
|
3972
4037
|
if (reusedIdle) {
|
|
3973
4038
|
idleLease.reused = "idle";
|
|
@@ -4408,7 +4473,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4408
4473
|
? `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
4474
|
// BOT-2418: an idle release is a deliberate keep (BOT-1803), not an unproven reap.
|
|
4410
4475
|
: 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
|
|
4476
|
+
? `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
4477
|
: `reap not proven (${cleanupResult?.error ?? "no cleanup ran"})${retained} so a stack the Helper may still run is never double-booked` }
|
|
4413
4478
|
: releaseError || disposition?.released === false
|
|
4414
4479
|
? { released: false, reason: `the reap was proven but the claim could not be removed (${releaseError?.message ?? "it is still on disk"})${retained}` }
|