bopodev-api 0.1.12 → 0.1.13

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,39 @@ 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 { runPluginHook } from "./plugin-runtime";
21
36
 
22
37
  type HeartbeatRunTrigger = "manual" | "scheduler";
23
38
  type HeartbeatRunMode = "default" | "resume" | "redo";
39
+ type HeartbeatProviderType =
40
+ | "claude_code"
41
+ | "codex"
42
+ | "cursor"
43
+ | "opencode"
44
+ | "openai_api"
45
+ | "anthropic_api"
46
+ | "http"
47
+ | "shell";
24
48
 
25
49
  type ActiveHeartbeatRun = {
26
50
  companyId: string;
@@ -87,7 +111,7 @@ export async function stopHeartbeatRun(
87
111
  db: BopoDb,
88
112
  companyId: string,
89
113
  runId: string,
90
- options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger }
114
+ options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger; realtimeHub?: RealtimeHub }
91
115
  ) {
92
116
  const runTrigger = options?.trigger ?? "manual";
93
117
  const [run] = await db
@@ -114,14 +138,22 @@ export async function stopHeartbeatRun(
114
138
  active.cancelRequestedBy = options?.actorId ?? null;
115
139
  active.abortController.abort(cancelReason);
116
140
  } else {
141
+ const finishedAt = new Date();
117
142
  await db
118
143
  .update(heartbeatRuns)
119
144
  .set({
120
145
  status: "failed",
121
- finishedAt: new Date(),
146
+ finishedAt,
122
147
  message: "Heartbeat cancelled by stop request."
123
148
  })
124
149
  .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
150
+ publishHeartbeatRunStatus(options?.realtimeHub, {
151
+ companyId,
152
+ runId,
153
+ status: "failed",
154
+ message: "Heartbeat cancelled by stop request.",
155
+ finishedAt
156
+ });
125
157
  }
126
158
  await appendAuditEvent(db, {
127
159
  companyId,
@@ -224,14 +256,22 @@ export async function runHeartbeatForAgent(
224
256
  });
225
257
  if (!claimed) {
226
258
  const skippedRunId = nanoid(14);
259
+ const skippedAt = new Date();
227
260
  await db.insert(heartbeatRuns).values({
228
261
  id: skippedRunId,
229
262
  companyId,
230
263
  agentId,
231
264
  status: "skipped",
232
- finishedAt: new Date(),
265
+ finishedAt: skippedAt,
233
266
  message: "Heartbeat skipped: another run is already in progress for this agent."
234
267
  });
268
+ publishHeartbeatRunStatus(options?.realtimeHub, {
269
+ companyId,
270
+ runId: skippedRunId,
271
+ status: "skipped",
272
+ message: "Heartbeat skipped: another run is already in progress for this agent.",
273
+ finishedAt: skippedAt
274
+ });
235
275
  await appendAuditEvent(db, {
236
276
  companyId,
237
277
  actorType: "system",
@@ -251,6 +291,12 @@ export async function runHeartbeatForAgent(
251
291
  status: "skipped",
252
292
  message: "Heartbeat skipped due to budget hard-stop."
253
293
  });
294
+ publishHeartbeatRunStatus(options?.realtimeHub, {
295
+ companyId,
296
+ runId,
297
+ status: "skipped",
298
+ message: "Heartbeat skipped due to budget hard-stop."
299
+ });
254
300
  }
255
301
 
256
302
  if (budgetCheck.allowed) {
@@ -269,6 +315,12 @@ export async function runHeartbeatForAgent(
269
315
  sourceRunId: options?.sourceRunId ?? null
270
316
  }
271
317
  });
318
+ publishHeartbeatRunStatus(options?.realtimeHub, {
319
+ companyId,
320
+ runId,
321
+ status: "started",
322
+ message: "Heartbeat started."
323
+ });
272
324
  }
273
325
 
274
326
  if (!budgetCheck.allowed) {
@@ -318,14 +370,149 @@ export async function runHeartbeatForAgent(
318
370
  let executionSummary = "";
319
371
  let executionTrace: unknown = null;
320
372
  let executionOutcome: ExecutionOutcome | null = null;
373
+ let memoryContext: HeartbeatContext["memoryContext"] | undefined;
321
374
  let stateParseError: string | null = null;
322
375
  let runtimeLaunchSummary: ReturnType<typeof summarizeRuntimeLaunch> | null = null;
376
+ let transcriptSequence = 0;
377
+ let transcriptWriteQueue = Promise.resolve();
378
+ let transcriptLiveCount = 0;
379
+ let transcriptLiveUsefulCount = 0;
380
+ let transcriptLiveHighSignalCount = 0;
381
+ let transcriptPersistFailureReported = false;
382
+ let pluginFailureSummary: string[] = [];
383
+
384
+ const enqueueTranscriptEvent = (event: {
385
+ kind: string;
386
+ label?: string;
387
+ text?: string;
388
+ payload?: string;
389
+ signalLevel?: "high" | "medium" | "low" | "noise";
390
+ groupKey?: string;
391
+ source?: "stdout" | "stderr" | "trace_fallback";
392
+ }) => {
393
+ const sequence = transcriptSequence++;
394
+ const createdAt = new Date();
395
+ const messageId = nanoid(14);
396
+ const signalLevel = normalizeTranscriptSignalLevel(event.signalLevel, event.kind);
397
+ const groupKey = event.groupKey ?? defaultTranscriptGroupKey(event.kind, event.label);
398
+ const source = event.source ?? "stdout";
399
+ transcriptLiveCount += 1;
400
+ if (isUsefulTranscriptSignal(signalLevel)) {
401
+ transcriptLiveUsefulCount += 1;
402
+ }
403
+ if (signalLevel === "high") {
404
+ transcriptLiveHighSignalCount += 1;
405
+ }
406
+ transcriptWriteQueue = transcriptWriteQueue
407
+ .then(async () => {
408
+ await appendHeartbeatRunMessages(db, {
409
+ companyId,
410
+ runId,
411
+ messages: [
412
+ {
413
+ id: messageId,
414
+ sequence,
415
+ kind: event.kind,
416
+ label: event.label ?? null,
417
+ text: event.text ?? null,
418
+ payloadJson: event.payload ?? null,
419
+ signalLevel,
420
+ groupKey,
421
+ source,
422
+ createdAt
423
+ }
424
+ ]
425
+ });
426
+ options?.realtimeHub?.publish(
427
+ createHeartbeatRunsRealtimeEvent(companyId, {
428
+ type: "run.transcript.append",
429
+ runId,
430
+ messages: [
431
+ {
432
+ id: messageId,
433
+ runId,
434
+ sequence,
435
+ kind: normalizeTranscriptKind(event.kind),
436
+ label: event.label ?? null,
437
+ text: event.text ?? null,
438
+ payload: event.payload ?? null,
439
+ signalLevel,
440
+ groupKey,
441
+ source,
442
+ createdAt: createdAt.toISOString()
443
+ }
444
+ ]
445
+ })
446
+ );
447
+ })
448
+ .catch(async (error) => {
449
+ if (transcriptPersistFailureReported) {
450
+ return;
451
+ }
452
+ transcriptPersistFailureReported = true;
453
+ try {
454
+ await appendAuditEvent(db, {
455
+ companyId,
456
+ actorType: "system",
457
+ eventType: "heartbeat.transcript_persist_failed",
458
+ entityType: "heartbeat_run",
459
+ entityId: runId,
460
+ correlationId: options?.requestId ?? runId,
461
+ payload: {
462
+ agentId,
463
+ sequence,
464
+ messageId,
465
+ error: String(error)
466
+ }
467
+ });
468
+ } catch {
469
+ // Best effort: keep run execution resilient even when observability insert fails.
470
+ }
471
+ });
472
+ };
473
+ const emitCanonicalResultEvent = (text: string, label: "completed" | "failed") => {
474
+ const trimmed = text.trim();
475
+ if (!trimmed) {
476
+ return;
477
+ }
478
+ enqueueTranscriptEvent({
479
+ kind: "result",
480
+ label,
481
+ text: trimmed,
482
+ signalLevel: "high",
483
+ groupKey: "result",
484
+ source: "trace_fallback"
485
+ });
486
+ };
323
487
 
324
488
  try {
489
+ await runPluginHook(db, {
490
+ hook: "beforeClaim",
491
+ context: {
492
+ companyId,
493
+ agentId,
494
+ runId,
495
+ requestId: options?.requestId,
496
+ providerType: agent.providerType
497
+ },
498
+ failClosed: false
499
+ });
325
500
  const workItems = await claimIssuesForAgent(db, companyId, agentId, runId);
326
501
  issueIds = workItems.map((item) => item.id);
502
+ await runPluginHook(db, {
503
+ hook: "afterClaim",
504
+ context: {
505
+ companyId,
506
+ agentId,
507
+ runId,
508
+ requestId: options?.requestId,
509
+ providerType: agent.providerType,
510
+ workItemCount: workItems.length
511
+ },
512
+ failClosed: false
513
+ });
327
514
  await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
328
- const adapter = resolveAdapter(agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell");
515
+ const adapter = resolveAdapter(agent.providerType as HeartbeatProviderType);
329
516
  const parsedState = parseAgentState(agent.stateBlob);
330
517
  state = parsedState.state;
331
518
  stateParseError = parsedState.parseError;
@@ -347,6 +534,25 @@ export async function runHeartbeatForAgent(
347
534
  ...persistedRuntime.runtimeEnv,
348
535
  ...heartbeatRuntimeEnv
349
536
  },
537
+ onTranscriptEvent: (event: {
538
+ kind: string;
539
+ label?: string;
540
+ text?: string;
541
+ payload?: string;
542
+ signalLevel?: "high" | "medium" | "low" | "noise";
543
+ groupKey?: string;
544
+ source?: "stdout" | "stderr" | "trace_fallback";
545
+ }) => {
546
+ enqueueTranscriptEvent({
547
+ kind: event.kind,
548
+ label: event.label,
549
+ text: event.text,
550
+ payload: event.payload,
551
+ signalLevel: event.signalLevel,
552
+ groupKey: event.groupKey,
553
+ source: event.source
554
+ });
555
+ },
350
556
  model: persistedRuntime.runtimeModel,
351
557
  thinkingEffort: persistedRuntime.runtimeThinkingEffort,
352
558
  bootstrapPrompt: persistedRuntime.bootstrapPrompt,
@@ -365,15 +571,20 @@ export async function runHeartbeatForAgent(
365
571
  ...state,
366
572
  runtime: workspaceResolution.runtime
367
573
  };
574
+ memoryContext = await loadAgentMemoryContext({
575
+ companyId,
576
+ agentId
577
+ });
368
578
 
369
- const context = await buildHeartbeatContext(db, companyId, {
579
+ let context = await buildHeartbeatContext(db, companyId, {
370
580
  agentId,
371
581
  agentName: agent.name,
372
582
  agentRole: agent.role,
373
583
  managerAgentId: agent.managerAgentId,
374
- providerType: agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
584
+ providerType: agent.providerType as HeartbeatProviderType,
375
585
  heartbeatRunId: runId,
376
586
  state,
587
+ memoryContext,
377
588
  runtime: workspaceResolution.runtime,
378
589
  workItems
379
590
  });
@@ -435,7 +646,7 @@ export async function runHeartbeatForAgent(
435
646
  if (
436
647
  resolveControlPlanePreflightEnabled() &&
437
648
  shouldRequireControlPlanePreflight(
438
- agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
649
+ agent.providerType as HeartbeatProviderType,
439
650
  workItems.length
440
651
  )
441
652
  ) {
@@ -463,7 +674,7 @@ export async function runHeartbeatForAgent(
463
674
  }
464
675
 
465
676
  runtimeLaunchSummary = summarizeRuntimeLaunch(
466
- agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
677
+ agent.providerType as HeartbeatProviderType,
467
678
  workspaceResolution.runtime
468
679
  );
469
680
  await appendAuditEvent(db, {
@@ -508,6 +719,40 @@ export async function runHeartbeatForAgent(
508
719
  abortController: activeRunAbort
509
720
  });
510
721
 
722
+ const beforeAdapterHook = await runPluginHook(db, {
723
+ hook: "beforeAdapterExecute",
724
+ context: {
725
+ companyId,
726
+ agentId,
727
+ runId,
728
+ requestId: options?.requestId,
729
+ providerType: agent.providerType,
730
+ workItemCount: workItems.length,
731
+ runtime: {
732
+ command: workspaceResolution.runtime.command,
733
+ cwd: workspaceResolution.runtime.cwd
734
+ }
735
+ },
736
+ failClosed: true
737
+ });
738
+ if (beforeAdapterHook.blocked) {
739
+ pluginFailureSummary = beforeAdapterHook.failures;
740
+ throw new Error(`Plugin policy blocked adapter execution: ${beforeAdapterHook.failures.join(" | ")}`);
741
+ }
742
+ if (beforeAdapterHook.promptAppend && beforeAdapterHook.promptAppend.trim().length > 0) {
743
+ const existingPrompt = context.runtime?.bootstrapPrompt ?? "";
744
+ const nextPrompt = existingPrompt.trim().length > 0
745
+ ? `${existingPrompt}\n\n${beforeAdapterHook.promptAppend}`
746
+ : beforeAdapterHook.promptAppend;
747
+ context = {
748
+ ...context,
749
+ runtime: {
750
+ ...(context.runtime ?? {}),
751
+ bootstrapPrompt: nextPrompt
752
+ }
753
+ };
754
+ }
755
+
511
756
  const execution = await executeAdapterWithWatchdog({
512
757
  execute: (abortSignal) =>
513
758
  adapter.execute({
@@ -517,14 +762,78 @@ export async function runHeartbeatForAgent(
517
762
  abortSignal
518
763
  }
519
764
  }),
520
- providerType: agent.providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
765
+ providerType: agent.providerType as HeartbeatProviderType,
521
766
  runtime: workspaceResolution.runtime,
522
767
  externalAbortSignal: activeRunAbort.signal
523
768
  });
524
769
  executionSummary = execution.summary;
770
+ const afterAdapterHook = await runPluginHook(db, {
771
+ hook: "afterAdapterExecute",
772
+ context: {
773
+ companyId,
774
+ agentId,
775
+ runId,
776
+ requestId: options?.requestId,
777
+ providerType: agent.providerType,
778
+ status: execution.status,
779
+ summary: execution.summary,
780
+ trace: execution.trace ?? null,
781
+ outcome: execution.outcome ?? null
782
+ },
783
+ failClosed: false
784
+ });
785
+ if (afterAdapterHook.failures.length > 0) {
786
+ pluginFailureSummary = [...pluginFailureSummary, ...afterAdapterHook.failures];
787
+ }
788
+ emitCanonicalResultEvent(executionSummary, "completed");
525
789
  executionTrace = execution.trace ?? null;
526
790
  const parsedOutcome = ExecutionOutcomeSchema.safeParse(execution.outcome);
527
791
  executionOutcome = parsedOutcome.success ? parsedOutcome.data : null;
792
+ const persistedMemory = await persistHeartbeatMemory({
793
+ companyId,
794
+ agentId,
795
+ runId,
796
+ status: execution.status,
797
+ summary: execution.summary,
798
+ outcomeKind: executionOutcome?.kind ?? null
799
+ });
800
+ await appendAuditEvent(db, {
801
+ companyId,
802
+ actorType: "system",
803
+ eventType: "heartbeat.memory_updated",
804
+ entityType: "heartbeat_run",
805
+ entityId: runId,
806
+ correlationId: options?.requestId ?? runId,
807
+ payload: {
808
+ agentId,
809
+ memoryRoot: persistedMemory.memoryRoot,
810
+ dailyNotePath: persistedMemory.dailyNotePath,
811
+ candidateFacts: persistedMemory.candidateFacts
812
+ }
813
+ });
814
+ if (execution.status === "ok") {
815
+ for (const fact of persistedMemory.candidateFacts) {
816
+ const targetFile = await appendDurableFact({
817
+ companyId,
818
+ agentId,
819
+ fact,
820
+ sourceRunId: runId
821
+ });
822
+ await appendAuditEvent(db, {
823
+ companyId,
824
+ actorType: "system",
825
+ eventType: "heartbeat.memory_fact_promoted",
826
+ entityType: "heartbeat_run",
827
+ entityId: runId,
828
+ correlationId: options?.requestId ?? runId,
829
+ payload: {
830
+ agentId,
831
+ fact,
832
+ targetFile
833
+ }
834
+ });
835
+ }
836
+ }
528
837
 
529
838
  if (execution.tokenInput > 0 || execution.tokenOutput > 0 || execution.usdCost > 0) {
530
839
  await appendCost(db, {
@@ -613,6 +922,23 @@ export async function runHeartbeatForAgent(
613
922
  });
614
923
  }
615
924
 
925
+ const beforePersistHook = await runPluginHook(db, {
926
+ hook: "beforePersist",
927
+ context: {
928
+ companyId,
929
+ agentId,
930
+ runId,
931
+ requestId: options?.requestId,
932
+ providerType: agent.providerType,
933
+ status: execution.status,
934
+ summary: execution.summary
935
+ },
936
+ failClosed: false
937
+ });
938
+ if (beforePersistHook.failures.length > 0) {
939
+ pluginFailureSummary = [...pluginFailureSummary, ...beforePersistHook.failures];
940
+ }
941
+
616
942
  await db
617
943
  .update(heartbeatRuns)
618
944
  .set({
@@ -621,6 +947,91 @@ export async function runHeartbeatForAgent(
621
947
  message: execution.summary
622
948
  })
623
949
  .where(eq(heartbeatRuns.id, runId));
950
+ publishHeartbeatRunStatus(options?.realtimeHub, {
951
+ companyId,
952
+ runId,
953
+ status: execution.status === "failed" ? "failed" : "completed",
954
+ message: execution.summary,
955
+ finishedAt: new Date()
956
+ });
957
+
958
+ const fallbackMessages = normalizeTraceTranscript(executionTrace);
959
+ const fallbackHighSignalCount = fallbackMessages.filter((message) => message.signalLevel === "high").length;
960
+ const shouldAppendFallback =
961
+ fallbackMessages.length > 0 &&
962
+ (transcriptLiveCount === 0 ||
963
+ transcriptLiveUsefulCount < 2 ||
964
+ transcriptLiveHighSignalCount < 1 ||
965
+ (transcriptLiveHighSignalCount < 2 && fallbackHighSignalCount > transcriptLiveHighSignalCount));
966
+ if (shouldAppendFallback) {
967
+ const createdAt = new Date();
968
+ const rows: Array<{
969
+ id: string;
970
+ sequence: number;
971
+ kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
972
+ label: string | null;
973
+ text: string | null;
974
+ payloadJson: string | null;
975
+ signalLevel: "high" | "medium" | "low" | "noise";
976
+ groupKey: string | null;
977
+ source: "trace_fallback";
978
+ createdAt: Date;
979
+ }> = fallbackMessages.map((message) => ({
980
+ id: nanoid(14),
981
+ sequence: transcriptSequence++,
982
+ kind: message.kind,
983
+ label: message.label ?? null,
984
+ text: message.text ?? null,
985
+ payloadJson: message.payload ?? null,
986
+ signalLevel: message.signalLevel,
987
+ groupKey: message.groupKey ?? null,
988
+ source: "trace_fallback",
989
+ createdAt
990
+ }));
991
+ await appendHeartbeatRunMessages(db, {
992
+ companyId,
993
+ runId,
994
+ messages: rows
995
+ });
996
+ options?.realtimeHub?.publish(
997
+ createHeartbeatRunsRealtimeEvent(companyId, {
998
+ type: "run.transcript.append",
999
+ runId,
1000
+ messages: rows.map((row) => ({
1001
+ id: row.id,
1002
+ runId,
1003
+ sequence: row.sequence,
1004
+ kind: normalizeTranscriptKind(row.kind),
1005
+ label: row.label,
1006
+ text: row.text,
1007
+ payload: row.payloadJson,
1008
+ signalLevel: row.signalLevel ?? undefined,
1009
+ groupKey: row.groupKey ?? undefined,
1010
+ source: row.source ?? undefined,
1011
+ createdAt: row.createdAt.toISOString()
1012
+ }))
1013
+ })
1014
+ );
1015
+ }
1016
+
1017
+ const afterPersistHook = await runPluginHook(db, {
1018
+ hook: "afterPersist",
1019
+ context: {
1020
+ companyId,
1021
+ agentId,
1022
+ runId,
1023
+ requestId: options?.requestId,
1024
+ providerType: agent.providerType,
1025
+ status: execution.status,
1026
+ summary: execution.summary,
1027
+ trace: executionTrace,
1028
+ outcome: executionOutcome
1029
+ },
1030
+ failClosed: false
1031
+ });
1032
+ if (afterPersistHook.failures.length > 0) {
1033
+ pluginFailureSummary = [...pluginFailureSummary, ...afterPersistHook.failures];
1034
+ }
624
1035
 
625
1036
  await appendAuditEvent(db, {
626
1037
  companyId,
@@ -645,7 +1056,8 @@ export async function runHeartbeatForAgent(
645
1056
  diagnostics: {
646
1057
  stateParseError,
647
1058
  requestId: options?.requestId,
648
- trigger: runTrigger
1059
+ trigger: runTrigger,
1060
+ pluginFailures: pluginFailureSummary
649
1061
  }
650
1062
  }
651
1063
  });
@@ -655,6 +1067,23 @@ export async function runHeartbeatForAgent(
655
1067
  classified.type === "cancelled"
656
1068
  ? "Heartbeat cancelled by stop request."
657
1069
  : `Heartbeat failed (${classified.type}): ${classified.message}`;
1070
+ emitCanonicalResultEvent(executionSummary, "failed");
1071
+ const pluginErrorHook = await runPluginHook(db, {
1072
+ hook: "onError",
1073
+ context: {
1074
+ companyId,
1075
+ agentId,
1076
+ runId,
1077
+ requestId: options?.requestId,
1078
+ providerType: agent.providerType,
1079
+ error: String(error),
1080
+ summary: executionSummary
1081
+ },
1082
+ failClosed: false
1083
+ });
1084
+ if (pluginErrorHook.failures.length > 0) {
1085
+ pluginFailureSummary = [...pluginFailureSummary, ...pluginErrorHook.failures];
1086
+ }
658
1087
  if (!executionTrace && classified.type === "cancelled") {
659
1088
  executionTrace = {
660
1089
  command: runtimeLaunchSummary?.command ?? null,
@@ -688,6 +1117,13 @@ export async function runHeartbeatForAgent(
688
1117
  message: executionSummary
689
1118
  })
690
1119
  .where(eq(heartbeatRuns.id, runId));
1120
+ publishHeartbeatRunStatus(options?.realtimeHub, {
1121
+ companyId,
1122
+ runId,
1123
+ status: "failed",
1124
+ message: executionSummary,
1125
+ finishedAt: new Date()
1126
+ });
691
1127
  await appendAuditEvent(db, {
692
1128
  companyId,
693
1129
  actorType: "system",
@@ -710,7 +1146,8 @@ export async function runHeartbeatForAgent(
710
1146
  diagnostics: {
711
1147
  stateParseError,
712
1148
  requestId: options?.requestId,
713
- trigger: runTrigger
1149
+ trigger: runTrigger,
1150
+ pluginFailures: pluginFailureSummary
714
1151
  }
715
1152
  }
716
1153
  });
@@ -731,6 +1168,7 @@ export async function runHeartbeatForAgent(
731
1168
  });
732
1169
  }
733
1170
  } finally {
1171
+ await transcriptWriteQueue;
734
1172
  unregisterActiveHeartbeatRun(runId);
735
1173
  try {
736
1174
  await releaseClaimedIssues(db, companyId, issueIds);
@@ -899,9 +1337,10 @@ async function buildHeartbeatContext(
899
1337
  agentName: string;
900
1338
  agentRole: string;
901
1339
  managerAgentId: string | null;
902
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
1340
+ providerType: HeartbeatProviderType;
903
1341
  heartbeatRunId: string;
904
1342
  state: AgentState;
1343
+ memoryContext?: HeartbeatContext["memoryContext"];
905
1344
  runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
906
1345
  workItems: Array<{
907
1346
  id: string;
@@ -929,6 +1368,48 @@ async function buildHeartbeatContext(
929
1368
  .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)))
930
1369
  : [];
931
1370
  const projectNameById = new Map(projectRows.map((row) => [row.id, row.name]));
1371
+ const projectWorkspaceMap = await getProjectWorkspaceMap(db, companyId, projectIds);
1372
+ const issueIds = input.workItems.map((item) => item.id);
1373
+ const attachmentRows =
1374
+ issueIds.length > 0
1375
+ ? await db
1376
+ .select({
1377
+ id: issueAttachments.id,
1378
+ issueId: issueAttachments.issueId,
1379
+ projectId: issueAttachments.projectId,
1380
+ fileName: issueAttachments.fileName,
1381
+ mimeType: issueAttachments.mimeType,
1382
+ fileSizeBytes: issueAttachments.fileSizeBytes,
1383
+ relativePath: issueAttachments.relativePath
1384
+ })
1385
+ .from(issueAttachments)
1386
+ .where(and(eq(issueAttachments.companyId, companyId), inArray(issueAttachments.issueId, issueIds)))
1387
+ : [];
1388
+ const attachmentsByIssue = new Map<
1389
+ string,
1390
+ Array<{
1391
+ id: string;
1392
+ fileName: string;
1393
+ mimeType: string | null;
1394
+ fileSizeBytes: number;
1395
+ relativePath: string;
1396
+ absolutePath: string;
1397
+ }>
1398
+ >();
1399
+ for (const row of attachmentRows) {
1400
+ const projectWorkspace = projectWorkspaceMap.get(row.projectId) ?? resolveProjectWorkspacePath(companyId, row.projectId);
1401
+ const absolutePath = resolve(projectWorkspace, row.relativePath);
1402
+ const existing = attachmentsByIssue.get(row.issueId) ?? [];
1403
+ existing.push({
1404
+ id: row.id,
1405
+ fileName: row.fileName,
1406
+ mimeType: row.mimeType,
1407
+ fileSizeBytes: row.fileSizeBytes,
1408
+ relativePath: row.relativePath,
1409
+ absolutePath
1410
+ });
1411
+ attachmentsByIssue.set(row.issueId, existing);
1412
+ }
932
1413
  const goalRows = await db
933
1414
  .select({
934
1415
  id: goals.id,
@@ -968,6 +1449,7 @@ async function buildHeartbeatContext(
968
1449
  managerAgentId: input.managerAgentId
969
1450
  },
970
1451
  state: input.state,
1452
+ memoryContext: input.memoryContext,
971
1453
  runtime: input.runtime,
972
1454
  goalContext: {
973
1455
  companyGoals: activeCompanyGoals,
@@ -983,7 +1465,8 @@ async function buildHeartbeatContext(
983
1465
  status: item.status,
984
1466
  priority: item.priority,
985
1467
  labels: parseStringArray(item.labels_json),
986
- tags: parseStringArray(item.tags_json)
1468
+ tags: parseStringArray(item.tags_json),
1469
+ attachments: attachmentsByIssue.get(item.id) ?? []
987
1470
  }))
988
1471
  };
989
1472
  }
@@ -1116,6 +1599,131 @@ function readTraceString(trace: unknown, key: string) {
1116
1599
  return typeof value === "string" && value.trim().length > 0 ? value : null;
1117
1600
  }
1118
1601
 
1602
+ function normalizeTraceTranscript(trace: unknown) {
1603
+ type NormalizedTranscriptMessage = {
1604
+ kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
1605
+ label: string | undefined;
1606
+ text: string | undefined;
1607
+ payload: string | undefined;
1608
+ signalLevel: "high" | "medium" | "low" | "noise";
1609
+ groupKey: string | undefined;
1610
+ };
1611
+ if (!trace || typeof trace !== "object") {
1612
+ return [] as NormalizedTranscriptMessage[];
1613
+ }
1614
+ const transcript = (trace as Record<string, unknown>).transcript;
1615
+ if (!Array.isArray(transcript)) {
1616
+ return [];
1617
+ }
1618
+ const normalized: NormalizedTranscriptMessage[] = [];
1619
+ for (const entry of transcript) {
1620
+ if (!entry || typeof entry !== "object") {
1621
+ continue;
1622
+ }
1623
+ const record = entry as Record<string, unknown>;
1624
+ const kind = normalizeTranscriptKind(String(record.kind ?? "system"));
1625
+ const label = typeof record.label === "string" ? record.label : undefined;
1626
+ normalized.push({
1627
+ kind,
1628
+ label: typeof record.label === "string" ? record.label : undefined,
1629
+ text: typeof record.text === "string" ? record.text : undefined,
1630
+ payload: typeof record.payload === "string" ? record.payload : undefined,
1631
+ signalLevel: normalizeTranscriptSignalLevel(
1632
+ typeof record.signalLevel === "string" ? (record.signalLevel as "high" | "medium" | "low" | "noise") : undefined,
1633
+ kind
1634
+ ),
1635
+ groupKey:
1636
+ typeof record.groupKey === "string" && record.groupKey.trim().length > 0
1637
+ ? record.groupKey
1638
+ : defaultTranscriptGroupKey(kind, label)
1639
+ });
1640
+ }
1641
+ return normalized;
1642
+ }
1643
+
1644
+ function normalizeTranscriptKind(
1645
+ value: string
1646
+ ): "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr" {
1647
+ const normalized = value.trim().toLowerCase();
1648
+ if (
1649
+ normalized === "system" ||
1650
+ normalized === "assistant" ||
1651
+ normalized === "thinking" ||
1652
+ normalized === "tool_call" ||
1653
+ normalized === "tool_result" ||
1654
+ normalized === "result" ||
1655
+ normalized === "stderr"
1656
+ ) {
1657
+ return normalized;
1658
+ }
1659
+ return "system";
1660
+ }
1661
+
1662
+ function defaultTranscriptGroupKey(kind: string, label?: string) {
1663
+ if (kind === "tool_call" || kind === "tool_result") {
1664
+ return `tool:${(label ?? "unknown").trim().toLowerCase()}`;
1665
+ }
1666
+ if (kind === "result") {
1667
+ return "result";
1668
+ }
1669
+ if (kind === "assistant") {
1670
+ return "assistant";
1671
+ }
1672
+ if (kind === "stderr") {
1673
+ return "stderr";
1674
+ }
1675
+ return "system";
1676
+ }
1677
+
1678
+ function normalizeTranscriptSignalLevel(
1679
+ value: "high" | "medium" | "low" | "noise" | undefined,
1680
+ kind: string
1681
+ ): "high" | "medium" | "low" | "noise" {
1682
+ if (value === "high" || value === "medium" || value === "low" || value === "noise") {
1683
+ return value;
1684
+ }
1685
+ if (kind === "tool_call" || kind === "tool_result" || kind === "result") {
1686
+ return "high";
1687
+ }
1688
+ if (kind === "assistant") {
1689
+ return "medium";
1690
+ }
1691
+ if (kind === "stderr") {
1692
+ return "low";
1693
+ }
1694
+ return "noise";
1695
+ }
1696
+
1697
+ function isUsefulTranscriptSignal(level: "high" | "medium" | "low" | "noise") {
1698
+ return level === "high" || level === "medium";
1699
+ }
1700
+
1701
+ function publishHeartbeatRunStatus(
1702
+ realtimeHub: RealtimeHub | undefined,
1703
+ input: {
1704
+ companyId: string;
1705
+ runId: string;
1706
+ status: "started" | "completed" | "failed" | "skipped";
1707
+ message?: string | null;
1708
+ startedAt?: Date;
1709
+ finishedAt?: Date;
1710
+ }
1711
+ ) {
1712
+ if (!realtimeHub) {
1713
+ return;
1714
+ }
1715
+ realtimeHub.publish(
1716
+ createHeartbeatRunsRealtimeEvent(input.companyId, {
1717
+ type: "run.status.updated",
1718
+ runId: input.runId,
1719
+ status: input.status,
1720
+ message: input.message ?? null,
1721
+ startedAt: input.startedAt?.toISOString(),
1722
+ finishedAt: input.finishedAt?.toISOString() ?? null
1723
+ })
1724
+ );
1725
+ }
1726
+
1119
1727
  async function resolveRuntimeWorkspaceForWorkItems(
1120
1728
  db: BopoDb,
1121
1729
  companyId: string,
@@ -1226,7 +1834,7 @@ function resolveEffectiveStaleRunThresholdMs(input: {
1226
1834
 
1227
1835
  async function executeAdapterWithWatchdog<T>(input: {
1228
1836
  execute: (abortSignal: AbortSignal) => Promise<T>;
1229
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
1837
+ providerType: HeartbeatProviderType;
1230
1838
  externalAbortSignal?: AbortSignal;
1231
1839
  runtime:
1232
1840
  | {
@@ -1314,7 +1922,7 @@ class AdapterExecutionCancelledError extends Error {
1314
1922
  }
1315
1923
 
1316
1924
  function resolveAdapterWatchdogTimeoutMs(
1317
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1925
+ providerType: HeartbeatProviderType,
1318
1926
  runtime:
1319
1927
  | {
1320
1928
  timeoutMs?: number;
@@ -1331,7 +1939,7 @@ function resolveAdapterWatchdogTimeoutMs(
1331
1939
  }
1332
1940
 
1333
1941
  function estimateProviderExecutionBudgetMs(
1334
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1942
+ providerType: HeartbeatProviderType,
1335
1943
  runtime:
1336
1944
  | {
1337
1945
  timeoutMs?: number;
@@ -1351,32 +1959,26 @@ function estimateProviderExecutionBudgetMs(
1351
1959
  }
1352
1960
 
1353
1961
  function resolveRuntimeAttemptTimeoutMs(
1354
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1962
+ providerType: HeartbeatProviderType,
1355
1963
  configuredTimeoutMs: number | undefined
1356
1964
  ) {
1357
1965
  if (Number.isFinite(configuredTimeoutMs) && (configuredTimeoutMs ?? 0) > 0) {
1358
1966
  return Math.floor(configuredTimeoutMs ?? 0);
1359
1967
  }
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;
1968
+ if (providerType === "claude_code" || providerType === "codex" || providerType === "opencode" || providerType === "cursor") {
1969
+ return 15 * 60 * 1000;
1368
1970
  }
1369
- return 45_000;
1971
+ return 15 * 60 * 1000;
1370
1972
  }
1371
1973
 
1372
1974
  function resolveRuntimeRetryCount(
1373
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
1975
+ providerType: HeartbeatProviderType,
1374
1976
  configuredRetryCount: number | undefined
1375
1977
  ) {
1376
1978
  if (Number.isFinite(configuredRetryCount)) {
1377
1979
  return Math.max(0, Math.min(2, Math.floor(configuredRetryCount ?? 0)));
1378
1980
  }
1379
- return providerType === "codex" ? 1 : 0;
1981
+ return providerType === "codex" || providerType === "opencode" ? 1 : 0;
1380
1982
  }
1381
1983
 
1382
1984
  function mergeRuntimeForExecution(
@@ -1550,7 +2152,7 @@ function resolveClaudeApiKey() {
1550
2152
  }
1551
2153
 
1552
2154
  function summarizeRuntimeLaunch(
1553
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
2155
+ providerType: HeartbeatProviderType,
1554
2156
  runtime:
1555
2157
  | {
1556
2158
  command?: string;
@@ -1636,7 +2238,7 @@ function validateControlPlaneRuntimeEnv(runtimeEnv: Record<string, string>, runI
1636
2238
  }
1637
2239
 
1638
2240
  function shouldRequireControlPlanePreflight(
1639
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell",
2241
+ providerType: HeartbeatProviderType,
1640
2242
  workItemCount: number
1641
2243
  ) {
1642
2244
  if (workItemCount < 1) {