@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
@@ -21,23 +21,30 @@ import {
21
21
  } from '@lota-sdk/shared'
22
22
  import { RecordId } from 'surrealdb'
23
23
 
24
+ import { aiLogger } from '../config/logger'
24
25
  import type { RecordIdInput } from '../db/record-id'
25
26
  import { ensureRecordId, recordIdToString } from '../db/record-id'
26
27
  import { databaseService } from '../db/service'
27
28
  import type { DatabaseTransaction } from '../db/service'
28
29
  import { TABLES } from '../db/tables'
30
+ import { toDatabaseDateTime } from '../utils/date-time'
29
31
  import { isRecord } from '../utils/string'
32
+ import { feedbackLoopService } from './feedback-loop.service'
33
+ import { institutionalMemoryService } from './institutional-memory.service'
30
34
  import { planApprovalService } from './plan-approval.service'
31
35
  import { planArtifactService } from './plan-artifact.service'
32
36
  import { planCheckpointService } from './plan-checkpoint.service'
37
+ import { planCoordinationService } from './plan-coordination.service'
33
38
  import { readPathValue } from './plan-helpers'
34
39
  import { planRunService } from './plan-run.service'
40
+ import { planSchedulerService } from './plan-scheduler.service'
35
41
  import type { PlanValidationIssueInput } from './plan-validator.service'
36
42
  import { planValidatorService } from './plan-validator.service'
43
+ import { qualityMetricsService } from './quality-metrics.service'
37
44
 
38
- const SUCCESSFUL_TERMINAL_NODE_STATUSES = new Set(['completed', 'partial', 'skipped'])
45
+ const SUCCESSFUL_TERMINAL_NODE_STATUSES = new Set(['completed', 'partial', 'skipped', 'scheduled', 'monitoring'])
39
46
  const HUMAN_NODE_TYPES = new Set(['human-input', 'human-approval', 'human-review-edit', 'human-decision'])
40
- const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join'])
47
+ const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join', 'deliberation-fork'])
41
48
 
42
49
  function setPathValue(target: Record<string, unknown>, path: string, value: unknown) {
43
50
  const segments = path
@@ -146,8 +153,8 @@ type PlanRunUpdate = Omit<
146
153
  waitingNodeId?: string | null
147
154
  replacedRunId?: RecordIdInput | null
148
155
  lastCheckpointId?: RecordIdInput | null
149
- startedAt?: Date | null
150
- completedAt?: Date | null
156
+ startedAt?: string | Date | null
157
+ completedAt?: string | Date | null
151
158
  }
152
159
 
153
160
  type PlanNodeRunUpdate = Omit<
@@ -158,6 +165,7 @@ type PlanNodeRunUpdate = Omit<
158
165
  | 'latestStructuredOutput'
159
166
  | 'latestNotes'
160
167
  | 'latestAttemptId'
168
+ | 'scheduledAt'
161
169
  | 'readyAt'
162
170
  | 'startedAt'
163
171
  | 'completedAt'
@@ -168,9 +176,10 @@ type PlanNodeRunUpdate = Omit<
168
176
  latestStructuredOutput?: Record<string, unknown> | null
169
177
  latestNotes?: string | null
170
178
  latestAttemptId?: RecordIdInput | null
171
- readyAt?: Date | null
172
- startedAt?: Date | null
173
- completedAt?: Date | null
179
+ scheduledAt?: string | Date | null
180
+ readyAt?: string | Date | null
181
+ startedAt?: string | Date | null
182
+ completedAt?: string | Date | null
174
183
  }
175
184
 
176
185
  function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
@@ -213,16 +222,16 @@ function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
213
222
  ...(patch.startedAt === null
214
223
  ? {}
215
224
  : patch.startedAt !== undefined
216
- ? { startedAt: patch.startedAt }
225
+ ? { startedAt: toDatabaseDateTime(patch.startedAt) }
217
226
  : run.startedAt
218
- ? { startedAt: run.startedAt }
227
+ ? { startedAt: toDatabaseDateTime(run.startedAt) }
219
228
  : {}),
