bopodev-api 0.1.12 → 0.1.14
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 +6 -4
- package/src/app.ts +2 -0
- package/src/lib/agent-config.ts +36 -1
- package/src/lib/instance-paths.ts +12 -0
- package/src/lib/opencode-model.ts +11 -0
- package/src/lib/workspace-policy.ts +5 -0
- package/src/realtime/heartbeat-runs.ts +78 -0
- package/src/realtime/hub.ts +37 -1
- package/src/realtime/office-space.ts +10 -1
- package/src/routes/agents.ts +111 -2
- package/src/routes/companies.ts +4 -0
- package/src/routes/governance.ts +9 -2
- package/src/routes/heartbeats.ts +2 -1
- package/src/routes/issues.ts +321 -0
- package/src/routes/observability.ts +595 -18
- package/src/routes/plugins.ts +257 -0
- package/src/scripts/onboard-seed.ts +60 -12
- package/src/server.ts +62 -3
- package/src/services/governance-service.ts +106 -23
- package/src/services/heartbeat-service.ts +750 -49
- package/src/services/memory-file-service.ts +249 -0
- package/src/services/model-pricing.ts +217 -0
- package/src/services/plugin-manifest-loader.ts +65 -0
- package/src/services/plugin-runtime.ts +580 -0
- package/src/services/plugin-webhook-executor.ts +94 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
2
3
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
3
4
|
import { nanoid } from "nanoid";
|
|
4
5
|
import { resolveAdapter } from "bopodev-agent-sdk";
|
|
@@ -11,16 +12,41 @@ import {
|
|
|
11
12
|
type ExecutionOutcome
|
|
12
13
|
} from "bopodev-contracts";
|
|
13
14
|
import type { BopoDb } from "bopodev-db";
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
agents,
|
|
17
|
+
appendActivity,
|
|
18
|
+
appendHeartbeatRunMessages,
|
|
19
|
+
companies,
|
|
20
|
+
goals,
|
|
21
|
+
heartbeatRuns,
|
|
22
|
+
issueAttachments,
|
|
23
|
+
issues,
|
|
24
|
+
projects
|
|
25
|
+
} from "bopodev-db";
|
|
15
26
|
import { appendAuditEvent, appendCost } from "bopodev-db";
|
|
16
27
|
import { parseRuntimeConfigFromAgentRow } from "../lib/agent-config";
|
|
28
|
+
import { resolveProjectWorkspacePath } from "../lib/instance-paths";
|
|
17
29
|
import { getProjectWorkspaceMap, hasText, resolveAgentFallbackWorkspace } from "../lib/workspace-policy";
|
|
18
30
|
import type { RealtimeHub } from "../realtime/hub";
|
|
31
|
+
import { createHeartbeatRunsRealtimeEvent } from "../realtime/heartbeat-runs";
|
|
19
32
|
import { publishOfficeOccupantForAgent } from "../realtime/office-space";
|
|
20
33
|
import { checkAgentBudget } from "./budget-service";
|
|
34
|
+
import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "./memory-file-service";
|
|
35
|
+
import { calculateModelPricedUsdCost } from "./model-pricing";
|
|
36
|
+
import { runPluginHook } from "./plugin-runtime";
|
|
21
37
|
|
|
22
38
|
type HeartbeatRunTrigger = "manual" | "scheduler";
|
|
23
39
|
type HeartbeatRunMode = "default" | "resume" | "redo";
|
|
40
|
+
type HeartbeatProviderType =
|
|
41
|
+
| "claude_code"
|
|
42
|
+
| "codex"
|
|
43
|
+
| "cursor"
|
|
44
|
+
| "opencode"
|
|
45
|
+
| "gemini_cli"
|
|
46
|
+
| "openai_api"
|
|
47
|
+
| "anthropic_api"
|
|
48
|
+
| "http"
|
|
49
|
+
| "shell";
|
|
24
50
|
|
|
25
51
|
type ActiveHeartbeatRun = {
|
|
26
52
|
companyId: string;
|
|
@@ -87,7 +113,7 @@ export async function stopHeartbeatRun(
|
|
|
87
113
|
db: BopoDb,
|
|
88
114
|
companyId: string,
|
|
89
115
|
runId: string,
|
|
90
|
-
options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger }
|
|
116
|
+
options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger; realtimeHub?: RealtimeHub }
|
|
91
117
|
) {
|
|
92
118
|
const runTrigger = options?.trigger ?? "manual";
|
|
93
119
|
const [run] = await db
|
|
@@ -114,14 +140,22 @@ export async function stopHeartbeatRun(
|
|
|
114
140
|
active.cancelRequestedBy = options?.actorId ?? null;
|
|
115
141
|
active.abortController.abort(cancelReason);
|
|
116
142
|
} else {
|
|
143
|
+
const finishedAt = new Date();
|
|
117
144
|
await db
|
|
118
145
|
.update(heartbeatRuns)
|
|
119
146
|
.set({
|
|
120
147
|
status: "failed",
|
|
121
|
-
finishedAt
|
|
148
|
+
finishedAt,
|
|
122
149
|
message: "Heartbeat cancelled by stop request."
|
|
123
150
|
})
|
|
124
151
|
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
152
|
+
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
153
|
+
companyId,
|
|
154
|
+
runId,
|
|
155
|
+
status: "failed",
|
|
156
|
+
message: "Heartbeat cancelled by stop request.",
|
|
157
|
+
finishedAt
|
|
158
|
+
});
|
|
125
159
|
}
|
|
126
160
|
await appendAuditEvent(db, {
|
|
127
161
|
companyId,
|
|
@@ -224,14 +258,22 @@ export async function runHeartbeatForAgent(
|
|
|
224
258
|
});
|
|
225
259
|
if (!claimed) {
|
|
226
260
|
const skippedRunId = nanoid(14);
|
|
261
|
+
const skippedAt = new Date();
|
|
227
262
|
await db.insert(heartbeatRuns).values({
|
|
228
263
|
id: skippedRunId,
|
|
229
264
|
companyId,
|
|
230
265
|
agentId,
|
|
231
266
|
status: "skipped",
|
|
232
|
-
finishedAt:
|
|
267
|
+
finishedAt: skippedAt,
|
|
233
268
|
message: "Heartbeat skipped: another run is already in progress for this agent."
|
|
234
269
|
});
|
|
270
|
+
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
271
|
+
companyId,
|
|
272
|
+
runId: skippedRunId,
|
|
273
|
+
status: "skipped",
|
|
274
|
+
message: "Heartbeat skipped: another run is already in progress for this agent.",
|
|
275
|
+
finishedAt: skippedAt
|
|
276
|
+
});
|
|
235
277
|
await appendAuditEvent(db, {
|
|
236
278
|
companyId,
|
|
237
279
|
actorType: "system",
|
|
@@ -251,6 +293,12 @@ export async function runHeartbeatForAgent(
|
|
|
251
293
|
status: "skipped",
|
|
252
294
|
message: "Heartbeat skipped due to budget hard-stop."
|
|
253
295
|
});
|
|
296
|
+
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
297
|
+
companyId,
|
|
298
|
+
runId,
|
|
299
|
+
status: "skipped",
|
|
300
|
+
message: "Heartbeat skipped due to budget hard-stop."
|
|
301
|
+
});
|
|
254
302
|
}
|
|
255
303
|
|
|
256
304
|
if (budgetCheck.allowed) {
|
|
@@ -269,6 +317,12 @@ export async function runHeartbeatForAgent(
|
|
|
269
317
|
sourceRunId: options?.sourceRunId ?? null
|
|
270
318
|
}
|
|
271
319
|
});
|
|
320
|
+
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
321
|
+
companyId,
|
|
322
|
+
runId,
|
|
323
|
+
status: "started",
|
|
324
|
+
message: "Heartbeat started."
|
|
325
|
+
});
|
|
272
326
|
}
|
|
273
327
|
|
|
274
328
|
if (!budgetCheck.allowed) {
|
|
@@ -318,14 +372,153 @@ export async function runHeartbeatForAgent(
|
|
|
318
372
|
let executionSummary = "";
|
|
319
373
|
let executionTrace: unknown = null;
|
|
320
374
|
let executionOutcome: ExecutionOutcome | null = null;
|
|
375
|
+
let memoryContext: HeartbeatContext["memoryContext"] | undefined;
|
|
321
376
|
let stateParseError: string | null = null;
|
|
322
377
|
let runtimeLaunchSummary: ReturnType<typeof summarizeRuntimeLaunch> | null = null;
|
|
378
|
+
let primaryIssueId: string | null = null;
|
|
379
|
+
let primaryProjectId: string | null = null;
|
|
380
|
+
let transcriptSequence = 0;
|
|
381
|
+
let transcriptWriteQueue = Promise.resolve();
|
|
382
|
+
let transcriptLiveCount = 0;
|
|
383
|
+
let transcriptLiveUsefulCount = 0;
|
|
384
|
+
let transcriptLiveHighSignalCount = 0;
|
|
385
|
+
let transcriptPersistFailureReported = false;
|
|
386
|
+
let pluginFailureSummary: string[] = [];
|
|
387
|
+
|
|
388
|
+
const enqueueTranscriptEvent = (event: {
|
|
389
|
+
kind: string;
|
|
390
|
+
label?: string;
|
|
391
|
+
text?: string;
|
|
392
|
+
payload?: string;
|
|
393
|
+
signalLevel?: "high" | "medium" | "low" | "noise";
|
|
394
|
+
groupKey?: string;
|
|
395
|
+
source?: "stdout" | "stderr" | "trace_fallback";
|
|
396
|
+
}) => {
|
|
397
|
+
const sequence = transcriptSequence++;
|
|
398
|
+
const createdAt = new Date();
|
|
399
|
+
const messageId = nanoid(14);
|
|
400
|
+
const signalLevel = normalizeTranscriptSignalLevel(event.signalLevel, event.kind);
|
|
401
|
+
const groupKey = event.groupKey ?? defaultTranscriptGroupKey(event.kind, event.label);
|
|
402
|
+
const source = event.source ?? "stdout";
|
|
403
|
+
transcriptLiveCount += 1;
|
|
404
|
+
if (isUsefulTranscriptSignal(signalLevel)) {
|
|
405
|
+
transcriptLiveUsefulCount += 1;
|
|
406
|
+
}
|
|
407
|
+
if (signalLevel === "high") {
|
|
408
|
+
transcriptLiveHighSignalCount += 1;
|
|
409
|
+
}
|
|
410
|
+
transcriptWriteQueue = transcriptWriteQueue
|
|
411
|
+
.then(async () => {
|
|
412
|
+
await appendHeartbeatRunMessages(db, {
|
|
413
|
+
companyId,
|
|
414
|
+
runId,
|
|
415
|
+
messages: [
|
|
416
|
+
{
|
|
417
|
+
id: messageId,
|
|
418
|
+
sequence,
|
|
419
|
+
kind: event.kind,
|
|
420
|
+
label: event.label ?? null,
|
|
421
|
+
text: event.text ?? null,
|
|
422
|
+
payloadJson: event.payload ?? null,
|
|
423
|
+
signalLevel,
|
|
424
|
+
groupKey,
|
|
425
|
+
source,
|
|
426
|
+
createdAt
|
|
427
|
+
}
|
|
428
|
+
]
|
|
429
|
+
});
|
|
430
|
+
options?.realtimeHub?.publish(
|
|
431
|
+
createHeartbeatRunsRealtimeEvent(companyId, {
|
|
432
|
+
type: "run.transcript.append",
|
|
433
|
+
runId,
|
|
434
|
+
messages: [
|
|
435
|
+
{
|
|
436
|
+
id: messageId,
|
|
437
|
+
runId,
|
|
438
|
+
sequence,
|
|
439
|
+
kind: normalizeTranscriptKind(event.kind),
|
|
440
|
+
label: event.label ?? null,
|
|
441
|
+
text: event.text ?? null,
|
|
442
|
+
payload: event.payload ?? null,
|
|
443
|
+
signalLevel,
|
|
444
|
+
groupKey,
|
|
445
|
+
source,
|
|
446
|
+
createdAt: createdAt.toISOString()
|
|
447
|
+
}
|
|
448
|
+
]
|
|
449
|
+
})
|
|
450
|
+
);
|
|
451
|
+
})
|
|
452
|
+
.catch(async (error) => {
|
|
453
|
+
if (transcriptPersistFailureReported) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
transcriptPersistFailureReported = true;
|
|
457
|
+
try {
|
|
458
|
+
await appendAuditEvent(db, {
|
|
459
|
+
companyId,
|
|
460
|
+
actorType: "system",
|
|
461
|
+
eventType: "heartbeat.transcript_persist_failed",
|
|
462
|
+
entityType: "heartbeat_run",
|
|
463
|
+
entityId: runId,
|
|
464
|
+
correlationId: options?.requestId ?? runId,
|
|
465
|
+
payload: {
|
|
466
|
+
agentId,
|
|
467
|
+
sequence,
|
|
468
|
+
messageId,
|
|
469
|
+
error: String(error)
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
} catch {
|
|
473
|
+
// Best effort: keep run execution resilient even when observability insert fails.
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
};
|
|
477
|
+
const emitCanonicalResultEvent = (text: string, label: "completed" | "failed") => {
|
|
478
|
+
const trimmed = text.trim();
|
|
479
|
+
if (!trimmed) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
enqueueTranscriptEvent({
|
|
483
|
+
kind: "result",
|
|
484
|
+
label,
|
|
485
|
+
text: trimmed,
|
|
486
|
+
signalLevel: "high",
|
|
487
|
+
groupKey: "result",
|
|
488
|
+
source: "trace_fallback"
|
|
489
|
+
});
|
|
490
|
+
};
|
|
323
491
|
|
|
324
492
|
try {
|
|
493
|
+
await runPluginHook(db, {
|
|
494
|
+
hook: "beforeClaim",
|
|
495
|
+
context: {
|
|
496
|
+
companyId,
|
|
497
|
+
agentId,
|
|
498
|
+
runId,
|
|
499
|
+
requestId: options?.requestId,
|
|
500
|
+
providerType: agent.providerType
|
|
501
|
+
},
|
|
502
|
+
failClosed: false
|
|
503
|
+
});
|
|
325
504
|
const workItems = await claimIssuesForAgent(db, companyId, agentId, runId);
|
|
326
505
|
issueIds = workItems.map((item) => item.id);
|
|
506
|
+
primaryIssueId = workItems[0]?.id ?? null;
|
|
507
|
+
primaryProjectId = workItems[0]?.project_id ?? null;
|
|
508
|
+
await runPluginHook(db, {
|
|
509
|
+
hook: "afterClaim",
|
|
510
|
+
context: {
|
|
511
|
+
companyId,
|
|
512
|
+
agentId,
|
|
513
|
+
runId,
|
|
514
|
+
requestId: options?.requestId,
|
|
515
|
+
providerType: agent.providerType,
|
|
516
|
+
workItemCount: workItems.length
|
|
517
|
+
},
|
|
518
|
+
failClosed: false
|
|
519
|
+
});
|
|
327
520
|
await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
|
|
328
|
-
const adapter = resolveAdapter(agent.providerType as
|
|
521
|
+
const adapter = resolveAdapter(agent.providerType as HeartbeatProviderType);
|
|
329
522
|
const parsedState = parseAgentState(agent.stateBlob);
|
|
330
523
|
state = parsedState.state;
|
|
331
524
|
stateParseError = parsedState.parseError;
|
|
@@ -347,6 +540,25 @@ export async function runHeartbeatForAgent(
|
|
|
347
540
|
...persistedRuntime.runtimeEnv,
|
|
348
541
|
...heartbeatRuntimeEnv
|
|
349
542
|
},
|
|
543
|
+
onTranscriptEvent: (event: {
|
|
544
|
+
kind: string;
|
|
545
|
+
label?: string;
|
|
546
|
+
text?: string;
|
|
547
|
+
payload?: string;
|
|
548
|
+
signalLevel?: "high" | "medium" | "low" | "noise";
|
|
549
|
+
groupKey?: string;
|
|
550
|
+
source?: "stdout" | "stderr" | "trace_fallback";
|
|
551
|
+
}) => {
|
|
552
|
+
enqueueTranscriptEvent({
|
|
553
|
+
kind: event.kind,
|
|
554
|
+
label: event.label,
|
|
555
|
+
text: event.text,
|
|
556
|
+
payload: event.payload,
|
|
557
|
+
signalLevel: event.signalLevel,
|
|
558
|
+
groupKey: event.groupKey,
|
|
559
|
+
source: event.source
|
|
560
|
+
});
|
|
561
|
+
},
|
|
350
562
|
model: persistedRuntime.runtimeModel,
|
|
351
563
|
thinkingEffort: persistedRuntime.runtimeThinkingEffort,
|
|
352
564
|
bootstrapPrompt: persistedRuntime.bootstrapPrompt,
|
|
@@ -365,15 +577,20 @@ export async function runHeartbeatForAgent(
|
|
|
365
577
|
...state,
|
|
366
578
|
runtime: workspaceResolution.runtime
|
|
367
579
|
};
|
|
580
|
+
memoryContext = await loadAgentMemoryContext({
|
|
581
|
+
companyId,
|
|
582
|
+
agentId
|
|
583
|
+
});
|
|
368
584
|
|
|
369
|
-
|
|
585
|
+
let context = await buildHeartbeatContext(db, companyId, {
|
|
370
586
|
agentId,
|
|
371
587
|
agentName: agent.name,
|
|
372
588
|
agentRole: agent.role,
|
|
373
589
|
managerAgentId: agent.managerAgentId,
|
|
374
|
-
providerType: agent.providerType as
|
|
590
|
+
providerType: agent.providerType as HeartbeatProviderType,
|
|
375
591
|
heartbeatRunId: runId,
|
|
376
592
|
state,
|
|
593
|
+
memoryContext,
|
|
377
594
|
runtime: workspaceResolution.runtime,
|
|
378
595
|
workItems
|
|
379
596
|
});
|
|
@@ -435,7 +652,7 @@ export async function runHeartbeatForAgent(
|
|
|
435
652
|
if (
|
|
436
653
|
resolveControlPlanePreflightEnabled() &&
|
|
437
654
|
shouldRequireControlPlanePreflight(
|
|
438
|
-
agent.providerType as
|
|
655
|
+
agent.providerType as HeartbeatProviderType,
|
|
439
656
|
workItems.length
|
|
440
657
|
)
|
|
441
658
|
) {
|
|
@@ -463,7 +680,7 @@ export async function runHeartbeatForAgent(
|
|
|
463
680
|
}
|
|
464
681
|
|
|
465
682
|
runtimeLaunchSummary = summarizeRuntimeLaunch(
|
|
466
|
-
agent.providerType as
|
|
683
|
+
agent.providerType as HeartbeatProviderType,
|
|
467
684
|
workspaceResolution.runtime
|
|
468
685
|
);
|
|
469
686
|
await appendAuditEvent(db, {
|
|
@@ -508,6 +725,40 @@ export async function runHeartbeatForAgent(
|
|
|
508
725
|
abortController: activeRunAbort
|
|
509
726
|
});
|
|
510
727
|
|
|
728
|
+
const beforeAdapterHook = await runPluginHook(db, {
|
|
729
|
+
hook: "beforeAdapterExecute",
|
|
730
|
+
context: {
|
|
731
|
+
companyId,
|
|
732
|
+
agentId,
|
|
733
|
+
runId,
|
|
734
|
+
requestId: options?.requestId,
|
|
735
|
+
providerType: agent.providerType,
|
|
736
|
+
workItemCount: workItems.length,
|
|
737
|
+
runtime: {
|
|
738
|
+
command: workspaceResolution.runtime.command,
|
|
739
|
+
cwd: workspaceResolution.runtime.cwd
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
failClosed: true
|
|
743
|
+
});
|
|
744
|
+
if (beforeAdapterHook.blocked) {
|
|
745
|
+
pluginFailureSummary = beforeAdapterHook.failures;
|
|
746
|
+
throw new Error(`Plugin policy blocked adapter execution: ${beforeAdapterHook.failures.join(" | ")}`);
|
|
747
|
+
}
|
|
748
|
+
if (beforeAdapterHook.promptAppend && beforeAdapterHook.promptAppend.trim().length > 0) {
|
|
749
|
+
const existingPrompt = context.runtime?.bootstrapPrompt ?? "";
|
|
750
|
+
const nextPrompt = existingPrompt.trim().length > 0
|
|
751
|
+
? `${existingPrompt}\n\n${beforeAdapterHook.promptAppend}`
|
|
752
|
+
: beforeAdapterHook.promptAppend;
|
|
753
|
+
context = {
|
|
754
|
+
...context,
|
|
755
|
+
runtime: {
|
|
756
|
+
...(context.runtime ?? {}),
|
|
757
|
+
bootstrapPrompt: nextPrompt
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
511
762
|
const execution = await executeAdapterWithWatchdog({
|
|
512
763
|
execute: (abortSignal) =>
|
|
513
764
|
adapter.execute({
|
|
@@ -517,31 +768,103 @@ export async function runHeartbeatForAgent(
|
|
|
517
768
|
abortSignal
|
|
518
769
|
}
|
|
519
770
|
}),
|
|
520
|
-
providerType: agent.providerType as
|
|
771
|
+
providerType: agent.providerType as HeartbeatProviderType,
|
|
521
772
|
runtime: workspaceResolution.runtime,
|
|
522
773
|
externalAbortSignal: activeRunAbort.signal
|
|
523
774
|
});
|
|
524
775
|
executionSummary = execution.summary;
|
|
776
|
+
const afterAdapterHook = await runPluginHook(db, {
|
|
777
|
+
hook: "afterAdapterExecute",
|
|
778
|
+
context: {
|
|
779
|
+
companyId,
|
|
780
|
+
agentId,
|
|
781
|
+
runId,
|
|
782
|
+
requestId: options?.requestId,
|
|
783
|
+
providerType: agent.providerType,
|
|
784
|
+
status: execution.status,
|
|
785
|
+
summary: execution.summary,
|
|
786
|
+
trace: execution.trace ?? null,
|
|
787
|
+
outcome: execution.outcome ?? null
|
|
788
|
+
},
|
|
789
|
+
failClosed: false
|
|
790
|
+
});
|
|
791
|
+
if (afterAdapterHook.failures.length > 0) {
|
|
792
|
+
pluginFailureSummary = [...pluginFailureSummary, ...afterAdapterHook.failures];
|
|
793
|
+
}
|
|
794
|
+
emitCanonicalResultEvent(executionSummary, "completed");
|
|
525
795
|
executionTrace = execution.trace ?? null;
|
|
796
|
+
const runtimeModelId = resolveRuntimeModelId({
|
|
797
|
+
runtimeModel: persistedRuntime.runtimeModel,
|
|
798
|
+
stateBlob: agent.stateBlob
|
|
799
|
+
});
|
|
800
|
+
const effectivePricingProviderType = execution.pricingProviderType ?? agent.providerType;
|
|
801
|
+
const effectivePricingModelId = execution.pricingModelId ?? runtimeModelId;
|
|
802
|
+
const costDecision = await appendFinishedRunCostEntry({
|
|
803
|
+
db,
|
|
804
|
+
companyId,
|
|
805
|
+
providerType: agent.providerType,
|
|
806
|
+
runtimeModelId: effectivePricingModelId ?? runtimeModelId,
|
|
807
|
+
pricingProviderType: effectivePricingProviderType,
|
|
808
|
+
pricingModelId: effectivePricingModelId,
|
|
809
|
+
tokenInput: execution.tokenInput,
|
|
810
|
+
tokenOutput: execution.tokenOutput,
|
|
811
|
+
issueId: primaryIssueId,
|
|
812
|
+
projectId: primaryProjectId,
|
|
813
|
+
agentId,
|
|
814
|
+
status: execution.status
|
|
815
|
+
});
|
|
816
|
+
const executionUsdCost = costDecision.usdCost;
|
|
526
817
|
const parsedOutcome = ExecutionOutcomeSchema.safeParse(execution.outcome);
|
|
527
818
|
executionOutcome = parsedOutcome.success ? parsedOutcome.data : null;
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
819
|
+
const persistedMemory = await persistHeartbeatMemory({
|
|
820
|
+
companyId,
|
|
821
|
+
agentId,
|
|
822
|
+
runId,
|
|
823
|
+
status: execution.status,
|
|
824
|
+
summary: execution.summary,
|
|
825
|
+
outcomeKind: executionOutcome?.kind ?? null
|
|
826
|
+
});
|
|
827
|
+
await appendAuditEvent(db, {
|
|
828
|
+
companyId,
|
|
829
|
+
actorType: "system",
|
|
830
|
+
eventType: "heartbeat.memory_updated",
|
|
831
|
+
entityType: "heartbeat_run",
|
|
832
|
+
entityId: runId,
|
|
833
|
+
correlationId: options?.requestId ?? runId,
|
|
834
|
+
payload: {
|
|
835
|
+
agentId,
|
|
836
|
+
memoryRoot: persistedMemory.memoryRoot,
|
|
837
|
+
dailyNotePath: persistedMemory.dailyNotePath,
|
|
838
|
+
candidateFacts: persistedMemory.candidateFacts
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
if (execution.status === "ok") {
|
|
842
|
+
for (const fact of persistedMemory.candidateFacts) {
|
|
843
|
+
const targetFile = await appendDurableFact({
|
|
844
|
+
companyId,
|
|
845
|
+
agentId,
|
|
846
|
+
fact,
|
|
847
|
+
sourceRunId: runId
|
|
848
|
+
});
|
|
849
|
+
await appendAuditEvent(db, {
|
|
850
|
+
companyId,
|
|
851
|
+
actorType: "system",
|
|
852
|
+
eventType: "heartbeat.memory_fact_promoted",
|
|
853
|
+
entityType: "heartbeat_run",
|
|
854
|
+
entityId: runId,
|
|
855
|
+
correlationId: options?.requestId ?? runId,
|
|
856
|
+
payload: {
|
|
857
|
+
agentId,
|
|
858
|
+
fact,
|
|
859
|
+
targetFile
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
}
|
|
540
863
|
}
|
|
541
864
|
|
|
542
865
|
if (
|
|
543
866
|
execution.nextState ||
|
|
544
|
-
|
|
867
|
+
executionUsdCost > 0 ||
|
|
545
868
|
execution.tokenInput > 0 ||
|
|
546
869
|
execution.tokenOutput > 0 ||
|
|
547
870
|
execution.status !== "skipped"
|
|
@@ -550,7 +873,8 @@ export async function runHeartbeatForAgent(
|
|
|
550
873
|
.update(agents)
|
|
551
874
|
.set({
|
|
552
875
|
stateBlob: JSON.stringify(execution.nextState ?? state),
|
|
553
|
-
|
|
876
|
+
runtimeModel: effectivePricingModelId ?? persistedRuntime.runtimeModel ?? null,
|
|
877
|
+
usedBudgetUsd: sql`${agents.usedBudgetUsd} + ${executionUsdCost}`,
|
|
554
878
|
tokenUsage: sql`${agents.tokenUsage} + ${execution.tokenInput + execution.tokenOutput}`,
|
|
555
879
|
updatedAt: new Date()
|
|
556
880
|
})
|
|
@@ -561,7 +885,7 @@ export async function runHeartbeatForAgent(
|
|
|
561
885
|
summary: execution.summary,
|
|
562
886
|
tokenInput: execution.tokenInput,
|
|
563
887
|
tokenOutput: execution.tokenOutput,
|
|
564
|
-
usdCost:
|
|
888
|
+
usdCost: executionUsdCost,
|
|
565
889
|
trace: executionTrace,
|
|
566
890
|
outcome: executionOutcome
|
|
567
891
|
});
|
|
@@ -607,12 +931,29 @@ export async function runHeartbeatForAgent(
|
|
|
607
931
|
usage: {
|
|
608
932
|
tokenInput: execution.tokenInput,
|
|
609
933
|
tokenOutput: execution.tokenOutput,
|
|
610
|
-
usdCost:
|
|
934
|
+
usdCost: executionUsdCost
|
|
611
935
|
}
|
|
612
936
|
}
|
|
613
937
|
});
|
|
614
938
|
}
|
|
615
939
|
|
|
940
|
+
const beforePersistHook = await runPluginHook(db, {
|
|
941
|
+
hook: "beforePersist",
|
|
942
|
+
context: {
|
|
943
|
+
companyId,
|
|
944
|
+
agentId,
|
|
945
|
+
runId,
|
|
946
|
+
requestId: options?.requestId,
|
|
947
|
+
providerType: agent.providerType,
|
|
948
|
+
status: execution.status,
|
|
949
|
+
summary: execution.summary
|
|
950
|
+
},
|
|
951
|
+
failClosed: false
|
|
952
|
+
});
|
|
953
|
+
if (beforePersistHook.failures.length > 0) {
|
|
954
|
+
pluginFailureSummary = [...pluginFailureSummary, ...beforePersistHook.failures];
|
|
955
|
+
}
|
|
956
|
+
|
|
616
957
|
await db
|
|
617
958
|
.update(heartbeatRuns)
|
|
618
959
|
.set({
|
|
@@ -621,6 +962,91 @@ export async function runHeartbeatForAgent(
|
|
|
621
962
|
message: execution.summary
|
|
622
963
|
})
|
|
623
964
|
.where(eq(heartbeatRuns.id, runId));
|
|
965
|
+
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
966
|
+
companyId,
|
|
967
|
+
runId,
|
|
968
|
+
status: execution.status === "failed" ? "failed" : "completed",
|
|
969
|
+
message: execution.summary,
|
|
970
|
+
finishedAt: new Date()
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
const fallbackMessages = normalizeTraceTranscript(executionTrace);
|
|
974
|
+
const fallbackHighSignalCount = fallbackMessages.filter((message) => message.signalLevel === "high").length;
|
|
975
|
+
const shouldAppendFallback =
|
|
976
|
+
fallbackMessages.length > 0 &&
|
|
977
|
+
(transcriptLiveCount === 0 ||
|
|
978
|
+
transcriptLiveUsefulCount < 2 ||
|
|
979
|
+
transcriptLiveHighSignalCount < 1 ||
|
|
980
|
+
(transcriptLiveHighSignalCount < 2 && fallbackHighSignalCount > transcriptLiveHighSignalCount));
|
|
981
|
+
if (shouldAppendFallback) {
|
|
982
|
+
const createdAt = new Date();
|
|
983
|
+
const rows: Array<{
|
|
984
|
+
id: string;
|
|
985
|
+
sequence: number;
|
|
986
|
+
kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
|
|
987
|
+
label: string | null;
|
|
988
|
+
text: string | null;
|
|
989
|
+
payloadJson: string | null;
|
|
990
|
+
signalLevel: "high" | "medium" | "low" | "noise";
|
|
991
|
+
groupKey: string | null;
|
|
992
|
+
source: "trace_fallback";
|
|
993
|
+
createdAt: Date;
|
|
994
|
+
}> = fallbackMessages.map((message) => ({
|
|
995
|
+
id: nanoid(14),
|
|
996
|
+
sequence: transcriptSequence++,
|
|
997
|
+
kind: message.kind,
|
|
998
|
+
label: message.label ?? null,
|
|
999
|
+
text: message.text ?? null,
|
|
1000
|
+
payloadJson: message.payload ?? null,
|
|
1001
|
+
signalLevel: message.signalLevel,
|
|
1002
|
+
groupKey: message.groupKey ?? null,
|
|
1003
|
+
source: "trace_fallback",
|
|
1004
|
+
createdAt
|
|
1005
|
+
}));
|
|
1006
|
+
await appendHeartbeatRunMessages(db, {
|
|
1007
|
+
companyId,
|
|
1008
|
+
runId,
|
|
1009
|
+
messages: rows
|
|
1010
|
+
});
|
|
1011
|
+
options?.realtimeHub?.publish(
|
|
1012
|
+
createHeartbeatRunsRealtimeEvent(companyId, {
|
|
1013
|
+
type: "run.transcript.append",
|
|
1014
|
+
runId,
|
|
1015
|
+
messages: rows.map((row) => ({
|
|
1016
|
+
id: row.id,
|
|
1017
|
+
runId,
|
|
1018
|
+
sequence: row.sequence,
|
|
1019
|
+
kind: normalizeTranscriptKind(row.kind),
|
|
1020
|
+
label: row.label,
|
|
1021
|
+
text: row.text,
|
|
1022
|
+
payload: row.payloadJson,
|
|
1023
|
+
signalLevel: row.signalLevel ?? undefined,
|
|
1024
|
+
groupKey: row.groupKey ?? undefined,
|
|
1025
|
+
source: row.source ?? undefined,
|
|
1026
|
+
createdAt: row.createdAt.toISOString()
|
|
1027
|
+
}))
|
|
1028
|
+
})
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const afterPersistHook = await runPluginHook(db, {
|
|
1033
|
+
hook: "afterPersist",
|
|
1034
|
+
context: {
|
|
1035
|
+
companyId,
|
|
1036
|
+
agentId,
|
|
1037
|
+
runId,
|
|
1038
|
+
requestId: options?.requestId,
|
|
1039
|
+
providerType: agent.providerType,
|
|
1040
|
+
status: execution.status,
|
|
1041
|
+
summary: execution.summary,
|
|
1042
|
+
trace: executionTrace,
|
|
1043
|
+
outcome: executionOutcome
|
|
1044
|
+
},
|
|
1045
|
+
failClosed: false
|
|
1046
|
+
});
|
|
1047
|
+
if (afterPersistHook.failures.length > 0) {
|
|
1048
|
+
pluginFailureSummary = [...pluginFailureSummary, ...afterPersistHook.failures];
|
|
1049
|
+
}
|
|
624
1050
|
|
|
625
1051
|
await appendAuditEvent(db, {
|
|
626
1052
|
companyId,
|
|
@@ -645,7 +1071,8 @@ export async function runHeartbeatForAgent(
|
|
|
645
1071
|
diagnostics: {
|
|
646
1072
|
stateParseError,
|
|
647
1073
|
requestId: options?.requestId,
|
|
648
|
-
trigger: runTrigger
|
|
1074
|
+
trigger: runTrigger,
|
|
1075
|
+
pluginFailures: pluginFailureSummary
|
|
649
1076
|
}
|
|
650
1077
|
}
|
|
651
1078
|
});
|
|
@@ -655,6 +1082,23 @@ export async function runHeartbeatForAgent(
|
|
|
655
1082
|
classified.type === "cancelled"
|
|
656
1083
|
? "Heartbeat cancelled by stop request."
|
|
657
1084
|
: `Heartbeat failed (${classified.type}): ${classified.message}`;
|
|
1085
|
+
emitCanonicalResultEvent(executionSummary, "failed");
|
|
1086
|
+
const pluginErrorHook = await runPluginHook(db, {
|
|
1087
|
+
hook: "onError",
|
|
1088
|
+
context: {
|
|
1089
|
+
companyId,
|
|
1090
|
+
agentId,
|
|
1091
|
+
runId,
|
|
1092
|
+
requestId: options?.requestId,
|
|
1093
|
+
providerType: agent.providerType,
|
|
1094
|
+
error: String(error),
|
|
1095
|
+
summary: executionSummary
|
|
1096
|
+
},
|
|
1097
|
+
failClosed: false
|
|
1098
|
+
});
|
|
1099
|
+
if (pluginErrorHook.failures.length > 0) {
|
|
1100
|
+
pluginFailureSummary = [...pluginFailureSummary, ...pluginErrorHook.failures];
|
|
1101
|
+
}
|
|
658
1102
|
if (!executionTrace && classified.type === "cancelled") {
|
|
659
1103
|
executionTrace = {
|
|
660
1104
|
command: runtimeLaunchSummary?.command ?? null,
|
|
@@ -680,6 +1124,24 @@ export async function runHeartbeatForAgent(
|
|
|
680
1124
|
cwd: runtimeLaunchSummary.cwd ?? null
|
|
681
1125
|
};
|
|
682
1126
|
}
|
|
1127
|
+
const runtimeModelId = resolveRuntimeModelId({
|
|
1128
|
+
runtimeModel: persistedRuntime.runtimeModel,
|
|
1129
|
+
stateBlob: agent.stateBlob
|
|
1130
|
+
});
|
|
1131
|
+
await appendFinishedRunCostEntry({
|
|
1132
|
+
db,
|
|
1133
|
+
companyId,
|
|
1134
|
+
providerType: agent.providerType,
|
|
1135
|
+
runtimeModelId,
|
|
1136
|
+
pricingProviderType: agent.providerType,
|
|
1137
|
+
pricingModelId: runtimeModelId,
|
|
1138
|
+
tokenInput: 0,
|
|
1139
|
+
tokenOutput: 0,
|
|
1140
|
+
issueId: primaryIssueId,
|
|
1141
|
+
projectId: primaryProjectId,
|
|
1142
|
+
agentId,
|
|
1143
|
+
status: "failed"
|
|
1144
|
+
});
|
|
683
1145
|
await db
|
|
684
1146
|
.update(heartbeatRuns)
|
|
685
1147
|
.set({
|
|
@@ -688,6 +1150,13 @@ export async function runHeartbeatForAgent(
|
|
|
688
1150
|
message: executionSummary
|
|
689
1151
|
})
|
|
690
1152
|
.where(eq(heartbeatRuns.id, runId));
|
|
1153
|
+
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
1154
|
+
companyId,
|
|
1155
|
+
runId,
|
|
1156
|
+
status: "failed",
|
|
1157
|
+
message: executionSummary,
|
|
1158
|
+
finishedAt: new Date()
|
|
1159
|
+
});
|
|
691
1160
|
await appendAuditEvent(db, {
|
|
692
1161
|
companyId,
|
|
693
1162
|
actorType: "system",
|
|
@@ -710,7 +1179,8 @@ export async function runHeartbeatForAgent(
|
|
|
710
1179
|
diagnostics: {
|
|
711
1180
|
stateParseError,
|
|
712
1181
|
requestId: options?.requestId,
|
|
713
|
-
trigger: runTrigger
|
|
1182
|
+
trigger: runTrigger,
|
|
1183
|
+
pluginFailures: pluginFailureSummary
|
|
714
1184
|
}
|
|
715
1185
|
}
|
|
716
1186
|
});
|
|
@@ -731,6 +1201,7 @@ export async function runHeartbeatForAgent(
|
|
|
731
1201
|
});
|
|
732
1202
|
}
|
|
733
1203
|
} finally {
|
|
1204
|
+
await transcriptWriteQueue;
|
|
734
1205
|
unregisterActiveHeartbeatRun(runId);
|
|
735
1206
|
try {
|
|
736
1207
|
await releaseClaimedIssues(db, companyId, issueIds);
|
|
@@ -899,9 +1370,10 @@ async function buildHeartbeatContext(
|
|
|
899
1370
|
agentName: string;
|
|
900
1371
|
agentRole: string;
|
|
901
1372
|
managerAgentId: string | null;
|
|
902
|
-
providerType:
|
|
1373
|
+
providerType: HeartbeatProviderType;
|
|
903
1374
|
heartbeatRunId: string;
|
|
904
1375
|
state: AgentState;
|
|
1376
|
+
memoryContext?: HeartbeatContext["memoryContext"];
|
|
905
1377
|
runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
|
|
906
1378
|
workItems: Array<{
|
|
907
1379
|
id: string;
|
|
@@ -929,6 +1401,48 @@ async function buildHeartbeatContext(
|
|
|
929
1401
|
.where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)))
|
|
930
1402
|
: [];
|
|
931
1403
|
const projectNameById = new Map(projectRows.map((row) => [row.id, row.name]));
|
|
1404
|
+
const projectWorkspaceMap = await getProjectWorkspaceMap(db, companyId, projectIds);
|
|
1405
|
+
const issueIds = input.workItems.map((item) => item.id);
|
|
1406
|
+
const attachmentRows =
|
|
1407
|
+
issueIds.length > 0
|
|
1408
|
+
? await db
|
|
1409
|
+
.select({
|
|
1410
|
+
id: issueAttachments.id,
|
|
1411
|
+
issueId: issueAttachments.issueId,
|
|
1412
|
+
projectId: issueAttachments.projectId,
|
|
1413
|
+
fileName: issueAttachments.fileName,
|
|
1414
|
+
mimeType: issueAttachments.mimeType,
|
|
1415
|
+
fileSizeBytes: issueAttachments.fileSizeBytes,
|
|
1416
|
+
relativePath: issueAttachments.relativePath
|
|
1417
|
+
})
|
|
1418
|
+
.from(issueAttachments)
|
|
1419
|
+
.where(and(eq(issueAttachments.companyId, companyId), inArray(issueAttachments.issueId, issueIds)))
|
|
1420
|
+
: [];
|
|
1421
|
+
const attachmentsByIssue = new Map<
|
|
1422
|
+
string,
|
|
1423
|
+
Array<{
|
|
1424
|
+
id: string;
|
|
1425
|
+
fileName: string;
|
|
1426
|
+
mimeType: string | null;
|
|
1427
|
+
fileSizeBytes: number;
|
|
1428
|
+
relativePath: string;
|
|
1429
|
+
absolutePath: string;
|
|
1430
|
+
}>
|
|
1431
|
+
>();
|
|
1432
|
+
for (const row of attachmentRows) {
|
|
1433
|
+
const projectWorkspace = projectWorkspaceMap.get(row.projectId) ?? resolveProjectWorkspacePath(companyId, row.projectId);
|
|
1434
|
+
const absolutePath = resolve(projectWorkspace, row.relativePath);
|
|
1435
|
+
const existing = attachmentsByIssue.get(row.issueId) ?? [];
|
|
1436
|
+
existing.push({
|
|
1437
|
+
id: row.id,
|
|
1438
|
+
fileName: row.fileName,
|
|
1439
|
+
mimeType: row.mimeType,
|
|
1440
|
+
fileSizeBytes: row.fileSizeBytes,
|
|
1441
|
+
relativePath: row.relativePath,
|
|
1442
|
+
absolutePath
|
|
1443
|
+
});
|
|
1444
|
+
attachmentsByIssue.set(row.issueId, existing);
|
|
1445
|
+
}
|
|
932
1446
|
const goalRows = await db
|
|
933
1447
|
.select({
|
|
934
1448
|
id: goals.id,
|
|
@@ -968,6 +1482,7 @@ async function buildHeartbeatContext(
|
|
|
968
1482
|
managerAgentId: input.managerAgentId
|
|
969
1483
|
},
|
|
970
1484
|
state: input.state,
|
|
1485
|
+
memoryContext: input.memoryContext,
|
|
971
1486
|
runtime: input.runtime,
|
|
972
1487
|
goalContext: {
|
|
973
1488
|
companyGoals: activeCompanyGoals,
|
|
@@ -983,7 +1498,8 @@ async function buildHeartbeatContext(
|
|
|
983
1498
|
status: item.status,
|
|
984
1499
|
priority: item.priority,
|
|
985
1500
|
labels: parseStringArray(item.labels_json),
|
|
986
|
-
tags: parseStringArray(item.tags_json)
|
|
1501
|
+
tags: parseStringArray(item.tags_json),
|
|
1502
|
+
attachments: attachmentsByIssue.get(item.id) ?? []
|
|
987
1503
|
}))
|
|
988
1504
|
};
|
|
989
1505
|
}
|
|
@@ -1116,6 +1632,131 @@ function readTraceString(trace: unknown, key: string) {
|
|
|
1116
1632
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
1117
1633
|
}
|
|
1118
1634
|
|
|
1635
|
+
function normalizeTraceTranscript(trace: unknown) {
|
|
1636
|
+
type NormalizedTranscriptMessage = {
|
|
1637
|
+
kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
|
|
1638
|
+
label: string | undefined;
|
|
1639
|
+
text: string | undefined;
|
|
1640
|
+
payload: string | undefined;
|
|
1641
|
+
signalLevel: "high" | "medium" | "low" | "noise";
|
|
1642
|
+
groupKey: string | undefined;
|
|
1643
|
+
};
|
|
1644
|
+
if (!trace || typeof trace !== "object") {
|
|
1645
|
+
return [] as NormalizedTranscriptMessage[];
|
|
1646
|
+
}
|
|
1647
|
+
const transcript = (trace as Record<string, unknown>).transcript;
|
|
1648
|
+
if (!Array.isArray(transcript)) {
|
|
1649
|
+
return [];
|
|
1650
|
+
}
|
|
1651
|
+
const normalized: NormalizedTranscriptMessage[] = [];
|
|
1652
|
+
for (const entry of transcript) {
|
|
1653
|
+
if (!entry || typeof entry !== "object") {
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
const record = entry as Record<string, unknown>;
|
|
1657
|
+
const kind = normalizeTranscriptKind(String(record.kind ?? "system"));
|
|
1658
|
+
const label = typeof record.label === "string" ? record.label : undefined;
|
|
1659
|
+
normalized.push({
|
|
1660
|
+
kind,
|
|
1661
|
+
label: typeof record.label === "string" ? record.label : undefined,
|
|
1662
|
+
text: typeof record.text === "string" ? record.text : undefined,
|
|
1663
|
+
payload: typeof record.payload === "string" ? record.payload : undefined,
|
|
1664
|
+
signalLevel: normalizeTranscriptSignalLevel(
|
|
1665
|
+
typeof record.signalLevel === "string" ? (record.signalLevel as "high" | "medium" | "low" | "noise") : undefined,
|
|
1666
|
+
kind
|
|
1667
|
+
),
|
|
1668
|
+
groupKey:
|
|
1669
|
+
typeof record.groupKey === "string" && record.groupKey.trim().length > 0
|
|
1670
|
+
? record.groupKey
|
|
1671
|
+
: defaultTranscriptGroupKey(kind, label)
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
return normalized;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function normalizeTranscriptKind(
|
|
1678
|
+
value: string
|
|
1679
|
+
): "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr" {
|
|
1680
|
+
const normalized = value.trim().toLowerCase();
|
|
1681
|
+
if (
|
|
1682
|
+
normalized === "system" ||
|
|
1683
|
+
normalized === "assistant" ||
|
|
1684
|
+
normalized === "thinking" ||
|
|
1685
|
+
normalized === "tool_call" ||
|
|
1686
|
+
normalized === "tool_result" ||
|
|
1687
|
+
normalized === "result" ||
|
|
1688
|
+
normalized === "stderr"
|
|
1689
|
+
) {
|
|
1690
|
+
return normalized;
|
|
1691
|
+
}
|
|
1692
|
+
return "system";
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function defaultTranscriptGroupKey(kind: string, label?: string) {
|
|
1696
|
+
if (kind === "tool_call" || kind === "tool_result") {
|
|
1697
|
+
return `tool:${(label ?? "unknown").trim().toLowerCase()}`;
|
|
1698
|
+
}
|
|
1699
|
+
if (kind === "result") {
|
|
1700
|
+
return "result";
|
|
1701
|
+
}
|
|
1702
|
+
if (kind === "assistant") {
|
|
1703
|
+
return "assistant";
|
|
1704
|
+
}
|
|
1705
|
+
if (kind === "stderr") {
|
|
1706
|
+
return "stderr";
|
|
1707
|
+
}
|
|
1708
|
+
return "system";
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function normalizeTranscriptSignalLevel(
|
|
1712
|
+
value: "high" | "medium" | "low" | "noise" | undefined,
|
|
1713
|
+
kind: string
|
|
1714
|
+
): "high" | "medium" | "low" | "noise" {
|
|
1715
|
+
if (value === "high" || value === "medium" || value === "low" || value === "noise") {
|
|
1716
|
+
return value;
|
|
1717
|
+
}
|
|
1718
|
+
if (kind === "tool_call" || kind === "tool_result" || kind === "result") {
|
|
1719
|
+
return "high";
|
|
1720
|
+
}
|
|
1721
|
+
if (kind === "assistant") {
|
|
1722
|
+
return "medium";
|
|
1723
|
+
}
|
|
1724
|
+
if (kind === "stderr") {
|
|
1725
|
+
return "low";
|
|
1726
|
+
}
|
|
1727
|
+
return "noise";
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function isUsefulTranscriptSignal(level: "high" | "medium" | "low" | "noise") {
|
|
1731
|
+
return level === "high" || level === "medium";
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function publishHeartbeatRunStatus(
|
|
1735
|
+
realtimeHub: RealtimeHub | undefined,
|
|
1736
|
+
input: {
|
|
1737
|
+
companyId: string;
|
|
1738
|
+
runId: string;
|
|
1739
|
+
status: "started" | "completed" | "failed" | "skipped";
|
|
1740
|
+
message?: string | null;
|
|
1741
|
+
startedAt?: Date;
|
|
1742
|
+
finishedAt?: Date;
|
|
1743
|
+
}
|
|
1744
|
+
) {
|
|
1745
|
+
if (!realtimeHub) {
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
realtimeHub.publish(
|
|
1749
|
+
createHeartbeatRunsRealtimeEvent(input.companyId, {
|
|
1750
|
+
type: "run.status.updated",
|
|
1751
|
+
runId: input.runId,
|
|
1752
|
+
status: input.status,
|
|
1753
|
+
message: input.message ?? null,
|
|
1754
|
+
startedAt: input.startedAt?.toISOString(),
|
|
1755
|
+
finishedAt: input.finishedAt?.toISOString() ?? null
|
|
1756
|
+
})
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1119
1760
|
async function resolveRuntimeWorkspaceForWorkItems(
|
|
1120
1761
|
db: BopoDb,
|
|
1121
1762
|
companyId: string,
|
|
@@ -1226,7 +1867,7 @@ function resolveEffectiveStaleRunThresholdMs(input: {
|
|
|
1226
1867
|
|
|
1227
1868
|
async function executeAdapterWithWatchdog<T>(input: {
|
|
1228
1869
|
execute: (abortSignal: AbortSignal) => Promise<T>;
|
|
1229
|
-
providerType:
|
|
1870
|
+
providerType: HeartbeatProviderType;
|
|
1230
1871
|
externalAbortSignal?: AbortSignal;
|
|
1231
1872
|
runtime:
|
|
1232
1873
|
| {
|
|
@@ -1314,7 +1955,7 @@ class AdapterExecutionCancelledError extends Error {
|
|
|
1314
1955
|
}
|
|
1315
1956
|
|
|
1316
1957
|
function resolveAdapterWatchdogTimeoutMs(
|
|
1317
|
-
providerType:
|
|
1958
|
+
providerType: HeartbeatProviderType,
|
|
1318
1959
|
runtime:
|
|
1319
1960
|
| {
|
|
1320
1961
|
timeoutMs?: number;
|
|
@@ -1331,7 +1972,7 @@ function resolveAdapterWatchdogTimeoutMs(
|
|
|
1331
1972
|
}
|
|
1332
1973
|
|
|
1333
1974
|
function estimateProviderExecutionBudgetMs(
|
|
1334
|
-
providerType:
|
|
1975
|
+
providerType: HeartbeatProviderType,
|
|
1335
1976
|
runtime:
|
|
1336
1977
|
| {
|
|
1337
1978
|
timeoutMs?: number;
|
|
@@ -1351,32 +1992,26 @@ function estimateProviderExecutionBudgetMs(
|
|
|
1351
1992
|
}
|
|
1352
1993
|
|
|
1353
1994
|
function resolveRuntimeAttemptTimeoutMs(
|
|
1354
|
-
providerType:
|
|
1995
|
+
providerType: HeartbeatProviderType,
|
|
1355
1996
|
configuredTimeoutMs: number | undefined
|
|
1356
1997
|
) {
|
|
1357
1998
|
if (Number.isFinite(configuredTimeoutMs) && (configuredTimeoutMs ?? 0) > 0) {
|
|
1358
1999
|
return Math.floor(configuredTimeoutMs ?? 0);
|
|
1359
2000
|
}
|
|
1360
|
-
if (providerType === "claude_code") {
|
|
1361
|
-
return
|
|
1362
|
-
}
|
|
1363
|
-
if (providerType === "codex") {
|
|
1364
|
-
return 5 * 60 * 1000;
|
|
1365
|
-
}
|
|
1366
|
-
if (providerType === "cursor") {
|
|
1367
|
-
return 30_000;
|
|
2001
|
+
if (providerType === "claude_code" || providerType === "codex" || providerType === "opencode" || providerType === "cursor") {
|
|
2002
|
+
return 15 * 60 * 1000;
|
|
1368
2003
|
}
|
|
1369
|
-
return
|
|
2004
|
+
return 15 * 60 * 1000;
|
|
1370
2005
|
}
|
|
1371
2006
|
|
|
1372
2007
|
function resolveRuntimeRetryCount(
|
|
1373
|
-
providerType:
|
|
2008
|
+
providerType: HeartbeatProviderType,
|
|
1374
2009
|
configuredRetryCount: number | undefined
|
|
1375
2010
|
) {
|
|
1376
2011
|
if (Number.isFinite(configuredRetryCount)) {
|
|
1377
2012
|
return Math.max(0, Math.min(2, Math.floor(configuredRetryCount ?? 0)));
|
|
1378
2013
|
}
|
|
1379
|
-
return providerType === "codex" ? 1 : 0;
|
|
2014
|
+
return providerType === "codex" || providerType === "opencode" ? 1 : 0;
|
|
1380
2015
|
}
|
|
1381
2016
|
|
|
1382
2017
|
function mergeRuntimeForExecution(
|
|
@@ -1520,6 +2155,7 @@ function buildHeartbeatRuntimeEnv(input: {
|
|
|
1520
2155
|
BOPODEV_RUN_ID: input.heartbeatRunId,
|
|
1521
2156
|
BOPODEV_FORCE_MANAGED_CODEX_HOME: "false",
|
|
1522
2157
|
BOPODEV_API_BASE_URL: apiBaseUrl,
|
|
2158
|
+
BOPODEV_API_URL: apiBaseUrl,
|
|
1523
2159
|
BOPODEV_ACTOR_TYPE: "agent",
|
|
1524
2160
|
BOPODEV_ACTOR_ID: input.agentId,
|
|
1525
2161
|
BOPODEV_ACTOR_COMPANIES: input.companyId,
|
|
@@ -1533,7 +2169,9 @@ function buildHeartbeatRuntimeEnv(input: {
|
|
|
1533
2169
|
}
|
|
1534
2170
|
|
|
1535
2171
|
function resolveControlPlaneApiBaseUrl() {
|
|
1536
|
-
|
|
2172
|
+
// Agent runtimes must call the control-plane API directly; do not inherit
|
|
2173
|
+
// browser-facing NEXT_PUBLIC_API_URL (can point to non-runtime endpoints).
|
|
2174
|
+
const configured = resolveControlPlaneProcessEnv("API_BASE_URL");
|
|
1537
2175
|
return normalizeControlPlaneApiBaseUrl(configured) ?? "http://127.0.0.1:4020";
|
|
1538
2176
|
}
|
|
1539
2177
|
|
|
@@ -1550,7 +2188,7 @@ function resolveClaudeApiKey() {
|
|
|
1550
2188
|
}
|
|
1551
2189
|
|
|
1552
2190
|
function summarizeRuntimeLaunch(
|
|
1553
|
-
providerType:
|
|
2191
|
+
providerType: HeartbeatProviderType,
|
|
1554
2192
|
runtime:
|
|
1555
2193
|
| {
|
|
1556
2194
|
command?: string;
|
|
@@ -1636,7 +2274,7 @@ function validateControlPlaneRuntimeEnv(runtimeEnv: Record<string, string>, runI
|
|
|
1636
2274
|
}
|
|
1637
2275
|
|
|
1638
2276
|
function shouldRequireControlPlanePreflight(
|
|
1639
|
-
providerType:
|
|
2277
|
+
providerType: HeartbeatProviderType,
|
|
1640
2278
|
workItemCount: number
|
|
1641
2279
|
) {
|
|
1642
2280
|
if (workItemCount < 1) {
|
|
@@ -1646,7 +2284,8 @@ function shouldRequireControlPlanePreflight(
|
|
|
1646
2284
|
providerType === "codex" ||
|
|
1647
2285
|
providerType === "claude_code" ||
|
|
1648
2286
|
providerType === "cursor" ||
|
|
1649
|
-
providerType === "opencode"
|
|
2287
|
+
providerType === "opencode" ||
|
|
2288
|
+
providerType === "gemini_cli"
|
|
1650
2289
|
);
|
|
1651
2290
|
}
|
|
1652
2291
|
|
|
@@ -1765,6 +2404,68 @@ function resolveControlPlaneHeaders(runtimeEnv: Record<string, string>):
|
|
|
1765
2404
|
return { ok: true, headers: jsonHeadersResult.data };
|
|
1766
2405
|
}
|
|
1767
2406
|
|
|
2407
|
+
function resolveRuntimeModelId(input: { runtimeModel?: string; stateBlob?: string | null }) {
|
|
2408
|
+
const runtimeModel = input.runtimeModel?.trim();
|
|
2409
|
+
if (runtimeModel) {
|
|
2410
|
+
return runtimeModel;
|
|
2411
|
+
}
|
|
2412
|
+
if (!input.stateBlob) {
|
|
2413
|
+
return null;
|
|
2414
|
+
}
|
|
2415
|
+
try {
|
|
2416
|
+
const parsed = JSON.parse(input.stateBlob) as { runtime?: { model?: unknown } };
|
|
2417
|
+
const modelId = parsed.runtime?.model;
|
|
2418
|
+
return typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
|
|
2419
|
+
} catch {
|
|
2420
|
+
return null;
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
async function appendFinishedRunCostEntry(input: {
|
|
2425
|
+
db: BopoDb;
|
|
2426
|
+
companyId: string;
|
|
2427
|
+
providerType: string;
|
|
2428
|
+
runtimeModelId: string | null;
|
|
2429
|
+
pricingProviderType?: string | null;
|
|
2430
|
+
pricingModelId?: string | null;
|
|
2431
|
+
tokenInput: number;
|
|
2432
|
+
tokenOutput: number;
|
|
2433
|
+
issueId?: string | null;
|
|
2434
|
+
projectId?: string | null;
|
|
2435
|
+
agentId?: string | null;
|
|
2436
|
+
status: "ok" | "failed" | "skipped";
|
|
2437
|
+
}) {
|
|
2438
|
+
const pricingDecision = await calculateModelPricedUsdCost({
|
|
2439
|
+
db: input.db,
|
|
2440
|
+
companyId: input.companyId,
|
|
2441
|
+
providerType: input.providerType,
|
|
2442
|
+
pricingProviderType: input.pricingProviderType ?? input.providerType,
|
|
2443
|
+
modelId: input.pricingModelId ?? input.runtimeModelId,
|
|
2444
|
+
tokenInput: input.tokenInput,
|
|
2445
|
+
tokenOutput: input.tokenOutput
|
|
2446
|
+
});
|
|
2447
|
+
|
|
2448
|
+
const shouldPersist = input.status === "ok" || input.status === "failed";
|
|
2449
|
+
if (shouldPersist) {
|
|
2450
|
+
await appendCost(input.db, {
|
|
2451
|
+
companyId: input.companyId,
|
|
2452
|
+
providerType: input.providerType,
|
|
2453
|
+
runtimeModelId: input.runtimeModelId,
|
|
2454
|
+
pricingProviderType: pricingDecision.pricingProviderType,
|
|
2455
|
+
pricingModelId: pricingDecision.pricingModelId,
|
|
2456
|
+
pricingSource: pricingDecision.pricingSource,
|
|
2457
|
+
tokenInput: input.tokenInput,
|
|
2458
|
+
tokenOutput: input.tokenOutput,
|
|
2459
|
+
usdCost: pricingDecision.usdCost.toFixed(6),
|
|
2460
|
+
issueId: input.issueId ?? null,
|
|
2461
|
+
projectId: input.projectId ?? null,
|
|
2462
|
+
agentId: input.agentId ?? null
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
return pricingDecision;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
1768
2469
|
function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
|
|
1769
2470
|
const normalizedNow = truncateToMinute(now);
|
|
1770
2471
|
if (!matchesCronExpression(cronExpression, normalizedNow)) {
|