@botbuddy/cli 1.60.0 → 1.60.2
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/commands.mjs +16 -4
- package/src/run.mjs +165 -7
- package/src/stack-run-outcome.mjs +5 -3
- package/src/stack.mjs +82 -20
- 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.2",
|
|
3
|
+
"source_commit": "6c687a8220e66196ede9b560405ff1493d0752f0",
|
|
4
|
+
"source_sha256": "2bb86426999a110ead51a17d7ab60b5cc816901b9cbd3c4426b6d02e75ab6288",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "5a197ab76ecd57a479f26d364ad06b1f226a45ac6642a7f034ab725c432940e7"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.60.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.2",
|
|
3
|
+
"source_commit": "6c687a8220e66196ede9b560405ff1493d0752f0",
|
|
4
|
+
"source_sha256": "2bb86426999a110ead51a17d7ab60b5cc816901b9cbd3c4426b6d02e75ab6288",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "f3c2ae3f7dd8c80ff439817d25e0577f236d532dafbc6714402945a84abce354"
|
|
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.2",
|
|
4
|
+
"source_commit": "6c687a8220e66196ede9b560405ff1493d0752f0",
|
|
5
|
+
"source_sha256": "2bb86426999a110ead51a17d7ab60b5cc816901b9cbd3c4426b6d02e75ab6288",
|
|
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": "5a197ab76ecd57a479f26d364ad06b1f226a45ac6642a7f034ab725c432940e7",
|
|
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": "f3c2ae3f7dd8c80ff439817d25e0577f236d532dafbc6714402945a84abce354",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -483,12 +483,24 @@ does not prove that the workload is still running.
|
|
|
483
483
|
|
|
484
484
|
${bold("Safe recovery")}
|
|
485
485
|
1. Preserve the command-run ID and the original result.
|
|
486
|
-
2.
|
|
487
|
-
3.
|
|
486
|
+
2. Run ${cyan("bb run inspect <command-run-id>")} from the owning checkout.
|
|
487
|
+
3. Prefer the owning runner's local terminal receipt for the workload verdict.
|
|
488
|
+
4. If the receipt is terminal, do not cancel or duplicate the command solely
|
|
488
489
|
because remote state remains ${cyan("registered")} or ${cyan("running")}.
|
|
489
|
-
|
|
490
|
+
5. If the receipt is absent/non-terminal, retain the original runner result;
|
|
490
491
|
do not poll or infer completion from elapsed time.
|
|
491
|
-
|
|
492
|
+
6. Report the receipt and remote discrepancy as telemetry evidence. A stale
|
|
493
|
+
test-run or command-run is never validation evidence.
|
|
494
|
+
|
|
495
|
+
${bold("One controlled push retry")}
|
|
496
|
+
Only after inspection leaves the original push unresolved, arm an exact-SHA CI
|
|
497
|
+
wait before one new push, then verify the resulting CI run reports that SHA:
|
|
498
|
+
|
|
499
|
+
${cyan("bb wait 'ci:repo=SupplySpark/supplyspark-ent,scope=next,sha=<full-sha>' --heartbeat")}
|
|
500
|
+
|
|
501
|
+
Do not substitute ${cyan("pr-state")}, a timer, or a status-polling loop for a bare
|
|
502
|
+
push. If the prior push has no terminal receipt, keep it unresolved rather than
|
|
503
|
+
counting it as validation evidence.
|
|
492
504
|
|
|
493
505
|
${bold("Boundary")}
|
|
494
506
|
Command-run status is agent-owned. A different agent may be denied access; that
|
package/src/run.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { stackLeaseIdFromEnv, withStackLeaseCorrelation } from "./stack-lease-co
|
|
|
11
11
|
import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
|
-
import { spawn } from "node:child_process";
|
|
14
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { performance } from "node:perf_hooks";
|
|
17
17
|
import { callToolJson } from "./api.mjs";
|
|
@@ -26,7 +26,7 @@ import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
|
26
26
|
import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
|
|
27
27
|
import { VERSION } from "./version.mjs";
|
|
28
28
|
import {
|
|
29
|
-
TERMINAL_RETRY_BUDGET_MS, DELIVERY_CALL_TIMEOUT_MS, callWithContextIdentity, classifyTerminalUpdateFailure,
|
|
29
|
+
TERMINAL_RETRY_BUDGET_MS, DELIVERY_CALL_TIMEOUT_MS, callWithContextIdentity, classifyTerminalUpdateFailure, contextServerUrl,
|
|
30
30
|
ensureRunDelivery, parkedRecord, runDelivery, terminalUpdateAccepted,
|
|
31
31
|
} from "./run-delivery.mjs";
|
|
32
32
|
|
|
@@ -221,6 +221,154 @@ export function commandHash(command) { return createHash("sha256").update(JSON.s
|
|
|
221
221
|
export function environmentHash(cwd, environment) { return createHash("sha256").update(`${cwd}\0${environment}`).digest("hex"); }
|
|
222
222
|
export function receiptPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.receipt.json`); }
|
|
223
223
|
export function contextPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.context.json`); }
|
|
224
|
+
|
|
225
|
+
// A command receipt is evidence about one checkout revision, not a claim that
|
|
226
|
+
// its working tree was clean. Resolving this once at launch lets recovery
|
|
227
|
+
// distinguish a stale callback from an unrelated later command without ever
|
|
228
|
+
// returning the command line itself.
|
|
229
|
+
export function sourceSha(cwd, { exec = execFileSync } = {}) {
|
|
230
|
+
try {
|
|
231
|
+
const sha = String(exec("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })).trim();
|
|
232
|
+
return /^[0-9a-f]{40,64}$/i.test(sha) ? sha.toLowerCase() : null;
|
|
233
|
+
} catch { return null; }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const TERMINAL_COMMAND_STATUSES = new Set(["success", "failure", "cancelled", "timed_out", "owner_lost"]);
|
|
237
|
+
const TERMINAL_REMOTE_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out", "owner_lost", "invalidated"]);
|
|
238
|
+
const REMOTE_STATUS_FOR_COMMAND_STATUS = Object.freeze({
|
|
239
|
+
success: "succeeded",
|
|
240
|
+
failure: "failed",
|
|
241
|
+
cancelled: "cancelled",
|
|
242
|
+
timed_out: "timed_out",
|
|
243
|
+
owner_lost: "owner_lost",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
function parseOwnedRunFile(raw, runId) {
|
|
247
|
+
try {
|
|
248
|
+
const value = JSON.parse(raw);
|
|
249
|
+
return value && value.run_id === runId ? value : null;
|
|
250
|
+
} catch { return null; }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function freshContext(context, now) {
|
|
254
|
+
const launchedAt = Date.parse(context?.launched_at ?? "");
|
|
255
|
+
const timeoutMs = Number(context?.timeout_seconds) * 1_000;
|
|
256
|
+
return Number.isFinite(launchedAt) && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
257
|
+
&& now <= launchedAt + timeoutMs + TIMEOUT_GRACE_MS + 60_000;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function publicRemoteSummary(result) {
|
|
261
|
+
if (!result?.ok || result.isError || !result.data || typeof result.data !== "object") return { state: "unavailable" };
|
|
262
|
+
const status = typeof result.data.status === "string" ? result.data.status : null;
|
|
263
|
+
return {
|
|
264
|
+
state: status ? "available" : "unavailable",
|
|
265
|
+
...(status ? { status } : {}),
|
|
266
|
+
...(typeof result.data.exit_code === "number" ? { exit_code: result.data.exit_code } : {}),
|
|
267
|
+
...(typeof result.data.ended_at === "string" ? { ended_at: result.data.ended_at } : {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function terminalOutcomesAgree(receipt, remote) {
|
|
272
|
+
if (!receipt || remote.state !== "available") return false;
|
|
273
|
+
if (REMOTE_STATUS_FOR_COMMAND_STATUS[receipt.status] !== remote.status) return false;
|
|
274
|
+
// A missing exit code is meaningful for cancellation, timeout, and
|
|
275
|
+
// owner-loss. When both sides do have one, a disagreement is never hidden.
|
|
276
|
+
return typeof receipt.exit_code !== "number" || typeof remote.exit_code !== "number"
|
|
277
|
+
|| receipt.exit_code === remote.exit_code;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Reconcile only evidence the current OS user and owning BotBuddy agent can
|
|
281
|
+
// read. This is intentionally advisory: an inspector must never terminate a
|
|
282
|
+
// worker, replay a callback, or make a retry safe merely by observing a file.
|
|
283
|
+
export async function inspectRun(runId, {
|
|
284
|
+
call = callToolJson,
|
|
285
|
+
read = readFile,
|
|
286
|
+
now = Date.now(),
|
|
287
|
+
processAlive = workerProcessGroupAlive,
|
|
288
|
+
receiptFile = receiptPath,
|
|
289
|
+
contextFile = contextPath,
|
|
290
|
+
} = {}) {
|
|
291
|
+
if (typeof runId !== "string" || !runId.trim()) {
|
|
292
|
+
return { schema_version: RUN_SCHEMA_VERSION, classification: "rejected", rerun_safe: false, cancellation_safe: false, error: "run_id_required" };
|
|
293
|
+
}
|
|
294
|
+
const id = runId.trim();
|
|
295
|
+
const [receiptRaw, contextRaw] = await Promise.all([
|
|
296
|
+
read(receiptFile(id), "utf8").catch(() => null),
|
|
297
|
+
read(contextFile(id), "utf8").catch(() => null),
|
|
298
|
+
]);
|
|
299
|
+
const receipt = typeof receiptRaw === "string" ? parseOwnedRunFile(receiptRaw, id) : null;
|
|
300
|
+
const context = typeof contextRaw === "string" ? parseOwnedRunFile(contextRaw, id) : null;
|
|
301
|
+
// A durable run belongs to the agent that launched it. Prefer its sealed
|
|
302
|
+
// session identity; older contexts must use a current worktree session and
|
|
303
|
+
// never silently fall back to an unrelated durable machine credential.
|
|
304
|
+
const remoteResult = await callWithContextIdentity(context, call)("get_command_run", { run_id: id }, {
|
|
305
|
+
...(context ? { serverUrl: contextServerUrl(context) } : {}),
|
|
306
|
+
}).catch(() => null);
|
|
307
|
+
const remote = publicRemoteSummary(remoteResult);
|
|
308
|
+
const receiptTerminal = Boolean(receipt && TERMINAL_COMMAND_STATUSES.has(receipt.status));
|
|
309
|
+
const receiptFingerprint = receipt && typeof receipt.command_hash === "string" && /^[a-f0-9]{64}$/i.test(receipt.command_hash)
|
|
310
|
+
&& typeof receipt.source_sha === "string" && /^[a-f0-9]{40,64}$/i.test(receipt.source_sha);
|
|
311
|
+
const fingerprintMismatch = Boolean(context && (
|
|
312
|
+
(context.command_hash && context.command_hash !== receipt?.command_hash)
|
|
313
|
+
|| (context.source_sha && context.source_sha !== receipt?.source_sha)
|
|
314
|
+
));
|
|
315
|
+
const contextMatchesReceipt = !fingerprintMismatch;
|
|
316
|
+
const sourceBound = Boolean(receiptTerminal && receiptFingerprint && contextMatchesReceipt);
|
|
317
|
+
|
|
318
|
+
if (receiptTerminal) {
|
|
319
|
+
// A context that identifies a different command/revision must never be
|
|
320
|
+
// promoted to completed evidence, even if its receipt is terminal.
|
|
321
|
+
if (fingerprintMismatch) {
|
|
322
|
+
return {
|
|
323
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
324
|
+
run_id: id,
|
|
325
|
+
classification: "unknown",
|
|
326
|
+
rerun_safe: false,
|
|
327
|
+
cancellation_safe: false,
|
|
328
|
+
source_bound: false,
|
|
329
|
+
local: { status: null, exit_code: null },
|
|
330
|
+
remote,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const remoteTerminal = remote.state === "available" && TERMINAL_REMOTE_STATUSES.has(remote.status);
|
|
334
|
+
const outcomesAgree = remoteTerminal && terminalOutcomesAgree(receipt, remote);
|
|
335
|
+
return {
|
|
336
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
337
|
+
run_id: id,
|
|
338
|
+
classification: outcomesAgree ? "completed" : remoteTerminal ? "completed_locally_remote_conflict" : remote.status === "registered" || remote.status === "running" || remote.status === "approval_required" ? "completed_locally_remote_stale" : "completed_locally_remote_unknown",
|
|
339
|
+
rerun_safe: false,
|
|
340
|
+
cancellation_safe: false,
|
|
341
|
+
source_bound: sourceBound,
|
|
342
|
+
local: { status: receipt.status, exit_code: receipt.exit_code ?? null },
|
|
343
|
+
remote,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const pids = [context?.worker_pid, context?.workload_pid].filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
348
|
+
if (context && freshContext(context, now) && pids.some((pid) => processAlive(pid))) {
|
|
349
|
+
return {
|
|
350
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
351
|
+
run_id: id,
|
|
352
|
+
classification: "running",
|
|
353
|
+
rerun_safe: false,
|
|
354
|
+
cancellation_safe: false,
|
|
355
|
+
source_bound: false,
|
|
356
|
+
local: { status: null, exit_code: null },
|
|
357
|
+
remote,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
363
|
+
run_id: id,
|
|
364
|
+
classification: "unknown",
|
|
365
|
+
rerun_safe: false,
|
|
366
|
+
cancellation_safe: false,
|
|
367
|
+
source_bound: false,
|
|
368
|
+
local: { status: null, exit_code: null },
|
|
369
|
+
remote,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
224
372
|
function cancellationPath(contextFile) { return `${contextFile}.cancelled`; }
|
|
225
373
|
|
|
226
374
|
async function markCancellation(contextFile, runId) {
|
|
@@ -250,6 +398,8 @@ async function preWorkloadCancellationReceipt(context) {
|
|
|
250
398
|
ended_at: now,
|
|
251
399
|
exit_code: null,
|
|
252
400
|
signal: "SIGTERM",
|
|
401
|
+
command_hash: context.command_hash ?? commandHash(context.command ?? []),
|
|
402
|
+
source_sha: context.source_sha ?? null,
|
|
253
403
|
recovery_command: null,
|
|
254
404
|
bounded_log: "",
|
|
255
405
|
output_characters: 0,
|
|
@@ -347,7 +497,7 @@ function rejectedSessionReceipt(error, runId) {
|
|
|
347
497
|
|
|
348
498
|
export async function launchRun(argv, {
|
|
349
499
|
cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null,
|
|
350
|
-
resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env,
|
|
500
|
+
resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env, getSourceSha = sourceSha,
|
|
351
501
|
} = {}) {
|
|
352
502
|
const parsed = parseRunArgs(argv, env);
|
|
353
503
|
let managedCredential = null;
|
|
@@ -376,12 +526,14 @@ export async function launchRun(argv, {
|
|
|
376
526
|
const effectiveCall = (t, a) => (call ?? callToolJson)(t, a,
|
|
377
527
|
opts.sessionToken ? { auth: { "x-agent-api-key": opts.sessionToken } } : {});
|
|
378
528
|
const runId = randomUUID();
|
|
529
|
+
const commandFingerprint = commandHash(command);
|
|
530
|
+
const launchSourceSha = getSourceSha(cwd);
|
|
379
531
|
const registerArgs = () => ({
|
|
380
532
|
// Omit session_id entirely when only a token is present — the backend derives
|
|
381
533
|
// it. A plain id is still sent (and honoured) when supplied.
|
|
382
534
|
...(opts.sessionId ? { session_id: opts.sessionId } : {}),
|
|
383
535
|
run_id: runId, tool_category: opts.category,
|
|
384
|
-
command_hash:
|
|
536
|
+
command_hash: commandFingerprint, ...(launchSourceSha ? { source_sha: launchSourceSha } : {}), environment_hash: environmentHash(cwd, opts.environment),
|
|
385
537
|
environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
|
|
386
538
|
rerun_reason: opts.rerunReason,
|
|
387
539
|
});
|
|
@@ -411,7 +563,7 @@ export async function launchRun(argv, {
|
|
|
411
563
|
// every callback is attributable to the same agent. It deliberately stays
|
|
412
564
|
// outside child_env, receipts, and the workload's process environment.
|
|
413
565
|
const knownSession = opts.sessionId ?? managedCredential?.identity?.sessionId ?? managedCredential?.sessionId;
|
|
414
|
-
await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), server_url: SERVER_URL
|
|
566
|
+
await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, command_hash: commandFingerprint, source_sha: launchSourceSha, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), server_url: SERVER_URL, ...(opts.sessionToken ? { callback_session_token: opts.sessionToken } : {}), ...(knownSession ? { callback_session_id: knownSession } : {}), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
|
|
415
567
|
const [file, args] = workerInvocation(context);
|
|
416
568
|
try {
|
|
417
569
|
const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
@@ -804,7 +956,7 @@ export async function cancelRun(runId, {
|
|
|
804
956
|
async function finalize(context, state, call, retry = {}) {
|
|
805
957
|
const boundedLog = String(state.output ?? "").slice(-8192);
|
|
806
958
|
const outputCharacters = state.outputCharacters ?? String(state.output ?? "").length;
|
|
807
|
-
const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
|
|
959
|
+
const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, command_hash: context.command_hash ?? commandHash(context.command ?? []), source_sha: context.source_sha ?? null, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
|
|
808
960
|
await writeJson(context.receipt_path, receipt);
|
|
809
961
|
const bytes = await stat(context.receipt_path);
|
|
810
962
|
const sha256 = createHash("sha256").update(await readFile(context.receipt_path)).digest("hex");
|
|
@@ -882,6 +1034,12 @@ export async function cmdRun(args) {
|
|
|
882
1034
|
if (args.includes("--json")) oneLine({ schema_version: RUN_SCHEMA_VERSION, ...result }, false);
|
|
883
1035
|
return;
|
|
884
1036
|
}
|
|
1037
|
+
if (args[0] === "inspect") {
|
|
1038
|
+
const result = await inspectRun(args[1] ?? "");
|
|
1039
|
+
oneLine(result, false);
|
|
1040
|
+
process.exitCode = result.classification === "rejected" ? EXIT.INVALID : EXIT.OK;
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
885
1043
|
if (args[0] === "cancel") {
|
|
886
1044
|
const result = await cancelRun(args[1] ?? "");
|
|
887
1045
|
oneLine(result.receipt, false);
|
|
@@ -889,7 +1047,7 @@ export async function cmdRun(args) {
|
|
|
889
1047
|
return;
|
|
890
1048
|
}
|
|
891
1049
|
if (args[0] === "help" || args[0] === "--help") {
|
|
892
|
-
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
|
|
1050
|
+
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run inspect <run_id>\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
|
|
893
1051
|
return;
|
|
894
1052
|
}
|
|
895
1053
|
// BOT-1701 (AC-6): one stderr nudge when a tier-3 legacy flag/env alias is used
|
|
@@ -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
|
@@ -728,6 +728,10 @@ export function truncateReceipt(receipt, maxBytes = DEFAULT_RECEIPT_MAX_BYTES) {
|
|
|
728
728
|
schema_version: r.schema_version, command: r.command, outcome: r.outcome,
|
|
729
729
|
lease_id: r.lease_id ?? null, state: r.state ?? null, exit_code: r.exit_code,
|
|
730
730
|
error: r.error ?? null, truncated: true,
|
|
731
|
+
...(r.cleanup !== undefined ? { cleanup: r.cleanup } : {}),
|
|
732
|
+
...(r.fenced !== undefined ? { fenced: r.fenced } : {}),
|
|
733
|
+
...(r.cleanup_code ? { cleanup_code: r.cleanup_code } : {}),
|
|
734
|
+
...(r.cleanup_error ? { cleanup_error: String(r.cleanup_error).slice(0, 160) } : {}),
|
|
731
735
|
};
|
|
732
736
|
}
|
|
733
737
|
|
|
@@ -1156,6 +1160,11 @@ export const REMOTE_REFUSAL_GUIDANCE = Object.freeze({
|
|
|
1156
1160
|
|
|
1157
1161
|
/** The refusal text for a receipt: the server's message, else the CLI's guidance, else the code. */
|
|
1158
1162
|
export function leaseRefusalError(data) {
|
|
1163
|
+
if (data?.lease_id && ["STACK_CONTEXT_BIND_FAILED", "STACK_CONTEXT_ALREADY_BOUND"].includes(data?.code)) {
|
|
1164
|
+
return `${data.message || data.code}; lease ${data.lease_id} is ${data.state ?? "unknown"}. ` +
|
|
1165
|
+
`Inspect with \`bb stack status ${data.lease_id}\`; after the owning batch ends, release with \`bb stack done ${data.lease_id} --reap\`, ` +
|
|
1166
|
+
`then wait with \`bb wait 'lease:id=${data.lease_id},state=reaped' --heartbeat\`.`;
|
|
1167
|
+
}
|
|
1159
1168
|
return data?.message || REMOTE_REFUSAL_GUIDANCE[data?.code] || data?.code || "lease request refused";
|
|
1160
1169
|
}
|
|
1161
1170
|
|
|
@@ -3452,9 +3461,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3452
3461
|
// BOT-2099: a best-effort diagnostic read (ledger, persisted Helper reason) never throws.
|
|
3453
3462
|
const readLeaseQuietly = async (id) => { try { return await api.get?.(id) ?? null; } catch { return null; } };
|
|
3454
3463
|
// BOT-2237: the typed end of a park that cannot re-authenticate — names the lease, never a
|
|
3455
|
-
// capacity timeout.
|
|
3464
|
+
// capacity timeout. Keep AUTH after confirmed cleanup; unproven cleanup takes
|
|
3465
|
+
// CLEANUP_FAILED while preserving the primary credential diagnostic.
|
|
3456
3466
|
const authUnavailableResult = (waited, phase, cleanup, waitedLeaseId = leaseId) => ({
|
|
3457
|
-
exitCode:
|
|
3467
|
+
exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : EXIT.AUTH,
|
|
3468
|
+
outcome: "error", code: PARK_INCIDENT_REASONS.AUTH, leaseId: waitedLeaseId ?? null, cleanup,
|
|
3458
3469
|
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
3470
|
...(parkIncident ? { incident: parkIncident } : {}),
|
|
3460
3471
|
});
|
|
@@ -3595,7 +3606,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3595
3606
|
// state — instead of leaving this wait to park until --reap-timeout on a reap
|
|
3596
3607
|
// that already failed. Report that specific outcome instead of the generic
|
|
3597
3608
|
// "was not proven" timeout message.
|
|
3598
|
-
//
|
|
3609
|
+
// Cleanup owns one immutable deadline. Its signed reap wait may recover from a
|
|
3610
|
+
// transient relay/auth blip, but never repeats the release request. A persistent
|
|
3611
|
+
// failure remains explicit receipt data below; it never fabricates a reap.
|
|
3599
3612
|
const reaped = await resilientWait(leaseId, (state) => state === "reaped", (state) => state === "reap_failed", { timeoutSec: opts.reapTimeout, auth: rpc.auth });
|
|
3600
3613
|
if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
3601
3614
|
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 +3625,21 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3612
3625
|
if (recheck?.ok && recheck.data?.success && recheck.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
3613
3626
|
const postCheck = durableReapFailure(recheck);
|
|
3614
3627
|
if (postCheck) return cleanupResult = postCheck;
|
|
3615
|
-
|
|
3628
|
+
const persistentWaitFailure = reaped?.incident && reaped.incident.recovered === false
|
|
3629
|
+
? reaped.incident.last_error
|
|
3630
|
+
: null;
|
|
3631
|
+
return cleanupResult = {
|
|
3632
|
+
ok: false,
|
|
3633
|
+
error: persistentWaitFailure ?? (reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven")),
|
|
3634
|
+
};
|
|
3616
3635
|
})().catch((error) => {
|
|
3617
|
-
//
|
|
3618
|
-
//
|
|
3619
|
-
|
|
3620
|
-
|
|
3636
|
+
// A release/read whose session renewal throws is a failed cleanup of THIS lease —
|
|
3637
|
+
// never an exception that escapes the lifecycle and loses its recovery receipt.
|
|
3638
|
+
return cleanupResult = {
|
|
3639
|
+
ok: false,
|
|
3640
|
+
code: error?.code ?? null,
|
|
3641
|
+
error: String(error?.message ?? error),
|
|
3642
|
+
};
|
|
3621
3643
|
});
|
|
3622
3644
|
return cleanupPromise;
|
|
3623
3645
|
};
|
|
@@ -3906,11 +3928,14 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3906
3928
|
?? stackPathRootRefusalNotice(request.data?.code, request.data?.message)
|
|
3907
3929
|
?? remoteRefusalNotice(request.data?.code);
|
|
3908
3930
|
if (hint) stderrLine(hint);
|
|
3909
|
-
//
|
|
3910
|
-
|
|
3931
|
+
// A refusal naming an admitted lease (including a failed context bind or
|
|
3932
|
+
// an idle mode mismatch) is not proof that its ports are free. Keep the
|
|
3933
|
+
// claim until owner-safe cleanup is verified; never reap a possibly live batch.
|
|
3934
|
+
if (request.data?.lease_id) retainBlock = true;
|
|
3911
3935
|
return {
|
|
3912
3936
|
exitCode: leaseRefusalExit(request.data?.code), outcome: "error", code: request.data?.code,
|
|
3913
3937
|
...capacityStaleReceiptFields(request.data), error: leaseRefusalError(request.data),
|
|
3938
|
+
...(request.data.lease_id ? { leaseId: request.data.lease_id, state: request.data.state ?? null } : {}),
|
|
3914
3939
|
};
|
|
3915
3940
|
}
|
|
3916
3941
|
leaseId = request.data.lease_id;
|
|
@@ -3934,7 +3959,12 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3934
3959
|
// With a stable identity a live lease in THIS stack directory is bound to our block.
|
|
3935
3960
|
if (request.data.stack_path != null && request.data.stack_path === execution.stackPath &&
|
|
3936
3961
|
(request.data.worktree_root == null || request.data.worktree_root === execution.worktreeRoot)) retainBlock = true;
|
|
3937
|
-
|
|
3962
|
+
const blockedState = request.data.state ?? "unknown";
|
|
3963
|
+
const recovery = blockedState === "reaping"
|
|
3964
|
+
? `wait with \`bb wait 'lease:id=${leaseId},state=reaped' --heartbeat\``
|
|
3965
|
+
: `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\``;
|
|
3966
|
+
return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, state: blockedState,
|
|
3967
|
+
error: `live lease already exists for this agent and stack slot: ${leaseId} is ${blockedState}; ${recovery}` };
|
|
3938
3968
|
}
|
|
3939
3969
|
|
|
3940
3970
|
leaseProvisioned = true; // from here a Helper may act on this lease
|
|
@@ -4112,7 +4142,13 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4112
4142
|
};
|
|
4113
4143
|
}
|
|
4114
4144
|
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) {
|
|
4145
|
+
if (parked?.error || parked?.auth) {
|
|
4146
|
+
const cleanup = await requestCleanup();
|
|
4147
|
+
return {
|
|
4148
|
+
exitCode: parked?.auth && cleanup?.ok === false ? EXIT.CLEANUP_FAILED : (parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED),
|
|
4149
|
+
outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup, ...incident,
|
|
4150
|
+
};
|
|
4151
|
+
}
|
|
4116
4152
|
state = parked.state;
|
|
4117
4153
|
}
|
|
4118
4154
|
if (state !== "active") {
|
|
@@ -4143,7 +4179,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4143
4179
|
// BOT-1816's class of dropped wakes) even though the lease genuinely went
|
|
4144
4180
|
// active or was deferred back to queued. One direct read before declaring
|
|
4145
4181
|
// a timeout avoids reaping a healthy stack or destroying a kept queue place.
|
|
4146
|
-
|
|
4182
|
+
// Production adapters always expose `get`. Focused lifecycle adapters may
|
|
4183
|
+
// intentionally model only admission and release; preserve their original
|
|
4184
|
+
// terminal cleanup receipt rather than replacing it with a test-only TypeError.
|
|
4185
|
+
const recheck = api.get ? await api.get(leaseId) : null;
|
|
4147
4186
|
if (recheck?.ok && recheck.data?.success && recheck.data.state === "queued") {
|
|
4148
4187
|
parkView = recheck.data;
|
|
4149
4188
|
const why = parkView.provision_defer_reason ?? "host capacity";
|
|
@@ -4153,9 +4192,16 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4153
4192
|
}
|
|
4154
4193
|
if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
|
|
4155
4194
|
const cleanup = await requestCleanup();
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4195
|
+
const cleanupFailed = cleanup?.ok === false;
|
|
4196
|
+
return {
|
|
4197
|
+
exitCode: cleanupFailed ? EXIT.CLEANUP_FAILED : EXIT.TIMEOUT,
|
|
4198
|
+
outcome: "timeout", timeoutPhase: "provision", leaseId,
|
|
4199
|
+
// An unproven cleanup is more actionable than the generic timeout for an
|
|
4200
|
+
// admitted lease, because recovery must preserve its fenced claim.
|
|
4201
|
+
error: cleanupFailed ? cleanup.error : `physical provision did not reach active in ${provisionTimeout}s`,
|
|
4202
|
+
cleanup, ...(parkIncident ? { incident: parkIncident } : {}),
|
|
4203
|
+
};
|
|
4204
|
+
|
|
4159
4205
|
}
|
|
4160
4206
|
}
|
|
4161
4207
|
if (active?.failed || active?.error || active?.auth) {
|
|
@@ -4167,7 +4213,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4167
4213
|
if (read?.ok && read.data?.success && typeof read.data.error === "string" && read.data.error) why = ` — ${read.data.error}`;
|
|
4168
4214
|
}
|
|
4169
4215
|
const cleanup = await requestCleanup();
|
|
4170
|
-
return {
|
|
4216
|
+
return {
|
|
4217
|
+
exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : (active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED),
|
|
4218
|
+
outcome: "error", leaseId, state: active?.state ?? null, error: active?.error || `lease ${active?.state ?? "failed"} before reaching active${why}`, cleanup,
|
|
4219
|
+
...(parkIncident ? { incident: parkIncident } : {}),
|
|
4220
|
+
};
|
|
4171
4221
|
}
|
|
4172
4222
|
}
|
|
4173
4223
|
break;
|
|
@@ -4374,6 +4424,7 @@ export function runReceiptFields(result) {
|
|
|
4374
4424
|
// BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
|
|
4375
4425
|
// so automation can branch on it instead of matching the message text.
|
|
4376
4426
|
command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
|
|
4427
|
+
...(result.state ? { state: result.state } : {}),
|
|
4377
4428
|
// BOT-2468: the lease's /docker?lease= deeplink (null when the server sent none).
|
|
4378
4429
|
web_url: result.webUrl ?? null,
|
|
4379
4430
|
// BOT-2024: a stale-capacity refusal carries its phase + armable wait on the receipt.
|
|
@@ -4382,6 +4433,13 @@ export function runReceiptFields(result) {
|
|
|
4382
4433
|
child_exit_code: result.childExitCode ?? null,
|
|
4383
4434
|
// BOT-1803: an idle release is `cleanup: { idle: true, idle_since }` (nothing was reaped).
|
|
4384
4435
|
cleanup: result.cleanup?.idle ? { idle: true, idle_since: result.cleanup.idle_since ?? null } : result.cleanup?.ok ?? null,
|
|
4436
|
+
cleanup_code: result.cleanup?.code ?? null,
|
|
4437
|
+
cleanup_error: result.cleanup?.ok === false ? result.cleanup.error ?? "cleanup was not proven" : null,
|
|
4438
|
+
...(result.leaseId && result.cleanup?.ok === false ? { recovery: {
|
|
4439
|
+
status: `bb stack status ${result.leaseId}`,
|
|
4440
|
+
wait: `bb wait 'lease:id=${result.leaseId},state=reaped' --heartbeat`,
|
|
4441
|
+
release: `bb stack done ${result.leaseId} --reap`,
|
|
4442
|
+
} } : {}),
|
|
4385
4443
|
...(result.volumesKept ? { volumes_kept: true } : {}),
|
|
4386
4444
|
...(result.idleLease?.reused ? { reused: result.idleLease.reused } : {}),
|
|
4387
4445
|
...(result.idleLease?.reprovisioned ? { reprovisioned: result.idleLease.reprovisioned } : {}),
|
|
@@ -4401,7 +4459,7 @@ export function runReceiptFields(result) {
|
|
|
4401
4459
|
// BOT-2207: why it ran where it did (e.g. moved_remote_on_requeue) and every host weighed.
|
|
4402
4460
|
...(result.execution?.placementReason ? { placement_reason: result.execution.placementReason } : {}),
|
|
4403
4461
|
...(result.execution?.considered ? { considered: result.execution.considered } : {}),
|
|
4404
|
-
fenced: result.fenced
|
|
4462
|
+
fenced: result.fenced === true || result.cleanup?.ok === false, error: result.error ?? result.cleanup?.error ?? null,
|
|
4405
4463
|
// BOT-2099: a platform auth/availability blip during the park is reported as an
|
|
4406
4464
|
// incident (reason, window, attempts, recovered) — never as the lease's outcome.
|
|
4407
4465
|
...(result.incident ? { platform_incident: result.incident } : {}),
|
|
@@ -4440,10 +4498,14 @@ export async function cmdRun(opts, childArgv, {
|
|
|
4440
4498
|
*/
|
|
4441
4499
|
/**
|
|
4442
4500
|
* 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
|
|
4501
|
+
* receipt deliberately does not carry (which wait a timeout ended and the original
|
|
4502
|
+
* lease fence). Never emitted.
|
|
4444
4503
|
*/
|
|
4445
4504
|
export function stackRunOutcomeFields(result) {
|
|
4446
|
-
return {
|
|
4505
|
+
return {
|
|
4506
|
+
...runReceiptFields(result), lease_fenced: result?.fenced === true,
|
|
4507
|
+
...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}),
|
|
4508
|
+
};
|
|
4447
4509
|
}
|
|
4448
4510
|
|
|
4449
4511
|
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);
|