220
229
  ...(patch.completedAt === null
221
230
  ? {}
222
231
  : patch.completedAt !== undefined
223
- ? { completedAt: patch.completedAt }
232
+ ? { completedAt: toDatabaseDateTime(patch.completedAt) }
224
233
  : run.completedAt
225
- ? { completedAt: run.completedAt }
234
+ ? { completedAt: toDatabaseDateTime(run.completedAt) }
226
235
  : {}),
227
236
  }
228
237
  }
@@ -277,26 +286,33 @@ function toNodeRunData(nodeRun: PlanNodeRunRecord, patch: PlanNodeRunUpdate) {
277
286
  : nodeRun.failureClass
278
287
  ? { failureClass: nodeRun.failureClass }
279
288
  : {}),
289
+ ...(patch.scheduledAt === null
290
+ ? {}
291
+ : patch.scheduledAt !== undefined
292
+ ? { scheduledAt: toDatabaseDateTime(patch.scheduledAt) }
293
+ : nodeRun.scheduledAt
294
+ ? { scheduledAt: toDatabaseDateTime(nodeRun.scheduledAt) }
295
+ : {}),
280
296
  ...(patch.readyAt === null
281
297
  ? {}
282
298
  : patch.readyAt !== undefined
283
- ? { readyAt: patch.readyAt }
299
+ ? { readyAt: toDatabaseDateTime(patch.readyAt) }
284
300
  : nodeRun.readyAt
285
- ? { readyAt: nodeRun.readyAt }
301
+ ? { readyAt: toDatabaseDateTime(nodeRun.readyAt) }
286
302
  : {}),
287
303
  ...(patch.startedAt === null
288
304
  ? {}
289
305
  : patch.startedAt !== undefined
290
- ? { startedAt: patch.startedAt }
306
+ ? { startedAt: toDatabaseDateTime(patch.startedAt) }
291
307
  : nodeRun.startedAt
292
- ? { startedAt: nodeRun.startedAt }
308
+ ? { startedAt: toDatabaseDateTime(nodeRun.startedAt) }
293
309
  : {}),
294
310
  ...(patch.completedAt === null
295
311
  ? {}
296
312
  : patch.completedAt !== undefined
297
- ? { completedAt: patch.completedAt }
313
+ ? { completedAt: toDatabaseDateTime(patch.completedAt) }
298
314
  : nodeRun.completedAt
299
- ? { completedAt: nodeRun.completedAt }
315
+ ? { completedAt: toDatabaseDateTime(nodeRun.completedAt) }
300
316
  : {}),
301
317
  }
302
318
  }
@@ -751,7 +767,63 @@ class PlanExecutorService {
751
767
  await this.attachCheckpoint(tx, synced.run, checkpoint)
752
768
  })
753
769
 
