@lota-sdk/core 0.1.14 → 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 (174) 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 +9 -8
  9. package/src/ai/definitions.ts +80 -2
  10. package/src/ai/embedding-cache.ts +7 -6
  11. package/src/ai/index.ts +0 -1
  12. package/src/bifrost/bifrost.ts +14 -14
  13. package/src/config/agent-defaults.ts +32 -22
  14. package/src/config/agent-types.ts +11 -0
  15. package/src/config/constants.ts +2 -14
  16. package/src/config/debug-logger.ts +5 -1
  17. package/src/config/index.ts +3 -0
  18. package/src/config/logger.ts +7 -9
  19. package/src/config/model-constants.ts +16 -34
  20. package/src/config/search.ts +1 -15
  21. package/src/create-runtime.ts +453 -0
  22. package/src/db/cursor-pagination.ts +3 -6
  23. package/src/db/index.ts +2 -0
  24. package/src/db/memory-store.rows.ts +7 -7
  25. package/src/db/memory-store.ts +24 -24
  26. package/src/db/memory.ts +18 -16
  27. package/src/db/schema-fingerprint.ts +1 -0
  28. package/src/db/service.ts +193 -122
  29. package/src/db/startup.ts +9 -13
  30. package/src/db/surreal-mutation.ts +43 -0
  31. package/src/db/tables.ts +7 -0
  32. package/src/db/workstream-message-row.ts +15 -0
  33. package/src/embeddings/provider.ts +1 -1
  34. package/src/index.ts +1 -1
  35. package/src/queues/context-compaction.queue.ts +17 -52
  36. package/src/queues/delayed-node-promotion.queue.ts +41 -0
  37. package/src/queues/document-processor.queue.ts +7 -7
  38. package/src/queues/index.ts +3 -0
  39. package/src/queues/memory-consolidation.queue.ts +18 -54
  40. package/src/queues/plan-scheduler.queue.ts +97 -0
  41. package/src/queues/post-chat-memory.queue.ts +15 -60
  42. package/src/queues/queue-factory.ts +100 -0
  43. package/src/queues/recent-activity-title-refinement.queue.ts +15 -54
  44. package/src/queues/regular-chat-memory-digest.queue.ts +16 -55
  45. package/src/queues/skill-extraction.queue.ts +15 -50
  46. package/src/queues/workstream-title-generation.queue.ts +15 -51
  47. package/src/redis/connection.ts +12 -3
  48. package/src/redis/index.ts +2 -1
  49. package/src/redis/org-memory-lock.ts +1 -1
  50. package/src/redis/redis-lease-lock.ts +41 -8
  51. package/src/redis/stream-context.ts +11 -0
  52. package/src/runtime/agent-runtime-policy.ts +106 -21
  53. package/src/runtime/agent-stream-helpers.ts +2 -1
  54. package/src/runtime/approval-continuation.ts +12 -6
  55. package/src/runtime/context-compaction-constants.ts +1 -1
  56. package/src/runtime/context-compaction-runtime.ts +7 -5
  57. package/src/runtime/context-compaction.ts +40 -97
  58. package/src/runtime/execution-plan.ts +23 -19
  59. package/src/runtime/graph-designer.ts +15 -0
  60. package/src/runtime/helper-model.ts +10 -196
  61. package/src/runtime/index.ts +14 -1
  62. package/src/runtime/llm-content.ts +1 -1
  63. package/src/runtime/memory-block.ts +11 -12
  64. package/src/runtime/memory-pipeline.ts +26 -10
  65. package/src/runtime/plugin-resolution.ts +35 -0
  66. package/src/runtime/plugin-types.ts +73 -1
  67. package/src/runtime/retrieval-adapters.ts +1 -1
  68. package/src/runtime/runtime-config.ts +25 -12
  69. package/src/runtime/runtime-extensions.ts +91 -15
  70. package/src/runtime/runtime-worker-registry.ts +6 -0
  71. package/src/runtime/team-consultation-orchestrator.ts +45 -28
  72. package/src/runtime/team-consultation-prompts.ts +11 -2
  73. package/src/runtime/title-helpers.ts +11 -4
  74. package/src/runtime/workstream-chat-helpers.ts +6 -7
  75. package/src/runtime/workstream-routing-policy.ts +0 -30
  76. package/src/runtime/workstream-state.ts +17 -7
  77. package/src/services/adaptive-playbook.service.ts +152 -0
  78. package/src/services/agent-executor.service.ts +293 -0
  79. package/src/services/artifact-provenance.service.ts +172 -0
  80. package/src/services/attachment.service.ts +7 -12
  81. package/src/services/context-compaction.service.ts +75 -58
  82. package/src/services/context-enrichment.service.ts +33 -0
  83. package/src/services/coordination-registry.service.ts +117 -0
  84. package/src/services/document-chunk.service.ts +38 -33
  85. package/src/services/domain-agent-executor.service.ts +71 -0
  86. package/src/services/execution-plan.service.ts +271 -50
  87. package/src/services/feedback-loop.service.ts +96 -0
  88. package/src/services/global-orchestrator.service.ts +148 -0
  89. package/src/services/index.ts +26 -0
  90. package/src/services/institutional-memory.service.ts +145 -0
  91. package/src/services/learned-skill.service.ts +30 -15
  92. package/src/services/memory-assessment.service.ts +3 -2
  93. package/src/services/{memory.utils.ts → memory-utils.ts} +4 -13
  94. package/src/services/memory.service.ts +55 -69
  95. package/src/services/monitoring-window.service.ts +86 -0
  96. package/src/services/mutating-approval.service.ts +1 -1
  97. package/src/services/node-workspace.service.ts +155 -0
  98. package/src/services/notification.service.ts +39 -0
  99. package/src/services/organization-member.service.ts +12 -5
  100. package/src/services/organization.service.ts +5 -5
  101. package/src/services/ownership-dispatcher.service.ts +403 -0
  102. package/src/services/plan-approval.service.ts +1 -1
  103. package/src/services/plan-artifact.service.ts +1 -0
  104. package/src/services/plan-builder.service.ts +1 -0
  105. package/src/services/plan-checkpoint.service.ts +30 -2
  106. package/src/services/plan-compiler.service.ts +5 -0
  107. package/src/services/plan-coordination.service.ts +152 -0
  108. package/src/services/plan-cycle.service.ts +284 -0
  109. package/src/services/plan-deadline.service.ts +287 -0
  110. package/src/services/plan-executor.service.ts +386 -58
  111. package/src/services/plan-helpers.ts +15 -0
  112. package/src/services/plan-run.service.ts +41 -7
  113. package/src/services/plan-scheduler.service.ts +240 -0
  114. package/src/services/plan-template.service.ts +117 -0
  115. package/src/services/plan-validator.service.ts +87 -20
  116. package/src/services/plan-workspace.service.ts +83 -0
  117. package/src/services/playbook-registry.service.ts +67 -0
  118. package/src/services/plugin-executor.service.ts +103 -0
  119. package/src/services/quality-metrics.service.ts +132 -0
  120. package/src/services/recent-activity-title.service.ts +3 -10
  121. package/src/services/recent-activity.service.ts +33 -43
  122. package/src/services/skill-resolver.service.ts +19 -0
  123. package/src/services/system-executor.service.ts +105 -0
  124. package/src/services/workstream-message.service.ts +29 -41
  125. package/src/services/workstream-plan-registry.service.ts +22 -0
  126. package/src/services/workstream-title.service.ts +3 -9
  127. package/src/services/{workstream-turn-preparation.ts → workstream-turn-preparation.service.ts} +428 -373
  128. package/src/services/workstream-turn.ts +2 -2
  129. package/src/services/workstream.service.ts +55 -65
  130. package/src/services/workstream.types.ts +10 -19
  131. package/src/services/write-intent-validator.service.ts +81 -0
  132. package/src/storage/attachment-parser.ts +1 -1
  133. package/src/storage/attachment-storage.service.ts +4 -4
  134. package/src/storage/{attachments.utils.ts → attachment-utils.ts} +2 -5
  135. package/src/storage/generated-document-storage.service.ts +3 -2
  136. package/src/storage/index.ts +2 -2
  137. package/src/system-agents/{context-compacter.agent.ts → context-compaction.agent.ts} +4 -4
  138. package/src/system-agents/delegated-agent-factory.ts +5 -2
  139. package/src/system-agents/index.ts +8 -0
  140. package/src/system-agents/memory-reranker.agent.ts +1 -1
  141. package/src/system-agents/memory.agent.ts +1 -1
  142. package/src/system-agents/recent-activity-title-refiner.agent.ts +1 -1
  143. package/src/tools/execution-plan.tool.ts +17 -19
  144. package/src/tools/fetch-webpage.tool.ts +20 -18
  145. package/src/tools/index.ts +2 -3
  146. package/src/tools/read-file-parts.tool.ts +1 -1
  147. package/src/tools/search-web.tool.ts +18 -15
  148. package/src/tools/{search-tools.ts → search.tool.ts} +1 -1
  149. package/src/tools/team-think.tool.ts +14 -8
  150. package/src/tools/{tool-contract.ts → tool-contracts.ts} +9 -2
  151. package/src/utils/async.ts +3 -2
  152. package/src/utils/date-time.ts +4 -32
  153. package/src/utils/env.ts +8 -0
  154. package/src/utils/errors.ts +47 -0
  155. package/src/utils/hono-error-handler.ts +1 -2
  156. package/src/utils/index.ts +19 -2
  157. package/src/utils/string.ts +128 -1
  158. package/src/workers/bootstrap.ts +2 -2
  159. package/src/workers/index.ts +1 -0
  160. package/src/workers/memory-consolidation.worker.ts +12 -12
  161. package/src/workers/regular-chat-memory-digest.helpers.ts +2 -7
  162. package/src/workers/regular-chat-memory-digest.runner.ts +11 -105
  163. package/src/workers/skill-extraction.runner.ts +8 -102
  164. package/src/workers/utils/file-section-chunker.ts +6 -3
  165. package/src/workers/utils/repomix-file-sections.ts +2 -2
  166. package/src/workers/utils/sandbox-error.ts +11 -2
  167. package/src/workers/utils/workstream-message-query.ts +97 -0
  168. package/src/workers/worker-utils.ts +6 -2
  169. package/src/runtime/retrieval-pipeline.ts +0 -3
  170. package/src/runtime.ts +0 -387
  171. package/src/tools/log-hello-world.tool.ts +0 -17
  172. package/src/utils/error.ts +0 -10
  173. /package/src/services/{context-compaction-runtime.ts → context-compaction-runtime.singleton.ts} +0 -0
  174. /package/src/storage/{attachments.types.ts → attachment-types.ts} +0 -0
