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.
@@ -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 { AgentCreateRequestSchema, TemplateManifestDefault, TemplateManifestSchema } from "bopodev-contracts";
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.role === parsed.data.role &&
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(db, companyId, startupProjectId, agent.id, agent.role);
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 === "pause_agent" || action === "terminate_agent" || action === "override_budget") {
351
- return { applied: false };
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
- role: string
698
+ roleTitle: string
520
699
  ) {
521
- const title = `Set up ${role} operating files`;
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
+