bopodev-api 0.1.27 → 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 -70
- package/src/lib/drainable-work.ts +36 -0
- package/src/lib/run-artifact-paths.ts +8 -0
- package/src/lib/workspace-policy.ts +1 -2
- 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/realtime/office-space.ts +3 -1
- 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 +8 -27
- package/src/routes/issues.ts +66 -121
- 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 +35 -276
- package/src/services/attention-service.ts +4 -1
- package/src/services/budget-service.ts +1 -2
- package/src/services/comment-recipient-dispatch-service.ts +39 -2
- package/src/services/company-export-service.ts +63 -0
- package/src/services/governance-service.ts +6 -2
- package/src/services/heartbeat-queue-service.ts +34 -3
- 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} +217 -635
- 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
- package/src/worker/scheduler.ts +20 -4
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { mkdir, stat } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
-
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
4
3
|
import { nanoid } from "nanoid";
|
|
5
4
|
import { resolveAdapter } from "bopodev-agent-sdk";
|
|
6
5
|
import type { AdapterExecutionResult, AgentState, HeartbeatContext } from "bopodev-agent-sdk";
|
|
@@ -19,294 +18,65 @@ import {
|
|
|
19
18
|
import type { BopoDb } from "bopodev-db";
|
|
20
19
|
import {
|
|
21
20
|
addIssueComment,
|
|
21
|
+
and,
|
|
22
22
|
approvalRequests,
|
|
23
23
|
agents,
|
|
24
24
|
appendActivity,
|
|
25
25
|
appendHeartbeatRunMessages,
|
|
26
|
+
auditEvents,
|
|
26
27
|
companies,
|
|
27
28
|
createApprovalRequest,
|
|
29
|
+
desc,
|
|
30
|
+
eq,
|
|
28
31
|
goals,
|
|
29
32
|
heartbeatRuns,
|
|
33
|
+
inArray,
|
|
30
34
|
issueComments,
|
|
31
35
|
issueAttachments,
|
|
32
36
|
issues,
|
|
33
|
-
|
|
37
|
+
listIssueGoalIdsBatch,
|
|
38
|
+
projects,
|
|
39
|
+
sql
|
|
34
40
|
} from "bopodev-db";
|
|
35
41
|
import { appendAuditEvent, appendCost } from "bopodev-db";
|
|
36
|
-
import { parseRuntimeConfigFromAgentRow } from "
|
|
37
|
-
import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "
|
|
42
|
+
import { parseRuntimeConfigFromAgentRow } from "../../lib/agent-config";
|
|
43
|
+
import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../../lib/git-runtime";
|
|
38
44
|
import {
|
|
39
45
|
isInsidePath,
|
|
40
46
|
normalizeCompanyWorkspacePath,
|
|
41
47
|
resolveCompanyWorkspaceRootPath,
|
|
42
48
|
resolveProjectWorkspacePath
|
|
43
|
-
} from "
|
|
44
|
-
import { resolveRunArtifactAbsolutePath } from "
|
|
49
|
+
} from "../../lib/instance-paths";
|
|
50
|
+
import { resolveRunArtifactAbsolutePath } from "../../lib/run-artifact-paths";
|
|
45
51
|
import {
|
|
46
52
|
assertRuntimeCwdForCompany,
|
|
47
53
|
getProjectWorkspaceContextMap,
|
|
48
54
|
hasText,
|
|
49
55
|
resolveAgentFallbackWorkspace
|
|
50
|
-
} from "
|
|
51
|
-
import type { RealtimeHub } from "
|
|
52
|
-
import { createHeartbeatRunsRealtimeEvent } from "
|
|
53
|
-
import { publishAttentionSnapshot } from "
|
|
54
|
-
import { publishOfficeOccupantForAgent } from "
|
|
55
|
-
import { appendProjectBudgetUsage, checkAgentBudget, checkProjectBudget } from "
|
|
56
|
-
import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "
|
|
57
|
-
import { calculateModelPricedUsdCost } from "
|
|
58
|
-
import { runPluginHook } from "
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
companyId: string;
|
|
75
|
-
agentId: string;
|
|
76
|
-
abortController: AbortController;
|
|
77
|
-
cancelReason?: string | null;
|
|
78
|
-
cancelRequestedAt?: string | null;
|
|
79
|
-
cancelRequestedBy?: string | null;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
const activeHeartbeatRuns = new Map<string, ActiveHeartbeatRun>();
|
|
83
|
-
type HeartbeatWakeContext = {
|
|
84
|
-
reason?: string | null;
|
|
85
|
-
commentId?: string | null;
|
|
86
|
-
commentBody?: string | null;
|
|
87
|
-
issueIds?: string[];
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const AGENT_COMMENT_EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
|
|
91
|
-
|
|
92
|
-
type RunDigestSignal = {
|
|
93
|
-
sequence: number;
|
|
94
|
-
kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
|
|
95
|
-
label: string | null;
|
|
96
|
-
text: string | null;
|
|
97
|
-
payload: string | null;
|
|
98
|
-
signalLevel: "high" | "medium" | "low" | "noise";
|
|
99
|
-
groupKey: string | null;
|
|
100
|
-
source: "stdout" | "stderr" | "trace_fallback";
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
type RunDigest = {
|
|
104
|
-
status: "completed" | "failed" | "skipped";
|
|
105
|
-
headline: string;
|
|
106
|
-
summary: string;
|
|
107
|
-
successes: string[];
|
|
108
|
-
failures: string[];
|
|
109
|
-
blockers: string[];
|
|
110
|
-
nextAction: string;
|
|
111
|
-
evidence: {
|
|
112
|
-
transcriptSignalCount: number;
|
|
113
|
-
outcomeActionCount: number;
|
|
114
|
-
outcomeBlockerCount: number;
|
|
115
|
-
failureType: string | null;
|
|
116
|
-
};
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
type RunTerminalPresentation = {
|
|
120
|
-
internalStatus: "completed" | "failed" | "skipped";
|
|
121
|
-
publicStatus: "completed" | "failed";
|
|
122
|
-
completionReason: RunCompletionReason;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
export async function claimIssuesForAgent(
|
|
126
|
-
db: BopoDb,
|
|
127
|
-
companyId: string,
|
|
128
|
-
agentId: string,
|
|
129
|
-
heartbeatRunId: string,
|
|
130
|
-
maxItems = 5
|
|
131
|
-
) {
|
|
132
|
-
const result = await db.execute(sql`
|
|
133
|
-
WITH candidate AS (
|
|
134
|
-
SELECT id
|
|
135
|
-
FROM issues
|
|
136
|
-
WHERE company_id = ${companyId}
|
|
137
|
-
AND assignee_agent_id = ${agentId}
|
|
138
|
-
AND status IN ('todo', 'in_progress')
|
|
139
|
-
AND is_claimed = false
|
|
140
|
-
ORDER BY
|
|
141
|
-
CASE priority
|
|
142
|
-
WHEN 'urgent' THEN 0
|
|
143
|
-
WHEN 'high' THEN 1
|
|
144
|
-
WHEN 'medium' THEN 2
|
|
145
|
-
WHEN 'low' THEN 3
|
|
146
|
-
ELSE 4
|
|
147
|
-
END ASC,
|
|
148
|
-
updated_at ASC
|
|
149
|
-
LIMIT ${maxItems}
|
|
150
|
-
FOR UPDATE SKIP LOCKED
|
|
151
|
-
)
|
|
152
|
-
UPDATE issues i
|
|
153
|
-
SET is_claimed = true,
|
|
154
|
-
claimed_by_heartbeat_run_id = ${heartbeatRunId},
|
|
155
|
-
updated_at = CURRENT_TIMESTAMP
|
|
156
|
-
FROM candidate c
|
|
157
|
-
WHERE i.id = c.id
|
|
158
|
-
RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
|
|
159
|
-
`);
|
|
160
|
-
|
|
161
|
-
return (result.rows ?? []) as Array<{
|
|
162
|
-
id: string;
|
|
163
|
-
project_id: string;
|
|
164
|
-
parent_issue_id: string | null;
|
|
165
|
-
title: string;
|
|
166
|
-
body: string | null;
|
|
167
|
-
status: string;
|
|
168
|
-
priority: string;
|
|
169
|
-
labels_json: string;
|
|
170
|
-
tags_json: string;
|
|
171
|
-
}>;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
|
|
175
|
-
if (issueIds.length === 0) {
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
await db
|
|
179
|
-
.update(issues)
|
|
180
|
-
.set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
|
|
181
|
-
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export async function stopHeartbeatRun(
|
|
185
|
-
db: BopoDb,
|
|
186
|
-
companyId: string,
|
|
187
|
-
runId: string,
|
|
188
|
-
options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger; realtimeHub?: RealtimeHub }
|
|
189
|
-
) {
|
|
190
|
-
const runTrigger = options?.trigger ?? "manual";
|
|
191
|
-
const [run] = await db
|
|
192
|
-
.select({
|
|
193
|
-
id: heartbeatRuns.id,
|
|
194
|
-
status: heartbeatRuns.status,
|
|
195
|
-
agentId: heartbeatRuns.agentId
|
|
196
|
-
})
|
|
197
|
-
.from(heartbeatRuns)
|
|
198
|
-
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
|
|
199
|
-
.limit(1);
|
|
200
|
-
if (!run) {
|
|
201
|
-
return { ok: false as const, reason: "not_found" as const };
|
|
202
|
-
}
|
|
203
|
-
if (run.status !== "started") {
|
|
204
|
-
return { ok: false as const, reason: "invalid_status" as const, status: run.status };
|
|
205
|
-
}
|
|
206
|
-
const active = activeHeartbeatRuns.get(runId);
|
|
207
|
-
const cancelReason = "cancelled by stop request";
|
|
208
|
-
const cancelRequestedAt = new Date().toISOString();
|
|
209
|
-
if (active) {
|
|
210
|
-
active.cancelReason = cancelReason;
|
|
211
|
-
active.cancelRequestedAt = cancelRequestedAt;
|
|
212
|
-
active.cancelRequestedBy = options?.actorId ?? null;
|
|
213
|
-
active.abortController.abort(cancelReason);
|
|
214
|
-
} else {
|
|
215
|
-
const finishedAt = new Date();
|
|
216
|
-
await db
|
|
217
|
-
.update(heartbeatRuns)
|
|
218
|
-
.set({
|
|
219
|
-
status: "failed",
|
|
220
|
-
finishedAt,
|
|
221
|
-
message: "Heartbeat cancelled by stop request."
|
|
222
|
-
})
|
|
223
|
-
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
224
|
-
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
225
|
-
companyId,
|
|
226
|
-
runId,
|
|
227
|
-
status: "failed",
|
|
228
|
-
message: "Heartbeat cancelled by stop request.",
|
|
229
|
-
finishedAt
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
await appendAuditEvent(db, {
|
|
233
|
-
companyId,
|
|
234
|
-
actorType: "system",
|
|
235
|
-
eventType: "heartbeat.cancel_requested",
|
|
236
|
-
entityType: "heartbeat_run",
|
|
237
|
-
entityId: runId,
|
|
238
|
-
correlationId: options?.requestId ?? runId,
|
|
239
|
-
payload: {
|
|
240
|
-
agentId: run.agentId,
|
|
241
|
-
trigger: runTrigger,
|
|
242
|
-
requestId: options?.requestId ?? null,
|
|
243
|
-
actorId: options?.actorId ?? null,
|
|
244
|
-
inMemoryAbortRegistered: Boolean(active)
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
if (!active) {
|
|
248
|
-
await appendAuditEvent(db, {
|
|
249
|
-
companyId,
|
|
250
|
-
actorType: "system",
|
|
251
|
-
eventType: "heartbeat.cancelled",
|
|
252
|
-
entityType: "heartbeat_run",
|
|
253
|
-
entityId: runId,
|
|
254
|
-
correlationId: options?.requestId ?? runId,
|
|
255
|
-
payload: {
|
|
256
|
-
agentId: run.agentId,
|
|
257
|
-
reason: cancelReason,
|
|
258
|
-
trigger: runTrigger,
|
|
259
|
-
requestId: options?.requestId ?? null,
|
|
260
|
-
actorId: options?.actorId ?? null
|
|
261
|
-
}
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
return { ok: true as const, runId, agentId: run.agentId, status: run.status };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export async function findPendingProjectBudgetOverrideBlocksForAgent(
|
|
268
|
-
db: BopoDb,
|
|
269
|
-
companyId: string,
|
|
270
|
-
agentId: string
|
|
271
|
-
) {
|
|
272
|
-
const assignedRows = await db
|
|
273
|
-
.select({ projectId: issues.projectId })
|
|
274
|
-
.from(issues)
|
|
275
|
-
.where(
|
|
276
|
-
and(
|
|
277
|
-
eq(issues.companyId, companyId),
|
|
278
|
-
eq(issues.assigneeAgentId, agentId),
|
|
279
|
-
inArray(issues.status, ["todo", "in_progress"])
|
|
280
|
-
)
|
|
281
|
-
);
|
|
282
|
-
const assignedProjectIds = new Set(assignedRows.map((row) => row.projectId));
|
|
283
|
-
if (assignedProjectIds.size === 0) {
|
|
284
|
-
return [] as string[];
|
|
285
|
-
}
|
|
286
|
-
const pendingOverrides = await db
|
|
287
|
-
.select({ payloadJson: approvalRequests.payloadJson })
|
|
288
|
-
.from(approvalRequests)
|
|
289
|
-
.where(
|
|
290
|
-
and(
|
|
291
|
-
eq(approvalRequests.companyId, companyId),
|
|
292
|
-
eq(approvalRequests.action, "override_budget"),
|
|
293
|
-
eq(approvalRequests.status, "pending")
|
|
294
|
-
)
|
|
295
|
-
);
|
|
296
|
-
const blockedProjectIds = new Set<string>();
|
|
297
|
-
for (const approval of pendingOverrides) {
|
|
298
|
-
try {
|
|
299
|
-
const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
|
|
300
|
-
const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
|
|
301
|
-
if (projectId && assignedProjectIds.has(projectId)) {
|
|
302
|
-
blockedProjectIds.add(projectId);
|
|
303
|
-
}
|
|
304
|
-
} catch {
|
|
305
|
-
// Ignore malformed payloads to keep enforcement resilient.
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
return Array.from(blockedProjectIds);
|
|
309
|
-
}
|
|
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";
|
|
310
80
|
|
|
311
81
|
export async function runHeartbeatForAgent(
|
|
312
82
|
db: BopoDb,
|
|
@@ -713,6 +483,8 @@ export async function runHeartbeatForAgent(
|
|
|
713
483
|
|
|
714
484
|
let issueIds: string[] = [];
|
|
715
485
|
let claimedIssueIds: string[] = [];
|
|
486
|
+
/** After transcript flush: remove DB row + audit noise for idle heartbeats with no issues. */
|
|
487
|
+
let discardIdleNoWorkRunAfterFlush = false;
|
|
716
488
|
let executionWorkItemsForBudget: Array<{ issueId: string; projectId: string }> = [];
|
|
717
489
|
let state: AgentState & {
|
|
718
490
|
runtime?: {
|
|
@@ -901,7 +673,11 @@ export async function runHeartbeatForAgent(
|
|
|
901
673
|
const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
|
|
902
674
|
const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
|
|
903
675
|
const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
|
|
904
|
-
const contextWorkItems =
|
|
676
|
+
const contextWorkItems = await hydrateIssueWorkItemsWithGoalIds(
|
|
677
|
+
db,
|
|
678
|
+
companyId,
|
|
679
|
+
resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext)
|
|
680
|
+
);
|
|
905
681
|
executionWorkItemsForBudget = contextWorkItems.map((item) => ({ issueId: item.id, projectId: item.project_id }));
|
|
906
682
|
claimedIssueIds = workItems.map((item) => item.id);
|
|
907
683
|
issueIds = contextWorkItems.map((item) => item.id);
|
|
@@ -1293,7 +1069,8 @@ export async function runHeartbeatForAgent(
|
|
|
1293
1069
|
mission: context.company.mission ?? null,
|
|
1294
1070
|
goalContext: {
|
|
1295
1071
|
companyGoals: context.goalContext?.companyGoals ?? [],
|
|
1296
|
-
projectGoals: context.goalContext?.projectGoals ?? []
|
|
1072
|
+
projectGoals: context.goalContext?.projectGoals ?? [],
|
|
1073
|
+
agentGoals: context.goalContext?.agentGoals ?? []
|
|
1297
1074
|
}
|
|
1298
1075
|
});
|
|
1299
1076
|
await appendAuditEvent(db, {
|
|
@@ -1337,7 +1114,8 @@ export async function runHeartbeatForAgent(
|
|
|
1337
1114
|
summary: executionSummary,
|
|
1338
1115
|
mission: context.company.mission ?? null,
|
|
1339
1116
|
companyGoals: context.goalContext?.companyGoals ?? [],
|
|
1340
|
-
projectGoals: context.goalContext?.projectGoals ?? []
|
|
1117
|
+
projectGoals: context.goalContext?.projectGoals ?? [],
|
|
1118
|
+
agentGoals: context.goalContext?.agentGoals ?? []
|
|
1341
1119
|
});
|
|
1342
1120
|
await appendAuditEvent(db, {
|
|
1343
1121
|
companyId,
|
|
@@ -1716,6 +1494,11 @@ export async function runHeartbeatForAgent(
|
|
|
1716
1494
|
}
|
|
1717
1495
|
}
|
|
1718
1496
|
});
|
|
1497
|
+
discardIdleNoWorkRunAfterFlush =
|
|
1498
|
+
issueIds.length === 0 &&
|
|
1499
|
+
!isCommentOrderWake &&
|
|
1500
|
+
(terminalPresentation.completionReason === "no_assigned_work" ||
|
|
1501
|
+
(isIdleNoWork && heartbeatIdlePolicy === "skip_adapter" && persistedRunStatus === "completed"));
|
|
1719
1502
|
} catch (error) {
|
|
1720
1503
|
const classified = classifyHeartbeatError(error);
|
|
1721
1504
|
executionSummary =
|
|
@@ -1951,6 +1734,17 @@ export async function runHeartbeatForAgent(
|
|
|
1951
1734
|
}
|
|
1952
1735
|
} finally {
|
|
1953
1736
|
await transcriptWriteQueue;
|
|
1737
|
+
if (discardIdleNoWorkRunAfterFlush) {
|
|
1738
|
+
try {
|
|
1739
|
+
await purgeIdleNoWorkHeartbeatRun(db, companyId, runId);
|
|
1740
|
+
if (options?.realtimeHub) {
|
|
1741
|
+
options.realtimeHub.publish(await loadHeartbeatRunsRealtimeSnapshot(db, companyId));
|
|
1742
|
+
}
|
|
1743
|
+
} catch (purgeError) {
|
|
1744
|
+
// eslint-disable-next-line no-console
|
|
1745
|
+
console.error("[heartbeat] failed to purge idle no-work run", runId, purgeError);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1954
1748
|
unregisterActiveHeartbeatRun(runId);
|
|
1955
1749
|
try {
|
|
1956
1750
|
await releaseClaimedIssues(db, companyId, claimedIssueIds);
|
|
@@ -1971,7 +1765,7 @@ export async function runHeartbeatForAgent(
|
|
|
1971
1765
|
}
|
|
1972
1766
|
await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
|
|
1973
1767
|
try {
|
|
1974
|
-
const queueModule = await import("
|
|
1768
|
+
const queueModule = await import("../heartbeat-queue-service");
|
|
1975
1769
|
queueModule.triggerHeartbeatQueueWorker(db, companyId, {
|
|
1976
1770
|
requestId: options?.requestId,
|
|
1977
1771
|
realtimeHub: options?.realtimeHub
|
|
@@ -1984,17 +1778,31 @@ export async function runHeartbeatForAgent(
|
|
|
1984
1778
|
return runId;
|
|
1985
1779
|
}
|
|
1986
1780
|
|
|
1781
|
+
async function purgeIdleNoWorkHeartbeatRun(db: BopoDb, companyId: string, runId: string) {
|
|
1782
|
+
await db
|
|
1783
|
+
.delete(auditEvents)
|
|
1784
|
+
.where(
|
|
1785
|
+
and(eq(auditEvents.companyId, companyId), eq(auditEvents.entityType, "heartbeat_run"), eq(auditEvents.entityId, runId))
|
|
1786
|
+
);
|
|
1787
|
+
await db.delete(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1987
1790
|
async function insertStartedRunAtomic(
|
|
1988
1791
|
db: BopoDb,
|
|
1989
1792
|
input: { id: string; companyId: string; agentId: string; message: string }
|
|
1990
1793
|
) {
|
|
1991
|
-
const
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
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;
|
|
1998
1806
|
}
|
|
1999
1807
|
|
|
2000
1808
|
async function recoverStaleHeartbeatRuns(
|
|
@@ -2054,129 +1862,43 @@ async function recoverStaleHeartbeatRuns(
|
|
|
2054
1862
|
}
|
|
2055
1863
|
}
|
|
2056
1864
|
|
|
2057
|
-
|
|
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(
|
|
2058
1880
|
db: BopoDb,
|
|
2059
1881
|
companyId: string,
|
|
2060
|
-
|
|
2061
|
-
) {
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
const now = new Date();
|
|
2066
|
-
const enqueuedJobIds: string[] = [];
|
|
2067
|
-
const dueAgents: Array<{ id: string }> = [];
|
|
2068
|
-
let skippedNotDue = 0;
|
|
2069
|
-
let skippedStatus = 0;
|
|
2070
|
-
let skippedBudgetBlocked = 0;
|
|
2071
|
-
let failedStarts = 0;
|
|
2072
|
-
const sweepStartedAt = Date.now();
|
|
2073
|
-
for (const agent of companyAgents) {
|
|
2074
|
-
if (agent.status !== "idle" && agent.status !== "running") {
|
|
2075
|
-
skippedStatus += 1;
|
|
2076
|
-
continue;
|
|
2077
|
-
}
|
|
2078
|
-
if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
|
|
2079
|
-
skippedNotDue += 1;
|
|
2080
|
-
continue;
|
|
2081
|
-
}
|
|
2082
|
-
const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(db, companyId, agent.id);
|
|
2083
|
-
if (blockedProjectIds.length > 0) {
|
|
2084
|
-
skippedBudgetBlocked += 1;
|
|
2085
|
-
continue;
|
|
2086
|
-
}
|
|
2087
|
-
dueAgents.push({ id: agent.id });
|
|
1882
|
+
items: IssueWorkItemRow[]
|
|
1883
|
+
): Promise<IssueWorkItemRowWithGoals[]> {
|
|
1884
|
+
if (items.length === 0) {
|
|
1885
|
+
return [];
|
|
2088
1886
|
}
|
|
2089
|
-
const
|
|
2090
|
-
|
|
2091
|
-
await runWithConcurrency(dueAgents, sweepConcurrency, async (agent) => {
|
|
2092
|
-
try {
|
|
2093
|
-
const job = await queueModule.enqueueHeartbeatQueueJob(db, {
|
|
2094
|
-
companyId,
|
|
2095
|
-
agentId: agent.id,
|
|
2096
|
-
jobType: "scheduler",
|
|
2097
|
-
priority: 80,
|
|
2098
|
-
idempotencyKey: options?.requestId ? `scheduler:${agent.id}:${options.requestId}` : null,
|
|
2099
|
-
payload: {}
|
|
2100
|
-
});
|
|
2101
|
-
enqueuedJobIds.push(job.id);
|
|
2102
|
-
queueModule.triggerHeartbeatQueueWorker(db, companyId, {
|
|
2103
|
-
requestId: options?.requestId,
|
|
2104
|
-
realtimeHub: options?.realtimeHub
|
|
2105
|
-
});
|
|
2106
|
-
} catch {
|
|
2107
|
-
failedStarts += 1;
|
|
2108
|
-
}
|
|
2109
|
-
});
|
|
2110
|
-
await appendAuditEvent(db, {
|
|
1887
|
+
const map = await listIssueGoalIdsBatch(
|
|
1888
|
+
db,
|
|
2111
1889
|
companyId,
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
runIds: enqueuedJobIds,
|
|
2119
|
-
startedCount: enqueuedJobIds.length,
|
|
2120
|
-
dueCount: dueAgents.length,
|
|
2121
|
-
failedStarts,
|
|
2122
|
-
skippedStatus,
|
|
2123
|
-
skippedNotDue,
|
|
2124
|
-
skippedBudgetBlocked,
|
|
2125
|
-
concurrency: sweepConcurrency,
|
|
2126
|
-
elapsedMs: Date.now() - sweepStartedAt,
|
|
2127
|
-
requestId: options?.requestId ?? null
|
|
2128
|
-
}
|
|
2129
|
-
});
|
|
2130
|
-
return enqueuedJobIds;
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
async function listLatestRunByAgent(db: BopoDb, companyId: string) {
|
|
2134
|
-
const result = await db.execute(sql`
|
|
2135
|
-
SELECT agent_id, MAX(started_at) AS latest_started_at
|
|
2136
|
-
FROM heartbeat_runs
|
|
2137
|
-
WHERE company_id = ${companyId}
|
|
2138
|
-
GROUP BY agent_id
|
|
2139
|
-
`);
|
|
2140
|
-
const latestRunByAgent = new Map<string, Date>();
|
|
2141
|
-
for (const row of result.rows ?? []) {
|
|
2142
|
-
const agentId = typeof row.agent_id === "string" ? row.agent_id : null;
|
|
2143
|
-
if (!agentId) {
|
|
2144
|
-
continue;
|
|
2145
|
-
}
|
|
2146
|
-
const startedAt = coerceDate(row.latest_started_at);
|
|
2147
|
-
if (!startedAt) {
|
|
2148
|
-
continue;
|
|
2149
|
-
}
|
|
2150
|
-
latestRunByAgent.set(agentId, startedAt);
|
|
2151
|
-
}
|
|
2152
|
-
return latestRunByAgent;
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
function coerceDate(value: unknown) {
|
|
2156
|
-
if (value instanceof Date) {
|
|
2157
|
-
return Number.isNaN(value.getTime()) ? null : value;
|
|
2158
|
-
}
|
|
2159
|
-
if (typeof value === "string" || typeof value === "number") {
|
|
2160
|
-
const parsed = new Date(value);
|
|
2161
|
-
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
2162
|
-
}
|
|
2163
|
-
return null;
|
|
1890
|
+
items.map((item) => item.id)
|
|
1891
|
+
);
|
|
1892
|
+
return items.map((item) => ({
|
|
1893
|
+
...item,
|
|
1894
|
+
goal_ids: map.get(item.id) ?? []
|
|
1895
|
+
}));
|
|
2164
1896
|
}
|
|
2165
1897
|
|
|
2166
1898
|
async function loadWakeContextWorkItems(db: BopoDb, companyId: string, wakeIssueIds?: string[]) {
|
|
2167
1899
|
const normalizedIds = Array.from(new Set((wakeIssueIds ?? []).filter((id) => id.trim().length > 0)));
|
|
2168
1900
|
if (normalizedIds.length === 0) {
|
|
2169
|
-
return [] as
|
|
2170
|
-
id: string;
|
|
2171
|
-
project_id: string;
|
|
2172
|
-
parent_issue_id: string | null;
|
|
2173
|
-
title: string;
|
|
2174
|
-
body: string | null;
|
|
2175
|
-
status: string;
|
|
2176
|
-
priority: string;
|
|
2177
|
-
labels_json: string;
|
|
2178
|
-
tags_json: string;
|
|
2179
|
-
}>;
|
|
1901
|
+
return [] as IssueWorkItemRow[];
|
|
2180
1902
|
}
|
|
2181
1903
|
const rows = await db
|
|
2182
1904
|
.select({
|
|
@@ -2196,32 +1918,9 @@ async function loadWakeContextWorkItems(db: BopoDb, companyId: string, wakeIssue
|
|
|
2196
1918
|
return rows.sort((a, b) => (sortOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (sortOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
|
|
2197
1919
|
}
|
|
2198
1920
|
|
|
2199
|
-
function mergeContextWorkItems(
|
|
2200
|
-
assigned: Array<{
|
|
2201
|
-
id: string;
|
|
2202
|
-
project_id: string;
|
|
2203
|
-
parent_issue_id: string | null;
|
|
2204
|
-
title: string;
|
|
2205
|
-
body: string | null;
|
|
2206
|
-
status: string;
|
|
2207
|
-
priority: string;
|
|
2208
|
-
labels_json: string;
|
|
2209
|
-
tags_json: string;
|
|
2210
|
-
}>,
|
|
2211
|
-
wakeContext: Array<{
|
|
2212
|
-
id: string;
|
|
2213
|
-
project_id: string;
|
|
2214
|
-
parent_issue_id: string | null;
|
|
2215
|
-
title: string;
|
|
2216
|
-
body: string | null;
|
|
2217
|
-
status: string;
|
|
2218
|
-
priority: string;
|
|
2219
|
-
labels_json: string;
|
|
2220
|
-
tags_json: string;
|
|
2221
|
-
}>
|
|
2222
|
-
) {
|
|
1921
|
+
function mergeContextWorkItems(assigned: IssueWorkItemRow[], wakeContext: IssueWorkItemRow[]) {
|
|
2223
1922
|
const seen = new Set<string>();
|
|
2224
|
-
const merged:
|
|
1923
|
+
const merged: IssueWorkItemRow[] = [];
|
|
2225
1924
|
for (const item of assigned) {
|
|
2226
1925
|
if (!seen.has(item.id)) {
|
|
2227
1926
|
seen.add(item.id);
|
|
@@ -2238,28 +1937,8 @@ function mergeContextWorkItems(
|
|
|
2238
1937
|
}
|
|
2239
1938
|
|
|
2240
1939
|
function resolveExecutionWorkItems(
|
|
2241
|
-
assigned:
|
|
2242
|
-
|
|
2243
|
-
project_id: string;
|
|
2244
|
-
parent_issue_id: string | null;
|
|
2245
|
-
title: string;
|
|
2246
|
-
body: string | null;
|
|
2247
|
-
status: string;
|
|
2248
|
-
priority: string;
|
|
2249
|
-
labels_json: string;
|
|
2250
|
-
tags_json: string;
|
|
2251
|
-
}>,
|
|
2252
|
-
wakeContextItems: Array<{
|
|
2253
|
-
id: string;
|
|
2254
|
-
project_id: string;
|
|
2255
|
-
parent_issue_id: string | null;
|
|
2256
|
-
title: string;
|
|
2257
|
-
body: string | null;
|
|
2258
|
-
status: string;
|
|
2259
|
-
priority: string;
|
|
2260
|
-
labels_json: string;
|
|
2261
|
-
tags_json: string;
|
|
2262
|
-
}>,
|
|
1940
|
+
assigned: IssueWorkItemRow[],
|
|
1941
|
+
wakeContextItems: IssueWorkItemRow[],
|
|
2263
1942
|
wakeContext?: HeartbeatWakeContext
|
|
2264
1943
|
) {
|
|
2265
1944
|
if (wakeContext?.reason === "issue_comment_recipient" && wakeContextItems.length > 0) {
|
|
@@ -2297,6 +1976,62 @@ async function loadWakeContextCommentBody(db: BopoDb, companyId: string, comment
|
|
|
2297
1976
|
return body && body.length > 0 ? body : null;
|
|
2298
1977
|
}
|
|
2299
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
|
+
|
|
2300
2035
|
async function buildHeartbeatContext(
|
|
2301
2036
|
db: BopoDb,
|
|
2302
2037
|
companyId: string,
|
|
@@ -2311,17 +2046,7 @@ async function buildHeartbeatContext(
|
|
|
2311
2046
|
memoryContext?: HeartbeatContext["memoryContext"];
|
|
2312
2047
|
runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
|
|
2313
2048
|
wakeContext?: HeartbeatWakeContext;
|
|
2314
|
-
workItems:
|
|
2315
|
-
id: string;
|
|
2316
|
-
project_id: string;
|
|
2317
|
-
parent_issue_id: string | null;
|
|
2318
|
-
title: string;
|
|
2319
|
-
body: string | null;
|
|
2320
|
-
status: string;
|
|
2321
|
-
priority: string;
|
|
2322
|
-
labels_json: string;
|
|
2323
|
-
tags_json: string;
|
|
2324
|
-
}>;
|
|
2049
|
+
workItems: IssueWorkItemRowWithGoals[];
|
|
2325
2050
|
}
|
|
2326
2051
|
): Promise<HeartbeatContext> {
|
|
2327
2052
|
const [company] = await db
|
|
@@ -2412,24 +2137,48 @@ async function buildHeartbeatContext(
|
|
|
2412
2137
|
id: goals.id,
|
|
2413
2138
|
level: goals.level,
|
|
2414
2139
|
title: goals.title,
|
|
2140
|
+
description: goals.description,
|
|
2415
2141
|
status: goals.status,
|
|
2416
|
-
projectId: goals.projectId
|
|
2142
|
+
projectId: goals.projectId,
|
|
2143
|
+
parentGoalId: goals.parentGoalId,
|
|
2144
|
+
ownerAgentId: goals.ownerAgentId
|
|
2417
2145
|
})
|
|
2418
2146
|
.from(goals)
|
|
2419
2147
|
.where(eq(goals.companyId, companyId));
|
|
2420
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
|
+
|
|
2421
2165
|
const activeCompanyGoals = goalRows
|
|
2422
2166
|
.filter((goal) => goal.status === "active" && goal.level === "company")
|
|
2423
|
-
.map((goal) => goal
|
|
2167
|
+
.map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
|
|
2424
2168
|
const activeProjectGoals = goalRows
|
|
2425
2169
|
.filter(
|
|
2426
2170
|
(goal) =>
|
|
2427
2171
|
goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId)
|
|
2428
2172
|
)
|
|
2429
|
-
.map((goal) => goal
|
|
2173
|
+
.map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
|
|
2430
2174
|
const activeAgentGoals = goalRows
|
|
2431
|
-
.filter(
|
|
2432
|
-
|
|
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));
|
|
2433
2182
|
const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
|
|
2434
2183
|
const promptMode = resolveHeartbeatPromptMode();
|
|
2435
2184
|
|
|
@@ -2468,6 +2217,11 @@ async function buildHeartbeatContext(
|
|
|
2468
2217
|
issueId: item.id,
|
|
2469
2218
|
projectId: item.project_id,
|
|
2470
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
|
+
}),
|
|
2471
2225
|
childIssueIds: childIssueIdsByParent.get(item.id) ?? [],
|
|
2472
2226
|
projectName: projectNameById.get(item.project_id) ?? null,
|
|
2473
2227
|
title: item.title,
|
|
@@ -2499,10 +2253,13 @@ function computeMissionAlignmentSignal(input: {
|
|
|
2499
2253
|
mission: string | null;
|
|
2500
2254
|
companyGoals: string[];
|
|
2501
2255
|
projectGoals: string[];
|
|
2256
|
+
agentGoals: string[];
|
|
2502
2257
|
}) {
|
|
2503
2258
|
const summaryTokens = new Set(tokenizeAlignmentText(input.summary));
|
|
2504
2259
|
const missionTokens = tokenizeAlignmentText(input.mission ?? "");
|
|
2505
|
-
const goalTokens = tokenizeAlignmentText(
|
|
2260
|
+
const goalTokens = tokenizeAlignmentText(
|
|
2261
|
+
[...input.companyGoals, ...input.projectGoals, ...input.agentGoals].join(" ")
|
|
2262
|
+
);
|
|
2506
2263
|
const matchedMissionTerms = missionTokens.filter((token) => summaryTokens.has(token));
|
|
2507
2264
|
const matchedGoalTerms = goalTokens.filter((token) => summaryTokens.has(token));
|
|
2508
2265
|
const missionScore = missionTokens.length > 0 ? matchedMissionTerms.length / missionTokens.length : 0;
|
|
@@ -2733,34 +2490,6 @@ async function ensureProjectBudgetOverrideApprovalRequest(
|
|
|
2733
2490
|
return approvalId;
|
|
2734
2491
|
}
|
|
2735
2492
|
|
|
2736
|
-
function sanitizeAgentSummaryCommentBody(body: string) {
|
|
2737
|
-
const sanitized = body.replace(AGENT_COMMENT_EMOJI_REGEX, "").trim();
|
|
2738
|
-
return sanitized.length > 0 ? sanitized : "Run update.";
|
|
2739
|
-
}
|
|
2740
|
-
|
|
2741
|
-
function extractNaturalRunUpdate(executionSummary: string) {
|
|
2742
|
-
const normalized = executionSummary.trim();
|
|
2743
|
-
const jsonSummary = extractSummaryFromJsonLikeText(normalized);
|
|
2744
|
-
const source = jsonSummary ?? normalized;
|
|
2745
|
-
const lines = source
|
|
2746
|
-
.split("\n")
|
|
2747
|
-
.map((line) => line.trim())
|
|
2748
|
-
.filter((line) => line.length > 0)
|
|
2749
|
-
.filter((line) => !line.startsWith("{") && !line.startsWith("}"));
|
|
2750
|
-
const compact = (lines.length > 0 ? lines.slice(0, 2).join(" ") : source)
|
|
2751
|
-
.replace(/^run (failure )?summary\s*:\s*/i, "")
|
|
2752
|
-
.replace(/^completed all assigned issue steps\s*:\s*/i, "")
|
|
2753
|
-
.replace(/^issue status\s*:\s*/i, "")
|
|
2754
|
-
.replace(/`+/g, "")
|
|
2755
|
-
.replace(/\s+/g, " ")
|
|
2756
|
-
.trim();
|
|
2757
|
-
const bounded = compact.length > 260 ? `${compact.slice(0, 257).trimEnd()}...` : compact;
|
|
2758
|
-
if (!bounded) {
|
|
2759
|
-
return "Run update.";
|
|
2760
|
-
}
|
|
2761
|
-
return /[.!?]$/.test(bounded) ? bounded : `${bounded}.`;
|
|
2762
|
-
}
|
|
2763
|
-
|
|
2764
2493
|
function buildRunDigest(input: {
|
|
2765
2494
|
status: "completed" | "failed" | "skipped";
|
|
2766
2495
|
executionSummary: string;
|
|
@@ -3156,6 +2885,11 @@ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, c
|
|
|
3156
2885
|
const [, agentId, suffix = ""] = directMatch;
|
|
3157
2886
|
return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
|
|
3158
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
|
+
}
|
|
3159
2893
|
const issueScopedMatch = normalized.match(
|
|
3160
2894
|
/^(?:[^/]+\/)?projects\/[^/]+\/issues\/[^/]+\/agents\/([^/]+)\/operating(\/.*)?$/
|
|
3161
2895
|
);
|
|
@@ -3452,30 +3186,6 @@ function isMachineNoiseLine(text: string) {
|
|
|
3452
3186
|
return patterns.some((pattern) => pattern.test(normalized));
|
|
3453
3187
|
}
|
|
3454
3188
|
|
|
3455
|
-
function extractSummaryFromJsonLikeText(input: string) {
|
|
3456
|
-
const fencedMatch = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
3457
|
-
const candidate = fencedMatch?.[1]?.trim() ?? input.match(/\{[\s\S]*\}\s*$/)?.[0]?.trim();
|
|
3458
|
-
if (!candidate) {
|
|
3459
|
-
return null;
|
|
3460
|
-
}
|
|
3461
|
-
try {
|
|
3462
|
-
const parsed = JSON.parse(candidate) as Record<string, unknown>;
|
|
3463
|
-
const summary = parsed.summary;
|
|
3464
|
-
if (typeof summary === "string" && summary.trim().length > 0) {
|
|
3465
|
-
return summary.trim();
|
|
3466
|
-
}
|
|
3467
|
-
} catch {
|
|
3468
|
-
// Fall through to regex extraction for loosely-formatted JSON.
|
|
3469
|
-
}
|
|
3470
|
-
const summaryMatch = candidate.match(/"summary"\s*:\s*"([\s\S]*?)"/);
|
|
3471
|
-
const summary = summaryMatch?.[1]
|
|
3472
|
-
?.replace(/\\"/g, "\"")
|
|
3473
|
-
.replace(/\\n/g, " ")
|
|
3474
|
-
.replace(/\s+/g, " ")
|
|
3475
|
-
.trim();
|
|
3476
|
-
return summary && summary.length > 0 ? summary : null;
|
|
3477
|
-
}
|
|
3478
|
-
|
|
3479
3189
|
async function appendRunSummaryComments(
|
|
3480
3190
|
db: BopoDb,
|
|
3481
3191
|
input: {
|
|
@@ -3849,32 +3559,6 @@ function isUsefulTranscriptSignal(level: "high" | "medium" | "low" | "noise") {
|
|
|
3849
3559
|
return level === "high" || level === "medium";
|
|
3850
3560
|
}
|
|
3851
3561
|
|
|
3852
|
-
function publishHeartbeatRunStatus(
|
|
3853
|
-
realtimeHub: RealtimeHub | undefined,
|
|
3854
|
-
input: {
|
|
3855
|
-
companyId: string;
|
|
3856
|
-
runId: string;
|
|
3857
|
-
status: "started" | "completed" | "failed" | "skipped";
|
|
3858
|
-
message?: string | null;
|
|
3859
|
-
startedAt?: Date;
|
|
3860
|
-
finishedAt?: Date;
|
|
3861
|
-
}
|
|
3862
|
-
) {
|
|
3863
|
-
if (!realtimeHub) {
|
|
3864
|
-
return;
|
|
3865
|
-
}
|
|
3866
|
-
realtimeHub.publish(
|
|
3867
|
-
createHeartbeatRunsRealtimeEvent(input.companyId, {
|
|
3868
|
-
type: "run.status.updated",
|
|
3869
|
-
runId: input.runId,
|
|
3870
|
-
status: input.status,
|
|
3871
|
-
message: input.message ?? null,
|
|
3872
|
-
startedAt: input.startedAt?.toISOString(),
|
|
3873
|
-
finishedAt: input.finishedAt?.toISOString() ?? null
|
|
3874
|
-
})
|
|
3875
|
-
);
|
|
3876
|
-
}
|
|
3877
|
-
|
|
3878
3562
|
async function resolveRuntimeWorkspaceForWorkItems(
|
|
3879
3563
|
db: BopoDb,
|
|
3880
3564
|
companyId: string,
|
|
@@ -4039,39 +3723,6 @@ function resolveStaleRunThresholdMs() {
|
|
|
4039
3723
|
return parsed;
|
|
4040
3724
|
}
|
|
4041
3725
|
|
|
4042
|
-
function resolveHeartbeatSweepConcurrency(dueAgentsCount: number) {
|
|
4043
|
-
const configured = Number(process.env.BOPO_HEARTBEAT_SWEEP_CONCURRENCY ?? "4");
|
|
4044
|
-
const fallback = 4;
|
|
4045
|
-
const normalized = Number.isFinite(configured) ? Math.floor(configured) : fallback;
|
|
4046
|
-
if (normalized < 1) {
|
|
4047
|
-
return 1;
|
|
4048
|
-
}
|
|
4049
|
-
// Prevent scheduler bursts from starving the API event loop.
|
|
4050
|
-
const bounded = Math.min(normalized, 16);
|
|
4051
|
-
return Math.min(bounded, Math.max(1, dueAgentsCount));
|
|
4052
|
-
}
|
|
4053
|
-
|
|
4054
|
-
async function runWithConcurrency<T>(
|
|
4055
|
-
items: T[],
|
|
4056
|
-
concurrency: number,
|
|
4057
|
-
worker: (item: T, index: number) => Promise<void>
|
|
4058
|
-
) {
|
|
4059
|
-
if (items.length === 0) {
|
|
4060
|
-
return;
|
|
4061
|
-
}
|
|
4062
|
-
const workerCount = Math.max(1, Math.min(Math.floor(concurrency), items.length));
|
|
4063
|
-
let cursor = 0;
|
|
4064
|
-
await Promise.all(
|
|
4065
|
-
Array.from({ length: workerCount }, async () => {
|
|
4066
|
-
while (cursor < items.length) {
|
|
4067
|
-
const index = cursor;
|
|
4068
|
-
cursor += 1;
|
|
4069
|
-
await worker(items[index] as T, index);
|
|
4070
|
-
}
|
|
4071
|
-
})
|
|
4072
|
-
);
|
|
4073
|
-
}
|
|
4074
|
-
|
|
4075
3726
|
function resolveEffectiveStaleRunThresholdMs(input: {
|
|
4076
3727
|
baseThresholdMs: number;
|
|
4077
3728
|
runtimeTimeoutSec: number;
|
|
@@ -4291,14 +3942,6 @@ function mergeRuntimeForExecution(
|
|
|
4291
3942
|
};
|
|
4292
3943
|
}
|
|
4293
3944
|
|
|
4294
|
-
function registerActiveHeartbeatRun(runId: string, run: ActiveHeartbeatRun) {
|
|
4295
|
-
activeHeartbeatRuns.set(runId, run);
|
|
4296
|
-
}
|
|
4297
|
-
|
|
4298
|
-
function unregisterActiveHeartbeatRun(runId: string) {
|
|
4299
|
-
activeHeartbeatRuns.delete(runId);
|
|
4300
|
-
}
|
|
4301
|
-
|
|
4302
3945
|
function clearResumeState(
|
|
4303
3946
|
state: AgentState & {
|
|
4304
3947
|
runtime?: {
|
|
@@ -4350,8 +3993,6 @@ function resolveHeartbeatPromptMode(): "full" | "compact" {
|
|
|
4350
3993
|
return raw === "compact" ? "compact" : "full";
|
|
4351
3994
|
}
|
|
4352
3995
|
|
|
4353
|
-
type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
|
|
4354
|
-
|
|
4355
3996
|
function resolveHeartbeatIdlePolicy(): HeartbeatIdlePolicy {
|
|
4356
3997
|
const raw = process.env.BOPO_HEARTBEAT_IDLE_POLICY?.trim().toLowerCase();
|
|
4357
3998
|
if (raw === "skip_adapter") {
|
|
@@ -4738,62 +4379,3 @@ async function appendFinishedRunCostEntry(input: {
|
|
|
4738
4379
|
usdCostStatus
|
|
4739
4380
|
};
|
|
4740
4381
|
}
|
|
4741
|
-
|
|
4742
|
-
function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
|
|
4743
|
-
const normalizedNow = truncateToMinute(now);
|
|
4744
|
-
if (!matchesCronExpression(cronExpression, normalizedNow)) {
|
|
4745
|
-
return false;
|
|
4746
|
-
}
|
|
4747
|
-
if (!lastRunAt) {
|
|
4748
|
-
return true;
|
|
4749
|
-
}
|
|
4750
|
-
return truncateToMinute(lastRunAt).getTime() !== normalizedNow.getTime();
|
|
4751
|
-
}
|
|
4752
|
-
|
|
4753
|
-
function truncateToMinute(date: Date) {
|
|
4754
|
-
const clone = new Date(date);
|
|
4755
|
-
clone.setSeconds(0, 0);
|
|
4756
|
-
return clone;
|
|
4757
|
-
}
|
|
4758
|
-
|
|
4759
|
-
function matchesCronExpression(expression: string, date: Date) {
|
|
4760
|
-
const parts = expression.trim().split(/\s+/);
|
|
4761
|
-
if (parts.length !== 5) {
|
|
4762
|
-
return false;
|
|
4763
|
-
}
|
|
4764
|
-
|
|
4765
|
-
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
|
|
4766
|
-
return (
|
|
4767
|
-
matchesCronField(minute, date.getMinutes(), 0, 59) &&
|
|
4768
|
-
matchesCronField(hour, date.getHours(), 0, 23) &&
|
|
4769
|
-
matchesCronField(dayOfMonth, date.getDate(), 1, 31) &&
|
|
4770
|
-
matchesCronField(month, date.getMonth() + 1, 1, 12) &&
|
|
4771
|
-
matchesCronField(dayOfWeek, date.getDay(), 0, 6)
|
|
4772
|
-
);
|
|
4773
|
-
}
|
|
4774
|
-
|
|
4775
|
-
function matchesCronField(field: string, value: number, min: number, max: number) {
|
|
4776
|
-
return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
|
|
4777
|
-
}
|
|
4778
|
-
|
|
4779
|
-
function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
|
|
4780
|
-
if (part === "*") {
|
|
4781
|
-
return true;
|
|
4782
|
-
}
|
|
4783
|
-
|
|
4784
|
-
const stepMatch = part.match(/^\*\/(\d+)$/);
|
|
4785
|
-
if (stepMatch) {
|
|
4786
|
-
const step = Number(stepMatch[1]);
|
|
4787
|
-
return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
|
|
4788
|
-
}
|
|
4789
|
-
|
|
4790
|
-
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
|
4791
|
-
if (rangeMatch) {
|
|
4792
|
-
const start = Number(rangeMatch[1]);
|
|
4793
|
-
const end = Number(rangeMatch[2]);
|
|
4794
|
-
return start <= value && value <= end;
|
|
4795
|
-
}
|
|
4796
|
-
|
|
4797
|
-
const exact = Number(part);
|
|
4798
|
-
return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
|
|
4799
|
-
}
|