@lota-sdk/core 0.1.15 → 0.1.16

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.
Files changed (138) hide show
  1. package/infrastructure/schema/00_identity.surql +0 -2
  2. package/infrastructure/schema/01_memory.surql +1 -1
  3. package/infrastructure/schema/02_execution_plan.surql +62 -1
  4. package/infrastructure/schema/03_learned_skill.surql +1 -1
  5. package/infrastructure/schema/06_playbook.surql +25 -0
  6. package/infrastructure/schema/07_institutional_memory.surql +13 -0
  7. package/infrastructure/schema/08_quality_metrics.surql +17 -0
  8. package/package.json +8 -7
  9. package/src/ai/definitions.ts +80 -2
  10. package/src/ai/index.ts +0 -2
  11. package/src/bifrost/bifrost.ts +2 -7
  12. package/src/config/agent-defaults.ts +31 -21
  13. package/src/config/agent-types.ts +11 -0
  14. package/src/config/constants.ts +2 -14
  15. package/src/config/debug-logger.ts +5 -1
  16. package/src/config/index.ts +3 -0
  17. package/src/config/model-constants.ts +16 -34
  18. package/src/config/search.ts +1 -15
  19. package/src/create-runtime.ts +244 -178
  20. package/src/db/cursor-pagination.ts +3 -6
  21. package/src/db/index.ts +2 -0
  22. package/src/db/memory-store.rows.ts +7 -7
  23. package/src/db/memory-store.ts +14 -18
  24. package/src/db/memory.ts +13 -13
  25. package/src/db/service.ts +153 -79
  26. package/src/db/startup.ts +6 -10
  27. package/src/db/surreal-mutation.ts +43 -0
  28. package/src/db/tables.ts +7 -0
  29. package/src/db/workstream-message-row.ts +15 -0
  30. package/src/embeddings/provider.ts +1 -1
  31. package/src/queues/context-compaction.queue.ts +15 -46
  32. package/src/queues/delayed-node-promotion.queue.ts +41 -0
  33. package/src/queues/index.ts +3 -0
  34. package/src/queues/memory-consolidation.queue.ts +16 -51
  35. package/src/queues/plan-scheduler.queue.ts +97 -0
  36. package/src/queues/post-chat-memory.queue.ts +15 -56
  37. package/src/queues/queue-factory.ts +100 -0
  38. package/src/queues/recent-activity-title-refinement.queue.ts +15 -50
  39. package/src/queues/regular-chat-memory-digest.queue.ts +16 -52
  40. package/src/queues/skill-extraction.queue.ts +15 -47
  41. package/src/queues/workstream-title-generation.queue.ts +15 -47
  42. package/src/redis/connection.ts +6 -0
  43. package/src/redis/index.ts +1 -1
  44. package/src/redis/stream-context.ts +11 -0
  45. package/src/runtime/agent-runtime-policy.ts +106 -21
  46. package/src/runtime/approval-continuation.ts +12 -6
  47. package/src/runtime/context-compaction-runtime.ts +1 -1
  48. package/src/runtime/context-compaction.ts +22 -60
  49. package/src/runtime/execution-plan.ts +22 -18
  50. package/src/runtime/graph-designer.ts +15 -0
  51. package/src/runtime/helper-model.ts +9 -197
  52. package/src/runtime/index.ts +2 -0
  53. package/src/runtime/llm-content.ts +1 -1
  54. package/src/runtime/memory-block.ts +9 -11
  55. package/src/runtime/memory-pipeline.ts +6 -9
  56. package/src/runtime/plugin-resolution.ts +35 -0
  57. package/src/runtime/plugin-types.ts +72 -0
  58. package/src/runtime/retrieval-adapters.ts +1 -1
  59. package/src/runtime/runtime-config.ts +25 -12
  60. package/src/runtime/runtime-extensions.ts +2 -2
  61. package/src/runtime/runtime-worker-registry.ts +6 -0
  62. package/src/runtime/team-consultation-orchestrator.ts +45 -28
  63. package/src/runtime/team-consultation-prompts.ts +11 -2
  64. package/src/runtime/title-helpers.ts +2 -4
  65. package/src/runtime/workstream-chat-helpers.ts +1 -1
  66. package/src/services/adaptive-playbook.service.ts +152 -0
  67. package/src/services/agent-executor.service.ts +293 -0
  68. package/src/services/artifact-provenance.service.ts +172 -0
  69. package/src/services/attachment.service.ts +6 -11
  70. package/src/services/context-compaction.service.ts +72 -55
  71. package/src/services/context-enrichment.service.ts +33 -0
  72. package/src/services/coordination-registry.service.ts +117 -0
  73. package/src/services/document-chunk.service.ts +1 -1
  74. package/src/services/domain-agent-executor.service.ts +71 -0
  75. package/src/services/execution-plan.service.ts +269 -50
  76. package/src/services/feedback-loop.service.ts +96 -0
  77. package/src/services/global-orchestrator.service.ts +148 -0
  78. package/src/services/index.ts +26 -0
  79. package/src/services/institutional-memory.service.ts +145 -0
  80. package/src/services/learned-skill.service.ts +24 -5
  81. package/src/services/memory-assessment.service.ts +3 -2
  82. package/src/services/memory-utils.ts +3 -8
  83. package/src/services/memory.service.ts +42 -59
  84. package/src/services/monitoring-window.service.ts +86 -0
  85. package/src/services/mutating-approval.service.ts +1 -1
  86. package/src/services/node-workspace.service.ts +155 -0
  87. package/src/services/notification.service.ts +39 -0
  88. package/src/services/organization-member.service.ts +11 -4
  89. package/src/services/organization.service.ts +5 -5
  90. package/src/services/ownership-dispatcher.service.ts +403 -0
  91. package/src/services/plan-approval.service.ts +1 -1
  92. package/src/services/plan-builder.service.ts +1 -0
  93. package/src/services/plan-checkpoint.service.ts +30 -2
  94. package/src/services/plan-compiler.service.ts +5 -0
  95. package/src/services/plan-coordination.service.ts +152 -0
  96. package/src/services/plan-cycle.service.ts +284 -0
  97. package/src/services/plan-deadline.service.ts +287 -0
  98. package/src/services/plan-executor.service.ts +384 -40
  99. package/src/services/plan-run.service.ts +41 -7
  100. package/src/services/plan-scheduler.service.ts +240 -0
  101. package/src/services/plan-template.service.ts +117 -0
  102. package/src/services/plan-validator.service.ts +84 -2
  103. package/src/services/plan-workspace.service.ts +83 -0
  104. package/src/services/playbook-registry.service.ts +67 -0
  105. package/src/services/plugin-executor.service.ts +103 -0
  106. package/src/services/quality-metrics.service.ts +132 -0
  107. package/src/services/recent-activity.service.ts +27 -31
  108. package/src/services/skill-resolver.service.ts +19 -0
  109. package/src/services/system-executor.service.ts +105 -0
  110. package/src/services/workstream-message.service.ts +12 -34
  111. package/src/services/workstream-plan-registry.service.ts +22 -0
  112. package/src/services/workstream-title.service.ts +3 -1
  113. package/src/services/workstream-turn-preparation.service.ts +34 -66
  114. package/src/services/workstream.service.ts +33 -55
  115. package/src/services/workstream.types.ts +9 -9
  116. package/src/services/write-intent-validator.service.ts +81 -0
  117. package/src/storage/attachment-parser.ts +1 -1
  118. package/src/storage/attachment-utils.ts +1 -1
  119. package/src/storage/generated-document-storage.service.ts +3 -2
  120. package/src/system-agents/delegated-agent-factory.ts +2 -0
  121. package/src/tools/execution-plan.tool.ts +17 -23
  122. package/src/tools/index.ts +0 -1
  123. package/src/tools/team-think.tool.ts +6 -4
  124. package/src/utils/async.ts +2 -1
  125. package/src/utils/date-time.ts +4 -32
  126. package/src/utils/env.ts +8 -0
  127. package/src/utils/errors.ts +42 -10
  128. package/src/utils/index.ts +9 -0
  129. package/src/utils/string.ts +114 -1
  130. package/src/workers/index.ts +1 -0
  131. package/src/workers/regular-chat-memory-digest.runner.ts +2 -2
  132. package/src/workers/skill-extraction.runner.ts +1 -1
  133. package/src/workers/utils/file-section-chunker.ts +2 -1
  134. package/src/workers/utils/repomix-file-sections.ts +2 -2
  135. package/src/workers/utils/sandbox-error.ts +11 -2
  136. package/src/workers/utils/workstream-message-query.ts +11 -20
  137. package/src/workers/worker-utils.ts +2 -2
  138. package/src/tools/log-hello-world.tool.ts +0 -17
