bopodev-api 0.1.10 → 0.1.11

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.
@@ -3,6 +3,13 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm";
3
3
  import { nanoid } from "nanoid";
4
4
  import { resolveAdapter } from "bopodev-agent-sdk";
5
5
  import type { AgentState, HeartbeatContext } from "bopodev-agent-sdk";
6
+ import {
7
+ ControlPlaneHeadersJsonSchema,
8
+ ControlPlaneRequestHeadersSchema,
9
+ ControlPlaneRuntimeEnvSchema,
10
+ ExecutionOutcomeSchema,
11
+ type ExecutionOutcome
12
+ } from "bopodev-contracts";
6
13
  import type { BopoDb } from "bopodev-db";
7
14
  import { agents, appendActivity, companies, goals, heartbeatRuns, issues, projects } from "bopodev-db";
8
15
  import { appendAuditEvent, appendCost } from "bopodev-db";
@@ -12,6 +19,20 @@ import type { RealtimeHub } from "../realtime/hub";
12
19
  import { publishOfficeOccupantForAgent } from "../realtime/office-space";
13
20
  import { checkAgentBudget } from "./budget-service";
14
21
 
22
+ type HeartbeatRunTrigger = "manual" | "scheduler";
23
+ type HeartbeatRunMode = "default" | "resume" | "redo";
24
+
25
+ type ActiveHeartbeatRun = {
26
+ companyId: string;
27
+ agentId: string;
28
+ abortController: AbortController;
29
+ cancelReason?: string | null;
30
+ cancelRequestedAt?: string | null;
31
+ cancelRequestedBy?: string | null;
32
+ };
33
+
34
+ const activeHeartbeatRuns = new Map<string, ActiveHeartbeatRun>();
35
+
15
36
  export async function claimIssuesForAgent(
16
37
  db: BopoDb,
17
38
  companyId: string,
@@ -62,12 +83,95 @@ export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueI
62
83
  .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
63
84
  }
64
85
 
86
+ export async function stopHeartbeatRun(
87
+ db: BopoDb,
88
+ companyId: string,
89
+ runId: string,
90
+ options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger }
91
+ ) {
92
+ const runTrigger = options?.trigger ?? "manual";
93
+ const [run] = await db
94
+ .select({
95
+ id: heartbeatRuns.id,
96
+ status: heartbeatRuns.status,
97
+ agentId: heartbeatRuns.agentId
98
+ })
99
+ .from(heartbeatRuns)
100
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
101
+ .limit(1);
102
+ if (!run) {
103
+ return { ok: false as const, reason: "not_found" as const };
104
+ }
105
+ if (run.status !== "started") {
106
+ return { ok: false as const, reason: "invalid_status" as const, status: run.status };
107
+ }
108
+ const active = activeHeartbeatRuns.get(runId);
109
+ const cancelReason = "cancelled by stop request";
110
+ const cancelRequestedAt = new Date().toISOString();
111
+ if (active) {
112
+ active.cancelReason = cancelReason;
113
+ active.cancelRequestedAt = cancelRequestedAt;
114
+ active.cancelRequestedBy = options?.actorId ?? null;
115
+ active.abortController.abort(cancelReason);
116
+ } else {
117
+ await db
118
+ .update(heartbeatRuns)
119
+ .set({
120
+ status: "failed",
121
+ finishedAt: new Date(),
122
+ message: "Heartbeat cancelled by stop request."
123
+ })
124
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
125
+ }
126
+ await appendAuditEvent(db, {
127
+ companyId,
128
+ actorType: "system",
129
+ eventType: "heartbeat.cancel_requested",
130
+ entityType: "heartbeat_run",
131
+ entityId: runId,
132
+ correlationId: options?.requestId ?? runId,
133
+ payload: {
134
+ agentId: run.agentId,
135
+ trigger: runTrigger,
136
+ requestId: options?.requestId ?? null,
137
+ actorId: options?.actorId ?? null,
138
+ inMemoryAbortRegistered: Boolean(active)
139
+ }
140
+ });
141
+ if (!active) {
142
+ await appendAuditEvent(db, {
143
+ companyId,
144
+ actorType: "system",
145
+ eventType: "heartbeat.cancelled",
146
+ entityType: "heartbeat_run",
147
+ entityId: runId,
148
+ correlationId: options?.requestId ?? runId,
149
+ payload: {
150
+ agentId: run.agentId,
151
+ reason: cancelReason,
152
+ trigger: runTrigger,
153
+ requestId: options?.requestId ?? null,
154
+ actorId: options?.actorId ?? null
155
+ }
156
+ });
157
+ }
158
+ return { ok: true as const, runId, agentId: run.agentId, status: run.status };
159
+ }
160
+
65
161
  export async function runHeartbeatForAgent(
66
162
  db: BopoDb,
67
163
  companyId: string,
68
164
  agentId: string,
69
- options?: { requestId?: string; trigger?: "manual" | "scheduler"; realtimeHub?: RealtimeHub }
165
+ options?: {
166
+ requestId?: string;
167
+ trigger?: HeartbeatRunTrigger;
168
+ realtimeHub?: RealtimeHub;
169
+ mode?: HeartbeatRunMode;
170
+ sourceRunId?: string;
171
+ }
70
172
  ) {
173
+ const runMode = options?.mode ?? "default";
174
+ const runTrigger = options?.trigger ?? "manual";
71
175
  const [agent] = await db
72
176
  .select()
73
177
  .from(agents)
@@ -78,6 +182,7 @@ export async function runHeartbeatForAgent(
78
182
  return null;
79
183
  }
80
184
 
185
+ const persistedRuntime = parseRuntimeConfigFromAgentRow(agent as unknown as Record<string, unknown>);
81
186
  const startedRuns = await db
82
187
  .select({ id: heartbeatRuns.id, startedAt: heartbeatRuns.startedAt })
83
188
  .from(heartbeatRuns)
@@ -89,17 +194,22 @@ export async function runHeartbeatForAgent(
89
194
  )
90
195
  );
91
196
  const staleRunThresholdMs = resolveStaleRunThresholdMs();
