bopodev-api 0.1.23 → 0.1.25
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 +5 -0
- package/src/routes/heartbeats.ts +81 -43
- package/src/routes/issues.ts +293 -62
- package/src/routes/observability.ts +62 -1
- package/src/routes/projects.ts +7 -2
- package/src/scripts/onboard-seed.ts +3 -1
- package/src/server.ts +3 -1
- package/src/services/attention-service.ts +391 -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 +233 -9
- package/src/services/heartbeat-queue-service.ts +318 -0
- package/src/services/heartbeat-service.ts +930 -49
- package/src/services/memory-file-service.ts +513 -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,10 +1,18 @@
|
|
|
1
1
|
import { and, eq } from "drizzle-orm";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AGENT_ROLE_LABELS,
|
|
6
|
+
AgentCreateRequestSchema,
|
|
7
|
+
AgentRoleKeySchema,
|
|
8
|
+
TemplateManifestDefault,
|
|
9
|
+
TemplateManifestSchema
|
|
10
|
+
} from "bopodev-contracts";
|
|
5
11
|
import type { BopoDb } from "bopodev-db";
|
|
6
12
|
import {
|
|
7
13
|
approvalRequests,
|
|
14
|
+
agents,
|
|
15
|
+
appendAuditEvent,
|
|
8
16
|
createAgent,
|
|
9
17
|
createGoal,
|
|
10
18
|
createIssue,
|
|
@@ -107,6 +115,32 @@ const applyTemplatePayloadSchema = z.object({
|
|
|
107
115
|
templateVersion: z.string().min(1),
|
|
108
116
|
variables: z.record(z.string(), z.unknown()).default({})
|
|
109
117
|
});
|
|
118
|
+
const overrideBudgetPayloadSchema = z
|
|
119
|
+
.object({
|
|
120
|
+
agentId: z.string().min(1).optional(),
|
|
121
|
+
projectId: z.string().min(1).optional(),
|
|
122
|
+
reason: z.string().optional(),
|
|
123
|
+
additionalBudgetUsd: z.number().positive().optional(),
|
|
124
|
+
revisedMonthlyBudgetUsd: z.number().positive().optional()
|
|
125
|
+
})
|
|
126
|
+
.refine(
|
|
127
|
+
(value) => Boolean(value.agentId) || Boolean(value.projectId),
|
|
128
|
+
"Budget override payload requires agentId or projectId."
|
|
129
|
+
)
|
|
130
|
+
.refine(
|
|
131
|
+
(value) => !(value.agentId && value.projectId),
|
|
132
|
+
"Budget override payload must target either agentId or projectId, not both."
|
|
133
|
+
)
|
|
134
|
+
.refine(
|
|
135
|
+
(value) =>
|
|
136
|
+
(typeof value.additionalBudgetUsd === "number" && value.additionalBudgetUsd > 0) ||
|
|
137
|
+
(typeof value.revisedMonthlyBudgetUsd === "number" && value.revisedMonthlyBudgetUsd > 0),
|
|
138
|
+
"Budget override payload requires additionalBudgetUsd or revisedMonthlyBudgetUsd."
|
|
139
|
+
);
|
|
140
|
+
const pauseOrTerminateAgentPayloadSchema = z.object({
|
|
141
|
+
agentId: z.string().min(1),
|
|
142
|
+
reason: z.string().optional()
|
|
143
|
+
});
|
|
110
144
|
const AGENT_STARTUP_PROJECT_NAME = "Agent Onboarding";
|
|
111
145
|
const AGENT_STARTUP_TASK_MARKER = "[bopodev:onboarding:agent-startup:v1]";
|
|
112
146
|
|
|
@@ -153,7 +187,7 @@ export async function resolveApproval(
|
|
|
153
187
|
let execution:
|
|
154
188
|
| {
|
|
155
189
|
applied: boolean;
|
|
156
|
-
entityType?: "agent" | "goal" | "memory" | "template";
|
|
190
|
+
entityType?: "agent" | "goal" | "project" | "memory" | "template";
|
|
157
191
|
entityId?: string;
|
|
158
192
|
entity?: Record<string, unknown>;
|
|
159
193
|
}
|
|
@@ -255,7 +289,8 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
255
289
|
const existingAgents = await listAgents(db, companyId);
|
|
256
290
|
const duplicate = existingAgents.find(
|
|
257
291
|
(agent) =>
|
|
258
|
-
agent.
|
|
292
|
+
((parsed.data.roleKey && agent.roleKey === parsed.data.roleKey) ||
|
|
293
|
+
(!parsed.data.roleKey && parsed.data.role && agent.role === parsed.data.role)) &&
|
|
259
294
|
(agent.managerAgentId ?? null) === (parsed.data.managerAgentId ?? null) &&
|
|
260
295
|
agent.status !== "terminated"
|
|
261
296
|
);
|
|
@@ -271,7 +306,9 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
271
306
|
const agent = await createAgent(db, {
|
|
272
307
|
companyId,
|
|
273
308
|
managerAgentId: parsed.data.managerAgentId,
|
|
274
|
-
role: parsed.data.role,
|
|
309
|
+
role: resolveAgentRoleText(parsed.data.role, parsed.data.roleKey, parsed.data.title),
|
|
310
|
+
roleKey: normalizeRoleKey(parsed.data.roleKey),
|
|
311
|
+
title: normalizeTitle(parsed.data.title),
|
|
275
312
|
name: parsed.data.name,
|
|
276
313
|
providerType: parsed.data.providerType,
|
|
277
314
|
heartbeatCron: parsed.data.heartbeatCron,
|
|
@@ -281,7 +318,13 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
281
318
|
initialState: runtimeConfigToStateBlobPatch(runtimeConfig)
|
|
282
319
|
});
|
|
283
320
|
const startupProjectId = await ensureAgentStartupProject(db, companyId);
|
|
284
|
-
await ensureAgentStartupIssue(
|
|
321
|
+
await ensureAgentStartupIssue(
|
|
322
|
+
db,
|
|
323
|
+
companyId,
|
|
324
|
+
startupProjectId,
|
|
325
|
+
agent.id,
|
|
326
|
+
resolveAgentDisplayTitle(agent.title, agent.roleKey, agent.role)
|
|
327
|
+
);
|
|
285
328
|
|
|
286
329
|
return {
|
|
287
330
|
applied: true,
|
|
@@ -347,8 +390,144 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
347
390
|
};
|
|
348
391
|
}
|
|
349
392
|
|
|
350
|
-
if (action === "
|
|
351
|
-
|
|
393
|
+
if (action === "override_budget") {
|
|
394
|
+
const parsed = overrideBudgetPayloadSchema.safeParse(payload);
|
|
395
|
+
if (!parsed.success) {
|
|
396
|
+
throw new GovernanceError("Approval payload for budget override is invalid.");
|
|
397
|
+
}
|
|
398
|
+
if (parsed.data.agentId) {
|
|
399
|
+
const [agent] = await db
|
|
400
|
+
.select({
|
|
401
|
+
id: agents.id,
|
|
402
|
+
monthlyBudgetUsd: agents.monthlyBudgetUsd,
|
|
403
|
+
usedBudgetUsd: agents.usedBudgetUsd
|
|
404
|
+
})
|
|
405
|
+
.from(agents)
|
|
406
|
+
.where(and(eq(agents.companyId, companyId), eq(agents.id, parsed.data.agentId)))
|
|
407
|
+
.limit(1);
|
|
408
|
+
if (!agent) {
|
|
409
|
+
throw new GovernanceError("Agent not found for budget override request.");
|
|
410
|
+
}
|
|
411
|
+
const currentMonthlyBudget = Number(agent.monthlyBudgetUsd);
|
|
412
|
+
const currentUsedBudget = Number(agent.usedBudgetUsd);
|
|
413
|
+
const nextMonthlyBudget =
|
|
414
|
+
typeof parsed.data.revisedMonthlyBudgetUsd === "number"
|
|
415
|
+
? parsed.data.revisedMonthlyBudgetUsd
|
|
416
|
+
: currentMonthlyBudget + (parsed.data.additionalBudgetUsd ?? 0);
|
|
417
|
+
if (!Number.isFinite(nextMonthlyBudget) || nextMonthlyBudget <= 0) {
|
|
418
|
+
throw new GovernanceError("Budget override must resolve to a positive monthly budget.");
|
|
419
|
+
}
|
|
420
|
+
if (nextMonthlyBudget <= currentUsedBudget) {
|
|
421
|
+
throw new GovernanceError("Budget override must exceed current used budget.");
|
|
422
|
+
}
|
|
423
|
+
await db
|
|
424
|
+
.update(agents)
|
|
425
|
+
.set({
|
|
426
|
+
monthlyBudgetUsd: nextMonthlyBudget.toFixed(4),
|
|
427
|
+
updatedAt: new Date()
|
|
428
|
+
})
|
|
429
|
+
.where(and(eq(agents.companyId, companyId), eq(agents.id, parsed.data.agentId)));
|
|
430
|
+
return {
|
|
431
|
+
applied: true,
|
|
432
|
+
entityType: "agent" as const,
|
|
433
|
+
entityId: parsed.data.agentId,
|
|
434
|
+
entity: {
|
|
435
|
+
agentId: parsed.data.agentId,
|
|
436
|
+
previousMonthlyBudgetUsd: currentMonthlyBudget,
|
|
437
|
+
monthlyBudgetUsd: nextMonthlyBudget,
|
|
438
|
+
usedBudgetUsd: currentUsedBudget,
|
|
439
|
+
reason: parsed.data.reason ?? null
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const [project] = await db
|
|
444
|
+
.select({
|
|
445
|
+
id: projects.id,
|
|
446
|
+
monthlyBudgetUsd: projects.monthlyBudgetUsd,
|
|
447
|
+
usedBudgetUsd: projects.usedBudgetUsd
|
|
448
|
+
})
|
|
449
|
+
.from(projects)
|
|
450
|
+
.where(and(eq(projects.companyId, companyId), eq(projects.id, parsed.data.projectId!)))
|
|
451
|
+
.limit(1);
|
|
452
|
+
if (!project) {
|
|
453
|
+
throw new GovernanceError("Project not found for budget override request.");
|
|
454
|
+
}
|
|
455
|
+
const currentMonthlyBudget = Number(project.monthlyBudgetUsd);
|
|
456
|
+
const currentUsedBudget = Number(project.usedBudgetUsd);
|
|
457
|
+
const nextMonthlyBudget =
|
|
458
|
+
typeof parsed.data.revisedMonthlyBudgetUsd === "number"
|
|
459
|
+
? parsed.data.revisedMonthlyBudgetUsd
|
|
460
|
+
: currentMonthlyBudget + (parsed.data.additionalBudgetUsd ?? 0);
|
|
461
|
+
if (!Number.isFinite(nextMonthlyBudget) || nextMonthlyBudget <= 0) {
|
|
462
|
+
throw new GovernanceError("Budget override must resolve to a positive monthly budget.");
|
|
463
|
+
}
|
|
464
|
+
if (nextMonthlyBudget <= currentUsedBudget) {
|
|
465
|
+
throw new GovernanceError("Budget override must exceed current used budget.");
|
|
466
|
+
}
|
|
467
|
+
await db
|
|
468
|
+
.update(projects)
|
|
469
|
+
.set({
|
|
470
|
+
monthlyBudgetUsd: nextMonthlyBudget.toFixed(4),
|
|
471
|
+
updatedAt: new Date()
|
|
472
|
+
})
|
|
473
|
+
.where(and(eq(projects.companyId, companyId), eq(projects.id, parsed.data.projectId!)));
|
|
474
|
+
await appendAuditEvent(db, {
|
|
475
|
+
companyId,
|
|
476
|
+
actorType: "system",
|
|
477
|
+
eventType: "project_budget.override_applied",
|
|
478
|
+
entityType: "project",
|
|
479
|
+
entityId: parsed.data.projectId!,
|
|
480
|
+
payload: {
|
|
481
|
+
previousMonthlyBudgetUsd: currentMonthlyBudget,
|
|
482
|
+
monthlyBudgetUsd: nextMonthlyBudget,
|
|
483
|
+
usedBudgetUsd: currentUsedBudget,
|
|
484
|
+
reason: parsed.data.reason ?? null
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
return {
|
|
488
|
+
applied: true,
|
|
489
|
+
entityType: "project" as const,
|
|
490
|
+
entityId: parsed.data.projectId!,
|
|
491
|
+
entity: {
|
|
492
|
+
projectId: parsed.data.projectId!,
|
|
493
|
+
previousMonthlyBudgetUsd: currentMonthlyBudget,
|
|
494
|
+
monthlyBudgetUsd: nextMonthlyBudget,
|
|
495
|
+
usedBudgetUsd: currentUsedBudget,
|
|
496
|
+
reason: parsed.data.reason ?? null
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (action === "pause_agent" || action === "terminate_agent") {
|
|
502
|
+
const parsed = pauseOrTerminateAgentPayloadSchema.safeParse(payload);
|
|
503
|
+
if (!parsed.success) {
|
|
504
|
+
throw new GovernanceError(`Approval payload for ${action} is invalid.`);
|
|
505
|
+
}
|
|
506
|
+
const nextStatus = action === "pause_agent" ? "paused" : "terminated";
|
|
507
|
+
const [updated] = await db
|
|
508
|
+
.update(agents)
|
|
509
|
+
.set({
|
|
510
|
+
status: nextStatus,
|
|
511
|
+
updatedAt: new Date()
|
|
512
|
+
})
|
|
513
|
+
.where(and(eq(agents.companyId, companyId), eq(agents.id, parsed.data.agentId)))
|
|
514
|
+
.returning({
|
|
515
|
+
id: agents.id,
|
|
516
|
+
status: agents.status
|
|
517
|
+
});
|
|
518
|
+
if (!updated) {
|
|
519
|
+
throw new GovernanceError("Agent not found for lifecycle governance action.");
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
applied: true,
|
|
523
|
+
entityType: "agent" as const,
|
|
524
|
+
entityId: updated.id,
|
|
525
|
+
entity: {
|
|
526
|
+
id: updated.id,
|
|
527
|
+
status: updated.status,
|
|
528
|
+
reason: parsed.data.reason ?? null
|
|
529
|
+
}
|
|
530
|
+
};
|
|
352
531
|
}
|
|
353
532
|
|
|
354
533
|
if (action === "promote_memory_fact") {
|
|
@@ -516,9 +695,9 @@ async function ensureAgentStartupIssue(
|
|
|
516
695
|
companyId: string,
|
|
517
696
|
projectId: string,
|
|
518
697
|
agentId: string,
|
|
519
|
-
|
|
698
|
+
roleTitle: string
|
|
520
699
|
) {
|
|
521
|
-
const title = `Set up ${
|
|
700
|
+
const title = `Set up ${roleTitle} operating files`;
|
|
522
701
|
const body = buildAgentStartupTaskBody(companyId, agentId);
|
|
523
702
|
const existingIssues = await listIssues(db, companyId);
|
|
524
703
|
const existing = existingIssues.find(
|
|
@@ -545,6 +724,51 @@ async function ensureAgentStartupIssue(
|
|
|
545
724
|
return created.id;
|
|
546
725
|
}
|
|
547
726
|
|
|
727
|
+
function normalizeRoleKey(input: string | null | undefined) {
|
|
728
|
+
const normalized = input?.trim().toLowerCase();
|
|
729
|
+
if (!normalized) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
return AgentRoleKeySchema.safeParse(normalized).success ? normalized : null;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function normalizeTitle(input: string | null | undefined) {
|
|
736
|
+
const normalized = input?.trim();
|
|
737
|
+
return normalized ? normalized : null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function resolveAgentRoleText(
|
|
741
|
+
legacyRole: string | undefined,
|
|
742
|
+
roleKeyInput: string | undefined,
|
|
743
|
+
titleInput: string | null | undefined
|
|
744
|
+
) {
|
|
745
|
+
const normalizedLegacy = legacyRole?.trim();
|
|
746
|
+
if (normalizedLegacy) {
|
|
747
|
+
return normalizedLegacy;
|
|
748
|
+
}
|
|
749
|
+
const normalizedTitle = normalizeTitle(titleInput);
|
|
750
|
+
if (normalizedTitle) {
|
|
751
|
+
return normalizedTitle;
|
|
752
|
+
}
|
|
753
|
+
const roleKey = normalizeRoleKey(roleKeyInput);
|
|
754
|
+
if (roleKey) {
|
|
755
|
+
return AGENT_ROLE_LABELS[roleKey as keyof typeof AGENT_ROLE_LABELS];
|
|
756
|
+
}
|
|
757
|
+
return AGENT_ROLE_LABELS.general;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function resolveAgentDisplayTitle(title: string | null | undefined, roleKeyInput: string | null | undefined, role: string) {
|
|
761
|
+
const normalizedTitle = normalizeTitle(title);
|
|
762
|
+
if (normalizedTitle) {
|
|
763
|
+
return normalizedTitle;
|
|
764
|
+
}
|
|
765
|
+
const roleKey = normalizeRoleKey(roleKeyInput);
|
|
766
|
+
if (roleKey) {
|
|
767
|
+
return AGENT_ROLE_LABELS[roleKey as keyof typeof AGENT_ROLE_LABELS];
|
|
768
|
+
}
|
|
769
|
+
return role;
|
|
770
|
+
}
|
|
771
|
+
|
|
548
772
|
function buildAgentStartupTaskBody(companyId: string, agentId: string) {
|
|
549
773
|
const agentWorkspaceRoot = resolveAgentFallbackWorkspacePath(companyId, agentId);
|
|
550
774
|
const agentOperatingFolder = `${agentWorkspaceRoot}/operating`;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import {
|
|
3
|
+
cancelHeartbeatJob,
|
|
4
|
+
getHeartbeatRun,
|
|
5
|
+
claimNextHeartbeatJob,
|
|
6
|
+
enqueueHeartbeatJob,
|
|
7
|
+
issueComments,
|
|
8
|
+
markHeartbeatJobCompleted,
|
|
9
|
+
markHeartbeatJobDeadLetter,
|
|
10
|
+
markHeartbeatJobRetry,
|
|
11
|
+
updateIssueCommentRecipients,
|
|
12
|
+
type BopoDb
|
|
13
|
+
} from "bopodev-db";
|
|
14
|
+
import { parseIssueCommentRecipients } from "../lib/comment-recipients";
|
|
15
|
+
import type { RealtimeHub } from "../realtime/hub";
|
|
16
|
+
import { runHeartbeatForAgent } from "./heartbeat-service";
|
|
17
|
+
|
|
18
|
+
type QueueJobPayload = {
|
|
19
|
+
sourceRunId?: string;
|
|
20
|
+
wakeContext?: {
|
|
21
|
+
reason?: string | null;
|
|
22
|
+
commentId?: string | null;
|
|
23
|
+
issueIds?: string[];
|
|
24
|
+
};
|
|
25
|
+
commentDispatch?: {
|
|
26
|
+
commentId: string;
|
|
27
|
+
issueId: string;
|
|
28
|
+
recipientId: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const activeCompanyQueueWorkers = new Set<string>();
|
|
33
|
+
|
|
34
|
+
export async function enqueueHeartbeatQueueJob(
|
|
35
|
+
db: BopoDb,
|
|
36
|
+
input: {
|
|
37
|
+
companyId: string;
|
|
38
|
+
agentId: string;
|
|
39
|
+
jobType: "manual" | "scheduler" | "resume" | "redo" | "comment_dispatch";
|
|
40
|
+
payload?: QueueJobPayload;
|
|
41
|
+
priority?: number;
|
|
42
|
+
idempotencyKey?: string | null;
|
|
43
|
+
maxAttempts?: number;
|
|
44
|
+
availableAt?: Date;
|
|
45
|
+
}
|
|
46
|
+
) {
|
|
47
|
+
return enqueueHeartbeatJob(db, {
|
|
48
|
+
companyId: input.companyId,
|
|
49
|
+
agentId: input.agentId,
|
|
50
|
+
jobType: input.jobType,
|
|
51
|
+
payload: input.payload ?? {},
|
|
52
|
+
priority: input.priority ?? 100,
|
|
53
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
54
|
+
maxAttempts: input.maxAttempts ?? 10,
|
|
55
|
+
availableAt: input.availableAt
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function triggerHeartbeatQueueWorker(
|
|
60
|
+
db: BopoDb,
|
|
61
|
+
companyId: string,
|
|
62
|
+
options?: { requestId?: string; realtimeHub?: RealtimeHub; maxJobsPerSweep?: number }
|
|
63
|
+
) {
|
|
64
|
+
if (activeCompanyQueueWorkers.has(companyId)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
activeCompanyQueueWorkers.add(companyId);
|
|
68
|
+
queueMicrotask(() => {
|
|
69
|
+
void runHeartbeatQueueSweep(db, companyId, options)
|
|
70
|
+
.catch((error) => {
|
|
71
|
+
// eslint-disable-next-line no-console
|
|
72
|
+
console.error("[heartbeat-queue] worker run failed", error);
|
|
73
|
+
})
|
|
74
|
+
.finally(() => {
|
|
75
|
+
activeCompanyQueueWorkers.delete(companyId);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runHeartbeatQueueSweep(
|
|
81
|
+
db: BopoDb,
|
|
82
|
+
companyId: string,
|
|
83
|
+
options?: { requestId?: string; realtimeHub?: RealtimeHub; maxJobsPerSweep?: number }
|
|
84
|
+
) {
|
|
85
|
+
const maxJobs = Math.max(1, Math.min(options?.maxJobsPerSweep ?? 50, 500));
|
|
86
|
+
let processed = 0;
|
|
87
|
+
while (processed < maxJobs) {
|
|
88
|
+
const job = await claimNextHeartbeatJob(db, companyId);
|
|
89
|
+
if (!job) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
processed += 1;
|
|
93
|
+
try {
|
|
94
|
+
await processHeartbeatQueueJob(db, {
|
|
95
|
+
companyId,
|
|
96
|
+
job: {
|
|
97
|
+
id: job.id,
|
|
98
|
+
agentId: job.agentId,
|
|
99
|
+
jobType: job.jobType as "manual" | "scheduler" | "resume" | "redo" | "comment_dispatch",
|
|
100
|
+
attemptCount: job.attemptCount,
|
|
101
|
+
maxAttempts: job.maxAttempts,
|
|
102
|
+
payload: (job.payload ?? {}) as QueueJobPayload
|
|
103
|
+
},
|
|
104
|
+
requestId: options?.requestId,
|
|
105
|
+
realtimeHub: options?.realtimeHub
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
await handleQueueRetryOrDeadLetter(db, {
|
|
109
|
+
companyId,
|
|
110
|
+
jobId: job.id,
|
|
111
|
+
attemptCount: job.attemptCount,
|
|
112
|
+
maxAttempts: job.maxAttempts,
|
|
113
|
+
error: `Queue processing failed: ${String((error as Error)?.message ?? error)}`
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { processed };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function processHeartbeatQueueJob(
|
|
121
|
+
db: BopoDb,
|
|
122
|
+
input: {
|
|
123
|
+
companyId: string;
|
|
124
|
+
job: {
|
|
125
|
+
id: string;
|
|
126
|
+
agentId: string;
|
|
127
|
+
jobType: "manual" | "scheduler" | "resume" | "redo" | "comment_dispatch";
|
|
128
|
+
attemptCount: number;
|
|
129
|
+
maxAttempts: number;
|
|
130
|
+
payload: QueueJobPayload;
|
|
131
|
+
};
|
|
132
|
+
requestId?: string;
|
|
133
|
+
realtimeHub?: RealtimeHub;
|
|
134
|
+
}
|
|
135
|
+
) {
|
|
136
|
+
let runId: string | null = null;
|
|
137
|
+
try {
|
|
138
|
+
runId = await runHeartbeatForAgent(db, input.companyId, input.job.agentId, {
|
|
139
|
+
trigger: input.job.jobType === "manual" || input.job.jobType === "resume" || input.job.jobType === "redo" ? "manual" : "scheduler",
|
|
140
|
+
requestId: input.requestId,
|
|
141
|
+
realtimeHub: input.realtimeHub,
|
|
142
|
+
...(input.job.jobType === "resume" || input.job.jobType === "redo" ? { mode: input.job.jobType } : {}),
|
|
143
|
+
...(input.job.payload.sourceRunId ? { sourceRunId: input.job.payload.sourceRunId } : {}),
|
|
144
|
+
...(input.job.payload.wakeContext ? { wakeContext: input.job.payload.wakeContext } : {})
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
await handleQueueRetryOrDeadLetter(db, {
|
|
148
|
+
companyId: input.companyId,
|
|
149
|
+
jobId: input.job.id,
|
|
150
|
+
attemptCount: input.job.attemptCount,
|
|
151
|
+
maxAttempts: input.job.maxAttempts,
|
|
152
|
+
error: `Heartbeat executor failed: ${String((error as Error)?.message ?? error)}`
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!runId) {
|
|
158
|
+
await handleQueueRetryOrDeadLetter(db, {
|
|
159
|
+
companyId: input.companyId,
|
|
160
|
+
jobId: input.job.id,
|
|
161
|
+
attemptCount: input.job.attemptCount,
|
|
162
|
+
maxAttempts: input.job.maxAttempts,
|
|
163
|
+
error: "Heartbeat execution returned no run id."
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const run = await getHeartbeatRun(db, input.companyId, runId);
|
|
169
|
+
if (!run) {
|
|
170
|
+
await handleQueueRetryOrDeadLetter(db, {
|
|
171
|
+
companyId: input.companyId,
|
|
172
|
+
jobId: input.job.id,
|
|
173
|
+
attemptCount: input.job.attemptCount,
|
|
174
|
+
maxAttempts: input.job.maxAttempts,
|
|
175
|
+
heartbeatRunId: runId,
|
|
176
|
+
error: "Heartbeat run record was not found after execution."
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (run.status === "skipped" && String(run.message ?? "").toLowerCase().includes("already in progress")) {
|
|
182
|
+
await markHeartbeatJobRetry(db, {
|
|
183
|
+
companyId: input.companyId,
|
|
184
|
+
id: input.job.id,
|
|
185
|
+
heartbeatRunId: runId,
|
|
186
|
+
retryAt: new Date(Date.now() + resolveRetryDelayMs(input.job.attemptCount)),
|
|
187
|
+
error: "Agent busy, retry queued."
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (run.status === "skipped") {
|
|
193
|
+
if (isProjectBudgetHardStopMessage(run.message)) {
|
|
194
|
+
await cancelHeartbeatJob(db, {
|
|
195
|
+
companyId: input.companyId,
|
|
196
|
+
id: input.job.id
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
await handleQueueRetryOrDeadLetter(db, {
|
|
201
|
+
companyId: input.companyId,
|
|
202
|
+
jobId: input.job.id,
|
|
203
|
+
attemptCount: input.job.attemptCount,
|
|
204
|
+
maxAttempts: input.job.maxAttempts,
|
|
205
|
+
heartbeatRunId: runId,
|
|
206
|
+
error: run.message ?? "Heartbeat skipped."
|
|
207
|
+
});
|
|
208
|
+
await markCommentRecipientDeliveryIfNeeded(db, input.companyId, input.job.payload.commentDispatch, {
|
|
209
|
+
deliveryStatus: "failed",
|
|
210
|
+
dispatchedRunId: null,
|
|
211
|
+
dispatchedAt: null
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await markHeartbeatJobCompleted(db, {
|
|
217
|
+
companyId: input.companyId,
|
|
218
|
+
id: input.job.id,
|
|
219
|
+
heartbeatRunId: runId
|
|
220
|
+
});
|
|
221
|
+
await markCommentRecipientDeliveryIfNeeded(db, input.companyId, input.job.payload.commentDispatch, {
|
|
222
|
+
deliveryStatus: "dispatched",
|
|
223
|
+
dispatchedRunId: runId,
|
|
224
|
+
dispatchedAt: new Date().toISOString()
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function handleQueueRetryOrDeadLetter(
|
|
229
|
+
db: BopoDb,
|
|
230
|
+
input: {
|
|
231
|
+
companyId: string;
|
|
232
|
+
jobId: string;
|
|
233
|
+
attemptCount: number;
|
|
234
|
+
maxAttempts: number;
|
|
235
|
+
error: string;
|
|
236
|
+
heartbeatRunId?: string;
|
|
237
|
+
}
|
|
238
|
+
) {
|
|
239
|
+
if (input.attemptCount >= input.maxAttempts) {
|
|
240
|
+
await markHeartbeatJobDeadLetter(db, {
|
|
241
|
+
companyId: input.companyId,
|
|
242
|
+
id: input.jobId,
|
|
243
|
+
heartbeatRunId: input.heartbeatRunId ?? null,
|
|
244
|
+
error: input.error
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
await markHeartbeatJobRetry(db, {
|
|
249
|
+
companyId: input.companyId,
|
|
250
|
+
id: input.jobId,
|
|
251
|
+
heartbeatRunId: input.heartbeatRunId ?? null,
|
|
252
|
+
retryAt: new Date(Date.now() + resolveRetryDelayMs(input.attemptCount)),
|
|
253
|
+
error: input.error
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resolveRetryDelayMs(attemptCount: number) {
|
|
258
|
+
const baseDelayMs = Number(process.env.BOPO_HEARTBEAT_QUEUE_RETRY_BASE_MS ?? 1000);
|
|
259
|
+
const cappedAttempt = Math.max(0, Math.min(attemptCount, 8));
|
|
260
|
+
return Math.max(500, baseDelayMs) * 2 ** cappedAttempt;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isProjectBudgetHardStopMessage(message: string | null | undefined) {
|
|
264
|
+
const normalized = String(message ?? "")
|
|
265
|
+
.trim()
|
|
266
|
+
.toLowerCase();
|
|
267
|
+
return normalized.includes("project budget hard-stop");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function markCommentRecipientDeliveryIfNeeded(
|
|
271
|
+
db: BopoDb,
|
|
272
|
+
companyId: string,
|
|
273
|
+
dispatch: QueueJobPayload["commentDispatch"] | undefined,
|
|
274
|
+
input: {
|
|
275
|
+
deliveryStatus: "dispatched" | "failed";
|
|
276
|
+
dispatchedRunId: string | null;
|
|
277
|
+
dispatchedAt: string | null;
|
|
278
|
+
}
|
|
279
|
+
) {
|
|
280
|
+
if (!dispatch) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const [comment] = await db
|
|
284
|
+
.select({ recipientsJson: issueComments.recipientsJson })
|
|
285
|
+
.from(issueComments)
|
|
286
|
+
.where(and(eq(issueComments.companyId, companyId), eq(issueComments.id, dispatch.commentId)))
|
|
287
|
+
.limit(1);
|
|
288
|
+
if (!comment) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const recipients = parseIssueCommentRecipients(comment.recipientsJson);
|
|
292
|
+
let changed = false;
|
|
293
|
+
const updatedRecipients = recipients.map((recipient) => {
|
|
294
|
+
if (recipient.recipientType !== "agent" || recipient.recipientId !== dispatch.recipientId) {
|
|
295
|
+
return recipient;
|
|
296
|
+
}
|
|
297
|
+
if (recipient.deliveryStatus !== "pending") {
|
|
298
|
+
return recipient;
|
|
299
|
+
}
|
|
300
|
+
changed = true;
|
|
301
|
+
return {
|
|
302
|
+
...recipient,
|
|
303
|
+
deliveryStatus: input.deliveryStatus,
|
|
304
|
+
dispatchedRunId: input.dispatchedRunId,
|
|
305
|
+
dispatchedAt: input.dispatchedAt
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
if (!changed) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await updateIssueCommentRecipients(db, {
|
|
312
|
+
companyId,
|
|
313
|
+
issueId: dispatch.issueId,
|
|
314
|
+
id: dispatch.commentId,
|
|
315
|
+
recipients: updatedRecipients
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|