@botbuddy/cli 1.59.9 → 1.60.0
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/bin/botbuddy.mjs +10 -1
- 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/agent-doctor.mjs +23 -3
- package/src/api.mjs +7 -4
- package/src/commands.mjs +51 -1
- package/src/config.mjs +2 -2
- package/src/fixtures/bot-2600-update-command-run-rejections-2026-10-20.json +13 -0
- package/src/git-transport.mjs +476 -0
- package/src/run-delivery.mjs +496 -0
- package/src/run.mjs +79 -29
package/src/run.mjs
CHANGED
|
@@ -13,7 +13,9 @@ import { dirname, join } from "node:path";
|
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { performance } from "node:perf_hooks";
|
|
16
17
|
import { callToolJson } from "./api.mjs";
|
|
18
|
+
import { SERVER_URL } from "./config.mjs";
|
|
17
19
|
import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
|
|
18
20
|
import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
19
21
|
import { hasAgentSessionRuntime, isRejectedCachedMcpSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
@@ -23,6 +25,10 @@ import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
|
23
25
|
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
24
26
|
import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
|
|
25
27
|
import { VERSION } from "./version.mjs";
|
|
28
|
+
import {
|
|
29
|
+
TERMINAL_RETRY_BUDGET_MS, DELIVERY_CALL_TIMEOUT_MS, callWithContextIdentity, classifyTerminalUpdateFailure,
|
|
30
|
+
ensureRunDelivery, parkedRecord, runDelivery, terminalUpdateAccepted,
|
|
31
|
+
} from "./run-delivery.mjs";
|
|
26
32
|
|
|
27
33
|
export const RUN_SCHEMA_VERSION = 1;
|
|
28
34
|
export const EXIT = Object.freeze({ OK: 0, AUTH: 3, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
|
|
@@ -405,7 +411,7 @@ export async function launchRun(argv, {
|
|
|
405
411
|
// every callback is attributable to the same agent. It deliberately stays
|
|
406
412
|
// outside child_env, receipts, and the workload's process environment.
|
|
407
413
|
const knownSession = opts.sessionId ?? managedCredential?.identity?.sessionId ?? managedCredential?.sessionId;
|
|
408
|
-
await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(),
|
|
414
|
+
await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), server_url: SERVER_URL,...(opts.sessionToken ? { callback_session_token: opts.sessionToken } : {}), ...(knownSession ? { callback_session_id: knownSession } : {}), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
|
|
409
415
|
const [file, args] = workerInvocation(context);
|
|
410
416
|
try {
|
|
411
417
|
const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
@@ -426,7 +432,10 @@ export async function runWorker(contextFile, {
|
|
|
426
432
|
call = callToolJson,
|
|
427
433
|
signalEmitter = process,
|
|
428
434
|
terminate = terminateProcessGroup,
|
|
435
|
+
// BOT-2600: the bounded terminal delivery (see retryTerminalUpdate).
|
|
436
|
+
sleep, now, terminalBudgetMs, startDelivery,
|
|
429
437
|
} = {}) {
|
|
438
|
+
const retry = { sleep, now, budgetMs: terminalBudgetMs, startDelivery };
|
|
430
439
|
const context = JSON.parse(await readFile(contextFile, "utf8"));
|
|
431
440
|
const callbackCall = callWithContextIdentity(context, call);
|
|
432
441
|
const ownerHeartbeat = startOwnerHeartbeat(context, callbackCall);
|
|
@@ -492,7 +501,7 @@ export async function runWorker(contextFile, {
|
|
|
492
501
|
child = spawnImpl(context.command[0], context.command.slice(1), { cwd: context.cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...(context.child_env ? { env: { ...process.env, ...context.child_env } } : {}) });
|
|
493
502
|
} catch (error) {
|
|
494
503
|
const endedAt = new Date().toISOString();
|
|
495
|
-
const receipt = await finalize(context, { status: "owner_lost", startedAt, endedAt, exitCode: null, signal: null, reason: `workload_spawn_failed:${error instanceof Error ? error.message : String(error)}` }, callbackCall);
|
|
504
|
+
const receipt = await finalize(context, { status: "owner_lost", startedAt, endedAt, exitCode: null, signal: null, reason: `workload_spawn_failed:${error instanceof Error ? error.message : String(error)}` }, callbackCall, retry);
|
|
496
505
|
return receipt;
|
|
497
506
|
}
|
|
498
507
|
// A failed spawn can report through the child `error` event on the next turn.
|
|
@@ -523,11 +532,11 @@ export async function runWorker(contextFile, {
|
|
|
523
532
|
if (lane) await lane.stop();
|
|
524
533
|
const endedAt = new Date().toISOString();
|
|
525
534
|
if (result.error) {
|
|
526
|
-
const receipt = await finalize(context, { status: "owner_lost", startedAt, endedAt, exitCode: null, signal: null, reason: `workload_spawn_failed:${result.error.message}`, output: capture.output, outputCharacters: capture.characters }, callbackCall);
|
|
535
|
+
const receipt = await finalize(context, { status: "owner_lost", startedAt, endedAt, exitCode: null, signal: null, reason: `workload_spawn_failed:${result.error.message}`, output: capture.output, outputCharacters: capture.characters }, callbackCall, retry);
|
|
527
536
|
return receipt;
|
|
528
537
|
}
|
|
529
538
|
const status = terminalStatus({ ...result, timedOut });
|
|
530
|
-
const receipt = await finalize(context, { status, startedAt, endedAt, exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, callbackCall);
|
|
539
|
+
const receipt = await finalize(context, { status, startedAt, endedAt, exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, callbackCall, retry);
|
|
531
540
|
return receipt;
|
|
532
541
|
} finally {
|
|
533
542
|
if (ownerHeartbeat) await ownerHeartbeat.stop();
|
|
@@ -556,7 +565,14 @@ function startOwnerHeartbeat(context, call, { intervalMs = 5 * 60_000, setInterv
|
|
|
556
565
|
tick();
|
|
557
566
|
const timer = setIntervalFn(tick, intervalMs);
|
|
558
567
|
timer.unref?.();
|
|
559
|
-
|
|
568
|
+
// BOT-2600: an in-flight heartbeat must never hold a finished worker open.
|
|
569
|
+
return { async stop() {
|
|
570
|
+
stopped = true;
|
|
571
|
+
clearInterval(timer);
|
|
572
|
+
let bound;
|
|
573
|
+
await Promise.race([chain, new Promise((resolve) => { bound = setTimeout(resolve, 2_000); bound.unref?.(); })]);
|
|
574
|
+
clearTimeout(bound);
|
|
575
|
+
} };
|
|
560
576
|
}
|
|
561
577
|
|
|
562
578
|
// Periodic lane flusher: reduce the NDJSON events file to the current case state
|
|
@@ -785,7 +801,7 @@ export async function cancelRun(runId, {
|
|
|
785
801
|
return { exitCode: EXIT.OK, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "cancellation_requested", run_id: runId, exit_code: EXIT.OK } };
|
|
786
802
|
}
|
|
787
803
|
|
|
788
|
-
async function finalize(context, state, call) {
|
|
804
|
+
async function finalize(context, state, call, retry = {}) {
|
|
789
805
|
const boundedLog = String(state.output ?? "").slice(-8192);
|
|
790
806
|
const outputCharacters = state.outputCharacters ?? String(state.output ?? "").length;
|
|
791
807
|
const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
|
|
@@ -795,43 +811,77 @@ async function finalize(context, state, call) {
|
|
|
795
811
|
const apiStatus = ({ success: "succeeded", failure: "failed", cancelled: "cancelled", timed_out: "timed_out", owner_lost: "owner_lost" })[state.status];
|
|
796
812
|
const update = { run_id: context.run_id, run_credential: context.run_credential, status: apiStatus, exit_code: state.exitCode ?? undefined, invalidated_reason: state.reason ?? (state.status === "timed_out" ? "timeout" : state.status === "owner_lost" ? "owner_lost" : undefined), receipt_path: context.receipt_upload, receipt_content_type: "application/json", receipt_byte_size: bytes.size, receipt_sha256: sha256, output_characters: outputCharacters, truncated: outputCharacters > MAX_CAPTURE_BYTES };
|
|
797
813
|
// A locally durable receipt is enough to publish the linked test run. Do it
|
|
798
|
-
// before the command callback's
|
|
799
|
-
//
|
|
800
|
-
//
|
|
814
|
+
// before the command callback's retry: otherwise an auth outage (for example
|
|
815
|
+
// agent_identity_required) strands a 0-case run in active state even though
|
|
816
|
+
// its command has already failed locally.
|
|
801
817
|
if (context.test_run) await completeTestRun(context, { status: state.status, exitCode: state.exitCode, signal: state.signal, startedAt: state.startedAt, endedAt: state.endedAt, call });
|
|
802
|
-
await retryTerminalUpdate(context, update, call);
|
|
803
|
-
|
|
818
|
+
const delivery = await retryTerminalUpdate(context, update, call, retry);
|
|
819
|
+
// A parked delivery keeps its context: it is the durable record the
|
|
820
|
+
// per-machine delivery process (and an operator) reads.
|
|
821
|
+
if (delivery.delivered) {
|
|
822
|
+
try { await unlink(context.context_path); } catch { /* receipt is authoritative; context cleanup is best effort */ }
|
|
823
|
+
}
|
|
804
824
|
return receipt;
|
|
805
825
|
}
|
|
806
826
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
827
|
+
// BOT-2600: the terminal callback is bounded. The worker that owns the receipt
|
|
828
|
+
// retries transient failures (transport, timeout, 5xx) within a monotonic
|
|
829
|
+
// budget of at most ten minutes and parks a permanent rejection (no owning
|
|
830
|
+
// identity, an invalid run credential, a local environment error) at once.
|
|
831
|
+
// Either way it records `terminal_update.parked` in the private context and
|
|
832
|
+
// returns so the worker process exits; a retryable parked delivery is handed to
|
|
833
|
+
// the single per-machine delivery process (`bb run --deliver`).
|
|
834
|
+
export async function retryTerminalUpdate(context, update, call, {
|
|
835
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
836
|
+
now = () => performance.now(),
|
|
837
|
+
budgetMs = TERMINAL_RETRY_BUDGET_MS,
|
|
838
|
+
startDelivery = () => ensureRunDelivery({ force: true }),
|
|
839
|
+
} = {}) {
|
|
840
|
+
const started = now();
|
|
820
841
|
let attempts = 0;
|
|
821
842
|
let delay = 1_000;
|
|
822
843
|
for (;;) {
|
|
823
|
-
let result;
|
|
824
|
-
|
|
825
|
-
|
|
844
|
+
let result = null;
|
|
845
|
+
let failure;
|
|
846
|
+
try {
|
|
847
|
+
result = await call("update_command_run", update, {
|
|
848
|
+
signal: AbortSignal.timeout(DELIVERY_CALL_TIMEOUT_MS),
|
|
849
|
+
// The endpoint this run was registered with (BOT-2600; sealed at launch).
|
|
850
|
+
...(typeof context.server_url === "string" && context.server_url ? { serverUrl: context.server_url } : {}),
|
|
851
|
+
});
|
|
852
|
+
if (terminalUpdateAccepted(result)) return { delivered: true, attempts: attempts + 1, result };
|
|
853
|
+
failure = classifyTerminalUpdateFailure(result);
|
|
854
|
+
} catch (error) {
|
|
855
|
+
failure = classifyTerminalUpdateFailure(null, error);
|
|
856
|
+
}
|
|
826
857
|
attempts += 1;
|
|
827
|
-
|
|
858
|
+
const exhausted = failure.retryable && !failure.parkNow && now() - started + delay > budgetMs;
|
|
859
|
+
if (!failure.retryable || failure.parkNow || exhausted) {
|
|
860
|
+
const parked = parkedRecord(exhausted ? { ...failure, kind: "retry_budget_exhausted" } : failure, "worker");
|
|
861
|
+
await writeJson(context.context_path, { ...context, terminal_update: { ...update, attempts, last_error: failure.reason, retry_budget_ms: budgetMs, parked } });
|
|
862
|
+
if (parked.retryable) { try { await startDelivery(); } catch { /* the next bb invocation starts it */ } }
|
|
863
|
+
return { delivered: false, attempts, parked, result };
|
|
864
|
+
}
|
|
865
|
+
await writeJson(context.context_path, { ...context, terminal_update: { ...update, attempts, last_error: failure.reason, retry_budget_ms: budgetMs, next_retry_at: new Date(Date.now() + delay).toISOString() } });
|
|
828
866
|
await sleep(delay);
|
|
829
867
|
delay = Math.min(delay * 2, 60_000);
|
|
830
868
|
}
|
|
831
869
|
}
|
|
832
870
|
|
|
833
871
|
export async function cmdRun(args) {
|
|
834
|
-
if (args[0] === "--worker") {
|
|
872
|
+
if (args[0] === "--worker") {
|
|
873
|
+
await runWorker(args[1]);
|
|
874
|
+
// BOT-2600: the receipt is durable and its delivery is accepted or parked.
|
|
875
|
+
// Nothing may keep a finished worker resident; an unref'd guard exits even
|
|
876
|
+
// if a stray handle (a keep-alive socket) would otherwise hold the loop.
|
|
877
|
+
setTimeout(() => process.exit(process.exitCode ?? 0), 1_000).unref();
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (args[0] === "--deliver") {
|
|
881
|
+
const result = await runDelivery({ call: callToolJson });
|
|
882
|
+
if (args.includes("--json")) oneLine({ schema_version: RUN_SCHEMA_VERSION, ...result }, false);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
835
885
|
if (args[0] === "cancel") {
|
|
836
886
|
const result = await cancelRun(args[1] ?? "");
|
|
837
887
|
oneLine(result.receipt, false);
|