197
+ const effectiveStaleRunThresholdMs = resolveEffectiveStaleRunThresholdMs({
198
+ baseThresholdMs: staleRunThresholdMs,
199
+ runtimeTimeoutSec: persistedRuntime.runtimeTimeoutSec,
200
+ interruptGraceSec: persistedRuntime.interruptGraceSec
201
+ });
92
202
  const nowTs = Date.now();
93
203
  const staleRuns = startedRuns.filter((run) => {
94
204
  const startedAt = run.startedAt.getTime();
95
- return nowTs - startedAt >= staleRunThresholdMs;
205
+ return nowTs - startedAt >= effectiveStaleRunThresholdMs;
96
206
  });
97
207
 
98
208
  if (staleRuns.length > 0) {
99
209
  await recoverStaleHeartbeatRuns(db, companyId, agentId, staleRuns, {
100
210
  requestId: options?.requestId,
101
- trigger: options?.trigger ?? "manual",
102
- staleRunThresholdMs
211
+ trigger: runTrigger,
212
+ staleRunThresholdMs: effectiveStaleRunThresholdMs
103
213
  });
104
214
  }
105
215
 
@@ -129,7 +239,7 @@ export async function runHeartbeatForAgent(
129
239
  entityType: "heartbeat_run",
130
240
  entityId: skippedRunId,
131
241
  correlationId: options?.requestId ?? skippedRunId,
132
- payload: { agentId, requestId: options?.requestId, trigger: options?.trigger ?? "manual" }
242
+ payload: { agentId, requestId: options?.requestId, trigger: runTrigger }
133
243
  });
134
244
  return skippedRunId;
135
245
  }
@@ -154,7 +264,9 @@ export async function runHeartbeatForAgent(
154
264
  payload: {
155
265
  agentId,
156
266
  requestId: options?.requestId ?? null,
157
- trigger: options?.trigger ?? "manual"
267
+ trigger: runTrigger,
268
+ mode: runMode,
269
+ sourceRunId: options?.sourceRunId ?? null
158
270
  }
159
271
  });
160
272
  }
@@ -205,17 +317,21 @@ export async function runHeartbeatForAgent(
205
317
  } = {};
206
318
  let executionSummary = "";
207
319
  let executionTrace: unknown = null;
320
+ let executionOutcome: ExecutionOutcome | null = null;
208
321
  let stateParseError: string | null = null;
322
+ let runtimeLaunchSummary: ReturnType<typeof summarizeRuntimeLaunch> | null = null;
209
323
 
