@botbuddy/cli 1.29.2 → 1.29.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/wait-checkpoint.mjs +410 -0
- package/src/wait-core.mjs +187 -63
- package/src/wait.mjs +807 -21
package/src/wait.mjs
CHANGED
|
@@ -20,6 +20,8 @@ import { fileURLToPath } from "node:url";
|
|
|
20
20
|
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
21
21
|
import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
|
|
22
22
|
import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
23
|
+
import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
|
|
24
|
+
import { realpath } from "node:fs/promises";
|
|
23
25
|
|
|
24
26
|
// A protocol is deliberately distinct from package semver: compatible pinned
|
|
25
27
|
// clients keep working until the server raises this minimum, while a stale
|
|
@@ -33,6 +35,7 @@ import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
|
33
35
|
export const WAIT_PROTOCOL_VERSION = 3;
|
|
34
36
|
const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
|
|
35
37
|
const MIN_RECEIPT_MAX_BYTES = 512;
|
|
38
|
+
const MAX_RECEIPT_MAX_BYTES = 64 * 1024;
|
|
36
39
|
// BOT-1582: the server's session-token shape (bb_agent_ + 64 hex), plus the
|
|
37
40
|
// BOT-1572 bb_sess_ legacy alias, both accepted for one release.
|
|
38
41
|
const SESSION_TOKEN_RE = AGENT_KEY_RE;
|
|
@@ -116,7 +119,7 @@ OPTIONS
|
|
|
116
119
|
--any wake on the first matching condition (default; only mode in v1)
|
|
117
120
|
--timeout <seconds> max wait (default 7200; hard cap 28800). Timeout is exit 2, not an error.
|
|
118
121
|
--since <seq> resume from a prior receipt's next_cursor (replay what you missed)
|
|
119
|
-
--receipt-max-bytes <n> cap the receipt (
|
|
122
|
+
--receipt-max-bytes <n> cap the receipt (512..65536; default 10240; payloads truncate to pointers)
|
|
120
123
|
--heartbeat keep this agent session alive while waiting (so it is not reaped)
|
|
121
124
|
--url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
|
|
122
125
|
--agent-key <token> the bb_agent_ session token to authenticate this wait — a one-liner
|
|
@@ -126,6 +129,9 @@ OPTIONS
|
|
|
126
129
|
--session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID.
|
|
127
130
|
Unnecessary when $BOTBUDDY_AGENT_KEY / --agent-key is set (the relay derives the session from the token).
|
|
128
131
|
--help show this help
|
|
132
|
+
resume <local_wait_id> recover one explicitly named saved wait
|
|
133
|
+
cancel <local_wait_id> explicitly stop one saved wait
|
|
134
|
+
acknowledge <local_wait_id> mark one saved terminal receipt read
|
|
129
135
|
|
|
130
136
|
AUTH
|
|
131
137
|
A wait authenticates from the bb_agent_ session token (minted by register_agent):
|
|
@@ -184,6 +190,8 @@ function parseArgv(argv) {
|
|
|
184
190
|
// is for tests/overrides.
|
|
185
191
|
sessionToken: readAgentKeyEnv(process.env),
|
|
186
192
|
help: false,
|
|
193
|
+
deadlineAt: null,
|
|
194
|
+
resumeLocalWait: null,
|
|
187
195
|
};
|
|
188
196
|
for (let i = 0; i < argv.length; i++) {
|
|
189
197
|
const a = argv[i];
|
|
@@ -200,6 +208,7 @@ function parseArgv(argv) {
|
|
|
200
208
|
else if (a === "--any") opts.mode = "any";
|
|
201
209
|
else if (a === "--heartbeat") opts.heartbeat = true;
|
|
202
210
|
else if (a === "--timeout") opts.timeout = Number(optionValue());
|
|
211
|
+
else if (a === "--deadline-at") opts.deadlineAt = optionValue(); // internal recovery argv; never shown as public API
|
|
203
212
|
else if (a === "--since") opts.since = optionValue();
|
|
204
213
|
else if (a === "--receipt-max-bytes") opts.receiptMaxBytes = Number(optionValue());
|
|
205
214
|
else if (a === "--url") opts.url = optionValue();
|
|
@@ -217,6 +226,7 @@ function parseArgv(argv) {
|
|
|
217
226
|
// alias for the same value.
|
|
218
227
|
else if (a === "--agent-key" || a === "--session-token") opts.sessionToken = optionValue();
|
|
219
228
|
else if (a === "--session-id") opts.sessionId = optionValue();
|
|
229
|
+
else if (a === "--resume-local-wait") opts.resumeLocalWait = optionValue();
|
|
220
230
|
else if (a.startsWith("--")) opts.unknown = a;
|
|
221
231
|
else opts.conditions.push(a);
|
|
222
232
|
}
|
|
@@ -226,6 +236,28 @@ function parseArgv(argv) {
|
|
|
226
236
|
// Register a wait_session on the relay (BOT-989 M2). Returns
|
|
227
237
|
// {waitSessionId, cursorStart} on success. Profiled waits fail closed unless the
|
|
228
238
|
// relay authenticates and attests their machine principal.
|
|
239
|
+
async function reserveClaimQueueBoundary(opts, conditions) {
|
|
240
|
+
const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: {
|
|
243
|
+
Authorization: `Bearer ${opts.token}`,
|
|
244
|
+
"x-agent-api-key": opts.token || "",
|
|
245
|
+
"Content-Type": "application/json",
|
|
246
|
+
},
|
|
247
|
+
body: JSON.stringify({
|
|
248
|
+
action: "reserve_claim_boundary",
|
|
249
|
+
client_version: VERSION,
|
|
250
|
+
wait_protocol_version: WAIT_PROTOCOL_VERSION,
|
|
251
|
+
local_wait_id: opts.localWaitId,
|
|
252
|
+
conditions,
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
const body = await res.json().catch(() => ({}));
|
|
257
|
+
throw new Error(body.detail || body.error || `claim_boundary_failed_${res.status}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
229
261
|
async function registerWait(opts, conditions, deadlineIso) {
|
|
230
262
|
// BOT-1608: a relay wait authenticates from the per-session `bb_agent_` token
|
|
231
263
|
// ($BOTBUDDY_AGENT_KEY) — the relay derives agent, tenant, and session from it.
|
|
@@ -238,6 +270,11 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
238
270
|
conditions,
|
|
239
271
|
deadline: deadlineIso,
|
|
240
272
|
mode: opts.mode,
|
|
273
|
+
...(opts.localWaitId ? { local_wait_id: opts.localWaitId } : {}),
|
|
274
|
+
...(opts.expectedAgentId ? {
|
|
275
|
+
expected_agent_id: opts.expectedAgentId,
|
|
276
|
+
expected_tenant_id: opts.expectedTenantId ?? null,
|
|
277
|
+
} : {}),
|
|
241
278
|
};
|
|
242
279
|
const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
|
|
243
280
|
method: "POST",
|
|
@@ -298,6 +335,8 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
298
335
|
// CLI fails fast before this, but a mismatched/forced body could reach it) —
|
|
299
336
|
// a hard stop, never the untracked live-only fallback.
|
|
300
337
|
"session_id_required",
|
|
338
|
+
"wait_resume_identity_invalid",
|
|
339
|
+
"wait_resume_identity_mismatch",
|
|
301
340
|
]);
|
|
302
341
|
if (INVALID_CONDITION_CODES.has(body.error)) {
|
|
303
342
|
const err = new Error(body.detail || body.error);
|
|
@@ -308,7 +347,7 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
308
347
|
}
|
|
309
348
|
if (res.status === 403) {
|
|
310
349
|
const body = await res.json().catch(() => ({}));
|
|
311
|
-
if (new Set(["wait_tenant_unresolved", "wait_cross_tenant", "wait_tenant_mismatch"]).has(body.error)) {
|
|
350
|
+
if (new Set(["wait_tenant_unresolved", "wait_cross_tenant", "wait_tenant_mismatch", "wait_resume_identity_mismatch"]).has(body.error)) {
|
|
312
351
|
const err = new Error(body.detail || body.error);
|
|
313
352
|
err.invalidCondition = true;
|
|
314
353
|
err.errorCode = body.error;
|
|
@@ -399,6 +438,13 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
399
438
|
// pr-review/pr-state target. Report-only — surfaced on stderr so a silent
|
|
400
439
|
// edge-triggered park isn't mistaken for "no review yet".
|
|
401
440
|
prReviewSnapshot: Array.isArray(body.pr_review_snapshot) ? body.pr_review_snapshot : null,
|
|
441
|
+
status: typeof body.status === "string" ? body.status : "active",
|
|
442
|
+
// A canonicalized alias wait was deliberately abandoned and already has a
|
|
443
|
+
// sequenced wait_superseded signal. Preserve that terminal state and replay
|
|
444
|
+
// the signal; treating it as a reaper abandonment would resurrect an
|
|
445
|
+
// unmatchable alias registration.
|
|
446
|
+
recoveryError: typeof body.recovery_error === "string" ? body.recovery_error : null,
|
|
447
|
+
terminalReceipt: body.terminal_receipt && typeof body.terminal_receipt === "object" ? body.terminal_receipt : null,
|
|
402
448
|
};
|
|
403
449
|
}
|
|
404
450
|
|
|
@@ -428,11 +474,15 @@ async function finalizeWait(opts, waitSessionId, receipt, timeoutMs = 5000) {
|
|
|
428
474
|
}),
|
|
429
475
|
signal: ac.signal,
|
|
430
476
|
});
|
|
431
|
-
|
|
477
|
+
const body = await res.json().catch(() => ({}));
|
|
478
|
+
if (!res.ok || body?.finalized !== true) {
|
|
432
479
|
process.stderr.write(`bb-wait: finalize (non-fatal) returned ${res.status}\n`);
|
|
480
|
+
return { delivered: false, error: body?.error || (res.ok ? "finalize_not_applied" : `finalize_http_${res.status}`) };
|
|
433
481
|
}
|
|
482
|
+
return { delivered: true };
|
|
434
483
|
} catch (err) {
|
|
435
484
|
process.stderr.write(`bb-wait: finalize (non-fatal) failed: ${err && err.message || err}\n`);
|
|
485
|
+
return { delivered: false, error: err?.name === "AbortError" ? "finalize_timeout" : "finalize_unavailable" };
|
|
436
486
|
} finally {
|
|
437
487
|
clearTimeout(timer);
|
|
438
488
|
}
|
|
@@ -619,6 +669,34 @@ function withClientIdentity(receipt) {
|
|
|
619
669
|
};
|
|
620
670
|
}
|
|
621
671
|
|
|
672
|
+
function publicWaitReceipt(receipt, {
|
|
673
|
+
sessionTenant = null,
|
|
674
|
+
agentId = null,
|
|
675
|
+
sessionAgentId = null,
|
|
676
|
+
sessionId = null,
|
|
677
|
+
maxBytes = 10240,
|
|
678
|
+
} = {}) {
|
|
679
|
+
return truncateReceipt(withClientIdentity(withPrincipalReceipt(receipt, null, {
|
|
680
|
+
sessionTenant,
|
|
681
|
+
agentId,
|
|
682
|
+
sessionAgentId,
|
|
683
|
+
sessionId,
|
|
684
|
+
})), maxBytes);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// A recovered terminal receipt can originate from a server timeout before the
|
|
688
|
+
// client reconnects. Its cursor is only the server's registration frontier; a
|
|
689
|
+
// newer locally-persisted safe cursor is already known to be replayed and is
|
|
690
|
+
// therefore the correct continuation point for the next wait.
|
|
691
|
+
function preserveCheckpointCursor(receipt, safeCursor, createdAt = null) {
|
|
692
|
+
const saved = typeof safeCursor === "string" ? safeCursor : String(safeCursor ?? "");
|
|
693
|
+
const current = typeof receipt?.next_cursor === "string" ? receipt.next_cursor : String(receipt?.next_cursor ?? "");
|
|
694
|
+
const withCursor = /^\d+$/.test(saved) && /^\d+$/.test(current) && BigInt(saved) > BigInt(current)
|
|
695
|
+
? { ...receipt, next_cursor: saved }
|
|
696
|
+
: receipt;
|
|
697
|
+
return !withCursor?.started_at && typeof createdAt === "string" ? { ...withCursor, started_at: createdAt } : withCursor;
|
|
698
|
+
}
|
|
699
|
+
|
|
622
700
|
function emit(receipt, { versioned = false, maxBytes = null } = {}) {
|
|
623
701
|
const enriched = versioned ? receipt : withClientIdentity(receipt);
|
|
624
702
|
const bounded = Number.isSafeInteger(maxBytes) && maxBytes >= MIN_RECEIPT_MAX_BYTES
|
|
@@ -758,7 +836,486 @@ async function sendSetupErrorEvent(opts, { error, exitCode, conditions }, signal
|
|
|
758
836
|
});
|
|
759
837
|
}
|
|
760
838
|
|
|
761
|
-
|
|
839
|
+
function checkpointStore() {
|
|
840
|
+
// State is intentionally bound to the current worktree. A caller may point
|
|
841
|
+
// cwd at another checkout only by explicitly starting there; we never follow
|
|
842
|
+
// an arbitrary state-file path supplied in argv.
|
|
843
|
+
return createWaitCheckpointStore({ root: process.cwd() });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function inventoryEntry(checkpoint) {
|
|
847
|
+
const id = checkpoint.local_wait_id;
|
|
848
|
+
const terminal = checkpoint.status === "terminal";
|
|
849
|
+
const pendingCancellation = checkpoint.status === "cancelled" && checkpoint.cloud_cancel_pending === true;
|
|
850
|
+
const pendingAcknowledgement = checkpoint.status === "acknowledged" && checkpoint.cloud_ack_pending === true;
|
|
851
|
+
return {
|
|
852
|
+
local_wait_id: id,
|
|
853
|
+
status: checkpoint.status,
|
|
854
|
+
conditions: checkpoint.conditions?.map((c) => ({ condition_id: c.id, type: c.type })) ?? [],
|
|
855
|
+
ticket: checkpoint.ticket ?? null,
|
|
856
|
+
created_at: checkpoint.created_at ?? null,
|
|
857
|
+
deadline_at: checkpoint.deadline_at ?? null,
|
|
858
|
+
receipt_available: terminal && checkpoint.terminal_receipt != null,
|
|
859
|
+
recovery_argv: pendingCancellation
|
|
860
|
+
? ["wait", "cancel", id]
|
|
861
|
+
: pendingAcknowledgement ? ["wait", "acknowledge", id] : ["wait", "resume", id],
|
|
862
|
+
cancel_argv: terminal || pendingAcknowledgement ? null : ["wait", "cancel", id],
|
|
863
|
+
acknowledge_argv: terminal || pendingAcknowledgement ? ["wait", "acknowledge", id] : null,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function inspectSavedWaits(argv = []) {
|
|
868
|
+
const rawOffset = argv[0] === "--offset" ? Number(argv[1]) : 0;
|
|
869
|
+
if (!Number.isSafeInteger(rawOffset) || rawOffset < 0 || argv.length > (argv[0] === "--offset" ? 2 : 0)) {
|
|
870
|
+
process.stderr.write("bb-wait: usage: bb wait [--offset <n>]\n");
|
|
871
|
+
process.exit(EXIT.INVALID);
|
|
872
|
+
}
|
|
873
|
+
const inventory = await checkpointStore().list({ offset: rawOffset });
|
|
874
|
+
// Never let a non-interactive agent accidentally acquire an owner or consume
|
|
875
|
+
// a result. The inventory deliberately omits receipt bodies and shell text.
|
|
876
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
877
|
+
process.stdout.write(`${JSON.stringify({
|
|
878
|
+
schema_version: 1,
|
|
879
|
+
outcome: "saved_waits",
|
|
880
|
+
waits: inventory.items.map(inventoryEntry),
|
|
881
|
+
has_more: inventory.has_more,
|
|
882
|
+
...(inventory.next_offset != null ? { next_offset: inventory.next_offset, next_page_argv: ["wait", "--offset", String(inventory.next_offset)] } : {}),
|
|
883
|
+
})}\n`);
|
|
884
|
+
process.exit(EXIT.MATCHED);
|
|
885
|
+
}
|
|
886
|
+
if (inventory.items.length === 0) {
|
|
887
|
+
process.stdout.write(HELP);
|
|
888
|
+
process.exit(EXIT.MATCHED);
|
|
889
|
+
}
|
|
890
|
+
// A TTY gets discovery guidance without an implicit selection. This is a
|
|
891
|
+
// deliberately conservative menu: a restart must name an ID before mutation.
|
|
892
|
+
process.stdout.write("Saved bb waits:\n");
|
|
893
|
+
for (const item of inventory.items) {
|
|
894
|
+
process.stdout.write(` ${item.local_wait_id} ${item.status} ${item.deadline_at ?? ""}\n`);
|
|
895
|
+
}
|
|
896
|
+
process.stdout.write("Run `bb wait resume <local_wait_id>`, `bb wait cancel <local_wait_id>`, or `bb wait acknowledge <local_wait_id>`. Ctrl-C leaves saved waits unchanged.\n");
|
|
897
|
+
process.exit(EXIT.MATCHED);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export function resumedSpecs(checkpoint) {
|
|
901
|
+
const specs = Array.isArray(checkpoint.condition_specs) ? [...checkpoint.condition_specs] : [];
|
|
902
|
+
if (specs.length === 0) throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint has no recoverable condition arguments");
|
|
903
|
+
let timerIndex = 0;
|
|
904
|
+
return specs.map((spec) => {
|
|
905
|
+
if (!String(spec).startsWith("timer:")) return spec;
|
|
906
|
+
const fireAt = checkpoint.timer_deadlines?.[timerIndex++];
|
|
907
|
+
if (typeof fireAt !== "string") throw new WaitCheckpointError("wait_checkpoint_invalid", "timer checkpoint has no original fire time");
|
|
908
|
+
return `timer:deadline=${fireAt}`;
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export function restoreCapacityStaleDeadlines(conditions, checkpoint) {
|
|
913
|
+
const capacity = conditions.filter((condition) => condition.type === "capacity");
|
|
914
|
+
if (capacity.length === 0) return conditions;
|
|
915
|
+
const deadlines = checkpoint.capacity_stale_deadlines;
|
|
916
|
+
const createdMs = Date.parse(checkpoint.created_at);
|
|
917
|
+
if (!Array.isArray(deadlines) || deadlines.length !== capacity.length || !Number.isFinite(createdMs)) {
|
|
918
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "capacity checkpoint has no original stale-grace deadlines");
|
|
919
|
+
}
|
|
920
|
+
for (let index = 0; index < capacity.length; index++) {
|
|
921
|
+
const deadlineMs = Date.parse(deadlines[index]);
|
|
922
|
+
const maximum = createdMs + capacity[index].params.staleGraceSec * 1000 + 1_000;
|
|
923
|
+
if (!Number.isFinite(deadlineMs) || deadlineMs > maximum) {
|
|
924
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "capacity stale-grace deadline exceeds its original budget");
|
|
925
|
+
}
|
|
926
|
+
capacity[index].params.staleDeadlineMs = deadlineMs;
|
|
927
|
+
}
|
|
928
|
+
return conditions;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function deliverRecoveryAction(checkpoint, action) {
|
|
932
|
+
if (!checkpoint.cloud_wait_session_id) return { delivered: true };
|
|
933
|
+
const token = readAgentKeyEnv(process.env);
|
|
934
|
+
if (!token || !SESSION_TOKEN_RE.test(token)) {
|
|
935
|
+
return { delivered: false, error: "session_token_required" };
|
|
936
|
+
}
|
|
937
|
+
const baseUrl = checkpoint.relay_url || process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
|
|
938
|
+
const controller = new AbortController();
|
|
939
|
+
const timer = setTimeout(() => controller.abort(), 5_000);
|
|
940
|
+
try {
|
|
941
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/event-stream`, {
|
|
942
|
+
method: "POST",
|
|
943
|
+
headers: {
|
|
944
|
+
Authorization: `Bearer ${token}`,
|
|
945
|
+
"x-agent-api-key": token,
|
|
946
|
+
"Content-Type": "application/json",
|
|
947
|
+
},
|
|
948
|
+
body: JSON.stringify({ action, wait_session_id: checkpoint.cloud_wait_session_id }),
|
|
949
|
+
signal: controller.signal,
|
|
950
|
+
});
|
|
951
|
+
const body = await res.json().catch(() => ({}));
|
|
952
|
+
const resultKey = action === "acknowledge" ? "acknowledged" : "cancelled";
|
|
953
|
+
// The recovery endpoint is deliberately idempotent and uses a 200 response
|
|
954
|
+
// for a rejected state transition. Only its explicit boolean proves that
|
|
955
|
+
// the cloud row changed; otherwise retain the local delivery outbox.
|
|
956
|
+
if (!res.ok || body?.[resultKey] !== true) {
|
|
957
|
+
return { delivered: false, error: body?.error || `${action}_not_applied` };
|
|
958
|
+
}
|
|
959
|
+
return { delivered: true };
|
|
960
|
+
} catch (error) {
|
|
961
|
+
return { delivered: false, error: error?.name === "AbortError" ? `${action}_timeout` : `${action}_unavailable` };
|
|
962
|
+
} finally {
|
|
963
|
+
clearTimeout(timer);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function checkpointNeedsRelay(checkpoint) {
|
|
968
|
+
return checkpoint.conditions.some((condition) => condition.type !== "timer");
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async function resolveRecoveryWaitSession(checkpoint, { reserveMissing = false } = {}) {
|
|
972
|
+
if (checkpoint.cloud_wait_session_id || !checkpointNeedsRelay(checkpoint)) {
|
|
973
|
+
return { registration: null };
|
|
974
|
+
}
|
|
975
|
+
const token = readAgentKeyEnv(process.env);
|
|
976
|
+
if (!token || !SESSION_TOKEN_RE.test(token)) {
|
|
977
|
+
return { error: "session_token_required" };
|
|
978
|
+
}
|
|
979
|
+
const url = checkpoint.relay_url || process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
|
|
980
|
+
const controller = new AbortController();
|
|
981
|
+
const timer = setTimeout(() => controller.abort(), 5_000);
|
|
982
|
+
try {
|
|
983
|
+
const res = await fetch(`${url.replace(/\/$/, "")}/event-stream`, {
|
|
984
|
+
method: "POST",
|
|
985
|
+
headers: {
|
|
986
|
+
Authorization: `Bearer ${token}`,
|
|
987
|
+
"x-agent-api-key": token,
|
|
988
|
+
"Content-Type": "application/json",
|
|
989
|
+
},
|
|
990
|
+
body: JSON.stringify({ action: reserveMissing ? "recover_cancel_absent" : "recover_lookup", local_wait_id: checkpoint.local_wait_id }),
|
|
991
|
+
signal: controller.signal,
|
|
992
|
+
});
|
|
993
|
+
const body = await res.json().catch(() => ({}));
|
|
994
|
+
if (!res.ok) return { error: body?.error || "wait_recovery_lookup_failed" };
|
|
995
|
+
if (body?.found !== true) return { missing: true };
|
|
996
|
+
if (typeof body.wait_session_id !== "string" || !body.wait_session_id) {
|
|
997
|
+
return { error: "wait_session_id_unavailable" };
|
|
998
|
+
}
|
|
999
|
+
return {
|
|
1000
|
+
registration: {
|
|
1001
|
+
waitSessionId: body.wait_session_id,
|
|
1002
|
+
cursorStart: body.cursor_start ?? null,
|
|
1003
|
+
sessionTenant: checkpoint.tenant_id ?? null,
|
|
1004
|
+
agentId: checkpoint.agent_id ?? null,
|
|
1005
|
+
sessionId: checkpoint.arming_session_id ?? null,
|
|
1006
|
+
sessionAgentId: null,
|
|
1007
|
+
status: typeof body.status === "string" ? body.status : "active",
|
|
1008
|
+
terminalReceipt: body.terminal_receipt && typeof body.terminal_receipt === "object" ? body.terminal_receipt : null,
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
return { error: error?.name === "AbortError" ? "wait_recovery_lookup_timeout" : "wait_recovery_lookup_unavailable" };
|
|
1013
|
+
} finally {
|
|
1014
|
+
clearTimeout(timer);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function deliverTerminalFinalization(checkpoint) {
|
|
1019
|
+
if (!checkpoint.cloud_wait_session_id || !checkpoint.terminal_receipt) return { delivered: true };
|
|
1020
|
+
const token = readAgentKeyEnv(process.env);
|
|
1021
|
+
if (!token || !SESSION_TOKEN_RE.test(token)) return { delivered: false, error: "session_token_required" };
|
|
1022
|
+
const baseUrl = checkpoint.relay_url || process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
|
|
1023
|
+
const controller = new AbortController();
|
|
1024
|
+
const timer = setTimeout(() => controller.abort(), 5_000);
|
|
1025
|
+
try {
|
|
1026
|
+
const receipt = checkpoint.terminal_receipt;
|
|
1027
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/event-stream`, {
|
|
1028
|
+
method: "POST",
|
|
1029
|
+
headers: {
|
|
1030
|
+
Authorization: `Bearer ${token}`,
|
|
1031
|
+
"x-agent-api-key": token,
|
|
1032
|
+
"Content-Type": "application/json",
|
|
1033
|
+
},
|
|
1034
|
+
body: JSON.stringify({
|
|
1035
|
+
action: "recover_finalize",
|
|
1036
|
+
wait_session_id: checkpoint.cloud_wait_session_id,
|
|
1037
|
+
status: receipt.outcome,
|
|
1038
|
+
receipt,
|
|
1039
|
+
reconnects: receipt.reconnects ?? 0,
|
|
1040
|
+
degraded: receipt.degraded ?? [],
|
|
1041
|
+
}),
|
|
1042
|
+
signal: controller.signal,
|
|
1043
|
+
});
|
|
1044
|
+
const body = await res.json().catch(() => ({}));
|
|
1045
|
+
if (!res.ok || body?.finalized !== true) {
|
|
1046
|
+
return { delivered: false, error: body?.error || "finalize_not_applied" };
|
|
1047
|
+
}
|
|
1048
|
+
return { delivered: true };
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
return { delivered: false, error: error?.name === "AbortError" ? "finalize_timeout" : "finalize_unavailable" };
|
|
1051
|
+
} finally {
|
|
1052
|
+
clearTimeout(timer);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function flushPendingFinalization(store, checkpoint) {
|
|
1057
|
+
if (!checkpoint.cloud_wait_session_id || !checkpoint.terminal_receipt ||
|
|
1058
|
+
(checkpoint.cloud_finalize_pending === false && checkpoint.cloud_finalized_at)) return checkpoint;
|
|
1059
|
+
if (checkpoint.terminal_receipt.outcome === "superseded") {
|
|
1060
|
+
return store.update(checkpoint.local_wait_id, {
|
|
1061
|
+
cloud_finalize_pending: false,
|
|
1062
|
+
cloud_finalized_at: new Date().toISOString(),
|
|
1063
|
+
cloud_finalize_error: null,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
const delivered = await deliverTerminalFinalization(checkpoint);
|
|
1067
|
+
return store.update(checkpoint.local_wait_id, delivered.delivered
|
|
1068
|
+
? { cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null }
|
|
1069
|
+
: { cloud_finalize_pending: true, cloud_finalize_error: delivered.error });
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function flushPendingAcknowledgement(store, checkpoint) {
|
|
1073
|
+
if (!checkpoint.cloud_ack_pending || !checkpoint.cloud_wait_session_id) return checkpoint;
|
|
1074
|
+
checkpoint = await flushPendingFinalization(store, checkpoint);
|
|
1075
|
+
if (checkpoint.cloud_finalize_pending) {
|
|
1076
|
+
return store.update(checkpoint.local_wait_id, { cloud_ack_pending: true, cloud_ack_error: "finalize_pending" });
|
|
1077
|
+
}
|
|
1078
|
+
const delivered = await deliverRecoveryAction(checkpoint, "acknowledge");
|
|
1079
|
+
if (delivered.delivered) {
|
|
1080
|
+
return store.update(checkpoint.local_wait_id, {
|
|
1081
|
+
cloud_ack_pending: false,
|
|
1082
|
+
cloud_acknowledged_at: new Date().toISOString(),
|
|
1083
|
+
cloud_ack_error: null,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
return store.update(checkpoint.local_wait_id, { cloud_ack_pending: true, cloud_ack_error: delivered.error });
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async function resumeSavedWait(id) {
|
|
1090
|
+
const store = checkpointStore();
|
|
1091
|
+
let checkpoint;
|
|
1092
|
+
try { checkpoint = await store.read(id); }
|
|
1093
|
+
catch (error) { return emitSavedWaitError(error); }
|
|
1094
|
+
if (!(await sameWorktree(checkpoint.worktree, process.cwd()))) {
|
|
1095
|
+
return emitSavedWaitError(new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different worktree"));
|
|
1096
|
+
}
|
|
1097
|
+
if (checkpoint.status === "terminal") {
|
|
1098
|
+
let fence;
|
|
1099
|
+
try {
|
|
1100
|
+
fence = await store.acquireFence(id);
|
|
1101
|
+
checkpoint = await store.read(id);
|
|
1102
|
+
if (checkpoint.status !== "terminal") {
|
|
1103
|
+
throw new WaitCheckpointError("wait_not_resumable", `saved wait ${id} is ${checkpoint.status}`);
|
|
1104
|
+
}
|
|
1105
|
+
checkpoint = await flushPendingFinalization(store, checkpoint);
|
|
1106
|
+
checkpoint = await flushPendingAcknowledgement(store, checkpoint);
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
return emitSavedWaitError(error);
|
|
1109
|
+
} finally {
|
|
1110
|
+
await fence?.release();
|
|
1111
|
+
}
|
|
1112
|
+
process.stdout.write(`${JSON.stringify(checkpoint.terminal_receipt)}\n`);
|
|
1113
|
+
process.exit(Number.isInteger(checkpoint.exit_code) ? checkpoint.exit_code : EXIT.INTERNAL);
|
|
1114
|
+
}
|
|
1115
|
+
if (["cancelled", "acknowledged"].includes(checkpoint.status)) {
|
|
1116
|
+
return emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", `saved wait ${id} is ${checkpoint.status}`));
|
|
1117
|
+
}
|
|
1118
|
+
const remaining = Date.parse(checkpoint.deadline_at) - Date.now();
|
|
1119
|
+
if (!Number.isFinite(remaining)) return emitSavedWaitError(new WaitCheckpointError("wait_checkpoint_invalid", "saved wait deadline is invalid"));
|
|
1120
|
+
// Preserve the original absolute deadline. A resumed wait is never granted a
|
|
1121
|
+
// fresh timeout budget; the core loop settles it immediately if already due.
|
|
1122
|
+
const args = [
|
|
1123
|
+
...resumedSpecs(checkpoint),
|
|
1124
|
+
"--deadline-at", checkpoint.deadline_at,
|
|
1125
|
+
"--resume-local-wait", checkpoint.local_wait_id,
|
|
1126
|
+
"--receipt-max-bytes", String(checkpoint.receipt_max_bytes ?? 10240),
|
|
1127
|
+
];
|
|
1128
|
+
if (checkpoint.safe_cursor != null) args.push("--since", String(checkpoint.safe_cursor));
|
|
1129
|
+
if (checkpoint.heartbeat) args.push("--heartbeat");
|
|
1130
|
+
if (checkpoint.relay_url) args.push("--url", checkpoint.relay_url);
|
|
1131
|
+
return runWait(args, { recoveryLocalWaitId: checkpoint.local_wait_id });
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async function sameWorktree(first, second) {
|
|
1135
|
+
try { return await realpath(first) === await realpath(second); }
|
|
1136
|
+
catch { return false; }
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function emitSavedWaitError(error) {
|
|
1140
|
+
const code = error?.code ?? "wait_checkpoint_invalid";
|
|
1141
|
+
const exitCode = code === "wait_checkpoint_write_failed" ? EXIT.INTERNAL : EXIT.INVALID;
|
|
1142
|
+
process.stderr.write(`bb-wait: ${error?.message ?? code}\n`);
|
|
1143
|
+
process.stdout.write(`${JSON.stringify({
|
|
1144
|
+
schema_version: 1,
|
|
1145
|
+
outcome: "error",
|
|
1146
|
+
error: code,
|
|
1147
|
+
...(error?.localWaitId ? { local_wait_id: error.localWaitId } : {}),
|
|
1148
|
+
...(Array.isArray(error?.recoveryArgv) ? { recovery_argv: error.recoveryArgv } : {}),
|
|
1149
|
+
...(typeof error?.recovery === "string" ? { recovery: error.recovery } : {}),
|
|
1150
|
+
})}\n`);
|
|
1151
|
+
process.exit(exitCode);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function exitForRecoveredOutcome(receipt) {
|
|
1155
|
+
if (receipt?.outcome === "matched") return EXIT.MATCHED;
|
|
1156
|
+
if (receipt?.outcome === "cancelled") return EXIT.MATCHED;
|
|
1157
|
+
if (receipt?.outcome === "timeout") return EXIT.TIMEOUT;
|
|
1158
|
+
if (receipt?.outcome === "superseded") return EXIT.SUPERSEDED;
|
|
1159
|
+
if (receipt?.error === "cursor_expired") return EXIT.CURSOR_EXPIRED;
|
|
1160
|
+
if (receipt?.error === "unauthorized" || receipt?.error === "forbidden") return EXIT.AUTH;
|
|
1161
|
+
return EXIT.BACKEND;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async function cancelSavedWait(id) {
|
|
1165
|
+
try {
|
|
1166
|
+
const store = checkpointStore();
|
|
1167
|
+
let current = await store.read(id);
|
|
1168
|
+
if (current.status === "terminal" || current.status === "acknowledged") {
|
|
1169
|
+
const conflict = new WaitCheckpointError(
|
|
1170
|
+
"wait_not_cancellable",
|
|
1171
|
+
`saved wait ${id} is ${current.status} and cannot be cancelled`,
|
|
1172
|
+
);
|
|
1173
|
+
conflict.localWaitId = id;
|
|
1174
|
+
if (current.status === "terminal") conflict.recoveryArgv = ["wait", "resume", id];
|
|
1175
|
+
throw conflict;
|
|
1176
|
+
}
|
|
1177
|
+
// Refuse to cancel underneath a surviving waiter. A crashed owner is
|
|
1178
|
+
// reclaimed by the same process-instance fence used for resume.
|
|
1179
|
+
const fence = await store.acquireFence(id);
|
|
1180
|
+
try {
|
|
1181
|
+
current = await store.read(id);
|
|
1182
|
+
if (current.status === "terminal" || current.status === "acknowledged") {
|
|
1183
|
+
const conflict = new WaitCheckpointError(
|
|
1184
|
+
"wait_not_cancellable",
|
|
1185
|
+
`saved wait ${id} is ${current.status} and cannot be cancelled`,
|
|
1186
|
+
);
|
|
1187
|
+
conflict.localWaitId = id;
|
|
1188
|
+
if (current.status === "terminal") conflict.recoveryArgv = ["wait", "resume", id];
|
|
1189
|
+
throw conflict;
|
|
1190
|
+
}
|
|
1191
|
+
let delivered;
|
|
1192
|
+
const resolution = await resolveRecoveryWaitSession(current, { reserveMissing: true });
|
|
1193
|
+
if (resolution.registration) {
|
|
1194
|
+
const reg = resolution.registration;
|
|
1195
|
+
current = await store.update(id, {
|
|
1196
|
+
cloud_wait_session_id: reg.waitSessionId,
|
|
1197
|
+
cursor_start: reg.cursorStart,
|
|
1198
|
+
tenant_id: reg.sessionTenant ?? null,
|
|
1199
|
+
agent_id: reg.agentId ?? null,
|
|
1200
|
+
arming_session_id: reg.sessionId ?? null,
|
|
1201
|
+
});
|
|
1202
|
+
if (reg.terminalReceipt) {
|
|
1203
|
+
const recovered = preserveCheckpointCursor(publicWaitReceipt(reg.terminalReceipt, {
|
|
1204
|
+
sessionTenant: reg.sessionTenant,
|
|
1205
|
+
agentId: reg.agentId,
|
|
1206
|
+
sessionAgentId: reg.sessionAgentId,
|
|
1207
|
+
sessionId: reg.sessionId,
|
|
1208
|
+
maxBytes: current.receipt_max_bytes,
|
|
1209
|
+
}), current.safe_cursor, current.created_at);
|
|
1210
|
+
if (recovered.outcome === "cancelled") {
|
|
1211
|
+
current = await store.update(id, {
|
|
1212
|
+
status: "cancelled",
|
|
1213
|
+
terminal_receipt: recovered,
|
|
1214
|
+
exit_code: exitForRecoveredOutcome(recovered),
|
|
1215
|
+
cancelled_at: new Date().toISOString(),
|
|
1216
|
+
cloud_cancel_pending: false,
|
|
1217
|
+
cloud_cancelled_at: new Date().toISOString(),
|
|
1218
|
+
cloud_cancel_error: null,
|
|
1219
|
+
});
|
|
1220
|
+
delivered = { delivered: true };
|
|
1221
|
+
} else {
|
|
1222
|
+
await store.update(id, {
|
|
1223
|
+
status: "terminal",
|
|
1224
|
+
terminal_receipt: recovered,
|
|
1225
|
+
exit_code: exitForRecoveredOutcome(recovered),
|
|
1226
|
+
});
|
|
1227
|
+
const conflict = new WaitCheckpointError(
|
|
1228
|
+
"wait_not_cancellable",
|
|
1229
|
+
`saved wait ${id} is terminal and cannot be cancelled`,
|
|
1230
|
+
);
|
|
1231
|
+
conflict.localWaitId = id;
|
|
1232
|
+
conflict.recoveryArgv = ["wait", "resume", id];
|
|
1233
|
+
throw conflict;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
} else if (resolution.error) {
|
|
1237
|
+
delivered = { delivered: false, error: resolution.error };
|
|
1238
|
+
} else if (resolution.missing) {
|
|
1239
|
+
// The original process never reached registration. A lookup must never
|
|
1240
|
+
// create/evaluate a new claim merely to cancel it.
|
|
1241
|
+
delivered = { delivered: true };
|
|
1242
|
+
}
|
|
1243
|
+
delivered ??= await deliverRecoveryAction(current, "cancel");
|
|
1244
|
+
if (delivered.error === "wait_claim_grant_recovery_required") {
|
|
1245
|
+
// The grant committed before the former process could save its
|
|
1246
|
+
// receipt. Keep (or restore) a resumable checkpoint so the held
|
|
1247
|
+
// resource is visible and its grant can be replayed.
|
|
1248
|
+
current = await store.update(id, {
|
|
1249
|
+
status: "armed",
|
|
1250
|
+
cancelled_at: null,
|
|
1251
|
+
cloud_cancel_pending: false,
|
|
1252
|
+
cloud_cancel_error: delivered.error,
|
|
1253
|
+
});
|
|
1254
|
+
const conflict = new WaitCheckpointError(
|
|
1255
|
+
"wait_claim_grant_recovery_required",
|
|
1256
|
+
`saved wait ${id} already holds a committed claim; resume it to replay the grant`,
|
|
1257
|
+
);
|
|
1258
|
+
conflict.localWaitId = id;
|
|
1259
|
+
conflict.recoveryArgv = ["wait", "resume", id];
|
|
1260
|
+
throw conflict;
|
|
1261
|
+
}
|
|
1262
|
+
current = await store.update(id, {
|
|
1263
|
+
status: "cancelled",
|
|
1264
|
+
cancelled_at: new Date().toISOString(),
|
|
1265
|
+
cloud_cancel_pending: checkpointNeedsRelay(current) && !delivered.delivered,
|
|
1266
|
+
...(delivered.delivered
|
|
1267
|
+
? { cloud_cancelled_at: new Date().toISOString(), cloud_cancel_error: null }
|
|
1268
|
+
: { cloud_cancel_error: delivered.error }),
|
|
1269
|
+
});
|
|
1270
|
+
} finally {
|
|
1271
|
+
await fence.release();
|
|
1272
|
+
}
|
|
1273
|
+
process.stdout.write(`${JSON.stringify({
|
|
1274
|
+
schema_version: 1, outcome: "cancelled", local_wait_id: id,
|
|
1275
|
+
...(current.cloud_cancel_pending ? { cloud_cancel_pending: true } : {}),
|
|
1276
|
+
})}\n`);
|
|
1277
|
+
process.exit(EXIT.MATCHED);
|
|
1278
|
+
} catch (error) { return emitSavedWaitError(error); }
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function acknowledgeSavedWait(id) {
|
|
1282
|
+
let fence;
|
|
1283
|
+
try {
|
|
1284
|
+
const store = checkpointStore();
|
|
1285
|
+
// A terminal checkpoint can exist briefly while its original owner is still
|
|
1286
|
+
// responsible for emitting the receipt. Never hide or mutate that result
|
|
1287
|
+
// until the same process-instance fence used by resume proves ownership.
|
|
1288
|
+
fence = await store.acquireFence(id);
|
|
1289
|
+
let checkpoint = await store.read(id);
|
|
1290
|
+
if (checkpoint.status === "terminal") checkpoint = await flushPendingFinalization(store, checkpoint);
|
|
1291
|
+
checkpoint = await store.acknowledge(id, {
|
|
1292
|
+
cloudAckPending: Boolean(checkpoint.cloud_wait_session_id),
|
|
1293
|
+
});
|
|
1294
|
+
if (checkpoint.cloud_wait_session_id) {
|
|
1295
|
+
checkpoint = await flushPendingAcknowledgement(store, checkpoint);
|
|
1296
|
+
}
|
|
1297
|
+
process.stdout.write(`${JSON.stringify({
|
|
1298
|
+
schema_version: 1, outcome: "acknowledged", local_wait_id: id,
|
|
1299
|
+
...(checkpoint.cloud_ack_pending ? { cloud_ack_pending: true } : {}),
|
|
1300
|
+
})}\n`);
|
|
1301
|
+
await fence.release();
|
|
1302
|
+
fence = null;
|
|
1303
|
+
process.exit(EXIT.MATCHED);
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
await fence?.release();
|
|
1306
|
+
return emitSavedWaitError(error);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
1311
|
+
if (argv.length === 0 || argv[0] === "--offset") return inspectSavedWaits(argv);
|
|
1312
|
+
if (argv[0] === "resume") {
|
|
1313
|
+
const id = argv[1];
|
|
1314
|
+
if (!id || argv.length > 2) return emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "select exactly one saved wait: bb wait resume <local_wait_id>"));
|
|
1315
|
+
return resumeSavedWait(id);
|
|
1316
|
+
}
|
|
1317
|
+
if (argv[0] === "cancel") return argv.length === 2 ? cancelSavedWait(argv[1]) : emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "usage: bb wait cancel <local_wait_id>"));
|
|
1318
|
+
if (argv[0] === "acknowledge") return argv.length === 2 ? acknowledgeSavedWait(argv[1]) : emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "usage: bb wait acknowledge <local_wait_id>"));
|
|
762
1319
|
const opts = parseArgv(argv);
|
|
763
1320
|
const emitReceipt = (receipt, options = {}) => emit(receipt, {
|
|
764
1321
|
...options,
|
|
@@ -795,6 +1352,22 @@ export async function runWait(argv) {
|
|
|
795
1352
|
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.missing });
|
|
796
1353
|
process.exit(EXIT.INVALID);
|
|
797
1354
|
}
|
|
1355
|
+
if ((opts.resumeLocalWait != null || opts.deadlineAt != null) &&
|
|
1356
|
+
(recoveryLocalWaitId == null || opts.resumeLocalWait !== recoveryLocalWaitId)) {
|
|
1357
|
+
process.stderr.write("bb-wait: saved wait recovery options are internal; use bb wait resume <local_wait_id>\n");
|
|
1358
|
+
emitReceipt({
|
|
1359
|
+
schema_version: 1,
|
|
1360
|
+
outcome: "error",
|
|
1361
|
+
error: "wait_not_resumable",
|
|
1362
|
+
recovery_argv: ["wait", "resume", opts.resumeLocalWait ?? "<local_wait_id>"],
|
|
1363
|
+
});
|
|
1364
|
+
process.exit(EXIT.INVALID);
|
|
1365
|
+
}
|
|
1366
|
+
if (opts.deadlineAt != null && !opts.resumeLocalWait) {
|
|
1367
|
+
process.stderr.write("bb-wait: --deadline-at is reserved for saved wait recovery\n");
|
|
1368
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: "--deadline-at" });
|
|
1369
|
+
process.exit(EXIT.INVALID);
|
|
1370
|
+
}
|
|
798
1371
|
|
|
799
1372
|
const { conditions, errors } = parseConditions(opts.conditions);
|
|
800
1373
|
if (errors.length > 0) {
|
|
@@ -812,13 +1385,91 @@ export async function runWait(argv) {
|
|
|
812
1385
|
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_timeout" });
|
|
813
1386
|
process.exit(EXIT.INVALID);
|
|
814
1387
|
}
|
|
815
|
-
if (!Number.isSafeInteger(opts.receiptMaxBytes) || opts.receiptMaxBytes < MIN_RECEIPT_MAX_BYTES
|
|
816
|
-
|
|
817
|
-
|
|
1388
|
+
if (!Number.isSafeInteger(opts.receiptMaxBytes) || opts.receiptMaxBytes < MIN_RECEIPT_MAX_BYTES ||
|
|
1389
|
+
opts.receiptMaxBytes > MAX_RECEIPT_MAX_BYTES) {
|
|
1390
|
+
process.stderr.write(`bb-wait: --receipt-max-bytes must be an integer from ${MIN_RECEIPT_MAX_BYTES} through ${MAX_RECEIPT_MAX_BYTES}\n`);
|
|
1391
|
+
emitReceipt({
|
|
1392
|
+
schema_version: 1, outcome: "error", error: "invalid_receipt_max_bytes",
|
|
1393
|
+
minimum: MIN_RECEIPT_MAX_BYTES, maximum: MAX_RECEIPT_MAX_BYTES,
|
|
1394
|
+
});
|
|
818
1395
|
process.exit(EXIT.INVALID);
|
|
819
1396
|
}
|
|
820
1397
|
const timeoutSec = Math.min(opts.timeout, 28800); // 8h hard cap (session-budget gate)
|
|
821
|
-
const deadlineMs = Date.now() + timeoutSec * 1000;
|
|
1398
|
+
const deadlineMs = opts.deadlineAt == null ? Date.now() + timeoutSec * 1000 : Date.parse(opts.deadlineAt);
|
|
1399
|
+
if (!Number.isFinite(deadlineMs)) {
|
|
1400
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "wait_checkpoint_invalid" });
|
|
1401
|
+
process.exit(EXIT.INVALID);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// A valid wait gets a durable identity BEFORE its first remote side effect.
|
|
1405
|
+
// Credentials are intentionally absent: resume always obtains fresh session
|
|
1406
|
+
// credentials through register_agent/the normal environment bootstrap.
|
|
1407
|
+
const store = checkpointStore();
|
|
1408
|
+
let checkpoint;
|
|
1409
|
+
try {
|
|
1410
|
+
if (opts.resumeLocalWait) {
|
|
1411
|
+
checkpoint = await store.read(opts.resumeLocalWait);
|
|
1412
|
+
if (!(await sameWorktree(checkpoint.worktree, process.cwd()))) throw new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different worktree");
|
|
1413
|
+
if (!["preparing", "armed", "interrupted"].includes(checkpoint.status)) {
|
|
1414
|
+
throw new WaitCheckpointError("wait_not_resumable", `saved wait is ${checkpoint.status}`);
|
|
1415
|
+
}
|
|
1416
|
+
const createdMs = Date.parse(checkpoint.created_at);
|
|
1417
|
+
const savedDeadlineMs = Date.parse(checkpoint.deadline_at);
|
|
1418
|
+
if (opts.deadlineAt !== checkpoint.deadline_at || !Number.isFinite(createdMs) ||
|
|
1419
|
+
!Number.isFinite(savedDeadlineMs) || savedDeadlineMs > createdMs + 28_800_000) {
|
|
1420
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "saved wait deadline exceeds its original eight-hour cap");
|
|
1421
|
+
}
|
|
1422
|
+
const expectedSpecs = resumedSpecs(checkpoint);
|
|
1423
|
+
if (expectedSpecs.length !== opts.conditions.length ||
|
|
1424
|
+
expectedSpecs.some((spec, index) => spec !== opts.conditions[index])) {
|
|
1425
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "saved wait conditions differ from the recovery invocation");
|
|
1426
|
+
}
|
|
1427
|
+
restoreCapacityStaleDeadlines(conditions, checkpoint);
|
|
1428
|
+
} else {
|
|
1429
|
+
let relayUrl = null;
|
|
1430
|
+
try {
|
|
1431
|
+
const parsedUrl = new URL(opts.url);
|
|
1432
|
+
// A bearer URL is a secret. Retain only the endpoint origin/path used for
|
|
1433
|
+
// recovery; query strings, fragments, and userinfo never touch disk.
|
|
1434
|
+
parsedUrl.username = ""; parsedUrl.password = ""; parsedUrl.search = ""; parsedUrl.hash = "";
|
|
1435
|
+
relayUrl = parsedUrl.toString().replace(/\/$/, "");
|
|
1436
|
+
} catch { throw new WaitCheckpointError("wait_checkpoint_write_failed", "relay URL cannot be stored safely"); }
|
|
1437
|
+
const timerDeadlines = conditions.filter((condition) => condition.type === "timer").map((condition) =>
|
|
1438
|
+
condition.params.deadline ?? new Date(Date.now() + condition.params.duration * 1000).toISOString());
|
|
1439
|
+
const capacityStaleDeadlines = conditions.filter((condition) => condition.type === "capacity").map((condition) =>
|
|
1440
|
+
new Date(Date.now() + condition.params.staleGraceSec * 1000).toISOString());
|
|
1441
|
+
checkpoint = await store.create({
|
|
1442
|
+
local_wait_id: `wait-${crypto.randomUUID()}`,
|
|
1443
|
+
deadline_at: new Date(deadlineMs).toISOString(),
|
|
1444
|
+
conditions,
|
|
1445
|
+
condition_specs: opts.conditions,
|
|
1446
|
+
timer_deadlines: timerDeadlines,
|
|
1447
|
+
capacity_stale_deadlines: capacityStaleDeadlines,
|
|
1448
|
+
heartbeat: opts.heartbeat,
|
|
1449
|
+
receipt_max_bytes: opts.receiptMaxBytes,
|
|
1450
|
+
relay_url: relayUrl,
|
|
1451
|
+
worktree: process.cwd(),
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
// A live process retains this fence until exit. The file records a PID and
|
|
1455
|
+
// its start identity, so a reused PID cannot become evidence of ownership.
|
|
1456
|
+
// Recovery takes the fence before changing state or opening another SSE
|
|
1457
|
+
// subscriber for this logical wait.
|
|
1458
|
+
const fence = await store.acquireFence(checkpoint.local_wait_id);
|
|
1459
|
+
checkpoint = await store.update(checkpoint.local_wait_id, {
|
|
1460
|
+
owner_fence: fence.owner,
|
|
1461
|
+
...(opts.resumeLocalWait ? { resumed_at: new Date().toISOString() } : {}),
|
|
1462
|
+
});
|
|
1463
|
+
opts.localWaitId = checkpoint.local_wait_id;
|
|
1464
|
+
if (opts.resumeLocalWait) {
|
|
1465
|
+
opts.expectedAgentId = typeof checkpoint.agent_id === "string" ? checkpoint.agent_id : null;
|
|
1466
|
+
opts.expectedTenantId = typeof checkpoint.tenant_id === "string" ? checkpoint.tenant_id : null;
|
|
1467
|
+
}
|
|
1468
|
+
} catch (error) {
|
|
1469
|
+
return emitSavedWaitError(error instanceof WaitCheckpointError
|
|
1470
|
+
? error
|
|
1471
|
+
: new WaitCheckpointError("wait_checkpoint_write_failed", String(error?.message ?? error)));
|
|
1472
|
+
}
|
|
822
1473
|
|
|
823
1474
|
const needsRelay = conditions.some((c) => c.type !== "timer");
|
|
824
1475
|
// BOT-1572/1582/1608: a relay wait authenticates from the per-session
|
|
@@ -882,8 +1533,17 @@ export async function runWait(argv) {
|
|
|
882
1533
|
// BOT-1467: the session agent the relay attributed the wait to (when overridden).
|
|
883
1534
|
let registeredSessionAgentId = null;
|
|
884
1535
|
let registeredSessionId = null;
|
|
1536
|
+
let replayMatchedClaim = false;
|
|
1537
|
+
let replayExpiredRecovery = false;
|
|
885
1538
|
if (needsRelay) {
|
|
886
1539
|
try {
|
|
1540
|
+
// The reservation is made while this checkpoint is still live, on the
|
|
1541
|
+
// database clock. An expired recovery with no reservation fails closed and
|
|
1542
|
+
// cannot release a queue re-created by another logical wait.
|
|
1543
|
+
if ((Date.now() < deadlineMs || recoveryLocalWaitId != null) &&
|
|
1544
|
+
conditions.some((condition) => condition.type === "lock" && condition.params?.claim === true)) {
|
|
1545
|
+
await reserveClaimQueueBoundary(opts, conditions);
|
|
1546
|
+
}
|
|
887
1547
|
const reg = await registerWait(opts, conditions, new Date(deadlineMs).toISOString());
|
|
888
1548
|
waitSessionId = reg.waitSessionId;
|
|
889
1549
|
opts.waitSessionId = waitSessionId;
|
|
@@ -891,6 +1551,55 @@ export async function runWait(argv) {
|
|
|
891
1551
|
registeredAgentId = reg.agentId;
|
|
892
1552
|
registeredSessionAgentId = reg.sessionAgentId;
|
|
893
1553
|
registeredSessionId = reg.sessionId;
|
|
1554
|
+
// A freshly minted session token may represent the same immutable agent,
|
|
1555
|
+
// but never a different tenant/agent or worktree. Check before exposing a
|
|
1556
|
+
// result or opening the SSE stream.
|
|
1557
|
+
if ((checkpoint.agent_id && checkpoint.agent_id !== registeredAgentId) ||
|
|
1558
|
+
(checkpoint.tenant_id != null && checkpoint.tenant_id !== sessionTenant)) {
|
|
1559
|
+
throw new WaitCheckpointError("wait_resume_identity_mismatch", "fresh session does not own this saved wait");
|
|
1560
|
+
}
|
|
1561
|
+
// A lock grant is committed as a durable spine signal and terminalizes the
|
|
1562
|
+
// cloud wait without a receipt. Replaying from cursor_start/safe_cursor is
|
|
1563
|
+
// therefore the recovery path for a receipt-less matched claim.
|
|
1564
|
+
replayMatchedClaim = reg.status === "matched" && !reg.terminalReceipt && hasClaim;
|
|
1565
|
+
replayExpiredRecovery = Boolean(
|
|
1566
|
+
opts.resumeLocalWait && Date.now() >= deadlineMs && (
|
|
1567
|
+
(!hasClaim && reg.status === "active") ||
|
|
1568
|
+
(reg.status === "abandoned" && reg.recoveryError === "wait_superseded")
|
|
1569
|
+
),
|
|
1570
|
+
);
|
|
1571
|
+
if (["matched", "timeout", "error"].includes(reg.status) && !reg.terminalReceipt && !replayMatchedClaim) {
|
|
1572
|
+
throw new WaitCheckpointError("wait_resume_result_unavailable", "cloud wait is terminal but has no recoverable receipt");
|
|
1573
|
+
}
|
|
1574
|
+
const abandonedClaim = reg.status === "abandoned"
|
|
1575
|
+
? checkpoint.conditions.find((condition) => condition.type === "lock" && condition.params?.claim === true)
|
|
1576
|
+
: null;
|
|
1577
|
+
if (abandonedClaim && reg.recoveryError !== "wait_superseded") {
|
|
1578
|
+
const error = new WaitCheckpointError("wait_claim_requeue_required", "reacquire the claimed resource before resuming this abandoned wait");
|
|
1579
|
+
error.localWaitId = checkpoint.local_wait_id;
|
|
1580
|
+
error.recovery = `call acquire_lock for ${abandonedClaim.params.subtype}:${abandonedClaim.params.host}:${abandonedClaim.params.slot}, then: bb wait resume ${checkpoint.local_wait_id}`;
|
|
1581
|
+
throw error;
|
|
1582
|
+
}
|
|
1583
|
+
if (reg.terminalReceipt) {
|
|
1584
|
+
const recovered = preserveCheckpointCursor(publicWaitReceipt(reg.terminalReceipt, {
|
|
1585
|
+
sessionTenant, agentId: registeredAgentId, sessionAgentId: registeredSessionAgentId,
|
|
1586
|
+
sessionId: registeredSessionId, maxBytes: opts.receiptMaxBytes,
|
|
1587
|
+
}), checkpoint.safe_cursor, checkpoint.created_at);
|
|
1588
|
+
const recoveredCancellation = recovered.outcome === "cancelled";
|
|
1589
|
+
await store.update(checkpoint.local_wait_id, {
|
|
1590
|
+
status: recoveredCancellation ? "cancelled" : "terminal", terminal_receipt: recovered,
|
|
1591
|
+
exit_code: exitForRecoveredOutcome(recovered), cloud_wait_session_id: waitSessionId,
|
|
1592
|
+
cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null,
|
|
1593
|
+
...(recoveredCancellation ? {
|
|
1594
|
+
cancelled_at: new Date().toISOString(),
|
|
1595
|
+
cloud_cancel_pending: false,
|
|
1596
|
+
cloud_cancelled_at: new Date().toISOString(),
|
|
1597
|
+
cloud_cancel_error: null,
|
|
1598
|
+
} : {}),
|
|
1599
|
+
});
|
|
1600
|
+
emitReceipt(recovered, { versioned: true });
|
|
1601
|
+
process.exit(exitForRecoveredOutcome(recovered));
|
|
1602
|
+
}
|
|
894
1603
|
// BOT-1539: if any pr-review/pr-state target already has an outstanding
|
|
895
1604
|
// review, tell the operator now (stderr, one line per PR). Purely
|
|
896
1605
|
// informational — it does not change arming or exit semantics.
|
|
@@ -941,6 +1650,16 @@ export async function runWait(argv) {
|
|
|
941
1650
|
// Arm from the registration high-water mark unless the caller pinned an
|
|
942
1651
|
// explicit --since (resume). cursor_start replays the register→connect gap.
|
|
943
1652
|
if (opts.since == null && reg.cursorStart != null) effectiveSince = String(reg.cursorStart);
|
|
1653
|
+
checkpoint = await store.update(checkpoint.local_wait_id, {
|
|
1654
|
+
status: "armed",
|
|
1655
|
+
cloud_wait_session_id: waitSessionId,
|
|
1656
|
+
cursor_start: reg.cursorStart,
|
|
1657
|
+
safe_cursor: effectiveSince,
|
|
1658
|
+
tenant_id: sessionTenant ?? null,
|
|
1659
|
+
agent_id: registeredAgentId ?? null,
|
|
1660
|
+
arming_session_id: registeredSessionId ?? null,
|
|
1661
|
+
conditions,
|
|
1662
|
+
});
|
|
944
1663
|
if (hasClaim && waitSessionId == null) {
|
|
945
1664
|
process.stderr.write("bb-wait: claim=true registration returned no wait_session_id; refusing to arm an unmatchable claim wait\n");
|
|
946
1665
|
emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
@@ -1066,6 +1785,7 @@ export async function runWait(argv) {
|
|
|
1066
1785
|
emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.detail) });
|
|
1067
1786
|
process.exit(EXIT.INTERNAL);
|
|
1068
1787
|
}
|
|
1788
|
+
if (err instanceof WaitCheckpointError) return emitSavedWaitError(err);
|
|
1069
1789
|
if (hasClaim) {
|
|
1070
1790
|
process.stderr.write(`bb-wait: claim=true wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
|
|
1071
1791
|
emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
@@ -1084,48 +1804,114 @@ export async function runWait(argv) {
|
|
|
1084
1804
|
}
|
|
1085
1805
|
}
|
|
1086
1806
|
|
|
1807
|
+
if (!needsRelay) {
|
|
1808
|
+
try {
|
|
1809
|
+
checkpoint = await store.update(checkpoint.local_wait_id, { status: "armed", safe_cursor: effectiveSince, conditions });
|
|
1810
|
+
} catch (error) { return emitSavedWaitError(error); }
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1087
1813
|
const connect = needsRelay ? makeConnect(opts) : async () => offlineStream();
|
|
1088
1814
|
// BOT-1147 AC-3: wire the feed-freshness probe only when a linear condition is
|
|
1089
1815
|
// armed against the relay (runWaitLoop no-ops the probe for other wait types).
|
|
1090
1816
|
const feedLagProbe = needsRelay && conditions.some((c) => c.type === "linear")
|
|
1091
1817
|
? makeFeedLagProbe(opts)
|
|
1092
1818
|
: null;
|
|
1819
|
+
const buildPublicReceipt = (receipt) => publicWaitReceipt(receipt, {
|
|
1820
|
+
sessionTenant,
|
|
1821
|
+
agentId: registeredAgentId,
|
|
1822
|
+
sessionAgentId: registeredSessionAgentId,
|
|
1823
|
+
sessionId: registeredSessionId,
|
|
1824
|
+
maxBytes: opts.receiptMaxBytes,
|
|
1825
|
+
});
|
|
1826
|
+
let terminalPersistedFromFrame = false;
|
|
1093
1827
|
|
|
1094
1828
|
try {
|
|
1829
|
+
// A committed claim grant is authoritative even if recovery starts after the
|
|
1830
|
+
// original deadline. It must keep replaying until the durable grant arrives
|
|
1831
|
+
// (or the transport fails non-terminally); a fixed grace would persist a false
|
|
1832
|
+
// timeout while the resource remains held. Non-claim expired recovery retains
|
|
1833
|
+
// a bounded transport grace while it replays pre-deadline signals.
|
|
1834
|
+
const loopDeadlineMs = replayMatchedClaim ? null : replayExpiredRecovery ? Date.now() + 10_000 : deadlineMs;
|
|
1095
1835
|
const { receipt, exitCode } = await runWaitLoop({
|
|
1096
1836
|
waitSessionId,
|
|
1097
1837
|
conditions,
|
|
1098
|
-
deadlineMs,
|
|
1838
|
+
deadlineMs: loopDeadlineMs,
|
|
1839
|
+
replayOnlyThroughMs: replayExpiredRecovery ? deadlineMs : null,
|
|
1099
1840
|
since: effectiveSince,
|
|
1100
1841
|
connect,
|
|
1101
1842
|
feedLagProbe,
|
|
1102
1843
|
receiptMaxBytes: opts.receiptMaxBytes,
|
|
1844
|
+
startedAt: checkpoint.created_at,
|
|
1103
1845
|
// BOT-1259: fail-closed tenant scope for the central guard, plus a debug sink
|
|
1104
1846
|
// (opt-in via BB_WAIT_DEBUG) that surfaces each fail-closed drop without turning
|
|
1105
1847
|
// it into an error.
|
|
1106
1848
|
sessionTenant,
|
|
1849
|
+
onFrame: async (_signal, state) => {
|
|
1850
|
+
if (state?.terminalReceipt) {
|
|
1851
|
+
// The core has evaluated this frame as a match. Save its bounded result
|
|
1852
|
+
// before returning to the caller, so SIGKILL cannot lose a match between
|
|
1853
|
+
// decode and stdout.
|
|
1854
|
+
const terminalReceipt = buildPublicReceipt(state.terminalReceipt);
|
|
1855
|
+
checkpoint = await store.update(checkpoint.local_wait_id, {
|
|
1856
|
+
status: "terminal",
|
|
1857
|
+
terminal_receipt: terminalReceipt,
|
|
1858
|
+
exit_code: state.exitCode,
|
|
1859
|
+
safe_cursor: terminalReceipt.next_cursor ?? state.cursor ?? checkpoint.safe_cursor,
|
|
1860
|
+
cloud_finalize_pending: Boolean(waitSessionId && terminalReceipt.outcome !== "superseded"),
|
|
1861
|
+
});
|
|
1862
|
+
terminalPersistedFromFrame = true;
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
checkpoint = await store.update(checkpoint.local_wait_id, { safe_cursor: state?.cursor ?? checkpoint.safe_cursor });
|
|
1866
|
+
},
|
|
1107
1867
|
debug: process.env.BB_WAIT_DEBUG ? (msg) => process.stderr.write(`${msg}\n`) : null,
|
|
1108
1868
|
});
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
const terminalReceipt =
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1869
|
+
// Build the same bounded public receipt used by the frame-time durable write.
|
|
1870
|
+
// Cloud finalization is best-effort telemetry and must never gate stdout.
|
|
1871
|
+
const terminalReceipt = buildPublicReceipt(receipt);
|
|
1872
|
+
if (replayExpiredRecovery && receipt.error === "replay_incomplete") {
|
|
1873
|
+
// A replay grace only limits this process, never the durable logical wait.
|
|
1874
|
+
// Keep the checkpoint resumable and leave the active cloud row untouched.
|
|
1875
|
+
checkpoint = await store.update(checkpoint.local_wait_id, {
|
|
1876
|
+
status: "interrupted",
|
|
1877
|
+
safe_cursor: terminalReceipt.next_cursor ?? checkpoint.safe_cursor ?? null,
|
|
1878
|
+
});
|
|
1879
|
+
emitReceipt(terminalReceipt, { versioned: true });
|
|
1880
|
+
process.exit(exitCode);
|
|
1881
|
+
}
|
|
1882
|
+
// Receipt first, stdout second, cloud finalization last. A killed harness can
|
|
1883
|
+
// always re-read this exact terminal result without opening a new wait.
|
|
1884
|
+
if (!terminalPersistedFromFrame) {
|
|
1885
|
+
checkpoint = await store.update(checkpoint.local_wait_id, {
|
|
1886
|
+
status: "terminal",
|
|
1887
|
+
terminal_receipt: terminalReceipt,
|
|
1888
|
+
exit_code: exitCode,
|
|
1889
|
+
safe_cursor: terminalReceipt.next_cursor ?? checkpoint.safe_cursor ?? null,
|
|
1890
|
+
cloud_finalize_pending: Boolean(waitSessionId && terminalReceipt.outcome !== "superseded"),
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1120
1893
|
emitReceipt(terminalReceipt, { versioned: true });
|
|
1121
1894
|
// BOT-1228: a `superseded` outcome means an external actor already terminalized
|
|
1122
1895
|
// this wait_session server-side (reconcile_waits_to_canonical → abandoned, with
|
|
1123
1896
|
// ended_at/updated_at stamped). Skip the client finalize: the row is already
|
|
1124
1897
|
// terminal (finalize_wait_session only transitions from active, so it'd be a
|
|
1125
1898
|
// no-op) and `superseded` isn't a wait_session_status enum value anyway.
|
|
1126
|
-
if (waitSessionId && receipt.outcome !== "superseded")
|
|
1899
|
+
if (waitSessionId && receipt.outcome !== "superseded") {
|
|
1900
|
+
const finalized = await finalizeWait(opts, waitSessionId, terminalReceipt);
|
|
1901
|
+
try {
|
|
1902
|
+
checkpoint = await store.update(checkpoint.local_wait_id, finalized.delivered
|
|
1903
|
+
? { cloud_finalize_pending: false, cloud_finalized_at: new Date().toISOString(), cloud_finalize_error: null }
|
|
1904
|
+
: { cloud_finalize_pending: true, cloud_finalize_error: finalized.error });
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
// The terminal receipt was already durable and emitted. Leave its
|
|
1907
|
+
// pending marker in place for recovery; never emit a second receipt.
|
|
1908
|
+
process.stderr.write(`bb-wait: finalize checkpoint update (non-fatal) failed: ${error?.message ?? error}\n`);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1127
1911
|
process.exit(exitCode);
|
|
1128
1912
|
} catch (err) {
|
|
1913
|
+
try { if (checkpoint?.local_wait_id) await store.update(checkpoint.local_wait_id, { status: "interrupted" }); } catch { /* retain original diagnostic state */ }
|
|
1914
|
+
if (err instanceof WaitCheckpointError) return emitSavedWaitError(err);
|
|
1129
1915
|
process.stderr.write(`bb-wait: ${err && err.stack || err}\n`);
|
|
1130
1916
|
emitReceipt({ schema_version: 1, outcome: "error", error: "internal" });
|
|
1131
1917
|
process.exit(EXIT.INTERNAL);
|