@@ -21,39 +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'
31
+ import { isRecord } from '../utils/string'
32
+ import { feedbackLoopService } from './feedback-loop.service'
33
+ import { institutionalMemoryService } from './institutional-memory.service'
29
34
  import { planApprovalService } from './plan-approval.service'
30
35
  import { planArtifactService } from './plan-artifact.service'
31
36
  import { planCheckpointService } from './plan-checkpoint.service'
37
+ import { planCoordinationService } from './plan-coordination.service'
38
+ import { readPathValue } from './plan-helpers'
32
39
  import { planRunService } from './plan-run.service'
40
+ import { planSchedulerService } from './plan-scheduler.service'
33
41
  import type { PlanValidationIssueInput } from './plan-validator.service'
34
42
  import { planValidatorService } from './plan-validator.service'
43
+ import { qualityMetricsService } from './quality-metrics.service'
35
44
 
36
- const SUCCESSFUL_TERMINAL_NODE_STATUSES = new Set(['completed', 'partial', 'skipped'])
45
+ const SUCCESSFUL_TERMINAL_NODE_STATUSES = new Set(['completed', 'partial', 'skipped', 'scheduled', 'monitoring'])
37
46
  const HUMAN_NODE_TYPES = new Set(['human-input', 'human-approval', 'human-review-edit', 'human-decision'])
