@botbuddy/cli 1.60.1 → 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-run-outcome.mjs +5 -3
- package/src/stack.mjs +152 -25
- package/src/wait-core.mjs +58 -5
- package/src/wait.mjs +10 -4
|
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`);
|
|
@@ -49,8 +49,8 @@ function boundedCode(value) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* Classify one `bb stack run` terminal from its
|
|
53
|
-
*
|
|
52
|
+
* Classify one `bb stack run` terminal from its internal outcome fields
|
|
53
|
+
* (`stackRunOutcomeFields`, or the argument-error receipt) and exit code. Pure.
|
|
54
54
|
*
|
|
55
55
|
* script — the lease was active and the child's exit decided the result
|
|
56
56
|
* (`--hard-ttl` stop → code HARD_TTL)
|
|
@@ -64,7 +64,9 @@ export function classifyStackRunOutcome(fields = {}, exitCode) {
|
|
|
64
64
|
const outcome = typeof fields.outcome === "string" ? fields.outcome : null;
|
|
65
65
|
if (outcome === "interrupted") return { category: "interrupted", code };
|
|
66
66
|
if (code && PLATFORM_CODES.has(code)) return { category: "platform", code };
|
|
67
|
-
|
|
67
|
+
// The stdout fence also covers unresolved cleanup. The lifecycle supplies its
|
|
68
|
+
// original lease-fence fact separately so coincident child exits cannot hide it.
|
|
69
|
+
if ((fields.lease_fenced ?? fields.fenced) === true) return { category: "platform", code: code ?? "LEASE_LOST" };
|
|
68
70
|
if (outcome === "completed" && exitCode === 0) return { category: "script", code: null };
|
|
69
71
|
if (outcome === "failed" && code == null && child != null) {
|
|
70
72
|
if (SIGNAL_EXITS.has(exitCode)) return { category: "interrupted", code: null };
|
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
|
}
|
|
@@ -728,6 +732,10 @@ export function truncateReceipt(receipt, maxBytes = DEFAULT_RECEIPT_MAX_BYTES) {
|
|
|
728
732
|
schema_version: r.schema_version, command: r.command, outcome: r.outcome,
|
|
729
733
|
lease_id: r.lease_id ?? null, state: r.state ?? null, exit_code: r.exit_code,
|
|
730
734
|
error: r.error ?? null, truncated: true,
|
|
735
|
+
...(r.cleanup !== undefined ? { cleanup: r.cleanup } : {}),
|
|
736
|
+
...(r.fenced !== undefined ? { fenced: r.fenced } : {}),
|
|
737
|
+
...(r.cleanup_code ? { cleanup_code: r.cleanup_code } : {}),
|
|
738
|
+
...(r.cleanup_error ? { cleanup_error: String(r.cleanup_error).slice(0, 160) } : {}),
|
|
731
739
|
};
|
|
732
740
|
}
|
|
733
741
|
|
|
@@ -840,6 +848,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
840
848
|
// A prepared directory carries no retention marker, so its reservation is
|
|
841
849
|
// renewed here on the batch's own beat (Codex #928 R21 P2).
|
|
842
850
|
renew: (options) => ports.renew(options),
|
|
851
|
+
bindLease: (leaseId) => ports.bindLease(leaseId),
|
|
843
852
|
// BOT-2237: returns the VERIFIED disposition the receipt reports.
|
|
844
853
|
release: ({ reaped = false } = {}) => {
|
|
845
854
|
let released = false;
|
|
@@ -867,6 +876,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
867
876
|
)));
|
|
868
877
|
return {
|
|
869
878
|
...execution,
|
|
879
|
+
bindLease: (leaseId) => bindLeasedStackClaim(stackDirFor(execution), leaseId, portRegistryDir ? { registryDir: portRegistryDir } : undefined),
|
|
870
880
|
// Release is called once the batch's signed reap is done, so hand the host
|
|
871
881
|
// its port block back immediately rather than holding it for the retention
|
|
872
882
|
// window this batch no longer needs (Codex #928 R19 P2).
|
|
@@ -1156,6 +1166,11 @@ export const REMOTE_REFUSAL_GUIDANCE = Object.freeze({
|
|
|
1156
1166
|
|
|
1157
1167
|
/** The refusal text for a receipt: the server's message, else the CLI's guidance, else the code. */
|
|
1158
1168
|
export function leaseRefusalError(data) {
|
|
1169
|
+
if (data?.lease_id && ["STACK_CONTEXT_BIND_FAILED", "STACK_CONTEXT_ALREADY_BOUND"].includes(data?.code)) {
|
|
1170
|
+
return `${data.message || data.code}; lease ${data.lease_id} is ${data.state ?? "unknown"}. ` +
|
|
1171
|
+
`Inspect with \`bb stack status ${data.lease_id}\`; after the owning batch ends, release with \`bb stack done ${data.lease_id} --reap\`, ` +
|
|
1172
|
+
`then wait with \`bb wait 'lease:id=${data.lease_id},state=reaped' --heartbeat\`.`;
|
|
1173
|
+
}
|
|
1159
1174
|
return data?.message || REMOTE_REFUSAL_GUIDANCE[data?.code] || data?.code || "lease request refused";
|
|
1160
1175
|
}
|
|
1161
1176
|
|
|
@@ -1447,7 +1462,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
1447
1462
|
decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
|
|
1448
1463
|
break frameLoop;
|
|
1449
1464
|
}
|
|
1450
|
-
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; }
|
|
1451
1466
|
}
|
|
1452
1467
|
}
|
|
1453
1468
|
} catch (err) {
|
|
@@ -3002,6 +3017,9 @@ export async function cmdDone(leaseId, opts, {
|
|
|
3002
3017
|
localTeardownFn = localTeardown,
|
|
3003
3018
|
emitResult = emit,
|
|
3004
3019
|
refreshAuth = refreshRejectedCacheAuth,
|
|
3020
|
+
waitFn = waitForLease,
|
|
3021
|
+
recoverClaim = recoverLocalLeaseClaim,
|
|
3022
|
+
localClaimExists = hasLocalLeaseClaim,
|
|
3005
3023
|
} = {}) {
|
|
3006
3024
|
// BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
|
|
3007
3025
|
// its own — otherwise an active lease can't be released until the cache is manually
|
|
@@ -3234,10 +3252,65 @@ export async function cmdDone(leaseId, opts, {
|
|
|
3234
3252
|
(retry.budget_reset === true ? " and the reap retry budget was reset" : ""),
|
|
3235
3253
|
}
|
|
3236
3254
|
: {};
|
|
3237
|
-
|
|
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,
|
|
3238
3278
|
...retried,
|
|
3279
|
+
...(portBlock ? { port_block: portBlock } : {}),
|
|
3239
3280
|
...(helperTeardown ? { teardown: "helper" } : {}),
|
|
3240
|
-
...(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(); }
|
|
3241
3314
|
}
|
|
3242
3315
|
|
|
3243
3316
|
const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
|
|
@@ -3452,9 +3525,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3452
3525
|
// BOT-2099: a best-effort diagnostic read (ledger, persisted Helper reason) never throws.
|
|
3453
3526
|
const readLeaseQuietly = async (id) => { try { return await api.get?.(id) ?? null; } catch { return null; } };
|
|
3454
3527
|
// BOT-2237: the typed end of a park that cannot re-authenticate — names the lease, never a
|
|
3455
|
-
// capacity timeout.
|
|
3528
|
+
// capacity timeout. Keep AUTH after confirmed cleanup; unproven cleanup takes
|
|
3529
|
+
// CLEANUP_FAILED while preserving the primary credential diagnostic.
|
|
3456
3530
|
const authUnavailableResult = (waited, phase, cleanup, waitedLeaseId = leaseId) => ({
|
|
3457
|
-
exitCode:
|
|
3531
|
+
exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : EXIT.AUTH,
|
|
3532
|
+
outcome: "error", code: PARK_INCIDENT_REASONS.AUTH, leaseId: waitedLeaseId ?? null, cleanup,
|
|
3458
3533
|
error: `${waited.error} while the lease was ${phase}; its server state is unknown to this run — inspect it with \`bb stack status ${waitedLeaseId}\` after \`bb doctor --fix\``,
|
|
3459
3534
|
...(parkIncident ? { incident: parkIncident } : {}),
|
|
3460
3535
|
});
|
|
@@ -3595,7 +3670,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3595
3670
|
// state — instead of leaving this wait to park until --reap-timeout on a reap
|
|
3596
3671
|
// that already failed. Report that specific outcome instead of the generic
|
|
3597
3672
|
// "was not proven" timeout message.
|
|
3598
|
-
//
|
|
3673
|
+
// Cleanup owns one immutable deadline. Its signed reap wait may recover from a
|
|
3674
|
+
// transient relay/auth blip, but never repeats the release request. A persistent
|
|
3675
|
+
// failure remains explicit receipt data below; it never fabricates a reap.
|
|
3599
3676
|
const reaped = await resilientWait(leaseId, (state) => state === "reaped", (state) => state === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth });
|
|
3600
3677
|
if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
3601
3678
|
if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
|
|
@@ -3612,12 +3689,21 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3612
3689
|
if (recheck?.ok && recheck.data?.success && recheck.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
3613
3690
|
const postCheck = durableReapFailure(recheck);
|
|
3614
3691
|
if (postCheck) return cleanupResult = postCheck;
|
|
3615
|
-
|
|
3692
|
+
const persistentWaitFailure = reaped?.incident && reaped.incident.recovered === false
|
|
3693
|
+
? reaped.incident.last_error
|
|
3694
|
+
: null;
|
|
3695
|
+
return cleanupResult = {
|
|
3696
|
+
ok: false,
|
|
3697
|
+
error: persistentWaitFailure ?? (reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven")),
|
|
3698
|
+
};
|
|
3616
3699
|
})().catch((error) => {
|
|
3617
|
-
//
|
|
3618
|
-
//
|
|
3619
|
-
|
|
3620
|
-
|
|
3700
|
+
// A release/read whose session renewal throws is a failed cleanup of THIS lease —
|
|
3701
|
+
// never an exception that escapes the lifecycle and loses its recovery receipt.
|
|
3702
|
+
return cleanupResult = {
|
|
3703
|
+
ok: false,
|
|
3704
|
+
code: error?.code ?? null,
|
|
3705
|
+
error: String(error?.message ?? error),
|
|
3706
|
+
};
|
|
3621
3707
|
});
|
|
3622
3708
|
return cleanupPromise;
|
|
3623
3709
|
};
|
|
@@ -3906,11 +3992,14 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3906
3992
|
?? stackPathRootRefusalNotice(request.data?.code, request.data?.message)
|
|
3907
3993
|
?? remoteRefusalNotice(request.data?.code);
|
|
3908
3994
|
if (hint) stderrLine(hint);
|
|
3909
|
-
//
|
|
3910
|
-
|
|
3995
|
+
// A refusal naming an admitted lease (including a failed context bind or
|
|
3996
|
+
// an idle mode mismatch) is not proof that its ports are free. Keep the
|
|
3997
|
+
// claim until owner-safe cleanup is verified; never reap a possibly live batch.
|
|
3998
|
+
if (request.data?.lease_id) retainBlock = true;
|
|
3911
3999
|
return {
|
|
3912
4000
|
exitCode: leaseRefusalExit(request.data?.code), outcome: "error", code: request.data?.code,
|
|
3913
4001
|
...capacityStaleReceiptFields(request.data), error: leaseRefusalError(request.data),
|
|
4002
|
+
...(request.data.lease_id ? { leaseId: request.data.lease_id, state: request.data.state ?? null } : {}),
|
|
3914
4003
|
};
|
|
3915
4004
|
}
|
|
3916
4005
|
leaseId = request.data.lease_id;
|
|
@@ -3934,10 +4023,16 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3934
4023
|
// With a stable identity a live lease in THIS stack directory is bound to our block.
|
|
3935
4024
|
if (request.data.stack_path != null && request.data.stack_path === execution.stackPath &&
|
|
3936
4025
|
(request.data.worktree_root == null || request.data.worktree_root === execution.worktreeRoot)) retainBlock = true;
|
|
3937
|
-
|
|
4026
|
+
const blockedState = request.data.state ?? "unknown";
|
|
4027
|
+
const recovery = blockedState === "reaping"
|
|
4028
|
+
? `wait with \`bb wait 'lease:id=${leaseId},state=reaped' --heartbeat\``
|
|
4029
|
+
: `inspect with \`bb stack status ${leaseId}\`; after the owning batch ends, release with \`bb stack done ${leaseId} --reap\`, then wait with \`bb wait 'lease:id=${leaseId},state=reaped' --heartbeat\``;
|
|
4030
|
+
return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, state: blockedState,
|
|
4031
|
+
error: `live lease already exists for this agent and stack slot: ${leaseId} is ${blockedState}; ${recovery}` };
|
|
3938
4032
|
}
|
|
3939
4033
|
|
|
3940
4034
|
leaseProvisioned = true; // from here a Helper may act on this lease
|
|
4035
|
+
execution.bindLease?.(leaseId);
|
|
3941
4036
|
let state = request.data.state;
|
|
3942
4037
|
if (reusedIdle) {
|
|
3943
4038
|
idleLease.reused = "idle";
|
|
@@ -4112,7 +4207,13 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4112
4207
|
};
|
|
4113
4208
|
}
|
|
4114
4209
|
if (parked?.failed) { const cleanup = await requestCleanup(); return { exitCode: EXIT.LEASE_FAILED, outcome: "error", leaseId, error: `lease was ${parked.state} while queued (reap_reason=${parked.reap_reason ?? "unknown"})`, cleanup, ...incident }; }
|
|
4115
|
-
if (parked?.error || parked?.auth) {
|
|
4210
|
+
if (parked?.error || parked?.auth) {
|
|
4211
|
+
const cleanup = await requestCleanup();
|
|
4212
|
+
return {
|
|
4213
|
+
exitCode: parked?.auth && cleanup?.ok === false ? EXIT.CLEANUP_FAILED : (parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED),
|
|
4214
|
+
outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup, ...incident,
|
|
4215
|
+
};
|
|
4216
|
+
}
|
|
4116
4217
|
state = parked.state;
|
|
4117
4218
|
}
|
|
4118
4219
|
if (state !== "active") {
|
|
@@ -4143,7 +4244,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4143
4244
|
// BOT-1816's class of dropped wakes) even though the lease genuinely went
|
|
4144
4245
|
// active or was deferred back to queued. One direct read before declaring
|
|
4145
4246
|
// a timeout avoids reaping a healthy stack or destroying a kept queue place.
|
|
4146
|
-
|
|
4247
|
+
// Production adapters always expose `get`. Focused lifecycle adapters may
|
|
4248
|
+
// intentionally model only admission and release; preserve their original
|
|
4249
|
+
// terminal cleanup receipt rather than replacing it with a test-only TypeError.
|
|
4250
|
+
const recheck = api.get ? await api.get(leaseId) : null;
|
|
4147
4251
|
if (recheck?.ok && recheck.data?.success && recheck.data.state === "queued") {
|
|
4148
4252
|
parkView = recheck.data;
|
|
4149
4253
|
const why = parkView.provision_defer_reason ?? "host capacity";
|
|
@@ -4153,9 +4257,16 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4153
4257
|
}
|
|
4154
4258
|
if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
|
|
4155
4259
|
const cleanup = await requestCleanup();
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4260
|
+
const cleanupFailed = cleanup?.ok === false;
|
|
4261
|
+
return {
|
|
4262
|
+
exitCode: cleanupFailed ? EXIT.CLEANUP_FAILED : EXIT.TIMEOUT,
|
|
4263
|
+
outcome: "timeout", timeoutPhase: "provision", leaseId,
|
|
4264
|
+
// An unproven cleanup is more actionable than the generic timeout for an
|
|
4265
|
+
// admitted lease, because recovery must preserve its fenced claim.
|
|
4266
|
+
error: cleanupFailed ? cleanup.error : `physical provision did not reach active in ${provisionTimeout}s`,
|
|
4267
|
+
cleanup, ...(parkIncident ? { incident: parkIncident } : {}),
|
|
4268
|
+
};
|
|
4269
|
+
|
|
4159
4270
|
}
|
|
4160
4271
|
}
|
|
4161
4272
|
if (active?.failed || active?.error || active?.auth) {
|
|
@@ -4167,7 +4278,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4167
4278
|
if (read?.ok && read.data?.success && typeof read.data.error === "string" && read.data.error) why = ` — ${read.data.error}`;
|
|
4168
4279
|
}
|
|
4169
4280
|
const cleanup = await requestCleanup();
|
|
4170
|
-
return {
|
|
4281
|
+
return {
|
|
4282
|
+
exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : (active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED),
|
|
4283
|
+
outcome: "error", leaseId, state: active?.state ?? null, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active${why}`, cleanup,
|
|
4284
|
+
...(parkIncident ? { incident: parkIncident } : {}),
|
|
4285
|
+
};
|
|
4171
4286
|
}
|
|
4172
4287
|
}
|
|
4173
4288
|
break;
|
|
@@ -4358,7 +4473,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4358
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`
|
|
4359
4474
|
// BOT-2418: an idle release is a deliberate keep (BOT-1803), not an unproven reap.
|
|
4360
4475
|
: cleanupResult?.ok === true && cleanupResult.idle === true
|
|
4361
|
-
? `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)`
|
|
4362
4477
|
: `reap not proven (${cleanupResult?.error ?? "no cleanup ran"})${retained} so a stack the Helper may still run is never double-booked` }
|
|
4363
4478
|
: releaseError || disposition?.released === false
|
|
4364
4479
|
? { released: false, reason: `the reap was proven but the claim could not be removed (${releaseError?.message ?? "it is still on disk"})${retained}` }
|
|
@@ -4374,6 +4489,7 @@ export function runReceiptFields(result) {
|
|
|
4374
4489
|
// BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
|
|
4375
4490
|
// so automation can branch on it instead of matching the message text.
|
|
4376
4491
|
command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
|
|
4492
|
+
...(result.state ? { state: result.state } : {}),
|
|
4377
4493
|
// BOT-2468: the lease's /docker?lease= deeplink (null when the server sent none).
|
|
4378
4494
|
web_url: result.webUrl ?? null,
|
|
4379
4495
|
// BOT-2024: a stale-capacity refusal carries its phase + armable wait on the receipt.
|
|
@@ -4382,6 +4498,13 @@ export function runReceiptFields(result) {
|
|
|
4382
4498
|
child_exit_code: result.childExitCode ?? null,
|
|
4383
4499
|
// BOT-1803: an idle release is `cleanup: { idle: true, idle_since }` (nothing was reaped).
|
|
4384
4500
|
cleanup: result.cleanup?.idle ? { idle: true, idle_since: result.cleanup.idle_since ?? null } : result.cleanup?.ok ?? null,
|
|
4501
|
+
cleanup_code: result.cleanup?.code ?? null,
|
|
4502
|
+
cleanup_error: result.cleanup?.ok === false ? result.cleanup.error ?? "cleanup was not proven" : null,
|
|
4503
|
+
...(result.leaseId && result.cleanup?.ok === false ? { recovery: {
|
|
4504
|
+
status: `bb stack status ${result.leaseId}`,
|
|
4505
|
+
wait: `bb wait 'lease:id=${result.leaseId},state=reaped' --heartbeat`,
|
|
4506
|
+
release: `bb stack done ${result.leaseId} --reap`,
|
|
4507
|
+
} } : {}),
|
|
4385
4508
|
...(result.volumesKept ? { volumes_kept: true } : {}),
|
|
4386
4509
|
...(result.idleLease?.reused ? { reused: result.idleLease.reused } : {}),
|
|
4387
4510
|
...(result.idleLease?.reprovisioned ? { reprovisioned: result.idleLease.reprovisioned } : {}),
|
|
@@ -4401,7 +4524,7 @@ export function runReceiptFields(result) {
|
|
|
4401
4524
|
// BOT-2207: why it ran where it did (e.g. moved_remote_on_requeue) and every host weighed.
|
|
4402
4525
|
...(result.execution?.placementReason ? { placement_reason: result.execution.placementReason } : {}),
|
|
4403
4526
|
...(result.execution?.considered ? { considered: result.execution.considered } : {}),
|
|
4404
|
-
fenced: result.fenced
|
|
4527
|
+
fenced: result.fenced === true || result.cleanup?.ok === false, error: result.error ?? result.cleanup?.error ?? null,
|
|
4405
4528
|
// BOT-2099: a platform auth/availability blip during the park is reported as an
|
|
4406
4529
|
// incident (reason, window, attempts, recovered) — never as the lease's outcome.
|
|
4407
4530
|
...(result.incident ? { platform_incident: result.incident } : {}),
|
|
@@ -4440,10 +4563,14 @@ export async function cmdRun(opts, childArgv, {
|
|
|
4440
4563
|
*/
|
|
4441
4564
|
/**
|
|
4442
4565
|
* BOT-2467: the fields the outcome classifier reads — the receipt's, plus facts the
|
|
4443
|
-
* receipt deliberately does not carry (which wait a timeout ended
|
|
4566
|
+
* receipt deliberately does not carry (which wait a timeout ended and the original
|
|
4567
|
+
* lease fence). Never emitted.
|
|
4444
4568
|
*/
|
|
4445
4569
|
export function stackRunOutcomeFields(result) {
|
|
4446
|
-
return {
|
|
4570
|
+
return {
|
|
4571
|
+
...runReceiptFields(result), lease_fenced: result?.fenced === true,
|
|
4572
|
+
...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}),
|
|
4573
|
+
};
|
|
4447
4574
|
}
|
|
4448
4575
|
|
|
4449
4576
|
async function parseErrorOutcomeChannel(argv, { resolveCredential = resolveAgentSessionCredential, callTool = callToolJson } = {}) {
|
package/src/wait-core.mjs
CHANGED
|
@@ -35,6 +35,7 @@ export const EXIT = Object.freeze({
|
|
|
35
35
|
// liveness reaper abandoned it, it was cancelled, or its row is gone. The relay
|
|
36
36
|
// refuses the stream with a typed `wait_abandoned` error frame; re-arm from next_cursor.
|
|
37
37
|
ABANDONED: 11,
|
|
38
|
+
LEASE_CLEANUP_FAILED: 12,
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
// BOT-2013: the uuid spellings Postgres accepts for a lease id (uuid_in), mirrored by the SQL
|
|
@@ -58,6 +59,7 @@ export function leaseLostDetail(signal, condition) {
|
|
|
58
59
|
lease_id: signal.payload?.lease_id ?? condition.params?.id ?? null,
|
|
59
60
|
lease_state: leaseState,
|
|
60
61
|
reap_reason: typeof signal.payload?.reap_reason === "string" ? signal.payload.reap_reason : null,
|
|
62
|
+
...(condition.params?.state === "active" && signal.payload?.error_class ? { error_class: signal.payload.error_class } : {}),
|
|
61
63
|
};
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -797,7 +799,15 @@ function compactLeaseLostState(state) {
|
|
|
797
799
|
if (!state || typeof state !== "object") return null;
|
|
798
800
|
const short = (v) => (typeof v === "string" ? v.slice(0, 64) : null);
|
|
799
801
|
if (state.lease_id == null && state.reap_reason == null) return null;
|
|
800
|
-
return { lease_id: short(state.lease_id), lease_state: short(state.lease_state), reap_reason: short(state.reap_reason)
|
|
802
|
+
return { lease_id: short(state.lease_id), lease_state: short(state.lease_state), reap_reason: short(state.reap_reason),
|
|
803
|
+
...(state.cleanup_unverified === true ? { cleanup_unverified: true } : {}) };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function compactCleanupState(state) {
|
|
807
|
+
if (!state || !["signed_reap", "never_provisioned", "local_exec_teardown", "pre_driver_absence"].includes(state.cleanup_proof)) return null;
|
|
808
|
+
const short = (v) => typeof v === "string" ? v.slice(0, 64) : null;
|
|
809
|
+
return Object.fromEntries(["lease_id", "lease_state", "reap_disposition", "cleanup_proof", "cleanup_receipt_id"]
|
|
810
|
+
.filter((key) => state[key] != null).map((key) => [key, short(state[key])]));
|
|
801
811
|
}
|
|
802
812
|
|
|
803
813
|
/**
|
|
@@ -818,8 +828,9 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
|
|
|
818
828
|
// state is the whole point of that receipt (which lease, why) and is already three short
|
|
819
829
|
// fields, so it is kept compact instead (BOT-2013, Codex #1017 P2).
|
|
820
830
|
const leaseLostState = clone.outcome === "lease_lost" ? compactLeaseLostState(clone.state) : null;
|
|
831
|
+
const cleanupState = ["matched", "error"].includes(clone.outcome) ? compactCleanupState(clone.state) : null;
|
|
821
832
|
if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes) {
|
|
822
|
-
clone.state = leaseLostState ?? { truncated: true };
|
|
833
|
+
clone.state = leaseLostState ?? cleanupState ?? { truncated: true };
|
|
823
834
|
}
|
|
824
835
|
// A matched signal's subject key can be caller-controlled through the raw
|
|
825
836
|
// event escape hatch. Keep the durable sequence pointer, but not an
|
|
@@ -863,6 +874,19 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
|
|
|
863
874
|
delete clone.detail;
|
|
864
875
|
clone.detail_truncated = true;
|
|
865
876
|
}
|
|
877
|
+
if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && cleanupState) {
|
|
878
|
+
const base = {
|
|
879
|
+
schema_version: clone.schema_version, outcome: clone.outcome,
|
|
880
|
+
...(clone.error ? { error: String(clone.error).slice(0, 48) } : {}),
|
|
881
|
+
state: cleanupState, truncated: true,
|
|
882
|
+
};
|
|
883
|
+
for (const key of ["client", "next_cursor", "wait_session_id"]) {
|
|
884
|
+
if (clone[key] == null) continue;
|
|
885
|
+
const candidate = { ...base, [key]: clone[key] };
|
|
886
|
+
if (Buffer.byteLength(JSON.stringify(candidate)) <= maxBytes) base[key] = clone[key];
|
|
887
|
+
}
|
|
888
|
+
return base;
|
|
889
|
+
}
|
|
866
890
|
if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "lease_lost") {
|
|
867
891
|
// BOT-2013: the bounded fallback keeps the lease-lost detail, then adds the resume
|
|
868
892
|
// pointers only while they still fit.
|
|
@@ -1096,12 +1120,22 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
1096
1120
|
// stack_lease frame for this lease id is ours to wake on.
|
|
1097
1121
|
if (signal.signal_type !== "stack_lease" || signal.subject_key !== `lease:${params.id}`) return false;
|
|
1098
1122
|
// BOT-2400: with `state=`, only that state matches — or a terminal loss on the way
|
|
1099
|
-
// (
|
|
1100
|
-
//
|
|
1123
|
+
// (any reap state while waiting for provisioning/active), which finalizeMatch reports
|
|
1124
|
+
// as lease_lost. `reaping` en route to `reaped` keeps waiting.
|
|
1101
1125
|
if (params.state != null) {
|
|
1102
1126
|
const leaseState = signal.payload?.state;
|
|
1127
|
+
if (params.state === "reaped") {
|
|
1128
|
+
// BOT-1948: a raw `reaped` row is not cleanup proof. Only the server's verified
|
|
1129
|
+
// evidence (an accepted signed reap receipt, or a verified never-provisioned no-op)
|
|
1130
|
+
// ends the reap wait as matched/cleanup-failed. An unverified reap_failed is still
|
|
1131
|
+
// terminal (BOT-2400 Codex #1194 P1) and ends lease_lost without claiming cleanup.
|
|
1132
|
+
if (signal.payload?.cleanup_verified === true) return leaseState === "reaped" || leaseState === "reap_failed";
|
|
1133
|
+
// The server marks a reaped registration snapshot it can never prove as
|
|
1134
|
+
// explicitly unverified (strictly false; an absent field keeps waiting).
|
|
1135
|
+
if (signal.payload?.cleanup_verified === false && leaseState === "reaped") return true;
|
|
1136
|
+
return leaseState === "reap_failed";
|
|
1137
|
+
}
|
|
1103
1138
|
if (leaseState === params.state) return true;
|
|
1104
|
-
if (params.state === "reaped") return leaseState === "reap_failed";
|
|
1105
1139
|
return LEASE_LOST_STATES.has(leaseState);
|
|
1106
1140
|
}
|
|
1107
1141
|
return true;
|
|
@@ -1564,6 +1598,25 @@ export async function runWaitLoop({
|
|
|
1564
1598
|
// provisioning against a dead lease.
|
|
1565
1599
|
const finalizeMatch = async (entry, signal) => {
|
|
1566
1600
|
const condition = conditions.find((c) => c.id === entry.condition_id);
|
|
1601
|
+
if (condition?.type === "lease" && condition.params.state === "reaped" && signal.payload?.cleanup_verified === true) {
|
|
1602
|
+
const p = signal.payload;
|
|
1603
|
+
const failed = p.state === "reap_failed";
|
|
1604
|
+
return finalize(failed ? "error" : "matched", {
|
|
1605
|
+
exitCode: failed ? EXIT.LEASE_CLEANUP_FAILED : EXIT.MATCHED, matched: [entry],
|
|
1606
|
+
state: { lease_id: condition.params.id, lease_state: p.state,
|
|
1607
|
+
reap_disposition: p.reap_disposition ?? null, cleanup_proof: p.cleanup_proof ?? null,
|
|
1608
|
+
cleanup_receipt_id: p.cleanup_receipt_id ?? null },
|
|
1609
|
+
error: failed ? "lease_cleanup_failed" : null,
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
if (condition?.type === "lease" && condition.params.state === "reaped" && signal.payload?.cleanup_verified === false) {
|
|
1613
|
+
const p = signal.payload;
|
|
1614
|
+
return finalize("lease_lost", {
|
|
1615
|
+
exitCode: EXIT.LEASE_LOST, matched: [entry], error: "lease_lost",
|
|
1616
|
+
state: { lease_id: p.lease_id ?? condition.params.id, lease_state: p.state ?? null,
|
|
1617
|
+
reap_reason: typeof p.reap_reason === "string" ? p.reap_reason : null, cleanup_unverified: true },
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1567
1620
|
const lost = leaseLostDetail(signal, condition);
|
|
1568
1621
|
if (lost) {
|
|
1569
1622
|
return finalize("lease_lost", { exitCode: EXIT.LEASE_LOST, matched: [entry], state: lost, error: "lease_lost" });
|
package/src/wait.mjs
CHANGED
|
@@ -108,9 +108,14 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
108
108
|
\`botbuddy stack up\` (recipient-scoped to the owner).
|
|
109
109
|
Level-triggered: an already-granted lease wakes at once.
|
|
110
110
|
lease:id=<lease_id>,state=<s> BOT-2400: wait for that exact state (provisioning, active,
|
|
111
|
-
reaped).
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
reaped). provisioning/active end on lease loss (exit 9,
|
|
112
|
+
with the provisioning failure's reason and class).
|
|
113
|
+
lease:id=<lease_id>,state=reaped proven cleanup of that owned lease (BOT-1948). Requires an
|
|
114
|
+
accepted successful reap receipt or a verified
|
|
115
|
+
never-provisioned no-op; preserves stop/destroy
|
|
116
|
+
disposition. An already-cleaned lease matches at once;
|
|
117
|
+
reaping keeps waiting. A verified failed reap exits 12;
|
|
118
|
+
an unverified gave-up reap exits 9 (lease_lost).
|
|
114
119
|
guidance:request=<post_id> a command-post guidance request being answered
|
|
115
120
|
(scoped to the asking agent; fetch the body with
|
|
116
121
|
get_guidance_response)
|
|
@@ -200,7 +205,8 @@ OUTPUT
|
|
|
200
205
|
· 11 abandoned (the server ended this wait while it
|
|
201
206
|
was parked — reaped after 180 s without a live stream,
|
|
202
207
|
or cancelled; receipt outcome abandoned +
|
|
203
|
-
state.status/reason — re-arm with --since <next_cursor>)
|
|
208
|
+
state.status/reason — re-arm with --since <next_cursor>)
|
|
209
|
+
· 12 named lease cleanup failed.
|
|
204
210
|
If the receipt has
|
|
205
211
|
reacquire_lock (claim=true), acquire_lock again FIRST and re-run
|
|
206
212
|
only on a queued/busy result (granted = you hold it — just resume);
|