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.
- package/LICENSE +1 -1
- package/package.json +4 -4
- package/src/lib/agent-config.ts +7 -1
- package/src/middleware/request-actor.ts +25 -15
- package/src/routes/agents.ts +43 -93
- package/src/routes/heartbeats.ts +144 -1
- package/src/routes/observability.ts +24 -1
- package/src/scripts/onboard-seed.ts +23 -13
- package/src/server.ts +1 -1
- package/src/services/governance-service.ts +1 -1
- package/src/services/heartbeat-service.ts +596 -90
|
@@ -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?: {
|
|
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 >=
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
426
|
+
validationErrorCode: controlPlaneEnvValidation.validationErrorCode,
|
|
427
|
+
invalidFieldPaths: controlPlaneEnvValidation.invalidFieldPaths
|
|
311
428
|
}
|
|
312
429
|
});
|
|
313
430
|
throw new Error(
|
|
314
|
-
`Control-plane runtime env is
|
|
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(
|
|
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
|
|
324
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
648
|
+
trigger: runTrigger
|
|
485
649
|
}
|
|
486
650
|
}
|
|
487
651
|
});
|
|
488
652
|
} catch (error) {
|
|
489
653
|
const classified = classifyHeartbeatError(error);
|
|
490
|
-
executionSummary =
|
|
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:
|
|
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 (
|
|
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
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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 =
|
|
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
|
|
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
|
|
1116
|
-
hasRequestHeadersJson: hasText(env
|
|
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
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
"
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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:
|
|
1165
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
1677
|
+
message: `Invalid BOPODEV_API_BASE_URL '${input.apiBaseUrl || "<empty>"}'.`,
|
|
1204
1678
|
endpoint: null
|
|
1205
1679
|
};
|
|
1206
1680
|
}
|
|
1207
1681
|
|
|
1208
|
-
|
|
1209
|
-
|
|
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:
|
|
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)) {
|