754
- const snapshot = await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
770
+ // Record node-level quality metrics (fire-and-forget)
771
+ const orgId = recordIdToString(run.organizationId, TABLES.ORGANIZATION)
772
+ const runIdStr = recordIdToString(run.id, TABLES.PLAN_RUN)
773
+ const nodeStartedAt = nodeRun.startedAt
774
+ const executionTimeMs = nodeStartedAt ? Date.now() - new Date(nodeStartedAt).getTime() : 0
775
+ qualityMetricsService
776
+ .recordNodeMetrics({
777
+ organizationId: orgId,
778
+ runId: runIdStr,
779
+ nodeId: params.nodeId,
780
+ metrics: {
781
+ executionTimeMs: Math.max(0, executionTimeMs),
782
+ attemptCount: nodeRun.attemptCount + 1,
783
+ artifactCount: params.result.artifacts.length,
784
+ validationIssueCount: validation.blocking.length + validation.warnings.length,
785
+ ownerRef: nodeSpec.owner.ref,
786
+ ownerType: nodeSpec.owner.executorType,
787
+ nodeType: nodeSpec.type,
788
+ },
789
+ })
790
+ .catch((error) => {
791
+ aiLogger.warn`Failed to record node quality metrics for run ${runIdStr} node ${params.nodeId}: ${error instanceof Error ? error.message : String(error)}`
792
+ })
793
+
794
+ // If the run just completed, record cycle metrics, persist feedback recommendations, and extract patterns
795
+ const updatedRun = await planRunService.getRunById(run.id)
796
+ if (updatedRun.status === 'completed') {
797
+ qualityMetricsService.recordCycleMetrics({ organizationId: orgId, runId: runIdStr }).catch((error) => {
798
+ aiLogger.warn`Failed to record cycle quality metrics for run ${runIdStr}: ${error instanceof Error ? error.message : String(error)}`
799
+ })
800
+ feedbackLoopService
801
+ .analyzeOutcomes({ runId: runIdStr, organizationId: orgId })
802
+ .then(async (recommendations) => {
803
+ if (recommendations.length === 0) return
804
+ const specRecord = await planRunService.getPlanSpecById(updatedRun.planSpecId)
805
+ await databaseService.create(
806
+ TABLES.PLAN_EVENT,
807
+ {
808
+ planSpecId: ensureRecordId(specRecord.id, TABLES.PLAN_SPEC),
809
+ runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
810
+ eventType: 'feedback-analyzed',
811
+ message: `Feedback analysis produced ${recommendations.length} recommendation(s).`,
812
+ detail: { recommendations },
813
+ emittedBy: 'system',
814
+ },
815
+ PlanEventSchema,
816
+ )
817
+ })
818
+ .catch((error) => {
819
+ aiLogger.warn`Failed to analyze feedback outcomes for run ${runIdStr}: ${error instanceof Error ? error.message : String(error)}`
820
+ })
821
+ institutionalMemoryService.extractPatterns({ organizationId: orgId, runId: runIdStr }).catch((error) => {
822
+ aiLogger.warn`Failed to extract institutional memory patterns for run ${runIdStr}: ${error instanceof Error ? error.message : String(error)}`
823
+ })
824
+ }
825
+
826
+ const snapshot = await planRunService.toSerializablePlan(updatedRun, {
755
827
  includeEvents: true,
756
828
  includeArtifacts: true,
757
829
  includeApprovals: true,
@@ -992,7 +1064,7 @@ class PlanExecutorService {
992
1064
  await this.attachCheckpoint(tx, synced.run, checkpoint)
993
1065
  })
994
1066
 
995
- return await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
1067
+ return planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
996
1068
  includeEvents: true,
997
1069
  includeArtifacts: true,
998
1070
  includeApprovals: true,
@@ -1099,6 +1171,176 @@ class PlanExecutorService {
1099
1171
  })
1100
1172
  }
1101
1173
 