38
- const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join'])
39
-
40
- function isRecord(value: unknown): value is Record<string, unknown> {
41
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
42
- }
43
-
44
- function readPathValue(source: unknown, path: string): unknown {
45
- if (!path.trim()) return source
46
-
47
- let current: unknown = source
48
- for (const segment of path
49
- .split('.')
50
- .map((part) => part.trim())
51
- .filter(Boolean)) {
52
- if (!isRecord(current)) return undefined
53
- current = current[segment]
54
- }
55
- return current
56
- }
47
+ const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join', 'deliberation-fork'])
57
48
 
58
49
  function setPathValue(target: Record<string, unknown>, path: string, value: unknown) {
59
50
  const segments = path
@@ -162,8 +153,8 @@ type PlanRunUpdate = Omit<
162
153
  waitingNodeId?: string | null
163
154
  replacedRunId?: RecordIdInput | null
164
155
  lastCheckpointId?: RecordIdInput | null
165
- startedAt?: Date | null
166
- completedAt?: Date | null
156
+ startedAt?: string | Date | null
157
+ completedAt?: string | Date | null
167
158
  }
168
159
 
169
160
  type PlanNodeRunUpdate = Omit<
@@ -174,6 +165,7 @@ type PlanNodeRunUpdate = Omit<
174
165
  | 'latestStructuredOutput'
