@botbuddy/cli 1.61.4 → 1.61.6
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/daemon/bootstrap.mjs +47 -2
- package/src/daemon/commands.mjs +3 -0
- package/src/daemon/profile-delivery.mjs +29 -0
- package/src/wait-result.mjs +142 -0
- package/src/wait.mjs +71 -43
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.61.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.61.6",
|
|
3
|
+
"source_commit": "1e8d5f51d3f9be35c44e793551defaeaf8ae2d23",
|
|
4
|
+
"source_sha256": "0156e8ade4a62f2a6c4cb29d57fa0f5c23600ba264093198900c22e4c6fc0d68",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "8bc316711530d7900ff4186b4eb114cf48ce255344d5d3436962c8d6bee46810"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.61.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.61.6",
|
|
3
|
+
"source_commit": "1e8d5f51d3f9be35c44e793551defaeaf8ae2d23",
|
|
4
|
+
"source_sha256": "0156e8ade4a62f2a6c4cb29d57fa0f5c23600ba264093198900c22e4c6fc0d68",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "3bf5b7a6396da374f12abada7e42685ad8f783366c5c4de885da21d9749b257b"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.61.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.61.6",
|
|
4
|
+
"source_commit": "1e8d5f51d3f9be35c44e793551defaeaf8ae2d23",
|
|
5
|
+
"source_sha256": "0156e8ade4a62f2a6c4cb29d57fa0f5c23600ba264093198900c22e4c6fc0d68",
|
|
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": "8bc316711530d7900ff4186b4eb114cf48ce255344d5d3436962c8d6bee46810",
|
|
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": "3bf5b7a6396da374f12abada7e42685ad8f783366c5c4de885da21d9749b257b",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/daemon/bootstrap.mjs
CHANGED
|
@@ -4,15 +4,18 @@
|
|
|
4
4
|
// no local approval polling loop: host policy wakes only through the pushed
|
|
5
5
|
// BOT-2324 raw wait condition, then this flow re-registers server-side.
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
|
+
import { dirname } from "node:path";
|
|
7
8
|
|
|
8
9
|
import { DAEMON_PRODUCTION_MCP_SERVER, enrollDaemonDeviceProfileFromBootstrapIdentity } from "./enrollment.mjs";
|
|
9
10
|
import { installRemoteService, installedBinaryPath, stageRemoteDaemonRelease } from "./installer.mjs";
|
|
10
11
|
import {
|
|
11
12
|
createRemoteBootstrapIdentity,
|
|
12
13
|
deliverDaemonProfileFromBootstrapIdentityOverSSH,
|
|
14
|
+
installDaemonRuntimeConfigOverSSH,
|
|
13
15
|
inspectRemoteBootstrapIdentity,
|
|
14
16
|
verifyRemoteServiceIdentity,
|
|
15
17
|
} from "./profile-delivery.mjs";
|
|
18
|
+
import { buildRuntimeConfig } from "./runtime-config.mjs";
|
|
16
19
|
import { validateDaemonRelease } from "./release.mjs";
|
|
17
20
|
import { renderLinuxUnit, renderMacDaemon } from "./service.mjs";
|
|
18
21
|
|
|
@@ -45,7 +48,7 @@ function exactObject(value, keys) {
|
|
|
45
48
|
function checkedSpec(spec) {
|
|
46
49
|
const keys = [
|
|
47
50
|
"mode", "serverUrl", "sshHost", "serviceUser", "daemonBinary", "profilePath", "requestingHost",
|
|
48
|
-
"runtimeEngineId", "platform", "arch", "releaseRoot", "home", "config", "approvalTimeoutSeconds",
|
|
51
|
+
"runtimeEngineId", "platform", "arch", "releaseRoot", "home", "config", "hostPolicy", "approvalTimeoutSeconds",
|
|
49
52
|
];
|
|
50
53
|
const optional = ["caller", "callerHarness", "callerTicket"];
|
|
51
54
|
if (!spec || typeof spec !== "object" || Array.isArray(spec) ||
|
|
@@ -59,7 +62,11 @@ function checkedSpec(spec) {
|
|
|
59
62
|
typeof spec.requestingHost !== "string" || !spec.requestingHost || Buffer.byteLength(spec.requestingHost, "utf8") > 80 ||
|
|
60
63
|
typeof spec.runtimeEngineId !== "string" || !ENGINE_ID_RE.test(spec.runtimeEngineId) ||
|
|
61
64
|
!["darwin", "linux"].includes(spec.platform) || typeof spec.arch !== "string" || !spec.arch ||
|
|
62
|
-
!validPath(spec.releaseRoot) || !validPath(spec.home) || !validPath(spec.config) ||
|
|
65
|
+
!validPath(spec.releaseRoot) || !validPath(spec.home) || !validPath(spec.config) || !spec.config.endsWith("/daemon.json") ||
|
|
66
|
+
dirname(spec.config) !== dirname(spec.profilePath) ||
|
|
67
|
+
!spec.hostPolicy || typeof spec.hostPolicy !== "object" || Array.isArray(spec.hostPolicy) ||
|
|
68
|
+
Object.keys(spec.hostPolicy).length !== 4 ||
|
|
69
|
+
!["machineId", "dockerEndpoint", "supabaseBinary", "dockerCLI"].every((key) => typeof spec.hostPolicy[key] === "string" && spec.hostPolicy[key]) ||
|
|
63
70
|
!Number.isSafeInteger(spec.approvalTimeoutSeconds) || spec.approvalTimeoutSeconds < 1 || spec.approvalTimeoutSeconds > 3_600 ||
|
|
64
71
|
optional.some((key) => spec[key] !== undefined && (typeof spec[key] !== "string" || !spec[key]))
|
|
65
72
|
) fail("daemon_bootstrap_spec_invalid");
|
|
@@ -215,12 +222,14 @@ export async function bootstrapFreshDaemon(input, {
|
|
|
215
222
|
enroll = enrollDaemonDeviceProfileFromBootstrapIdentity,
|
|
216
223
|
deliver = deliverDaemonProfileFromBootstrapIdentityOverSSH,
|
|
217
224
|
installRemote = installRemoteService,
|
|
225
|
+
installRuntimeConfig = installDaemonRuntimeConfigOverSSH,
|
|
218
226
|
} = {}) {
|
|
219
227
|
const spec = checkedSpec(input);
|
|
220
228
|
if (typeof registerHost !== "function" || typeof verifyRemoteIdentity !== "function" ||
|
|
221
229
|
typeof createIdentity !== "function" || typeof inspectIdentity !== "function" ||
|
|
222
230
|
typeof validateRelease !== "function" || typeof stageRemote !== "function" || typeof enroll !== "function" ||
|
|
223
231
|
typeof deliver !== "function" || typeof installRemote !== "function" ||
|
|
232
|
+
typeof installRuntimeConfig !== "function" ||
|
|
224
233
|
(awaitApproval !== null && typeof awaitApproval !== "function") ||
|
|
225
234
|
(onApprovalRequired !== null && typeof onApprovalRequired !== "function") ||
|
|
226
235
|
(resumeStore !== null && (typeof resumeStore !== "object" || typeof resumeStore.save !== "function" ||
|
|
@@ -355,6 +364,42 @@ export async function bootstrapFreshDaemon(input, {
|
|
|
355
364
|
}
|
|
356
365
|
}
|
|
357
366
|
|
|
367
|
+
let runtimeConfig;
|
|
368
|
+
try {
|
|
369
|
+
runtimeConfig = buildRuntimeConfig({
|
|
370
|
+
host_key: publicIdentity.hostKey,
|
|
371
|
+
state_dir: dirname(spec.config),
|
|
372
|
+
docker_endpoint: spec.hostPolicy.dockerEndpoint,
|
|
373
|
+
expected_engine_id: spec.runtimeEngineId,
|
|
374
|
+
machine_id: spec.hostPolicy.machineId,
|
|
375
|
+
supabase_binary: spec.hostPolicy.supabaseBinary,
|
|
376
|
+
docker_cli: spec.hostPolicy.dockerCLI,
|
|
377
|
+
host_credential_profile: spec.profilePath,
|
|
378
|
+
device_consent: true,
|
|
379
|
+
runtime_version: `go-daemon/${release.releaseVersion}`,
|
|
380
|
+
runtime_git_sha: release.sourceCommit,
|
|
381
|
+
runtime_protocol_version: 2,
|
|
382
|
+
});
|
|
383
|
+
} catch {
|
|
384
|
+
fail("daemon_bootstrap_runtime_config_invalid");
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const installed = await installRuntimeConfig({
|
|
388
|
+
config: JSON.stringify(runtimeConfig),
|
|
389
|
+
configPath: spec.config,
|
|
390
|
+
sshHost: spec.sshHost,
|
|
391
|
+
serviceUser: spec.serviceUser,
|
|
392
|
+
daemonBinary: trustedDaemonBinary,
|
|
393
|
+
profilePath: spec.profilePath,
|
|
394
|
+
expectedHostKey: publicIdentity.hostKey,
|
|
395
|
+
directInstall: remote.directInstall,
|
|
396
|
+
});
|
|
397
|
+
if (!installed || installed.installed !== true) fail("daemon_bootstrap_runtime_config_failed");
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error instanceof DaemonBootstrapError) throw error;
|
|
400
|
+
fail("daemon_bootstrap_runtime_config_failed");
|
|
401
|
+
}
|
|
402
|
+
|
|
358
403
|
const serviceSpec = {
|
|
359
404
|
user: spec.serviceUser,
|
|
360
405
|
home: spec.home,
|
package/src/daemon/commands.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
createRemoteBootstrapIdentity,
|
|
30
30
|
deliverDaemonProfileFromBootstrapIdentityOverSSH,
|
|
31
31
|
deliverDaemonProfileFromHelperIdentityOverSSH,
|
|
32
|
+
installDaemonRuntimeConfigOverSSH,
|
|
32
33
|
inspectRemoteBootstrapIdentity,
|
|
33
34
|
verifyRemoteHelperIdentity as verifyRemoteHelperIdentityOnTarget,
|
|
34
35
|
verifyRemoteServiceIdentity,
|
|
@@ -285,6 +286,7 @@ export async function cmdDaemon(args, {
|
|
|
285
286
|
verifyRuntime = checkRuntimeConfig,
|
|
286
287
|
enroll = enrollDaemonDeviceProfileFromHelperIdentity,
|
|
287
288
|
deliver = deliverDaemonProfileFromHelperIdentityOverSSH,
|
|
289
|
+
installRuntimeConfig = installDaemonRuntimeConfigOverSSH,
|
|
288
290
|
verifyRemoteIdentity = verifyRemoteServiceIdentity,
|
|
289
291
|
verifyRemoteHelperIdentity = verifyRemoteHelperIdentityOnTarget,
|
|
290
292
|
bootstrap = bootstrapFreshDaemon,
|
|
@@ -332,6 +334,7 @@ export async function cmdDaemon(args, {
|
|
|
332
334
|
},
|
|
333
335
|
enroll: enrollBootstrap,
|
|
334
336
|
deliver: deliverBootstrap,
|
|
337
|
+
installRuntimeConfig,
|
|
335
338
|
installRemote,
|
|
336
339
|
onApprovalRequired: ({ approval_url: approvalUrl, host_key: hostKey }) => {
|
|
337
340
|
notice(`bb daemon bootstrap: owner approval required for ${hostKey}: ${approvalUrl}`);
|
|
@@ -334,3 +334,32 @@ export async function deliverDaemonProfileFromBootstrapIdentityOverSSH({
|
|
|
334
334
|
installFlag: "--install-profile-from-bootstrap-identity",
|
|
335
335
|
});
|
|
336
336
|
}
|
|
337
|
+
|
|
338
|
+
// Runtime policy is public host metadata, never a credential. It is still
|
|
339
|
+
// installed through the daemon's descriptor-pinned private root so a caller
|
|
340
|
+
// cannot redirect it with a symlink or broaden a prior host's policy.
|
|
341
|
+
export async function installDaemonRuntimeConfigOverSSH({
|
|
342
|
+
config,
|
|
343
|
+
configPath,
|
|
344
|
+
sshHost,
|
|
345
|
+
serviceUser,
|
|
346
|
+
daemonBinary,
|
|
347
|
+
profilePath,
|
|
348
|
+
expectedHostKey,
|
|
349
|
+
sshBin = "ssh",
|
|
350
|
+
spawnImpl = realSpawn,
|
|
351
|
+
env = process.env,
|
|
352
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
353
|
+
directInstall = false,
|
|
354
|
+
} = {}) {
|
|
355
|
+
safeInputs({ sshHost, serviceUser, daemonBinary, profilePath, expectedHostKey, sshBin, spawnImpl, env, timeoutMs, directInstall });
|
|
356
|
+
if (typeof config !== "string" || config.length === 0 || Buffer.byteLength(config, "utf8") > 64 * 1024 ||
|
|
357
|
+
typeof configPath !== "string" || !SAFE_ABSOLUTE_PATH.test(configPath) || !configPath.endsWith("/daemon.json")) throw invalid();
|
|
358
|
+
const command = remoteDaemonCommand({
|
|
359
|
+
serviceUser, daemonBinary, directInstall,
|
|
360
|
+
args: ["--install-runtime-config", "--config", configPath, "--profile", profilePath, "--expected-host-key", expectedHostKey],
|
|
361
|
+
});
|
|
362
|
+
const stdout = await fixedSsh({ sshHost, sshBin, spawnImpl, env, timeoutMs, command, stdin: Buffer.from(config, "utf8") });
|
|
363
|
+
if (!stdout.equals(Buffer.from('{"installed":true}\n'))) throw failed();
|
|
364
|
+
return { installed: true };
|
|
365
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// BOT-1688 — observation of an existing wait, independent of condition matching.
|
|
2
|
+
// No registration, subscription, finalization or acknowledgement belongs here.
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { open, realpath } from "node:fs/promises";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { harnessSessionIdFromEnv, readAgentState } from "./agent-state.mjs";
|
|
7
|
+
import { readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
8
|
+
import { WaitCheckpointError } from "./wait-checkpoint.mjs";
|
|
9
|
+
|
|
10
|
+
const MAX_ARTIFACT_BYTES = 256 * 1024;
|
|
11
|
+
const MAX_RECEIPT_BYTES = 64 * 1024;
|
|
12
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
const TERMINAL_OUTCOMES = new Set(["matched", "timeout", "error", "superseded", "lease_lost", "abandoned", "cancelled"]);
|
|
14
|
+
|
|
15
|
+
// Persisted != delivered. Await the stream callback before exiting, including
|
|
16
|
+
// when a bounded receipt is larger than the pipe's immediately writable space.
|
|
17
|
+
export function writeWaitReceipt(receipt, output = process.stdout) {
|
|
18
|
+
const line = `${JSON.stringify(receipt)}\n`;
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const failed = (error) => reject(error);
|
|
21
|
+
output.once("error", failed);
|
|
22
|
+
try {
|
|
23
|
+
output.write(line, (error) => {
|
|
24
|
+
if (error) reject(error);
|
|
25
|
+
else { output.off("error", failed); resolve(); }
|
|
26
|
+
});
|
|
27
|
+
} catch (error) { output.off("error", failed); reject(error); }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function waitObserverId(env = process.env) {
|
|
32
|
+
const id = harnessSessionIdFromEnv(env);
|
|
33
|
+
if (id) return id;
|
|
34
|
+
if (env.CLAUDE_CODE_CHILD_SESSION === "1") return null;
|
|
35
|
+
const thread = env.CODEX_THREAD_ID;
|
|
36
|
+
return typeof thread === "string" && UUID.test(thread) ? thread.toLowerCase() : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function waitCompletionState(checkpoint) {
|
|
40
|
+
if (checkpoint.status === "acknowledged") return "consumed";
|
|
41
|
+
if (checkpoint.status === "cancelled") return "cancelled";
|
|
42
|
+
if (checkpoint.status === "terminal") return checkpoint.terminal_receipt ? "receipt_available" : "observation_gap";
|
|
43
|
+
return ["preparing", "armed", "interrupted"].includes(checkpoint.status) ? "waiting" : "invalid";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function assertWaitObserver(checkpoint, { cwd = process.cwd(), env = process.env, requirePrincipal = false } = {}) {
|
|
47
|
+
let same = false;
|
|
48
|
+
try { same = await realpath(checkpoint.worktree) === await realpath(cwd); } catch { /* fail closed */ }
|
|
49
|
+
if (!same) throw new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different worktree");
|
|
50
|
+
const observer = waitObserverId(env);
|
|
51
|
+
if (checkpoint.observer_harness_id && checkpoint.observer_harness_id !== observer) {
|
|
52
|
+
throw new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different harness task");
|
|
53
|
+
}
|
|
54
|
+
// Local files are OS-user protected. For an authenticated result, also require
|
|
55
|
+
// the same cached agent/tenant; never mint an identity to read a local receipt.
|
|
56
|
+
if (requirePrincipal && (checkpoint.agent_id || checkpoint.tenant_id)) {
|
|
57
|
+
const state = await readAgentState(cwd);
|
|
58
|
+
const token = readAgentSessionTokenEnv(env);
|
|
59
|
+
if (!state || (token && token !== state.agent_session_token) ||
|
|
60
|
+
state.agent_id !== checkpoint.agent_id || state.tenant !== checkpoint.tenant_id ||
|
|
61
|
+
(observer && state.harness_session_id && observer !== state.harness_session_id)) {
|
|
62
|
+
throw new WaitCheckpointError("wait_resume_identity_mismatch", "result requires this worktree's original agent and tenant; use named resume with fresh credentials for recovery");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function receiptFromArtifact(path) {
|
|
68
|
+
let handle;
|
|
69
|
+
try {
|
|
70
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
71
|
+
const stat = await handle.stat();
|
|
72
|
+
if (!stat.isFile() || stat.size > MAX_ARTIFACT_BYTES) throw new Error("unbounded artifact");
|
|
73
|
+
const buffer = Buffer.alloc(MAX_ARTIFACT_BYTES + 1);
|
|
74
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
75
|
+
if (bytesRead > MAX_ARTIFACT_BYTES) throw new Error("unbounded artifact");
|
|
76
|
+
const receipts = [];
|
|
77
|
+
for (const line of buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/)) {
|
|
78
|
+
if (!line.trimStart().startsWith("{")) continue;
|
|
79
|
+
let value;
|
|
80
|
+
try { value = JSON.parse(line); } catch { continue; }
|
|
81
|
+
if (value?.schema_version === 1 && typeof value.outcome === "string") receipts.push(value);
|
|
82
|
+
}
|
|
83
|
+
if (receipts.length !== 1) throw new Error("expected exactly one receipt");
|
|
84
|
+
return receipts[0];
|
|
85
|
+
} catch {
|
|
86
|
+
// Never echo arbitrary artifact contents (which may include credentials).
|
|
87
|
+
throw new WaitCheckpointError("wait_observation_gap", "completion artifact must contain exactly one bounded terminal receipt");
|
|
88
|
+
} finally { await handle?.close(); }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function readWaitResult(store, id, { cwd, env, fromFile = null, exitCode = null } = {}) {
|
|
92
|
+
// An explicitly named artifact identifies a cloud wait, not "the newest"
|
|
93
|
+
// checkpoint. Read it once, only after the caller awaited the original exit.
|
|
94
|
+
const observed = fromFile == null ? null : await receiptFromArtifact(fromFile);
|
|
95
|
+
if (id == null) {
|
|
96
|
+
const cloudId = observed?.wait_session_id;
|
|
97
|
+
if (typeof cloudId !== "string" || !cloudId) {
|
|
98
|
+
throw new WaitCheckpointError("wait_observation_gap", "artifact has no cloud wait identity; select its exact local_wait_id explicitly");
|
|
99
|
+
}
|
|
100
|
+
const matches = [];
|
|
101
|
+
let offset = 0;
|
|
102
|
+
do {
|
|
103
|
+
const page = await store.list({ includeInactive: true, offset });
|
|
104
|
+
for (const item of page.items) if (item.cloud_wait_session_id === cloudId) matches.push(item.local_wait_id);
|
|
105
|
+
offset = page.next_offset;
|
|
106
|
+
} while (offset != null);
|
|
107
|
+
if (matches.length !== 1) {
|
|
108
|
+
throw new WaitCheckpointError("wait_observation_gap", "artifact must identify exactly one checkpoint in this worktree");
|
|
109
|
+
}
|
|
110
|
+
[id] = matches;
|
|
111
|
+
}
|
|
112
|
+
let checkpoint = await store.read(id);
|
|
113
|
+
await assertWaitObserver(checkpoint, { cwd, env, requirePrincipal: true });
|
|
114
|
+
const state = waitCompletionState(checkpoint);
|
|
115
|
+
if (state !== "receipt_available") {
|
|
116
|
+
throw new WaitCheckpointError(state === "consumed" ? "wait_result_consumed" : "wait_result_unavailable", `saved wait ${id}: ${state}; no new wait was started`);
|
|
117
|
+
}
|
|
118
|
+
// Frame-time persistence may precede the original owner's stdout and exit.
|
|
119
|
+
// Its process-instance fence, not a file or PID sighting, decides ownership.
|
|
120
|
+
const fence = await store.acquireFence(id);
|
|
121
|
+
try {
|
|
122
|
+
checkpoint = await store.read(id);
|
|
123
|
+
await assertWaitObserver(checkpoint, { cwd, env, requirePrincipal: true });
|
|
124
|
+
if (waitCompletionState(checkpoint) !== "receipt_available") {
|
|
125
|
+
throw new WaitCheckpointError("wait_result_unavailable", "saved wait changed before observation");
|
|
126
|
+
}
|
|
127
|
+
const receipt = checkpoint.terminal_receipt;
|
|
128
|
+
if (!receipt || Array.isArray(receipt) || receipt.schema_version !== 1 || !TERMINAL_OUTCOMES.has(receipt.outcome) ||
|
|
129
|
+
Buffer.byteLength(JSON.stringify(receipt)) > MAX_RECEIPT_BYTES ||
|
|
130
|
+
!Number.isInteger(checkpoint.exit_code) || checkpoint.exit_code < 0 || checkpoint.exit_code > 255 ||
|
|
131
|
+
(receipt.wait_session_id !== undefined && receipt.wait_session_id !== (checkpoint.cloud_wait_session_id ?? null)) ||
|
|
132
|
+
(receipt.principal && checkpoint.agent_id && receipt.principal.agent_id !== checkpoint.agent_id) ||
|
|
133
|
+
(receipt.principal && checkpoint.tenant_id && receipt.principal.tenant_id !== checkpoint.tenant_id)) {
|
|
134
|
+
throw new WaitCheckpointError("wait_observation_gap", "saved terminal receipt does not match its wait identity");
|
|
135
|
+
}
|
|
136
|
+
if (fromFile != null && (!Number.isInteger(exitCode) || exitCode !== checkpoint.exit_code ||
|
|
137
|
+
!isDeepStrictEqual(observed, receipt))) {
|
|
138
|
+
throw new WaitCheckpointError("wait_observation_gap", "original command exit or redirected receipt differs from the selected durable result");
|
|
139
|
+
}
|
|
140
|
+
return { receipt, exitCode: checkpoint.exit_code };
|
|
141
|
+
} finally { await fence.release(); }
|
|
142
|
+
}
|
package/src/wait.mjs
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// Park on a set of conditions (a lock coming free, a chat reply, a build
|
|
5
5
|
// finishing, a plain deadline) and wake EXACTLY ONCE with one machine-readable
|
|
6
6
|
// JSON receipt on stdout. Zero polling, zero tokens spent while waiting: the
|
|
7
|
-
// process blocks on a pushed SSE stream
|
|
8
|
-
//
|
|
7
|
+
// process blocks on a pushed SSE stream. The owning harness retains its pending
|
|
8
|
+
// result; process exit is not proof that the agent consumed the receipt.
|
|
9
9
|
//
|
|
10
10
|
// Dependency-free (Node >= 20 stdlib only). All wait semantics live in
|
|
11
11
|
// ./wait-core.mjs; this file is just argv + a real `connect()` + I/O.
|
|
@@ -26,6 +26,7 @@ import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
|
26
26
|
import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
|
|
27
27
|
import { realpath } from "node:fs/promises";
|
|
28
28
|
import { deepLinkNotice, safeWebUrl } from "./deep-link.mjs";
|
|
29
|
+
import { assertWaitObserver, readWaitResult, waitCompletionState, waitObserverId, writeWaitReceipt } from "./wait-result.mjs";
|
|
29
30
|
|
|
30
31
|
// A protocol is deliberately distinct from package semver: compatible pinned
|
|
31
32
|
// clients keep working until the server raises this minimum, while a stale
|
|
@@ -181,6 +182,9 @@ OPTIONS
|
|
|
181
182
|
--session-id <uuid> advanced attribution override; normal worktrees derive it automatically.
|
|
182
183
|
--help show this help
|
|
183
184
|
resume <local_wait_id> recover one explicitly named saved wait
|
|
185
|
+
result [<local_wait_id>] read its terminal receipt without rearming or acknowledging
|
|
186
|
+
[--from-file <path> --exit-code <n>]
|
|
187
|
+
validate redirected output AFTER the original command exits
|
|
184
188
|
cancel <local_wait_id> explicitly stop one saved wait
|
|
185
189
|
acknowledge <local_wait_id> mark one saved terminal receipt read
|
|
186
190
|
|
|
@@ -933,7 +937,7 @@ function emit(receipt, { versioned = false, maxBytes = null } = {}) {
|
|
|
933
937
|
const bounded = Number.isSafeInteger(maxBytes) && maxBytes >= MIN_RECEIPT_MAX_BYTES
|
|
934
938
|
? truncateReceipt(enriched, maxBytes)
|
|
935
939
|
: enriched;
|
|
936
|
-
|
|
940
|
+
return writeWaitReceipt(bounded);
|
|
937
941
|
}
|
|
938
942
|
|
|
939
943
|
// BOT-1590: canonical one-line recoveries for every setup/auth/parameter error.
|
|
@@ -1081,12 +1085,15 @@ function inventoryEntry(checkpoint) {
|
|
|
1081
1085
|
const pendingAcknowledgement = checkpoint.status === "acknowledged" && checkpoint.cloud_ack_pending === true;
|
|
1082
1086
|
return {
|
|
1083
1087
|
local_wait_id: id,
|
|
1088
|
+
wait_session_id: checkpoint.cloud_wait_session_id ?? null,
|
|
1084
1089
|
status: checkpoint.status,
|
|
1090
|
+
completion_state: waitCompletionState(checkpoint),
|
|
1085
1091
|
conditions: checkpoint.conditions?.map((c) => ({ condition_id: c.id, type: c.type })) ?? [],
|
|
1086
1092
|
ticket: checkpoint.ticket ?? null,
|
|
1087
1093
|
created_at: checkpoint.created_at ?? null,
|
|
1088
1094
|
deadline_at: checkpoint.deadline_at ?? null,
|
|
1089
1095
|
receipt_available: terminal && checkpoint.terminal_receipt != null,
|
|
1096
|
+
result_argv: terminal ? ["wait", "result", id] : null,
|
|
1090
1097
|
recovery_argv: pendingCancellation
|
|
1091
1098
|
? ["wait", "cancel", id]
|
|
1092
1099
|
: pendingAcknowledgement ? ["wait", "acknowledge", id] : ["wait", "resume", id],
|
|
@@ -1105,13 +1112,13 @@ async function inspectSavedWaits(argv = []) {
|
|
|
1105
1112
|
// Never let a non-interactive agent accidentally acquire an owner or consume
|
|
1106
1113
|
// a result. The inventory deliberately omits receipt bodies and shell text.
|
|
1107
1114
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1108
|
-
|
|
1115
|
+
await writeWaitReceipt({
|
|
1109
1116
|
schema_version: 1,
|
|
1110
1117
|
outcome: "saved_waits",
|
|
1111
1118
|
waits: inventory.items.map(inventoryEntry),
|
|
1112
1119
|
has_more: inventory.has_more,
|
|
1113
1120
|
...(inventory.next_offset != null ? { next_offset: inventory.next_offset, next_page_argv: ["wait", "--offset", String(inventory.next_offset)] } : {}),
|
|
1114
|
-
})
|
|
1121
|
+
});
|
|
1115
1122
|
process.exit(EXIT.MATCHED);
|
|
1116
1123
|
}
|
|
1117
1124
|
if (inventory.items.length === 0) {
|
|
@@ -1336,6 +1343,8 @@ async function resumeSavedWait(id) {
|
|
|
1336
1343
|
let checkpoint;
|
|
1337
1344
|
try { checkpoint = await store.read(id); }
|
|
1338
1345
|
catch (error) { return emitSavedWaitError(error); }
|
|
1346
|
+
try { await assertWaitObserver(checkpoint); }
|
|
1347
|
+
catch (error) { return emitSavedWaitError(error); }
|
|
1339
1348
|
if (!(await sameWorktree(checkpoint.worktree, process.cwd()))) {
|
|
1340
1349
|
return emitSavedWaitError(new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different worktree"));
|
|
1341
1350
|
}
|
|
@@ -1355,7 +1364,7 @@ async function resumeSavedWait(id) {
|
|
|
1355
1364
|
await fence?.release();
|
|
1356
1365
|
}
|
|
1357
1366
|
writeLeaseLostNotice(checkpoint.terminal_receipt);
|
|
1358
|
-
|
|
1367
|
+
await writeWaitReceipt(checkpoint.terminal_receipt);
|
|
1359
1368
|
process.exit(Number.isInteger(checkpoint.exit_code) ? checkpoint.exit_code : EXIT.INTERNAL);
|
|
1360
1369
|
}
|
|
1361
1370
|
if (["cancelled", "acknowledged"].includes(checkpoint.status)) {
|
|
@@ -1382,18 +1391,18 @@ async function sameWorktree(first, second) {
|
|
|
1382
1391
|
catch { return false; }
|
|
1383
1392
|
}
|
|
1384
1393
|
|
|
1385
|
-
function emitSavedWaitError(error) {
|
|
1394
|
+
async function emitSavedWaitError(error) {
|
|
1386
1395
|
const code = error?.code ?? "wait_checkpoint_invalid";
|
|
1387
1396
|
const exitCode = code === "wait_checkpoint_write_failed" ? EXIT.INTERNAL : EXIT.INVALID;
|
|
1388
1397
|
process.stderr.write(`bb-wait: ${error?.message ?? code}\n`);
|
|
1389
|
-
|
|
1398
|
+
await writeWaitReceipt({
|
|
1390
1399
|
schema_version: 1,
|
|
1391
1400
|
outcome: "error",
|
|
1392
1401
|
error: code,
|
|
1393
1402
|
...(error?.localWaitId ? { local_wait_id: error.localWaitId } : {}),
|
|
1394
1403
|
...(Array.isArray(error?.recoveryArgv) ? { recovery_argv: error.recoveryArgv } : {}),
|
|
1395
1404
|
...(typeof error?.recovery === "string" ? { recovery: error.recovery } : {}),
|
|
1396
|
-
})
|
|
1405
|
+
});
|
|
1397
1406
|
process.exit(exitCode);
|
|
1398
1407
|
}
|
|
1399
1408
|
|
|
@@ -1413,6 +1422,7 @@ async function cancelSavedWait(id) {
|
|
|
1413
1422
|
try {
|
|
1414
1423
|
const store = checkpointStore();
|
|
1415
1424
|
let current = await store.read(id);
|
|
1425
|
+
await assertWaitObserver(current);
|
|
1416
1426
|
if (current.status === "terminal" || current.status === "acknowledged") {
|
|
1417
1427
|
const conflict = new WaitCheckpointError(
|
|
1418
1428
|
"wait_not_cancellable",
|
|
@@ -1518,10 +1528,10 @@ async function cancelSavedWait(id) {
|
|
|
1518
1528
|
} finally {
|
|
1519
1529
|
await fence.release();
|
|
1520
1530
|
}
|
|
1521
|
-
|
|
1531
|
+
await writeWaitReceipt({
|
|
1522
1532
|
schema_version: 1, outcome: "cancelled", local_wait_id: id,
|
|
1523
1533
|
...(current.cloud_cancel_pending ? { cloud_cancel_pending: true } : {}),
|
|
1524
|
-
})
|
|
1534
|
+
});
|
|
1525
1535
|
process.exit(EXIT.MATCHED);
|
|
1526
1536
|
} catch (error) { return emitSavedWaitError(error); }
|
|
1527
1537
|
}
|
|
@@ -1535,6 +1545,7 @@ async function acknowledgeSavedWait(id) {
|
|
|
1535
1545
|
// until the same process-instance fence used by resume proves ownership.
|
|
1536
1546
|
fence = await store.acquireFence(id);
|
|
1537
1547
|
let checkpoint = await store.read(id);
|
|
1548
|
+
await assertWaitObserver(checkpoint, { requirePrincipal: Boolean(checkpoint.observer_harness_id) });
|
|
1538
1549
|
if (checkpoint.status === "terminal") checkpoint = await flushPendingFinalization(store, checkpoint);
|
|
1539
1550
|
checkpoint = await store.acknowledge(id, {
|
|
1540
1551
|
cloudAckPending: Boolean(checkpoint.cloud_wait_session_id),
|
|
@@ -1542,10 +1553,10 @@ async function acknowledgeSavedWait(id) {
|
|
|
1542
1553
|
if (checkpoint.cloud_wait_session_id) {
|
|
1543
1554
|
checkpoint = await flushPendingAcknowledgement(store, checkpoint);
|
|
1544
1555
|
}
|
|
1545
|
-
|
|
1556
|
+
await writeWaitReceipt({
|
|
1546
1557
|
schema_version: 1, outcome: "acknowledged", local_wait_id: id,
|
|
1547
1558
|
...(checkpoint.cloud_ack_pending ? { cloud_ack_pending: true } : {}),
|
|
1548
|
-
})
|
|
1559
|
+
});
|
|
1549
1560
|
await fence.release();
|
|
1550
1561
|
fence = null;
|
|
1551
1562
|
process.exit(EXIT.MATCHED);
|
|
@@ -1557,6 +1568,22 @@ async function acknowledgeSavedWait(id) {
|
|
|
1557
1568
|
|
|
1558
1569
|
export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
1559
1570
|
if (argv.length === 0 || argv[0] === "--offset") return inspectSavedWaits(argv);
|
|
1571
|
+
if (argv[0] === "result") {
|
|
1572
|
+
try {
|
|
1573
|
+
const args = argv.slice(1);
|
|
1574
|
+
const id = args[0] && !args[0].startsWith("--") ? args.shift() : null;
|
|
1575
|
+
const rest = args;
|
|
1576
|
+
if ((!id && !rest.length) || (rest.length !== 0 && (rest.length !== 4 || rest[0] !== "--from-file" ||
|
|
1577
|
+
!rest[1] || rest[2] !== "--exit-code" || !/^\d{1,3}$/.test(rest[3]) || Number(rest[3]) > 255))) {
|
|
1578
|
+
throw new WaitCheckpointError("wait_observation_gap", "usage: bb wait result [<local_wait_id>] [--from-file <path> --exit-code <n>]; await the original command first");
|
|
1579
|
+
}
|
|
1580
|
+
const result = await readWaitResult(checkpointStore(), id, {
|
|
1581
|
+
fromFile: rest[1] ?? null, exitCode: rest.length ? Number(rest[3]) : null,
|
|
1582
|
+
});
|
|
1583
|
+
await writeWaitReceipt(result.receipt);
|
|
1584
|
+
process.exit(result.exitCode);
|
|
1585
|
+
} catch (error) { return emitSavedWaitError(error); }
|
|
1586
|
+
}
|
|
1560
1587
|
// BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy alias (flag, env, or a
|
|
1561
1588
|
// bb_agent_ token value), emitted BEFORE any subcommand dispatch so the
|
|
1562
1589
|
// credentialed recovery actions (resume/cancel/acknowledge) warn too (Codex R7).
|
|
@@ -1592,7 +1619,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1592
1619
|
`botbuddy wait: ${opts.retiredFlag} is retired — run bb wait from a configured worktree and it restores its session automatically. `
|
|
1593
1620
|
+ "If setup is needed, run bb setup, then retry the same wait.\n",
|
|
1594
1621
|
);
|
|
1595
|
-
emitReceipt({
|
|
1622
|
+
await emitReceipt({
|
|
1596
1623
|
schema_version: 1,
|
|
1597
1624
|
outcome: "error",
|
|
1598
1625
|
error: "unknown_option",
|
|
@@ -1603,18 +1630,18 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1603
1630
|
}
|
|
1604
1631
|
if (opts.unknown) {
|
|
1605
1632
|
process.stderr.write(`bb-wait: unknown option ${opts.unknown}\n`);
|
|
1606
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.unknown });
|
|
1633
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.unknown });
|
|
1607
1634
|
process.exit(EXIT.INVALID);
|
|
1608
1635
|
}
|
|
1609
1636
|
if (opts.missing) {
|
|
1610
1637
|
process.stderr.write(`bb-wait: option ${opts.missing} requires a value\n`);
|
|
1611
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.missing });
|
|
1638
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.missing });
|
|
1612
1639
|
process.exit(EXIT.INVALID);
|
|
1613
1640
|
}
|
|
1614
1641
|
if ((opts.resumeLocalWait != null || opts.deadlineAt != null) &&
|
|
1615
1642
|
(recoveryLocalWaitId == null || opts.resumeLocalWait !== recoveryLocalWaitId)) {
|
|
1616
1643
|
process.stderr.write("bb-wait: saved wait recovery options are internal; use bb wait resume <local_wait_id>\n");
|
|
1617
|
-
emitReceipt({
|
|
1644
|
+
await emitReceipt({
|
|
1618
1645
|
schema_version: 1,
|
|
1619
1646
|
outcome: "error",
|
|
1620
1647
|
error: "wait_not_resumable",
|
|
@@ -1624,7 +1651,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1624
1651
|
}
|
|
1625
1652
|
if (opts.deadlineAt != null && !opts.resumeLocalWait) {
|
|
1626
1653
|
process.stderr.write("bb-wait: --deadline-at is reserved for saved wait recovery\n");
|
|
1627
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: "--deadline-at" });
|
|
1654
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: "--deadline-at" });
|
|
1628
1655
|
process.exit(EXIT.INVALID);
|
|
1629
1656
|
}
|
|
1630
1657
|
|
|
@@ -1635,7 +1662,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1635
1662
|
const since = normalizeSince(opts.since);
|
|
1636
1663
|
if (since.kind !== "ok") {
|
|
1637
1664
|
process.stderr.write("bb-wait: --since must be a non-negative signal sequence <= 9007199254740991\n");
|
|
1638
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_since", cursor: opts.since });
|
|
1665
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_since", cursor: opts.since });
|
|
1639
1666
|
process.exit(EXIT.INVALID);
|
|
1640
1667
|
}
|
|
1641
1668
|
opts.since = since.value;
|
|
@@ -1645,7 +1672,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1645
1672
|
if (errors.length > 0) {
|
|
1646
1673
|
for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
|
|
1647
1674
|
process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
|
|
1648
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
|
|
1675
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
|
|
1649
1676
|
// BOT-1593: condition_types is [] on a grammar failure (the parsed set is
|
|
1650
1677
|
// partial/empty and is caller data regardless). BOT-2467: instead, each failed
|
|
1651
1678
|
// spec is described by its attempted TYPE and a parse_error category — never
|
|
@@ -1656,13 +1683,13 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1656
1683
|
|
|
1657
1684
|
if (!Number.isFinite(opts.timeout) || opts.timeout <= 0) {
|
|
1658
1685
|
process.stderr.write("bb-wait: --timeout must be a positive number of seconds\n");
|
|
1659
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_timeout" });
|
|
1686
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_timeout" });
|
|
1660
1687
|
process.exit(EXIT.INVALID);
|
|
1661
1688
|
}
|
|
1662
1689
|
if (!Number.isSafeInteger(opts.receiptMaxBytes) || opts.receiptMaxBytes < MIN_RECEIPT_MAX_BYTES ||
|
|
1663
1690
|
opts.receiptMaxBytes > MAX_RECEIPT_MAX_BYTES) {
|
|
1664
1691
|
process.stderr.write(`bb-wait: --receipt-max-bytes must be an integer from ${MIN_RECEIPT_MAX_BYTES} through ${MAX_RECEIPT_MAX_BYTES}\n`);
|
|
1665
|
-
emitReceipt({
|
|
1692
|
+
await emitReceipt({
|
|
1666
1693
|
schema_version: 1, outcome: "error", error: "invalid_receipt_max_bytes",
|
|
1667
1694
|
minimum: MIN_RECEIPT_MAX_BYTES, maximum: MAX_RECEIPT_MAX_BYTES,
|
|
1668
1695
|
});
|
|
@@ -1674,7 +1701,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1674
1701
|
});
|
|
1675
1702
|
if (shortRefusal) {
|
|
1676
1703
|
process.stderr.write(`bb-wait: --timeout ${opts.timeout} is under the ${MIN_WAIT_TIMEOUT_SEC} s floor — ${shortRefusal.recovery}\n`);
|
|
1677
|
-
emitReceipt({ schema_version: 1, outcome: "error", ...shortRefusal });
|
|
1704
|
+
await emitReceipt({ schema_version: 1, outcome: "error", ...shortRefusal });
|
|
1678
1705
|
process.exit(EXIT.INVALID);
|
|
1679
1706
|
}
|
|
1680
1707
|
if (opts.timeout < MIN_WAIT_TIMEOUT_SEC && !conditions.every((condition) => condition.type === "timer") && !opts.resumeLocalWait) {
|
|
@@ -1683,7 +1710,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1683
1710
|
const timeoutSec = Math.min(opts.timeout, MAX_WAIT_TIMEOUT_SEC); // BOT-2400: 72 h hard cap
|
|
1684
1711
|
const deadlineMs = opts.deadlineAt == null ? Date.now() + timeoutSec * 1000 : Date.parse(opts.deadlineAt);
|
|
1685
1712
|
if (!Number.isFinite(deadlineMs)) {
|
|
1686
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "wait_checkpoint_invalid" });
|
|
1713
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "wait_checkpoint_invalid" });
|
|
1687
1714
|
process.exit(EXIT.INVALID);
|
|
1688
1715
|
}
|
|
1689
1716
|
|
|
@@ -1735,6 +1762,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1735
1762
|
receipt_max_bytes: opts.receiptMaxBytes,
|
|
1736
1763
|
relay_url: relayUrl,
|
|
1737
1764
|
worktree: process.cwd(),
|
|
1765
|
+
observer_harness_id: waitObserverId(),
|
|
1738
1766
|
});
|
|
1739
1767
|
}
|
|
1740
1768
|
// A live process retains this fence until exit. The file records a PID and
|
|
@@ -1780,7 +1808,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1780
1808
|
? "run from a configured project worktree, then run `bb setup` and retry"
|
|
1781
1809
|
: "run `bb setup` and retry";
|
|
1782
1810
|
process.stderr.write(`botbuddy wait: ${error?.message ?? error}; ${recovery}\n`);
|
|
1783
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
|
|
1811
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
|
|
1784
1812
|
await reportSetupError(opts, { error: code, exitCode: EXIT.AUTH, conditions });
|
|
1785
1813
|
process.exit(EXIT.AUTH);
|
|
1786
1814
|
}
|
|
@@ -1794,13 +1822,13 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1794
1822
|
if (needsRelay && opts.sessionToken) {
|
|
1795
1823
|
if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
|
|
1796
1824
|
process.stderr.write(`botbuddy wait: BOTBUDDY_AGENT_SESSION_TOKEN must match bb_sess_<64 hex> (bb_agent_ is a legacy prefix); ${RECOVERY.sessionToken}\n`);
|
|
1797
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
|
|
1825
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
|
|
1798
1826
|
await reportSetupError(opts, { error: "invalid_session_token", exitCode: EXIT.INVALID, conditions });
|
|
1799
1827
|
process.exit(EXIT.INVALID);
|
|
1800
1828
|
}
|
|
1801
1829
|
if (opts.token && opts.token !== opts.sessionToken) {
|
|
1802
1830
|
process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_AGENT_SESSION_TOKEN; ${RECOVERY.sessionTokenConflict}\n`);
|
|
1803
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
|
|
1831
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
|
|
1804
1832
|
await reportSetupError(opts, { error: "session_token_conflict", exitCode: EXIT.INVALID, conditions });
|
|
1805
1833
|
process.exit(EXIT.INVALID);
|
|
1806
1834
|
}
|
|
@@ -1813,7 +1841,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1813
1841
|
process.stderr.write(
|
|
1814
1842
|
`botbuddy wait: a relay wait requires the per-session agent token — ${RECOVERY.sessionToken}, then: botbuddy wait '<condition>'\n`,
|
|
1815
1843
|
);
|
|
1816
|
-
emitReceipt({
|
|
1844
|
+
await emitReceipt({
|
|
1817
1845
|
schema_version: 1,
|
|
1818
1846
|
outcome: "error",
|
|
1819
1847
|
error: "session_token_required",
|
|
@@ -1950,7 +1978,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1950
1978
|
cloud_cancel_error: null,
|
|
1951
1979
|
} : {}),
|
|
1952
1980
|
});
|
|
1953
|
-
emitReceipt(recovered, { versioned: true });
|
|
1981
|
+
await emitReceipt(recovered, { versioned: true });
|
|
1954
1982
|
process.exit(exitForRecoveredOutcome(recovered));
|
|
1955
1983
|
}
|
|
1956
1984
|
// BOT-2468: the wait's dashboard deeplink, once, at arm time (and again on
|
|
@@ -2038,13 +2066,13 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2038
2066
|
});
|
|
2039
2067
|
if (hasClaim && waitSessionId == null) {
|
|
2040
2068
|
process.stderr.write("bb-wait: claim=true registration returned no wait_session_id; refusing to arm an unmatchable claim wait\n");
|
|
2041
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
2069
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
2042
2070
|
process.exit(EXIT.INTERNAL);
|
|
2043
2071
|
}
|
|
2044
2072
|
} catch (err) {
|
|
2045
2073
|
if (err && err.clientOutdated) {
|
|
2046
2074
|
process.stderr.write(`botbuddy wait: client protocol is no longer supported — upgrade with: ${err.upgradeCommand}\n`);
|
|
2047
|
-
emitReceipt({
|
|
2075
|
+
await emitReceipt({
|
|
2048
2076
|
schema_version: 1,
|
|
2049
2077
|
outcome: "error",
|
|
2050
2078
|
error: err.errorCode,
|
|
@@ -2061,7 +2089,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2061
2089
|
process.stderr.write(
|
|
2062
2090
|
"botbuddy wait: this worktree needs a session; run bb setup, then retry the same wait\n",
|
|
2063
2091
|
);
|
|
2064
|
-
emitReceipt({
|
|
2092
|
+
await emitReceipt({
|
|
2065
2093
|
schema_version: 1,
|
|
2066
2094
|
outcome: "error",
|
|
2067
2095
|
error: "client_key_cannot_wait",
|
|
@@ -2083,7 +2111,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2083
2111
|
process.stderr.write(
|
|
2084
2112
|
`botbuddy wait: ${cause}; run bb setup, then retry the same wait\n`,
|
|
2085
2113
|
);
|
|
2086
|
-
emitReceipt(withPrincipalReceipt({
|
|
2114
|
+
await emitReceipt(withPrincipalReceipt({
|
|
2087
2115
|
schema_version: 1,
|
|
2088
2116
|
outcome: "error",
|
|
2089
2117
|
error: err.errorCode,
|
|
@@ -2098,7 +2126,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2098
2126
|
process.stderr.write(
|
|
2099
2127
|
`botbuddy wait: session rejected (${err.errorCode || "unauthorized"}) — run bb setup, then retry the same wait\n`,
|
|
2100
2128
|
);
|
|
2101
|
-
emitReceipt(withPrincipalReceipt({
|
|
2129
|
+
await emitReceipt(withPrincipalReceipt({
|
|
2102
2130
|
schema_version: 1,
|
|
2103
2131
|
outcome: "error",
|
|
2104
2132
|
error: err.errorCode || "unauthorized",
|
|
@@ -2111,7 +2139,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2111
2139
|
}
|
|
2112
2140
|
if (err && err.cap) {
|
|
2113
2141
|
process.stderr.write(`bb-wait: too many active waits for this agent — ${err.message}\n`);
|
|
2114
|
-
emitReceipt({
|
|
2142
|
+
await emitReceipt({
|
|
2115
2143
|
schema_version: 1,
|
|
2116
2144
|
outcome: "error",
|
|
2117
2145
|
error: err.errorCode || "wait_session_cap_exceeded",
|
|
@@ -2133,7 +2161,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2133
2161
|
? "the Linear issue belongs to a different Linear workspace than this tenant"
|
|
2134
2162
|
: "retry once Linear is reachable";
|
|
2135
2163
|
process.stderr.write(`bb-wait: unblocked:${err.ticket ?? "?"} cannot register — ${reason}; ${fix}\n`);
|
|
2136
|
-
emitReceipt({
|
|
2164
|
+
await emitReceipt({
|
|
2137
2165
|
schema_version: 1,
|
|
2138
2166
|
outcome: "error",
|
|
2139
2167
|
error: err.errorCode,
|
|
@@ -2155,7 +2183,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2155
2183
|
}
|
|
2156
2184
|
}
|
|
2157
2185
|
process.stderr.write(`bb-wait: invalid condition (${err.errorCode}) — ${err.message}\n`);
|
|
2158
|
-
emitReceipt({
|
|
2186
|
+
await emitReceipt({
|
|
2159
2187
|
schema_version: 1,
|
|
2160
2188
|
outcome: "error",
|
|
2161
2189
|
error: err.errorCode,
|
|
@@ -2166,20 +2194,20 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2166
2194
|
}
|
|
2167
2195
|
if (err && err.initialEval) {
|
|
2168
2196
|
process.stderr.write(`bb-wait: initial wait evaluation failed (${err.errorCode}) — ${err.detail}\n`);
|
|
2169
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.detail) });
|
|
2197
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.detail) });
|
|
2170
2198
|
process.exit(EXIT.INTERNAL);
|
|
2171
2199
|
}
|
|
2172
2200
|
if (err instanceof WaitCheckpointError) return emitSavedWaitError(err);
|
|
2173
2201
|
if (hasClaim) {
|
|
2174
2202
|
process.stderr.write(`bb-wait: claim=true wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
|
|
2175
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
2203
|
+
await emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
|
|
2176
2204
|
process.exit(EXIT.INTERNAL);
|
|
2177
2205
|
}
|
|
2178
2206
|
// BOT-1338/1608: every non-timer wait is a relay-tracked, session-token wait.
|
|
2179
2207
|
// If registration did not succeed, neither the server nor this client has
|
|
2180
2208
|
// verified the session's tenant, so there is no safe untracked fallback.
|
|
2181
2209
|
process.stderr.write(`bb-wait: wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
|
|
2182
|
-
emitReceipt(withPrincipalReceipt(
|
|
2210
|
+
await emitReceipt(withPrincipalReceipt(
|
|
2183
2211
|
{ schema_version: 1, outcome: "error", error: "register_failed", detail: String(err && err.message || err) },
|
|
2184
2212
|
null,
|
|
2185
2213
|
{ sessionTenant: sessionTenant ?? null, agentId: null },
|
|
@@ -2267,7 +2295,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2267
2295
|
status: "interrupted",
|
|
2268
2296
|
safe_cursor: terminalReceipt.next_cursor ?? checkpoint.safe_cursor ?? null,
|
|
2269
2297
|
});
|
|
2270
|
-
emitReceipt(terminalReceipt, { versioned: true });
|
|
2298
|
+
await emitReceipt(terminalReceipt, { versioned: true });
|
|
2271
2299
|
process.exit(exitCode);
|
|
2272
2300
|
}
|
|
2273
2301
|
// Receipt first, stdout second, cloud finalization last. A killed harness can
|
|
@@ -2281,7 +2309,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2281
2309
|
cloud_finalize_pending: Boolean(waitSessionId && !SERVER_ENDED_OUTCOMES.has(terminalReceipt.outcome)),
|
|
2282
2310
|
});
|
|
2283
2311
|
}
|
|
2284
|
-
emitReceipt(terminalReceipt, { versioned: true });
|
|
2312
|
+
await emitReceipt(terminalReceipt, { versioned: true });
|
|
2285
2313
|
// BOT-1228: a `superseded` outcome means an external actor already terminalized
|
|
2286
2314
|
// this wait_session server-side (reconcile_waits_to_canonical → abandoned, with
|
|
2287
2315
|
// ended_at/updated_at stamped). Skip the client finalize: the row is already
|
|
@@ -2314,7 +2342,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
2314
2342
|
});
|
|
2315
2343
|
process.stderr.write(`bb-wait: ${err && err.stack || err}\n`);
|
|
2316
2344
|
// Receipt first, stdout second, cloud finalization last — as on the normal path.
|
|
2317
|
-
emitReceipt(settled.receipt, { versioned: true });
|
|
2345
|
+
await emitReceipt(settled.receipt, { versioned: true });
|
|
2318
2346
|
await settled.finalizeCloud();
|
|
2319
2347
|
process.exit(settled.exitCode);
|
|
2320
2348
|
}
|