bopodev-api 0.1.24 → 0.1.26

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,24 +1,33 @@
1
1
  import { mkdir } from "node:fs/promises";
2
- import { resolve } from "node:path";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
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
6
  import type { 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 {
21
+ addIssueComment,
22
+ approvalRequests,
16
23
  agents,
17
24
  appendActivity,
18
25
  appendHeartbeatRunMessages,
19
26
  companies,
27
+ createApprovalRequest,
20
28
  goals,
21
29
  heartbeatRuns,
30
+ issueComments,
22
31
  issueAttachments,
23
32
  issues,
24
33
  projects
@@ -26,12 +35,18 @@ import {
26
35
  import { appendAuditEvent, appendCost } from "bopodev-db";
27
36
  import { parseRuntimeConfigFromAgentRow } from "../lib/agent-config";
28
37
  import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../lib/git-runtime";
29
- import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
38
+ import {
39
+ isInsidePath,
40
+ normalizeCompanyWorkspacePath,
41
+ resolveCompanyWorkspaceRootPath,
42
+ resolveProjectWorkspacePath
43
+ } from "../lib/instance-paths";
30
44
  import { assertRuntimeCwdForCompany, getProjectWorkspaceContextMap, hasText, resolveAgentFallbackWorkspace } from "../lib/workspace-policy";
31
45
  import type { RealtimeHub } from "../realtime/hub";
32
46
  import { createHeartbeatRunsRealtimeEvent } from "../realtime/heartbeat-runs";
47
+ import { publishAttentionSnapshot } from "../realtime/attention";
33
48
  import { publishOfficeOccupantForAgent } from "../realtime/office-space";
34
- import { checkAgentBudget } from "./budget-service";
49
+ import { appendProjectBudgetUsage, checkAgentBudget, checkProjectBudget } from "./budget-service";
35
50
  import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "./memory-file-service";
36
51
  import { calculateModelPricedUsdCost } from "./model-pricing";
37
52
  import { runPluginHook } from "./plugin-runtime";
@@ -59,6 +74,47 @@ type ActiveHeartbeatRun = {
59
74
  };
60
75
 
61
76
  const activeHeartbeatRuns = new Map<string, ActiveHeartbeatRun>();
77
+ type HeartbeatWakeContext = {
78
+ reason?: string | null;
79
+ commentId?: string | null;
80
+ commentBody?: string | null;
81
+ issueIds?: string[];
82
+ };
83
+
84
+ const AGENT_COMMENT_EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
85
+
86
+ type RunDigestSignal = {
87
+ sequence: number;
88
+ kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
89
+ label: string | null;
90
+ text: string | null;
91
+ payload: string | null;
92
+ signalLevel: "high" | "medium" | "low" | "noise";
93
+ groupKey: string | null;
94
+ source: "stdout" | "stderr" | "trace_fallback";
95
+ };
96
+
97
+ type RunDigest = {
98
+ status: "completed" | "failed" | "skipped";
99
+ headline: string;
100
+ summary: string;
101
+ successes: string[];
102
+ failures: string[];
103
+ blockers: string[];
104
+ nextAction: string;
105
+ evidence: {
106
+ transcriptSignalCount: number;
107
+ outcomeActionCount: number;
108
+ outcomeBlockerCount: number;
109
+ failureType: string | null;
110
+ };
111
+ };
112
+
113
+ type RunTerminalPresentation = {
114
+ internalStatus: "completed" | "failed" | "skipped";
115
+ publicStatus: "completed" | "failed";
116
+ completionReason: RunCompletionReason;
117
+ };
62
118
 
63
119
  export async function claimIssuesForAgent(
64
120
  db: BopoDb,
@@ -75,7 +131,15 @@ export async function claimIssuesForAgent(
75
131
  AND assignee_agent_id = ${agentId}
76
132
  AND status IN ('todo', 'in_progress')
77
133
  AND is_claimed = false
78
- ORDER BY updated_at ASC
134
+ ORDER BY
135
+ CASE priority
136
+ WHEN 'urgent' THEN 0
137
+ WHEN 'high' THEN 1
138
+ WHEN 'medium' THEN 2
139
+ WHEN 'low' THEN 3
140
+ ELSE 4
141
+ END ASC,
142
+ updated_at ASC
79
143
  LIMIT ${maxItems}
80
144
  FOR UPDATE SKIP LOCKED
81
145
  )
@@ -85,12 +149,13 @@ export async function claimIssuesForAgent(
85
149
  updated_at = CURRENT_TIMESTAMP
86
150
  FROM candidate c
87
151
  WHERE i.id = c.id
88
- RETURNING i.id, i.project_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
152
+ RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
89
153
  `);
90
154
 
91
155
  return (result.rows ?? []) as Array<{
92
156
  id: string;
93
157
  project_id: string;
158
+ parent_issue_id: string | null;
94
159
  title: string;
95
160
  body: string | null;
96
161
  status: string;
@@ -193,6 +258,50 @@ export async function stopHeartbeatRun(
193
258
  return { ok: true as const, runId, agentId: run.agentId, status: run.status };
194
259
  }
195
260
 
261
+ export async function findPendingProjectBudgetOverrideBlocksForAgent(
262
+ db: BopoDb,
263
+ companyId: string,
264
+ agentId: string
265
+ ) {
266
+ const assignedRows = await db
267
+ .select({ projectId: issues.projectId })
268
+ .from(issues)
269
+ .where(
270
+ and(
271
+ eq(issues.companyId, companyId),
272
+ eq(issues.assigneeAgentId, agentId),
273
+ inArray(issues.status, ["todo", "in_progress"])
274
+ )
275
+ );
276
+ const assignedProjectIds = new Set(assignedRows.map((row) => row.projectId));
277
+ if (assignedProjectIds.size === 0) {
278
+ return [] as string[];
279
+ }
280
+ const pendingOverrides = await db
281
+ .select({ payloadJson: approvalRequests.payloadJson })
282
+ .from(approvalRequests)
283
+ .where(
284
+ and(
285
+ eq(approvalRequests.companyId, companyId),
286
+ eq(approvalRequests.action, "override_budget"),
287
+ eq(approvalRequests.status, "pending")
288
+ )
289
+ );
290
+ const blockedProjectIds = new Set<string>();
291
+ for (const approval of pendingOverrides) {
292
+ try {
293
+ const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
294
+ const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
295
+ if (projectId && assignedProjectIds.has(projectId)) {
296
+ blockedProjectIds.add(projectId);
297
+ }
298
+ } catch {
299
+ // Ignore malformed payloads to keep enforcement resilient.
300
+ }
301
+ }
302
+ return Array.from(blockedProjectIds);
303
+ }
304
+
196
305
  export async function runHeartbeatForAgent(
197
306
  db: BopoDb,
198
307
  companyId: string,
@@ -203,6 +312,7 @@ export async function runHeartbeatForAgent(
203
312
  realtimeHub?: RealtimeHub;
204
313
  mode?: HeartbeatRunMode;
205
314
  sourceRunId?: string;
315
+ wakeContext?: HeartbeatWakeContext;
206
316
  }
207
317
  ) {
208
318
  const runMode = options?.mode ?? "default";
@@ -250,6 +360,128 @@ export async function runHeartbeatForAgent(
250
360
 
251
361
  const budgetCheck = await checkAgentBudget(db, companyId, agentId);
252
362
  const runId = nanoid(14);
363
+ let blockedProjectBudgetChecks: Array<{ projectId: string; utilizationPct: number; monthlyBudgetUsd: number; usedBudgetUsd: number }> =
364
+ [];
365
+ if (budgetCheck.allowed) {
366
+ const projectIds = await loadProjectIdsForRunBudgetCheck(db, companyId, agentId, options?.wakeContext);
367
+ const projectChecks = await Promise.all(projectIds.map((projectId) => checkProjectBudget(db, companyId, projectId)));
368
+ blockedProjectBudgetChecks = projectChecks
369
+ .filter((entry) => entry.hardStopped)
370
+ .map((entry) => ({
371
+ projectId: entry.projectId,
372
+ utilizationPct: entry.utilizationPct,
373
+ monthlyBudgetUsd: entry.monthlyBudgetUsd,
374
+ usedBudgetUsd: entry.usedBudgetUsd
375
+ }));
376
+ }
377
+ if (blockedProjectBudgetChecks.length > 0) {
378
+ const blockedProjectIds = blockedProjectBudgetChecks.map((entry) => entry.projectId);
379
+ const message = `Heartbeat skipped due to project budget hard-stop: ${blockedProjectIds.join(",")}.`;
380
+ const runDigest = buildRunDigest({
381
+ status: "skipped",
382
+ executionSummary: message,
383
+ outcome: null,
384
+ trace: null,
385
+ signals: []
386
+ });
387
+ const runReport = buildRunCompletionReport({
388
+ companyId,
389
+ agentName: agent.name,
390
+ providerType: agent.providerType as HeartbeatProviderType,
391
+ issueIds: [],
392
+ executionSummary: message,
393
+ outcome: null,
394
+ trace: null,
395
+ digest: runDigest,
396
+ terminal: resolveRunTerminalPresentation({
397
+ internalStatus: "skipped",
398
+ executionSummary: message,
399
+ outcome: null,
400
+ trace: null
401
+ }),
402
+ cost: buildRunCostSummary({
403
+ tokenInput: 0,
404
+ tokenOutput: 0,
405
+ usdCost: null,
406
+ usdCostStatus: "unknown",
407
+ pricingSource: null,
408
+ source: "none"
409
+ })
410
+ });
411
+ const runListMessage = buildRunListMessageFromReport(runReport);
412
+ await db.insert(heartbeatRuns).values({
413
+ id: runId,
414
+ companyId,
415
+ agentId,
416
+ status: "skipped",
417
+ finishedAt: new Date(),
418
+ message: runListMessage
419
+ });
420
+ publishHeartbeatRunStatus(options?.realtimeHub, {
421
+ companyId,
422
+ runId,
423
+ status: "skipped",
424
+ message: runListMessage,
425
+ finishedAt: new Date()
426
+ });
427
+ await appendAuditEvent(db, {
428
+ companyId,
429
+ actorType: "system",
430
+ eventType: "heartbeat.failed",
431
+ entityType: "heartbeat_run",
432
+ entityId: runId,
433
+ correlationId: options?.requestId ?? runId,
434
+ payload: {
435
+ agentId,
436
+ issueIds: [],
437
+ result: runReport.resultSummary,
438
+ message: runListMessage,
439
+ errorType: runReport.completionReason,
440
+ errorMessage: message,
441
+ report: runReport,
442
+ outcome: null,
443
+ usage: {
444
+ tokenInput: 0,
445
+ tokenOutput: 0,
446
+ usdCostStatus: "unknown",
447
+ source: "none"
448
+ },
449
+ trace: null,
450
+ diagnostics: {
451
+ requestId: options?.requestId,
452
+ trigger: runTrigger
453
+ }
454
+ }
455
+ });
456
+ for (const blockedProject of blockedProjectBudgetChecks) {
457
+ const approvalId = await ensureProjectBudgetOverrideApprovalRequest(db, {
458
+ companyId,
459
+ projectId: blockedProject.projectId,
460
+ utilizationPct: blockedProject.utilizationPct,
461
+ monthlyBudgetUsd: blockedProject.monthlyBudgetUsd,
462
+ usedBudgetUsd: blockedProject.usedBudgetUsd,
463
+ runId
464
+ });
465
+ if (approvalId && options?.realtimeHub) {
466
+ await publishAttentionSnapshot(db, options.realtimeHub, companyId);
467
+ }
468
+ await appendAuditEvent(db, {
469
+ companyId,
470
+ actorType: "system",
471
+ eventType: "project_budget.hard_stop",
472
+ entityType: "project",
473
+ entityId: blockedProject.projectId,
474
+ payload: {
475
+ utilizationPct: blockedProject.utilizationPct,
476
+ monthlyBudgetUsd: blockedProject.monthlyBudgetUsd,
477
+ usedBudgetUsd: blockedProject.usedBudgetUsd,
478
+ runId
479
+ }
480
+ });
481
+ }
482
+ await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
483
+ return runId;
484
+ }
253
485
  if (budgetCheck.allowed) {
254
486
  const claimed = await insertStartedRunAtomic(db, {
255
487
  id: runId,
@@ -260,45 +492,156 @@ export async function runHeartbeatForAgent(
260
492
  if (!claimed) {
261
493
  const skippedRunId = nanoid(14);
262
494
  const skippedAt = new Date();
495
+ const overlapMessage = "Heartbeat skipped: another run is already in progress for this agent.";
496
+ const runDigest = buildRunDigest({
497
+ status: "skipped",
498
+ executionSummary: overlapMessage,
499
+ outcome: null,
500
+ trace: null,
501
+ signals: []
502
+ });
503
+ const runReport = buildRunCompletionReport({
504
+ companyId,
505
+ agentName: agent.name,
506
+ providerType: agent.providerType as HeartbeatProviderType,
507
+ issueIds: [],
508
+ executionSummary: overlapMessage,
509
+ outcome: null,
510
+ trace: null,
511
+ digest: runDigest,
512
+ terminal: resolveRunTerminalPresentation({
513
+ internalStatus: "skipped",
514
+ executionSummary: overlapMessage,
515
+ outcome: null,
516
+ trace: null
517
+ }),
518
+ cost: buildRunCostSummary({
519
+ tokenInput: 0,
520
+ tokenOutput: 0,
521
+ usdCost: null,
522
+ usdCostStatus: "unknown",
523
+ pricingSource: null,
524
+ source: "none"
525
+ })
526
+ });
527
+ const runListMessage = buildRunListMessageFromReport(runReport);
263
528
  await db.insert(heartbeatRuns).values({
264
529
  id: skippedRunId,
265
530
  companyId,
266
531
  agentId,
267
532
  status: "skipped",
268
533
  finishedAt: skippedAt,
269
- message: "Heartbeat skipped: another run is already in progress for this agent."
534
+ message: runListMessage
270
535
  });
271
536
  publishHeartbeatRunStatus(options?.realtimeHub, {
272
537
  companyId,
273
538
  runId: skippedRunId,
274
539
  status: "skipped",
275
- message: "Heartbeat skipped: another run is already in progress for this agent.",
540
+ message: runListMessage,
276
541
  finishedAt: skippedAt
277
542
  });
278
543
  await appendAuditEvent(db, {
279
544
  companyId,
280
545
  actorType: "system",
281
- eventType: "heartbeat.skipped_overlap",
546
+ eventType: "heartbeat.failed",
282
547
  entityType: "heartbeat_run",
283
548
  entityId: skippedRunId,
284
549
  correlationId: options?.requestId ?? skippedRunId,
285
- payload: { agentId, requestId: options?.requestId, trigger: runTrigger }
550
+ payload: {
551
+ agentId,
552
+ issueIds: [],
553
+ result: runReport.resultSummary,
554
+ message: runListMessage,
555
+ errorType: runReport.completionReason,
556
+ errorMessage: overlapMessage,
557
+ report: runReport,
558
+ outcome: null,
559
+ usage: {
560
+ tokenInput: 0,
561
+ tokenOutput: 0,
562
+ usdCostStatus: "unknown",
563
+ source: "none"
564
+ },
565
+ trace: null,
566
+ diagnostics: { requestId: options?.requestId, trigger: runTrigger }
567
+ }
286
568
  });
287
569
  return skippedRunId;
288
570
  }
289
571
  } else {
572
+ const budgetMessage = "Heartbeat skipped due to budget hard-stop.";
573
+ const runDigest = buildRunDigest({
574
+ status: "skipped",
575
+ executionSummary: budgetMessage,
576
+ outcome: null,
577
+ trace: null,
578
+ signals: []
579
+ });
580
+ const runReport = buildRunCompletionReport({
581
+ companyId,
582
+ agentName: agent.name,
583
+ providerType: agent.providerType as HeartbeatProviderType,
584
+ issueIds: [],
585
+ executionSummary: budgetMessage,
586
+ outcome: null,
587
+ trace: null,
588
+ digest: runDigest,
589
+ terminal: resolveRunTerminalPresentation({
590
+ internalStatus: "skipped",
591
+ executionSummary: budgetMessage,
592
+ outcome: null,
593
+ trace: null
594
+ }),
595
+ cost: buildRunCostSummary({
596
+ tokenInput: 0,
597
+ tokenOutput: 0,
598
+ usdCost: null,
599
+ usdCostStatus: "unknown",
600
+ pricingSource: null,
601
+ source: "none"
602
+ })
603
+ });
604
+ const runListMessage = buildRunListMessageFromReport(runReport);
290
605
  await db.insert(heartbeatRuns).values({
291
606
  id: runId,
292
607
  companyId,
293
608
  agentId,
294
609
  status: "skipped",
295
- message: "Heartbeat skipped due to budget hard-stop."
610
+ finishedAt: new Date(),
611
+ message: runListMessage
296
612
  });
297
613
  publishHeartbeatRunStatus(options?.realtimeHub, {
298
614
  companyId,
299
615
  runId,
300
616
  status: "skipped",
301
- message: "Heartbeat skipped due to budget hard-stop."
617
+ message: runListMessage,
618
+ finishedAt: new Date()
619
+ });
620
+ await appendAuditEvent(db, {
621
+ companyId,
622
+ actorType: "system",
623
+ eventType: "heartbeat.failed",
624
+ entityType: "heartbeat_run",
625
+ entityId: runId,
626
+ correlationId: options?.requestId ?? runId,
627
+ payload: {
628
+ agentId,
629
+ issueIds: [],
630
+ result: runReport.resultSummary,
631
+ message: runListMessage,
632
+ errorType: runReport.completionReason,
633
+ errorMessage: budgetMessage,
634
+ report: runReport,
635
+ outcome: null,
636
+ usage: {
637
+ tokenInput: 0,
638
+ tokenOutput: 0,
639
+ usdCostStatus: "unknown",
640
+ source: "none"
641
+ },
642
+ trace: null,
643
+ diagnostics: { requestId: options?.requestId, trigger: runTrigger }
644
+ }
302
645
  });
303
646
  }
304
647
 
@@ -315,7 +658,8 @@ export async function runHeartbeatForAgent(
315
658
  requestId: options?.requestId ?? null,
316
659
  trigger: runTrigger,
317
660
  mode: runMode,
318
- sourceRunId: options?.sourceRunId ?? null
661
+ sourceRunId: options?.sourceRunId ?? null,
662
+ wakeContext: options?.wakeContext ?? null
319
663
  }
320
664
  });
321
665
  publishHeartbeatRunStatus(options?.realtimeHub, {
@@ -327,6 +671,17 @@ export async function runHeartbeatForAgent(
327
671
  }
328
672
 
329
673
  if (!budgetCheck.allowed) {
674
+ const approvalId = await ensureBudgetOverrideApprovalRequest(db, {
675
+ companyId,
676
+ agentId,
677
+ utilizationPct: budgetCheck.utilizationPct,
678
+ usedBudgetUsd: Number(agent.usedBudgetUsd),
679
+ monthlyBudgetUsd: Number(agent.monthlyBudgetUsd),
680
+ runId
681
+ });
682
+ if (approvalId && options?.realtimeHub) {
683
+ await publishAttentionSnapshot(db, options.realtimeHub, companyId);
684
+ }
330
685
  await appendAuditEvent(db, {
331
686
  companyId,
332
687
  actorType: "system",
@@ -351,6 +706,8 @@ export async function runHeartbeatForAgent(
351
706
  }
352
707
 
353
708
  let issueIds: string[] = [];
709
+ let claimedIssueIds: string[] = [];
710
+ let executionWorkItemsForBudget: Array<{ issueId: string; projectId: string }> = [];
354
711
  let state: AgentState & {
355
712
  runtime?: {
356
713
  command?: string;
@@ -378,6 +735,13 @@ export async function runHeartbeatForAgent(
378
735
  let runtimeLaunchSummary: ReturnType<typeof summarizeRuntimeLaunch> | null = null;
379
736
  let primaryIssueId: string | null = null;
380
737
  let primaryProjectId: string | null = null;
738
+ let providerUsageLimitDisposition:
739
+ | {
740
+ message: string;
741
+ notifyBoard: boolean;
742
+ pauseAgent: boolean;
743
+ }
744
+ | null = null;
381
745
  let transcriptSequence = 0;
382
746
  let transcriptWriteQueue = Promise.resolve();
383
747
  let transcriptLiveCount = 0;
@@ -386,6 +750,7 @@ export async function runHeartbeatForAgent(
386
750
  let transcriptPersistFailureReported = false;
387
751
  let pluginFailureSummary: string[] = [];
388
752
  const seenResultMessages = new Set<string>();
753
+ const runDigestSignals: RunDigestSignal[] = [];
389
754
 
390
755
  const enqueueTranscriptEvent = (event: {
391
756
  kind: string;
@@ -413,6 +778,21 @@ export async function runHeartbeatForAgent(
413
778
  if (signalLevel === "high") {
414
779
  transcriptLiveHighSignalCount += 1;
415
780
  }
781
+ if (isUsefulTranscriptSignal(signalLevel)) {
782
+ runDigestSignals.push({
783
+ sequence,
784
+ kind: normalizeTranscriptKind(event.kind),
785
+ label: event.label ?? null,
786
+ text: event.text ?? null,
787
+ payload: event.payload ?? null,
788
+ signalLevel,
789
+ groupKey: groupKey ?? null,
790
+ source
791
+ });
792
+ if (runDigestSignals.length > 200) {
793
+ runDigestSignals.splice(0, runDigestSignals.length - 200);
794
+ }
795
+ }
416
796
  transcriptWriteQueue = transcriptWriteQueue
417
797
  .then(async () => {
418
798
  await appendHeartbeatRunMessages(db, {
@@ -511,10 +891,16 @@ export async function runHeartbeatForAgent(
511
891
  },
512
892
  failClosed: false
513
893
  });
514
- const workItems = await claimIssuesForAgent(db, companyId, agentId, runId);
515
- issueIds = workItems.map((item) => item.id);
516
- primaryIssueId = workItems[0]?.id ?? null;
517
- primaryProjectId = workItems[0]?.project_id ?? null;
894
+ const isCommentOrderWake = options?.wakeContext?.reason === "issue_comment_recipient";
895
+ const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
896
+ const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
897
+ const contextWorkItems = resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext);
898
+ executionWorkItemsForBudget = contextWorkItems.map((item) => ({ issueId: item.id, projectId: item.project_id }));
899
+ claimedIssueIds = workItems.map((item) => item.id);
900
+ issueIds = contextWorkItems.map((item) => item.id);
901
+ primaryIssueId = contextWorkItems[0]?.id ?? null;
902
+ primaryProjectId = contextWorkItems[0]?.project_id ?? null;
903
+ const resolvedWakeContext = await resolveHeartbeatWakeContext(db, companyId, options?.wakeContext);
518
904
  await runPluginHook(db, {
519
905
  hook: "afterClaim",
520
906
  context: {
@@ -523,7 +909,7 @@ export async function runHeartbeatForAgent(
523
909
  runId,
524
910
  requestId: options?.requestId,
525
911
  providerType: agent.providerType,
526
- workItemCount: workItems.length
912
+ workItemCount: contextWorkItems.length
527
913
  },
528
914
  failClosed: false
529
915
  });
@@ -539,7 +925,8 @@ export async function runHeartbeatForAgent(
539
925
  companyId,
540
926
  agentId: agent.id,
541
927
  heartbeatRunId: runId,
542
- canHireAgents: agent.canHireAgents
928
+ canHireAgents: agent.canHireAgents,
929
+ wakeContext: options?.wakeContext
543
930
  });
544
931
  const runtimeFromConfig = {
545
932
  command: persistedRuntime.runtimeCommand,
@@ -580,18 +967,13 @@ export async function runHeartbeatForAgent(
580
967
  db,
581
968
  companyId,
582
969
  agent.id,
583
- workItems,
970
+ contextWorkItems,
584
971
  mergedRuntime
585
972
  );
586
973
  state = {
587
974
  ...state,
588
975
  runtime: workspaceResolution.runtime
589
976
  };
590
- memoryContext = await loadAgentMemoryContext({
591
- companyId,
592
- agentId
593
- });
594
-
595
977
  let context = await buildHeartbeatContext(db, companyId, {
596
978
  agentId,
597
979
  agentName: agent.name,
@@ -602,8 +984,27 @@ export async function runHeartbeatForAgent(
602
984
  state,
603
985
  memoryContext,
604
986
  runtime: workspaceResolution.runtime,
605
- workItems
987
+ workItems: contextWorkItems,
988
+ wakeContext: resolvedWakeContext
989
+ });
990
+ const memoryQueryText = [
991
+ context.company.mission ?? "",
992
+ ...(context.goalContext?.companyGoals ?? []),
993
+ ...(context.goalContext?.projectGoals ?? []),
994
+ ...context.workItems.map((item) => `${item.title} ${item.body ?? ""}`)
995
+ ]
996
+ .join(" ")
997
+ .trim();
998
+ memoryContext = await loadAgentMemoryContext({
999
+ companyId,
1000
+ agentId,
1001
+ projectIds: context.workItems.map((item) => item.projectId),
1002
+ queryText: memoryQueryText
606
1003
  });
1004
+ context = {
1005
+ ...context,
1006
+ memoryContext
1007
+ };
607
1008
  if (workspaceResolution.warnings.length > 0) {
608
1009
  await appendAuditEvent(db, {
609
1010
  companyId,
@@ -663,7 +1064,7 @@ export async function runHeartbeatForAgent(
663
1064
  resolveControlPlanePreflightEnabled() &&
664
1065
  shouldRequireControlPlanePreflight(
665
1066
  agent.providerType as HeartbeatProviderType,
666
- workItems.length
1067
+ contextWorkItems.length
667
1068
  )
668
1069
  ) {
669
1070
  const preflight = await runControlPlaneConnectivityPreflight({
@@ -743,7 +1144,7 @@ export async function runHeartbeatForAgent(
743
1144
  runId,
744
1145
  requestId: options?.requestId,
745
1146
  providerType: agent.providerType,
746
- workItemCount: workItems.length,
1147
+ workItemCount: contextWorkItems.length,
747
1148
  runtime: {
748
1149
  command: workspaceResolution.runtime.command,
749
1150
  cwd: workspaceResolution.runtime.cwd
@@ -782,7 +1183,20 @@ export async function runHeartbeatForAgent(
782
1183
  runtime: workspaceResolution.runtime,
783
1184
  externalAbortSignal: activeRunAbort.signal
784
1185
  });
785
- executionSummary = execution.summary;
1186
+ const usageLimitHint = execution.dispositionHint?.kind === "provider_usage_limited" ? execution.dispositionHint : null;
1187
+ if (usageLimitHint) {
1188
+ providerUsageLimitDisposition = {
1189
+ message: usageLimitHint.message,
1190
+ notifyBoard: usageLimitHint.notifyBoard,
1191
+ pauseAgent: usageLimitHint.pauseAgent
1192
+ };
1193
+ }
1194
+ executionSummary =
1195
+ usageLimitHint?.message && usageLimitHint.message.trim().length > 0 ? usageLimitHint.message.trim() : execution.summary;
1196
+ executionSummary = sanitizeAgentSummaryCommentBody(extractNaturalRunUpdate(executionSummary));
1197
+ const persistedExecutionStatus: "ok" | "failed" | "skipped" = usageLimitHint ? "skipped" : execution.status;
1198
+ const persistedRunStatus: "completed" | "failed" | "skipped" =
1199
+ persistedExecutionStatus === "ok" ? "completed" : persistedExecutionStatus;
786
1200
  const normalizedUsage = execution.usage ?? {
787
1201
  inputTokens: Math.max(0, execution.tokenInput),
788
1202
  cachedInputTokens: 0,
@@ -811,7 +1225,6 @@ export async function runHeartbeatForAgent(
811
1225
  if (afterAdapterHook.failures.length > 0) {
812
1226
  pluginFailureSummary = [...pluginFailureSummary, ...afterAdapterHook.failures];
813
1227
  }
814
- emitCanonicalResultEvent(executionSummary, "completed");
815
1228
  executionTrace = execution.trace ?? null;
816
1229
  const runtimeModelId = resolveRuntimeModelId({
817
1230
  runtimeModel: persistedRuntime.runtimeModel,
@@ -822,6 +1235,7 @@ export async function runHeartbeatForAgent(
822
1235
  const costDecision = await appendFinishedRunCostEntry({
823
1236
  db,
824
1237
  companyId,
1238
+ runId,
825
1239
  providerType: agent.providerType,
826
1240
  runtimeModelId: effectivePricingModelId ?? runtimeModelId,
827
1241
  pricingProviderType: effectivePricingProviderType,
@@ -833,18 +1247,27 @@ export async function runHeartbeatForAgent(
833
1247
  issueId: primaryIssueId,
834
1248
  projectId: primaryProjectId,
835
1249
  agentId,
836
- status: execution.status
1250
+ status: persistedExecutionStatus
837
1251
  });
838
1252
  const executionUsdCost = costDecision.usdCost;
1253
+ await appendProjectBudgetUsage(db, {
1254
+ companyId,
1255
+ projectCostsUsd: buildProjectBudgetCostAllocations(executionWorkItemsForBudget, executionUsdCost)
1256
+ });
839
1257
  const parsedOutcome = ExecutionOutcomeSchema.safeParse(execution.outcome);
840
1258
  executionOutcome = parsedOutcome.success ? parsedOutcome.data : null;
841
1259
  const persistedMemory = await persistHeartbeatMemory({
842
1260
  companyId,
843
1261
  agentId,
844
1262
  runId,
845
- status: execution.status,
846
- summary: execution.summary,
847
- outcomeKind: executionOutcome?.kind ?? null
1263
+ status: persistedExecutionStatus === "ok" ? "ok" : "failed",
1264
+ summary: executionSummary,
1265
+ outcomeKind: executionOutcome?.kind ?? null,
1266
+ mission: context.company.mission ?? null,
1267
+ goalContext: {
1268
+ companyGoals: context.goalContext?.companyGoals ?? [],
1269
+ projectGoals: context.goalContext?.projectGoals ?? []
1270
+ }
848
1271
  });
849
1272
  await appendAuditEvent(db, {
850
1273
  companyId,
@@ -860,7 +1283,7 @@ export async function runHeartbeatForAgent(
860
1283
  candidateFacts: persistedMemory.candidateFacts
861
1284
  }
862
1285
  });
863
- if (execution.status === "ok") {
1286
+ if (execution.status === "ok" && !usageLimitHint) {
864
1287
  for (const fact of persistedMemory.candidateFacts) {
865
1288
  const targetFile = await appendDurableFact({
866
1289
  companyId,
@@ -883,13 +1306,33 @@ export async function runHeartbeatForAgent(
883
1306
  });
884
1307
  }
885
1308
  }
1309
+ const missionAlignment = computeMissionAlignmentSignal({
1310
+ summary: executionSummary,
1311
+ mission: context.company.mission ?? null,
1312
+ companyGoals: context.goalContext?.companyGoals ?? [],
1313
+ projectGoals: context.goalContext?.projectGoals ?? []
1314
+ });
1315
+ await appendAuditEvent(db, {
1316
+ companyId,
1317
+ actorType: "system",
1318
+ eventType: "heartbeat.memory_alignment_scored",
1319
+ entityType: "heartbeat_run",
1320
+ entityId: runId,
1321
+ correlationId: options?.requestId ?? runId,
1322
+ payload: {
1323
+ agentId,
1324
+ score: missionAlignment.score,
1325
+ matchedMissionTerms: missionAlignment.matchedMissionTerms,
1326
+ matchedGoalTerms: missionAlignment.matchedGoalTerms
1327
+ }
1328
+ });
886
1329
 
887
1330
  if (
888
1331
  execution.nextState ||
889
1332
  executionUsdCost > 0 ||
890
1333
  effectiveTokenInput > 0 ||
891
1334
  effectiveTokenOutput > 0 ||
892
- execution.status !== "skipped"
1335
+ persistedExecutionStatus !== "skipped"
893
1336
  ) {
894
1337
  await db
895
1338
  .update(agents)
@@ -967,8 +1410,8 @@ export async function runHeartbeatForAgent(
967
1410
  runId,
968
1411
  requestId: options?.requestId,
969
1412
  providerType: agent.providerType,
970
- status: execution.status,
971
- summary: execution.summary
1413
+ status: persistedExecutionStatus,
1414
+ summary: executionSummary
972
1415
  },
973
1416
  failClosed: false
974
1417
  });
@@ -976,25 +1419,95 @@ export async function runHeartbeatForAgent(
976
1419
  pluginFailureSummary = [...pluginFailureSummary, ...beforePersistHook.failures];
977
1420
  }
978
1421
 
1422
+ const runDigest = buildRunDigest({
1423
+ status: persistedRunStatus,
1424
+ executionSummary,
1425
+ outcome: executionOutcome,
1426
+ trace: executionTrace,
1427
+ signals: runDigestSignals
1428
+ });
1429
+ const terminalPresentation = resolveRunTerminalPresentation({
1430
+ internalStatus: persistedRunStatus,
1431
+ executionSummary,
1432
+ outcome: executionOutcome,
1433
+ trace: executionTrace
1434
+ });
1435
+ const runCost = buildRunCostSummary({
1436
+ tokenInput: effectiveTokenInput,
1437
+ tokenOutput: effectiveTokenOutput,
1438
+ usdCost: costDecision.usdCostStatus === "unknown" ? null : executionUsdCost,
1439
+ usdCostStatus: costDecision.usdCostStatus,
1440
+ pricingSource: costDecision.pricingSource ?? null,
1441
+ source: readTraceString(execution.trace, "usageSource") ?? "unknown"
1442
+ });
1443
+ const runReport = buildRunCompletionReport({
1444
+ companyId,
1445
+ agentName: agent.name,
1446
+ providerType: agent.providerType as HeartbeatProviderType,
1447
+ issueIds,
1448
+ executionSummary,
1449
+ outcome: executionOutcome,
1450
+ finalRunOutput: execution.finalRunOutput ?? null,
1451
+ trace: executionTrace,
1452
+ digest: runDigest,
1453
+ terminal: terminalPresentation,
1454
+ cost: runCost,
1455
+ runtimeCwd: workspaceResolution.runtime.cwd
1456
+ });
1457
+ emitCanonicalResultEvent(runReport.resultSummary, runReport.finalStatus);
1458
+ const runListMessage = buildRunListMessageFromReport(runReport);
979
1459
  await db
980
1460
  .update(heartbeatRuns)
981
1461
  .set({
982
- status: execution.status === "failed" ? "failed" : "completed",
1462
+ status: persistedRunStatus,
983
1463
  finishedAt: new Date(),
984
- message: execution.summary
1464
+ message: runListMessage
985
1465
  })
986
1466
  .where(eq(heartbeatRuns.id, runId));
987
1467
  publishHeartbeatRunStatus(options?.realtimeHub, {
988
1468
  companyId,
989
1469
  runId,
990
- status: execution.status === "failed" ? "failed" : "completed",
991
- message: execution.summary,
1470
+ status: persistedRunStatus,
1471
+ message: runListMessage,
992
1472
  finishedAt: new Date()
993
1473
  });
1474
+ await appendAuditEvent(db, {
1475
+ companyId,
1476
+ actorType: "system",
1477
+ eventType: "heartbeat.run_digest",
1478
+ entityType: "heartbeat_run",
1479
+ entityId: runId,
1480
+ correlationId: options?.requestId ?? runId,
1481
+ payload: runDigest
1482
+ });
1483
+ try {
1484
+ await appendRunSummaryComments(db, {
1485
+ companyId,
1486
+ issueIds,
1487
+ agentId,
1488
+ runId,
1489
+ report: runReport
1490
+ });
1491
+ } catch (commentError) {
1492
+ await appendAuditEvent(db, {
1493
+ companyId,
1494
+ actorType: "system",
1495
+ eventType: "heartbeat.run_comment_failed",
1496
+ entityType: "heartbeat_run",
1497
+ entityId: runId,
1498
+ correlationId: options?.requestId ?? runId,
1499
+ payload: {
1500
+ agentId,
1501
+ issueIds,
1502
+ error: String(commentError)
1503
+ }
1504
+ });
1505
+ }
994
1506
 
995
1507
  const fallbackMessages = normalizeTraceTranscript(executionTrace);
996
1508
  const fallbackHighSignalCount = fallbackMessages.filter((message) => message.signalLevel === "high").length;
997
1509
  const shouldAppendFallback =
1510
+ !providerUsageLimitDisposition &&
998
1511
  fallbackMessages.length > 0 &&
999
1512
  (transcriptLiveCount === 0 ||
1000
1513
  transcriptLiveUsefulCount < 2 ||
@@ -1039,6 +1552,24 @@ export async function runHeartbeatForAgent(
1039
1552
  source: "trace_fallback",
1040
1553
  createdAt
1041
1554
  }));
1555
+ for (const row of rows) {
1556
+ if (!isUsefulTranscriptSignal(row.signalLevel)) {
1557
+ continue;
1558
+ }
1559
+ runDigestSignals.push({
1560
+ sequence: row.sequence,
1561
+ kind: row.kind,
1562
+ label: row.label,
1563
+ text: row.text,
1564
+ payload: row.payloadJson,
1565
+ signalLevel: row.signalLevel,
1566
+ groupKey: row.groupKey,
1567
+ source: "trace_fallback"
1568
+ });
1569
+ }
1570
+ if (runDigestSignals.length > 200) {
1571
+ runDigestSignals.splice(0, runDigestSignals.length - 200);
1572
+ }
1042
1573
  await appendHeartbeatRunMessages(db, {
1043
1574
  companyId,
1044
1575
  runId,
@@ -1073,8 +1604,8 @@ export async function runHeartbeatForAgent(
1073
1604
  runId,
1074
1605
  requestId: options?.requestId,
1075
1606
  providerType: agent.providerType,
1076
- status: execution.status,
1077
- summary: execution.summary,
1607
+ status: persistedExecutionStatus,
1608
+ summary: executionSummary,
1078
1609
  trace: executionTrace,
1079
1610
  outcome: executionOutcome
1080
1611
  },
@@ -1084,6 +1615,48 @@ export async function runHeartbeatForAgent(
1084
1615
  pluginFailureSummary = [...pluginFailureSummary, ...afterPersistHook.failures];
1085
1616
  }
1086
1617
 
1618
+ if (providerUsageLimitDisposition) {
1619
+ await appendAuditEvent(db, {
1620
+ companyId,
1621
+ actorType: "system",
1622
+ eventType: "heartbeat.provider_usage_limited",
1623
+ entityType: "heartbeat_run",
1624
+ entityId: runId,
1625
+ correlationId: options?.requestId ?? runId,
1626
+ payload: {
1627
+ agentId,
1628
+ providerType: agent.providerType,
1629
+ issueIds,
1630
+ message: providerUsageLimitDisposition.message
1631
+ }
1632
+ });
1633
+ const pauseResult = providerUsageLimitDisposition.pauseAgent
1634
+ ? await pauseAgentForProviderUsageLimit(db, {
1635
+ companyId,
1636
+ agentId,
1637
+ requestId: options?.requestId ?? runId,
1638
+ runId,
1639
+ providerType: agent.providerType,
1640
+ message: providerUsageLimitDisposition.message
1641
+ })
1642
+ : { paused: false };
1643
+ if (providerUsageLimitDisposition.notifyBoard) {
1644
+ await appendProviderUsageLimitBoardComments(db, {
1645
+ companyId,
1646
+ issueIds,
1647
+ agentId,
1648
+ runId,
1649
+ providerType: agent.providerType,
1650
+ message: providerUsageLimitDisposition.message,
1651
+ paused: pauseResult.paused
1652
+ });
1653
+ if (options?.realtimeHub) {
1654
+ await publishAttentionSnapshot(db, options.realtimeHub, companyId);
1655
+ }
1656
+ }
1657
+ await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
1658
+ }
1659
+
1087
1660
  await appendAuditEvent(db, {
1088
1661
  companyId,
1089
1662
  actorType: "system",
@@ -1093,14 +1666,17 @@ export async function runHeartbeatForAgent(
1093
1666
  correlationId: options?.requestId ?? runId,
1094
1667
  payload: {
1095
1668
  agentId,
1096
- result: execution.summary,
1097
- message: execution.summary,
1669
+ status: persistedRunStatus,
1670
+ result: runReport.resultSummary,
1671
+ message: runListMessage,
1672
+ report: runReport,
1098
1673
  outcome: executionOutcome,
1099
1674
  issueIds,
1100
1675
  usage: {
1101
1676
  tokenInput: effectiveTokenInput,
1102
1677
  tokenOutput: effectiveTokenOutput,
1103
1678
  usdCost: executionUsdCost,
1679
+ usdCostStatus: costDecision.usdCostStatus,
1104
1680
  source: readTraceString(execution.trace, "usageSource") ?? "unknown"
1105
1681
  },
1106
1682
  trace: execution.trace ?? null,
@@ -1160,13 +1736,41 @@ export async function runHeartbeatForAgent(
1160
1736
  cwd: runtimeLaunchSummary.cwd ?? null
1161
1737
  };
1162
1738
  }
1739
+ try {
1740
+ const failedMemory = await persistHeartbeatMemory({
1741
+ companyId,
1742
+ agentId,
1743
+ runId,
1744
+ status: "failed",
1745
+ summary: executionSummary,
1746
+ outcomeKind: executionOutcome?.kind ?? null
1747
+ });
1748
+ await appendAuditEvent(db, {
1749
+ companyId,
1750
+ actorType: "system",
1751
+ eventType: "heartbeat.memory_updated",
1752
+ entityType: "heartbeat_run",
1753
+ entityId: runId,
1754
+ correlationId: options?.requestId ?? runId,
1755
+ payload: {
1756
+ agentId,
1757
+ memoryRoot: failedMemory.memoryRoot,
1758
+ dailyNotePath: failedMemory.dailyNotePath,
1759
+ candidateFacts: failedMemory.candidateFacts,
1760
+ failurePath: true
1761
+ }
1762
+ });
1763
+ } catch {
1764
+ // best effort; do not mask primary heartbeat failure.
1765
+ }
1163
1766
  const runtimeModelId = resolveRuntimeModelId({
1164
1767
  runtimeModel: persistedRuntime.runtimeModel,
1165
1768
  stateBlob: agent.stateBlob
1166
1769
  });
1167
- await appendFinishedRunCostEntry({
1770
+ const failureCostDecision = await appendFinishedRunCostEntry({
1168
1771
  db,
1169
1772
  companyId,
1773
+ runId,
1170
1774
  providerType: agent.providerType,
1171
1775
  runtimeModelId,
1172
1776
  pricingProviderType: agent.providerType,
@@ -1178,21 +1782,96 @@ export async function runHeartbeatForAgent(
1178
1782
  agentId,
1179
1783
  status: "failed"
1180
1784
  });
1181
- await db
1182
- .update(heartbeatRuns)
1183
- .set({
1184
- status: "failed",
1185
- finishedAt: new Date(),
1186
- message: executionSummary
1187
- })
1188
- .where(eq(heartbeatRuns.id, runId));
1189
- publishHeartbeatRunStatus(options?.realtimeHub, {
1785
+ await appendProjectBudgetUsage(db, {
1190
1786
  companyId,
1191
- runId,
1787
+ projectCostsUsd: buildProjectBudgetCostAllocations(executionWorkItemsForBudget, failureCostDecision.usdCost)
1788
+ });
1789
+ const runDigest = buildRunDigest({
1192
1790
  status: "failed",
1193
- message: executionSummary,
1194
- finishedAt: new Date()
1791
+ executionSummary,
1792
+ outcome: executionOutcome,
1793
+ trace: executionTrace,
1794
+ signals: runDigestSignals
1195
1795
  });
1796
+ const runCost = buildRunCostSummary({
1797
+ tokenInput: 0,
1798
+ tokenOutput: 0,
1799
+ usdCost: failureCostDecision.usdCostStatus === "unknown" ? null : failureCostDecision.usdCost,
1800
+ usdCostStatus: failureCostDecision.usdCostStatus,
1801
+ pricingSource: failureCostDecision.pricingSource ?? null,
1802
+ source: readTraceString(executionTrace, "usageSource") ?? "unknown"
1803
+ });
1804
+ const runReport = buildRunCompletionReport({
1805
+ companyId,
1806
+ agentName: agent.name,
1807
+ providerType: agent.providerType as HeartbeatProviderType,
1808
+ issueIds,
1809
+ executionSummary,
1810
+ outcome: executionOutcome,
1811
+ finalRunOutput: null,
1812
+ trace: executionTrace,
1813
+ digest: runDigest,
1814
+ terminal: resolveRunTerminalPresentation({
1815
+ internalStatus: "failed",
1816
+ executionSummary,
1817
+ outcome: executionOutcome,
1818
+ trace: executionTrace,
1819
+ errorType: classified.type
1820
+ }),
1821
+ cost: runCost,
1822
+ runtimeCwd: runtimeLaunchSummary?.cwd ?? persistedRuntime.runtimeCwd ?? null,
1823
+ errorType: classified.type,
1824
+ errorMessage: classified.message
1825
+ });
1826
+ const runListMessage = buildRunListMessageFromReport(runReport);
1827
+ await db
1828
+ .update(heartbeatRuns)
1829
+ .set({
1830
+ status: "failed",
1831
+ finishedAt: new Date(),
1832
+ message: runListMessage
1833
+ })
1834
+ .where(eq(heartbeatRuns.id, runId));
1835
+ publishHeartbeatRunStatus(options?.realtimeHub, {
1836
+ companyId,
1837
+ runId,
1838
+ status: "failed",
1839
+ message: runListMessage,
1840
+ finishedAt: new Date()
1841
+ });
1842
+ emitCanonicalResultEvent(runReport.resultSummary, runReport.finalStatus);
1843
+ await appendAuditEvent(db, {
1844
+ companyId,
1845
+ actorType: "system",
1846
+ eventType: "heartbeat.run_digest",
1847
+ entityType: "heartbeat_run",
1848
+ entityId: runId,
1849
+ correlationId: options?.requestId ?? runId,
1850
+ payload: runDigest
1851
+ });
1852
+ try {
1853
+ await appendRunSummaryComments(db, {
1854
+ companyId,
1855
+ issueIds,
1856
+ agentId,
1857
+ runId,
1858
+ report: runReport
1859
+ });
1860
+ } catch (commentError) {
1861
+ await appendAuditEvent(db, {
1862
+ companyId,
1863
+ actorType: "system",
1864
+ eventType: "heartbeat.run_comment_failed",
1865
+ entityType: "heartbeat_run",
1866
+ entityId: runId,
1867
+ correlationId: options?.requestId ?? runId,
1868
+ payload: {
1869
+ agentId,
1870
+ issueIds,
1871
+ error: String(commentError)
1872
+ }
1873
+ });
1874
+ }
1196
1875
  await appendAuditEvent(db, {
1197
1876
  companyId,
1198
1877
  actorType: "system",
@@ -1203,12 +1882,17 @@ export async function runHeartbeatForAgent(
1203
1882
  payload: {
1204
1883
  agentId,
1205
1884
  issueIds,
1206
- result: executionSummary,
1207
- message: executionSummary,
1885
+ result: runReport.resultSummary,
1886
+ message: runListMessage,
1208
1887
  errorType: classified.type,
1209
1888
  errorMessage: classified.message,
1889
+ report: runReport,
1210
1890
  outcome: executionOutcome,
1211
1891
  usage: {
1892
+ tokenInput: 0,
1893
+ tokenOutput: 0,
1894
+ usdCost: failureCostDecision.usdCost,
1895
+ usdCostStatus: failureCostDecision.usdCostStatus,
1212
1896
  source: readTraceString(executionTrace, "usageSource") ?? "unknown"
1213
1897
  },
1214
1898
  trace: executionTrace,
@@ -1240,7 +1924,7 @@ export async function runHeartbeatForAgent(
1240
1924
  await transcriptWriteQueue;
1241
1925
  unregisterActiveHeartbeatRun(runId);
1242
1926
  try {
1243
- await releaseClaimedIssues(db, companyId, issueIds);
1927
+ await releaseClaimedIssues(db, companyId, claimedIssueIds);
1244
1928
  } catch (releaseError) {
1245
1929
  await appendAuditEvent(db, {
1246
1930
  companyId,
@@ -1251,12 +1935,21 @@ export async function runHeartbeatForAgent(
1251
1935
  correlationId: options?.requestId ?? runId,
1252
1936
  payload: {
1253
1937
  agentId,
1254
- issueIds,
1938
+ issueIds: claimedIssueIds,
1255
1939
  error: String(releaseError)
1256
1940
  }
1257
1941
  });
1258
1942
  }
1259
1943
  await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
1944
+ try {
1945
+ const queueModule = await import("./heartbeat-queue-service");
1946
+ queueModule.triggerHeartbeatQueueWorker(db, companyId, {
1947
+ requestId: options?.requestId,
1948
+ realtimeHub: options?.realtimeHub
1949
+ });
1950
+ } catch {
1951
+ // Queue worker trigger is best-effort to keep heartbeat execution resilient.
1952
+ }
1260
1953
  }
1261
1954
 
1262
1955
  return runId;
@@ -1332,230 +2025,1554 @@ async function recoverStaleHeartbeatRuns(
1332
2025
  }
1333
2026
  }
1334
2027
 
1335
- export async function runHeartbeatSweep(
1336
- db: BopoDb,
1337
- companyId: string,
1338
- options?: { requestId?: string; realtimeHub?: RealtimeHub }
1339
- ) {
1340
- const companyAgents = await db.select().from(agents).where(eq(agents.companyId, companyId));
1341
- const recentRuns = await db
1342
- .select({ agentId: heartbeatRuns.agentId, startedAt: heartbeatRuns.startedAt })
1343
- .from(heartbeatRuns)
1344
- .where(eq(heartbeatRuns.companyId, companyId))
1345
- .orderBy(desc(heartbeatRuns.startedAt));
1346
- const latestRunByAgent = new Map<string, Date>();
1347
- for (const run of recentRuns) {
1348
- if (!latestRunByAgent.has(run.agentId)) {
1349
- latestRunByAgent.set(run.agentId, run.startedAt);
2028
+ export async function runHeartbeatSweep(
2029
+ db: BopoDb,
2030
+ companyId: string,
2031
+ options?: { requestId?: string; realtimeHub?: RealtimeHub }
2032
+ ) {
2033
+ const companyAgents = await db.select().from(agents).where(eq(agents.companyId, companyId));
2034
+ const latestRunByAgent = await listLatestRunByAgent(db, companyId);
2035
+
2036
+ const now = new Date();
2037
+ const enqueuedJobIds: string[] = [];
2038
+ const dueAgents: Array<{ id: string }> = [];
2039
+ let skippedNotDue = 0;
2040
+ let skippedStatus = 0;
2041
+ let skippedBudgetBlocked = 0;
2042
+ let failedStarts = 0;
2043
+ const sweepStartedAt = Date.now();
2044
+ for (const agent of companyAgents) {
2045
+ if (agent.status !== "idle" && agent.status !== "running") {
2046
+ skippedStatus += 1;
2047
+ continue;
2048
+ }
2049
+ if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
2050
+ skippedNotDue += 1;
2051
+ continue;
2052
+ }
2053
+ const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(db, companyId, agent.id);
2054
+ if (blockedProjectIds.length > 0) {
2055
+ skippedBudgetBlocked += 1;
2056
+ continue;
2057
+ }
2058
+ dueAgents.push({ id: agent.id });
2059
+ }
2060
+ const sweepConcurrency = resolveHeartbeatSweepConcurrency(dueAgents.length);
2061
+ const queueModule = await import("./heartbeat-queue-service");
2062
+ await runWithConcurrency(dueAgents, sweepConcurrency, async (agent) => {
2063
+ try {
2064
+ const job = await queueModule.enqueueHeartbeatQueueJob(db, {
2065
+ companyId,
2066
+ agentId: agent.id,
2067
+ jobType: "scheduler",
2068
+ priority: 80,
2069
+ idempotencyKey: options?.requestId ? `scheduler:${agent.id}:${options.requestId}` : null,
2070
+ payload: {}
2071
+ });
2072
+ enqueuedJobIds.push(job.id);
2073
+ queueModule.triggerHeartbeatQueueWorker(db, companyId, {
2074
+ requestId: options?.requestId,
2075
+ realtimeHub: options?.realtimeHub
2076
+ });
2077
+ } catch {
2078
+ failedStarts += 1;
2079
+ }
2080
+ });
2081
+ await appendAuditEvent(db, {
2082
+ companyId,
2083
+ actorType: "system",
2084
+ eventType: "heartbeat.sweep.completed",
2085
+ entityType: "company",
2086
+ entityId: companyId,
2087
+ correlationId: options?.requestId ?? null,
2088
+ payload: {
2089
+ runIds: enqueuedJobIds,
2090
+ startedCount: enqueuedJobIds.length,
2091
+ dueCount: dueAgents.length,
2092
+ failedStarts,
2093
+ skippedStatus,
2094
+ skippedNotDue,
2095
+ skippedBudgetBlocked,
2096
+ concurrency: sweepConcurrency,
2097
+ elapsedMs: Date.now() - sweepStartedAt,
2098
+ requestId: options?.requestId ?? null
2099
+ }
2100
+ });
2101
+ return enqueuedJobIds;
2102
+ }
2103
+
2104
+ async function listLatestRunByAgent(db: BopoDb, companyId: string) {
2105
+ const result = await db.execute(sql`
2106
+ SELECT agent_id, MAX(started_at) AS latest_started_at
2107
+ FROM heartbeat_runs
2108
+ WHERE company_id = ${companyId}
2109
+ GROUP BY agent_id
2110
+ `);
2111
+ const latestRunByAgent = new Map<string, Date>();
2112
+ for (const row of result.rows ?? []) {
2113
+ const agentId = typeof row.agent_id === "string" ? row.agent_id : null;
2114
+ if (!agentId) {
2115
+ continue;
2116
+ }
2117
+ const startedAt = coerceDate(row.latest_started_at);
2118
+ if (!startedAt) {
2119
+ continue;
2120
+ }
2121
+ latestRunByAgent.set(agentId, startedAt);
2122
+ }
2123
+ return latestRunByAgent;
2124
+ }
2125
+
2126
+ function coerceDate(value: unknown) {
2127
+ if (value instanceof Date) {
2128
+ return Number.isNaN(value.getTime()) ? null : value;
2129
+ }
2130
+ if (typeof value === "string" || typeof value === "number") {
2131
+ const parsed = new Date(value);
2132
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
2133
+ }
2134
+ return null;
2135
+ }
2136
+
2137
+ async function loadWakeContextWorkItems(db: BopoDb, companyId: string, wakeIssueIds?: string[]) {
2138
+ const normalizedIds = Array.from(new Set((wakeIssueIds ?? []).filter((id) => id.trim().length > 0)));
2139
+ if (normalizedIds.length === 0) {
2140
+ return [] as Array<{
2141
+ id: string;
2142
+ project_id: string;
2143
+ parent_issue_id: string | null;
2144
+ title: string;
2145
+ body: string | null;
2146
+ status: string;
2147
+ priority: string;
2148
+ labels_json: string;
2149
+ tags_json: string;
2150
+ }>;
2151
+ }
2152
+ const rows = await db
2153
+ .select({
2154
+ id: issues.id,
2155
+ project_id: issues.projectId,
2156
+ parent_issue_id: issues.parentIssueId,
2157
+ title: issues.title,
2158
+ body: issues.body,
2159
+ status: issues.status,
2160
+ priority: issues.priority,
2161
+ labels_json: issues.labelsJson,
2162
+ tags_json: issues.tagsJson
2163
+ })
2164
+ .from(issues)
2165
+ .where(and(eq(issues.companyId, companyId), inArray(issues.id, normalizedIds)));
2166
+ const sortOrder = new Map(normalizedIds.map((id, index) => [id, index]));
2167
+ return rows.sort((a, b) => (sortOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (sortOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
2168
+ }
2169
+
2170
+ function mergeContextWorkItems(
2171
+ assigned: Array<{
2172
+ id: string;
2173
+ project_id: string;
2174
+ parent_issue_id: string | null;
2175
+ title: string;
2176
+ body: string | null;
2177
+ status: string;
2178
+ priority: string;
2179
+ labels_json: string;
2180
+ tags_json: string;
2181
+ }>,
2182
+ wakeContext: Array<{
2183
+ id: string;
2184
+ project_id: string;
2185
+ parent_issue_id: string | null;
2186
+ title: string;
2187
+ body: string | null;
2188
+ status: string;
2189
+ priority: string;
2190
+ labels_json: string;
2191
+ tags_json: string;
2192
+ }>
2193
+ ) {
2194
+ const seen = new Set<string>();
2195
+ const merged: typeof assigned = [];
2196
+ for (const item of assigned) {
2197
+ if (!seen.has(item.id)) {
2198
+ seen.add(item.id);
2199
+ merged.push(item);
2200
+ }
2201
+ }
2202
+ for (const item of wakeContext) {
2203
+ if (!seen.has(item.id)) {
2204
+ seen.add(item.id);
2205
+ merged.push(item);
2206
+ }
2207
+ }
2208
+ return merged;
2209
+ }
2210
+
2211
+ function resolveExecutionWorkItems(
2212
+ assigned: Array<{
2213
+ id: string;
2214
+ project_id: string;
2215
+ parent_issue_id: string | null;
2216
+ title: string;
2217
+ body: string | null;
2218
+ status: string;
2219
+ priority: string;
2220
+ labels_json: string;
2221
+ tags_json: string;
2222
+ }>,
2223
+ wakeContextItems: Array<{
2224
+ id: string;
2225
+ project_id: string;
2226
+ parent_issue_id: string | null;
2227
+ title: string;
2228
+ body: string | null;
2229
+ status: string;
2230
+ priority: string;
2231
+ labels_json: string;
2232
+ tags_json: string;
2233
+ }>,
2234
+ wakeContext?: HeartbeatWakeContext
2235
+ ) {
2236
+ if (wakeContext?.reason === "issue_comment_recipient" && wakeContextItems.length > 0) {
2237
+ return wakeContextItems;
2238
+ }
2239
+ return mergeContextWorkItems(assigned, wakeContextItems);
2240
+ }
2241
+
2242
+ async function resolveHeartbeatWakeContext(
2243
+ db: BopoDb,
2244
+ companyId: string,
2245
+ wakeContext?: HeartbeatWakeContext
2246
+ ): Promise<HeartbeatWakeContext | undefined> {
2247
+ if (!wakeContext) {
2248
+ return undefined;
2249
+ }
2250
+ const commentBody = wakeContext.commentId
2251
+ ? await loadWakeContextCommentBody(db, companyId, wakeContext.commentId)
2252
+ : null;
2253
+ return {
2254
+ reason: wakeContext.reason ?? null,
2255
+ commentId: wakeContext.commentId ?? null,
2256
+ commentBody,
2257
+ issueIds: wakeContext.issueIds ?? []
2258
+ };
2259
+ }
2260
+
2261
+ async function loadWakeContextCommentBody(db: BopoDb, companyId: string, commentId: string) {
2262
+ const [comment] = await db
2263
+ .select({ body: issueComments.body })
2264
+ .from(issueComments)
2265
+ .where(and(eq(issueComments.companyId, companyId), eq(issueComments.id, commentId)))
2266
+ .limit(1);
2267
+ const body = comment?.body?.trim();
2268
+ return body && body.length > 0 ? body : null;
2269
+ }
2270
+
2271
+ async function buildHeartbeatContext(
2272
+ db: BopoDb,
2273
+ companyId: string,
2274
+ input: {
2275
+ agentId: string;
2276
+ agentName: string;
2277
+ agentRole: string;
2278
+ managerAgentId: string | null;
2279
+ providerType: HeartbeatProviderType;
2280
+ heartbeatRunId: string;
2281
+ state: AgentState;
2282
+ memoryContext?: HeartbeatContext["memoryContext"];
2283
+ runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
2284
+ wakeContext?: HeartbeatWakeContext;
2285
+ workItems: Array<{
2286
+ id: string;
2287
+ project_id: string;
2288
+ parent_issue_id: string | null;
2289
+ title: string;
2290
+ body: string | null;
2291
+ status: string;
2292
+ priority: string;
2293
+ labels_json: string;
2294
+ tags_json: string;
2295
+ }>;
2296
+ }
2297
+ ): Promise<HeartbeatContext> {
2298
+ const [company] = await db
2299
+ .select({ name: companies.name, mission: companies.mission })
2300
+ .from(companies)
2301
+ .where(eq(companies.id, companyId))
2302
+ .limit(1);
2303
+ const projectIds = Array.from(new Set(input.workItems.map((item) => item.project_id)));
2304
+ const projectRows =
2305
+ projectIds.length > 0
2306
+ ? await db
2307
+ .select({ id: projects.id, name: projects.name })
2308
+ .from(projects)
2309
+ .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)))
2310
+ : [];
2311
+ const projectNameById = new Map(projectRows.map((row) => [row.id, row.name]));
2312
+ const projectWorkspaceContextMap = await getProjectWorkspaceContextMap(db, companyId, projectIds);
2313
+ const projectWorkspaceMap = new Map(
2314
+ Array.from(projectWorkspaceContextMap.entries()).map(([projectId, context]) => [projectId, context.cwd])
2315
+ );
2316
+ const issueIds = input.workItems.map((item) => item.id);
2317
+ const childIssueRows =
2318
+ issueIds.length > 0
2319
+ ? await db
2320
+ .select({
2321
+ id: issues.id,
2322
+ parentIssueId: issues.parentIssueId
2323
+ })
2324
+ .from(issues)
2325
+ .where(and(eq(issues.companyId, companyId), inArray(issues.parentIssueId, issueIds)))
2326
+ : [];
2327
+ const childIssueIdsByParent = new Map<string, string[]>();
2328
+ for (const row of childIssueRows) {
2329
+ if (!row.parentIssueId) {
2330
+ continue;
2331
+ }
2332
+ const existing = childIssueIdsByParent.get(row.parentIssueId) ?? [];
2333
+ existing.push(row.id);
2334
+ childIssueIdsByParent.set(row.parentIssueId, existing);
2335
+ }
2336
+ const attachmentRows =
2337
+ issueIds.length > 0
2338
+ ? await db
2339
+ .select({
2340
+ id: issueAttachments.id,
2341
+ issueId: issueAttachments.issueId,
2342
+ projectId: issueAttachments.projectId,
2343
+ fileName: issueAttachments.fileName,
2344
+ mimeType: issueAttachments.mimeType,
2345
+ fileSizeBytes: issueAttachments.fileSizeBytes,
2346
+ relativePath: issueAttachments.relativePath
2347
+ })
2348
+ .from(issueAttachments)
2349
+ .where(and(eq(issueAttachments.companyId, companyId), inArray(issueAttachments.issueId, issueIds)))
2350
+ : [];
2351
+ const attachmentsByIssue = new Map<
2352
+ string,
2353
+ Array<{
2354
+ id: string;
2355
+ fileName: string;
2356
+ mimeType: string | null;
2357
+ fileSizeBytes: number;
2358
+ relativePath: string;
2359
+ absolutePath: string;
2360
+ }>
2361
+ >();
2362
+ for (const row of attachmentRows) {
2363
+ const projectWorkspace = projectWorkspaceMap.get(row.projectId) ?? resolveProjectWorkspacePath(companyId, row.projectId);
2364
+ const absolutePath = resolve(projectWorkspace, row.relativePath);
2365
+ if (!isInsidePath(projectWorkspace, absolutePath)) {
2366
+ continue;
2367
+ }
2368
+ const existing = attachmentsByIssue.get(row.issueId) ?? [];
2369
+ existing.push({
2370
+ id: row.id,
2371
+ fileName: row.fileName,
2372
+ mimeType: row.mimeType,
2373
+ fileSizeBytes: row.fileSizeBytes,
2374
+ relativePath: row.relativePath,
2375
+ absolutePath
2376
+ });
2377
+ attachmentsByIssue.set(row.issueId, existing);
2378
+ }
2379
+ const goalRows = await db
2380
+ .select({
2381
+ id: goals.id,
2382
+ level: goals.level,
2383
+ title: goals.title,
2384
+ status: goals.status,
2385
+ projectId: goals.projectId
2386
+ })
2387
+ .from(goals)
2388
+ .where(eq(goals.companyId, companyId));
2389
+
2390
+ const activeCompanyGoals = goalRows
2391
+ .filter((goal) => goal.status === "active" && goal.level === "company")
2392
+ .map((goal) => goal.title);
2393
+ const activeProjectGoals = goalRows
2394
+ .filter(
2395
+ (goal) =>
2396
+ goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId)
2397
+ )
2398
+ .map((goal) => goal.title);
2399
+ const activeAgentGoals = goalRows
2400
+ .filter((goal) => goal.status === "active" && goal.level === "agent")
2401
+ .map((goal) => goal.title);
2402
+ const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
2403
+
2404
+ return {
2405
+ companyId,
2406
+ agentId: input.agentId,
2407
+ providerType: input.providerType,
2408
+ heartbeatRunId: input.heartbeatRunId,
2409
+ company: {
2410
+ name: company?.name ?? "Unknown company",
2411
+ mission: company?.mission ?? null
2412
+ },
2413
+ agent: {
2414
+ name: input.agentName,
2415
+ role: input.agentRole,
2416
+ managerAgentId: input.managerAgentId
2417
+ },
2418
+ state: input.state,
2419
+ memoryContext: input.memoryContext,
2420
+ runtime: input.runtime,
2421
+ wakeContext: input.wakeContext
2422
+ ? {
2423
+ reason: input.wakeContext.reason ?? null,
2424
+ commentId: input.wakeContext.commentId ?? null,
2425
+ commentBody: input.wakeContext.commentBody ?? null,
2426
+ issueIds: input.wakeContext.issueIds ?? []
2427
+ }
2428
+ : undefined,
2429
+ goalContext: {
2430
+ companyGoals: activeCompanyGoals,
2431
+ projectGoals: activeProjectGoals,
2432
+ agentGoals: activeAgentGoals
2433
+ },
2434
+ workItems: input.workItems.map((item) => ({
2435
+ issueId: item.id,
2436
+ projectId: item.project_id,
2437
+ parentIssueId: item.parent_issue_id,
2438
+ childIssueIds: childIssueIdsByParent.get(item.id) ?? [],
2439
+ projectName: projectNameById.get(item.project_id) ?? null,
2440
+ title: item.title,
2441
+ // Comment-order runs should treat linked issues as context-only, not as a full issue execution order.
2442
+ body: isCommentOrderWake ? null : item.body,
2443
+ status: item.status,
2444
+ priority: item.priority,
2445
+ labels: parseStringArray(item.labels_json),
2446
+ tags: parseStringArray(item.tags_json),
2447
+ attachments: isCommentOrderWake ? [] : (attachmentsByIssue.get(item.id) ?? [])
2448
+ }))
2449
+ };
2450
+ }
2451
+
2452
+ function parseStringArray(value: string | null) {
2453
+ if (!value) {
2454
+ return [];
2455
+ }
2456
+ try {
2457
+ const parsed = JSON.parse(value) as unknown;
2458
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
2459
+ } catch {
2460
+ return [];
2461
+ }
2462
+ }
2463
+
2464
+ function computeMissionAlignmentSignal(input: {
2465
+ summary: string;
2466
+ mission: string | null;
2467
+ companyGoals: string[];
2468
+ projectGoals: string[];
2469
+ }) {
2470
+ const summaryTokens = new Set(tokenizeAlignmentText(input.summary));
2471
+ const missionTokens = tokenizeAlignmentText(input.mission ?? "");
2472
+ const goalTokens = tokenizeAlignmentText([...input.companyGoals, ...input.projectGoals].join(" "));
2473
+ const matchedMissionTerms = missionTokens.filter((token) => summaryTokens.has(token));
2474
+ const matchedGoalTerms = goalTokens.filter((token) => summaryTokens.has(token));
2475
+ const missionScore = missionTokens.length > 0 ? matchedMissionTerms.length / missionTokens.length : 0;
2476
+ const goalScore = goalTokens.length > 0 ? matchedGoalTerms.length / goalTokens.length : 0;
2477
+ const score = Number(Math.min(1, missionScore * 0.55 + goalScore * 0.45).toFixed(3));
2478
+ return {
2479
+ score,
2480
+ matchedMissionTerms,
2481
+ matchedGoalTerms
2482
+ };
2483
+ }
2484
+
2485
+ function tokenizeAlignmentText(value: string) {
2486
+ return Array.from(
2487
+ new Set(
2488
+ value
2489
+ .toLowerCase()
2490
+ .replace(/[^a-z0-9\s]/g, " ")
2491
+ .split(/\s+/)
2492
+ .map((entry) => entry.trim())
2493
+ .filter((entry) => entry.length >= 3)
2494
+ )
2495
+ );
2496
+ }
2497
+
2498
+ async function loadProjectIdsForRunBudgetCheck(
2499
+ db: BopoDb,
2500
+ companyId: string,
2501
+ agentId: string,
2502
+ wakeContext?: HeartbeatWakeContext
2503
+ ) {
2504
+ const projectIds = new Set<string>();
2505
+ const isCommentOrderWake = wakeContext?.reason === "issue_comment_recipient";
2506
+ if (!isCommentOrderWake) {
2507
+ const assignedRows = await db
2508
+ .select({ projectId: issues.projectId })
2509
+ .from(issues)
2510
+ .where(
2511
+ and(
2512
+ eq(issues.companyId, companyId),
2513
+ eq(issues.assigneeAgentId, agentId),
2514
+ inArray(issues.status, ["todo", "in_progress"]),
2515
+ eq(issues.isClaimed, false)
2516
+ )
2517
+ );
2518
+ for (const row of assignedRows) {
2519
+ projectIds.add(row.projectId);
2520
+ }
2521
+ }
2522
+ const wakeIssueIds = Array.from(new Set((wakeContext?.issueIds ?? []).map((entry) => entry.trim()).filter(Boolean)));
2523
+ if (wakeIssueIds.length > 0) {
2524
+ const wakeRows = await db
2525
+ .select({ projectId: issues.projectId })
2526
+ .from(issues)
2527
+ .where(and(eq(issues.companyId, companyId), inArray(issues.id, wakeIssueIds)));
2528
+ for (const row of wakeRows) {
2529
+ projectIds.add(row.projectId);
2530
+ }
2531
+ }
2532
+ return Array.from(projectIds);
2533
+ }
2534
+
2535
+ function buildProjectBudgetCostAllocations(
2536
+ workItems: Array<{ issueId: string; projectId: string }>,
2537
+ usdCost: number
2538
+ ): Array<{ projectId: string; usdCost: number }> {
2539
+ const effectiveCost = Math.max(0, usdCost);
2540
+ if (effectiveCost <= 0 || workItems.length === 0) {
2541
+ return [];
2542
+ }
2543
+ const issueCountByProject = new Map<string, number>();
2544
+ for (const item of workItems) {
2545
+ issueCountByProject.set(item.projectId, (issueCountByProject.get(item.projectId) ?? 0) + 1);
2546
+ }
2547
+ const totalIssues = Array.from(issueCountByProject.values()).reduce((sum, count) => sum + count, 0);
2548
+ if (totalIssues <= 0) {
2549
+ return [];
2550
+ }
2551
+ const projectIds = Array.from(issueCountByProject.keys());
2552
+ let allocated = 0;
2553
+ const allocations = projectIds.map((projectId, index) => {
2554
+ if (index === projectIds.length - 1) {
2555
+ return {
2556
+ projectId,
2557
+ usdCost: Number((effectiveCost - allocated).toFixed(6))
2558
+ };
2559
+ }
2560
+ const count = issueCountByProject.get(projectId) ?? 0;
2561
+ const share = Number(((effectiveCost * count) / totalIssues).toFixed(6));
2562
+ allocated += share;
2563
+ return {
2564
+ projectId,
2565
+ usdCost: share
2566
+ };
2567
+ });
2568
+ return allocations.filter((entry) => entry.usdCost > 0);
2569
+ }
2570
+
2571
+ async function ensureBudgetOverrideApprovalRequest(
2572
+ db: BopoDb,
2573
+ input: {
2574
+ companyId: string;
2575
+ agentId: string;
2576
+ utilizationPct: number;
2577
+ usedBudgetUsd: number;
2578
+ monthlyBudgetUsd: number;
2579
+ runId: string;
2580
+ }
2581
+ ): Promise<string | null> {
2582
+ const pendingOverrides = await db
2583
+ .select({ id: approvalRequests.id, payloadJson: approvalRequests.payloadJson })
2584
+ .from(approvalRequests)
2585
+ .where(
2586
+ and(
2587
+ eq(approvalRequests.companyId, input.companyId),
2588
+ eq(approvalRequests.action, "override_budget"),
2589
+ eq(approvalRequests.status, "pending")
2590
+ )
2591
+ );
2592
+ const alreadyPending = pendingOverrides.some((approval) => {
2593
+ try {
2594
+ const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
2595
+ return payload.agentId === input.agentId;
2596
+ } catch {
2597
+ return false;
2598
+ }
2599
+ });
2600
+ if (alreadyPending) {
2601
+ return null;
2602
+ }
2603
+ const recommendedAdditionalBudgetUsd = Math.max(1, Math.ceil(Math.max(input.monthlyBudgetUsd * 0.25, 1)));
2604
+ const approvalId = await createApprovalRequest(db, {
2605
+ companyId: input.companyId,
2606
+ action: "override_budget",
2607
+ payload: {
2608
+ agentId: input.agentId,
2609
+ reason: "Agent reached budget hard-stop and needs additional funds.",
2610
+ currentMonthlyBudgetUsd: input.monthlyBudgetUsd,
2611
+ usedBudgetUsd: input.usedBudgetUsd,
2612
+ utilizationPct: input.utilizationPct,
2613
+ additionalBudgetUsd: recommendedAdditionalBudgetUsd,
2614
+ revisedMonthlyBudgetUsd: Number((input.monthlyBudgetUsd + recommendedAdditionalBudgetUsd).toFixed(4)),
2615
+ triggerRunId: input.runId
2616
+ }
2617
+ });
2618
+ await appendAuditEvent(db, {
2619
+ companyId: input.companyId,
2620
+ actorType: "system",
2621
+ eventType: "budget.override_requested",
2622
+ entityType: "approval",
2623
+ entityId: approvalId,
2624
+ correlationId: input.runId,
2625
+ payload: {
2626
+ agentId: input.agentId,
2627
+ runId: input.runId,
2628
+ currentMonthlyBudgetUsd: input.monthlyBudgetUsd,
2629
+ usedBudgetUsd: input.usedBudgetUsd,
2630
+ utilizationPct: input.utilizationPct,
2631
+ additionalBudgetUsd: recommendedAdditionalBudgetUsd
2632
+ }
2633
+ });
2634
+ return approvalId;
2635
+ }
2636
+
2637
+ async function ensureProjectBudgetOverrideApprovalRequest(
2638
+ db: BopoDb,
2639
+ input: {
2640
+ companyId: string;
2641
+ projectId: string;
2642
+ utilizationPct: number;
2643
+ usedBudgetUsd: number;
2644
+ monthlyBudgetUsd: number;
2645
+ runId: string;
2646
+ }
2647
+ ): Promise<string | null> {
2648
+ const pendingOverrides = await db
2649
+ .select({ id: approvalRequests.id, payloadJson: approvalRequests.payloadJson })
2650
+ .from(approvalRequests)
2651
+ .where(
2652
+ and(
2653
+ eq(approvalRequests.companyId, input.companyId),
2654
+ eq(approvalRequests.action, "override_budget"),
2655
+ eq(approvalRequests.status, "pending")
2656
+ )
2657
+ );
2658
+ const alreadyPending = pendingOverrides.some((approval) => {
2659
+ try {
2660
+ const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
2661
+ return payload.projectId === input.projectId;
2662
+ } catch {
2663
+ return false;
2664
+ }
2665
+ });
2666
+ if (alreadyPending) {
2667
+ return null;
2668
+ }
2669
+ const recommendedAdditionalBudgetUsd = Math.max(1, Math.ceil(Math.max(input.monthlyBudgetUsd * 0.25, 1)));
2670
+ const approvalId = await createApprovalRequest(db, {
2671
+ companyId: input.companyId,
2672
+ action: "override_budget",
2673
+ payload: {
2674
+ projectId: input.projectId,
2675
+ reason: "Project reached budget hard-stop and needs additional funds.",
2676
+ currentMonthlyBudgetUsd: input.monthlyBudgetUsd,
2677
+ usedBudgetUsd: input.usedBudgetUsd,
2678
+ utilizationPct: input.utilizationPct,
2679
+ additionalBudgetUsd: recommendedAdditionalBudgetUsd,
2680
+ revisedMonthlyBudgetUsd: Number((input.monthlyBudgetUsd + recommendedAdditionalBudgetUsd).toFixed(4)),
2681
+ triggerRunId: input.runId
2682
+ }
2683
+ });
2684
+ await appendAuditEvent(db, {
2685
+ companyId: input.companyId,
2686
+ actorType: "system",
2687
+ eventType: "project_budget.override_requested",
2688
+ entityType: "approval",
2689
+ entityId: approvalId,
2690
+ correlationId: input.runId,
2691
+ payload: {
2692
+ projectId: input.projectId,
2693
+ runId: input.runId,
2694
+ currentMonthlyBudgetUsd: input.monthlyBudgetUsd,
2695
+ usedBudgetUsd: input.usedBudgetUsd,
2696
+ utilizationPct: input.utilizationPct,
2697
+ additionalBudgetUsd: recommendedAdditionalBudgetUsd
2698
+ }
2699
+ });
2700
+ return approvalId;
2701
+ }
2702
+
2703
+ function sanitizeAgentSummaryCommentBody(body: string) {
2704
+ const sanitized = body.replace(AGENT_COMMENT_EMOJI_REGEX, "").trim();
2705
+ return sanitized.length > 0 ? sanitized : "Run update.";
2706
+ }
2707
+
2708
+ function extractNaturalRunUpdate(executionSummary: string) {
2709
+ const normalized = executionSummary.trim();
2710
+ const jsonSummary = extractSummaryFromJsonLikeText(normalized);
2711
+ const source = jsonSummary ?? normalized;
2712
+ const lines = source
2713
+ .split("\n")
2714
+ .map((line) => line.trim())
2715
+ .filter((line) => line.length > 0)
2716
+ .filter((line) => !line.startsWith("{") && !line.startsWith("}"));
2717
+ const compact = (lines.length > 0 ? lines.slice(0, 2).join(" ") : source)
2718
+ .replace(/^run (failure )?summary\s*:\s*/i, "")
2719
+ .replace(/^completed all assigned issue steps\s*:\s*/i, "")
2720
+ .replace(/^issue status\s*:\s*/i, "")
2721
+ .replace(/`+/g, "")
2722
+ .replace(/\s+/g, " ")
2723
+ .trim();
2724
+ const bounded = compact.length > 260 ? `${compact.slice(0, 257).trimEnd()}...` : compact;
2725
+ if (!bounded) {
2726
+ return "Run update.";
2727
+ }
2728
+ return /[.!?]$/.test(bounded) ? bounded : `${bounded}.`;
2729
+ }
2730
+
2731
+ function buildRunDigest(input: {
2732
+ status: "completed" | "failed" | "skipped";
2733
+ executionSummary: string;
2734
+ outcome: ExecutionOutcome | null;
2735
+ trace: unknown;
2736
+ signals: RunDigestSignal[];
2737
+ }): RunDigest {
2738
+ const summary = sanitizeAgentSummaryCommentBody(extractNaturalRunUpdate(input.executionSummary));
2739
+ const successes: string[] = [];
2740
+ const failures: string[] = [];
2741
+ const blockers: string[] = [];
2742
+ if (input.outcome) {
2743
+ for (const action of input.outcome.actions) {
2744
+ const detail = summarizeRunDigestPoint(action.detail);
2745
+ if (!detail) {
2746
+ continue;
2747
+ }
2748
+ if (action.status === "ok") {
2749
+ successes.push(detail);
2750
+ } else if (action.status === "error") {
2751
+ failures.push(detail);
2752
+ }
2753
+ }
2754
+ for (const blocker of input.outcome.blockers) {
2755
+ const detail = summarizeRunDigestPoint(blocker.message);
2756
+ if (detail) {
2757
+ blockers.push(detail);
2758
+ }
2759
+ }
2760
+ }
2761
+ for (const signal of input.signals) {
2762
+ if (signal.signalLevel !== "high" && signal.signalLevel !== "medium") {
2763
+ continue;
2764
+ }
2765
+ const signalText = summarizeRunDigestPoint(signal.text ?? signal.payload ?? "");
2766
+ if (!signalText) {
2767
+ continue;
2768
+ }
2769
+ if (signal.kind === "tool_result" || signal.kind === "stderr") {
2770
+ if (looksLikeRunFailureSignal(signalText)) {
2771
+ failures.push(signalText);
2772
+ } else if (signal.kind === "tool_result") {
2773
+ successes.push(signalText);
2774
+ }
2775
+ continue;
2776
+ }
2777
+ if (signal.kind === "result" && !looksLikeRunFailureSignal(signalText)) {
2778
+ successes.push(signalText);
2779
+ }
2780
+ }
2781
+ if (input.status === "completed" && successes.length === 0) {
2782
+ successes.push(summary);
2783
+ }
2784
+ if (input.status === "failed" && failures.length === 0) {
2785
+ failures.push(summary);
2786
+ }
2787
+ if (input.status === "failed" && blockers.length === 0) {
2788
+ const traceFailureType = summarizeRunDigestPoint(readTraceString(input.trace, "failureType") ?? "");
2789
+ if (traceFailureType) {
2790
+ blockers.push(`failure type: ${traceFailureType}`);
2791
+ }
2792
+ }
2793
+ const uniqueSuccesses = dedupeRunDigestPoints(successes, 3);
2794
+ const uniqueFailures = dedupeRunDigestPoints(failures, 3);
2795
+ const uniqueBlockers = dedupeRunDigestPoints(blockers, 2);
2796
+ const headline =
2797
+ input.status === "completed"
2798
+ ? `Run completed: ${summary}`
2799
+ : input.status === "failed"
2800
+ ? `Run failed: ${summary}`
2801
+ : `Run skipped: ${summary}`;
2802
+ const nextAction = resolveRunDigestNextAction({
2803
+ status: input.status,
2804
+ blockers: uniqueBlockers,
2805
+ failures: uniqueFailures
2806
+ });
2807
+ return {
2808
+ status: input.status,
2809
+ headline,
2810
+ summary,
2811
+ successes: uniqueSuccesses,
2812
+ failures: uniqueFailures,
2813
+ blockers: uniqueBlockers,
2814
+ nextAction,
2815
+ evidence: {
2816
+ transcriptSignalCount: input.signals.length,
2817
+ outcomeActionCount: input.outcome?.actions.length ?? 0,
2818
+ outcomeBlockerCount: input.outcome?.blockers.length ?? 0,
2819
+ failureType: readTraceString(input.trace, "failureType")
2820
+ }
2821
+ };
2822
+ }
2823
+
2824
+ function summarizeRunDigestPoint(value: string | null | undefined) {
2825
+ if (!value) {
2826
+ return "";
2827
+ }
2828
+ const normalized = sanitizeAgentSummaryCommentBody(extractNaturalRunUpdate(value));
2829
+ if (!normalized || normalized.toLowerCase() === "run update.") {
2830
+ return "";
2831
+ }
2832
+ const bounded = normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized;
2833
+ return bounded;
2834
+ }
2835
+
2836
+ function dedupeRunDigestPoints(values: string[], limit: number) {
2837
+ const seen = new Set<string>();
2838
+ const deduped: string[] = [];
2839
+ for (const value of values) {
2840
+ const key = value.toLowerCase().replace(/\s+/g, " ").trim();
2841
+ if (!key || seen.has(key)) {
2842
+ continue;
2843
+ }
2844
+ seen.add(key);
2845
+ deduped.push(value);
2846
+ if (deduped.length >= limit) {
2847
+ break;
2848
+ }
2849
+ }
2850
+ return deduped;
2851
+ }
2852
+
2853
+ function looksLikeRunFailureSignal(value: string) {
2854
+ const normalized = value.toLowerCase();
2855
+ return /(failed|error|exception|timed out|timeout|unauthorized|not supported|unsupported|no capacity|rate limit|429|500|blocked|unable to)/.test(
2856
+ normalized
2857
+ );
2858
+ }
2859
+
2860
+ function resolveRunDigestNextAction(input: { status: "completed" | "failed" | "skipped"; blockers: string[]; failures: string[] }) {
2861
+ if (input.status === "completed") {
2862
+ return "Review outputs and move the issue to the next workflow state.";
2863
+ }
2864
+ const combined = [...input.blockers, ...input.failures].join(" ").toLowerCase();
2865
+ if (combined.includes("auth") || combined.includes("unauthorized") || combined.includes("login")) {
2866
+ return "Fix credentials/authentication, then rerun.";
2867
+ }
2868
+ if (combined.includes("model") && (combined.includes("not supported") || combined.includes("unavailable"))) {
2869
+ return "Select a supported model and rerun.";
2870
+ }
2871
+ if (combined.includes("usage limit") || combined.includes("rate limit") || combined.includes("no capacity")) {
2872
+ return "Retry after provider quota/capacity recovers.";
2873
+ }
2874
+ return "Fix listed failures/blockers and rerun.";
2875
+ }
2876
+
2877
+ function resolveRunTerminalPresentation(input: {
2878
+ internalStatus: "completed" | "failed" | "skipped";
2879
+ executionSummary: string;
2880
+ outcome: ExecutionOutcome | null;
2881
+ trace: unknown;
2882
+ errorType?: string | null;
2883
+ }) : RunTerminalPresentation {
2884
+ if (isNoAssignedWorkOutcomeForReport(input.outcome)) {
2885
+ return {
2886
+ internalStatus: input.internalStatus,
2887
+ publicStatus: "completed",
2888
+ completionReason: "no_assigned_work"
2889
+ };
2890
+ }
2891
+ if (input.internalStatus === "completed") {
2892
+ return {
2893
+ internalStatus: input.internalStatus,
2894
+ publicStatus: "completed",
2895
+ completionReason: "task_completed"
2896
+ };
2897
+ }
2898
+ const completionReason = inferRunCompletionReason(input);
2899
+ return {
2900
+ internalStatus: input.internalStatus,
2901
+ publicStatus: "failed",
2902
+ completionReason
2903
+ };
2904
+ }
2905
+
2906
+ function inferRunCompletionReason(input: {
2907
+ internalStatus: "completed" | "failed" | "skipped";
2908
+ executionSummary: string;
2909
+ outcome: ExecutionOutcome | null;
2910
+ trace: unknown;
2911
+ errorType?: string | null;
2912
+ }): RunCompletionReason {
2913
+ const texts = [
2914
+ input.executionSummary,
2915
+ readTraceString(input.trace, "failureType") ?? "",
2916
+ readTraceString(input.trace, "stderrPreview") ?? "",
2917
+ input.errorType ?? "",
2918
+ ...(input.outcome?.blockers ?? []).flatMap((blocker) => [blocker.code, blocker.message]),
2919
+ ...(input.outcome?.actions ?? []).flatMap((action) => [action.type, action.detail ?? ""])
2920
+ ];
2921
+ const combined = texts.join("\n").toLowerCase();
2922
+ if (
2923
+ combined.includes("insufficient_quota") ||
2924
+ combined.includes("billing_hard_limit_reached") ||
2925
+ combined.includes("out of funds") ||
2926
+ combined.includes("payment required")
2927
+ ) {
2928
+ return "provider_out_of_funds";
2929
+ }
2930
+ if (
2931
+ combined.includes("usage limit") ||
2932
+ combined.includes("rate limit") ||
2933
+ combined.includes("429") ||
2934
+ combined.includes("quota")
2935
+ ) {
2936
+ return combined.includes("quota") ? "provider_quota_exhausted" : "provider_rate_limited";
2937
+ }
2938
+ if (combined.includes("budget hard-stop")) {
2939
+ return "budget_hard_stop";
2940
+ }
2941
+ if (combined.includes("already in progress") || combined.includes("skipped_overlap")) {
2942
+ return "overlap_in_progress";
2943
+ }
2944
+ if (combined.includes("unauthorized") || combined.includes("auth") || combined.includes("api key")) {
2945
+ return "auth_error";
2946
+ }
2947
+ if (combined.includes("contract") || combined.includes("missing_structured_output")) {
2948
+ return "contract_invalid";
2949
+ }
2950
+ if (combined.includes("watchdog_timeout") || combined.includes("runtime_timeout") || combined.includes("timed out")) {
2951
+ return "timeout";
2952
+ }
2953
+ if (combined.includes("cancelled")) {
2954
+ return "cancelled";
2955
+ }
2956
+ if (combined.includes("enoent") || combined.includes("runtime_missing")) {
2957
+ return "runtime_missing";
2958
+ }
2959
+ if (
2960
+ combined.includes("provider unavailable") ||
2961
+ combined.includes("no capacity") ||
2962
+ combined.includes("unavailable") ||
2963
+ combined.includes("http_error")
2964
+ ) {
2965
+ return "provider_unavailable";
2966
+ }
2967
+ if (input.outcome?.kind === "blocked") {
2968
+ return "blocked";
2969
+ }
2970
+ return "runtime_error";
2971
+ }
2972
+
2973
+ function isNoAssignedWorkOutcomeForReport(outcome: ExecutionOutcome | null) {
2974
+ if (!outcome) {
2975
+ return false;
2976
+ }
2977
+ if (outcome.kind !== "skipped") {
2978
+ return false;
2979
+ }
2980
+ if (outcome.issueIdsTouched.length === 0) {
2981
+ return true;
2982
+ }
2983
+ return outcome.actions.some((action) => action.type === "heartbeat.skip");
2984
+ }
2985
+
2986
+ function buildRunCostSummary(input: {
2987
+ tokenInput: number;
2988
+ tokenOutput: number;
2989
+ usdCost: number | null;
2990
+ usdCostStatus: "exact" | "estimated" | "unknown";
2991
+ pricingSource: string | null;
2992
+ source: string | null;
2993
+ }): RunCostSummary {
2994
+ return {
2995
+ tokenInput: Math.max(0, input.tokenInput),
2996
+ tokenOutput: Math.max(0, input.tokenOutput),
2997
+ usdCost: input.usdCostStatus === "unknown" ? null : Math.max(0, input.usdCost ?? 0),
2998
+ usdCostStatus: input.usdCostStatus,
2999
+ pricingSource: input.pricingSource ?? null,
3000
+ source: input.source ?? null
3001
+ };
3002
+ }
3003
+
3004
+ function buildRunArtifacts(input: {
3005
+ outcome: ExecutionOutcome | null;
3006
+ finalRunOutput?: AgentFinalRunOutput | null;
3007
+ runtimeCwd?: string | null;
3008
+ workspaceRootPath?: string | null;
3009
+ companyId?: string;
3010
+ }): RunArtifact[] {
3011
+ const sourceArtifacts =
3012
+ input.finalRunOutput?.artifacts && input.finalRunOutput.artifacts.length > 0
3013
+ ? input.finalRunOutput.artifacts
3014
+ : input.outcome?.artifacts ?? [];
3015
+ if (sourceArtifacts.length === 0) {
3016
+ return [];
3017
+ }
3018
+ const runtimeCwd = input.runtimeCwd?.trim() ? input.runtimeCwd.trim() : null;
3019
+ const workspaceRootPath = input.workspaceRootPath?.trim() ? input.workspaceRootPath.trim() : null;
3020
+ const companyId = input.companyId?.trim() ? input.companyId.trim() : null;
3021
+ return sourceArtifacts.map((artifact) => {
3022
+ const originalPath = artifact.path.trim();
3023
+ const artifactIsAbsolute = isAbsolute(originalPath);
3024
+ const absolutePath = artifactIsAbsolute ? resolve(originalPath) : runtimeCwd ? resolve(runtimeCwd, originalPath) : null;
3025
+ let relativePathValue: string | null = null;
3026
+ if (absolutePath && workspaceRootPath && isInsidePath(workspaceRootPath, absolutePath)) {
3027
+ relativePathValue = toNormalizedWorkspaceRelativePath(relative(workspaceRootPath, absolutePath));
3028
+ } else if (!artifactIsAbsolute) {
3029
+ relativePathValue = toNormalizedWorkspaceRelativePath(originalPath);
3030
+ } else if (runtimeCwd) {
3031
+ const candidate = toNormalizedWorkspaceRelativePath(relative(runtimeCwd, absolutePath ?? originalPath));
3032
+ relativePathValue = candidate && !candidate.startsWith("../") ? candidate : null;
3033
+ }
3034
+ if (companyId) {
3035
+ const normalizedRelative = normalizeAgentOperatingArtifactRelativePath(relativePathValue, companyId);
3036
+ if (normalizedRelative) {
3037
+ relativePathValue = normalizedRelative;
3038
+ } else {
3039
+ const normalizedOriginal = toNormalizedWorkspaceRelativePath(originalPath);
3040
+ const normalizedFromOriginal = normalizeAgentOperatingArtifactRelativePath(normalizedOriginal, companyId);
3041
+ if (normalizedFromOriginal) {
3042
+ relativePathValue = normalizedFromOriginal;
3043
+ }
3044
+ }
3045
+ }
3046
+ const location = relativePathValue ?? absolutePath ?? originalPath;
3047
+ return {
3048
+ path: originalPath,
3049
+ kind: artifact.kind,
3050
+ label: describeArtifact(artifact.kind, location),
3051
+ relativePath: relativePathValue,
3052
+ absolutePath
3053
+ };
3054
+ });
3055
+ }
3056
+
3057
+ function toNormalizedWorkspaceRelativePath(inputPath: string | null | undefined) {
3058
+ const trimmed = inputPath?.trim();
3059
+ if (!trimmed) {
3060
+ return null;
3061
+ }
3062
+ const unixSeparated = trimmed.replace(/\\/g, "/");
3063
+ const parts: string[] = [];
3064
+ for (const part of unixSeparated.split("/")) {
3065
+ if (!part || part === ".") {
3066
+ continue;
3067
+ }
3068
+ if (part === "..") {
3069
+ if (parts.length > 0 && parts[parts.length - 1] !== "..") {
3070
+ parts.pop();
3071
+ } else {
3072
+ parts.push(part);
3073
+ }
3074
+ continue;
3075
+ }
3076
+ parts.push(part);
3077
+ }
3078
+ const normalized = parts.join("/");
3079
+ return normalized || null;
3080
+ }
3081
+
3082
+ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, companyId: string) {
3083
+ const normalized = toNormalizedWorkspaceRelativePath(pathValue);
3084
+ if (!normalized) {
3085
+ return null;
3086
+ }
3087
+ const workspaceScopedMatch = normalized.match(/(?:^|\/)(workspace\/[^/]+\/agents\/[^/]+\/operating(?:\/.*)?)$/);
3088
+ if (workspaceScopedMatch) {
3089
+ const scopedPath = toNormalizedWorkspaceRelativePath(workspaceScopedMatch[1]);
3090
+ if (!scopedPath) {
3091
+ return null;
3092
+ }
3093
+ const parsed = scopedPath.match(/^workspace\/([^/]+)\/agents\/([^/]+)\/operating(\/.*)?$/);
3094
+ if (!parsed) {
3095
+ return null;
3096
+ }
3097
+ const embeddedCompanyId = parsed[1]?.trim() || companyId;
3098
+ const agentId = parsed[2];
3099
+ const suffix = parsed[3] ?? "";
3100
+ const effectiveCompanyId = embeddedCompanyId;
3101
+ return `workspace/${effectiveCompanyId}/agents/${agentId}/operating${suffix}`;
3102
+ }
3103
+ const directMatch = normalized.match(/^agents\/([^/]+)\/operating(\/.*)?$/);
3104
+ if (directMatch) {
3105
+ const [, agentId, suffix = ""] = directMatch;
3106
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3107
+ }
3108
+ const issueScopedMatch = normalized.match(
3109
+ /^(?:[^/]+\/)?projects\/[^/]+\/issues\/[^/]+\/agents\/([^/]+)\/operating(\/.*)?$/
3110
+ );
3111
+ if (issueScopedMatch) {
3112
+ const [, agentId, suffix = ""] = issueScopedMatch;
3113
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3114
+ }
3115
+ return null;
3116
+ }
3117
+
3118
+ function describeArtifact(kind: string, location: string) {
3119
+ const normalizedKind = kind.toLowerCase();
3120
+ if (normalizedKind.includes("folder") || normalizedKind.includes("directory") || normalizedKind === "website") {
3121
+ return `Created ${normalizedKind.replace(/_/g, " ")} at ${location}`;
3122
+ }
3123
+ if (normalizedKind.includes("file")) {
3124
+ return `Updated file ${location}`;
3125
+ }
3126
+ return `Produced ${normalizedKind.replace(/_/g, " ")} at ${location}`;
3127
+ }
3128
+
3129
+ function buildRunCompletionReport(input: {
3130
+ companyId?: string;
3131
+ agentName: string;
3132
+ providerType: HeartbeatProviderType;
3133
+ issueIds: string[];
3134
+ executionSummary: string;
3135
+ outcome: ExecutionOutcome | null;
3136
+ finalRunOutput?: AgentFinalRunOutput | null;
3137
+ trace: unknown;
3138
+ digest: RunDigest;
3139
+ terminal: RunTerminalPresentation;
3140
+ cost: RunCostSummary;
3141
+ runtimeCwd?: string | null;
3142
+ errorType?: string | null;
3143
+ errorMessage?: string | null;
3144
+ }): RunCompletionReport {
3145
+ const workspaceRootPath = input.companyId ? resolveCompanyWorkspaceRootPath(input.companyId) : null;
3146
+ const artifacts = buildRunArtifacts({
3147
+ outcome: input.outcome,
3148
+ finalRunOutput: input.finalRunOutput,
3149
+ runtimeCwd: input.runtimeCwd,
3150
+ workspaceRootPath,
3151
+ companyId: input.companyId
3152
+ });
3153
+ const fallbackSummary = sanitizeAgentSummaryCommentBody(extractNaturalRunUpdate(input.executionSummary));
3154
+ const employeeComment =
3155
+ input.finalRunOutput?.employee_comment?.trim() || buildLegacyEmployeeComment(fallbackSummary);
3156
+ const results = input.finalRunOutput
3157
+ ? input.finalRunOutput.results.filter((value): value is string => Boolean(value))
3158
+ : input.terminal.publicStatus === "completed"
3159
+ ? dedupeRunDigestPoints(
3160
+ [
3161
+ input.digest.successes[0],
3162
+ artifacts[0]?.label,
3163
+ input.terminal.completionReason === "no_assigned_work" ? "No assigned work was available for this run." : null
3164
+ ].filter((value): value is string => Boolean(value)),
3165
+ 4
3166
+ )
3167
+ : [];
3168
+ const errors =
3169
+ input.finalRunOutput?.errors.filter((value): value is string => Boolean(value)) ??
3170
+ dedupeRunDigestPoints([...input.digest.blockers, ...input.digest.failures].filter((value): value is string => Boolean(value)), 4);
3171
+ const summary = firstMeaningfulReportLine(employeeComment) || results[0] || fallbackSummary;
3172
+ const resultSummary =
3173
+ results[0] ??
3174
+ (input.terminal.publicStatus === "completed"
3175
+ ? artifacts[0]?.label ??
3176
+ (input.terminal.completionReason === "no_assigned_work" ? "No assigned work was available for this run." : summary)
3177
+ : input.finalRunOutput
3178
+ ? summary
3179
+ : "No valid final run output was produced.");
3180
+ const statusHeadline =
3181
+ input.terminal.publicStatus === "completed"
3182
+ ? `Completed: ${summary}`
3183
+ : `Failed: ${summary}`;
3184
+ const blockers = dedupeRunDigestPoints(errors, 4);
3185
+ const artifactPaths = artifacts
3186
+ .map((artifact) => artifact.relativePath ?? artifact.absolutePath ?? artifact.path)
3187
+ .filter((value): value is string => Boolean(value));
3188
+ const managerReport = {
3189
+ agentName: input.agentName,
3190
+ providerType: input.providerType,
3191
+ whatWasDone: results[0] ?? (input.terminal.publicStatus === "completed" ? input.digest.successes[0] ?? summary : summary),
3192
+ resultSummary,
3193
+ artifactPaths,
3194
+ blockers,
3195
+ nextAction: input.digest.nextAction,
3196
+ costLine: formatRunCostLine(input.cost)
3197
+ };
3198
+ const fallbackOutcome: ExecutionOutcome = input.outcome ?? {
3199
+ kind:
3200
+ input.terminal.completionReason === "no_assigned_work"
3201
+ ? "skipped"
3202
+ : input.terminal.publicStatus === "completed"
3203
+ ? "completed"
3204
+ : "failed",
3205
+ issueIdsTouched: input.issueIds,
3206
+ artifacts: artifacts.map((artifact) => ({ path: artifact.path, kind: artifact.kind })),
3207
+ actions:
3208
+ results.length > 0
3209
+ ? results.slice(0, 4).map((result) => ({
3210
+ type: input.terminal.publicStatus === "completed" ? "run.completed" : "run.failed",
3211
+ status: input.terminal.publicStatus === "completed" ? "ok" : "error",
3212
+ detail: result
3213
+ }))
3214
+ : [
3215
+ {
3216
+ type: input.terminal.publicStatus === "completed" ? "run.completed" : "run.failed",
3217
+ status: input.terminal.publicStatus === "completed" ? "ok" : "error",
3218
+ detail: managerReport.whatWasDone
3219
+ }
3220
+ ],
3221
+ blockers: blockers.map((message) => ({
3222
+ code: input.terminal.completionReason,
3223
+ message,
3224
+ retryable: input.terminal.publicStatus !== "completed"
3225
+ })),
3226
+ nextSuggestedState: input.terminal.publicStatus === "completed" ? "in_review" : "blocked"
3227
+ };
3228
+ return {
3229
+ finalStatus: input.terminal.publicStatus,
3230
+ completionReason: input.terminal.completionReason,
3231
+ statusHeadline,
3232
+ summary,
3233
+ employeeComment,
3234
+ results,
3235
+ errors,
3236
+ resultStatus: artifacts.length > 0 ? "reported" : "none_reported",
3237
+ resultSummary,
3238
+ issueIds: input.issueIds,
3239
+ artifacts,
3240
+ blockers,
3241
+ nextAction: input.digest.nextAction,
3242
+ cost: input.cost,
3243
+ managerReport,
3244
+ outcome: input.outcome ?? fallbackOutcome,
3245
+ debug: {
3246
+ persistedRunStatus: input.terminal.internalStatus,
3247
+ failureType: readTraceString(input.trace, "failureType"),
3248
+ errorType: input.errorType ?? null,
3249
+ errorMessage: input.errorMessage ?? null
3250
+ }
3251
+ };
3252
+ }
3253
+
3254
+ function firstMeaningfulReportLine(value: string) {
3255
+ for (const rawLine of value.split(/\r?\n/)) {
3256
+ const line = rawLine.replace(/^[#>*\-\s`]+/, "").trim();
3257
+ if (line) {
3258
+ return line;
3259
+ }
3260
+ }
3261
+ return "";
3262
+ }
3263
+
3264
+ function buildLegacyEmployeeComment(summary: string) {
3265
+ return summary;
3266
+ }
3267
+
3268
+ function formatRunCostLine(cost: RunCostSummary) {
3269
+ const tokens = `${cost.tokenInput} input / ${cost.tokenOutput} output tokens`;
3270
+ if (cost.usdCostStatus === "unknown" || cost.usdCost === null || cost.usdCost === undefined) {
3271
+ return `${tokens}; dollar cost unknown`;
3272
+ }
3273
+ const qualifier = cost.usdCostStatus === "estimated" ? "estimated" : "exact";
3274
+ return `${tokens}; ${qualifier} cost $${cost.usdCost.toFixed(6)}`;
3275
+ }
3276
+
3277
+ function buildHumanRunUpdateCommentFromReport(
3278
+ report: RunCompletionReport,
3279
+ options: { runId: string; companyId: string }
3280
+ ) {
3281
+ const lines = [
3282
+ report.employeeComment.trim(),
3283
+ "",
3284
+ `- Status: ${report.finalStatus}`,
3285
+ `- Agent: ${report.managerReport.agentName}`,
3286
+ `- Provider: ${report.managerReport.providerType}`,
3287
+ ""
3288
+ ];
3289
+ if (report.results.length > 0) {
3290
+ lines.push("### Results", "");
3291
+ for (const result of report.results) {
3292
+ lines.push(`- ${result}`);
3293
+ }
3294
+ lines.push("");
3295
+ }
3296
+ lines.push("### Result", "", `- What was done: ${report.managerReport.whatWasDone}`, `- Summary: ${report.managerReport.resultSummary}`);
3297
+ if (report.artifacts.length > 0) {
3298
+ for (const [artifactIndex, artifact] of report.artifacts.entries()) {
3299
+ lines.push(`- Artifact: ${formatRunArtifactMarkdownLink(artifact, { ...options, artifactIndex })}`);
3300
+ }
3301
+ }
3302
+ lines.push("");
3303
+ lines.push("### Cost", "");
3304
+ lines.push(`- Input tokens: \`${report.cost.tokenInput}\``);
3305
+ lines.push(`- Output tokens: \`${report.cost.tokenOutput}\``);
3306
+ lines.push(`- Dollar cost: ${formatRunCostForHumanReport(report.cost)}`);
3307
+ if (report.errors.length > 0) {
3308
+ lines.push("");
3309
+ lines.push("### Errors", "");
3310
+ for (const error of report.errors) {
3311
+ lines.push(`- ${error}`);
3312
+ }
3313
+ }
3314
+ return lines.join("\n");
3315
+ }
3316
+
3317
+ function formatRunArtifactMarkdownLink(
3318
+ artifact: RunArtifact,
3319
+ options: { runId: string; companyId: string; artifactIndex: number }
3320
+ ) {
3321
+ const label = resolveRunArtifactDisplayPath(artifact);
3322
+ const href = buildRunArtifactLinkHref(options);
3323
+ if (!label) {
3324
+ return "`artifact`";
3325
+ }
3326
+ if (!href) {
3327
+ return `\`${label}\``;
3328
+ }
3329
+ return `[${label}](${href})`;
3330
+ }
3331
+
3332
+ function resolveRunArtifactDisplayPath(artifact: RunArtifact) {
3333
+ const relative = toNormalizedWorkspaceRelativePath(artifact.relativePath);
3334
+ if (relative && !relative.startsWith("../")) {
3335
+ return relative;
3336
+ }
3337
+ const pathValue = toNormalizedWorkspaceRelativePath(artifact.path);
3338
+ if (pathValue && !pathValue.startsWith("../") && !isAbsolute(artifact.path)) {
3339
+ return pathValue;
3340
+ }
3341
+ return null;
3342
+ }
3343
+
3344
+ function buildRunArtifactLinkHref(options: { runId: string; companyId: string; artifactIndex: number }) {
3345
+ const apiBaseUrl = resolveControlPlaneApiBaseUrl().replace(/\/+$/, "");
3346
+ const runId = encodeURIComponent(options.runId);
3347
+ const artifactIndex = encodeURIComponent(String(options.artifactIndex));
3348
+ const companyId = encodeURIComponent(options.companyId);
3349
+ return `${apiBaseUrl}/observability/heartbeats/${runId}/artifacts/${artifactIndex}/download?companyId=${companyId}`;
3350
+ }
3351
+
3352
+ function formatRunCostForHumanReport(cost: RunCostSummary) {
3353
+ if (cost.usdCostStatus === "unknown" || cost.usdCost === null || cost.usdCost === undefined) {
3354
+ return "unknown";
3355
+ }
3356
+ const qualifier = cost.usdCostStatus === "estimated" ? "estimated " : "exact ";
3357
+ return `${qualifier}\`$${cost.usdCost.toFixed(6)}\``;
3358
+ }
3359
+
3360
+ function buildRunListMessageFromReport(report: RunCompletionReport) {
3361
+ const resultParts =
3362
+ report.finalStatus === "completed"
3363
+ ? report.results.length > 0
3364
+ ? report.results.slice(0, 2)
3365
+ : [report.resultSummary]
3366
+ : [];
3367
+ const parts = [report.statusHeadline, ...resultParts];
3368
+ if (report.artifacts.length > 0) {
3369
+ parts.push(`Artifacts: ${report.managerReport.artifactPaths.join(", ")}`);
3370
+ }
3371
+ if (report.cost.usdCostStatus === "unknown") {
3372
+ parts.push("Cost: unknown");
3373
+ } else if (report.cost.usdCost !== null && report.cost.usdCost !== undefined) {
3374
+ parts.push(`Cost: $${report.cost.usdCost.toFixed(6)}`);
3375
+ }
3376
+ const compact = parts.filter(Boolean).join(" | ");
3377
+ return compact.length > 220 ? `${compact.slice(0, 217).trimEnd()}...` : compact;
3378
+ }
3379
+
3380
+ function isMachineNoiseLine(text: string) {
3381
+ const normalized = text.trim();
3382
+ if (!normalized) {
3383
+ return true;
3384
+ }
3385
+ if (normalized.length > 220) {
3386
+ return true;
3387
+ }
3388
+ const patterns = [
3389
+ /^command:\s*/i,
3390
+ /^\s*[\[{].*[\]}]\s*$/,
3391
+ /\/bin\/(bash|zsh|sh)/i,
3392
+ /(^|\s)(\/Users\/|\/home\/|\/private\/var\/|[A-Za-z]:\\)/,
3393
+ /\b(stderr|stdout|stack trace|exit code|payload_json|tokeninput|tokenoutput|usdcost)\b/i,
3394
+ /(^|\s)at\s+\S+:\d+:\d+/,
3395
+ /```/,
3396
+ /\{[\s\S]*"(summary|tokenInput|tokenOutput|usdCost|trace|error)"[\s\S]*\}/i
3397
+ ];
3398
+ return patterns.some((pattern) => pattern.test(normalized));
3399
+ }
3400
+
3401
+ function extractSummaryFromJsonLikeText(input: string) {
3402
+ const fencedMatch = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
3403
+ const candidate = fencedMatch?.[1]?.trim() ?? input.match(/\{[\s\S]*\}\s*$/)?.[0]?.trim();
3404
+ if (!candidate) {
3405
+ return null;
3406
+ }
3407
+ try {
3408
+ const parsed = JSON.parse(candidate) as Record<string, unknown>;
3409
+ const summary = parsed.summary;
3410
+ if (typeof summary === "string" && summary.trim().length > 0) {
3411
+ return summary.trim();
1350
3412
  }
3413
+ } catch {
3414
+ // Fall through to regex extraction for loosely-formatted JSON.
1351
3415
  }
3416
+ const summaryMatch = candidate.match(/"summary"\s*:\s*"([\s\S]*?)"/);
3417
+ const summary = summaryMatch?.[1]
3418
+ ?.replace(/\\"/g, "\"")
3419
+ .replace(/\\n/g, " ")
3420
+ .replace(/\s+/g, " ")
3421
+ .trim();
3422
+ return summary && summary.length > 0 ? summary : null;
3423
+ }
1352
3424
 
1353
- const now = new Date();
1354
- const runs: string[] = [];
1355
- let skippedNotDue = 0;
1356
- let skippedStatus = 0;
1357
- let failedStarts = 0;
1358
- const sweepStartedAt = Date.now();
1359
- for (const agent of companyAgents) {
1360
- if (agent.status !== "idle" && agent.status !== "running") {
1361
- skippedStatus += 1;
1362
- continue;
1363
- }
1364
- if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
1365
- skippedNotDue += 1;
1366
- continue;
1367
- }
1368
- try {
1369
- const runId = await runHeartbeatForAgent(db, companyId, agent.id, {
1370
- trigger: "scheduler",
1371
- requestId: options?.requestId,
1372
- realtimeHub: options?.realtimeHub
1373
- });
1374
- if (runId) {
1375
- runs.push(runId);
1376
- }
1377
- } catch {
1378
- failedStarts += 1;
1379
- }
3425
+ async function appendRunSummaryComments(
3426
+ db: BopoDb,
3427
+ input: {
3428
+ companyId: string;
3429
+ issueIds: string[];
3430
+ agentId: string;
3431
+ runId: string;
3432
+ report: RunCompletionReport;
1380
3433
  }
1381
- await appendAuditEvent(db, {
1382
- companyId,
1383
- actorType: "system",
1384
- eventType: "heartbeat.sweep.completed",
1385
- entityType: "company",
1386
- entityId: companyId,
1387
- correlationId: options?.requestId ?? null,
1388
- payload: {
1389
- runIds: runs,
1390
- startedCount: runs.length,
1391
- failedStarts,
1392
- skippedStatus,
1393
- skippedNotDue,
1394
- elapsedMs: Date.now() - sweepStartedAt,
1395
- requestId: options?.requestId ?? null
1396
- }
3434
+ ) {
3435
+ if (input.issueIds.length === 0) {
3436
+ return;
3437
+ }
3438
+ const commentBody = buildHumanRunUpdateCommentFromReport(input.report, {
3439
+ runId: input.runId,
3440
+ companyId: input.companyId
1397
3441
  });
1398
- return runs;
3442
+ for (const issueId of input.issueIds) {
3443
+ const existingRunComments = await db
3444
+ .select({ id: issueComments.id })
3445
+ .from(issueComments)
3446
+ .where(
3447
+ and(
3448
+ eq(issueComments.companyId, input.companyId),
3449
+ eq(issueComments.issueId, issueId),
3450
+ eq(issueComments.runId, input.runId),
3451
+ eq(issueComments.authorType, "agent"),
3452
+ eq(issueComments.authorId, input.agentId)
3453
+ )
3454
+ )
3455
+ .orderBy(desc(issueComments.createdAt));
3456
+ if (existingRunComments.length > 0) {
3457
+ await db.delete(issueComments).where(
3458
+ and(
3459
+ eq(issueComments.companyId, input.companyId),
3460
+ inArray(
3461
+ issueComments.id,
3462
+ existingRunComments.map((comment) => comment.id)
3463
+ )
3464
+ )
3465
+ );
3466
+ }
3467
+ await addIssueComment(db, {
3468
+ companyId: input.companyId,
3469
+ issueId,
3470
+ authorType: "agent",
3471
+ authorId: input.agentId,
3472
+ runId: input.runId,
3473
+ body: commentBody
3474
+ });
3475
+ }
1399
3476
  }
1400
3477
 
1401
- async function buildHeartbeatContext(
3478
+ async function appendProviderUsageLimitBoardComments(
1402
3479
  db: BopoDb,
1403
- companyId: string,
1404
3480
  input: {
3481
+ companyId: string;
3482
+ issueIds: string[];
1405
3483
  agentId: string;
1406
- agentName: string;
1407
- agentRole: string;
1408
- managerAgentId: string | null;
1409
- providerType: HeartbeatProviderType;
1410
- heartbeatRunId: string;
1411
- state: AgentState;
1412
- memoryContext?: HeartbeatContext["memoryContext"];
1413
- runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
1414
- workItems: Array<{
1415
- id: string;
1416
- project_id: string;
1417
- title: string;
1418
- body: string | null;
1419
- status: string;
1420
- priority: string;
1421
- labels_json: string;
1422
- tags_json: string;
1423
- }>;
3484
+ runId: string;
3485
+ providerType: string;
3486
+ message: string;
3487
+ paused: boolean;
1424
3488
  }
1425
- ): Promise<HeartbeatContext> {
1426
- const [company] = await db
1427
- .select({ name: companies.name, mission: companies.mission })
1428
- .from(companies)
1429
- .where(eq(companies.id, companyId))
1430
- .limit(1);
1431
- const projectIds = Array.from(new Set(input.workItems.map((item) => item.project_id)));
1432
- const projectRows =
1433
- projectIds.length > 0
1434
- ? await db
1435
- .select({ id: projects.id, name: projects.name })
1436
- .from(projects)
1437
- .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)))
1438
- : [];
1439
- const projectNameById = new Map(projectRows.map((row) => [row.id, row.name]));
1440
- const projectWorkspaceContextMap = await getProjectWorkspaceContextMap(db, companyId, projectIds);
1441
- const projectWorkspaceMap = new Map(
1442
- Array.from(projectWorkspaceContextMap.entries()).map(([projectId, context]) => [projectId, context.cwd])
1443
- );
1444
- const issueIds = input.workItems.map((item) => item.id);
1445
- const attachmentRows =
1446
- issueIds.length > 0
1447
- ? await db
1448
- .select({
1449
- id: issueAttachments.id,
1450
- issueId: issueAttachments.issueId,
1451
- projectId: issueAttachments.projectId,
1452
- fileName: issueAttachments.fileName,
1453
- mimeType: issueAttachments.mimeType,
1454
- fileSizeBytes: issueAttachments.fileSizeBytes,
1455
- relativePath: issueAttachments.relativePath
1456
- })
1457
- .from(issueAttachments)
1458
- .where(and(eq(issueAttachments.companyId, companyId), inArray(issueAttachments.issueId, issueIds)))
1459
- : [];
1460
- const attachmentsByIssue = new Map<
1461
- string,
1462
- Array<{
1463
- id: string;
1464
- fileName: string;
1465
- mimeType: string | null;
1466
- fileSizeBytes: number;
1467
- relativePath: string;
1468
- absolutePath: string;
1469
- }>
1470
- >();
1471
- for (const row of attachmentRows) {
1472
- const projectWorkspace = projectWorkspaceMap.get(row.projectId) ?? resolveProjectWorkspacePath(companyId, row.projectId);
1473
- const absolutePath = resolve(projectWorkspace, row.relativePath);
1474
- if (!isInsidePath(projectWorkspace, absolutePath)) {
3489
+ ) {
3490
+ if (input.issueIds.length === 0) {
3491
+ return;
3492
+ }
3493
+ const commentBody = buildProviderUsageLimitBoardCommentBody(input);
3494
+ for (const issueId of input.issueIds) {
3495
+ const [existingRunComment] = await db
3496
+ .select({ id: issueComments.id })
3497
+ .from(issueComments)
3498
+ .where(
3499
+ and(
3500
+ eq(issueComments.companyId, input.companyId),
3501
+ eq(issueComments.issueId, issueId),
3502
+ eq(issueComments.runId, input.runId),
3503
+ eq(issueComments.authorType, "system"),
3504
+ eq(issueComments.authorId, input.agentId)
3505
+ )
3506
+ )
3507
+ .limit(1);
3508
+ if (existingRunComment) {
1475
3509
  continue;
1476
3510
  }
1477
- const existing = attachmentsByIssue.get(row.issueId) ?? [];
1478
- existing.push({
1479
- id: row.id,
1480
- fileName: row.fileName,
1481
- mimeType: row.mimeType,
1482
- fileSizeBytes: row.fileSizeBytes,
1483
- relativePath: row.relativePath,
1484
- absolutePath
3511
+ await addIssueComment(db, {
3512
+ companyId: input.companyId,
3513
+ issueId,
3514
+ authorType: "system",
3515
+ authorId: input.agentId,
3516
+ runId: input.runId,
3517
+ recipients: [
3518
+ {
3519
+ recipientType: "board",
3520
+ deliveryStatus: "pending"
3521
+ }
3522
+ ],
3523
+ body: commentBody
1485
3524
  });
1486
- attachmentsByIssue.set(row.issueId, existing);
1487
3525
  }
1488
- const goalRows = await db
1489
- .select({
1490
- id: goals.id,
1491
- level: goals.level,
1492
- title: goals.title,
1493
- status: goals.status,
1494
- projectId: goals.projectId
1495
- })
1496
- .from(goals)
1497
- .where(eq(goals.companyId, companyId));
1498
-
1499
- const activeCompanyGoals = goalRows
1500
- .filter((goal) => goal.status === "active" && goal.level === "company")
1501
- .map((goal) => goal.title);
1502
- const activeProjectGoals = goalRows
1503
- .filter(
1504
- (goal) =>
1505
- goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId)
1506
- )
1507
- .map((goal) => goal.title);
1508
- const activeAgentGoals = goalRows
1509
- .filter((goal) => goal.status === "active" && goal.level === "agent")
1510
- .map((goal) => goal.title);
3526
+ }
1511
3527
 
1512
- return {
1513
- companyId,
1514
- agentId: input.agentId,
1515
- providerType: input.providerType,
1516
- heartbeatRunId: input.heartbeatRunId,
1517
- company: {
1518
- name: company?.name ?? "Unknown company",
1519
- mission: company?.mission ?? null
1520
- },
1521
- agent: {
1522
- name: input.agentName,
1523
- role: input.agentRole,
1524
- managerAgentId: input.managerAgentId
1525
- },
1526
- state: input.state,
1527
- memoryContext: input.memoryContext,
1528
- runtime: input.runtime,
1529
- goalContext: {
1530
- companyGoals: activeCompanyGoals,
1531
- projectGoals: activeProjectGoals,
1532
- agentGoals: activeAgentGoals
1533
- },
1534
- workItems: input.workItems.map((item) => ({
1535
- issueId: item.id,
1536
- projectId: item.project_id,
1537
- projectName: projectNameById.get(item.project_id) ?? null,
1538
- title: item.title,
1539
- body: item.body,
1540
- status: item.status,
1541
- priority: item.priority,
1542
- labels: parseStringArray(item.labels_json),
1543
- tags: parseStringArray(item.tags_json),
1544
- attachments: attachmentsByIssue.get(item.id) ?? []
1545
- }))
1546
- };
3528
+ function buildProviderUsageLimitBoardCommentBody(input: {
3529
+ providerType: string;
3530
+ message: string;
3531
+ paused: boolean;
3532
+ }) {
3533
+ const providerLabel = input.providerType.replace(/[_-]+/g, " ").trim();
3534
+ const normalizedProvider = providerLabel.charAt(0).toUpperCase() + providerLabel.slice(1);
3535
+ const agentStateLine = input.paused ? "Agent paused." : "Agent already paused.";
3536
+ return `${normalizedProvider} usage limit reached.\nRun failed due to provider limits.\n${agentStateLine}\nNext: resume after usage reset or billing/credential fix.`;
1547
3537
  }
1548
3538
 
1549
- function parseStringArray(value: string | null) {
1550
- if (!value) {
1551
- return [];
3539
+ async function pauseAgentForProviderUsageLimit(
3540
+ db: BopoDb,
3541
+ input: {
3542
+ companyId: string;
3543
+ agentId: string;
3544
+ requestId: string;
3545
+ runId: string;
3546
+ providerType: string;
3547
+ message: string;
1552
3548
  }
1553
- try {
1554
- const parsed = JSON.parse(value) as unknown;
1555
- return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
1556
- } catch {
1557
- return [];
3549
+ ) {
3550
+ const [agentRow] = await db
3551
+ .select({ status: agents.status })
3552
+ .from(agents)
3553
+ .where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId)))
3554
+ .limit(1);
3555
+ if (!agentRow || agentRow.status === "paused" || agentRow.status === "terminated") {
3556
+ return { paused: false as const };
1558
3557
  }
3558
+ await db
3559
+ .update(agents)
3560
+ .set({ status: "paused", updatedAt: new Date() })
3561
+ .where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId)));
3562
+ await appendAuditEvent(db, {
3563
+ companyId: input.companyId,
3564
+ actorType: "system",
3565
+ eventType: "agent.paused_auto_provider_limit",
3566
+ entityType: "agent",
3567
+ entityId: input.agentId,
3568
+ correlationId: input.requestId,
3569
+ payload: {
3570
+ runId: input.runId,
3571
+ providerType: input.providerType,
3572
+ reason: input.message
3573
+ }
3574
+ });
3575
+ return { paused: true as const };
1559
3576
  }
1560
3577
 
1561
3578
  function parseAgentState(stateBlob: string | null) {
@@ -1859,6 +3876,7 @@ async function resolveRuntimeWorkspaceForWorkItems(
1859
3876
  continue;
1860
3877
  }
1861
3878
  let selectedWorkspaceCwd = normalizeCompanyWorkspacePath(companyId, baseWorkspaceCwd);
3879
+ const projectIssue = workItems.find((item) => item.project_id === projectId);
1862
3880
  await mkdir(baseWorkspaceCwd, { recursive: true });
1863
3881
  try {
1864
3882
  if (hasText(projectContext.repoUrl)) {
@@ -1878,7 +3896,6 @@ async function resolveRuntimeWorkspaceForWorkItems(
1878
3896
  projectContext.policy?.strategy?.type === "git_worktree" &&
1879
3897
  resolveGitWorktreeIsolationEnabled()
1880
3898
  ) {
1881
- const projectIssue = workItems.find((item) => item.project_id === projectId);
1882
3899
  const worktree = await ensureIsolatedGitWorktree({
1883
3900
  companyId,
1884
3901
  repoCwd: selectedWorkspaceCwd,
@@ -1899,6 +3916,12 @@ async function resolveRuntimeWorkspaceForWorkItems(
1899
3916
  warnings.push(`Workspace bootstrap failed for project '${projectId}': ${message}`);
1900
3917
  }
1901
3918
 
3919
+ if (projectIssue?.id) {
3920
+ const issueScopedWorkspaceCwd = resolveProjectIssueWorkspaceCwd(companyId, selectedWorkspaceCwd, projectIssue.id);
3921
+ await mkdir(issueScopedWorkspaceCwd, { recursive: true });
3922
+ selectedWorkspaceCwd = issueScopedWorkspaceCwd;
3923
+ }
3924
+
1902
3925
  if (hasText(normalizedRuntimeCwd) && normalizedRuntimeCwd !== selectedWorkspaceCwd) {
1903
3926
  warnings.push(
1904
3927
  `Runtime cwd '${normalizedRuntimeCwd}' was overridden to project workspace '${selectedWorkspaceCwd}' for assigned work.`
@@ -1943,6 +3966,10 @@ async function resolveRuntimeWorkspaceForWorkItems(
1943
3966
  };
1944
3967
  }
1945
3968
 
3969
+ function resolveProjectIssueWorkspaceCwd(companyId: string, projectWorkspaceCwd: string, issueId: string) {
3970
+ return normalizeCompanyWorkspacePath(companyId, join(projectWorkspaceCwd, "issues", issueId));
3971
+ }
3972
+
1946
3973
  function resolveGitWorktreeIsolationEnabled() {
1947
3974
  const value = String(process.env.BOPO_ENABLE_GIT_WORKTREE_ISOLATION ?? "")
1948
3975
  .trim()
@@ -1958,6 +3985,39 @@ function resolveStaleRunThresholdMs() {
1958
3985
  return parsed;
1959
3986
  }
1960
3987
 
3988
+ function resolveHeartbeatSweepConcurrency(dueAgentsCount: number) {
3989
+ const configured = Number(process.env.BOPO_HEARTBEAT_SWEEP_CONCURRENCY ?? "4");
3990
+ const fallback = 4;
3991
+ const normalized = Number.isFinite(configured) ? Math.floor(configured) : fallback;
3992
+ if (normalized < 1) {
3993
+ return 1;
3994
+ }
3995
+ // Prevent scheduler bursts from starving the API event loop.
3996
+ const bounded = Math.min(normalized, 16);
3997
+ return Math.min(bounded, Math.max(1, dueAgentsCount));
3998
+ }
3999
+
4000
+ async function runWithConcurrency<T>(
4001
+ items: T[],
4002
+ concurrency: number,
4003
+ worker: (item: T, index: number) => Promise<void>
4004
+ ) {
4005
+ if (items.length === 0) {
4006
+ return;
4007
+ }
4008
+ const workerCount = Math.max(1, Math.min(Math.floor(concurrency), items.length));
4009
+ let cursor = 0;
4010
+ await Promise.all(
4011
+ Array.from({ length: workerCount }, async () => {
4012
+ while (cursor < items.length) {
4013
+ const index = cursor;
4014
+ cursor += 1;
4015
+ await worker(items[index] as T, index);
4016
+ }
4017
+ })
4018
+ );
4019
+ }
4020
+
1961
4021
  function resolveEffectiveStaleRunThresholdMs(input: {
1962
4022
  baseThresholdMs: number;
1963
4023
  runtimeTimeoutSec: number;
@@ -2245,6 +4305,7 @@ function buildHeartbeatRuntimeEnv(input: {
2245
4305
  agentId: string;
2246
4306
  heartbeatRunId: string;
2247
4307
  canHireAgents: boolean;
4308
+ wakeContext?: HeartbeatWakeContext;
2248
4309
  }) {
2249
4310
  const apiBaseUrl = resolveControlPlaneApiBaseUrl();
2250
4311
  const actorPermissions = ["issues:write", ...(input.canHireAgents ? ["agents:write"] : [])].join(",");
@@ -2272,6 +4333,9 @@ function buildHeartbeatRuntimeEnv(input: {
2272
4333
  BOPODEV_REQUEST_HEADERS_JSON: actorHeaders,
2273
4334
  BOPODEV_REQUEST_APPROVAL_DEFAULT: "true",
2274
4335
  BOPODEV_CAN_HIRE_AGENTS: input.canHireAgents ? "true" : "false",
4336
+ ...(input.wakeContext?.reason ? { BOPODEV_WAKE_REASON: input.wakeContext.reason } : {}),
4337
+ ...(input.wakeContext?.commentId ? { BOPODEV_WAKE_COMMENT_ID: input.wakeContext.commentId } : {}),
4338
+ ...(input.wakeContext?.issueIds?.length ? { BOPODEV_LINKED_ISSUE_IDS: input.wakeContext.issueIds.join(",") } : {}),
2275
4339
  ...(codexApiKey ? { OPENAI_API_KEY: codexApiKey } : {}),
2276
4340
  ...(claudeApiKey ? { ANTHROPIC_API_KEY: claudeApiKey } : {})
2277
4341
  } satisfies Record<string, string>;
@@ -2533,6 +4597,7 @@ function resolveRuntimeModelId(input: { runtimeModel?: string; stateBlob?: strin
2533
4597
  async function appendFinishedRunCostEntry(input: {
2534
4598
  db: BopoDb;
2535
4599
  companyId: string;
4600
+ runId?: string | null;
2536
4601
  providerType: string;
2537
4602
  runtimeModelId: string | null;
2538
4603
  pricingProviderType?: string | null;
@@ -2559,25 +4624,22 @@ async function appendFinishedRunCostEntry(input: {
2559
4624
  const shouldPersist = input.status === "ok" || input.status === "failed";
2560
4625
  const runtimeUsdCost = Math.max(0, input.runtimeUsdCost ?? 0);
2561
4626
  const pricedUsdCost = Math.max(0, pricingDecision.usdCost);
2562
- const shouldUseRuntimeUsdCost = pricedUsdCost <= 0 && runtimeUsdCost > 0;
2563
- const baseUsdCost = shouldUseRuntimeUsdCost ? runtimeUsdCost : pricedUsdCost;
2564
- const effectiveUsdCost =
2565
- baseUsdCost > 0
2566
- ? baseUsdCost
2567
- : input.status === "failed" && input.failureType !== "spawn_error"
2568
- ? 0.000001
2569
- : 0;
4627
+ const usdCostStatus: "exact" | "estimated" | "unknown" =
4628
+ runtimeUsdCost > 0 ? "exact" : pricedUsdCost > 0 ? "estimated" : "unknown";
4629
+ const effectiveUsdCost = usdCostStatus === "exact" ? runtimeUsdCost : usdCostStatus === "estimated" ? pricedUsdCost : 0;
2570
4630
  const effectivePricingSource = pricingDecision.pricingSource;
2571
4631
  const shouldPersistWithUsage =
2572
- shouldPersist && (input.tokenInput > 0 || input.tokenOutput > 0 || effectiveUsdCost > 0);
4632
+ shouldPersist && (input.tokenInput > 0 || input.tokenOutput > 0 || usdCostStatus !== "unknown");
2573
4633
  if (shouldPersistWithUsage) {
2574
4634
  await appendCost(input.db, {
2575
4635
  companyId: input.companyId,
4636
+ runId: input.runId ?? null,
2576
4637
  providerType: input.providerType,
2577
4638
  runtimeModelId: input.runtimeModelId,
2578
4639
  pricingProviderType: pricingDecision.pricingProviderType,
2579
4640
  pricingModelId: pricingDecision.pricingModelId,
2580
4641
  pricingSource: effectivePricingSource,
4642
+ usdCostStatus,
2581
4643
  tokenInput: input.tokenInput,
2582
4644
  tokenOutput: input.tokenOutput,
2583
4645
  usdCost: effectiveUsdCost.toFixed(6),
@@ -2590,7 +4652,8 @@ async function appendFinishedRunCostEntry(input: {
2590
4652
  return {
2591
4653
  ...pricingDecision,
2592
4654
  pricingSource: effectivePricingSource,
2593
- usdCost: effectiveUsdCost
4655
+ usdCost: effectiveUsdCost,
4656
+ usdCostStatus
2594
4657
  };
2595
4658
  }
2596
4659