175
166
  | 'latestNotes'
176
167
  | 'latestAttemptId'
168
+ | 'scheduledAt'
177
169
  | 'readyAt'
178
170
  | 'startedAt'
179
171
  | 'completedAt'
@@ -184,9 +176,10 @@ type PlanNodeRunUpdate = Omit<
184
176
  latestStructuredOutput?: Record<string, unknown> | null
185
177
  latestNotes?: string | null
186
178
  latestAttemptId?: RecordIdInput | null
187
- readyAt?: Date | null
188
- startedAt?: Date | null
189
- completedAt?: Date | null
179
+ scheduledAt?: string | Date | null
180
+ readyAt?: string | Date | null
181
+ startedAt?: string | Date | null
182
+ completedAt?: string | Date | null
190
183
  }
191
184
 
192
185
  function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
@@ -229,16 +222,16 @@ function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
229
222
  ...(patch.startedAt === null
230
223
  ? {}
231
224
  : patch.startedAt !== undefined
232
- ? { startedAt: patch.startedAt }
225
+ ? { startedAt: toDatabaseDateTime(patch.startedAt) }
233
226
  : run.startedAt
234
- ? { startedAt: run.startedAt }
227
+ ? { startedAt: toDatabaseDateTime(run.startedAt) }
235
228
  : {}),
236
229
  ...(patch.completedAt === null
237
230
  ? {}
238
231
  : patch.completedAt !== undefined
239
- ? { completedAt: patch.completedAt }
232
+ ? { completedAt: toDatabaseDateTime(patch.completedAt) }
240
233
  : run.completedAt
241
- ? { completedAt: run.completedAt }
234
+ ? { completedAt: toDatabaseDateTime(run.completedAt) }
242
235
  : {}),
243
236
  }
244
237
  }
@@ -293,26 +286,33 @@ function toNodeRunData(nodeRun: PlanNodeRunRecord, patch: PlanNodeRunUpdate) {
293
286
  : nodeRun.failureClass
294
287
  ? { failureClass: nodeRun.failureClass }
295
288
  : {}),
