@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/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(), ...(opts.sessionToken ? { callback_session_token: opts.sessionToken } : {}), ...(knownSession ? { callback_session_id: knownSession } : {}), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
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
- return { async stop() { stopped = true; clearInterval(timer); await chain; } };
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 intentionally unbounded retry: otherwise an
799
- // auth outage (for example agent_identity_required) strands a 0-case run in
800
- // active state forever even though its command has already failed locally.
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
- try { await unlink(context.context_path); } catch { /* receipt is authoritative; context cleanup is best effort */ }
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
- function callWithContextIdentity(context, call) {
808
- const token = typeof context.callback_session_token === "string" && SESSION_TOKEN_RE.test(context.callback_session_token)
809
- ? context.callback_session_token
810
- : null;
811
- // Old contexts cannot safely fall back to the machine-level owner key. Ask
812
- // the API layer to require a session instead; the retained receipt/context
813
- // makes recovery possible without attributing a callback to the wrong agent.
814
- return (name, args) => call(name, args, token
815
- ? { auth: { "x-agent-api-key": token } }
816
- : { requireSessionToken: true });
817
- }
818
-
819
- export async function retryTerminalUpdate(context, update, call, { sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
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
- try { result = await call("update_command_run", update); } catch (error) { result = { ok: false, error: error instanceof Error ? error.message : String(error) }; }
825
- if (result?.ok && !result.isError && result.data?.error == null && result.data?.accepted !== false) return result;
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
- await writeJson(context.context_path, { ...context, terminal_update: { ...update, attempts, last_error: result?.error ?? result?.data?.error ?? "terminal_update_rejected", next_retry_at: new Date(Date.now() + delay).toISOString() } });
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") { await runWorker(args[1]); return; }
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);