210
324
  try {
211
325
  const workItems = await claimIssuesForAgent(db, companyId, agentId, runId);
212
326
  issueIds = workItems.map((item) => item.id);
213
327
  await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
214
- const adapter = resolveAdapter(agent.providerType as "claude_code" | "codex" | "http" | "shell");
328
+ const adapter = resolveAdapter(agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell");
215
329
  const parsedState = parseAgentState(agent.stateBlob);
216
330
  state = parsedState.state;
217
331
  stateParseError = parsedState.parseError;
218
- const persistedRuntime = parseRuntimeConfigFromAgentRow(agent as unknown as Record<string, unknown>);
332
+ if (runMode === "redo") {
333
+ state = clearResumeState(state);
334
+ }
219
335
  const heartbeatRuntimeEnv = buildHeartbeatRuntimeEnv({
220
336
  companyId,
221
337
  agentId: agent.id,
@@ -255,7 +371,7 @@ export async function runHeartbeatForAgent(
255
371
  agentName: agent.name,
256
372
  agentRole: agent.role,
257
373
  managerAgentId: agent.managerAgentId,
258
- providerType: agent.providerType as "claude_code" | "codex" | "http" | "shell",
374
+ providerType: agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
259
375
  heartbeatRunId: runId,
260
376
  state,
261
377
  runtime: workspaceResolution.runtime,
@@ -307,21 +423,25 @@ export async function runHeartbeatForAgent(
307
423
  payload: {
308
424
  agentId,
309
425
  providerType: agent.providerType,
310
- missingKeys: controlPlaneEnvValidation.missingKeys
426
+ validationErrorCode: controlPlaneEnvValidation.validationErrorCode,
427
+ invalidFieldPaths: controlPlaneEnvValidation.invalidFieldPaths
311
428
  }
312
429
  });
313
430
  throw new Error(
314
- `Control-plane runtime env is incomplete. Missing keys: ${controlPlaneEnvValidation.missingKeys.join(", ")}`
431
+ `Control-plane runtime env is invalid. Invalid fields: ${controlPlaneEnvValidation.invalidFieldPaths.join(", ")}`
315
432
  );
316
433
  }
317
434
 
318
435
  if (
319
436
  resolveControlPlanePreflightEnabled() &&
320
- shouldRequireControlPlanePreflight(agent.providerType as "claude_code" | "codex" | "http" | "shell", workItems.length)
437
+ shouldRequireControlPlanePreflight(
438
+ agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
439
+ workItems.length
440
+ )
321
441
  ) {
322
442
  const preflight = await runControlPlaneConnectivityPreflight({
323
- apiBaseUrl: workspaceResolution.runtime.env?.BOPOHQ_API_BASE_URL ?? "",
324
- requestHeadersJson: workspaceResolution.runtime.env?.BOPOHQ_REQUEST_HEADERS_JSON ?? "",
443
+ apiBaseUrl: resolveControlPlaneEnv(workspaceResolution.runtime.env ?? {}, "API_BASE_URL"),
444
+ runtimeEnv: workspaceResolution.runtime.env ?? {},
325
445
  timeoutMs: resolveControlPlanePreflightTimeoutMs()
326
446
  });
327
447
  await appendAuditEvent(db, {
@@ -342,6 +462,10 @@ export async function runHeartbeatForAgent(
342
462
  }
343
463
  }
344
464
 
465
+ runtimeLaunchSummary = summarizeRuntimeLaunch(
466
+ agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
467
+ workspaceResolution.runtime
468
+ );
345
469
  await appendAuditEvent(db, {
346
470
  companyId,
347
471
  actorType: "system",
@@ -351,20 +475,56 @@ export async function runHeartbeatForAgent(
351
475
  correlationId: options?.requestId ?? runId,
352
476
  payload: {
353
477
  agentId,
354
- runtime: summarizeRuntimeLaunch(
355
- agent.providerType as "claude_code" | "codex" | "http" | "shell",
356
- workspaceResolution.runtime
357
- ),
478
+ runtime: runtimeLaunchSummary,
358
479
  diagnostics: {
359
480
  requestId: options?.requestId ?? null,
360
- trigger: options?.trigger ?? "manual"
481
+ trigger: runTrigger,
482
+ mode: runMode,
483
+ sourceRunId: options?.sourceRunId ?? null
361
484
  }
362
485
  }
363
486
  });
487
+ if (runMode === "resume" || runMode === "redo") {
488
+ await appendAuditEvent(db, {
489
+ companyId,
490
+ actorType: "system",
491
+ eventType: runMode === "resume" ? "heartbeat.resumed" : "heartbeat.redo_started",
492
+ entityType: "heartbeat_run",
493
+ entityId: runId,
494
+ correlationId: options?.requestId ?? runId,
495
+ payload: {
496
+ agentId,
497
+ sourceRunId: options?.sourceRunId ?? null,
498
+ requestId: options?.requestId ?? null,
499
+ trigger: runTrigger
500
+ }
501
+ });
502
+ }
503
+
504
+ const activeRunAbort = new AbortController();
505
+ registerActiveHeartbeatRun(runId, {
506
+ companyId,
507
+ agentId,
508
+ abortController: activeRunAbort
509
+ });
364
510
 
365
- const execution = await adapter.execute(context);
511
+ const execution = await executeAdapterWithWatchdog({
512
+ execute: (abortSignal) =>
513
+ adapter.execute({
514
+ ...context,
515
+ runtime: {
516
+ ...(context.runtime ?? {}),
517
+ abortSignal
518
+ }
519
+ }),
520
+ providerType: agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
521
+ runtime: workspaceResolution.runtime,
522
+ externalAbortSignal: activeRunAbort.signal
523
+ });
366
524
  executionSummary = execution.summary;
367
525
  executionTrace = execution.trace ?? null;
526
+ const parsedOutcome = ExecutionOutcomeSchema.safeParse(execution.outcome);
527
+ executionOutcome = parsedOutcome.success ? parsedOutcome.data : null;
368
528
 
369
529
  if (execution.tokenInput > 0 || execution.tokenOutput > 0 || execution.usdCost > 0) {
370
530
  await appendCost(db, {
@@ -402,7 +562,8 @@ export async function runHeartbeatForAgent(
402
562
  tokenInput: execution.tokenInput,
403
563
  tokenOutput: execution.tokenOutput,
404
564
  usdCost: execution.usdCost,
405
- trace: executionTrace
565
+ trace: executionTrace,
566
+ outcome: executionOutcome
406
567
  });
407
568
 
408
569
  if (issueIds.length > 0 && execution.status === "ok" && shouldAdvanceIssuesToReview) {
@@ -442,6 +603,7 @@ export async function runHeartbeatForAgent(
442
603
  issueIds,
443
604
  reason: "insufficient_real_execution_evidence",
444
605
  summary: execution.summary,
606
+ outcome: executionOutcome,
445
607
  usage: {
446
608
  tokenInput: execution.tokenInput,
447
609
  tokenOutput: execution.tokenOutput,
@@ -470,6 +632,8 @@ export async function runHeartbeatForAgent(
470
632
  payload: {
471
633
  agentId,
472
634
  result: execution.summary,
635
+ message: execution.summary,
636
+ outcome: executionOutcome,
473
637
  issueIds,
474
638
  usage: {
475
639
  tokenInput: execution.tokenInput,
@@ -481,13 +645,41 @@ export async function runHeartbeatForAgent(
481
645
  diagnostics: {
482
646
  stateParseError,
483
647
  requestId: options?.requestId,
484
- trigger: options?.trigger ?? "manual"
648
+ trigger: runTrigger
485
649
  }
486
650
  }
487
651
  });
488
652
  } catch (error) {
489
653
  const classified = classifyHeartbeatError(error);
490
- executionSummary = `Heartbeat failed (${classified.type}): ${classified.message}`;
654
+ executionSummary =
655
+ classified.type === "cancelled"
656
+ ? "Heartbeat cancelled by stop request."
657
+ : `Heartbeat failed (${classified.type}): ${classified.message}`;
658
+ if (!executionTrace && classified.type === "cancelled") {
659
+ executionTrace = {
660
+ command: runtimeLaunchSummary?.command ?? null,
661
+ args: runtimeLaunchSummary?.args ?? [],
662
+ cwd: runtimeLaunchSummary?.cwd ?? null,
663
+ failureType: "cancelled",
664
+ timedOut: false,
665
+ timeoutSource: null
666
+ };
667
+ } else if (!executionTrace && classified.type === "timeout") {
668
+ executionTrace = {
669
+ command: runtimeLaunchSummary?.command ?? null,
670
+ args: runtimeLaunchSummary?.args ?? [],
671
+ cwd: runtimeLaunchSummary?.cwd ?? null,
672
+ failureType: classified.timeoutSource === "watchdog" ? "watchdog_timeout" : "runtime_timeout",
673
+ timedOut: true,
674
+ timeoutSource: classified.timeoutSource ?? "watchdog"
675
+ };
676
+ } else if (!executionTrace && runtimeLaunchSummary) {
677
+ executionTrace = {
678
+ command: runtimeLaunchSummary.command ?? null,
679
+ args: runtimeLaunchSummary.args ?? [],
680
+ cwd: runtimeLaunchSummary.cwd ?? null
681
+ };
682
+ }
491
683
  await db
492
684
  .update(heartbeatRuns)
493
685
  .set({
@@ -506,8 +698,11 @@ export async function runHeartbeatForAgent(
506
698
  payload: {
507
699
  agentId,
508
700
  issueIds,
701
+ result: executionSummary,
702
+ message: executionSummary,
509
703
  errorType: classified.type,
510
704
  errorMessage: classified.message,
705
+ outcome: executionOutcome,
511
706
  usage: {
512
707
  source: readTraceString(executionTrace, "usageSource") ?? "unknown"
513
708
  },
@@ -515,11 +710,28 @@ export async function runHeartbeatForAgent(
515
710
  diagnostics: {
516
711
  stateParseError,
517
712
  requestId: options?.requestId,
518
- trigger: options?.trigger ?? "manual"
713
+ trigger: runTrigger
519
714
  }
520
715
  }
521
716
  });
717
+ if (classified.type === "cancelled") {
718
+ await appendAuditEvent(db, {
719
+ companyId,
720
+ actorType: "system",
721
+ eventType: "heartbeat.cancelled",
722
+ entityType: "heartbeat_run",
723
+ entityId: runId,
724
+ correlationId: options?.requestId ?? runId,
725
+ payload: {
726
+ agentId,
727
+ requestId: options?.requestId ?? null,
728
+ trigger: runTrigger,
729
+ result: executionSummary
730
+ }
731
+ });
732
+ }
522
733
  } finally {
734
+ unregisterActiveHeartbeatRun(runId);
523
735
  try {
524
736
  await releaseClaimedIssues(db, companyId, issueIds);
525
737
  } catch (releaseError) {
@@ -687,7 +899,7 @@ async function buildHeartbeatContext(
687
899
  agentName: string;
688
900
  agentRole: string;
689
901
  managerAgentId: string | null;
690
- providerType: "claude_code" | "codex" | "http" | "shell";
902
+ providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
691
903
  heartbeatRunId: string;
692
904
  state: AgentState;
693
905
  runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
@@ -825,13 +1037,20 @@ function parseAgentState(stateBlob: string | null) {
825
1037
 
826
1038
  function classifyHeartbeatError(error: unknown) {
827
1039
  const message = String(error);
1040
+ const normalized = message.toLowerCase();
1041
+ if (error instanceof AdapterExecutionCancelledError || normalized.includes("adapter execution cancelled")) {
1042
+ return { type: "cancelled" as const, timeoutSource: null, message };
1043
+ }
1044
+ if (error instanceof AdapterExecutionWatchdogTimeoutError || normalized.includes("adapter execution timed out")) {
1045
+ return { type: "timeout" as const, timeoutSource: "watchdog" as const, message };
1046
+ }
828
1047
  if (message.includes("ENOENT")) {
829
- return { type: "runtime_missing", message };
1048
+ return { type: "runtime_missing" as const, timeoutSource: null, message };
830
1049
  }
831
- if (message.includes("timeout")) {
832
- return { type: "timeout", message };
1050
+ if (normalized.includes("timeout") || normalized.includes("timed out")) {
1051
+ return { type: "timeout" as const, timeoutSource: "runtime" as const, message };
833
1052
  }
834
- return { type: "unknown", message };
1053
+ return { type: "unknown" as const, timeoutSource: null, message };
835
1054
  }
836
1055
 
837
1056
  function shouldPromoteIssuesToReview(input: {
@@ -840,7 +1059,23 @@ function shouldPromoteIssuesToReview(input: {
840
1059
  tokenOutput: number;
841
1060
  usdCost: number;
842
1061
  trace: unknown;
1062
+ outcome: ExecutionOutcome | null;
843
1063
  }) {
1064
+ if (input.outcome) {
1065
+ if (isBootstrapDemoSummary(input.summary)) {
1066
+ return false;
1067
+ }
1068
+ if (input.outcome.kind !== "completed") {
1069
+ return false;
1070
+ }
1071
+ if (input.outcome.blockers.length > 0) {
1072
+ return false;
1073
+ }
1074
+ if (input.outcome.nextSuggestedState === "blocked") {
1075
+ return false;
1076
+ }
1077
+ return true;
1078
+ }
844
1079
  return !isBootstrapDemoSummary(input.summary) && hasRealExecutionEvidence(input);
845
1080
  }
846
1081
 
@@ -973,6 +1208,177 @@ function resolveStaleRunThresholdMs() {
973
1208
  return parsed;
974
1209
  }
975
1210
 
1211
+ function resolveEffectiveStaleRunThresholdMs(input: {
1212
+ baseThresholdMs: number;
1213
+ runtimeTimeoutSec: number;
1214
+ interruptGraceSec: number;
1215
+ }) {
1216
+ if (!Number.isFinite(input.runtimeTimeoutSec) || input.runtimeTimeoutSec <= 0) {
1217
+ return input.baseThresholdMs;
1218
+ }
1219
+ const timeoutMs = Math.floor(input.runtimeTimeoutSec * 1000);
1220
+ const graceMs = Math.max(5_000, Math.floor(Math.max(0, input.interruptGraceSec) * 1000));
1221
+ const jitterBufferMs = 30_000;
1222
+ const derivedThresholdMs = timeoutMs + graceMs + jitterBufferMs;
1223
+ const minimumThresholdMs = 30_000;
1224
+ return Math.max(minimumThresholdMs, Math.min(input.baseThresholdMs, derivedThresholdMs));
1225
+ }
1226
+
1227
+ async function executeAdapterWithWatchdog<T>(input: {
1228
+ execute: (abortSignal: AbortSignal) => Promise<T>;
1229
+ providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
1230
+ externalAbortSignal?: AbortSignal;
1231
+ runtime:
1232
+ | {
1233
+ timeoutMs?: number;
1234
+ interruptGraceSec?: number;
1235
+ }
1236
+ | undefined;
1237
+ }) {
1238
+ const timeoutMs = resolveAdapterWatchdogTimeoutMs(input.providerType, input.runtime);
1239
+ if (timeoutMs <= 0) {
1240
+ return input.execute(input.externalAbortSignal ?? new AbortController().signal);
1241
+ }
1242
+ const executionAbort = new AbortController();
1243
+ let timer: NodeJS.Timeout | null = null;
1244
+ let externalAbortListener: (() => void) | null = null;
1245
+ try {
1246
+ if (input.externalAbortSignal) {
1247
+ externalAbortListener = () => {
1248
+ if (!executionAbort.signal.aborted) {
1249
+ executionAbort.abort("external");
1250
+ }
1251
+ };
1252
+ if (input.externalAbortSignal.aborted) {
1253
+ externalAbortListener();
1254
+ } else {
1255
+ input.externalAbortSignal.addEventListener("abort", externalAbortListener, { once: true });
1256
+ }
1257
+ }
1258
+ const executionPromise = input.execute(executionAbort.signal);
1259
+ // If watchdog timeout wins race, suppress late adapter rejections after abort.
1260
+ void executionPromise.catch(() => undefined);
1261
+ const cancellationPromise = new Promise<T>((_, reject) => {
1262
+ if (!input.externalAbortSignal) {
1263
+ return;
1264
+ }
1265
+ if (input.externalAbortSignal.aborted) {
1266
+ reject(new AdapterExecutionCancelledError("adapter execution cancelled by external stop request"));
1267
+ return;
1268
+ }
1269
+ input.externalAbortSignal.addEventListener(
1270
+ "abort",
1271
+ () => {
1272
+ reject(new AdapterExecutionCancelledError("adapter execution cancelled by external stop request"));
1273
+ },
1274
+ { once: true }
1275
+ );
1276
+ });
1277
+ return await Promise.race([
1278
+ executionPromise,
1279
+ cancellationPromise,
1280
+ new Promise<T>((_, reject) => {
1281
+ timer = setTimeout(() => {
1282
+ if (!executionAbort.signal.aborted) {
1283
+ executionAbort.abort("watchdog");
1284
+ }
1285
+ reject(new AdapterExecutionWatchdogTimeoutError(timeoutMs));
1286
+ }, timeoutMs);
1287
+ })
1288
+ ]);
1289
+ } finally {
1290
+ if (timer) {
1291
+ clearTimeout(timer);
1292
+ }
1293
+ if (input.externalAbortSignal && externalAbortListener) {
1294
+ input.externalAbortSignal.removeEventListener("abort", externalAbortListener);
1295
+ }
1296
+ }
1297
+ }
1298
+
1299
+ class AdapterExecutionWatchdogTimeoutError extends Error {
1300
+ readonly timeoutMs: number;
1301
+
1302
+ constructor(timeoutMs: number) {
1303
+ super(`adapter execution timed out after ${timeoutMs}ms`);
1304
+ this.name = "AdapterExecutionWatchdogTimeoutError";
1305
+ this.timeoutMs = timeoutMs;
1306
+ }
1307
+ }
1308
+
1309
+ class AdapterExecutionCancelledError extends Error {
1310
+ constructor(message = "adapter execution cancelled") {
1311
+ super(message);
1312
+ this.name = "AdapterExecutionCancelledError";
1313
+ }
1314
+ }
1315
+
1316
+ function resolveAdapterWatchdogTimeoutMs(
1317
+ providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1318
+ runtime:
1319
+ | {
1320
+ timeoutMs?: number;
1321
+ interruptGraceSec?: number;
1322
+ }
1323
+ | undefined
1324
+ ) {
1325
+ const expectedBudgetMs = estimateProviderExecutionBudgetMs(providerType, runtime);
1326
+ const fallback = Number(process.env.BOPO_HEARTBEAT_EXECUTION_TIMEOUT_MS ?? expectedBudgetMs);
1327
+ if (!Number.isFinite(fallback) || fallback < 30_000) {
1328
+ return expectedBudgetMs;
1329
+ }
1330
+ return Math.floor(Math.min(fallback, 15 * 60 * 1000));
1331
+ }
1332
+
1333
+ function estimateProviderExecutionBudgetMs(
1334
+ providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1335
+ runtime:
1336
+ | {
1337
+ timeoutMs?: number;
1338
+ interruptGraceSec?: number;
1339
+ retryCount?: number;
1340
+ }
1341
+ | undefined
1342
+ ) {
1343
+ const perAttemptTimeoutMs = resolveRuntimeAttemptTimeoutMs(providerType, runtime?.timeoutMs);
1344
+ const perAttemptGraceMs = Math.max(5_000, Math.floor(Math.max(0, runtime?.interruptGraceSec ?? 0) * 1000));
1345
+ const retryCount = resolveRuntimeRetryCount(providerType, runtime?.retryCount);
1346
+ const attemptsPerExecution = Math.max(1, Math.min(3, 1 + retryCount));
1347
+ const executionMultiplier = providerType === "claude_code" ? 3 : 1;
1348
+ const expectedAttempts = attemptsPerExecution * executionMultiplier;
1349
+ const jitterBufferMs = 30_000;
1350
+ return Math.floor(perAttemptTimeoutMs * expectedAttempts + perAttemptGraceMs * expectedAttempts + jitterBufferMs);
1351
+ }
1352
+
1353
+ function resolveRuntimeAttemptTimeoutMs(
1354
+ providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1355
+ configuredTimeoutMs: number | undefined
1356
+ ) {
1357
+ if (Number.isFinite(configuredTimeoutMs) && (configuredTimeoutMs ?? 0) > 0) {
1358
+ return Math.floor(configuredTimeoutMs ?? 0);
1359
+ }
1360
+ if (providerType === "claude_code") {
1361
+ return 90_000;
1362
+ }
1363
+ if (providerType === "codex") {
1364
+ return 5 * 60 * 1000;
1365
+ }
1366
+ if (providerType === "cursor") {
1367
+ return 30_000;
1368
+ }
1369
+ return 45_000;
1370
+ }
1371
+
1372
+ function resolveRuntimeRetryCount(
1373
+ providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1374
+ configuredRetryCount: number | undefined
1375
+ ) {
1376
+ if (Number.isFinite(configuredRetryCount)) {
1377
+ return Math.max(0, Math.min(2, Math.floor(configuredRetryCount ?? 0)));
1378
+ }
1379
+ return providerType === "codex" ? 1 : 0;
1380
+ }
1381
+
976
1382
  function mergeRuntimeForExecution(
977
1383
  runtimeFromConfig:
978
1384
  | {
@@ -1019,7 +1425,7 @@ function mergeRuntimeForExecution(
1019
1425
  };
1020
1426
  return {
1021
1427
  ...merged,
1022
- // Keep system-injected BOPOHQ_* context even when state runtime carries env:{}.
1428
+ // Keep system-injected BOPODEV_* context even when state runtime carries env:{}.
1023
1429
  env: {
1024
1430
  ...(runtimeFromState?.env ?? {}),
1025
1431
  ...(runtimeFromConfig?.env ?? {})
@@ -1027,6 +1433,69 @@ function mergeRuntimeForExecution(
1027
1433
  };
1028
1434
  }
1029
1435
 
1436
+ function registerActiveHeartbeatRun(runId: string, run: ActiveHeartbeatRun) {
1437
+ activeHeartbeatRuns.set(runId, run);
1438
+ }
1439
+
1440
+ function unregisterActiveHeartbeatRun(runId: string) {
1441
+ activeHeartbeatRuns.delete(runId);
1442
+ }
1443
+
1444
+ function clearResumeState(
1445
+ state: AgentState & {
1446
+ runtime?: {
1447
+ command?: string;
1448
+ args?: string[];
1449
+ cwd?: string;
1450
+ timeoutMs?: number;
1451
+ interruptGraceSec?: number;
1452
+ retryCount?: number;
1453
+ retryBackoffMs?: number;
1454
+ env?: Record<string, string>;
1455
+ model?: string;
1456
+ thinkingEffort?: "auto" | "low" | "medium" | "high";
1457
+ bootstrapPrompt?: string;
1458
+ runPolicy?: {
1459
+ sandboxMode?: "workspace_write" | "full_access";
1460
+ allowWebSearch?: boolean;
1461
+ };
1462
+ };
1463
+ }
1464
+ ) {
1465
+ const nextState = { ...state } as AgentState & Record<string, unknown>;
1466
+ delete nextState.sessionId;
1467
+ delete nextState.cwd;
1468
+ delete nextState.cursorSession;
1469
+ return nextState as AgentState & {
1470
+ runtime?: {
1471
+ command?: string;
1472
+ args?: string[];
1473
+ cwd?: string;
1474
+ timeoutMs?: number;
1475
+ interruptGraceSec?: number;
1476
+ retryCount?: number;
1477
+ retryBackoffMs?: number;
1478
+ env?: Record<string, string>;
1479
+ model?: string;
1480
+ thinkingEffort?: "auto" | "low" | "medium" | "high";
1481
+ bootstrapPrompt?: string;
1482
+ runPolicy?: {
1483
+ sandboxMode?: "workspace_write" | "full_access";
1484
+ allowWebSearch?: boolean;
1485
+ };
1486
+ };
1487
+ };
1488
+ }
1489
+
1490
+ function resolveControlPlaneEnv(runtimeEnv: Record<string, string>, suffix: string) {
1491
+ const next = runtimeEnv[`BOPODEV_${suffix}`];
1492
+ return hasText(next) ? (next as string) : "";
1493
+ }
1494
+
1495
+ function resolveControlPlaneProcessEnv(suffix: string) {
1496
+ return process.env[`BOPODEV_${suffix}`];
1497
+ }
1498
+
1030
1499
  function buildHeartbeatRuntimeEnv(input: {
1031
1500
  companyId: string;
1032
1501
  agentId: string;
@@ -1044,25 +1513,27 @@ function buildHeartbeatRuntimeEnv(input: {
1044
1513
  });
1045
1514
 
1046
1515
  const codexApiKey = resolveCodexApiKey();
1516
+ const claudeApiKey = resolveClaudeApiKey();
1047
1517
  return {
1048
- BOPOHQ_AGENT_ID: input.agentId,
1049
- BOPOHQ_COMPANY_ID: input.companyId,
1050
- BOPOHQ_RUN_ID: input.heartbeatRunId,
1051
- BOPOHQ_FORCE_MANAGED_CODEX_HOME: "false",
1052
- BOPOHQ_API_BASE_URL: apiBaseUrl,
1053
- BOPOHQ_ACTOR_TYPE: "agent",
1054
- BOPOHQ_ACTOR_ID: input.agentId,
1055
- BOPOHQ_ACTOR_COMPANIES: input.companyId,
1056
- BOPOHQ_ACTOR_PERMISSIONS: actorPermissions,
1057
- BOPOHQ_REQUEST_HEADERS_JSON: actorHeaders,
1058
- BOPOHQ_REQUEST_APPROVAL_DEFAULT: "true",
1059
- BOPOHQ_CAN_HIRE_AGENTS: input.canHireAgents ? "true" : "false",
1060
- ...(codexApiKey ? { OPENAI_API_KEY: codexApiKey } : {})
1518
+ BOPODEV_AGENT_ID: input.agentId,
1519
+ BOPODEV_COMPANY_ID: input.companyId,
1520
+ BOPODEV_RUN_ID: input.heartbeatRunId,
1521
+ BOPODEV_FORCE_MANAGED_CODEX_HOME: "false",
1522
+ BOPODEV_API_BASE_URL: apiBaseUrl,
1523
+ BOPODEV_ACTOR_TYPE: "agent",
1524
+ BOPODEV_ACTOR_ID: input.agentId,
1525
+ BOPODEV_ACTOR_COMPANIES: input.companyId,
1526
+ BOPODEV_ACTOR_PERMISSIONS: actorPermissions,
1527
+ BOPODEV_REQUEST_HEADERS_JSON: actorHeaders,
1528
+ BOPODEV_REQUEST_APPROVAL_DEFAULT: "true",
1529
+ BOPODEV_CAN_HIRE_AGENTS: input.canHireAgents ? "true" : "false",
1530
+ ...(codexApiKey ? { OPENAI_API_KEY: codexApiKey } : {}),
1531
+ ...(claudeApiKey ? { ANTHROPIC_API_KEY: claudeApiKey } : {})
1061
1532
  } satisfies Record<string, string>;
1062
1533
  }
1063
1534
 
1064
1535
  function resolveControlPlaneApiBaseUrl() {
1065
- const configured = process.env.BOPOHQ_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL;
1536
+ const configured = resolveControlPlaneProcessEnv("API_BASE_URL") ?? process.env.NEXT_PUBLIC_API_URL;
1066
1537
  return normalizeControlPlaneApiBaseUrl(configured) ?? "http://127.0.0.1:4020";
1067
1538
  }
1068
1539
 
@@ -1072,8 +1543,14 @@ function resolveCodexApiKey() {
1072
1543
  return value && value.length > 0 ? value : null;
1073
1544
  }
1074
1545
 
1546
+ function resolveClaudeApiKey() {
1547
+ const configured = process.env.BOPO_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY;
1548
+ const value = configured?.trim();
1549
+ return value && value.length > 0 ? value : null;
1550
+ }
1551
+
1075
1552
  function summarizeRuntimeLaunch(
1076
- providerType: "claude_code" | "codex" | "http" | "shell",
1553
+ providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1077
1554
  runtime:
1078
1555
  | {
1079
1556
  command?: string;
@@ -1090,13 +1567,14 @@ function summarizeRuntimeLaunch(
1090
1567
  ) {
1091
1568
  const env = runtime?.env ?? {};
1092
1569
  const hasOpenAiKey = typeof env.OPENAI_API_KEY === "string" && env.OPENAI_API_KEY.trim().length > 0;
1570
+ const hasAnthropicKey = typeof env.ANTHROPIC_API_KEY === "string" && env.ANTHROPIC_API_KEY.trim().length > 0;
1093
1571
  const hasExplicitCodexHome = typeof env.CODEX_HOME === "string" && env.CODEX_HOME.trim().length > 0;
1094
1572
  const codexHomeMode =
1095
1573
  providerType !== "codex"
1096
1574
  ? null
1097
1575
  : hasExplicitCodexHome
1098
1576
  ? "explicit"
1099
- : hasText(env.BOPOHQ_COMPANY_ID) && hasText(env.BOPOHQ_AGENT_ID)
1577
+ : hasText(resolveControlPlaneEnv(env, "COMPANY_ID")) && hasText(resolveControlPlaneEnv(env, "AGENT_ID"))
1100
1578
  ? "managed"
1101
1579
  : "default";
1102
1580
  const authMode = providerType !== "codex" ? null : hasOpenAiKey ? "api_key" : "session";
@@ -1111,9 +1589,10 @@ function summarizeRuntimeLaunch(
1111
1589
  codexHomeMode,
1112
1590
  envFlags: {
1113
1591
  hasOpenAiKey,
1592
+ hasAnthropicKey,
1114
1593
  hasExplicitCodexHome,
1115
- hasControlPlaneBaseUrl: hasText(env.BOPOHQ_API_BASE_URL),
1116
- hasRequestHeadersJson: hasText(env.BOPOHQ_REQUEST_HEADERS_JSON)
1594
+ hasControlPlaneBaseUrl: hasText(resolveControlPlaneEnv(env, "API_BASE_URL")),
1595
+ hasRequestHeadersJson: hasText(resolveControlPlaneEnv(env, "REQUEST_HEADERS_JSON"))
1117
1596
  }
1118
1597
  };
1119
1598
  }
@@ -1142,49 +1621,44 @@ function normalizeControlPlaneApiBaseUrl(raw: string | undefined) {
1142
1621
  }
1143
1622
 
1144
1623
  function validateControlPlaneRuntimeEnv(runtimeEnv: Record<string, string>, runId: string) {
1145
- const requiredKeys = [
1146
- "BOPOHQ_AGENT_ID",
1147
- "BOPOHQ_COMPANY_ID",
1148
- "BOPOHQ_RUN_ID",
1149
- "BOPOHQ_API_BASE_URL",
1150
- "BOPOHQ_REQUEST_HEADERS_JSON"
1151
- ] as const;
1152
- const missingKeys = requiredKeys.filter((key) => {
1153
- const value = runtimeEnv[key];
1154
- if (typeof value !== "string" || value.trim().length === 0) {
1155
- return true;
1156
- }
1157
- return false;
1158
- });
1159
- const missing = [...missingKeys] as string[];
1160
- if (runtimeEnv.BOPOHQ_RUN_ID?.trim() && runtimeEnv.BOPOHQ_RUN_ID !== runId) {
1161
- missing.push("BOPOHQ_RUN_ID(mismatch)");
1162
- }
1624
+ const parsed = ControlPlaneRuntimeEnvSchema.safeParse(runtimeEnv);
1625
+ const invalidFieldPaths = parsed.success
1626
+ ? []
1627
+ : parsed.error.issues.map((issue) => (issue.path.length > 0 ? issue.path.join(".") : "<root>"));
1628
+ const runtimeRunId = resolveControlPlaneEnv(runtimeEnv, "RUN_ID");
1629
+ const mismatchError = runtimeRunId && runtimeRunId !== runId ? ["BOPODEV_RUN_ID(mismatch)"] : [];
1630
+ const allInvalidFieldPaths = [...invalidFieldPaths, ...mismatchError];
1163
1631
  return {
1164
- ok: missing.length === 0,
1165
- missingKeys: missing
1632
+ ok: allInvalidFieldPaths.length === 0,
1633
+ validationErrorCode: parsed.success ? (mismatchError.length > 0 ? "run_id_mismatch" : null) : "invalid_control_plane_runtime_env",
1634
+ invalidFieldPaths: allInvalidFieldPaths
1166
1635
  };
1167
1636
  }
1168
1637
 
1169
1638
  function shouldRequireControlPlanePreflight(
1170
- providerType: "claude_code" | "codex" | "http" | "shell",
1639
+ providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1171
1640
  workItemCount: number
1172
1641
  ) {
1173
1642
  if (workItemCount < 1) {
1174
1643
  return false;
1175
1644
  }
1176
- return providerType === "codex" || providerType === "claude_code";
1645
+ return (
1646
+ providerType === "codex" ||
1647
+ providerType === "claude_code" ||
1648
+ providerType === "cursor" ||
1649
+ providerType === "opencode"
1650
+ );
1177
1651
  }
1178
1652
 
1179
1653
  function resolveControlPlanePreflightEnabled() {
1180
- const value = String(process.env.BOPOHQ_COMMUNICATION_PREFLIGHT ?? "")
1654
+ const value = String(resolveControlPlaneProcessEnv("COMMUNICATION_PREFLIGHT") ?? "")
1181
1655
  .trim()
1182
1656
  .toLowerCase();
1183
1657
  return value === "1" || value === "true";
1184
1658
  }
1185
1659
 
1186
1660
  function resolveControlPlanePreflightTimeoutMs() {
1187
- const parsed = Number(process.env.BOPOHQ_COMMUNICATION_PREFLIGHT_TIMEOUT_MS ?? "1500");
1661
+ const parsed = Number(resolveControlPlaneProcessEnv("COMMUNICATION_PREFLIGHT_TIMEOUT_MS") ?? "1500");
1188
1662
  if (!Number.isFinite(parsed) || parsed < 200) {
1189
1663
  return 1500;
1190
1664
  }
@@ -1193,39 +1667,27 @@ function resolveControlPlanePreflightTimeoutMs() {
1193
1667
 
1194
1668
  async function runControlPlaneConnectivityPreflight(input: {
1195
1669
  apiBaseUrl: string;
1196
- requestHeadersJson: string;
1670
+ runtimeEnv: Record<string, string>;
1197
1671
  timeoutMs: number;
1198
1672
  }) {
1199
1673
  const normalizedApiBaseUrl = normalizeControlPlaneApiBaseUrl(input.apiBaseUrl);
1200
1674
  if (!normalizedApiBaseUrl) {
1201
1675
  return {
1202
1676
  ok: false as const,
1203
- message: `Invalid BOPOHQ_API_BASE_URL '${input.apiBaseUrl || "<empty>"}'.`,
1677
+ message: `Invalid BOPODEV_API_BASE_URL '${input.apiBaseUrl || "<empty>"}'.`,
1204
1678
  endpoint: null
1205
1679
  };
1206
1680
  }
1207
1681
 
1208
- let requestHeaders: Record<string, string> = {};
1209
- try {
1210
- const parsed = JSON.parse(input.requestHeadersJson) as Record<string, unknown>;
1211
- requestHeaders = Object.fromEntries(
1212
- Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string")
1213
- );
1214
- } catch {
1682
+ const headerResolution = resolveControlPlaneHeaders(input.runtimeEnv);
1683
+ if (!headerResolution.ok) {
1215
1684
  return {
1216
1685
  ok: false as const,
1217
- message: "Invalid BOPOHQ_REQUEST_HEADERS_JSON; expected JSON object of string headers.",
1218
- endpoint: `${normalizedApiBaseUrl}/agents`
1219
- };
1220
- }
1221
-
1222
- if (!hasText(requestHeaders["x-company-id"])) {
1223
- return {
1224
- ok: false as const,
1225
- message: "Missing x-company-id in BOPOHQ_REQUEST_HEADERS_JSON.",
1686
+ message: headerResolution.message,
1226
1687
  endpoint: `${normalizedApiBaseUrl}/agents`
1227
1688
  };
1228
1689
  }
1690
+ const requestHeaders = headerResolution.headers;
1229
1691
 
1230
1692
  const controller = new AbortController();
1231
1693
  const timeout = setTimeout(() => controller.abort(), input.timeoutMs);
@@ -1259,6 +1721,50 @@ async function runControlPlaneConnectivityPreflight(input: {
1259
1721
  }
1260
1722
  }
1261
1723
 
1724
+ function resolveControlPlaneHeaders(runtimeEnv: Record<string, string>):
1725
+ | { ok: true; headers: Record<string, string> }
1726
+ | { ok: false; message: string } {
1727
+ const directHeaderResult = ControlPlaneRequestHeadersSchema.safeParse({
1728
+ "x-company-id": resolveControlPlaneEnv(runtimeEnv, "COMPANY_ID"),
1729
+ "x-actor-type": resolveControlPlaneEnv(runtimeEnv, "ACTOR_TYPE"),
1730
+ "x-actor-id": resolveControlPlaneEnv(runtimeEnv, "ACTOR_ID"),
1731
+ "x-actor-companies": resolveControlPlaneEnv(runtimeEnv, "ACTOR_COMPANIES"),
1732
+ "x-actor-permissions": resolveControlPlaneEnv(runtimeEnv, "ACTOR_PERMISSIONS")
1733
+ });
1734
+ if (directHeaderResult.success) {
1735
+ return { ok: true, headers: directHeaderResult.data };
1736
+ }
1737
+
1738
+ const jsonHeadersRaw = resolveControlPlaneEnv(runtimeEnv, "REQUEST_HEADERS_JSON");
1739
+ if (!hasText(jsonHeadersRaw)) {
1740
+ return {
1741
+ ok: false,
1742
+ message:
1743
+ "Missing control-plane actor headers. Provide BOPODEV_ACTOR_* vars or BOPODEV_REQUEST_HEADERS_JSON."
1744
+ };
1745
+ }
1746
+
1747
+ let parsedJson: unknown;
1748
+ try {
1749
+ parsedJson = JSON.parse(jsonHeadersRaw as string);
1750
+ } catch {
1751
+ return {
1752
+ ok: false,
1753
+ message: "Invalid BOPODEV_REQUEST_HEADERS_JSON; expected JSON object of string headers."
1754
+ };
1755
+ }
1756
+ const jsonHeadersResult = ControlPlaneHeadersJsonSchema.safeParse(parsedJson);
1757
+ if (!jsonHeadersResult.success) {
1758
+ return {
1759
+ ok: false,
1760
+ message: `Invalid BOPODEV_REQUEST_HEADERS_JSON fields: ${jsonHeadersResult.error.issues
1761
+ .map((issue) => issue.path.join("."))
1762
+ .join(", ")}`
1763
+ };
1764
+ }
1765
+ return { ok: true, headers: jsonHeadersResult.data };
1766
+ }
1767
+
1262
1768
  function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
1263
1769
  const normalizedNow = truncateToMinute(now);
1264
1770
  if (!matchesCronExpression(cronExpression, normalizedNow)) {