289
+ ...(patch.scheduledAt === null
290
+ ? {}
291
+ : patch.scheduledAt !== undefined
292
+ ? { scheduledAt: toDatabaseDateTime(patch.scheduledAt) }
293
+ : nodeRun.scheduledAt
294
+ ? { scheduledAt: toDatabaseDateTime(nodeRun.scheduledAt) }
295
+ : {}),
296
296
  ...(patch.readyAt === null
297
297
  ? {}
298
298
  : patch.readyAt !== undefined
299
- ? { readyAt: patch.readyAt }
299
+ ? { readyAt: toDatabaseDateTime(patch.readyAt) }
300
300
  : nodeRun.readyAt
301
- ? { readyAt: nodeRun.readyAt }
301
+ ? { readyAt: toDatabaseDateTime(nodeRun.readyAt) }
302
302
  : {}),
303
303
  ...(patch.startedAt === null
304
304
  ? {}
305
305
  : patch.startedAt !== undefined
306
- ? { startedAt: patch.startedAt }
306
+ ? { startedAt: toDatabaseDateTime(patch.startedAt) }
307
307
  : nodeRun.startedAt
308
- ? { startedAt: nodeRun.startedAt }
308
+ ? { startedAt: toDatabaseDateTime(nodeRun.startedAt) }
309
309
  : {}),
310
310
  ...(patch.completedAt === null
311
311
  ? {}
312
312
  : patch.completedAt !== undefined
313
- ? { completedAt: patch.completedAt }
313
+ ? { completedAt: toDatabaseDateTime(patch.completedAt) }
314
314
  : nodeRun.completedAt
315
- ? { completedAt: nodeRun.completedAt }
315
+ ? { completedAt: toDatabaseDateTime(nodeRun.completedAt) }
316
316
  : {}),
317
317
  }
318
318
  }
@@ -767,7 +767,63 @@ class PlanExecutorService {
767
767
  await this.attachCheckpoint(tx, synced.run, checkpoint)
768
768
  })
769
769
 
770
- 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, {
771
827
  includeEvents: true,
772
828
  includeArtifacts: true,
773
829
  includeApprovals: true,
@@ -1008,7 +1064,7 @@ class PlanExecutorService {
1008
1064
  await this.attachCheckpoint(tx, synced.run, checkpoint)
1009
1065
  })
1010
1066
 
1011
- return await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
1067
+ return planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
1012
1068
  includeEvents: true,
1013
1069
  includeArtifacts: true,
1014
1070
  includeApprovals: true,
@@ -1115,6 +1171,176 @@ class PlanExecutorService {
1115
1171
  })
1116
1172
  }
