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.
- package/package.json +4 -4
- package/src/app.ts +11 -1
- package/src/http.ts +39 -0
- package/src/lib/comment-recipients.ts +105 -0
- package/src/lib/hiring-delegate.ts +7 -6
- package/src/lib/instance-paths.ts +11 -0
- package/src/realtime/attention.ts +47 -0
- package/src/realtime/governance.ts +11 -3
- package/src/realtime/heartbeat-runs.ts +33 -11
- package/src/realtime/hub.ts +34 -2
- package/src/realtime/office-space.ts +17 -1
- package/src/routes/agents.ts +81 -12
- package/src/routes/attention.ts +112 -0
- package/src/routes/companies.ts +13 -5
- package/src/routes/goals.ts +10 -2
- package/src/routes/governance.ts +85 -2
- package/src/routes/heartbeats.ts +81 -43
- package/src/routes/issues.ts +293 -62
- package/src/routes/observability.ts +219 -10
- package/src/routes/projects.ts +7 -2
- package/src/scripts/onboard-seed.ts +8 -7
- package/src/server.ts +3 -1
- package/src/services/attention-service.ts +412 -0
- package/src/services/budget-service.ts +99 -2
- package/src/services/comment-recipient-dispatch-service.ts +158 -0
- package/src/services/governance-service.ts +237 -14
- package/src/services/heartbeat-queue-service.ts +318 -0
- package/src/services/heartbeat-service.ts +2341 -278
- package/src/services/memory-file-service.ts +510 -35
- package/src/services/plugin-runtime.ts +33 -1
- package/src/services/template-apply-service.ts +37 -2
- package/src/worker/scheduler.ts +46 -8
|
@@ -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 {
|
|
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
|
|
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:
|
|
534
|
+
message: runListMessage
|
|
270
535
|
});
|
|
271
536
|
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
272
537
|
companyId,
|
|
273
538
|
runId: skippedRunId,
|
|
274
539
|
status: "skipped",
|
|
275
|
-
message:
|
|
540
|
+
message: runListMessage,
|
|
276
541
|
finishedAt: skippedAt
|
|
277
542
|
});
|
|
278
543
|
await appendAuditEvent(db, {
|
|
279
544
|
companyId,
|
|
280
545
|
actorType: "system",
|
|
281
|
-
eventType: "heartbeat.
|
|
546
|
+
eventType: "heartbeat.failed",
|
|
282
547
|
entityType: "heartbeat_run",
|
|
283
548
|
entityId: skippedRunId,
|
|
284
549
|
correlationId: options?.requestId ?? skippedRunId,
|
|
285
|
-
payload: {
|
|
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
|
-
|
|
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:
|
|
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
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
846
|
-
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
|
-
|
|
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:
|
|
971
|
-
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:
|
|
1462
|
+
status: persistedRunStatus,
|
|
983
1463
|
finishedAt: new Date(),
|
|
984
|
-
message:
|
|
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:
|
|
991
|
-
message:
|
|
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:
|
|
1077
|
-
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
|
-
|
|
1097
|
-
|
|
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
|
-
|
|
1787
|
+
projectCostsUsd: buildProjectBudgetCostAllocations(executionWorkItemsForBudget, failureCostDecision.usdCost)
|
|
1788
|
+
});
|
|
1789
|
+
const runDigest = buildRunDigest({
|
|
1192
1790
|
status: "failed",
|
|
1193
|
-
|
|
1194
|
-
|
|
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:
|
|
1207
|
-
message:
|
|
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,
|
|
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
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
-
)
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
.
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
|
2563
|
-
|
|
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 ||
|
|
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
|
|