@@ -0,0 +1,284 @@
1
+ import { PlanArtifactSchema, PlanCycleRecordSchema, PlanRunSchema } from '@lota-sdk/shared'
2
+ import type {
3
+ CarryForwardPolicy,
4
+ CycleSchedule,
5
+ PlanArtifactRecord,
6
+ PlanCycleRecord,
7
+ PlanDraft,
8
+ PlanRunStatus,
9
+ PlanScheduleSpec,
10
+ PlanTemplateRecord,
11
+ } from '@lota-sdk/shared'
12
+
13
+ import type { RecordIdInput } from '../db/record-id'
14
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
15
+ import { databaseService } from '../db/service'
16
+ import { TABLES } from '../db/tables'
17
+ import { planSchedulerService } from './plan-scheduler.service'
18
+ import { planTemplateService } from './plan-template.service'
19
+
20
+ const TERMINAL_RUN_STATUSES: ReadonlySet<PlanRunStatus> = new Set(['completed', 'failed', 'aborted'])
21
+
22
+ function cycleScheduleToSpec(schedule: CycleSchedule): PlanScheduleSpec {
23
+ const startAt = schedule.startAt ? new Date(schedule.startAt) : undefined
24
+ const isFutureStart = startAt && startAt.getTime() > Date.now()
25
+
26
+ if (isFutureStart) {
27
+ return { type: 'absolute', at: startAt.toISOString(), missedPolicy: 'run-immediately' }
28
+ }
29
+
30
+ const cron = frequencyToCron(schedule.frequency, schedule.customIntervalMs)
31
+ if (cron) {
32
+ return { type: 'cron', cron, missedPolicy: 'run-immediately' }
33
+ }
34
+
35
+ if (schedule.frequency === 'biweekly') {
36
+ return { type: 'monitoring', intervalMs: 14 * 24 * 60 * 60 * 1000, missedPolicy: 'run-immediately' }
37
+ }
38
+
39
+ if (schedule.frequency === 'custom' && schedule.customIntervalMs) {
40
+ return { type: 'monitoring', intervalMs: schedule.customIntervalMs, missedPolicy: 'run-immediately' }
41
+ }
42
+
43
+ return { type: 'cron', cron: '0 0 * * *', missedPolicy: 'run-immediately' }
44
+ }
45
+
46
+ function frequencyToCron(frequency: CycleSchedule['frequency'], _customIntervalMs?: number): string | null {
47
+ switch (frequency) {
48
+ case 'daily':
49
+ return '0 0 * * *'
50
+ case 'weekly':
51
+ return '0 0 * * 1'
52
+ case 'monthly':
53
+ return '0 0 1 * *'
54
+ case 'quarterly':
55
+ return '0 0 1 */3 *'
56
+ case 'biweekly':
57
+ case 'custom':
58
+ return null
59
+ }
60
+ }
61
+
62
+ class PlanCycleService {
63
+ cycleScheduleToSpec = cycleScheduleToSpec
64
+
65
+ async createCycle(params: {
66
+ organizationId: RecordIdInput
67
+ workstreamId: RecordIdInput
68
+ templateId: RecordIdInput
69
+ name: string
70
+ schedule: CycleSchedule
71
+ carryForwardPolicy?: CarryForwardPolicy
72
+ leadAgentId: string
73
+ }): Promise<PlanCycleRecord> {
74
+ const now = new Date()
75
+
76
+ const cycle = await databaseService.create(
77
+ TABLES.PLAN_CYCLE,
78
+ {
79
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
80
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
81
+ templateId: ensureRecordId(params.templateId, TABLES.PLAN_TEMPLATE),
82
+ name: params.name,
83
+ schedule: params.schedule,
84
+ carryForwardPolicy: params.carryForwardPolicy ?? 'incomplete-only',
85
+ status: 'active',
86
+ currentIteration: 0,
87
+ createdAt: now,
88
+ },
89
+ PlanCycleRecordSchema,
90
+ )
91
+
92
+ const createResult = await planTemplateService.instantiate({
93
+ templateId: params.templateId,
94
+ organizationId: params.organizationId,
95
+ workstreamId: params.workstreamId,
96
+ leadAgentId: params.leadAgentId,
97
+ })
98
+
99
+ const createdRunId = createResult.plan?.runId
100
+
101
+ const scheduleSpec = cycleScheduleToSpec(params.schedule)
102
+ const scheduleRecord = await planSchedulerService.createSchedule({
103
+ organizationId: params.organizationId,
104
+ workstreamId: params.workstreamId,
105
+ ...(createdRunId ? { runId: ensureRecordId(createdRunId, TABLES.PLAN_RUN) } : {}),
106
+ scheduleSpec,
107
+ })
108
+
109
+ const updated = await databaseService.update(
110
+ TABLES.PLAN_CYCLE,
111
+ ensureRecordId(cycle.id, TABLES.PLAN_CYCLE),
112
+ {
113
+ scheduleId: ensureRecordId(scheduleRecord.id, TABLES.PLAN_SCHEDULE),
114
+ ...(createdRunId ? { currentRunId: ensureRecordId(createdRunId, TABLES.PLAN_RUN) } : {}),
115
+ currentIteration: 1,
116
+ },
117
+ PlanCycleRecordSchema,
118
+ )
119
+
120
+ return updated ?? cycle
121
+ }
122
+
123
+ async advanceCycle(cycleId: RecordIdInput): Promise<void> {
124
+ const cycle = await this.getCycle(cycleId)
125
+ if (!cycle) {
126
+ throw new Error(`Cycle not found: ${recordIdToString(cycleId, TABLES.PLAN_CYCLE)}`)
127
+ }
128
+
129
+ if (cycle.status !== 'active') {
130
+ return
131
+ }
132
+
133
+ if (cycle.currentRunId) {
134
+ const runRecord = await databaseService.findOne(
135
+ TABLES.PLAN_RUN,
136
+ { id: ensureRecordId(cycle.currentRunId, TABLES.PLAN_RUN) },
137
+ PlanRunSchema,
138
+ )
139
+ if (runRecord && !TERMINAL_RUN_STATUSES.has(runRecord.status)) {
140
+ return
141
+ }
142
+ }
143
+
144
+ const template = await planTemplateService.getTemplate(cycle.templateId)
145
+ if (!template) {
146
+ throw new Error(`Template not found for cycle: ${recordIdToString(cycle.templateId, TABLES.PLAN_TEMPLATE)}`)
147
+ }
148
+
149
+ const previousRunArtifacts = cycle.currentRunId
150
+ ? await databaseService.findMany(
151
+ TABLES.PLAN_ARTIFACT,
152
+ { runId: ensureRecordId(cycle.currentRunId, TABLES.PLAN_RUN) },
153
+ PlanArtifactSchema,
154
+ { orderBy: 'createdAt', orderDir: 'ASC' },
155
+ )
156
+ : []
157
+
158
+ const draft = this.buildCarryForwardDraft({ template, previousRunArtifacts, policy: cycle.carryForwardPolicy })
159
+
160
+ const result = await planTemplateService.instantiate({
161
+ templateId: cycle.templateId,
162
+ organizationId: cycle.organizationId,
163
+ workstreamId: cycle.workstreamId,
164
+ leadAgentId: 'system',
165
+ overrides: draft,
166
+ })
167
+
168
+ const newRunId = result.plan?.runId
169
+
170
+ await databaseService.update(
171
+ TABLES.PLAN_CYCLE,
172
+ ensureRecordId(cycleId, TABLES.PLAN_CYCLE),
173
+ {
174
+ ...(newRunId ? { currentRunId: ensureRecordId(newRunId, TABLES.PLAN_RUN) } : {}),
175
+ currentIteration: cycle.currentIteration + 1,
176
+ },
177
+ PlanCycleRecordSchema,
178
+ )
179
+
180
+ // TODO: re-enable when playbook records exist and IDs align
181
+ // The previous implementation passed a PLAN_TEMPLATE record ID as a PLAYBOOK ID,
182
+ // causing refineFromCycle to always fail silently.
183
+ }
184
+
185
+ buildCarryForwardDraft(params: {
186
+ template: PlanTemplateRecord
187
+ previousRunArtifacts: PlanArtifactRecord[]
188
+ policy: CarryForwardPolicy
189
+ }): PlanDraft {
190
+ const draft = { ...params.template.draft }
191
+
192
+ if (params.policy === 'none' || params.previousRunArtifacts.length === 0) {
193
+ return draft
194
+ }
195
+
196
+ let artifacts: PlanArtifactRecord[]
197
+ if (params.policy === 'incomplete-only') {
198
+ artifacts = params.previousRunArtifacts.filter((a) => a.kind === 'markdown' || a.kind === 'json')
199
+ } else {
200
+ // 'all-pending'
201
+ artifacts = [...params.previousRunArtifacts]
202
+ }
203
+
204
+ if (artifacts.length > 0) {
205
+ const carryContext = artifacts.map((a) => `[carry-forward] ${a.name}: ${a.pointer}`)
206
+ draft.objective = `${draft.objective}\n\nCarry-forward context:\n${carryContext.join('\n')}`
207
+ }
208
+
209
+ return draft
210
+ }
211
+
212
+ async cancelCycle(cycleId: RecordIdInput): Promise<void> {
213
+ const cycle = await this.getCycle(cycleId)
214
+ if (!cycle) {
215
+ throw new Error(`Cycle not found: ${recordIdToString(cycleId, TABLES.PLAN_CYCLE)}`)
216
+ }
217
+
218
+ if (cycle.scheduleId) {
219
+ await planSchedulerService.cancelSchedule(cycle.scheduleId)
220
+ }
221
+
222
+ await databaseService.update(
223
+ TABLES.PLAN_CYCLE,
224
+ ensureRecordId(cycleId, TABLES.PLAN_CYCLE),
225
+ { status: 'cancelled' },
226
+ PlanCycleRecordSchema,
227
+ )
228
+ }
229
+
230
+ async pauseCycle(cycleId: RecordIdInput): Promise<void> {
231
+ const cycle = await this.getCycle(cycleId)
232
+ if (!cycle) {
233
+ throw new Error(`Cycle not found: ${recordIdToString(cycleId, TABLES.PLAN_CYCLE)}`)
234
+ }
235
+
236
+ if (cycle.scheduleId) {
237
+ await planSchedulerService.pauseSchedule(cycle.scheduleId)
238
+ }
239
+
240
+ await databaseService.update(
241
+ TABLES.PLAN_CYCLE,
242
+ ensureRecordId(cycleId, TABLES.PLAN_CYCLE),
243
+ { status: 'paused' },
244
+ PlanCycleRecordSchema,
245
+ )
246
+ }
247
+
248
+ async resumeCycle(cycleId: RecordIdInput): Promise<void> {
249
+ const cycle = await this.getCycle(cycleId)
250
+ if (!cycle) {
251
+ throw new Error(`Cycle not found: ${recordIdToString(cycleId, TABLES.PLAN_CYCLE)}`)
252
+ }
253
+
254
+ if (cycle.scheduleId) {
255
+ await planSchedulerService.resumeSchedule(cycle.scheduleId)
256
+ }
257
+
258
+ await databaseService.update(
259
+ TABLES.PLAN_CYCLE,
260
+ ensureRecordId(cycleId, TABLES.PLAN_CYCLE),
261
+ { status: 'active' },
262
+ PlanCycleRecordSchema,
263
+ )
264
+ }
265
+
266
+ async listCycles(workstreamId: RecordIdInput): Promise<PlanCycleRecord[]> {
267
+ return databaseService.findMany(
268
+ TABLES.PLAN_CYCLE,
269
+ { workstreamId: ensureRecordId(workstreamId, TABLES.WORKSTREAM) },
270
+ PlanCycleRecordSchema,
271
+ { orderBy: 'createdAt', orderDir: 'ASC' },
272
+ )
273
+ }
274
+
275
+ async getCycle(cycleId: RecordIdInput): Promise<PlanCycleRecord | null> {
276
+ return databaseService.findOne(
277
+ TABLES.PLAN_CYCLE,
278
+ { id: ensureRecordId(cycleId, TABLES.PLAN_CYCLE) },
279
+ PlanCycleRecordSchema,
280
+ )
281
+ }
282
+ }
283
+
284
+ export const planCycleService = new PlanCycleService()
@@ -0,0 +1,287 @@
1
+ import type {
2
+ DeadlineAction,
3
+ DeadlineReminder,
4
+ DeadlineSpec,
5
+ PlanNodeRunRecord,
6
+ PlanNodeSpecRecord,
7
+ PlanRunRecord,
8
+ } from '@lota-sdk/shared'
9
+ import { PlanNodeRunSchema, PlanNodeSpecRecordSchema, PlanRunSchema } from '@lota-sdk/shared'
10
+
11
+ import type { RecordIdInput } from '../db/record-id'
12
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
13
+ import { databaseService } from '../db/service'
14
+ import { TABLES } from '../db/tables'
15
+ import { getNotificationService } from './notification.service'
16
+
17
+ export type DeadlineEvaluationStatus = 'ok' | 'warning' | 'escalated' | 'missed'
18
+
19
+ export interface DeadlineEvaluationResult {
20
+ status: DeadlineEvaluationStatus
21
+ activeReminder?: DeadlineReminder
22
+ nextTriggerAt?: Date | null
23
+ }
24
+
25
+ class PlanDeadlineService {
26
+ evaluateDeadline(params: { deadline: DeadlineSpec; nodeStartedAt: Date; now?: Date }): DeadlineEvaluationResult {
27
+ const now = params.now ?? new Date()
28
+ const deadlineTime = this.resolveDeadlineTime(params.deadline, params.nodeStartedAt)
29
+
30
+ if (!deadlineTime) {
31
+ return { status: 'ok', nextTriggerAt: null }
32
+ }
33
+
34
+ const reminderEntries = [...params.deadline.reminders]
35
+ .map((reminder) => ({ reminder, triggerAt: new Date(deadlineTime.getTime() - reminder.beforeMs) }))
36
+ .sort((a, b) => a.triggerAt.getTime() - b.triggerAt.getTime())
37
+
38
+ let activeReminder: DeadlineReminder | undefined
39
+ let nextTriggerAt: Date | null = null
40
+
41
+ for (const entry of reminderEntries) {
42
+ if (now.getTime() < entry.triggerAt.getTime()) {
43
+ nextTriggerAt = entry.triggerAt
44
+ break
45
+ }
46
+
47
+ activeReminder = entry.reminder
48
+ }
49
+
50
+ if (now.getTime() >= deadlineTime.getTime()) {
51
+ return { status: 'missed', nextTriggerAt: null }
52
+ }
53
+
54
+ if (activeReminder) {
55
+ const status: DeadlineEvaluationStatus = activeReminder.action === 'escalate' ? 'escalated' : 'warning'
56
+ return { status, activeReminder, nextTriggerAt: nextTriggerAt ?? deadlineTime }
57
+ }
58
+
59
+ return { status: 'ok', nextTriggerAt: nextTriggerAt ?? deadlineTime }
60
+ }
61
+
62
+ async checkDeadlines(now?: Date): Promise<void> {
63
+ const currentTime = now ?? new Date()
64
+
65
+ const sweep = await this.collectDeadlineSweep(currentTime)
66
+ if (sweep.entries.length === 0) {
67
+ return
68
+ }
69
+
70
+ // Query parent runs lazily only for node runs that need notification or escalation handling.
71
+ const runCache = new Map<string, PlanRunRecord>()
72
+
73
+ for (const entry of sweep.entries) {
74
+ if (entry.evaluation.status === 'ok') continue
75
+
76
+ const deadline = entry.nodeSpec.deadline
77
+ if (!deadline) continue
78
+
79
+ const runIdStr = recordIdToString(entry.nodeRun.runId, TABLES.PLAN_RUN)
80
+ let run = runCache.get(runIdStr)
81
+ if (!run) {
82
+ const found = await databaseService.findOne(
83
+ TABLES.PLAN_RUN,
84
+ { id: ensureRecordId(entry.nodeRun.runId, TABLES.PLAN_RUN) },
85
+ PlanRunSchema,
86
+ )
87
+ if (!found) continue
88
+ run = found
89
+ runCache.set(runIdStr, run)
90
+ }
91
+
92
+ const organizationId = recordIdToString(run.organizationId, TABLES.ORGANIZATION)
93
+ const workstreamId = recordIdToString(run.workstreamId, TABLES.WORKSTREAM)
94
+ const notificationService = getNotificationService()
95
+ const dedupeKeyBase = `plan-deadline:${runIdStr}:${entry.nodeRun.nodeId}`
96
+
97
+ if (entry.evaluation.status === 'warning') {
98
+ await notificationService.remind({
99
+ organizationId,
100
+ workstreamId,
101
+ runId: runIdStr,
102
+ nodeId: entry.nodeRun.nodeId,
103
+ severity: 'warning',
104
+ title: 'Deadline approaching',
105
+ body:
106
+ entry.evaluation.activeReminder?.message ?? `Node "${entry.nodeRun.nodeId}" is approaching its deadline.`,
107
+ dedupeKey: `${dedupeKeyBase}:warning:${entry.evaluation.activeReminder?.beforeMs ?? 'default'}`,
108
+ })
109
+ } else if (entry.evaluation.status === 'escalated') {
110
+ await notificationService.escalate({
111
+ organizationId,
112
+ workstreamId,
113
+ runId: runIdStr,
114
+ nodeId: entry.nodeRun.nodeId,
115
+ severity: 'urgent',
116
+ title: 'Deadline escalation',
117
+ body:
118
+ entry.evaluation.activeReminder?.message ?? `Node "${entry.nodeRun.nodeId}" deadline requires escalation.`,
119
+ dedupeKey: `${dedupeKeyBase}:escalated:${entry.evaluation.activeReminder?.beforeMs ?? 'default'}`,
120
+ })
121
+ } else {
122
+ await this.applyDeadlineMissAction({
123
+ runId: entry.nodeRun.runId,
124
+ nodeId: entry.nodeRun.nodeId,
125
+ workstreamId,
126
+ organizationId,
127
+ action: deadline.missAction,
128
+ emittedBy: 'plan-deadline-checker',
129
+ })
130
+ }
131
+ }
132
+
133
+ const nextTriggerAt: Date | null =
134
+ sweep.entries
135
+ .map((entry) => entry.evaluation.nextTriggerAt)
136
+ .filter((value): value is Date => {
137
+ if (!value) return false
138
+ return value.getTime() > currentTime.getTime()
139
+ })
140
+ .sort((a, b) => a.getTime() - b.getTime())[0] ?? null
141
+
142
+ if (nextTriggerAt) {
143
+ await this.enqueueDeadlineCheck(nextTriggerAt)
144
+ }
145
+ }
146
+
147
+ async recoverDeadlineChecks(now = new Date()): Promise<void> {
148
+ const sweep = await this.collectDeadlineSweep(now)
149
+ const hasDueAction = sweep.entries.some((entry) => entry.evaluation.status !== 'ok')
150
+ if (hasDueAction) {
151
+ await this.enqueueDeadlineCheck(now)
152
+ return
153
+ }
154
+
155
+ const nextTriggerAt: Date | null =
156
+ sweep.entries
157
+ .map((entry) => entry.evaluation.nextTriggerAt)
158
+ .filter((value): value is Date => {
159
+ if (!value) return false
160
+ return value.getTime() > now.getTime()
161
+ })
162
+ .sort((a, b) => a.getTime() - b.getTime())[0] ?? null
163
+
164
+ if (nextTriggerAt) {
165
+ await this.enqueueDeadlineCheck(nextTriggerAt)
166
+ }
167
+ }
168
+
169
+ private async collectDeadlineSweep(
170
+ now: Date,
171
+ ): Promise<{
172
+ entries: Array<{ nodeRun: PlanNodeRunRecord; nodeSpec: PlanNodeSpecRecord; evaluation: DeadlineEvaluationResult }>
173
+ }> {
174
+ const activeNodeRuns = await databaseService.queryMany(
175
+ {
176
+ query: `SELECT * FROM ${TABLES.PLAN_NODE_RUN} WHERE status IN $statuses`,
177
+ bindings: { statuses: ['running', 'awaiting-human'] },
178
+ },
179
+ PlanNodeRunSchema,
180
+ )
181
+
182
+ const entries = []
183
+
184
+ for (const nodeRun of activeNodeRuns) {
185
+ const nodeSpec = await this.loadNodeSpec(nodeRun)
186
+ if (!nodeSpec?.deadline) continue
187
+
188
+ const evaluation = this.evaluateDeadline({
189
+ deadline: nodeSpec.deadline,
190
+ nodeStartedAt: new Date(nodeRun.startedAt ?? nodeRun.createdAt),
191
+ now,
192
+ })
193
+
194
+ entries.push({ nodeRun, nodeSpec, evaluation })
195
+ }
196
+
197
+ return { entries }
198
+ }
199
+
200
+ private resolveDeadlineTime(deadline: DeadlineSpec, nodeStartedAt: Date): Date | null {
201
+ return (
202
+ (deadline.dueAt ? new Date(deadline.dueAt) : null) ??
203
+ (deadline.durationMs !== undefined ? new Date(nodeStartedAt.getTime() + deadline.durationMs) : null)
204
+ )
205
+ }
206
+
207
+ private async loadNodeSpec(nodeRun: PlanNodeRunRecord): Promise<PlanNodeSpecRecord | null> {
208
+ return databaseService.findOne(
209
+ TABLES.PLAN_NODE_SPEC,
210
+ { planSpecId: ensureRecordId(nodeRun.planSpecId, TABLES.PLAN_SPEC), nodeId: nodeRun.nodeId },
211
+ PlanNodeSpecRecordSchema,
212
+ )
213
+ }
214
+
215
+ async applyDeadlineMissAction(params: {
216
+ runId: RecordIdInput
217
+ nodeId: string
218
+ workstreamId: string
219
+ organizationId: string
220
+ action: DeadlineAction
221
+ emittedBy: string
222
+ }): Promise<void> {
223
+ const notificationService = getNotificationService()
224
+ const runIdStr = recordIdToString(params.runId, TABLES.PLAN_RUN)
225
+
226
+ switch (params.action) {
227
+ case 'notify':
228
+ await notificationService.notify({
229
+ organizationId: params.organizationId,
230
+ workstreamId: params.workstreamId,
231
+ runId: runIdStr,
232
+ nodeId: params.nodeId,
233
+ severity: 'warning',
234
+ title: 'Deadline missed',
235
+ body: `Node "${params.nodeId}" has missed its deadline.`,
236
+ dedupeKey: `plan-deadline:${runIdStr}:${params.nodeId}:missed:notify`,
237
+ })
238
+ break
239
+
240
+ case 'escalate':
241
+ await notificationService.escalate({
242
+ organizationId: params.organizationId,
243
+ workstreamId: params.workstreamId,
244
+ runId: runIdStr,
245
+ nodeId: params.nodeId,
246
+ severity: 'urgent',
247
+ title: 'Deadline escalation',
248
+ body: `Node "${params.nodeId}" has missed its deadline and requires escalation.`,
249
+ dedupeKey: `plan-deadline:${runIdStr}:${params.nodeId}:missed:escalate`,
250
+ })
251
+ break
252
+
253
+ case 'block': {
254
+ const { planExecutorService } = await import('./plan-executor.service')
255
+ await planExecutorService.blockNodeOnDispatchFailure({
256
+ workstreamId: params.workstreamId,
257
+ runId: runIdStr,
258
+ nodeId: params.nodeId,
259
+ emittedBy: params.emittedBy,
260
+ message: 'Deadline missed — node blocked',
261
+ failureClass: 'timeout_exceeded',
262
+ })
263
+ break
264
+ }
265
+
266
+ case 'fail': {
267
+ const { planExecutorService } = await import('./plan-executor.service')
268
+ await planExecutorService.blockNodeOnDispatchFailure({
269
+ workstreamId: params.workstreamId,
270
+ runId: runIdStr,
271
+ nodeId: params.nodeId,
272
+ emittedBy: params.emittedBy,
273
+ message: 'Deadline missed — node failed',
274
+ failureClass: 'timeout_exceeded',
275
+ })
276
+ break
277
+ }
278
+ }
279
+ }
280
+
281
+ private async enqueueDeadlineCheck(scheduledFor: Date): Promise<void> {
282
+ const { enqueueDeadlineCheck } = await import('../queues/plan-scheduler.queue')
283
+ await enqueueDeadlineCheck(scheduledFor)
284
+ }
285
+ }
286
+
287
+ export const planDeadlineService = new PlanDeadlineService()