1117
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
+
1118
1344
  async syncRunGraph(params: {
1119
1345
  tx: DatabaseTransaction
1120
1346
  run: PlanRunRecord
@@ -1149,6 +1375,28 @@ class PlanExecutorService {
1149
1375
  const currentArtifacts = [...params.artifacts]
1150
1376
  const sortedNodeSpecs = [...params.nodeSpecs].sort((left, right) => left.position - right.position)
1151
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
+
1152
1400
  const replaceNodeRun = (nextNodeRun: PlanNodeRunRecord) => {
1153
1401
  currentNodeRuns = currentNodeRuns.map((candidate) =>
1154
1402
  candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
@@ -1223,25 +1471,89 @@ class PlanExecutorService {
1223
1471
  }
1224
1472
 
1225
1473
  const resolvedInput = this.buildResolvedInput({ spec: params.spec, nodeSpec, nodeRunsById, artifactsByNodeId })
1226
- const readyNodeRun = PlanNodeRunSchema.parse(
1227
- await params.tx
1228
- .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1229
- .content(toNodeRunData(nodeRun, { status: 'ready', resolvedInput, readyAt: new Date() }))
1230
- .output('after'),
1231
- )
1232
- replaceNodeRun(readyNodeRun)
1233
- await this.emitEvent({
1234
- tx: params.tx,
1235
- run: currentRun,
1236
- spec: params.spec,
1237
- nodeId: readyNodeRun.nodeId,
1238
- eventType: 'node-ready',
1239
- fromStatus: nodeRun.status,
1240
- toStatus: readyNodeRun.status,
1241
- message: `Node "${nodeSpec.label}" is ready to execute.`,
1242
- emittedBy: params.emittedBy,
1243
- })
1244
- 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
+ }
1245
1557
  }
1246
1558
 
1247
1559
  const readyStructuralNodes = sortedNodeSpecs.filter((nodeSpec) => {
@@ -1284,8 +1596,12 @@ class PlanExecutorService {
1284
1596
  const nodeRunsById = getNodeRunsById()
1285
1597
  const activeRunningNode = currentNodeRuns.find((nodeRun) => nodeRun.status === 'running')
1286
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
+ )
1287
1603
 
1288
- if (!activeRunningNode && !activeHumanNode) {
1604
+ if (!activeRunningNode && !activeHumanNode && !activeMonitoringNode) {
1289
1605
  const nextHumanNodeSpec = sortedNodeSpecs.find((nodeSpec) => {
1290
1606
  const nodeRun = nodeRunsById.get(nodeSpec.nodeId)
1291
1607
  return nodeRun?.status === 'ready' && isHumanNodeType(nodeSpec.type)
@@ -1400,6 +1716,16 @@ class PlanExecutorService {
1400
1716
  message: `Run "${params.spec.title}" completed.`,
1401
1717
  emittedBy: params.emittedBy,
1402
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
+ })
1403
1729
  } else {
1404
1730
  currentRun = await this.replaceRun(params.tx, currentRun, {
1405
1731
  status: 'blocked',
@@ -1414,7 +1740,7 @@ class PlanExecutorService {
1414
1740
  } else {
1415
1741
  currentRun = await this.replaceRun(params.tx, currentRun, {
1416
1742
  status: activeHumanNode ? 'awaiting-human' : 'running',
1417
- currentNodeId: activeHumanNode?.nodeId ?? activeRunningNode?.nodeId ?? null,
1743
+ currentNodeId: activeHumanNode?.nodeId ?? activeMonitoringNode?.nodeId ?? activeRunningNode?.nodeId ?? null,
1418
1744
  waitingNodeId: activeHumanNode?.nodeId ?? null,
1419
1745
  readyNodeIds: currentNodeRuns
1420
1746
  .filter((candidate) => candidate.status === 'ready')
@@ -1572,6 +1898,7 @@ class PlanExecutorService {
1572
1898
  artifacts: Array<{ id: RecordIdInput; nodeId: string }>
1573
1899
  sequence: number
1574
1900
  reason: string
1901
+ includeWorkspace?: boolean
1575
1902
  }) {
1576
1903
  const checkpoint = await planCheckpointService.createCheckpoint({
1577
1904
  tx: params.tx,
@@ -1592,6 +1919,7 @@ class PlanExecutorService {
1592
1919
  readyNodeIds: params.run.readyNodeIds,
1593
1920
  nodeStatuses: Object.fromEntries(params.nodeRuns.map((nodeRun) => [nodeRun.nodeId, nodeRun.status])),
1594
1921
  },
1922
+ includeWorkspace: params.includeWorkspace,
1595
1923
  })
1596
1924
 
1597
1925
  await this.emitEvent({
@@ -0,0 +1,15 @@
1
+ import { isRecord } from '../utils/string'
2
+
3
+ export function readPathValue(source: unknown, path: string): unknown {
4
+ if (!path.trim()) return source
5
+
6
+ let current: unknown = source
7
+ for (const segment of path
8
+ .split('.')
9
+ .map((part) => part.trim())
10
+ .filter(Boolean)) {
11
+ if (!isRecord(current)) return undefined
12
+ current = current[segment]
13
+ }
14
+ return current
15
+ }