1174
+ async transitionNodeToRunning(params: { runId: string; nodeId: string }): Promise<void> {
1175
+ const run = await planRunService.getRunById(params.runId)
1176
+ const nodeRun = await planRunService.getNodeRunByNodeId(run.id, params.nodeId)
1177
+ if (nodeRun.status !== 'ready') return
1178
+
1179
+ await databaseService.withTransaction(async (tx) => {
1180
+ const runningNodeRun = PlanNodeRunSchema.parse(
1181
+ await tx
1182
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1183
+ .content(toNodeRunData(nodeRun, { status: 'running', startedAt: nodeRun.startedAt ?? new Date() }))
1184
+ .output('after'),
1185
+ )
1186
+
1187
+ const nodeRuns = await planRunService.listNodeRuns(run.id)
1188
+ await this.replaceRun(tx, run, {
1189
+ status: 'running',
1190
+ currentNodeId: runningNodeRun.nodeId,
1191
+ waitingNodeId: null,
1192
+ readyNodeIds: nodeRuns
1193
+ .filter((candidate) => candidate.status === 'ready' && candidate.nodeId !== runningNodeRun.nodeId)
1194
+ .map((candidate) => candidate.nodeId),
1195
+ })
1196
+ })
1197
+ }
1198
+
1199
+ async blockNodeOnDispatchFailure(params: {
1200
+ workstreamId: RecordIdInput
1201
+ runId: string
1202
+ nodeId: string
1203
+ emittedBy: string
1204
+ message: string
1205
+ failureClass: PlanFailureClass
1206
+ }): Promise<SerializableExecutionPlan> {
1207
+ const run = await planRunService.getRunById(params.runId)
1208
+ if (
1209
+ recordIdToString(run.workstreamId, TABLES.WORKSTREAM) !== recordIdToString(params.workstreamId, TABLES.WORKSTREAM)
1210
+ ) {
1211
+ throw new Error('Execution run belongs to a different workstream.')
1212
+ }
1213
+
1214
+ const spec = await planRunService.getPlanSpecById(run.planSpecId)
1215
+ const nodeSpec = await planRunService.getNodeSpecByNodeId(spec.id, params.nodeId)
1216
+ const nodeRun = await planRunService.getNodeRunByNodeId(run.id, params.nodeId)
1217
+ const artifacts = await planRunService.listArtifacts(run.id)
1218
+ const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
1219
+
1220
+ await databaseService.withTransaction(async (tx) => {
1221
+ const blockedNodeRun =
1222
+ nodeRun.status === 'blocked'
1223
+ ? nodeRun
1224
+ : PlanNodeRunSchema.parse(
1225
+ await tx
1226
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1227
+ .content(
1228
+ toNodeRunData(nodeRun, {
1229
+ status: 'blocked',
1230
+ blockedReason: params.message,
1231
+ failureClass: params.failureClass,
1232
+ }),
1233
+ )
1234
+ .output('after'),
1235
+ )
1236
+
1237
+ const blockedRun = await this.replaceRun(tx, run, {
1238
+ status: 'blocked',
1239
+ currentNodeId: blockedNodeRun.nodeId,
1240
+ waitingNodeId: null,
1241
+ readyNodeIds: [],
1242
+ failureCount: run.failureCount + 1,
1243
+ })
1244
+
1245
+ await this.emitEvent({
1246
+ tx,
1247
+ run: blockedRun,
1248
+ spec,
1249
+ nodeId: blockedNodeRun.nodeId,
1250
+ eventType: 'node-blocked',
1251
+ fromStatus: nodeRun.status,
1252
+ toStatus: blockedNodeRun.status,
1253
+ message: `Node "${nodeSpec.label}" failed during owner dispatch.`,
1254
+ detail: { failureClass: params.failureClass, phase: 'dispatch', error: params.message },
1255
+ emittedBy: params.emittedBy,
1256
+ })
1257
+
1258
+ const checkpoint = await this.saveCheckpoint({
1259
+ tx,
1260
+ run: blockedRun,
1261
+ nodeRuns: (await planRunService.listNodeRuns(run.id)).map((candidate) =>
1262
+ candidate.nodeId === blockedNodeRun.nodeId ? blockedNodeRun : candidate,
1263
+ ),
1264
+ artifacts,
1265
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
1266
+ reason: 'owner-dispatch-failed',
1267
+ })
1268
+ await this.attachCheckpoint(tx, blockedRun, checkpoint)
1269
+ })
1270
+
1271
+ return planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
1272
+ includeEvents: true,
1273
+ includeArtifacts: true,
1274
+ includeApprovals: true,
1275
+ includeCheckpoints: true,
1276
+ includeValidationIssues: true,
1277
+ })
1278
+ }
1279
+
1280
+ async promoteDelayedNode(params: { runId: string; nodeId: string; emittedBy: string }): Promise<void> {
1281
+ const run = await planRunService.getRunById(params.runId)
1282
+ if (run.status === 'completed' || run.status === 'failed' || run.status === 'aborted') {
1283
+ return // Run is no longer active, skip promotion
1284
+ }
1285
+
1286
+ const spec = await planRunService.getPlanSpecById(run.planSpecId)
1287
+ const nodeSpecs = await planRunService.listNodeSpecs(spec.id)
1288
+ const nodeRun = await planRunService.getNodeRunByNodeId(run.id, params.nodeId)
1289
+
1290
+ // Only promote if still in scheduled state (delay hasn't been superseded)
1291
+ if (nodeRun.status !== 'scheduled') return
1292
+
1293
+ const nodeRuns = await planRunService.listNodeRuns(run.id)
1294
+ const artifacts = await planRunService.listArtifacts(run.id)
1295
+ const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
1296
+
1297
+ await databaseService.withTransaction(async (tx) => {
1298
+ const readyNodeRun = PlanNodeRunSchema.parse(
1299
+ await tx
1300
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1301
+ .content(toNodeRunData(nodeRun, { status: 'ready', readyAt: new Date() }))
1302
+ .output('after'),
1303
+ )
1304
+
1305
+ const updatedNodeRuns = nodeRuns.map((candidate) =>
1306
+ candidate.nodeId === readyNodeRun.nodeId ? readyNodeRun : candidate,
1307
+ )
1308
+
1309
+ const nodeSpec = nodeSpecs.find((s) => s.nodeId === params.nodeId)
1310
+ await this.emitEvent({
1311
+ tx,
1312
+ run,
1313
+ spec,
1314
+ nodeId: readyNodeRun.nodeId,
1315
+ eventType: 'node-ready',
1316
+ fromStatus: nodeRun.status,
1317
+ toStatus: readyNodeRun.status,
1318
+ message: `Node "${nodeSpec?.label ?? params.nodeId}" promoted to ready after delay.`,
1319
+ emittedBy: params.emittedBy,
1320
+ })
1321
+
1322
+ const synced = await this.syncRunGraph({
1323
+ tx,
1324
+ run,
1325
+ spec,
1326
+ nodeSpecs,
1327
+ nodeRuns: updatedNodeRuns,
1328
+ artifacts,
1329
+ emittedBy: params.emittedBy,
1330
+ })
1331
+
1332
+ const checkpoint = await this.saveCheckpoint({
1333
+ tx,
1334
+ run: synced.run,
1335
+ nodeRuns: synced.nodeRuns,
1336
+ artifacts: synced.artifacts,
1337
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
1338
+ reason: 'delayed-node-promoted',
1339
+ })
1340
+ await this.attachCheckpoint(tx, synced.run, checkpoint)
1341
+ })
1342
+ }
1343
+
1102
1344
  async syncRunGraph(params: {
1103
1345
  tx: DatabaseTransaction
1104
1346
  run: PlanRunRecord
@@ -1133,6 +1375,28 @@ class PlanExecutorService {
1133
1375
  const currentArtifacts = [...params.artifacts]
1134
1376
  const sortedNodeSpecs = [...params.nodeSpecs].sort((left, right) => left.position - right.position)
1135
1377
 
1378
+ // Cross-plan dependency check: if spec has block-mode dependencies that are unresolved, block the run
1379
+ if (params.spec.dependencies && params.spec.dependencies.length > 0) {
1380
+ const { unresolved } = await planCoordinationService.resolveDependencies({
1381
+ dependencies: params.spec.dependencies,
1382
+ workstreamId: recordIdToString(params.spec.workstreamId, TABLES.WORKSTREAM),
1383
+ })
1384
+ if (unresolved.length > 0) {
1385
+ currentRun = await this.replaceRun(params.tx, currentRun, { status: 'blocked', readyNodeIds: [] })
1386
+ await this.emitEvent({
1387
+ tx: params.tx,
1388
+ run: currentRun,
1389
+ spec: params.spec,
1390
+ eventType: 'run-status-changed',
1391
+ fromStatus: params.run.status,
1392
+ toStatus: currentRun.status,
1393
+ message: `Run blocked: unresolved cross-plan dependencies (${unresolved.map((d) => d.sourcePlanTitle).join(', ')}).`,
1394
+ emittedBy: params.emittedBy,
1395
+ })
1396
+ return { run: currentRun, nodeRuns: currentNodeRuns, artifacts: currentArtifacts }
1397
+ }
1398
+ }
1399
+
1136
1400
  const replaceNodeRun = (nextNodeRun: PlanNodeRunRecord) => {
1137
1401
  currentNodeRuns = currentNodeRuns.map((candidate) =>
1138
1402
  candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
@@ -1207,25 +1471,89 @@ class PlanExecutorService {
1207
1471
  }
1208
1472
 
1209
1473
  const resolvedInput = this.buildResolvedInput({ spec: params.spec, nodeSpec, nodeRunsById, artifactsByNodeId })
1210
- const readyNodeRun = PlanNodeRunSchema.parse(
1211
- await params.tx
1212
- .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1213
- .content(toNodeRunData(nodeRun, { status: 'ready', resolvedInput, readyAt: new Date() }))
1214
- .output('after'),
1215
- )
1216
- replaceNodeRun(readyNodeRun)
1217
- await this.emitEvent({
1218
- tx: params.tx,
1219
- run: currentRun,
1220
- spec: params.spec,
1221
- nodeId: readyNodeRun.nodeId,
1222
- eventType: 'node-ready',
1223
- fromStatus: nodeRun.status,
1224
- toStatus: readyNodeRun.status,
1225
- message: `Node "${nodeSpec.label}" is ready to execute.`,
1226
- emittedBy: params.emittedBy,
1227
- })
1228
- changed = true
1474
+
1475
+ const nodeSchedule = nodeSpec.schedule
1476
+ const hasNonImmediateSchedule = nodeSchedule && nodeSchedule.type !== 'immediate'
1477
+
1478
+ if (hasNonImmediateSchedule) {
1479
+ const scheduledNodeRun = PlanNodeRunSchema.parse(
1480
+ await params.tx
1481
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1482
+ .content(toNodeRunData(nodeRun, { status: 'scheduled', resolvedInput, scheduledAt: new Date() }))
1483
+ .output('after'),
1484
+ )
1485
+ replaceNodeRun(scheduledNodeRun)
1486
+ await planSchedulerService.createSchedule({
1487
+ organizationId: currentRun.organizationId,
1488
+ workstreamId: currentRun.workstreamId,
1489
+ planSpecId: params.spec.id,
1490
+ runId: currentRun.id,
1491
+ nodeId: nodeSpec.nodeId,
1492
+ scheduleSpec: nodeSchedule,
1493
+ })
1494
+ await this.emitEvent({
1495
+ tx: params.tx,
1496
+ run: currentRun,
1497
+ spec: params.spec,
1498
+ nodeId: scheduledNodeRun.nodeId,
1499
+ eventType: 'node-scheduled',
1500
+ fromStatus: nodeRun.status,
1501
+ toStatus: scheduledNodeRun.status,
1502
+ message: `Node "${nodeSpec.label}" is scheduled (${nodeSchedule.type}).`,
1503
+ emittedBy: params.emittedBy,
1504
+ })
1505
+ changed = true
1506
+ } else if (nodeSpec.delayAfterPredecessorMs) {
1507
+ // Event-triggered delay: enqueue a delayed promotion instead of transitioning to ready immediately
1508
+ const { enqueueDelayedNodePromotion } = await import('../queues/delayed-node-promotion.queue')
1509
+ const scheduledNodeRun = PlanNodeRunSchema.parse(
1510
+ await params.tx
1511
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1512
+ .content(toNodeRunData(nodeRun, { status: 'scheduled', resolvedInput, scheduledAt: new Date() }))
1513
+ .output('after'),
1514
+ )
1515
+ replaceNodeRun(scheduledNodeRun)
1516
+ await enqueueDelayedNodePromotion(
1517
+ {
1518
+ runId: recordIdToString(currentRun.id, TABLES.PLAN_RUN),
1519
+ nodeId: nodeSpec.nodeId,
1520
+ emittedBy: params.emittedBy,
1521
+ },
1522
+ nodeSpec.delayAfterPredecessorMs,
1523
+ )
1524
+ await this.emitEvent({
1525
+ tx: params.tx,
1526
+ run: currentRun,
1527
+ spec: params.spec,
1528
+ nodeId: scheduledNodeRun.nodeId,
1529
+ eventType: 'node-scheduled',
1530
+ fromStatus: nodeRun.status,
1531
+ toStatus: scheduledNodeRun.status,
1532
+ message: `Node "${nodeSpec.label}" is delayed by ${nodeSpec.delayAfterPredecessorMs}ms after predecessor.`,
1533
+ emittedBy: params.emittedBy,
1534
+ })
1535
+ changed = true
1536
+ } else {
1537
+ const readyNodeRun = PlanNodeRunSchema.parse(
1538
+ await params.tx
1539
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1540
+ .content(toNodeRunData(nodeRun, { status: 'ready', resolvedInput, readyAt: new Date() }))
1541
+ .output('after'),
1542
+ )
1543
+ replaceNodeRun(readyNodeRun)
1544
+ await this.emitEvent({
1545
+ tx: params.tx,
1546
+ run: currentRun,
1547
+ spec: params.spec,
1548
+ nodeId: readyNodeRun.nodeId,
1549
+ eventType: 'node-ready',
1550
+ fromStatus: nodeRun.status,
1551
+ toStatus: readyNodeRun.status,
1552
+ message: `Node "${nodeSpec.label}" is ready to execute.`,
1553
+ emittedBy: params.emittedBy,
1554
+ })
1555
+ changed = true
1556
+ }
1229
1557
  }
1230
1558
 
1231
1559
  const readyStructuralNodes = sortedNodeSpecs.filter((nodeSpec) => {
@@ -1268,8 +1596,12 @@ class PlanExecutorService {
1268
1596
  const nodeRunsById = getNodeRunsById()
1269
1597
  const activeRunningNode = currentNodeRuns.find((nodeRun) => nodeRun.status === 'running')
1270
1598
  const activeHumanNode = currentNodeRuns.find((nodeRun) => nodeRun.status === 'awaiting-human')
1599
+ const activeMonitoringNode = currentNodeRuns.find((nodeRun) => nodeRun.status === 'monitoring')
1600
+ const hasScheduledOrMonitoring = currentNodeRuns.some(
1601
+ (nodeRun) => nodeRun.status === 'scheduled' || nodeRun.status === 'monitoring',
1602
+ )
1271
1603
 
1272
- if (!activeRunningNode && !activeHumanNode) {
1604
+ if (!activeRunningNode && !activeHumanNode && !activeMonitoringNode) {
1273
1605
  const nextHumanNodeSpec = sortedNodeSpecs.find((nodeSpec) => {
1274
1606
  const nodeRun = nodeRunsById.get(nodeSpec.nodeId)
1275
1607
  return nodeRun?.status === 'ready' && isHumanNodeType(nodeSpec.type)
@@ -1384,6 +1716,16 @@ class PlanExecutorService {
1384
1716
  message: `Run "${params.spec.title}" completed.`,
1385
1717
  emittedBy: params.emittedBy,
1386
1718
  })
1719
+ } else if (hasScheduledOrMonitoring) {
1720
+ // Nodes are waiting on schedules/monitors — run stays active
1721
+ currentRun = await this.replaceRun(params.tx, currentRun, {
1722
+ status: 'running',
1723
+ currentNodeId: null,
1724
+ waitingNodeId: null,
1725
+ readyNodeIds: currentNodeRuns
1726
+ .filter((candidate) => candidate.status === 'ready')
1727
+ .map((candidate) => candidate.nodeId),
1728
+ })
1387
1729
  } else {
1388
1730
  currentRun = await this.replaceRun(params.tx, currentRun, {
1389
1731
  status: 'blocked',
@@ -1398,7 +1740,7 @@ class PlanExecutorService {
1398
1740
  } else {
1399
1741
  currentRun = await this.replaceRun(params.tx, currentRun, {
1400
1742
  status: activeHumanNode ? 'awaiting-human' : 'running',
1401
- currentNodeId: activeHumanNode?.nodeId ?? activeRunningNode?.nodeId ?? null,
1743
+ currentNodeId: activeHumanNode?.nodeId ?? activeMonitoringNode?.nodeId ?? activeRunningNode?.nodeId ?? null,
1402
1744
  waitingNodeId: activeHumanNode?.nodeId ?? null,
1403
1745
  readyNodeIds: currentNodeRuns
1404
1746
  .filter((candidate) => candidate.status === 'ready')
@@ -1556,6 +1898,7 @@ class PlanExecutorService {
1556
1898
  artifacts: Array<{ id: RecordIdInput; nodeId: string }>
1557
1899
  sequence: number
1558
1900
  reason: string
1901
+ includeWorkspace?: boolean
1559
1902
  }) {
1560
1903
  const checkpoint = await planCheckpointService.createCheckpoint({
1561
1904
  tx: params.tx,
@@ -1576,6 +1919,7 @@ class PlanExecutorService {
1576
1919
  readyNodeIds: params.run.readyNodeIds,
1577
1920
  nodeStatuses: Object.fromEntries(params.nodeRuns.map((nodeRun) => [nodeRun.nodeId, nodeRun.status])),
1578
1921
  },
1922
+ includeWorkspace: params.includeWorkspace,
1579
1923
  })
1580
1924
 
1581
1925
  await this.emitEvent({