bopodev-api 0.1.25 → 0.1.27

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,15 +1,20 @@
1
- import { mkdir } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
3
- import { and, eq, inArray, sql } from "drizzle-orm";
1
+ import { mkdir, stat } from "node:fs/promises";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { and, desc, eq, inArray, sql } from "drizzle-orm";
4
4
  import { nanoid } from "nanoid";
5
5
  import { resolveAdapter } from "bopodev-agent-sdk";
6
- import type { AgentState, HeartbeatContext } from "bopodev-agent-sdk";
6
+ import type { AdapterExecutionResult, AgentState, HeartbeatContext } from "bopodev-agent-sdk";
7
7
  import {
8
+ type AgentFinalRunOutput,
8
9
  ControlPlaneHeadersJsonSchema,
9
10
  ControlPlaneRequestHeadersSchema,
10
11
  ControlPlaneRuntimeEnvSchema,
11
12
  ExecutionOutcomeSchema,
12
- type ExecutionOutcome
13
+ type ExecutionOutcome,
14
+ type RunArtifact,
15
+ type RunCompletionReason,
16
+ type RunCompletionReport,
17
+ type RunCostSummary
13
18
  } from "bopodev-contracts";
14
19
  import type { BopoDb } from "bopodev-db";
15
20
  import {
@@ -30,8 +35,19 @@ import {
30
35
  import { appendAuditEvent, appendCost } from "bopodev-db";
31
36
  import { parseRuntimeConfigFromAgentRow } from "../lib/agent-config";
32
37
  import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../lib/git-runtime";
33
- import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
34
- import { assertRuntimeCwdForCompany, getProjectWorkspaceContextMap, hasText, resolveAgentFallbackWorkspace } from "../lib/workspace-policy";
38
+ import {
39
+ isInsidePath,
40
+ normalizeCompanyWorkspacePath,
41
+ resolveCompanyWorkspaceRootPath,
42
+ resolveProjectWorkspacePath
43
+ } from "../lib/instance-paths";
44
+ import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
45
+ import {
46
+ assertRuntimeCwdForCompany,
47
+ getProjectWorkspaceContextMap,
48
+ hasText,
49
+ resolveAgentFallbackWorkspace
50
+ } from "../lib/workspace-policy";
35
51
  import type { RealtimeHub } from "../realtime/hub";
36
52
  import { createHeartbeatRunsRealtimeEvent } from "../realtime/heartbeat-runs";
37
53
  import { publishAttentionSnapshot } from "../realtime/attention";
@@ -73,6 +89,39 @@ type HeartbeatWakeContext = {
73
89
 
74
90
  const AGENT_COMMENT_EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
75
91
 
92
+ type RunDigestSignal = {
93
+ sequence: number;
94
+ kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
95
+ label: string | null;
96
+ text: string | null;
97
+ payload: string | null;
98
+ signalLevel: "high" | "medium" | "low" | "noise";
99
+ groupKey: string | null;
100
+ source: "stdout" | "stderr" | "trace_fallback";
101
+ };
102
+
103
+ type RunDigest = {
104
+ status: "completed" | "failed" | "skipped";
105
+ headline: string;
106
+ summary: string;
107
+ successes: string[];
108
+ failures: string[];
109
+ blockers: string[];
110
+ nextAction: string;
111
+ evidence: {
112
+ transcriptSignalCount: number;
113
+ outcomeActionCount: number;
114
+ outcomeBlockerCount: number;
115
+ failureType: string | null;
116
+ };
117
+ };
118
+
119
+ type RunTerminalPresentation = {
120
+ internalStatus: "completed" | "failed" | "skipped";
121
+ publicStatus: "completed" | "failed";
122
+ completionReason: RunCompletionReason;
123
+ };
124
+
76
125
  export async function claimIssuesForAgent(
77
126
  db: BopoDb,
78
127
  companyId: string,
@@ -334,18 +383,81 @@ export async function runHeartbeatForAgent(
334
383
  if (blockedProjectBudgetChecks.length > 0) {
335
384
  const blockedProjectIds = blockedProjectBudgetChecks.map((entry) => entry.projectId);
336
385
  const message = `Heartbeat skipped due to project budget hard-stop: ${blockedProjectIds.join(",")}.`;
386
+ const runDigest = buildRunDigest({
387
+ status: "skipped",
388
+ executionSummary: message,
389
+ outcome: null,
390
+ trace: null,
391
+ signals: []
392
+ });
393
+ const runReport = buildRunCompletionReport({
394
+ companyId,
395
+ agentName: agent.name,
396
+ providerType: agent.providerType as HeartbeatProviderType,
397
+ issueIds: [],
398
+ executionSummary: message,
399
+ outcome: null,
400
+ trace: null,
401
+ digest: runDigest,
402
+ terminal: resolveRunTerminalPresentation({
403
+ internalStatus: "skipped",
404
+ executionSummary: message,
405
+ outcome: null,
406
+ trace: null
407
+ }),
408
+ cost: buildRunCostSummary({
409
+ tokenInput: 0,
410
+ tokenOutput: 0,
411
+ usdCost: null,
412
+ usdCostStatus: "unknown",
413
+ pricingSource: null,
414
+ source: "none"
415
+ })
416
+ });
417
+ const runListMessage = buildRunListMessageFromReport(runReport);
337
418
  await db.insert(heartbeatRuns).values({
338
419
  id: runId,
339
420
  companyId,
340
421
  agentId,
341
422
  status: "skipped",
342
- message
423
+ finishedAt: new Date(),
424
+ message: runListMessage
343
425
  });
344
426
  publishHeartbeatRunStatus(options?.realtimeHub, {
345
427
  companyId,
346
428
  runId,
347
429
  status: "skipped",
348
- message
430
+ message: runListMessage,
431
+ finishedAt: new Date()
432
+ });
433
+ await appendAuditEvent(db, {
434
+ companyId,
435
+ actorType: "system",
436
+ eventType: "heartbeat.failed",
437
+ entityType: "heartbeat_run",
438
+ entityId: runId,
439
+ correlationId: options?.requestId ?? runId,
440
+ payload: {
441
+ agentId,
442
+ issueIds: [],
443
+ result: runReport.resultSummary,
444
+ message: runListMessage,
445
+ errorType: runReport.completionReason,
446
+ errorMessage: message,
447
+ report: runReport,
448
+ outcome: null,
449
+ usage: {
450
+ tokenInput: 0,
451
+ tokenOutput: 0,
452
+ usdCostStatus: "unknown",
453
+ source: "none"
454
+ },
455
+ trace: null,
456
+ diagnostics: {
457
+ requestId: options?.requestId,
458
+ trigger: runTrigger
459
+ }
460
+ }
349
461
  });
350
462
  for (const blockedProject of blockedProjectBudgetChecks) {
351
463
  const approvalId = await ensureProjectBudgetOverrideApprovalRequest(db, {
@@ -386,45 +498,156 @@ export async function runHeartbeatForAgent(
386
498
  if (!claimed) {
387
499
  const skippedRunId = nanoid(14);
388
500
  const skippedAt = new Date();
501
+ const overlapMessage = "Heartbeat skipped: another run is already in progress for this agent.";
502
+ const runDigest = buildRunDigest({
503
+ status: "skipped",
504
+ executionSummary: overlapMessage,
505
+ outcome: null,
506
+ trace: null,
507
+ signals: []
508
+ });
509
+ const runReport = buildRunCompletionReport({
510
+ companyId,
511
+ agentName: agent.name,
512
+ providerType: agent.providerType as HeartbeatProviderType,
513
+ issueIds: [],
514
+ executionSummary: overlapMessage,
515
+ outcome: null,
516
+ trace: null,
517
+ digest: runDigest,
518
+ terminal: resolveRunTerminalPresentation({
519
+ internalStatus: "skipped",
520
+ executionSummary: overlapMessage,
521
+ outcome: null,
522
+ trace: null
523
+ }),
524
+ cost: buildRunCostSummary({
525
+ tokenInput: 0,
526
+ tokenOutput: 0,
527
+ usdCost: null,
528
+ usdCostStatus: "unknown",
529
+ pricingSource: null,
530
+ source: "none"
531
+ })
532
+ });
533
+ const runListMessage = buildRunListMessageFromReport(runReport);
389
534
  await db.insert(heartbeatRuns).values({
390
535
  id: skippedRunId,
391
536
  companyId,
392
537
  agentId,
393
538
  status: "skipped",
394
539
  finishedAt: skippedAt,
395
- message: "Heartbeat skipped: another run is already in progress for this agent."
540
+ message: runListMessage
396
541
  });
397
542
  publishHeartbeatRunStatus(options?.realtimeHub, {
398
543
  companyId,
399
544
  runId: skippedRunId,
400
545
  status: "skipped",
401
- message: "Heartbeat skipped: another run is already in progress for this agent.",
546
+ message: runListMessage,
402
547
  finishedAt: skippedAt
403
548
  });
404
549
  await appendAuditEvent(db, {
405
550
  companyId,
406
551
  actorType: "system",
407
- eventType: "heartbeat.skipped_overlap",
552
+ eventType: "heartbeat.failed",
408
553
  entityType: "heartbeat_run",
409
554
  entityId: skippedRunId,
410
555
  correlationId: options?.requestId ?? skippedRunId,
411
- payload: { agentId, requestId: options?.requestId, trigger: runTrigger }
556
+ payload: {
557
+ agentId,
558
+ issueIds: [],
559
+ result: runReport.resultSummary,
560
+ message: runListMessage,
561
+ errorType: runReport.completionReason,
562
+ errorMessage: overlapMessage,
563
+ report: runReport,
564
+ outcome: null,
565
+ usage: {
566
+ tokenInput: 0,
567
+ tokenOutput: 0,
568
+ usdCostStatus: "unknown",
569
+ source: "none"
570
+ },
571
+ trace: null,
572
+ diagnostics: { requestId: options?.requestId, trigger: runTrigger }
573
+ }
412
574
  });
413
575
  return skippedRunId;
414
576
  }
415
577
  } else {
578
+ const budgetMessage = "Heartbeat skipped due to budget hard-stop.";
579
+ const runDigest = buildRunDigest({
580
+ status: "skipped",
581
+ executionSummary: budgetMessage,
582
+ outcome: null,
583
+ trace: null,
584
+ signals: []
585
+ });
586
+ const runReport = buildRunCompletionReport({
587
+ companyId,
588
+ agentName: agent.name,
589
+ providerType: agent.providerType as HeartbeatProviderType,
590
+ issueIds: [],
591
+ executionSummary: budgetMessage,
592
+ outcome: null,
593
+ trace: null,
594
+ digest: runDigest,
595
+ terminal: resolveRunTerminalPresentation({
596
+ internalStatus: "skipped",
597
+ executionSummary: budgetMessage,
598
+ outcome: null,
599
+ trace: null
600
+ }),
601
+ cost: buildRunCostSummary({
602
+ tokenInput: 0,
603
+ tokenOutput: 0,
604
+ usdCost: null,
605
+ usdCostStatus: "unknown",
606
+ pricingSource: null,
607
+ source: "none"
608
+ })
609
+ });
610
+ const runListMessage = buildRunListMessageFromReport(runReport);
416
611
  await db.insert(heartbeatRuns).values({
417
612
  id: runId,
418
613
  companyId,
419
614
  agentId,
420
615
  status: "skipped",
421
- message: "Heartbeat skipped due to budget hard-stop."
616
+ finishedAt: new Date(),
617
+ message: runListMessage
422
618
  });
423
619
  publishHeartbeatRunStatus(options?.realtimeHub, {
424
620
  companyId,
425
621
  runId,
426
622
  status: "skipped",
427
- message: "Heartbeat skipped due to budget hard-stop."
623
+ message: runListMessage,
624
+ finishedAt: new Date()
625
+ });
626
+ await appendAuditEvent(db, {
627
+ companyId,
628
+ actorType: "system",
629
+ eventType: "heartbeat.failed",
630
+ entityType: "heartbeat_run",
631
+ entityId: runId,
632
+ correlationId: options?.requestId ?? runId,
633
+ payload: {
634
+ agentId,
635
+ issueIds: [],
636
+ result: runReport.resultSummary,
637
+ message: runListMessage,
638
+ errorType: runReport.completionReason,
639
+ errorMessage: budgetMessage,
640
+ report: runReport,
641
+ outcome: null,
642
+ usage: {
643
+ tokenInput: 0,
644
+ tokenOutput: 0,
645
+ usdCostStatus: "unknown",
646
+ source: "none"
647
+ },
648
+ trace: null,
649
+ diagnostics: { requestId: options?.requestId, trigger: runTrigger }
650
+ }
428
651
  });
429
652
  }
430
653
 
@@ -518,6 +741,13 @@ export async function runHeartbeatForAgent(
518
741
  let runtimeLaunchSummary: ReturnType<typeof summarizeRuntimeLaunch> | null = null;
519
742
  let primaryIssueId: string | null = null;
520
743
  let primaryProjectId: string | null = null;
744
+ let providerUsageLimitDisposition:
745
+ | {
746
+ message: string;
747
+ notifyBoard: boolean;
748
+ pauseAgent: boolean;
749
+ }
750
+ | null = null;
521
751
  let transcriptSequence = 0;
522
752
  let transcriptWriteQueue = Promise.resolve();
523
753
  let transcriptLiveCount = 0;
@@ -526,6 +756,7 @@ export async function runHeartbeatForAgent(
526
756
  let transcriptPersistFailureReported = false;
527
757
  let pluginFailureSummary: string[] = [];
528
758
  const seenResultMessages = new Set<string>();
759
+ const runDigestSignals: RunDigestSignal[] = [];
529
760
 
530
761
  const enqueueTranscriptEvent = (event: {
531
762
  kind: string;
@@ -553,6 +784,21 @@ export async function runHeartbeatForAgent(
553
784
  if (signalLevel === "high") {
554
785
  transcriptLiveHighSignalCount += 1;
555
786
  }
787
+ if (isUsefulTranscriptSignal(signalLevel)) {
788
+ runDigestSignals.push({
789
+ sequence,
790
+ kind: normalizeTranscriptKind(event.kind),
791
+ label: event.label ?? null,
792
+ text: event.text ?? null,
793
+ payload: event.payload ?? null,
794
+ signalLevel,
795
+ groupKey: groupKey ?? null,
796
+ source
797
+ });
798
+ if (runDigestSignals.length > 200) {
799
+ runDigestSignals.splice(0, runDigestSignals.length - 200);
800
+ }
801
+ }
556
802
  transcriptWriteQueue = transcriptWriteQueue
557
803
  .then(async () => {
558
804
  await appendHeartbeatRunMessages(db, {
@@ -652,6 +898,7 @@ export async function runHeartbeatForAgent(
652
898
  failClosed: false
653
899
  });
654
900
  const isCommentOrderWake = options?.wakeContext?.reason === "issue_comment_recipient";
901
+ const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
655
902
  const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
656
903
  const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
657
904
  const contextWorkItems = resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext);
@@ -730,6 +977,7 @@ export async function runHeartbeatForAgent(
730
977
  contextWorkItems,
731
978
  mergedRuntime
732
979
  );
980
+ await mkdir(join(resolveAgentFallbackWorkspace(companyId, agent.id), "operating"), { recursive: true });
733
981
  state = {
734
982
  ...state,
735
983
  runtime: workspaceResolution.runtime
@@ -765,6 +1013,10 @@ export async function runHeartbeatForAgent(
765
1013
  ...context,
766
1014
  memoryContext
767
1015
  };
1016
+ const isIdleNoWork = contextWorkItems.length === 0 && !isCommentOrderWake;
1017
+ if (heartbeatIdlePolicy === "micro_prompt" && isIdleNoWork) {
1018
+ context = { ...context, idleMicroPrompt: true };
1019
+ }
768
1020
  if (workspaceResolution.warnings.length > 0) {
769
1021
  await appendAuditEvent(db, {
770
1022
  companyId,
@@ -930,20 +1182,48 @@ export async function runHeartbeatForAgent(
930
1182
  };
931
1183
  }
932
1184
 
933
- const execution = await executeAdapterWithWatchdog({
934
- execute: (abortSignal) =>
935
- adapter.execute({
936
- ...context,
937
- runtime: {
938
- ...(context.runtime ?? {}),
939
- abortSignal
1185
+ const execution: AdapterExecutionResult =
1186
+ heartbeatIdlePolicy === "skip_adapter" && isIdleNoWork
1187
+ ? {
1188
+ status: "ok",
1189
+ summary:
1190
+ "Idle heartbeat: no assigned work items; adapter not invoked (BOPO_HEARTBEAT_IDLE_POLICY=skip_adapter).",
1191
+ tokenInput: 0,
1192
+ tokenOutput: 0,
1193
+ usdCost: 0,
1194
+ usage: {
1195
+ inputTokens: 0,
1196
+ cachedInputTokens: 0,
1197
+ outputTokens: 0
1198
+ }
940
1199
  }
941
- }),
942
- providerType: agent.providerType as HeartbeatProviderType,
943
- runtime: workspaceResolution.runtime,
944
- externalAbortSignal: activeRunAbort.signal
945
- });
946
- executionSummary = execution.summary;
1200
+ : await executeAdapterWithWatchdog({
1201
+ execute: (abortSignal) =>
1202
+ adapter.execute({
1203
+ ...context,
1204
+ runtime: {
1205
+ ...(context.runtime ?? {}),
1206
+ abortSignal
1207
+ }
1208
+ }),
1209
+ providerType: agent.providerType as HeartbeatProviderType,
1210
+ runtime: workspaceResolution.runtime,
1211
+ externalAbortSignal: activeRunAbort.signal
1212
+ });
1213
+ const usageLimitHint = execution.dispositionHint?.kind === "provider_usage_limited" ? execution.dispositionHint : null;
1214
+ if (usageLimitHint) {
1215
+ providerUsageLimitDisposition = {
1216
+ message: usageLimitHint.message,
1217
+ notifyBoard: usageLimitHint.notifyBoard,
1218
+ pauseAgent: usageLimitHint.pauseAgent
1219
+ };
1220
+ }
1221
+ executionSummary =
1222
+ usageLimitHint?.message && usageLimitHint.message.trim().length > 0 ? usageLimitHint.message.trim() : execution.summary;
1223
+ executionSummary = sanitizeAgentSummaryCommentBody(extractNaturalRunUpdate(executionSummary));
1224
+ const persistedExecutionStatus: "ok" | "failed" | "skipped" = usageLimitHint ? "skipped" : execution.status;
1225
+ const persistedRunStatus: "completed" | "failed" | "skipped" =
1226
+ persistedExecutionStatus === "ok" ? "completed" : persistedExecutionStatus;
947
1227
  const normalizedUsage = execution.usage ?? {
948
1228
  inputTokens: Math.max(0, execution.tokenInput),
949
1229
  cachedInputTokens: 0,
@@ -972,7 +1252,6 @@ export async function runHeartbeatForAgent(
972
1252
  if (afterAdapterHook.failures.length > 0) {
973
1253
  pluginFailureSummary = [...pluginFailureSummary, ...afterAdapterHook.failures];
974
1254
  }
975
- emitCanonicalResultEvent(executionSummary, "completed");
976
1255
  executionTrace = execution.trace ?? null;
977
1256
  const runtimeModelId = resolveRuntimeModelId({
978
1257
  runtimeModel: persistedRuntime.runtimeModel,
@@ -983,6 +1262,7 @@ export async function runHeartbeatForAgent(
983
1262
  const costDecision = await appendFinishedRunCostEntry({
984
1263
  db,
985
1264
  companyId,
1265
+ runId,
986
1266
  providerType: agent.providerType,
987
1267
  runtimeModelId: effectivePricingModelId ?? runtimeModelId,
988
1268
  pricingProviderType: effectivePricingProviderType,
@@ -994,7 +1274,7 @@ export async function runHeartbeatForAgent(
994
1274
  issueId: primaryIssueId,
995
1275
  projectId: primaryProjectId,
996
1276
  agentId,
997
- status: execution.status
1277
+ status: persistedExecutionStatus
998
1278
  });
999
1279
  const executionUsdCost = costDecision.usdCost;
1000
1280
  await appendProjectBudgetUsage(db, {
@@ -1007,8 +1287,8 @@ export async function runHeartbeatForAgent(
1007
1287
  companyId,
1008
1288
  agentId,
1009
1289
  runId,
1010
- status: execution.status,
1011
- summary: execution.summary,
1290
+ status: persistedExecutionStatus === "ok" ? "ok" : "failed",
1291
+ summary: executionSummary,
1012
1292
  outcomeKind: executionOutcome?.kind ?? null,
1013
1293
  mission: context.company.mission ?? null,
1014
1294
  goalContext: {
@@ -1030,7 +1310,7 @@ export async function runHeartbeatForAgent(
1030
1310
  candidateFacts: persistedMemory.candidateFacts
1031
1311
  }
1032
1312
  });
1033
- if (execution.status === "ok") {
1313
+ if (execution.status === "ok" && !usageLimitHint) {
1034
1314
  for (const fact of persistedMemory.candidateFacts) {
1035
1315
  const targetFile = await appendDurableFact({
1036
1316
  companyId,
@@ -1054,7 +1334,7 @@ export async function runHeartbeatForAgent(
1054
1334
  }
1055
1335
  }
1056
1336
  const missionAlignment = computeMissionAlignmentSignal({
1057
- summary: execution.summary,
1337
+ summary: executionSummary,
1058
1338
  mission: context.company.mission ?? null,
1059
1339
  companyGoals: context.goalContext?.companyGoals ?? [],
1060
1340
  projectGoals: context.goalContext?.projectGoals ?? []
@@ -1079,7 +1359,7 @@ export async function runHeartbeatForAgent(
1079
1359
  executionUsdCost > 0 ||
1080
1360
  effectiveTokenInput > 0 ||
1081
1361
  effectiveTokenOutput > 0 ||
1082
- execution.status !== "skipped"
1362
+ persistedExecutionStatus !== "skipped"
1083
1363
  ) {
1084
1364
  await db
1085
1365
  .update(agents)
@@ -1157,8 +1437,8 @@ export async function runHeartbeatForAgent(
1157
1437
  runId,
1158
1438
  requestId: options?.requestId,
1159
1439
  providerType: agent.providerType,
1160
- status: execution.status,
1161
- summary: execution.summary
1440
+ status: persistedExecutionStatus,
1441
+ summary: executionSummary
1162
1442
  },
1163
1443
  failClosed: false
1164
1444
  });
@@ -1166,29 +1446,75 @@ export async function runHeartbeatForAgent(
1166
1446
  pluginFailureSummary = [...pluginFailureSummary, ...beforePersistHook.failures];
1167
1447
  }
1168
1448
 
1449
+ const runDigest = buildRunDigest({
1450
+ status: persistedRunStatus,
1451
+ executionSummary,
1452
+ outcome: executionOutcome,
1453
+ trace: executionTrace,
1454
+ signals: runDigestSignals
1455
+ });
1456
+ const terminalPresentation = resolveRunTerminalPresentation({
1457
+ internalStatus: persistedRunStatus,
1458
+ executionSummary,
1459
+ outcome: executionOutcome,
1460
+ trace: executionTrace
1461
+ });
1462
+ const runCost = buildRunCostSummary({
1463
+ tokenInput: effectiveTokenInput,
1464
+ tokenOutput: effectiveTokenOutput,
1465
+ usdCost: costDecision.usdCostStatus === "unknown" ? null : executionUsdCost,
1466
+ usdCostStatus: costDecision.usdCostStatus,
1467
+ pricingSource: costDecision.pricingSource ?? null,
1468
+ source: readTraceString(execution.trace, "usageSource") ?? "unknown"
1469
+ });
1470
+ const runReport = buildRunCompletionReport({
1471
+ companyId,
1472
+ agentName: agent.name,
1473
+ providerType: agent.providerType as HeartbeatProviderType,
1474
+ issueIds,
1475
+ executionSummary,
1476
+ outcome: executionOutcome,
1477
+ finalRunOutput: execution.finalRunOutput ?? null,
1478
+ trace: executionTrace,
1479
+ digest: runDigest,
1480
+ terminal: terminalPresentation,
1481
+ cost: runCost,
1482
+ runtimeCwd: workspaceResolution.runtime.cwd
1483
+ });
1484
+ await verifyRunArtifactsOnDisk(companyId, runReport.artifacts);
1485
+ emitCanonicalResultEvent(runReport.resultSummary, runReport.finalStatus);
1486
+ const runListMessage = buildRunListMessageFromReport(runReport);
1169
1487
  await db
1170
1488
  .update(heartbeatRuns)
1171
1489
  .set({
1172
- status: execution.status === "failed" ? "failed" : "completed",
1490
+ status: persistedRunStatus,
1173
1491
  finishedAt: new Date(),
1174
- message: execution.summary
1492
+ message: runListMessage
1175
1493
  })
1176
1494
  .where(eq(heartbeatRuns.id, runId));
1177
1495
  publishHeartbeatRunStatus(options?.realtimeHub, {
1178
1496
  companyId,
1179
1497
  runId,
1180
- status: execution.status === "failed" ? "failed" : "completed",
1181
- message: execution.summary,
1498
+ status: persistedRunStatus,
1499
+ message: runListMessage,
1182
1500
  finishedAt: new Date()
1183
1501
  });
1502
+ await appendAuditEvent(db, {
1503
+ companyId,
1504
+ actorType: "system",
1505
+ eventType: "heartbeat.run_digest",
1506
+ entityType: "heartbeat_run",
1507
+ entityId: runId,
1508
+ correlationId: options?.requestId ?? runId,
1509
+ payload: runDigest
1510
+ });
1184
1511
  try {
1185
1512
  await appendRunSummaryComments(db, {
1186
1513
  companyId,
1187
1514
  issueIds,
1188
1515
  agentId,
1189
1516
  runId,
1190
- status: execution.status === "failed" ? "failed" : "completed",
1191
- executionSummary: execution.summary
1517
+ report: runReport
1192
1518
  });
1193
1519
  } catch (commentError) {
1194
1520
  await appendAuditEvent(db, {
@@ -1209,6 +1535,7 @@ export async function runHeartbeatForAgent(
1209
1535
  const fallbackMessages = normalizeTraceTranscript(executionTrace);
1210
1536
  const fallbackHighSignalCount = fallbackMessages.filter((message) => message.signalLevel === "high").length;
1211
1537
  const shouldAppendFallback =
1538
+ !providerUsageLimitDisposition &&
1212
1539
  fallbackMessages.length > 0 &&
1213
1540
  (transcriptLiveCount === 0 ||
1214
1541
  transcriptLiveUsefulCount < 2 ||
@@ -1253,6 +1580,24 @@ export async function runHeartbeatForAgent(
1253
1580
  source: "trace_fallback",
1254
1581
  createdAt
1255
1582
  }));
1583
+ for (const row of rows) {
1584
+ if (!isUsefulTranscriptSignal(row.signalLevel)) {
1585
+ continue;
1586
+ }
1587
+ runDigestSignals.push({
1588
+ sequence: row.sequence,
1589
+ kind: row.kind,
1590
+ label: row.label,
1591
+ text: row.text,
1592
+ payload: row.payloadJson,
1593
+ signalLevel: row.signalLevel,
1594
+ groupKey: row.groupKey,
1595
+ source: "trace_fallback"
1596
+ });
1597
+ }
1598
+ if (runDigestSignals.length > 200) {
1599
+ runDigestSignals.splice(0, runDigestSignals.length - 200);
1600
+ }
1256
1601
  await appendHeartbeatRunMessages(db, {
1257
1602
  companyId,
1258
1603
  runId,
@@ -1287,8 +1632,8 @@ export async function runHeartbeatForAgent(
1287
1632
  runId,
1288
1633
  requestId: options?.requestId,
1289
1634
  providerType: agent.providerType,
1290
- status: execution.status,
1291
- summary: execution.summary,
1635
+ status: persistedExecutionStatus,
1636
+ summary: executionSummary,
1292
1637
  trace: executionTrace,
1293
1638
  outcome: executionOutcome
1294
1639
  },
@@ -1298,6 +1643,48 @@ export async function runHeartbeatForAgent(
1298
1643
  pluginFailureSummary = [...pluginFailureSummary, ...afterPersistHook.failures];
1299
1644
  }
1300
1645
 
1646
+ if (providerUsageLimitDisposition) {
1647
+ await appendAuditEvent(db, {
1648
+ companyId,
1649
+ actorType: "system",
1650
+ eventType: "heartbeat.provider_usage_limited",
1651
+ entityType: "heartbeat_run",
1652
+ entityId: runId,
1653
+ correlationId: options?.requestId ?? runId,
1654
+ payload: {
1655
+ agentId,
1656
+ providerType: agent.providerType,
1657
+ issueIds,
1658
+ message: providerUsageLimitDisposition.message
1659
+ }
1660
+ });
1661
+ const pauseResult = providerUsageLimitDisposition.pauseAgent
1662
+ ? await pauseAgentForProviderUsageLimit(db, {
1663
+ companyId,
1664
+ agentId,
1665
+ requestId: options?.requestId ?? runId,
1666
+ runId,
1667
+ providerType: agent.providerType,
1668
+ message: providerUsageLimitDisposition.message
1669
+ })
1670
+ : { paused: false };
1671
+ if (providerUsageLimitDisposition.notifyBoard) {
1672
+ await appendProviderUsageLimitBoardComments(db, {
1673
+ companyId,
1674
+ issueIds,
1675
+ agentId,
1676
+ runId,
1677
+ providerType: agent.providerType,
1678
+ message: providerUsageLimitDisposition.message,
1679
+ paused: pauseResult.paused
1680
+ });
1681
+ if (options?.realtimeHub) {
1682
+ await publishAttentionSnapshot(db, options.realtimeHub, companyId);
1683
+ }
1684
+ }
1685
+ await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
1686
+ }
1687
+
1301
1688
  await appendAuditEvent(db, {
1302
1689
  companyId,
1303
1690
  actorType: "system",
@@ -1307,14 +1694,17 @@ export async function runHeartbeatForAgent(
1307
1694
  correlationId: options?.requestId ?? runId,
1308
1695
  payload: {
1309
1696
  agentId,
1310
- result: execution.summary,
1311
- message: execution.summary,
1697
+ status: persistedRunStatus,
1698
+ result: runReport.resultSummary,
1699
+ message: runListMessage,
1700
+ report: runReport,
1312
1701
  outcome: executionOutcome,
1313
1702
  issueIds,
1314
1703
  usage: {
1315
1704
  tokenInput: effectiveTokenInput,
1316
1705
  tokenOutput: effectiveTokenOutput,
1317
1706
  usdCost: executionUsdCost,
1707
+ usdCostStatus: costDecision.usdCostStatus,
1318
1708
  source: readTraceString(execution.trace, "usageSource") ?? "unknown"
1319
1709
  },
1320
1710
  trace: execution.trace ?? null,
@@ -1408,6 +1798,7 @@ export async function runHeartbeatForAgent(
1408
1798
  const failureCostDecision = await appendFinishedRunCostEntry({
1409
1799
  db,
1410
1800
  companyId,
1801
+ runId,
1411
1802
  providerType: agent.providerType,
1412
1803
  runtimeModelId,
1413
1804
  pricingProviderType: agent.providerType,
@@ -1423,29 +1814,77 @@ export async function runHeartbeatForAgent(
1423
1814
  companyId,
1424
1815
  projectCostsUsd: buildProjectBudgetCostAllocations(executionWorkItemsForBudget, failureCostDecision.usdCost)
1425
1816
  });
1817
+ const runDigest = buildRunDigest({
1818
+ status: "failed",
1819
+ executionSummary,
1820
+ outcome: executionOutcome,
1821
+ trace: executionTrace,
1822
+ signals: runDigestSignals
1823
+ });
1824
+ const runCost = buildRunCostSummary({
1825
+ tokenInput: 0,
1826
+ tokenOutput: 0,
1827
+ usdCost: failureCostDecision.usdCostStatus === "unknown" ? null : failureCostDecision.usdCost,
1828
+ usdCostStatus: failureCostDecision.usdCostStatus,
1829
+ pricingSource: failureCostDecision.pricingSource ?? null,
1830
+ source: readTraceString(executionTrace, "usageSource") ?? "unknown"
1831
+ });
1832
+ const runReport = buildRunCompletionReport({
1833
+ companyId,
1834
+ agentName: agent.name,
1835
+ providerType: agent.providerType as HeartbeatProviderType,
1836
+ issueIds,
1837
+ executionSummary,
1838
+ outcome: executionOutcome,
1839
+ finalRunOutput: null,
1840
+ trace: executionTrace,
1841
+ digest: runDigest,
1842
+ terminal: resolveRunTerminalPresentation({
1843
+ internalStatus: "failed",
1844
+ executionSummary,
1845
+ outcome: executionOutcome,
1846
+ trace: executionTrace,
1847
+ errorType: classified.type
1848
+ }),
1849
+ cost: runCost,
1850
+ runtimeCwd: runtimeLaunchSummary?.cwd ?? persistedRuntime.runtimeCwd ?? null,
1851
+ errorType: classified.type,
1852
+ errorMessage: classified.message
1853
+ });
1854
+ await verifyRunArtifactsOnDisk(companyId, runReport.artifacts);
1855
+ const runListMessage = buildRunListMessageFromReport(runReport);
1426
1856
  await db
1427
1857
  .update(heartbeatRuns)
1428
1858
  .set({
1429
1859
  status: "failed",
1430
1860
  finishedAt: new Date(),
1431
- message: executionSummary
1861
+ message: runListMessage
1432
1862
  })
1433
1863
  .where(eq(heartbeatRuns.id, runId));
1434
1864
  publishHeartbeatRunStatus(options?.realtimeHub, {
1435
1865
  companyId,
1436
1866
  runId,
1437
1867
  status: "failed",
1438
- message: executionSummary,
1868
+ message: runListMessage,
1439
1869
  finishedAt: new Date()
1440
1870
  });
1871
+ emitCanonicalResultEvent(runReport.resultSummary, runReport.finalStatus);
1872
+ await appendAuditEvent(db, {
1873
+ companyId,
1874
+ actorType: "system",
1875
+ eventType: "heartbeat.run_digest",
1876
+ entityType: "heartbeat_run",
1877
+ entityId: runId,
1878
+ correlationId: options?.requestId ?? runId,
1879
+ payload: runDigest
1880
+ });
1441
1881
  try {
1442
1882
  await appendRunSummaryComments(db, {
1443
1883
  companyId,
1444
1884
  issueIds,
1445
1885
  agentId,
1446
1886
  runId,
1447
- status: "failed",
1448
- executionSummary
1887
+ report: runReport
1449
1888
  });
1450
1889
  } catch (commentError) {
1451
1890
  await appendAuditEvent(db, {
@@ -1472,12 +1911,17 @@ export async function runHeartbeatForAgent(
1472
1911
  payload: {
1473
1912
  agentId,
1474
1913
  issueIds,
1475
- result: executionSummary,
1476
- message: executionSummary,
1914
+ result: runReport.resultSummary,
1915
+ message: runListMessage,
1477
1916
  errorType: classified.type,
1478
1917
  errorMessage: classified.message,
1918
+ report: runReport,
1479
1919
  outcome: executionOutcome,
1480
1920
  usage: {
1921
+ tokenInput: 0,
1922
+ tokenOutput: 0,
1923
+ usdCost: failureCostDecision.usdCost,
1924
+ usdCostStatus: failureCostDecision.usdCostStatus,
1481
1925
  source: readTraceString(executionTrace, "usageSource") ?? "unknown"
1482
1926
  },
1483
1927
  trace: executionTrace,
@@ -1942,6 +2386,7 @@ async function buildHeartbeatContext(
1942
2386
  fileSizeBytes: number;
1943
2387
  relativePath: string;
1944
2388
  absolutePath: string;
2389
+ downloadPath: string;
1945
2390
  }>
1946
2391
  >();
1947
2392
  for (const row of attachmentRows) {
@@ -1957,7 +2402,8 @@ async function buildHeartbeatContext(
1957
2402
  mimeType: row.mimeType,
1958
2403
  fileSizeBytes: row.fileSizeBytes,
1959
2404
  relativePath: row.relativePath,
1960
- absolutePath
2405
+ absolutePath,
2406
+ downloadPath: `/issues/${row.issueId}/attachments/${row.id}/download`
1961
2407
  });
1962
2408
  attachmentsByIssue.set(row.issueId, existing);
1963
2409
  }
@@ -1985,12 +2431,14 @@ async function buildHeartbeatContext(
1985
2431
  .filter((goal) => goal.status === "active" && goal.level === "agent")
1986
2432
  .map((goal) => goal.title);
1987
2433
  const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
2434
+ const promptMode = resolveHeartbeatPromptMode();
1988
2435
 
1989
2436
  return {
1990
2437
  companyId,
1991
2438
  agentId: input.agentId,
1992
2439
  providerType: input.providerType,
1993
2440
  heartbeatRunId: input.heartbeatRunId,
2441
+ promptMode,
1994
2442
  company: {
1995
2443
  name: company?.name ?? "Unknown company",
1996
2444
  mission: company?.mission ?? null
@@ -2290,16 +2738,6 @@ function sanitizeAgentSummaryCommentBody(body: string) {
2290
2738
  return sanitized.length > 0 ? sanitized : "Run update.";
2291
2739
  }
2292
2740
 
2293
- function buildRunSummaryCommentBody(input: { status: "completed" | "failed"; executionSummary: string }) {
2294
- const summary = sanitizeAgentSummaryCommentBody(extractNaturalRunUpdate(input.executionSummary));
2295
- if (input.status === "failed") {
2296
- return summary.toLowerCase().startsWith("couldn't")
2297
- ? summary
2298
- : `Couldn't complete this run: ${summary.charAt(0).toLowerCase()}${summary.slice(1)}`;
2299
- }
2300
- return summary;
2301
- }
2302
-
2303
2741
  function extractNaturalRunUpdate(executionSummary: string) {
2304
2742
  const normalized = executionSummary.trim();
2305
2743
  const jsonSummary = extractSummaryFromJsonLikeText(normalized);
@@ -2323,6 +2761,697 @@ function extractNaturalRunUpdate(executionSummary: string) {
2323
2761
  return /[.!?]$/.test(bounded) ? bounded : `${bounded}.`;
2324
2762
  }
2325
2763
 
2764
+ function buildRunDigest(input: {
2765
+ status: "completed" | "failed" | "skipped";
2766
+ executionSummary: string;
2767
+ outcome: ExecutionOutcome | null;
2768
+ trace: unknown;
2769
+ signals: RunDigestSignal[];
2770
+ }): RunDigest {
2771
+ const summary = sanitizeAgentSummaryCommentBody(extractNaturalRunUpdate(input.executionSummary));
2772
+ const successes: string[] = [];
2773
+ const failures: string[] = [];
2774
+ const blockers: string[] = [];
2775
+ if (input.outcome) {
2776
+ for (const action of input.outcome.actions) {
2777
+ const detail = summarizeRunDigestPoint(action.detail);
2778
+ if (!detail) {
2779
+ continue;
2780
+ }
2781
+ if (action.status === "ok") {
2782
+ successes.push(detail);
2783
+ } else if (action.status === "error") {
2784
+ failures.push(detail);
2785
+ }
2786
+ }
2787
+ for (const blocker of input.outcome.blockers) {
2788
+ const detail = summarizeRunDigestPoint(blocker.message);
2789
+ if (detail) {
2790
+ blockers.push(detail);
2791
+ }
2792
+ }
2793
+ }
2794
+ for (const signal of input.signals) {
2795
+ if (signal.signalLevel !== "high" && signal.signalLevel !== "medium") {
2796
+ continue;
2797
+ }
2798
+ const signalText = summarizeRunDigestPoint(signal.text ?? signal.payload ?? "");
2799
+ if (!signalText) {
2800
+ continue;
2801
+ }
2802
+ if (signal.kind === "tool_result" || signal.kind === "stderr") {
2803
+ if (looksLikeRunFailureSignal(signalText)) {
2804
+ failures.push(signalText);
2805
+ } else if (signal.kind === "tool_result") {
2806
+ successes.push(signalText);
2807
+ }
2808
+ continue;
2809
+ }
2810
+ if (signal.kind === "result" && !looksLikeRunFailureSignal(signalText)) {
2811
+ successes.push(signalText);
2812
+ }
2813
+ }
2814
+ if (input.status === "completed" && successes.length === 0) {
2815
+ successes.push(summary);
2816
+ }
2817
+ if (input.status === "failed" && failures.length === 0) {
2818
+ failures.push(summary);
2819
+ }
2820
+ if (input.status === "failed" && blockers.length === 0) {
2821
+ const traceFailureType = summarizeRunDigestPoint(readTraceString(input.trace, "failureType") ?? "");
2822
+ if (traceFailureType) {
2823
+ blockers.push(`failure type: ${traceFailureType}`);
2824
+ }
2825
+ }
2826
+ const uniqueSuccesses = dedupeRunDigestPoints(successes, 3);
2827
+ const uniqueFailures = dedupeRunDigestPoints(failures, 3);
2828
+ const uniqueBlockers = dedupeRunDigestPoints(blockers, 2);
2829
+ const headline =
2830
+ input.status === "completed"
2831
+ ? `Run completed: ${summary}`
2832
+ : input.status === "failed"
2833
+ ? `Run failed: ${summary}`
2834
+ : `Run skipped: ${summary}`;
2835
+ const nextAction = resolveRunDigestNextAction({
2836
+ status: input.status,
2837
+ blockers: uniqueBlockers,
2838
+ failures: uniqueFailures
2839
+ });
2840
+ return {
2841
+ status: input.status,
2842
+ headline,
2843
+ summary,
2844
+ successes: uniqueSuccesses,
2845
+ failures: uniqueFailures,
2846
+ blockers: uniqueBlockers,
2847
+ nextAction,
2848
+ evidence: {
2849
+ transcriptSignalCount: input.signals.length,
2850
+ outcomeActionCount: input.outcome?.actions.length ?? 0,
2851
+ outcomeBlockerCount: input.outcome?.blockers.length ?? 0,
2852
+ failureType: readTraceString(input.trace, "failureType")
2853
+ }
2854
+ };
2855
+ }
2856
+
2857
+ function summarizeRunDigestPoint(value: string | null | undefined) {
2858
+ if (!value) {
2859
+ return "";
2860
+ }
2861
+ const normalized = sanitizeAgentSummaryCommentBody(extractNaturalRunUpdate(value));
2862
+ if (!normalized || normalized.toLowerCase() === "run update.") {
2863
+ return "";
2864
+ }
2865
+ const bounded = normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized;
2866
+ return bounded;
2867
+ }
2868
+
2869
+ function dedupeRunDigestPoints(values: string[], limit: number) {
2870
+ const seen = new Set<string>();
2871
+ const deduped: string[] = [];
2872
+ for (const value of values) {
2873
+ const key = value.toLowerCase().replace(/\s+/g, " ").trim();
2874
+ if (!key || seen.has(key)) {
2875
+ continue;
2876
+ }
2877
+ seen.add(key);
2878
+ deduped.push(value);
2879
+ if (deduped.length >= limit) {
2880
+ break;
2881
+ }
2882
+ }
2883
+ return deduped;
2884
+ }
2885
+
2886
+ function looksLikeRunFailureSignal(value: string) {
2887
+ const normalized = value.toLowerCase();
2888
+ return /(failed|error|exception|timed out|timeout|unauthorized|not supported|unsupported|no capacity|rate limit|429|500|blocked|unable to)/.test(
2889
+ normalized
2890
+ );
2891
+ }
2892
+
2893
+ function resolveRunDigestNextAction(input: { status: "completed" | "failed" | "skipped"; blockers: string[]; failures: string[] }) {
2894
+ if (input.status === "completed") {
2895
+ return "Review outputs and move the issue to the next workflow state.";
2896
+ }
2897
+ const combined = [...input.blockers, ...input.failures].join(" ").toLowerCase();
2898
+ if (combined.includes("auth") || combined.includes("unauthorized") || combined.includes("login")) {
2899
+ return "Fix credentials/authentication, then rerun.";
2900
+ }
2901
+ if (combined.includes("model") && (combined.includes("not supported") || combined.includes("unavailable"))) {
2902
+ return "Select a supported model and rerun.";
2903
+ }
2904
+ if (combined.includes("usage limit") || combined.includes("rate limit") || combined.includes("no capacity")) {
2905
+ return "Retry after provider quota/capacity recovers.";
2906
+ }
2907
+ return "Fix listed failures/blockers and rerun.";
2908
+ }
2909
+
2910
+ function resolveRunTerminalPresentation(input: {
2911
+ internalStatus: "completed" | "failed" | "skipped";
2912
+ executionSummary: string;
2913
+ outcome: ExecutionOutcome | null;
2914
+ trace: unknown;
2915
+ errorType?: string | null;
2916
+ }) : RunTerminalPresentation {
2917
+ if (isNoAssignedWorkOutcomeForReport(input.outcome)) {
2918
+ return {
2919
+ internalStatus: input.internalStatus,
2920
+ publicStatus: "completed",
2921
+ completionReason: "no_assigned_work"
2922
+ };
2923
+ }
2924
+ if (input.internalStatus === "completed") {
2925
+ return {
2926
+ internalStatus: input.internalStatus,
2927
+ publicStatus: "completed",
2928
+ completionReason: "task_completed"
2929
+ };
2930
+ }
2931
+ const completionReason = inferRunCompletionReason(input);
2932
+ return {
2933
+ internalStatus: input.internalStatus,
2934
+ publicStatus: "failed",
2935
+ completionReason
2936
+ };
2937
+ }
2938
+
2939
+ function inferRunCompletionReason(input: {
2940
+ internalStatus: "completed" | "failed" | "skipped";
2941
+ executionSummary: string;
2942
+ outcome: ExecutionOutcome | null;
2943
+ trace: unknown;
2944
+ errorType?: string | null;
2945
+ }): RunCompletionReason {
2946
+ const texts = [
2947
+ input.executionSummary,
2948
+ readTraceString(input.trace, "failureType") ?? "",
2949
+ readTraceString(input.trace, "stderrPreview") ?? "",
2950
+ input.errorType ?? "",
2951
+ ...(input.outcome?.blockers ?? []).flatMap((blocker) => [blocker.code, blocker.message]),
2952
+ ...(input.outcome?.actions ?? []).flatMap((action) => [action.type, action.detail ?? ""])
2953
+ ];
2954
+ const combined = texts.join("\n").toLowerCase();
2955
+ if (
2956
+ combined.includes("insufficient_quota") ||
2957
+ combined.includes("billing_hard_limit_reached") ||
2958
+ combined.includes("out of funds") ||
2959
+ combined.includes("payment required")
2960
+ ) {
2961
+ return "provider_out_of_funds";
2962
+ }
2963
+ if (
2964
+ combined.includes("usage limit") ||
2965
+ combined.includes("rate limit") ||
2966
+ combined.includes("429") ||
2967
+ combined.includes("quota")
2968
+ ) {
2969
+ return combined.includes("quota") ? "provider_quota_exhausted" : "provider_rate_limited";
2970
+ }
2971
+ if (combined.includes("budget hard-stop")) {
2972
+ return "budget_hard_stop";
2973
+ }
2974
+ if (combined.includes("already in progress") || combined.includes("skipped_overlap")) {
2975
+ return "overlap_in_progress";
2976
+ }
2977
+ if (combined.includes("unauthorized") || combined.includes("auth") || combined.includes("api key")) {
2978
+ return "auth_error";
2979
+ }
2980
+ if (combined.includes("contract") || combined.includes("missing_structured_output")) {
2981
+ return "contract_invalid";
2982
+ }
2983
+ if (combined.includes("watchdog_timeout") || combined.includes("runtime_timeout") || combined.includes("timed out")) {
2984
+ return "timeout";
2985
+ }
2986
+ if (combined.includes("cancelled")) {
2987
+ return "cancelled";
2988
+ }
2989
+ if (combined.includes("enoent") || combined.includes("runtime_missing")) {
2990
+ return "runtime_missing";
2991
+ }
2992
+ if (
2993
+ combined.includes("provider unavailable") ||
2994
+ combined.includes("no capacity") ||
2995
+ combined.includes("unavailable") ||
2996
+ combined.includes("http_error")
2997
+ ) {
2998
+ return "provider_unavailable";
2999
+ }
3000
+ if (input.outcome?.kind === "blocked") {
3001
+ return "blocked";
3002
+ }
3003
+ return "runtime_error";
3004
+ }
3005
+
3006
+ function isNoAssignedWorkOutcomeForReport(outcome: ExecutionOutcome | null) {
3007
+ if (!outcome) {
3008
+ return false;
3009
+ }
3010
+ if (outcome.kind !== "skipped") {
3011
+ return false;
3012
+ }
3013
+ if (outcome.issueIdsTouched.length === 0) {
3014
+ return true;
3015
+ }
3016
+ return outcome.actions.some((action) => action.type === "heartbeat.skip");
3017
+ }
3018
+
3019
+ function buildRunCostSummary(input: {
3020
+ tokenInput: number;
3021
+ tokenOutput: number;
3022
+ usdCost: number | null;
3023
+ usdCostStatus: "exact" | "estimated" | "unknown";
3024
+ pricingSource: string | null;
3025
+ source: string | null;
3026
+ }): RunCostSummary {
3027
+ return {
3028
+ tokenInput: Math.max(0, input.tokenInput),
3029
+ tokenOutput: Math.max(0, input.tokenOutput),
3030
+ usdCost: input.usdCostStatus === "unknown" ? null : Math.max(0, input.usdCost ?? 0),
3031
+ usdCostStatus: input.usdCostStatus,
3032
+ pricingSource: input.pricingSource ?? null,
3033
+ source: input.source ?? null
3034
+ };
3035
+ }
3036
+
3037
+ function buildRunArtifacts(input: {
3038
+ outcome: ExecutionOutcome | null;
3039
+ finalRunOutput?: AgentFinalRunOutput | null;
3040
+ runtimeCwd?: string | null;
3041
+ workspaceRootPath?: string | null;
3042
+ companyId?: string;
3043
+ }): RunArtifact[] {
3044
+ const sourceArtifacts =
3045
+ input.finalRunOutput?.artifacts && input.finalRunOutput.artifacts.length > 0
3046
+ ? input.finalRunOutput.artifacts
3047
+ : input.outcome?.artifacts ?? [];
3048
+ if (sourceArtifacts.length === 0) {
3049
+ return [];
3050
+ }
3051
+ const runtimeCwd = input.runtimeCwd?.trim() ? input.runtimeCwd.trim() : null;
3052
+ const workspaceRootPath = input.workspaceRootPath?.trim() ? input.workspaceRootPath.trim() : null;
3053
+ const companyId = input.companyId?.trim() ? input.companyId.trim() : null;
3054
+ return sourceArtifacts.map((artifact) => {
3055
+ const originalPath = artifact.path.trim();
3056
+ const artifactIsAbsolute = isAbsolute(originalPath);
3057
+ const absolutePath = artifactIsAbsolute ? resolve(originalPath) : runtimeCwd ? resolve(runtimeCwd, originalPath) : null;
3058
+ let relativePathValue: string | null = null;
3059
+ if (absolutePath && workspaceRootPath && isInsidePath(workspaceRootPath, absolutePath)) {
3060
+ relativePathValue = toNormalizedWorkspaceRelativePath(relative(workspaceRootPath, absolutePath));
3061
+ } else if (!artifactIsAbsolute) {
3062
+ relativePathValue = toNormalizedWorkspaceRelativePath(originalPath);
3063
+ } else if (runtimeCwd) {
3064
+ const candidate = toNormalizedWorkspaceRelativePath(relative(runtimeCwd, absolutePath ?? originalPath));
3065
+ relativePathValue = candidate && !candidate.startsWith("../") ? candidate : null;
3066
+ }
3067
+ if (companyId) {
3068
+ const normalizedRelative = normalizeAgentOperatingArtifactRelativePath(relativePathValue, companyId);
3069
+ if (normalizedRelative) {
3070
+ relativePathValue = normalizedRelative;
3071
+ } else {
3072
+ const normalizedOriginal = toNormalizedWorkspaceRelativePath(originalPath);
3073
+ const normalizedFromOriginal = normalizeAgentOperatingArtifactRelativePath(normalizedOriginal, companyId);
3074
+ if (normalizedFromOriginal) {
3075
+ relativePathValue = normalizedFromOriginal;
3076
+ }
3077
+ }
3078
+ }
3079
+ const location = relativePathValue ?? absolutePath ?? originalPath;
3080
+ return {
3081
+ path: originalPath,
3082
+ kind: artifact.kind,
3083
+ label: describeArtifact(artifact.kind, location),
3084
+ relativePath: relativePathValue,
3085
+ absolutePath
3086
+ };
3087
+ });
3088
+ }
3089
+
3090
+ async function verifyRunArtifactsOnDisk(companyId: string, artifacts: RunArtifact[]) {
3091
+ for (const artifact of artifacts) {
3092
+ const resolved = resolveRunArtifactAbsolutePath(companyId, {
3093
+ path: artifact.path,
3094
+ relativePath: artifact.relativePath ?? undefined,
3095
+ absolutePath: artifact.absolutePath ?? undefined
3096
+ });
3097
+ if (!resolved) {
3098
+ artifact.verifiedOnDisk = false;
3099
+ continue;
3100
+ }
3101
+ try {
3102
+ const stats = await stat(resolved);
3103
+ artifact.verifiedOnDisk = stats.isFile();
3104
+ } catch {
3105
+ artifact.verifiedOnDisk = false;
3106
+ }
3107
+ }
3108
+ }
3109
+
3110
+ function toNormalizedWorkspaceRelativePath(inputPath: string | null | undefined) {
3111
+ const trimmed = inputPath?.trim();
3112
+ if (!trimmed) {
3113
+ return null;
3114
+ }
3115
+ const unixSeparated = trimmed.replace(/\\/g, "/");
3116
+ const parts: string[] = [];
3117
+ for (const part of unixSeparated.split("/")) {
3118
+ if (!part || part === ".") {
3119
+ continue;
3120
+ }
3121
+ if (part === "..") {
3122
+ if (parts.length > 0 && parts[parts.length - 1] !== "..") {
3123
+ parts.pop();
3124
+ } else {
3125
+ parts.push(part);
3126
+ }
3127
+ continue;
3128
+ }
3129
+ parts.push(part);
3130
+ }
3131
+ const normalized = parts.join("/");
3132
+ return normalized || null;
3133
+ }
3134
+
3135
+ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, companyId: string) {
3136
+ const normalized = toNormalizedWorkspaceRelativePath(pathValue);
3137
+ if (!normalized) {
3138
+ return null;
3139
+ }
3140
+ const workspaceScopedMatch = normalized.match(/(?:^|\/)(workspace\/[^/]+\/agents\/[^/]+\/operating(?:\/.*)?)$/);
3141
+ if (workspaceScopedMatch) {
3142
+ const scopedPath = toNormalizedWorkspaceRelativePath(workspaceScopedMatch[1]);
3143
+ if (!scopedPath) {
3144
+ return null;
3145
+ }
3146
+ const parsed = scopedPath.match(/^workspace\/([^/]+)\/agents\/([^/]+)\/operating(\/.*)?$/);
3147
+ if (!parsed) {
3148
+ return null;
3149
+ }
3150
+ const agentId = parsed[2];
3151
+ const suffix = parsed[3] ?? "";
3152
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3153
+ }
3154
+ const directMatch = normalized.match(/^agents\/([^/]+)\/operating(\/.*)?$/);
3155
+ if (directMatch) {
3156
+ const [, agentId, suffix = ""] = directMatch;
3157
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3158
+ }
3159
+ const issueScopedMatch = normalized.match(
3160
+ /^(?:[^/]+\/)?projects\/[^/]+\/issues\/[^/]+\/agents\/([^/]+)\/operating(\/.*)?$/
3161
+ );
3162
+ if (issueScopedMatch) {
3163
+ const [, agentId, suffix = ""] = issueScopedMatch;
3164
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3165
+ }
3166
+ return null;
3167
+ }
3168
+
3169
+ function describeArtifact(kind: string, location: string) {
3170
+ const normalizedKind = kind.toLowerCase();
3171
+ if (normalizedKind.includes("folder") || normalizedKind.includes("directory") || normalizedKind === "website") {
3172
+ return `Created ${normalizedKind.replace(/_/g, " ")} at ${location}`;
3173
+ }
3174
+ if (normalizedKind.includes("file")) {
3175
+ return `Updated file ${location}`;
3176
+ }
3177
+ return `Produced ${normalizedKind.replace(/_/g, " ")} at ${location}`;
3178
+ }
3179
+
3180
+ function buildRunCompletionReport(input: {
3181
+ companyId?: string;
3182
+ agentName: string;
3183
+ providerType: HeartbeatProviderType;
3184
+ issueIds: string[];
3185
+ executionSummary: string;
3186
+ outcome: ExecutionOutcome | null;
3187
+ finalRunOutput?: AgentFinalRunOutput | null;
3188
+ trace: unknown;
3189
+ digest: RunDigest;
3190
+ terminal: RunTerminalPresentation;
3191
+ cost: RunCostSummary;
3192
+ runtimeCwd?: string | null;
3193
+ errorType?: string | null;
3194
+ errorMessage?: string | null;
3195
+ }): RunCompletionReport {
3196
+ const workspaceRootPath = input.companyId ? resolveCompanyWorkspaceRootPath(input.companyId) : null;
3197
+ const artifacts = buildRunArtifacts({
3198
+ outcome: input.outcome,
3199
+ finalRunOutput: input.finalRunOutput,
3200
+ runtimeCwd: input.runtimeCwd,
3201
+ workspaceRootPath,
3202
+ companyId: input.companyId
3203
+ });
3204
+ const fallbackSummary = sanitizeAgentSummaryCommentBody(extractNaturalRunUpdate(input.executionSummary));
3205
+ const employeeComment =
3206
+ input.finalRunOutput?.employee_comment?.trim() || buildLegacyEmployeeComment(fallbackSummary);
3207
+ const results = input.finalRunOutput
3208
+ ? input.finalRunOutput.results.filter((value): value is string => Boolean(value))
3209
+ : input.terminal.publicStatus === "completed"
3210
+ ? dedupeRunDigestPoints(
3211
+ [
3212
+ input.digest.successes[0],
3213
+ artifacts[0]?.label,
3214
+ input.terminal.completionReason === "no_assigned_work" ? "No assigned work was available for this run." : null
3215
+ ].filter((value): value is string => Boolean(value)),
3216
+ 4
3217
+ )
3218
+ : [];
3219
+ const errors =
3220
+ input.finalRunOutput?.errors.filter((value): value is string => Boolean(value)) ??
3221
+ dedupeRunDigestPoints([...input.digest.blockers, ...input.digest.failures].filter((value): value is string => Boolean(value)), 4);
3222
+ const summary = firstMeaningfulReportLine(employeeComment) || results[0] || fallbackSummary;
3223
+ const resultSummary =
3224
+ results[0] ??
3225
+ (input.terminal.publicStatus === "completed"
3226
+ ? artifacts[0]?.label ??
3227
+ (input.terminal.completionReason === "no_assigned_work" ? "No assigned work was available for this run." : summary)
3228
+ : input.finalRunOutput
3229
+ ? summary
3230
+ : "No valid final run output was produced.");
3231
+ const statusHeadline =
3232
+ input.terminal.publicStatus === "completed"
3233
+ ? `Completed: ${summary}`
3234
+ : `Failed: ${summary}`;
3235
+ const blockers = dedupeRunDigestPoints(errors, 4);
3236
+ const artifactPaths = artifacts
3237
+ .map((artifact) => artifact.relativePath ?? artifact.absolutePath ?? artifact.path)
3238
+ .filter((value): value is string => Boolean(value));
3239
+ const managerReport = {
3240
+ agentName: input.agentName,
3241
+ providerType: input.providerType,
3242
+ whatWasDone: results[0] ?? (input.terminal.publicStatus === "completed" ? input.digest.successes[0] ?? summary : summary),
3243
+ resultSummary,
3244
+ artifactPaths,
3245
+ blockers,
3246
+ nextAction: input.digest.nextAction,
3247
+ costLine: formatRunCostLine(input.cost)
3248
+ };
3249
+ const fallbackOutcome: ExecutionOutcome = input.outcome ?? {
3250
+ kind:
3251
+ input.terminal.completionReason === "no_assigned_work"
3252
+ ? "skipped"
3253
+ : input.terminal.publicStatus === "completed"
3254
+ ? "completed"
3255
+ : "failed",
3256
+ issueIdsTouched: input.issueIds,
3257
+ artifacts: artifacts.map((artifact) => ({ path: artifact.path, kind: artifact.kind })),
3258
+ actions:
3259
+ results.length > 0
3260
+ ? results.slice(0, 4).map((result) => ({
3261
+ type: input.terminal.publicStatus === "completed" ? "run.completed" : "run.failed",
3262
+ status: input.terminal.publicStatus === "completed" ? "ok" : "error",
3263
+ detail: result
3264
+ }))
3265
+ : [
3266
+ {
3267
+ type: input.terminal.publicStatus === "completed" ? "run.completed" : "run.failed",
3268
+ status: input.terminal.publicStatus === "completed" ? "ok" : "error",
3269
+ detail: managerReport.whatWasDone
3270
+ }
3271
+ ],
3272
+ blockers: blockers.map((message) => ({
3273
+ code: input.terminal.completionReason,
3274
+ message,
3275
+ retryable: input.terminal.publicStatus !== "completed"
3276
+ })),
3277
+ nextSuggestedState: input.terminal.publicStatus === "completed" ? "in_review" : "blocked"
3278
+ };
3279
+ return {
3280
+ finalStatus: input.terminal.publicStatus,
3281
+ completionReason: input.terminal.completionReason,
3282
+ statusHeadline,
3283
+ summary,
3284
+ employeeComment,
3285
+ results,
3286
+ errors,
3287
+ resultStatus: artifacts.length > 0 ? "reported" : "none_reported",
3288
+ resultSummary,
3289
+ issueIds: input.issueIds,
3290
+ artifacts,
3291
+ blockers,
3292
+ nextAction: input.digest.nextAction,
3293
+ cost: input.cost,
3294
+ managerReport,
3295
+ outcome: input.outcome ?? fallbackOutcome,
3296
+ debug: {
3297
+ persistedRunStatus: input.terminal.internalStatus,
3298
+ failureType: readTraceString(input.trace, "failureType"),
3299
+ errorType: input.errorType ?? null,
3300
+ errorMessage: input.errorMessage ?? null
3301
+ }
3302
+ };
3303
+ }
3304
+
3305
+ function firstMeaningfulReportLine(value: string) {
3306
+ for (const rawLine of value.split(/\r?\n/)) {
3307
+ const line = rawLine.replace(/^[#>*\-\s`]+/, "").trim();
3308
+ if (line) {
3309
+ return line;
3310
+ }
3311
+ }
3312
+ return "";
3313
+ }
3314
+
3315
+ function buildLegacyEmployeeComment(summary: string) {
3316
+ return summary;
3317
+ }
3318
+
3319
+ function formatRunCostLine(cost: RunCostSummary) {
3320
+ const tokens = `${cost.tokenInput} input / ${cost.tokenOutput} output tokens`;
3321
+ if (cost.usdCostStatus === "unknown" || cost.usdCost === null || cost.usdCost === undefined) {
3322
+ return `${tokens}; dollar cost unknown`;
3323
+ }
3324
+ const qualifier = cost.usdCostStatus === "estimated" ? "estimated" : "exact";
3325
+ return `${tokens}; ${qualifier} cost $${cost.usdCost.toFixed(6)}`;
3326
+ }
3327
+
3328
+ function buildHumanRunUpdateCommentFromReport(
3329
+ report: RunCompletionReport,
3330
+ options: { runId: string; companyId: string }
3331
+ ) {
3332
+ const lines = [
3333
+ report.employeeComment.trim(),
3334
+ "",
3335
+ `- Status: ${report.finalStatus}`,
3336
+ `- Agent: ${report.managerReport.agentName}`,
3337
+ `- Provider: ${report.managerReport.providerType}`,
3338
+ ""
3339
+ ];
3340
+ if (report.results.length > 0) {
3341
+ lines.push("### Results", "");
3342
+ for (const result of report.results) {
3343
+ lines.push(`- ${result}`);
3344
+ }
3345
+ lines.push("");
3346
+ }
3347
+ lines.push("### Result", "", `- What was done: ${report.managerReport.whatWasDone}`, `- Summary: ${report.managerReport.resultSummary}`);
3348
+ if (report.artifacts.length > 0) {
3349
+ for (const [artifactIndex, artifact] of report.artifacts.entries()) {
3350
+ lines.push(`- Artifact: ${formatRunArtifactMarkdownLink(artifact, { ...options, artifactIndex })}`);
3351
+ }
3352
+ }
3353
+ lines.push("");
3354
+ lines.push("### Cost", "");
3355
+ lines.push(`- Input tokens: \`${report.cost.tokenInput}\``);
3356
+ lines.push(`- Output tokens: \`${report.cost.tokenOutput}\``);
3357
+ lines.push(`- Dollar cost: ${formatRunCostForHumanReport(report.cost)}`);
3358
+ if (report.errors.length > 0) {
3359
+ lines.push("");
3360
+ lines.push("### Errors", "");
3361
+ for (const error of report.errors) {
3362
+ lines.push(`- ${error}`);
3363
+ }
3364
+ }
3365
+ return lines.join("\n");
3366
+ }
3367
+
3368
+ function formatRunArtifactMarkdownLink(
3369
+ artifact: RunArtifact,
3370
+ options: { runId: string; companyId: string; artifactIndex: number }
3371
+ ) {
3372
+ const label = resolveRunArtifactDisplayPath(artifact);
3373
+ const href = buildRunArtifactLinkHref(options);
3374
+ if (!label) {
3375
+ return "`artifact`";
3376
+ }
3377
+ if (artifact.verifiedOnDisk === false) {
3378
+ return `\`${label}\` (not found under company workspace at run completion)`;
3379
+ }
3380
+ if (!href) {
3381
+ return `\`${label}\``;
3382
+ }
3383
+ return `[${label}](${href})`;
3384
+ }
3385
+
3386
+ function resolveRunArtifactDisplayPath(artifact: RunArtifact) {
3387
+ const relative = toNormalizedWorkspaceRelativePath(artifact.relativePath);
3388
+ if (relative && !relative.startsWith("../")) {
3389
+ return relative;
3390
+ }
3391
+ const pathValue = toNormalizedWorkspaceRelativePath(artifact.path);
3392
+ if (pathValue && !pathValue.startsWith("../") && !isAbsolute(artifact.path)) {
3393
+ return pathValue;
3394
+ }
3395
+ return null;
3396
+ }
3397
+
3398
+ function buildRunArtifactLinkHref(options: { runId: string; companyId: string; artifactIndex: number }) {
3399
+ const apiBaseUrl = resolveControlPlaneApiBaseUrl().replace(/\/+$/, "");
3400
+ const runId = encodeURIComponent(options.runId);
3401
+ const artifactIndex = encodeURIComponent(String(options.artifactIndex));
3402
+ const companyId = encodeURIComponent(options.companyId);
3403
+ return `${apiBaseUrl}/observability/heartbeats/${runId}/artifacts/${artifactIndex}/download?companyId=${companyId}`;
3404
+ }
3405
+
3406
+ function formatRunCostForHumanReport(cost: RunCostSummary) {
3407
+ if (cost.usdCostStatus === "unknown" || cost.usdCost === null || cost.usdCost === undefined) {
3408
+ return "unknown";
3409
+ }
3410
+ const qualifier = cost.usdCostStatus === "estimated" ? "estimated " : "exact ";
3411
+ return `${qualifier}\`$${cost.usdCost.toFixed(6)}\``;
3412
+ }
3413
+
3414
+ function buildRunListMessageFromReport(report: RunCompletionReport) {
3415
+ const resultParts =
3416
+ report.finalStatus === "completed"
3417
+ ? report.results.length > 0
3418
+ ? report.results.slice(0, 2)
3419
+ : [report.resultSummary]
3420
+ : [];
3421
+ const parts = [report.statusHeadline, ...resultParts];
3422
+ if (report.artifacts.length > 0) {
3423
+ parts.push(`Artifacts: ${report.managerReport.artifactPaths.join(", ")}`);
3424
+ }
3425
+ if (report.cost.usdCostStatus === "unknown") {
3426
+ parts.push("Cost: unknown");
3427
+ } else if (report.cost.usdCost !== null && report.cost.usdCost !== undefined) {
3428
+ parts.push(`Cost: $${report.cost.usdCost.toFixed(6)}`);
3429
+ }
3430
+ const compact = parts.filter(Boolean).join(" | ");
3431
+ return compact.length > 220 ? `${compact.slice(0, 217).trimEnd()}...` : compact;
3432
+ }
3433
+
3434
+ function isMachineNoiseLine(text: string) {
3435
+ const normalized = text.trim();
3436
+ if (!normalized) {
3437
+ return true;
3438
+ }
3439
+ if (normalized.length > 220) {
3440
+ return true;
3441
+ }
3442
+ const patterns = [
3443
+ /^command:\s*/i,
3444
+ /^\s*[\[{].*[\]}]\s*$/,
3445
+ /\/bin\/(bash|zsh|sh)/i,
3446
+ /(^|\s)(\/Users\/|\/home\/|\/private\/var\/|[A-Za-z]:\\)/,
3447
+ /\b(stderr|stdout|stack trace|exit code|payload_json|tokeninput|tokenoutput|usdcost)\b/i,
3448
+ /(^|\s)at\s+\S+:\d+:\d+/,
3449
+ /```/,
3450
+ /\{[\s\S]*"(summary|tokenInput|tokenOutput|usdCost|trace|error)"[\s\S]*\}/i
3451
+ ];
3452
+ return patterns.some((pattern) => pattern.test(normalized));
3453
+ }
3454
+
2326
3455
  function extractSummaryFromJsonLikeText(input: string) {
2327
3456
  const fencedMatch = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
2328
3457
  const candidate = fencedMatch?.[1]?.trim() ?? input.match(/\{[\s\S]*\}\s*$/)?.[0]?.trim();
@@ -2354,19 +3483,18 @@ async function appendRunSummaryComments(
2354
3483
  issueIds: string[];
2355
3484
  agentId: string;
2356
3485
  runId: string;
2357
- status: "completed" | "failed";
2358
- executionSummary: string;
3486
+ report: RunCompletionReport;
2359
3487
  }
2360
3488
  ) {
2361
3489
  if (input.issueIds.length === 0) {
2362
3490
  return;
2363
3491
  }
2364
- const commentBody = buildRunSummaryCommentBody({
2365
- status: input.status,
2366
- executionSummary: input.executionSummary
3492
+ const commentBody = buildHumanRunUpdateCommentFromReport(input.report, {
3493
+ runId: input.runId,
3494
+ companyId: input.companyId
2367
3495
  });
2368
3496
  for (const issueId of input.issueIds) {
2369
- const [existingRunComment] = await db
3497
+ const existingRunComments = await db
2370
3498
  .select({ id: issueComments.id })
2371
3499
  .from(issueComments)
2372
3500
  .where(
@@ -2378,6 +3506,58 @@ async function appendRunSummaryComments(
2378
3506
  eq(issueComments.authorId, input.agentId)
2379
3507
  )
2380
3508
  )
3509
+ .orderBy(desc(issueComments.createdAt));
3510
+ if (existingRunComments.length > 0) {
3511
+ await db.delete(issueComments).where(
3512
+ and(
3513
+ eq(issueComments.companyId, input.companyId),
3514
+ inArray(
3515
+ issueComments.id,
3516
+ existingRunComments.map((comment) => comment.id)
3517
+ )
3518
+ )
3519
+ );
3520
+ }
3521
+ await addIssueComment(db, {
3522
+ companyId: input.companyId,
3523
+ issueId,
3524
+ authorType: "agent",
3525
+ authorId: input.agentId,
3526
+ runId: input.runId,
3527
+ body: commentBody
3528
+ });
3529
+ }
3530
+ }
3531
+
3532
+ async function appendProviderUsageLimitBoardComments(
3533
+ db: BopoDb,
3534
+ input: {
3535
+ companyId: string;
3536
+ issueIds: string[];
3537
+ agentId: string;
3538
+ runId: string;
3539
+ providerType: string;
3540
+ message: string;
3541
+ paused: boolean;
3542
+ }
3543
+ ) {
3544
+ if (input.issueIds.length === 0) {
3545
+ return;
3546
+ }
3547
+ const commentBody = buildProviderUsageLimitBoardCommentBody(input);
3548
+ for (const issueId of input.issueIds) {
3549
+ const [existingRunComment] = await db
3550
+ .select({ id: issueComments.id })
3551
+ .from(issueComments)
3552
+ .where(
3553
+ and(
3554
+ eq(issueComments.companyId, input.companyId),
3555
+ eq(issueComments.issueId, issueId),
3556
+ eq(issueComments.runId, input.runId),
3557
+ eq(issueComments.authorType, "system"),
3558
+ eq(issueComments.authorId, input.agentId)
3559
+ )
3560
+ )
2381
3561
  .limit(1);
2382
3562
  if (existingRunComment) {
2383
3563
  continue;
@@ -2385,14 +3565,70 @@ async function appendRunSummaryComments(
2385
3565
  await addIssueComment(db, {
2386
3566
  companyId: input.companyId,
2387
3567
  issueId,
2388
- authorType: "agent",
3568
+ authorType: "system",
2389
3569
  authorId: input.agentId,
2390
3570
  runId: input.runId,
3571
+ recipients: [
3572
+ {
3573
+ recipientType: "board",
3574
+ deliveryStatus: "pending"
3575
+ }
3576
+ ],
2391
3577
  body: commentBody
2392
3578
  });
2393
3579
  }
2394
3580
  }
2395
3581
 
3582
+ function buildProviderUsageLimitBoardCommentBody(input: {
3583
+ providerType: string;
3584
+ message: string;
3585
+ paused: boolean;
3586
+ }) {
3587
+ const providerLabel = input.providerType.replace(/[_-]+/g, " ").trim();
3588
+ const normalizedProvider = providerLabel.charAt(0).toUpperCase() + providerLabel.slice(1);
3589
+ const agentStateLine = input.paused ? "Agent paused." : "Agent already paused.";
3590
+ return `${normalizedProvider} usage limit reached.\nRun failed due to provider limits.\n${agentStateLine}\nNext: resume after usage reset or billing/credential fix.`;
3591
+ }
3592
+
3593
+ async function pauseAgentForProviderUsageLimit(
3594
+ db: BopoDb,
3595
+ input: {
3596
+ companyId: string;
3597
+ agentId: string;
3598
+ requestId: string;
3599
+ runId: string;
3600
+ providerType: string;
3601
+ message: string;
3602
+ }
3603
+ ) {
3604
+ const [agentRow] = await db
3605
+ .select({ status: agents.status })
3606
+ .from(agents)
3607
+ .where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId)))
3608
+ .limit(1);
3609
+ if (!agentRow || agentRow.status === "paused" || agentRow.status === "terminated") {
3610
+ return { paused: false as const };
3611
+ }
3612
+ await db
3613
+ .update(agents)
3614
+ .set({ status: "paused", updatedAt: new Date() })
3615
+ .where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId)));
3616
+ await appendAuditEvent(db, {
3617
+ companyId: input.companyId,
3618
+ actorType: "system",
3619
+ eventType: "agent.paused_auto_provider_limit",
3620
+ entityType: "agent",
3621
+ entityId: input.agentId,
3622
+ correlationId: input.requestId,
3623
+ payload: {
3624
+ runId: input.runId,
3625
+ providerType: input.providerType,
3626
+ reason: input.message
3627
+ }
3628
+ });
3629
+ return { paused: true as const };
3630
+ }
3631
+
2396
3632
  function parseAgentState(stateBlob: string | null) {
2397
3633
  if (!stateBlob) {
2398
3634
  return { state: {} as AgentState, parseError: null };
@@ -2735,10 +3971,7 @@ async function resolveRuntimeWorkspaceForWorkItems(
2735
3971
  }
2736
3972
 
2737
3973
  if (projectIssue?.id) {
2738
- const issueScopedWorkspaceCwd = normalizeCompanyWorkspacePath(
2739
- companyId,
2740
- join(selectedWorkspaceCwd, "issues", projectIssue.id)
2741
- );
3974
+ const issueScopedWorkspaceCwd = resolveProjectIssueWorkspaceCwd(companyId, selectedWorkspaceCwd, projectIssue.id);
2742
3975
  await mkdir(issueScopedWorkspaceCwd, { recursive: true });
2743
3976
  selectedWorkspaceCwd = issueScopedWorkspaceCwd;
2744
3977
  }
@@ -2787,6 +4020,10 @@ async function resolveRuntimeWorkspaceForWorkItems(
2787
4020
  };
2788
4021
  }
2789
4022
 
4023
+ function resolveProjectIssueWorkspaceCwd(companyId: string, projectWorkspaceCwd: string, issueId: string) {
4024
+ return normalizeCompanyWorkspacePath(companyId, join(projectWorkspaceCwd, "issues", issueId));
4025
+ }
4026
+
2790
4027
  function resolveGitWorktreeIsolationEnabled() {
2791
4028
  const value = String(process.env.BOPO_ENABLE_GIT_WORKTREE_ISOLATION ?? "")
2792
4029
  .trim()
@@ -3108,6 +4345,24 @@ function clearResumeState(
3108
4345
  };
3109
4346
  }
3110
4347
 
4348
+ function resolveHeartbeatPromptMode(): "full" | "compact" {
4349
+ const raw = process.env.BOPO_HEARTBEAT_PROMPT_MODE?.trim().toLowerCase();
4350
+ return raw === "compact" ? "compact" : "full";
4351
+ }
4352
+
4353
+ type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
4354
+
4355
+ function resolveHeartbeatIdlePolicy(): HeartbeatIdlePolicy {
4356
+ const raw = process.env.BOPO_HEARTBEAT_IDLE_POLICY?.trim().toLowerCase();
4357
+ if (raw === "skip_adapter") {
4358
+ return "skip_adapter";
4359
+ }
4360
+ if (raw === "micro_prompt") {
4361
+ return "micro_prompt";
4362
+ }
4363
+ return "full";
4364
+ }
4365
+
3111
4366
  function resolveControlPlaneEnv(runtimeEnv: Record<string, string>, suffix: string) {
3112
4367
  const next = runtimeEnv[`BOPODEV_${suffix}`];
3113
4368
  return hasText(next) ? (next as string) : "";
@@ -3124,8 +4379,13 @@ function buildHeartbeatRuntimeEnv(input: {
3124
4379
  canHireAgents: boolean;
3125
4380
  wakeContext?: HeartbeatWakeContext;
3126
4381
  }) {
4382
+ const companyWorkspaceRoot = resolveCompanyWorkspaceRootPath(input.companyId);
4383
+ const agentHome = resolveAgentFallbackWorkspace(input.companyId, input.agentId);
4384
+ const agentOperatingDir = join(agentHome, "operating");
3127
4385
  const apiBaseUrl = resolveControlPlaneApiBaseUrl();
3128
- const actorPermissions = ["issues:write", ...(input.canHireAgents ? ["agents:write"] : [])].join(",");
4386
+ // agents:write is required for PUT /agents/:self (bootstrapPrompt, runtimeConfig). Route handlers
4387
+ // still forbid agents from updating other agents' rows and from POST /agents unless canHireAgents.
4388
+ const actorPermissions = ["issues:write", "agents:write"].join(",");
3129
4389
  const actorHeaders = JSON.stringify({
3130
4390
  "x-company-id": input.companyId,
3131
4391
  "x-actor-type": "agent",
@@ -3139,7 +4399,12 @@ function buildHeartbeatRuntimeEnv(input: {
3139
4399
  return {
3140
4400
  BOPODEV_AGENT_ID: input.agentId,
3141
4401
  BOPODEV_COMPANY_ID: input.companyId,
4402
+ BOPODEV_COMPANY_WORKSPACE_ROOT: companyWorkspaceRoot,
4403
+ BOPODEV_AGENT_HOME: agentHome,
4404
+ BOPODEV_AGENT_OPERATING_DIR: agentOperatingDir,
3142
4405
  BOPODEV_RUN_ID: input.heartbeatRunId,
4406
+ BOPODEV_HEARTBEAT_PROMPT_MODE: resolveHeartbeatPromptMode(),
4407
+ BOPODEV_HEARTBEAT_IDLE_POLICY: resolveHeartbeatIdlePolicy(),
3143
4408
  BOPODEV_FORCE_MANAGED_CODEX_HOME: "false",
3144
4409
  BOPODEV_API_BASE_URL: apiBaseUrl,
3145
4410
  BOPODEV_API_URL: apiBaseUrl,
@@ -3414,6 +4679,7 @@ function resolveRuntimeModelId(input: { runtimeModel?: string; stateBlob?: strin
3414
4679
  async function appendFinishedRunCostEntry(input: {
3415
4680
  db: BopoDb;
3416
4681
  companyId: string;
4682
+ runId?: string | null;
3417
4683
  providerType: string;
3418
4684
  runtimeModelId: string | null;
3419
4685
  pricingProviderType?: string | null;
@@ -3440,25 +4706,22 @@ async function appendFinishedRunCostEntry(input: {
3440
4706
  const shouldPersist = input.status === "ok" || input.status === "failed";
3441
4707
  const runtimeUsdCost = Math.max(0, input.runtimeUsdCost ?? 0);
3442
4708
  const pricedUsdCost = Math.max(0, pricingDecision.usdCost);
3443
- const shouldUseRuntimeUsdCost = pricedUsdCost <= 0 && runtimeUsdCost > 0;
3444
- const baseUsdCost = shouldUseRuntimeUsdCost ? runtimeUsdCost : pricedUsdCost;
3445
- const effectiveUsdCost =
3446
- baseUsdCost > 0
3447
- ? baseUsdCost
3448
- : input.status === "failed" && input.failureType !== "spawn_error"
3449
- ? 0.000001
3450
- : 0;
4709
+ const usdCostStatus: "exact" | "estimated" | "unknown" =
4710
+ runtimeUsdCost > 0 ? "exact" : pricedUsdCost > 0 ? "estimated" : "unknown";
4711
+ const effectiveUsdCost = usdCostStatus === "exact" ? runtimeUsdCost : usdCostStatus === "estimated" ? pricedUsdCost : 0;
3451
4712
  const effectivePricingSource = pricingDecision.pricingSource;
3452
4713
  const shouldPersistWithUsage =
3453
- shouldPersist && (input.tokenInput > 0 || input.tokenOutput > 0 || effectiveUsdCost > 0);
4714
+ shouldPersist && (input.tokenInput > 0 || input.tokenOutput > 0 || usdCostStatus !== "unknown");
3454
4715
  if (shouldPersistWithUsage) {
3455
4716
  await appendCost(input.db, {
3456
4717
  companyId: input.companyId,
4718
+ runId: input.runId ?? null,
3457
4719
  providerType: input.providerType,
3458
4720
  runtimeModelId: input.runtimeModelId,
3459
4721
  pricingProviderType: pricingDecision.pricingProviderType,
3460
4722
  pricingModelId: pricingDecision.pricingModelId,
3461
4723
  pricingSource: effectivePricingSource,
4724
+ usdCostStatus,
3462
4725
  tokenInput: input.tokenInput,
3463
4726
  tokenOutput: input.tokenOutput,
3464
4727
  usdCost: effectiveUsdCost.toFixed(6),
@@ -3471,7 +4734,8 @@ async function appendFinishedRunCostEntry(input: {
3471
4734
  return {
3472
4735
  ...pricingDecision,
3473
4736
  pricingSource: effectivePricingSource,
3474
- usdCost: effectiveUsdCost
4737
+ usdCost: effectiveUsdCost,
4738
+ usdCostStatus
3475
4739
  };
3476
4740
  }
3477
4741