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.
@@ -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 { agents, appendActivity, companies, goals, heartbeatRuns, issues, projects } from "bopodev-db";
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: new Date(),
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: new Date(),
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 "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell");
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
- const context = await buildHeartbeatContext(db, companyId, {
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 "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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 "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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 "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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 "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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
- if (execution.tokenInput > 0 || execution.tokenOutput > 0 || execution.usdCost > 0) {
530
- await appendCost(db, {
531
- companyId,
532
- providerType: agent.providerType,
533
- tokenInput: execution.tokenInput,
534
- tokenOutput: execution.tokenOutput,
535
- usdCost: execution.usdCost.toFixed(6),
536
- issueId: workItems[0]?.id ?? null,
537
- projectId: workItems[0]?.project_id ?? null,
538
- agentId
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
- execution.usdCost > 0 ||
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
- usedBudgetUsd: sql`${agents.usedBudgetUsd} + ${execution.usdCost}`,
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: execution.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: execution.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: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
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: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
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: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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 90_000;
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 45_000;
2004
+ return 15 * 60 * 1000;
1370
2005
  }
1371
2006
 
1372
2007
  function resolveRuntimeRetryCount(
1373
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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
- const configured = resolveControlPlaneProcessEnv("API_BASE_URL") ?? process.env.NEXT_PUBLIC_API_URL;
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: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
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)) {