bopodev-api 0.1.28 → 0.1.29
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 +17 -69
- package/src/lib/run-artifact-paths.ts +8 -0
- package/src/middleware/cors-config.ts +36 -0
- package/src/middleware/request-actor.ts +10 -16
- package/src/middleware/request-id.ts +9 -0
- package/src/middleware/request-logging.ts +24 -0
- package/src/routes/agents.ts +3 -9
- package/src/routes/companies.ts +18 -1
- package/src/routes/goals.ts +7 -13
- package/src/routes/governance.ts +2 -5
- package/src/routes/heartbeats.ts +7 -25
- package/src/routes/issues.ts +62 -120
- package/src/routes/observability.ts +6 -1
- package/src/routes/plugins.ts +5 -17
- package/src/routes/projects.ts +7 -25
- package/src/routes/templates.ts +6 -21
- package/src/scripts/onboard-seed.ts +5 -7
- package/src/server.ts +33 -292
- package/src/services/company-export-service.ts +63 -0
- package/src/services/governance-service.ts +4 -1
- package/src/services/heartbeat-service/active-runs.ts +15 -0
- package/src/services/heartbeat-service/budget-override.ts +46 -0
- package/src/services/heartbeat-service/claims.ts +61 -0
- package/src/services/heartbeat-service/cron.ts +58 -0
- package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
- package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
- package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +183 -633
- package/src/services/heartbeat-service/index.ts +5 -0
- package/src/services/heartbeat-service/stop.ts +90 -0
- package/src/services/heartbeat-service/sweep.ts +145 -0
- package/src/services/heartbeat-service/types.ts +65 -0
- package/src/services/memory-file-service.ts +10 -2
- package/src/shutdown/graceful-shutdown.ts +77 -0
- package/src/startup/database.ts +41 -0
- package/src/startup/deployment-validation.ts +37 -0
- package/src/startup/env.ts +17 -0
- package/src/startup/runtime-health.ts +128 -0
- package/src/startup/scheduler-config.ts +39 -0
- package/src/types/express.d.ts +13 -0
- package/src/types/request-actor.ts +6 -0
- package/src/validation/issue-routes.ts +79 -0
|
@@ -34,284 +34,49 @@ import {
|
|
|
34
34
|
issueComments,
|
|
35
35
|
issueAttachments,
|
|
36
36
|
issues,
|
|
37
|
+
listIssueGoalIdsBatch,
|
|
37
38
|
projects,
|
|
38
39
|
sql
|
|
39
40
|
} from "bopodev-db";
|
|
40
41
|
import { appendAuditEvent, appendCost } from "bopodev-db";
|
|
41
|
-
import { parseRuntimeConfigFromAgentRow } from "
|
|
42
|
-
import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "
|
|
42
|
+
import { parseRuntimeConfigFromAgentRow } from "../../lib/agent-config";
|
|
43
|
+
import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../../lib/git-runtime";
|
|
43
44
|
import {
|
|
44
45
|
isInsidePath,
|
|
45
46
|
normalizeCompanyWorkspacePath,
|
|
46
47
|
resolveCompanyWorkspaceRootPath,
|
|
47
48
|
resolveProjectWorkspacePath
|
|
48
|
-
} from "
|
|
49
|
-
import { resolveRunArtifactAbsolutePath } from "
|
|
49
|
+
} from "../../lib/instance-paths";
|
|
50
|
+
import { resolveRunArtifactAbsolutePath } from "../../lib/run-artifact-paths";
|
|
50
51
|
import {
|
|
51
52
|
assertRuntimeCwdForCompany,
|
|
52
53
|
getProjectWorkspaceContextMap,
|
|
53
54
|
hasText,
|
|
54
55
|
resolveAgentFallbackWorkspace
|
|
55
|
-
} from "
|
|
56
|
-
import type { RealtimeHub } from "
|
|
57
|
-
import { createHeartbeatRunsRealtimeEvent, loadHeartbeatRunsRealtimeSnapshot } from "
|
|
58
|
-
import { publishAttentionSnapshot } from "
|
|
59
|
-
import { publishOfficeOccupantForAgent } from "
|
|
60
|
-
import { appendProjectBudgetUsage, checkAgentBudget, checkProjectBudget } from "
|
|
61
|
-
import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "
|
|
62
|
-
import { calculateModelPricedUsdCost } from "
|
|
63
|
-
import { runPluginHook } from "
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
companyId: string;
|
|
80
|
-
agentId: string;
|
|
81
|
-
abortController: AbortController;
|
|
82
|
-
cancelReason?: string | null;
|
|
83
|
-
cancelRequestedAt?: string | null;
|
|
84
|
-
cancelRequestedBy?: string | null;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const activeHeartbeatRuns = new Map<string, ActiveHeartbeatRun>();
|
|
88
|
-
type HeartbeatWakeContext = {
|
|
89
|
-
reason?: string | null;
|
|
90
|
-
commentId?: string | null;
|
|
91
|
-
commentBody?: string | null;
|
|
92
|
-
issueIds?: string[];
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const AGENT_COMMENT_EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
|
|
96
|
-
|
|
97
|
-
type RunDigestSignal = {
|
|
98
|
-
sequence: number;
|
|
99
|
-
kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
|
|
100
|
-
label: string | null;
|
|
101
|
-
text: string | null;
|
|
102
|
-
payload: string | null;
|
|
103
|
-
signalLevel: "high" | "medium" | "low" | "noise";
|
|
104
|
-
groupKey: string | null;
|
|
105
|
-
source: "stdout" | "stderr" | "trace_fallback";
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
type RunDigest = {
|
|
109
|
-
status: "completed" | "failed" | "skipped";
|
|
110
|
-
headline: string;
|
|
111
|
-
summary: string;
|
|
112
|
-
successes: string[];
|
|
113
|
-
failures: string[];
|
|
114
|
-
blockers: string[];
|
|
115
|
-
nextAction: string;
|
|
116
|
-
evidence: {
|
|
117
|
-
transcriptSignalCount: number;
|
|
118
|
-
outcomeActionCount: number;
|
|
119
|
-
outcomeBlockerCount: number;
|
|
120
|
-
failureType: string | null;
|
|
121
|
-
};
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
type RunTerminalPresentation = {
|
|
125
|
-
internalStatus: "completed" | "failed" | "skipped";
|
|
126
|
-
publicStatus: "completed" | "failed";
|
|
127
|
-
completionReason: RunCompletionReason;
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
export async function claimIssuesForAgent(
|
|
131
|
-
db: BopoDb,
|
|
132
|
-
companyId: string,
|
|
133
|
-
agentId: string,
|
|
134
|
-
heartbeatRunId: string,
|
|
135
|
-
maxItems = 5
|
|
136
|
-
) {
|
|
137
|
-
const result = await db.execute(sql`
|
|
138
|
-
WITH candidate AS (
|
|
139
|
-
SELECT id
|
|
140
|
-
FROM issues
|
|
141
|
-
WHERE company_id = ${companyId}
|
|
142
|
-
AND assignee_agent_id = ${agentId}
|
|
143
|
-
AND status IN ('todo', 'in_progress')
|
|
144
|
-
AND is_claimed = false
|
|
145
|
-
ORDER BY
|
|
146
|
-
CASE priority
|
|
147
|
-
WHEN 'urgent' THEN 0
|
|
148
|
-
WHEN 'high' THEN 1
|
|
149
|
-
WHEN 'medium' THEN 2
|
|
150
|
-
WHEN 'low' THEN 3
|
|
151
|
-
ELSE 4
|
|
152
|
-
END ASC,
|
|
153
|
-
updated_at ASC
|
|
154
|
-
LIMIT ${maxItems}
|
|
155
|
-
FOR UPDATE SKIP LOCKED
|
|
156
|
-
)
|
|
157
|
-
UPDATE issues i
|
|
158
|
-
SET is_claimed = true,
|
|
159
|
-
claimed_by_heartbeat_run_id = ${heartbeatRunId},
|
|
160
|
-
updated_at = CURRENT_TIMESTAMP
|
|
161
|
-
FROM candidate c
|
|
162
|
-
WHERE i.id = c.id
|
|
163
|
-
RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
|
|
164
|
-
`);
|
|
165
|
-
|
|
166
|
-
return result as unknown as Array<{
|
|
167
|
-
id: string;
|
|
168
|
-
project_id: string;
|
|
169
|
-
parent_issue_id: string | null;
|
|
170
|
-
title: string;
|
|
171
|
-
body: string | null;
|
|
172
|
-
status: string;
|
|
173
|
-
priority: string;
|
|
174
|
-
labels_json: string;
|
|
175
|
-
tags_json: string;
|
|
176
|
-
}>;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
|
|
180
|
-
if (issueIds.length === 0) {
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
await db
|
|
184
|
-
.update(issues)
|
|
185
|
-
.set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
|
|
186
|
-
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export async function stopHeartbeatRun(
|
|
190
|
-
db: BopoDb,
|
|
191
|
-
companyId: string,
|
|
192
|
-
runId: string,
|
|
193
|
-
options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger; realtimeHub?: RealtimeHub }
|
|
194
|
-
) {
|
|
195
|
-
const runTrigger = options?.trigger ?? "manual";
|
|
196
|
-
const [run] = await db
|
|
197
|
-
.select({
|
|
198
|
-
id: heartbeatRuns.id,
|
|
199
|
-
status: heartbeatRuns.status,
|
|
200
|
-
agentId: heartbeatRuns.agentId
|
|
201
|
-
})
|
|
202
|
-
.from(heartbeatRuns)
|
|
203
|
-
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
|
|
204
|
-
.limit(1);
|
|
205
|
-
if (!run) {
|
|
206
|
-
return { ok: false as const, reason: "not_found" as const };
|
|
207
|
-
}
|
|
208
|
-
if (run.status !== "started") {
|
|
209
|
-
return { ok: false as const, reason: "invalid_status" as const, status: run.status };
|
|
210
|
-
}
|
|
211
|
-
const active = activeHeartbeatRuns.get(runId);
|
|
212
|
-
const cancelReason = "cancelled by stop request";
|
|
213
|
-
const cancelRequestedAt = new Date().toISOString();
|
|
214
|
-
if (active) {
|
|
215
|
-
active.cancelReason = cancelReason;
|
|
216
|
-
active.cancelRequestedAt = cancelRequestedAt;
|
|
217
|
-
active.cancelRequestedBy = options?.actorId ?? null;
|
|
218
|
-
active.abortController.abort(cancelReason);
|
|
219
|
-
} else {
|
|
220
|
-
const finishedAt = new Date();
|
|
221
|
-
await db
|
|
222
|
-
.update(heartbeatRuns)
|
|
223
|
-
.set({
|
|
224
|
-
status: "failed",
|
|
225
|
-
finishedAt,
|
|
226
|
-
message: "Heartbeat cancelled by stop request."
|
|
227
|
-
})
|
|
228
|
-
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
229
|
-
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
230
|
-
companyId,
|
|
231
|
-
runId,
|
|
232
|
-
status: "failed",
|
|
233
|
-
message: "Heartbeat cancelled by stop request.",
|
|
234
|
-
finishedAt
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
await appendAuditEvent(db, {
|
|
238
|
-
companyId,
|
|
239
|
-
actorType: "system",
|
|
240
|
-
eventType: "heartbeat.cancel_requested",
|
|
241
|
-
entityType: "heartbeat_run",
|
|
242
|
-
entityId: runId,
|
|
243
|
-
correlationId: options?.requestId ?? runId,
|
|
244
|
-
payload: {
|
|
245
|
-
agentId: run.agentId,
|
|
246
|
-
trigger: runTrigger,
|
|
247
|
-
requestId: options?.requestId ?? null,
|
|
248
|
-
actorId: options?.actorId ?? null,
|
|
249
|
-
inMemoryAbortRegistered: Boolean(active)
|
|
250
|
-
}
|
|
251
|
-
});
|
|
252
|
-
if (!active) {
|
|
253
|
-
await appendAuditEvent(db, {
|
|
254
|
-
companyId,
|
|
255
|
-
actorType: "system",
|
|
256
|
-
eventType: "heartbeat.cancelled",
|
|
257
|
-
entityType: "heartbeat_run",
|
|
258
|
-
entityId: runId,
|
|
259
|
-
correlationId: options?.requestId ?? runId,
|
|
260
|
-
payload: {
|
|
261
|
-
agentId: run.agentId,
|
|
262
|
-
reason: cancelReason,
|
|
263
|
-
trigger: runTrigger,
|
|
264
|
-
requestId: options?.requestId ?? null,
|
|
265
|
-
actorId: options?.actorId ?? null
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
return { ok: true as const, runId, agentId: run.agentId, status: run.status };
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export async function findPendingProjectBudgetOverrideBlocksForAgent(
|
|
273
|
-
db: BopoDb,
|
|
274
|
-
companyId: string,
|
|
275
|
-
agentId: string
|
|
276
|
-
) {
|
|
277
|
-
const assignedRows = await db
|
|
278
|
-
.select({ projectId: issues.projectId })
|
|
279
|
-
.from(issues)
|
|
280
|
-
.where(
|
|
281
|
-
and(
|
|
282
|
-
eq(issues.companyId, companyId),
|
|
283
|
-
eq(issues.assigneeAgentId, agentId),
|
|
284
|
-
inArray(issues.status, ["todo", "in_progress"])
|
|
285
|
-
)
|
|
286
|
-
);
|
|
287
|
-
const assignedProjectIds = new Set(assignedRows.map((row) => row.projectId));
|
|
288
|
-
if (assignedProjectIds.size === 0) {
|
|
289
|
-
return [] as string[];
|
|
290
|
-
}
|
|
291
|
-
const pendingOverrides = await db
|
|
292
|
-
.select({ payloadJson: approvalRequests.payloadJson })
|
|
293
|
-
.from(approvalRequests)
|
|
294
|
-
.where(
|
|
295
|
-
and(
|
|
296
|
-
eq(approvalRequests.companyId, companyId),
|
|
297
|
-
eq(approvalRequests.action, "override_budget"),
|
|
298
|
-
eq(approvalRequests.status, "pending")
|
|
299
|
-
)
|
|
300
|
-
);
|
|
301
|
-
const blockedProjectIds = new Set<string>();
|
|
302
|
-
for (const approval of pendingOverrides) {
|
|
303
|
-
try {
|
|
304
|
-
const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
|
|
305
|
-
const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
|
|
306
|
-
if (projectId && assignedProjectIds.has(projectId)) {
|
|
307
|
-
blockedProjectIds.add(projectId);
|
|
308
|
-
}
|
|
309
|
-
} catch {
|
|
310
|
-
// Ignore malformed payloads to keep enforcement resilient.
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
return Array.from(blockedProjectIds);
|
|
314
|
-
}
|
|
56
|
+
} from "../../lib/workspace-policy";
|
|
57
|
+
import type { RealtimeHub } from "../../realtime/hub";
|
|
58
|
+
import { createHeartbeatRunsRealtimeEvent, loadHeartbeatRunsRealtimeSnapshot } from "../../realtime/heartbeat-runs";
|
|
59
|
+
import { publishAttentionSnapshot } from "../../realtime/attention";
|
|
60
|
+
import { publishOfficeOccupantForAgent } from "../../realtime/office-space";
|
|
61
|
+
import { appendProjectBudgetUsage, checkAgentBudget, checkProjectBudget } from "../budget-service";
|
|
62
|
+
import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "../memory-file-service";
|
|
63
|
+
import { calculateModelPricedUsdCost } from "../model-pricing";
|
|
64
|
+
import { runPluginHook } from "../plugin-runtime";
|
|
65
|
+
import { registerActiveHeartbeatRun, unregisterActiveHeartbeatRun } from "./active-runs";
|
|
66
|
+
import { claimIssuesForAgent, releaseClaimedIssues } from "./claims";
|
|
67
|
+
import { publishHeartbeatRunStatus } from "./heartbeat-realtime";
|
|
68
|
+
import { extractNaturalRunUpdate, sanitizeAgentSummaryCommentBody } from "./heartbeat-run-summary-text";
|
|
69
|
+
import type {
|
|
70
|
+
ActiveHeartbeatRun,
|
|
71
|
+
HeartbeatIdlePolicy,
|
|
72
|
+
HeartbeatProviderType,
|
|
73
|
+
HeartbeatRunMode,
|
|
74
|
+
HeartbeatRunTrigger,
|
|
75
|
+
HeartbeatWakeContext,
|
|
76
|
+
RunDigest,
|
|
77
|
+
RunDigestSignal,
|
|
78
|
+
RunTerminalPresentation
|
|
79
|
+
} from "./types";
|
|
315
80
|
|
|
316
81
|
export async function runHeartbeatForAgent(
|
|
317
82
|
db: BopoDb,
|
|
@@ -908,7 +673,11 @@ export async function runHeartbeatForAgent(
|
|
|
908
673
|
const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
|
|
909
674
|
const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
|
|
910
675
|
const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
|
|
911
|
-
const contextWorkItems =
|
|
676
|
+
const contextWorkItems = await hydrateIssueWorkItemsWithGoalIds(
|
|
677
|
+
db,
|
|
678
|
+
companyId,
|
|
679
|
+
resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext)
|
|
680
|
+
);
|
|
912
681
|
executionWorkItemsForBudget = contextWorkItems.map((item) => ({ issueId: item.id, projectId: item.project_id }));
|
|
913
682
|
claimedIssueIds = workItems.map((item) => item.id);
|
|
914
683
|
issueIds = contextWorkItems.map((item) => item.id);
|
|
@@ -1300,7 +1069,8 @@ export async function runHeartbeatForAgent(
|
|
|
1300
1069
|
mission: context.company.mission ?? null,
|
|
1301
1070
|
goalContext: {
|
|
1302
1071
|
companyGoals: context.goalContext?.companyGoals ?? [],
|
|
1303
|
-
projectGoals: context.goalContext?.projectGoals ?? []
|
|
1072
|
+
projectGoals: context.goalContext?.projectGoals ?? [],
|
|
1073
|
+
agentGoals: context.goalContext?.agentGoals ?? []
|
|
1304
1074
|
}
|
|
1305
1075
|
});
|
|
1306
1076
|
await appendAuditEvent(db, {
|
|
@@ -1344,7 +1114,8 @@ export async function runHeartbeatForAgent(
|
|
|
1344
1114
|
summary: executionSummary,
|
|
1345
1115
|
mission: context.company.mission ?? null,
|
|
1346
1116
|
companyGoals: context.goalContext?.companyGoals ?? [],
|
|
1347
|
-
projectGoals: context.goalContext?.projectGoals ?? []
|
|
1117
|
+
projectGoals: context.goalContext?.projectGoals ?? [],
|
|
1118
|
+
agentGoals: context.goalContext?.agentGoals ?? []
|
|
1348
1119
|
});
|
|
1349
1120
|
await appendAuditEvent(db, {
|
|
1350
1121
|
companyId,
|
|
@@ -1994,7 +1765,7 @@ export async function runHeartbeatForAgent(
|
|
|
1994
1765
|
}
|
|
1995
1766
|
await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
|
|
1996
1767
|
try {
|
|
1997
|
-
const queueModule = await import("
|
|
1768
|
+
const queueModule = await import("../heartbeat-queue-service");
|
|
1998
1769
|
queueModule.triggerHeartbeatQueueWorker(db, companyId, {
|
|
1999
1770
|
requestId: options?.requestId,
|
|
2000
1771
|
realtimeHub: options?.realtimeHub
|
|
@@ -2020,13 +1791,18 @@ async function insertStartedRunAtomic(
|
|
|
2020
1791
|
db: BopoDb,
|
|
2021
1792
|
input: { id: string; companyId: string; agentId: string; message: string }
|
|
2022
1793
|
) {
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
1794
|
+
const inserted = await db
|
|
1795
|
+
.insert(heartbeatRuns)
|
|
1796
|
+
.values({
|
|
1797
|
+
id: input.id,
|
|
1798
|
+
companyId: input.companyId,
|
|
1799
|
+
agentId: input.agentId,
|
|
1800
|
+
status: "started",
|
|
1801
|
+
message: input.message
|
|
1802
|
+
})
|
|
1803
|
+
.onConflictDoNothing()
|
|
1804
|
+
.returning({ id: heartbeatRuns.id });
|
|
1805
|
+
return inserted.length > 0;
|
|
2030
1806
|
}
|
|
2031
1807
|
|
|
2032
1808
|
async function recoverStaleHeartbeatRuns(
|
|
@@ -2086,129 +1862,43 @@ async function recoverStaleHeartbeatRuns(
|
|
|
2086
1862
|
}
|
|
2087
1863
|
}
|
|
2088
1864
|
|
|
2089
|
-
|
|
1865
|
+
type IssueWorkItemRow = {
|
|
1866
|
+
id: string;
|
|
1867
|
+
project_id: string;
|
|
1868
|
+
parent_issue_id: string | null;
|
|
1869
|
+
title: string;
|
|
1870
|
+
body: string | null;
|
|
1871
|
+
status: string;
|
|
1872
|
+
priority: string;
|
|
1873
|
+
labels_json: string;
|
|
1874
|
+
tags_json: string;
|
|
1875
|
+
};
|
|
1876
|
+
|
|
1877
|
+
type IssueWorkItemRowWithGoals = IssueWorkItemRow & { goal_ids: string[] };
|
|
1878
|
+
|
|
1879
|
+
async function hydrateIssueWorkItemsWithGoalIds(
|
|
2090
1880
|
db: BopoDb,
|
|
2091
1881
|
companyId: string,
|
|
2092
|
-
|
|
2093
|
-
) {
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
const now = new Date();
|
|
2098
|
-
const enqueuedJobIds: string[] = [];
|
|
2099
|
-
const dueAgents: Array<{ id: string }> = [];
|
|
2100
|
-
let skippedNotDue = 0;
|
|
2101
|
-
let skippedStatus = 0;
|
|
2102
|
-
let skippedBudgetBlocked = 0;
|
|
2103
|
-
let failedStarts = 0;
|
|
2104
|
-
const sweepStartedAt = Date.now();
|
|
2105
|
-
for (const agent of companyAgents) {
|
|
2106
|
-
if (agent.status !== "idle" && agent.status !== "running") {
|
|
2107
|
-
skippedStatus += 1;
|
|
2108
|
-
continue;
|
|
2109
|
-
}
|
|
2110
|
-
if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
|
|
2111
|
-
skippedNotDue += 1;
|
|
2112
|
-
continue;
|
|
2113
|
-
}
|
|
2114
|
-
const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(db, companyId, agent.id);
|
|
2115
|
-
if (blockedProjectIds.length > 0) {
|
|
2116
|
-
skippedBudgetBlocked += 1;
|
|
2117
|
-
continue;
|
|
2118
|
-
}
|
|
2119
|
-
dueAgents.push({ id: agent.id });
|
|
1882
|
+
items: IssueWorkItemRow[]
|
|
1883
|
+
): Promise<IssueWorkItemRowWithGoals[]> {
|
|
1884
|
+
if (items.length === 0) {
|
|
1885
|
+
return [];
|
|
2120
1886
|
}
|
|
2121
|
-
const
|
|
2122
|
-
|
|
2123
|
-
await runWithConcurrency(dueAgents, sweepConcurrency, async (agent) => {
|
|
2124
|
-
try {
|
|
2125
|
-
const job = await queueModule.enqueueHeartbeatQueueJob(db, {
|
|
2126
|
-
companyId,
|
|
2127
|
-
agentId: agent.id,
|
|
2128
|
-
jobType: "scheduler",
|
|
2129
|
-
priority: 80,
|
|
2130
|
-
idempotencyKey: options?.requestId ? `scheduler:${agent.id}:${options.requestId}` : null,
|
|
2131
|
-
payload: {}
|
|
2132
|
-
});
|
|
2133
|
-
enqueuedJobIds.push(job.id);
|
|
2134
|
-
queueModule.triggerHeartbeatQueueWorker(db, companyId, {
|
|
2135
|
-
requestId: options?.requestId,
|
|
2136
|
-
realtimeHub: options?.realtimeHub
|
|
2137
|
-
});
|
|
2138
|
-
} catch {
|
|
2139
|
-
failedStarts += 1;
|
|
2140
|
-
}
|
|
2141
|
-
});
|
|
2142
|
-
await appendAuditEvent(db, {
|
|
1887
|
+
const map = await listIssueGoalIdsBatch(
|
|
1888
|
+
db,
|
|
2143
1889
|
companyId,
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
runIds: enqueuedJobIds,
|
|
2151
|
-
startedCount: enqueuedJobIds.length,
|
|
2152
|
-
dueCount: dueAgents.length,
|
|
2153
|
-
failedStarts,
|
|
2154
|
-
skippedStatus,
|
|
2155
|
-
skippedNotDue,
|
|
2156
|
-
skippedBudgetBlocked,
|
|
2157
|
-
concurrency: sweepConcurrency,
|
|
2158
|
-
elapsedMs: Date.now() - sweepStartedAt,
|
|
2159
|
-
requestId: options?.requestId ?? null
|
|
2160
|
-
}
|
|
2161
|
-
});
|
|
2162
|
-
return enqueuedJobIds;
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
async function listLatestRunByAgent(db: BopoDb, companyId: string) {
|
|
2166
|
-
const result = await db.execute(sql`
|
|
2167
|
-
SELECT agent_id, MAX(started_at) AS latest_started_at
|
|
2168
|
-
FROM heartbeat_runs
|
|
2169
|
-
WHERE company_id = ${companyId}
|
|
2170
|
-
GROUP BY agent_id
|
|
2171
|
-
`);
|
|
2172
|
-
const latestRunByAgent = new Map<string, Date>();
|
|
2173
|
-
for (const row of result as Array<Record<string, unknown>>) {
|
|
2174
|
-
const agentId = typeof row.agent_id === "string" ? row.agent_id : null;
|
|
2175
|
-
if (!agentId) {
|
|
2176
|
-
continue;
|
|
2177
|
-
}
|
|
2178
|
-
const startedAt = coerceDate(row.latest_started_at);
|
|
2179
|
-
if (!startedAt) {
|
|
2180
|
-
continue;
|
|
2181
|
-
}
|
|
2182
|
-
latestRunByAgent.set(agentId, startedAt);
|
|
2183
|
-
}
|
|
2184
|
-
return latestRunByAgent;
|
|
2185
|
-
}
|
|
2186
|
-
|
|
2187
|
-
function coerceDate(value: unknown) {
|
|
2188
|
-
if (value instanceof Date) {
|
|
2189
|
-
return Number.isNaN(value.getTime()) ? null : value;
|
|
2190
|
-
}
|
|
2191
|
-
if (typeof value === "string" || typeof value === "number") {
|
|
2192
|
-
const parsed = new Date(value);
|
|
2193
|
-
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
2194
|
-
}
|
|
2195
|
-
return null;
|
|
1890
|
+
items.map((item) => item.id)
|
|
1891
|
+
);
|
|
1892
|
+
return items.map((item) => ({
|
|
1893
|
+
...item,
|
|
1894
|
+
goal_ids: map.get(item.id) ?? []
|
|
1895
|
+
}));
|
|
2196
1896
|
}
|
|
2197
1897
|
|
|
2198
1898
|
async function loadWakeContextWorkItems(db: BopoDb, companyId: string, wakeIssueIds?: string[]) {
|
|
2199
1899
|
const normalizedIds = Array.from(new Set((wakeIssueIds ?? []).filter((id) => id.trim().length > 0)));
|
|
2200
1900
|
if (normalizedIds.length === 0) {
|
|
2201
|
-
return [] as
|
|
2202
|
-
id: string;
|
|
2203
|
-
project_id: string;
|
|
2204
|
-
parent_issue_id: string | null;
|
|
2205
|
-
title: string;
|
|
2206
|
-
body: string | null;
|
|
2207
|
-
status: string;
|
|
2208
|
-
priority: string;
|
|
2209
|
-
labels_json: string;
|
|
2210
|
-
tags_json: string;
|
|
2211
|
-
}>;
|
|
1901
|
+
return [] as IssueWorkItemRow[];
|
|
2212
1902
|
}
|
|
2213
1903
|
const rows = await db
|
|
2214
1904
|
.select({
|
|
@@ -2228,32 +1918,9 @@ async function loadWakeContextWorkItems(db: BopoDb, companyId: string, wakeIssue
|
|
|
2228
1918
|
return rows.sort((a, b) => (sortOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (sortOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
|
|
2229
1919
|
}
|
|
2230
1920
|
|
|
2231
|
-
function mergeContextWorkItems(
|
|
2232
|
-
assigned: Array<{
|
|
2233
|
-
id: string;
|
|
2234
|
-
project_id: string;
|
|
2235
|
-
parent_issue_id: string | null;
|
|
2236
|
-
title: string;
|
|
2237
|
-
body: string | null;
|
|
2238
|
-
status: string;
|
|
2239
|
-
priority: string;
|
|
2240
|
-
labels_json: string;
|
|
2241
|
-
tags_json: string;
|
|
2242
|
-
}>,
|
|
2243
|
-
wakeContext: Array<{
|
|
2244
|
-
id: string;
|
|
2245
|
-
project_id: string;
|
|
2246
|
-
parent_issue_id: string | null;
|
|
2247
|
-
title: string;
|
|
2248
|
-
body: string | null;
|
|
2249
|
-
status: string;
|
|
2250
|
-
priority: string;
|
|
2251
|
-
labels_json: string;
|
|
2252
|
-
tags_json: string;
|
|
2253
|
-
}>
|
|
2254
|
-
) {
|
|
1921
|
+
function mergeContextWorkItems(assigned: IssueWorkItemRow[], wakeContext: IssueWorkItemRow[]) {
|
|
2255
1922
|
const seen = new Set<string>();
|
|
2256
|
-
const merged:
|
|
1923
|
+
const merged: IssueWorkItemRow[] = [];
|
|
2257
1924
|
for (const item of assigned) {
|
|
2258
1925
|
if (!seen.has(item.id)) {
|
|
2259
1926
|
seen.add(item.id);
|
|
@@ -2270,28 +1937,8 @@ function mergeContextWorkItems(
|
|
|
2270
1937
|
}
|
|
2271
1938
|
|
|
2272
1939
|
function resolveExecutionWorkItems(
|
|
2273
|
-
assigned:
|
|
2274
|
-
|
|
2275
|
-
project_id: string;
|
|
2276
|
-
parent_issue_id: string | null;
|
|
2277
|
-
title: string;
|
|
2278
|
-
body: string | null;
|
|
2279
|
-
status: string;
|
|
2280
|
-
priority: string;
|
|
2281
|
-
labels_json: string;
|
|
2282
|
-
tags_json: string;
|
|
2283
|
-
}>,
|
|
2284
|
-
wakeContextItems: Array<{
|
|
2285
|
-
id: string;
|
|
2286
|
-
project_id: string;
|
|
2287
|
-
parent_issue_id: string | null;
|
|
2288
|
-
title: string;
|
|
2289
|
-
body: string | null;
|
|
2290
|
-
status: string;
|
|
2291
|
-
priority: string;
|
|
2292
|
-
labels_json: string;
|
|
2293
|
-
tags_json: string;
|
|
2294
|
-
}>,
|
|
1940
|
+
assigned: IssueWorkItemRow[],
|
|
1941
|
+
wakeContextItems: IssueWorkItemRow[],
|
|
2295
1942
|
wakeContext?: HeartbeatWakeContext
|
|
2296
1943
|
) {
|
|
2297
1944
|
if (wakeContext?.reason === "issue_comment_recipient" && wakeContextItems.length > 0) {
|
|
@@ -2329,6 +1976,62 @@ async function loadWakeContextCommentBody(db: BopoDb, companyId: string, comment
|
|
|
2329
1976
|
return body && body.length > 0 ? body : null;
|
|
2330
1977
|
}
|
|
2331
1978
|
|
|
1979
|
+
const GOAL_CONTEXT_DESC_MAX_CHARS = 280;
|
|
1980
|
+
const GOAL_ANCESTRY_NODE_DESC_MAX_CHARS = 120;
|
|
1981
|
+
const GOAL_ANCESTRY_MAX_DEPTH = 16;
|
|
1982
|
+
|
|
1983
|
+
type GoalRowForHeartbeat = {
|
|
1984
|
+
id: string;
|
|
1985
|
+
title: string;
|
|
1986
|
+
description: string | null;
|
|
1987
|
+
parentGoalId: string | null;
|
|
1988
|
+
level: string;
|
|
1989
|
+
projectId: string | null;
|
|
1990
|
+
status: string;
|
|
1991
|
+
ownerAgentId: string | null;
|
|
1992
|
+
};
|
|
1993
|
+
|
|
1994
|
+
function clipGoalDescription(text: string, max: number) {
|
|
1995
|
+
const t = text.trim();
|
|
1996
|
+
if (t.length <= max) {
|
|
1997
|
+
return t;
|
|
1998
|
+
}
|
|
1999
|
+
return `${t.slice(0, Math.max(0, max - 1))}…`;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function formatGoalContextLine(goal: Pick<GoalRowForHeartbeat, "title" | "description">, descMax: number) {
|
|
2003
|
+
const title = goal.title.trim();
|
|
2004
|
+
const desc = goal.description?.trim();
|
|
2005
|
+
if (!desc) {
|
|
2006
|
+
return title;
|
|
2007
|
+
}
|
|
2008
|
+
return `${title} — ${clipGoalDescription(desc, descMax)}`;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function buildGoalAncestryLines(
|
|
2012
|
+
goalId: string | null | undefined,
|
|
2013
|
+
byId: Map<string, GoalRowForHeartbeat>,
|
|
2014
|
+
nodeDescMax: number
|
|
2015
|
+
) {
|
|
2016
|
+
if (!goalId) {
|
|
2017
|
+
return [] as string[];
|
|
2018
|
+
}
|
|
2019
|
+
const chain: string[] = [];
|
|
2020
|
+
let current: GoalRowForHeartbeat | undefined = byId.get(goalId);
|
|
2021
|
+
const visited = new Set<string>();
|
|
2022
|
+
let depth = 0;
|
|
2023
|
+
while (current && depth < GOAL_ANCESTRY_MAX_DEPTH) {
|
|
2024
|
+
if (visited.has(current.id)) {
|
|
2025
|
+
break;
|
|
2026
|
+
}
|
|
2027
|
+
visited.add(current.id);
|
|
2028
|
+
chain.unshift(formatGoalContextLine(current, nodeDescMax));
|
|
2029
|
+
current = current.parentGoalId ? byId.get(current.parentGoalId) : undefined;
|
|
2030
|
+
depth += 1;
|
|
2031
|
+
}
|
|
2032
|
+
return chain;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2332
2035
|
async function buildHeartbeatContext(
|
|
2333
2036
|
db: BopoDb,
|
|
2334
2037
|
companyId: string,
|
|
@@ -2343,17 +2046,7 @@ async function buildHeartbeatContext(
|
|
|
2343
2046
|
memoryContext?: HeartbeatContext["memoryContext"];
|
|
2344
2047
|
runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
|
|
2345
2048
|
wakeContext?: HeartbeatWakeContext;
|
|
2346
|
-
workItems:
|
|
2347
|
-
id: string;
|
|
2348
|
-
project_id: string;
|
|
2349
|
-
parent_issue_id: string | null;
|
|
2350
|
-
title: string;
|
|
2351
|
-
body: string | null;
|
|
2352
|
-
status: string;
|
|
2353
|
-
priority: string;
|
|
2354
|
-
labels_json: string;
|
|
2355
|
-
tags_json: string;
|
|
2356
|
-
}>;
|
|
2049
|
+
workItems: IssueWorkItemRowWithGoals[];
|
|
2357
2050
|
}
|
|
2358
2051
|
): Promise<HeartbeatContext> {
|
|
2359
2052
|
const [company] = await db
|
|
@@ -2444,24 +2137,48 @@ async function buildHeartbeatContext(
|
|
|
2444
2137
|
id: goals.id,
|
|
2445
2138
|
level: goals.level,
|
|
2446
2139
|
title: goals.title,
|
|
2140
|
+
description: goals.description,
|
|
2447
2141
|
status: goals.status,
|
|
2448
|
-
projectId: goals.projectId
|
|
2142
|
+
projectId: goals.projectId,
|
|
2143
|
+
parentGoalId: goals.parentGoalId,
|
|
2144
|
+
ownerAgentId: goals.ownerAgentId
|
|
2449
2145
|
})
|
|
2450
2146
|
.from(goals)
|
|
2451
2147
|
.where(eq(goals.companyId, companyId));
|
|
2452
2148
|
|
|
2149
|
+
const goalById = new Map<string, GoalRowForHeartbeat>(
|
|
2150
|
+
goalRows.map((row) => [
|
|
2151
|
+
row.id,
|
|
2152
|
+
{
|
|
2153
|
+
id: row.id,
|
|
2154
|
+
title: row.title,
|
|
2155
|
+
description: row.description,
|
|
2156
|
+
parentGoalId: row.parentGoalId,
|
|
2157
|
+
level: row.level,
|
|
2158
|
+
projectId: row.projectId,
|
|
2159
|
+
status: row.status,
|
|
2160
|
+
ownerAgentId: row.ownerAgentId
|
|
2161
|
+
}
|
|
2162
|
+
])
|
|
2163
|
+
);
|
|
2164
|
+
|
|
2453
2165
|
const activeCompanyGoals = goalRows
|
|
2454
2166
|
.filter((goal) => goal.status === "active" && goal.level === "company")
|
|
2455
|
-
.map((goal) => goal
|
|
2167
|
+
.map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
|
|
2456
2168
|
const activeProjectGoals = goalRows
|
|
2457
2169
|
.filter(
|
|
2458
2170
|
(goal) =>
|
|
2459
2171
|
goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId)
|
|
2460
2172
|
)
|
|
2461
|
-
.map((goal) => goal
|
|
2173
|
+
.map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
|
|
2462
2174
|
const activeAgentGoals = goalRows
|
|
2463
|
-
.filter(
|
|
2464
|
-
|
|
2175
|
+
.filter(
|
|
2176
|
+
(goal) =>
|
|
2177
|
+
goal.status === "active" &&
|
|
2178
|
+
goal.level === "agent" &&
|
|
2179
|
+
(!goal.ownerAgentId || goal.ownerAgentId === input.agentId)
|
|
2180
|
+
)
|
|
2181
|
+
.map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
|
|
2465
2182
|
const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
|
|
2466
2183
|
const promptMode = resolveHeartbeatPromptMode();
|
|
2467
2184
|
|
|
@@ -2500,6 +2217,11 @@ async function buildHeartbeatContext(
|
|
|
2500
2217
|
issueId: item.id,
|
|
2501
2218
|
projectId: item.project_id,
|
|
2502
2219
|
parentIssueId: item.parent_issue_id,
|
|
2220
|
+
goalIds: item.goal_ids,
|
|
2221
|
+
goalAncestryChains: item.goal_ids.map((gid) => {
|
|
2222
|
+
const chain = buildGoalAncestryLines(gid, goalById, GOAL_ANCESTRY_NODE_DESC_MAX_CHARS);
|
|
2223
|
+
return chain.length > 0 ? chain : [gid];
|
|
2224
|
+
}),
|
|
2503
2225
|
childIssueIds: childIssueIdsByParent.get(item.id) ?? [],
|
|
2504
2226
|
projectName: projectNameById.get(item.project_id) ?? null,
|
|
2505
2227
|
title: item.title,
|
|
@@ -2531,10 +2253,13 @@ function computeMissionAlignmentSignal(input: {
|
|
|
2531
2253
|
mission: string | null;
|
|
2532
2254
|
companyGoals: string[];
|
|
2533
2255
|
projectGoals: string[];
|
|
2256
|
+
agentGoals: string[];
|
|
2534
2257
|
}) {
|
|
2535
2258
|
const summaryTokens = new Set(tokenizeAlignmentText(input.summary));
|
|
2536
2259
|
const missionTokens = tokenizeAlignmentText(input.mission ?? "");
|
|
2537
|
-
const goalTokens = tokenizeAlignmentText(
|
|
2260
|
+
const goalTokens = tokenizeAlignmentText(
|
|
2261
|
+
[...input.companyGoals, ...input.projectGoals, ...input.agentGoals].join(" ")
|
|
2262
|
+
);
|
|
2538
2263
|
const matchedMissionTerms = missionTokens.filter((token) => summaryTokens.has(token));
|
|
2539
2264
|
const matchedGoalTerms = goalTokens.filter((token) => summaryTokens.has(token));
|
|
2540
2265
|
const missionScore = missionTokens.length > 0 ? matchedMissionTerms.length / missionTokens.length : 0;
|
|
@@ -2765,34 +2490,6 @@ async function ensureProjectBudgetOverrideApprovalRequest(
|
|
|
2765
2490
|
return approvalId;
|
|
2766
2491
|
}
|
|
2767
2492
|
|
|
2768
|
-
function sanitizeAgentSummaryCommentBody(body: string) {
|
|
2769
|
-
const sanitized = body.replace(AGENT_COMMENT_EMOJI_REGEX, "").trim();
|
|
2770
|
-
return sanitized.length > 0 ? sanitized : "Run update.";
|
|
2771
|
-
}
|
|
2772
|
-
|
|
2773
|
-
function extractNaturalRunUpdate(executionSummary: string) {
|
|
2774
|
-
const normalized = executionSummary.trim();
|
|
2775
|
-
const jsonSummary = extractSummaryFromJsonLikeText(normalized);
|
|
2776
|
-
const source = jsonSummary ?? normalized;
|
|
2777
|
-
const lines = source
|
|
2778
|
-
.split("\n")
|
|
2779
|
-
.map((line) => line.trim())
|
|
2780
|
-
.filter((line) => line.length > 0)
|
|
2781
|
-
.filter((line) => !line.startsWith("{") && !line.startsWith("}"));
|
|
2782
|
-
const compact = (lines.length > 0 ? lines.slice(0, 2).join(" ") : source)
|
|
2783
|
-
.replace(/^run (failure )?summary\s*:\s*/i, "")
|
|
2784
|
-
.replace(/^completed all assigned issue steps\s*:\s*/i, "")
|
|
2785
|
-
.replace(/^issue status\s*:\s*/i, "")
|
|
2786
|
-
.replace(/`+/g, "")
|
|
2787
|
-
.replace(/\s+/g, " ")
|
|
2788
|
-
.trim();
|
|
2789
|
-
const bounded = compact.length > 260 ? `${compact.slice(0, 257).trimEnd()}...` : compact;
|
|
2790
|
-
if (!bounded) {
|
|
2791
|
-
return "Run update.";
|
|
2792
|
-
}
|
|
2793
|
-
return /[.!?]$/.test(bounded) ? bounded : `${bounded}.`;
|
|
2794
|
-
}
|
|
2795
|
-
|
|
2796
2493
|
function buildRunDigest(input: {
|
|
2797
2494
|
status: "completed" | "failed" | "skipped";
|
|
2798
2495
|
executionSummary: string;
|
|
@@ -3188,6 +2885,11 @@ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, c
|
|
|
3188
2885
|
const [, agentId, suffix = ""] = directMatch;
|
|
3189
2886
|
return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
|
|
3190
2887
|
}
|
|
2888
|
+
const projectAgentsMatch = normalized.match(/^projects\/agents\/([^/]+)\/operating(\/.*)?$/);
|
|
2889
|
+
if (projectAgentsMatch) {
|
|
2890
|
+
const [, agentId, suffix = ""] = projectAgentsMatch;
|
|
2891
|
+
return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
|
|
2892
|
+
}
|
|
3191
2893
|
const issueScopedMatch = normalized.match(
|
|
3192
2894
|
/^(?:[^/]+\/)?projects\/[^/]+\/issues\/[^/]+\/agents\/([^/]+)\/operating(\/.*)?$/
|
|
3193
2895
|
);
|
|
@@ -3484,30 +3186,6 @@ function isMachineNoiseLine(text: string) {
|
|
|
3484
3186
|
return patterns.some((pattern) => pattern.test(normalized));
|
|
3485
3187
|
}
|
|
3486
3188
|
|
|
3487
|
-
function extractSummaryFromJsonLikeText(input: string) {
|
|
3488
|
-
const fencedMatch = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
3489
|
-
const candidate = fencedMatch?.[1]?.trim() ?? input.match(/\{[\s\S]*\}\s*$/)?.[0]?.trim();
|
|
3490
|
-
if (!candidate) {
|
|
3491
|
-
return null;
|
|
3492
|
-
}
|
|
3493
|
-
try {
|
|
3494
|
-
const parsed = JSON.parse(candidate) as Record<string, unknown>;
|
|
3495
|
-
const summary = parsed.summary;
|
|
3496
|
-
if (typeof summary === "string" && summary.trim().length > 0) {
|
|
3497
|
-
return summary.trim();
|
|
3498
|
-
}
|
|
3499
|
-
} catch {
|
|
3500
|
-
// Fall through to regex extraction for loosely-formatted JSON.
|
|
3501
|
-
}
|
|
3502
|
-
const summaryMatch = candidate.match(/"summary"\s*:\s*"([\s\S]*?)"/);
|
|
3503
|
-
const summary = summaryMatch?.[1]
|
|
3504
|
-
?.replace(/\\"/g, "\"")
|
|
3505
|
-
.replace(/\\n/g, " ")
|
|
3506
|
-
.replace(/\s+/g, " ")
|
|
3507
|
-
.trim();
|
|
3508
|
-
return summary && summary.length > 0 ? summary : null;
|
|
3509
|
-
}
|
|
3510
|
-
|
|
3511
3189
|
async function appendRunSummaryComments(
|
|
3512
3190
|
db: BopoDb,
|
|
3513
3191
|
input: {
|
|
@@ -3881,32 +3559,6 @@ function isUsefulTranscriptSignal(level: "high" | "medium" | "low" | "noise") {
|
|
|
3881
3559
|
return level === "high" || level === "medium";
|
|
3882
3560
|
}
|
|
3883
3561
|
|
|
3884
|
-
function publishHeartbeatRunStatus(
|
|
3885
|
-
realtimeHub: RealtimeHub | undefined,
|
|
3886
|
-
input: {
|
|
3887
|
-
companyId: string;
|
|
3888
|
-
runId: string;
|
|
3889
|
-
status: "started" | "completed" | "failed" | "skipped";
|
|
3890
|
-
message?: string | null;
|
|
3891
|
-
startedAt?: Date;
|
|
3892
|
-
finishedAt?: Date;
|
|
3893
|
-
}
|
|
3894
|
-
) {
|
|
3895
|
-
if (!realtimeHub) {
|
|
3896
|
-
return;
|
|
3897
|
-
}
|
|
3898
|
-
realtimeHub.publish(
|
|
3899
|
-
createHeartbeatRunsRealtimeEvent(input.companyId, {
|
|
3900
|
-
type: "run.status.updated",
|
|
3901
|
-
runId: input.runId,
|
|
3902
|
-
status: input.status,
|
|
3903
|
-
message: input.message ?? null,
|
|
3904
|
-
startedAt: input.startedAt?.toISOString(),
|
|
3905
|
-
finishedAt: input.finishedAt?.toISOString() ?? null
|
|
3906
|
-
})
|
|
3907
|
-
);
|
|
3908
|
-
}
|
|
3909
|
-
|
|
3910
3562
|
async function resolveRuntimeWorkspaceForWorkItems(
|
|
3911
3563
|
db: BopoDb,
|
|
3912
3564
|
companyId: string,
|
|
@@ -4071,39 +3723,6 @@ function resolveStaleRunThresholdMs() {
|
|
|
4071
3723
|
return parsed;
|
|
4072
3724
|
}
|
|
4073
3725
|
|
|
4074
|
-
function resolveHeartbeatSweepConcurrency(dueAgentsCount: number) {
|
|
4075
|
-
const configured = Number(process.env.BOPO_HEARTBEAT_SWEEP_CONCURRENCY ?? "4");
|
|
4076
|
-
const fallback = 4;
|
|
4077
|
-
const normalized = Number.isFinite(configured) ? Math.floor(configured) : fallback;
|
|
4078
|
-
if (normalized < 1) {
|
|
4079
|
-
return 1;
|
|
4080
|
-
}
|
|
4081
|
-
// Prevent scheduler bursts from starving the API event loop.
|
|
4082
|
-
const bounded = Math.min(normalized, 16);
|
|
4083
|
-
return Math.min(bounded, Math.max(1, dueAgentsCount));
|
|
4084
|
-
}
|
|
4085
|
-
|
|
4086
|
-
async function runWithConcurrency<T>(
|
|
4087
|
-
items: T[],
|
|
4088
|
-
concurrency: number,
|
|
4089
|
-
worker: (item: T, index: number) => Promise<void>
|
|
4090
|
-
) {
|
|
4091
|
-
if (items.length === 0) {
|
|
4092
|
-
return;
|
|
4093
|
-
}
|
|
4094
|
-
const workerCount = Math.max(1, Math.min(Math.floor(concurrency), items.length));
|
|
4095
|
-
let cursor = 0;
|
|
4096
|
-
await Promise.all(
|
|
4097
|
-
Array.from({ length: workerCount }, async () => {
|
|
4098
|
-
while (cursor < items.length) {
|
|
4099
|
-
const index = cursor;
|
|
4100
|
-
cursor += 1;
|
|
4101
|
-
await worker(items[index] as T, index);
|
|
4102
|
-
}
|
|
4103
|
-
})
|
|
4104
|
-
);
|
|
4105
|
-
}
|
|
4106
|
-
|
|
4107
3726
|
function resolveEffectiveStaleRunThresholdMs(input: {
|
|
4108
3727
|
baseThresholdMs: number;
|
|
4109
3728
|
runtimeTimeoutSec: number;
|
|
@@ -4323,14 +3942,6 @@ function mergeRuntimeForExecution(
|
|
|
4323
3942
|
};
|
|
4324
3943
|
}
|
|
4325
3944
|
|
|
4326
|
-
function registerActiveHeartbeatRun(runId: string, run: ActiveHeartbeatRun) {
|
|
4327
|
-
activeHeartbeatRuns.set(runId, run);
|
|
4328
|
-
}
|
|
4329
|
-
|
|
4330
|
-
function unregisterActiveHeartbeatRun(runId: string) {
|
|
4331
|
-
activeHeartbeatRuns.delete(runId);
|
|
4332
|
-
}
|
|
4333
|
-
|
|
4334
3945
|
function clearResumeState(
|
|
4335
3946
|
state: AgentState & {
|
|
4336
3947
|
runtime?: {
|
|
@@ -4382,8 +3993,6 @@ function resolveHeartbeatPromptMode(): "full" | "compact" {
|
|
|
4382
3993
|
return raw === "compact" ? "compact" : "full";
|
|
4383
3994
|
}
|
|
4384
3995
|
|
|
4385
|
-
type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
|
|
4386
|
-
|
|
4387
3996
|
function resolveHeartbeatIdlePolicy(): HeartbeatIdlePolicy {
|
|
4388
3997
|
const raw = process.env.BOPO_HEARTBEAT_IDLE_POLICY?.trim().toLowerCase();
|
|
4389
3998
|
if (raw === "skip_adapter") {
|
|
@@ -4770,62 +4379,3 @@ async function appendFinishedRunCostEntry(input: {
|
|
|
4770
4379
|
usdCostStatus
|
|
4771
4380
|
};
|
|
4772
4381
|
}
|
|
4773
|
-
|
|
4774
|
-
function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
|
|
4775
|
-
const normalizedNow = truncateToMinute(now);
|
|
4776
|
-
if (!matchesCronExpression(cronExpression, normalizedNow)) {
|
|
4777
|
-
return false;
|
|
4778
|
-
}
|
|
4779
|
-
if (!lastRunAt) {
|
|
4780
|
-
return true;
|
|
4781
|
-
}
|
|
4782
|
-
return truncateToMinute(lastRunAt).getTime() !== normalizedNow.getTime();
|
|
4783
|
-
}
|
|
4784
|
-
|
|
4785
|
-
function truncateToMinute(date: Date) {
|
|
4786
|
-
const clone = new Date(date);
|
|
4787
|
-
clone.setSeconds(0, 0);
|
|
4788
|
-
return clone;
|
|
4789
|
-
}
|
|
4790
|
-
|
|
4791
|
-
function matchesCronExpression(expression: string, date: Date) {
|
|
4792
|
-
const parts = expression.trim().split(/\s+/);
|
|
4793
|
-
if (parts.length !== 5) {
|
|
4794
|
-
return false;
|
|
4795
|
-
}
|
|
4796
|
-
|
|
4797
|
-
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
|
|
4798
|
-
return (
|
|
4799
|
-
matchesCronField(minute, date.getMinutes(), 0, 59) &&
|
|
4800
|
-
matchesCronField(hour, date.getHours(), 0, 23) &&
|
|
4801
|
-
matchesCronField(dayOfMonth, date.getDate(), 1, 31) &&
|
|
4802
|
-
matchesCronField(month, date.getMonth() + 1, 1, 12) &&
|
|
4803
|
-
matchesCronField(dayOfWeek, date.getDay(), 0, 6)
|
|
4804
|
-
);
|
|
4805
|
-
}
|
|
4806
|
-
|
|
4807
|
-
function matchesCronField(field: string, value: number, min: number, max: number) {
|
|
4808
|
-
return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
|
|
4809
|
-
}
|
|
4810
|
-
|
|
4811
|
-
function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
|
|
4812
|
-
if (part === "*") {
|
|
4813
|
-
return true;
|
|
4814
|
-
}
|
|
4815
|
-
|
|
4816
|
-
const stepMatch = part.match(/^\*\/(\d+)$/);
|
|
4817
|
-
if (stepMatch) {
|
|
4818
|
-
const step = Number(stepMatch[1]);
|
|
4819
|
-
return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
|
|
4820
|
-
}
|
|
4821
|
-
|
|
4822
|
-
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
|
4823
|
-
if (rangeMatch) {
|
|
4824
|
-
const start = Number(rangeMatch[1]);
|
|
4825
|
-
const end = Number(rangeMatch[2]);
|
|
4826
|
-
return start <= value && value <= end;
|
|
4827
|
-
}
|
|
4828
|
-
|
|
4829
|
-
const exact = Number(part);
|
|
4830
|
-
return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
|
|
4831
|
-
}
|