@lota-sdk/core 0.4.8 → 0.4.9

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 (259) hide show
  1. package/package.json +11 -12
  2. package/src/ai/embedding-cache.ts +94 -22
  3. package/src/ai-gateway/ai-gateway.ts +738 -223
  4. package/src/config/agent-defaults.ts +176 -75
  5. package/src/config/agent-types.ts +54 -4
  6. package/src/config/constants.ts +8 -2
  7. package/src/config/logger.ts +286 -19
  8. package/src/config/thread-defaults.ts +33 -21
  9. package/src/create-runtime.ts +725 -387
  10. package/src/db/base.service.ts +52 -28
  11. package/src/db/cursor-pagination.ts +71 -30
  12. package/src/db/memory-store.helpers.ts +4 -7
  13. package/src/db/memory-store.ts +856 -598
  14. package/src/db/memory.ts +398 -275
  15. package/src/db/record-id.ts +32 -10
  16. package/src/db/schema-fingerprint.ts +30 -12
  17. package/src/db/service-normalization.ts +255 -0
  18. package/src/db/service.ts +726 -761
  19. package/src/db/startup.ts +140 -66
  20. package/src/db/transaction-conflict.ts +15 -0
  21. package/src/effect/awaitable-effect.ts +87 -0
  22. package/src/effect/errors.ts +121 -0
  23. package/src/effect/helpers.ts +98 -0
  24. package/src/effect/index.ts +22 -0
  25. package/src/effect/layers.ts +228 -0
  26. package/src/effect/runtime-ref.ts +25 -0
  27. package/src/effect/runtime.ts +31 -0
  28. package/src/effect/services.ts +57 -0
  29. package/src/effect/zod.ts +43 -0
  30. package/src/embeddings/provider.ts +122 -76
  31. package/src/index.ts +46 -1
  32. package/src/openrouter/direct-provider.ts +11 -35
  33. package/src/queues/autonomous-job.queue.ts +130 -74
  34. package/src/queues/context-compaction.queue.ts +60 -15
  35. package/src/queues/delayed-node-promotion.queue.ts +52 -15
  36. package/src/queues/document-processor.queue.ts +52 -77
  37. package/src/queues/memory-consolidation.queue.ts +47 -32
  38. package/src/queues/organization-learning.queue.ts +13 -4
  39. package/src/queues/plan-agent-heartbeat.queue.ts +65 -21
  40. package/src/queues/plan-scheduler.queue.ts +107 -31
  41. package/src/queues/post-chat-memory.queue.ts +66 -24
  42. package/src/queues/queue-factory.ts +142 -52
  43. package/src/queues/standalone-worker.ts +39 -0
  44. package/src/queues/title-generation.queue.ts +54 -9
  45. package/src/redis/connection.ts +84 -32
  46. package/src/redis/index.ts +6 -8
  47. package/src/redis/org-memory-lock.ts +60 -27
  48. package/src/redis/redis-lease-lock.ts +200 -121
  49. package/src/redis/runtime-connection.ts +10 -0
  50. package/src/redis/stream-context.ts +84 -46
  51. package/src/runtime/agent-identity-overrides.ts +2 -2
  52. package/src/runtime/agent-runtime-policy.ts +4 -1
  53. package/src/runtime/agent-stream-helpers.ts +20 -9
  54. package/src/runtime/chat-run-orchestration.ts +102 -19
  55. package/src/runtime/chat-run-registry.ts +36 -2
  56. package/src/runtime/context-compaction/context-compaction-runtime.ts +107 -0
  57. package/src/runtime/{context-compaction.ts → context-compaction/context-compaction.ts} +114 -91
  58. package/src/runtime/execution-plan-visibility.ts +2 -2
  59. package/src/runtime/execution-plan.ts +42 -15
  60. package/src/runtime/graph-designer.ts +11 -7
  61. package/src/runtime/helper-model.ts +135 -48
  62. package/src/runtime/index.ts +7 -7
  63. package/src/runtime/indexed-repositories-policy.ts +3 -3
  64. package/src/runtime/{memory-block.ts → memory/memory-block.ts} +40 -36
  65. package/src/runtime/{memory-digest-policy.ts → memory/memory-digest-policy.ts} +1 -1
  66. package/src/runtime/{memory-pipeline.ts → memory/memory-pipeline.ts} +1 -1
  67. package/src/runtime/{memory-prompts-fact.ts → memory/memory-prompts-fact.ts} +2 -2
  68. package/src/runtime/{memory-scope.ts → memory/memory-scope.ts} +12 -6
  69. package/src/runtime/plugin-resolution.ts +144 -24
  70. package/src/runtime/plugin-types.ts +9 -1
  71. package/src/runtime/post-turn-side-effects.ts +197 -130
  72. package/src/runtime/retrieval-adapters.ts +38 -4
  73. package/src/runtime/runtime-config.ts +150 -61
  74. package/src/runtime/runtime-extensions.ts +21 -34
  75. package/src/runtime/social-chat/social-chat-agent-runner.ts +157 -0
  76. package/src/runtime/{social-chat-history.ts → social-chat/social-chat-history.ts} +42 -20
  77. package/src/runtime/social-chat/social-chat.ts +594 -0
  78. package/src/runtime/specialist-runner.ts +36 -10
  79. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +427 -0
  80. package/src/runtime/{team-consultation-prompts.ts → team-consultation/team-consultation-prompts.ts} +6 -2
  81. package/src/runtime/thread-chat-helpers.ts +2 -2
  82. package/src/runtime/thread-plan-turn.ts +2 -1
  83. package/src/runtime/thread-turn-context.ts +172 -94
  84. package/src/runtime/turn-lifecycle.ts +93 -27
  85. package/src/services/agent-activity.service.ts +287 -203
  86. package/src/services/agent-executor.service.ts +329 -217
  87. package/src/services/artifact.service.ts +225 -148
  88. package/src/services/attachment.service.ts +137 -115
  89. package/src/services/autonomous-job.service.ts +888 -491
  90. package/src/services/chat-run-registry.service.ts +11 -1
  91. package/src/services/context-compaction.service.ts +136 -86
  92. package/src/services/document-chunk.service.ts +162 -90
  93. package/src/services/execution-plan/execution-plan-approval.ts +26 -0
  94. package/src/services/execution-plan/execution-plan-context.ts +29 -0
  95. package/src/services/execution-plan/execution-plan-graph.ts +256 -0
  96. package/src/services/execution-plan/execution-plan-schedule.ts +84 -0
  97. package/src/services/execution-plan/execution-plan-spec.ts +75 -0
  98. package/src/services/execution-plan/execution-plan.service.ts +1041 -0
  99. package/src/services/feedback-loop.service.ts +132 -76
  100. package/src/services/global-orchestrator.service.ts +80 -170
  101. package/src/services/graph-full-routing.ts +182 -0
  102. package/src/services/index.ts +18 -21
  103. package/src/services/institutional-memory.service.ts +220 -123
  104. package/src/services/learned-skill.service.ts +364 -259
  105. package/src/services/memory/memory-conversation.ts +95 -0
  106. package/src/services/memory/memory-org-memory.ts +39 -0
  107. package/src/services/memory/memory-preseeded.ts +80 -0
  108. package/src/services/memory/memory-rerank.ts +297 -0
  109. package/src/services/{memory-utils.ts → memory/memory-utils.ts} +5 -5
  110. package/src/services/memory/memory.service.ts +692 -0
  111. package/src/services/memory/rerank.service.ts +209 -0
  112. package/src/services/monitoring-window.service.ts +92 -70
  113. package/src/services/mutating-approval.service.ts +62 -53
  114. package/src/services/node-workspace.service.ts +141 -98
  115. package/src/services/notification.service.ts +17 -16
  116. package/src/services/organization-member.service.ts +120 -66
  117. package/src/services/organization.service.ts +144 -51
  118. package/src/services/ownership-dispatcher.service.ts +415 -264
  119. package/src/services/plan/plan-agent-heartbeat.service.ts +234 -0
  120. package/src/services/plan/plan-agent-query.service.ts +322 -0
  121. package/src/services/plan/plan-approval.service.ts +102 -0
  122. package/src/services/plan/plan-artifact.service.ts +60 -0
  123. package/src/services/plan/plan-builder.service.ts +76 -0
  124. package/src/services/plan/plan-checkpoint.service.ts +103 -0
  125. package/src/services/{plan-compiler.service.ts → plan/plan-compiler.service.ts} +26 -9
  126. package/src/services/plan/plan-completion-side-effects.ts +175 -0
  127. package/src/services/plan/plan-coordination.service.ts +181 -0
  128. package/src/services/plan/plan-cycle.service.ts +398 -0
  129. package/src/services/plan/plan-deadline.service.ts +547 -0
  130. package/src/services/plan/plan-event-delivery.service.ts +261 -0
  131. package/src/services/plan/plan-executor-context.ts +35 -0
  132. package/src/services/plan/plan-executor-graph.ts +475 -0
  133. package/src/services/plan/plan-executor-helpers.ts +322 -0
  134. package/src/services/plan/plan-executor-persistence.ts +209 -0
  135. package/src/services/plan/plan-executor.service.ts +1654 -0
  136. package/src/services/{plan-helpers.ts → plan/plan-helpers.ts} +1 -1
  137. package/src/services/{plan-run-data.ts → plan/plan-run-data.ts} +4 -4
  138. package/src/services/plan/plan-run-serialization.ts +15 -0
  139. package/src/services/plan/plan-run.service.ts +644 -0
  140. package/src/services/plan/plan-scheduler.service.ts +385 -0
  141. package/src/services/plan/plan-template.service.ts +224 -0
  142. package/src/services/plan/plan-transaction-events.ts +33 -0
  143. package/src/services/plan/plan-validator.service.ts +907 -0
  144. package/src/services/plan/plan-workspace.service.ts +125 -0
  145. package/src/services/plugin-executor.service.ts +97 -68
  146. package/src/services/quality-metrics.service.ts +112 -94
  147. package/src/services/queue-job.service.ts +296 -230
  148. package/src/services/recent-activity-title.service.ts +65 -36
  149. package/src/services/recent-activity.service.ts +274 -259
  150. package/src/services/skill-resolver.service.ts +38 -12
  151. package/src/services/social-chat-history.service.ts +176 -125
  152. package/src/services/system-executor.service.ts +91 -61
  153. package/src/services/thread/thread-active-run.ts +203 -0
  154. package/src/services/thread/thread-bootstrap.ts +369 -0
  155. package/src/services/thread/thread-listing.ts +198 -0
  156. package/src/services/thread/thread-memory-block.ts +117 -0
  157. package/src/services/thread/thread-message.service.ts +363 -0
  158. package/src/services/thread/thread-record-store.ts +155 -0
  159. package/src/services/thread/thread-title.service.ts +74 -0
  160. package/src/services/thread/thread-turn-execution.ts +280 -0
  161. package/src/services/thread/thread-turn-message-context.ts +73 -0
  162. package/src/services/thread/thread-turn-preparation.service.ts +1146 -0
  163. package/src/services/thread/thread-turn-streaming.ts +402 -0
  164. package/src/services/thread/thread-turn-tracing.ts +35 -0
  165. package/src/services/thread/thread-turn.ts +343 -0
  166. package/src/services/thread/thread.service.ts +335 -0
  167. package/src/services/user.service.ts +82 -32
  168. package/src/services/write-intent-validator.service.ts +63 -51
  169. package/src/storage/attachment-parser.ts +69 -27
  170. package/src/storage/attachment-storage.service.ts +331 -275
  171. package/src/storage/generated-document-storage.service.ts +66 -34
  172. package/src/system-agents/agent-result.ts +3 -1
  173. package/src/system-agents/context-compaction.agent.ts +2 -2
  174. package/src/system-agents/delegated-agent-factory.ts +159 -90
  175. package/src/system-agents/memory-reranker.agent.ts +2 -2
  176. package/src/system-agents/memory.agent.ts +2 -2
  177. package/src/system-agents/recent-activity-title-refiner.agent.ts +2 -2
  178. package/src/system-agents/regular-chat-memory-digest.agent.ts +2 -2
  179. package/src/system-agents/skill-extractor.agent.ts +2 -2
  180. package/src/system-agents/skill-manager.agent.ts +2 -2
  181. package/src/system-agents/thread-router.agent.ts +157 -113
  182. package/src/system-agents/title-generator.agent.ts +2 -2
  183. package/src/tools/execution-plan.tool.ts +220 -161
  184. package/src/tools/fetch-webpage.tool.ts +21 -17
  185. package/src/tools/firecrawl-client.ts +16 -6
  186. package/src/tools/index.ts +1 -0
  187. package/src/tools/memory-block.tool.ts +14 -6
  188. package/src/tools/plan-approval.tool.ts +49 -47
  189. package/src/tools/read-file-parts.tool.ts +44 -33
  190. package/src/tools/remember-memory.tool.ts +65 -45
  191. package/src/tools/search-web.tool.ts +26 -22
  192. package/src/tools/search.tool.ts +41 -29
  193. package/src/tools/team-think.tool.ts +124 -83
  194. package/src/tools/user-questions.tool.ts +4 -3
  195. package/src/tools/web-tool-shared.ts +6 -0
  196. package/src/utils/async.ts +17 -23
  197. package/src/utils/crypto.ts +21 -0
  198. package/src/utils/date-time.ts +40 -1
  199. package/src/utils/errors.ts +95 -16
  200. package/src/utils/hono-error-handler.ts +24 -39
  201. package/src/utils/index.ts +2 -1
  202. package/src/utils/null-proto-record.ts +41 -0
  203. package/src/utils/sse-keepalive.ts +124 -21
  204. package/src/workers/bootstrap.ts +186 -51
  205. package/src/workers/memory-consolidation.worker.ts +325 -237
  206. package/src/workers/organization-learning.worker.ts +50 -16
  207. package/src/workers/regular-chat-memory-digest.helpers.ts +28 -27
  208. package/src/workers/regular-chat-memory-digest.runner.ts +175 -114
  209. package/src/workers/skill-extraction.runner.ts +176 -93
  210. package/src/workers/utils/file-section-chunker.ts +8 -10
  211. package/src/workers/utils/repo-structure-extractor.ts +349 -260
  212. package/src/workers/utils/repomix-file-sections.ts +2 -2
  213. package/src/workers/utils/thread-message-query.ts +97 -38
  214. package/src/workers/worker-utils.ts +56 -31
  215. package/src/config/debug-logger.ts +0 -47
  216. package/src/redis/connection-accessor.ts +0 -26
  217. package/src/runtime/context-compaction-runtime.ts +0 -87
  218. package/src/runtime/social-chat-agent-runner.ts +0 -118
  219. package/src/runtime/social-chat.ts +0 -516
  220. package/src/runtime/team-consultation-orchestrator.ts +0 -272
  221. package/src/services/adaptive-playbook.service.ts +0 -152
  222. package/src/services/artifact-provenance.service.ts +0 -172
  223. package/src/services/chat-attachments.service.ts +0 -17
  224. package/src/services/context-compaction-runtime.singleton.ts +0 -13
  225. package/src/services/execution-plan.service.ts +0 -1118
  226. package/src/services/memory.service.ts +0 -914
  227. package/src/services/plan-agent-heartbeat.service.ts +0 -136
  228. package/src/services/plan-agent-query.service.ts +0 -267
  229. package/src/services/plan-approval.service.ts +0 -83
  230. package/src/services/plan-artifact.service.ts +0 -50
  231. package/src/services/plan-builder.service.ts +0 -67
  232. package/src/services/plan-checkpoint.service.ts +0 -81
  233. package/src/services/plan-completion-side-effects.ts +0 -80
  234. package/src/services/plan-coordination.service.ts +0 -157
  235. package/src/services/plan-cycle.service.ts +0 -284
  236. package/src/services/plan-deadline.service.ts +0 -430
  237. package/src/services/plan-event-delivery.service.ts +0 -166
  238. package/src/services/plan-executor.service.ts +0 -1950
  239. package/src/services/plan-run.service.ts +0 -515
  240. package/src/services/plan-scheduler.service.ts +0 -240
  241. package/src/services/plan-template.service.ts +0 -177
  242. package/src/services/plan-validator.service.ts +0 -818
  243. package/src/services/plan-workspace.service.ts +0 -83
  244. package/src/services/rerank.service.ts +0 -156
  245. package/src/services/thread-message.service.ts +0 -275
  246. package/src/services/thread-plan-registry.service.ts +0 -22
  247. package/src/services/thread-title.service.ts +0 -39
  248. package/src/services/thread-turn-preparation.service.ts +0 -1147
  249. package/src/services/thread-turn.ts +0 -172
  250. package/src/services/thread.service.ts +0 -869
  251. package/src/utils/env.ts +0 -8
  252. /package/src/runtime/{context-compaction-constants.ts → context-compaction/context-compaction-constants.ts} +0 -0
  253. /package/src/runtime/{memory-format.ts → memory/memory-format.ts} +0 -0
  254. /package/src/runtime/{memory-prompts-parse.ts → memory/memory-prompts-parse.ts} +0 -0
  255. /package/src/runtime/{memory-prompts-update.ts → memory/memory-prompts-update.ts} +0 -0
  256. /package/src/runtime/{social-chat-prompts.ts → social-chat/social-chat-prompts.ts} +0 -0
  257. /package/src/services/{plan-node-spec.ts → plan/plan-node-spec.ts} +0 -0
  258. /package/src/services/{thread-constants.ts → thread/thread-constants.ts} +0 -0
  259. /package/src/services/{thread.types.ts → thread/thread.types.ts} +0 -0
@@ -0,0 +1,1654 @@
1
+ import type {
2
+ ExecutionPlanToolResultData,
3
+ PlanFailureClass,
4
+ PlanNodeResultSubmission,
5
+ SerializableExecutionPlan,
6
+ } from '@lota-sdk/shared'
7
+ import { PlanNodeAttemptSchema, PlanNodeRunSchema } from '@lota-sdk/shared'
8
+ import { Context, Schema, Effect, Layer } from 'effect'
9
+
10
+ import { aiLogger } from '../../config/logger'
11
+ import type { RecordIdInput } from '../../db/record-id'
12
+ import { ensureRecordId, recordIdToString } from '../../db/record-id'
13
+ import type { DatabaseTransaction } from '../../db/service'
14
+ import { TABLES } from '../../db/tables'
15
+ import { BadRequestError, NotFoundError } from '../../effect/errors'
16
+ import { effectTryPromise as effectTryPromiseShared } from '../../effect/helpers'
17
+ import { runPromise } from '../../effect/runtime'
18
+ import { DatabaseServiceTag } from '../../effect/services'
19
+ import { GeneratedDocumentStorageServiceTag } from '../../storage/generated-document-storage.service'
20
+ import { nowDate } from '../../utils/date-time'
21
+ import { toError } from '../../utils/errors'
22
+ import { ArtifactServiceTag } from '../artifact.service'
23
+ import { FeedbackLoopServiceTag } from '../feedback-loop.service'
24
+ import { InstitutionalMemoryServiceTag } from '../institutional-memory.service'
25
+ import { QualityMetricsServiceTag } from '../quality-metrics.service'
26
+ import { PlanApprovalServiceTag } from './plan-approval.service'
27
+ import { PlanArtifactServiceTag } from './plan-artifact.service'
28
+ import { PlanCheckpointServiceTag } from './plan-checkpoint.service'
29
+ import { makePlanCompletionSideEffects } from './plan-completion-side-effects'
30
+ import { PlanCoordinationServiceTag } from './plan-coordination.service'
31
+ import { PlanEventDeliveryServiceTag } from './plan-event-delivery.service'
32
+ import type { PlanExecutorContext } from './plan-executor-context'
33
+ import { syncRunGraph } from './plan-executor-graph'
34
+ import type { HumanNodeResponsePayload } from './plan-executor-helpers'
35
+ import {
36
+ buildPublishedArtifactContent,
37
+ buildResolvedInput,
38
+ deriveApprovalStatus,
39
+ isHumanNodeType,
40
+ readHumanRequiredEdits,
41
+ readHumanResponseComments,
42
+ isStructuralNodeType,
43
+ resolveFailureAction,
44
+ toNodeRunData,
45
+ } from './plan-executor-helpers'
46
+ import {
47
+ attachCheckpoint,
48
+ createAttempt,
49
+ emitEvent,
50
+ persistValidationIssues,
51
+ replaceRun,
52
+ saveCheckpoint,
53
+ } from './plan-executor-persistence'
54
+ import { toPlanNodeValidationSpec } from './plan-node-spec'
55
+ import { buildExecutionPlanToolResult } from './plan-run-data'
56
+ import { serializeRunFull } from './plan-run-serialization'
57
+ import { PlanRunServiceTag } from './plan-run.service'
58
+ import { PlanSchedulerServiceTag } from './plan-scheduler.service'
59
+ import { withTransactionAndEventsEffect } from './plan-transaction-events'
60
+ import { PlanValidatorServiceTag } from './plan-validator.service'
61
+
62
+ interface PlanExecutorDeps {
63
+ db: Context.Service.Shape<typeof DatabaseServiceTag>
64
+ storage: Context.Service.Shape<typeof GeneratedDocumentStorageServiceTag>
65
+ artifactService: Context.Service.Shape<typeof ArtifactServiceTag>
66
+ feedbackLoopService: Context.Service.Shape<typeof FeedbackLoopServiceTag>
67
+ institutionalMemoryService: Context.Service.Shape<typeof InstitutionalMemoryServiceTag>
68
+ planApprovalService: Context.Service.Shape<typeof PlanApprovalServiceTag>
69
+ planArtifactService: Context.Service.Shape<typeof PlanArtifactServiceTag>
70
+ planCheckpointService: Context.Service.Shape<typeof PlanCheckpointServiceTag>
71
+ planCoordinationService: Context.Service.Shape<typeof PlanCoordinationServiceTag>
72
+ planEventDeliveryService: Context.Service.Shape<typeof PlanEventDeliveryServiceTag>
73
+ planRunService: Context.Service.Shape<typeof PlanRunServiceTag>
74
+ planSchedulerService: Context.Service.Shape<typeof PlanSchedulerServiceTag>
75
+ planValidatorService: Context.Service.Shape<typeof PlanValidatorServiceTag>
76
+ qualityMetricsService: Context.Service.Shape<typeof QualityMetricsServiceTag>
77
+ }
78
+
79
+ type PlanExecutorService = ReturnType<typeof makePlanExecutorService>
80
+
81
+ function saveCheckpointWithContext(
82
+ context: Pick<PlanExecutorContext, 'planCheckpointService'>,
83
+ params: Omit<Parameters<typeof saveCheckpoint>[0], 'planCheckpointService'>,
84
+ ) {
85
+ return saveCheckpoint({ ...params, planCheckpointService: context.planCheckpointService })
86
+ }
87
+
88
+ class PlanExecutorInternalError extends Schema.TaggedErrorClass<PlanExecutorInternalError>()(
89
+ 'PlanExecutorInternalError',
90
+ { message: Schema.String, cause: Schema.optional(Schema.Defect) },
91
+ ) {}
92
+
93
+ function fromPromise<A>(thunk: () => PromiseLike<A> | Effect.Effect<A, unknown>) {
94
+ return effectTryPromiseShared(
95
+ thunk,
96
+ (cause) => new PlanExecutorInternalError({ message: toError(cause).message, cause }),
97
+ )
98
+ }
99
+
100
+ function withDatabaseTransactionEffect<A, E, R>(
101
+ databaseService: Context.Service.Shape<typeof DatabaseServiceTag>,
102
+ run: (tx: DatabaseTransaction) => Effect.Effect<A, E, R>,
103
+ ) {
104
+ return databaseService.withTransaction((tx) => run(tx))
105
+ }
106
+
107
+ type SaveCheckpointParams = Parameters<typeof saveCheckpointWithContext>[1]
108
+ type CapturedPlanEvents = Parameters<typeof emitEvent>[0]['capturedEvents']
109
+ type EffectSuccess<T> = T extends Effect.Effect<infer A, infer _E, infer _R> ? A : Awaited<T>
110
+ type ActivePlanRunRecord = NonNullable<
111
+ EffectSuccess<ReturnType<PlanExecutorContext['planRunService']['getActiveRunRecord']>>
112
+ >
113
+ type PlanRunRecord = EffectSuccess<ReturnType<PlanExecutorContext['planRunService']['getRunById']>>
114
+ type PlanSpecRecord = EffectSuccess<ReturnType<PlanExecutorContext['planRunService']['getPlanSpecById']>>
115
+ type PlanNodeSpecRecord = EffectSuccess<ReturnType<PlanExecutorContext['planRunService']['listNodeSpecs']>>[number]
116
+ type PlanNodeRunRecord = EffectSuccess<ReturnType<PlanExecutorContext['planRunService']['getNodeRunByNodeId']>>
117
+ type PlanArtifacts = EffectSuccess<ReturnType<PlanExecutorContext['planRunService']['listArtifacts']>>
118
+ type PlanNodeResultValidation = ReturnType<PlanExecutorContext['planValidatorService']['validateNodeResult']>
119
+ type PlanApprovalRecord = NonNullable<
120
+ EffectSuccess<ReturnType<PlanExecutorContext['planApprovalService']['getApprovalById']>>
121
+ >
122
+ type ApprovalResolutionStatus = ReturnType<typeof deriveApprovalStatus>
123
+
124
+ interface LoadedNodeResultSubmission {
125
+ run: PlanRunRecord
126
+ spec: PlanSpecRecord
127
+ nodeSpecs: PlanNodeSpecRecord[]
128
+ nodeSpec: PlanNodeSpecRecord
129
+ nodeRun: PlanNodeRunRecord
130
+ existingArtifacts: PlanArtifacts
131
+ nextCheckpointSequence: number
132
+ validation: PlanNodeResultValidation
133
+ }
134
+
135
+ interface PersistedNodeResultAttempt {
136
+ attemptId: RecordIdInput
137
+ nextNodeRun: PlanNodeRunRecord
138
+ withUpdatedNodeRuns: PlanNodeRunRecord[]
139
+ nextArtifacts: PlanArtifacts
140
+ }
141
+
142
+ interface LoadedHumanNodeResponseSubmission {
143
+ run: ActivePlanRunRecord
144
+ spec: PlanSpecRecord
145
+ nodeSpecs: PlanNodeSpecRecord[]
146
+ nodeSpec: PlanNodeSpecRecord
147
+ nodeRun: PlanNodeRunRecord
148
+ approval: PlanApprovalRecord
149
+ existingArtifacts: PlanArtifacts
150
+ nextCheckpointSequence: number
151
+ validation: PlanNodeResultValidation
152
+ responseComments?: string
153
+ submittedResult: PlanNodeResultSubmission
154
+ }
155
+
156
+ interface PersistedHumanNodeResponseAttempt {
157
+ attemptId: RecordIdInput
158
+ approvalStatus: ApprovalResolutionStatus
159
+ nextNodeRun: PlanNodeRunRecord
160
+ nodeRuns: PlanNodeRunRecord[]
161
+ }
162
+
163
+ function replaceNodeRunInList<T extends { nodeId: string }>(nodeRuns: readonly T[], nextNodeRun: T): T[] {
164
+ return nodeRuns.map((candidate) => (candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate))
165
+ }
166
+
167
+ function saveAndAttachCheckpointEffect(
168
+ context: Pick<PlanExecutorContext, 'planCheckpointService'>,
169
+ params: SaveCheckpointParams,
170
+ ) {
171
+ return Effect.gen(function* () {
172
+ const checkpoint = yield* saveCheckpointWithContext(context, params)
173
+ yield* attachCheckpoint(params.tx, params.run, checkpoint)
174
+ })
175
+ }
176
+
177
+ function loadNodeResultSubmissionContextEffect(
178
+ context: Pick<PlanExecutorContext, 'planRunService' | 'planValidatorService'>,
179
+ params: { threadId: RecordIdInput; runId: string; nodeId: string; result: PlanNodeResultSubmission },
180
+ ) {
181
+ return Effect.gen(function* () {
182
+ const run = yield* context.planRunService.getRunById(params.runId)
183
+ if (recordIdToString(run.threadId, TABLES.THREAD) !== recordIdToString(params.threadId, TABLES.THREAD)) {
184
+ return yield* new BadRequestError({ message: 'Execution node result targets a different thread.' })
185
+ }
186
+ if (run.status === 'completed' || run.status === 'failed' || run.status === 'aborted') {
187
+ return yield* new BadRequestError({ message: 'Execution run is no longer active.' })
188
+ }
189
+
190
+ const spec = yield* context.planRunService.getPlanSpecById(run.planSpecId)
191
+ const nodeSpecs = yield* context.planRunService.listNodeSpecs(spec.id)
192
+ const nodeSpec = nodeSpecs.find((candidate) => candidate.nodeId === params.nodeId)
193
+ if (!nodeSpec) {
194
+ return yield* new NotFoundError({
195
+ resource: 'plan-node',
196
+ id: params.nodeId,
197
+ message: `Execution node "${params.nodeId}" does not exist in this run.`,
198
+ })
199
+ }
200
+ if (isHumanNodeType(nodeSpec.type) || isStructuralNodeType(nodeSpec.type)) {
201
+ return yield* new BadRequestError({
202
+ message: `Execution node "${nodeSpec.label}" is executor-owned and cannot accept direct result submission.`,
203
+ })
204
+ }
205
+
206
+ const nodeRun = yield* context.planRunService.getNodeRunByNodeId(run.id, params.nodeId)
207
+ if (nodeRun.status !== 'running') {
208
+ return yield* new BadRequestError({ message: `Execution node "${nodeSpec.label}" is not currently running.` })
209
+ }
210
+
211
+ const existingArtifacts = yield* context.planRunService.listArtifacts(run.id)
212
+ const nextCheckpointSequence = yield* context.planRunService.getNextCheckpointSequence(run.id)
213
+ const validation = context.planValidatorService.validateNodeResult({
214
+ draft: { schemas: spec.schemaRegistry },
215
+ node: toPlanNodeValidationSpec(nodeSpec),
216
+ result: params.result,
217
+ })
218
+
219
+ return {
220
+ run,
221
+ spec,
222
+ nodeSpecs,
223
+ nodeSpec,
224
+ nodeRun,
225
+ existingArtifacts,
226
+ nextCheckpointSequence,
227
+ validation,
228
+ } satisfies LoadedNodeResultSubmission
229
+ })
230
+ }
231
+
232
+ function publishNodeResultArtifactsEffect(params: {
233
+ artifactService: PlanExecutorContext['artifactService']
234
+ tx: DatabaseTransaction
235
+ run: PlanRunRecord
236
+ nodeSpec: PlanNodeSpecRecord
237
+ nodeId: string
238
+ emittedBy: string
239
+ result: PlanNodeResultSubmission
240
+ publishedArtifactStorageKeys: string[]
241
+ }) {
242
+ const { artifactService, tx, run, nodeSpec, nodeId, emittedBy, result, publishedArtifactStorageKeys } = params
243
+
244
+ return Effect.forEach(result.artifacts, (artifact) =>
245
+ Effect.gen(function* () {
246
+ const deliverable = nodeSpec.deliverables.find((candidate) => candidate.name === artifact.name)
247
+ if (!deliverable?.publishAs) {
248
+ return artifact
249
+ }
250
+ const publishAs = deliverable.publishAs
251
+
252
+ const content = buildPublishedArtifactContent({ artifact, notes: result.notes })
253
+ const published = yield* artifactService
254
+ .publishArtifactInTransaction(
255
+ {
256
+ organizationId: recordIdToString(run.organizationId, TABLES.ORGANIZATION),
257
+ authorAgentId: emittedBy,
258
+ title: artifact.name,
259
+ artifactKind: publishAs.artifactKind,
260
+ templateId: publishAs.templateId,
261
+ canonicalKey: publishAs.canonicalKey,
262
+ content,
263
+ tags: [],
264
+ ...(artifact.description ? { description: artifact.description } : {}),
265
+ sourceThreadId: recordIdToString(run.threadId, TABLES.THREAD),
266
+ sourcePlanRunId: recordIdToString(run.id, TABLES.PLAN_RUN),
267
+ sourcePlanNodeId: nodeId,
268
+ deliverableName: artifact.name,
269
+ },
270
+ tx,
271
+ )
272
+ .pipe(Effect.mapError((cause) => new PlanExecutorInternalError({ message: toError(cause).message, cause })))
273
+ publishedArtifactStorageKeys.push(published.storageKey)
274
+
275
+ return { ...artifact, content, publishedArtifactId: recordIdToString(published.id, TABLES.ARTIFACT) }
276
+ }),
277
+ )
278
+ }
279
+
280
+ function persistNodeResultAttemptEffect(
281
+ context: Pick<PlanExecutorContext, 'artifactService' | 'planArtifactService' | 'planRunService'>,
282
+ params: {
283
+ tx: DatabaseTransaction
284
+ submission: LoadedNodeResultSubmission
285
+ nodeId: string
286
+ emittedBy: string
287
+ result: PlanNodeResultSubmission
288
+ publishedArtifactStorageKeys: string[]
289
+ },
290
+ ) {
291
+ const { tx, submission, nodeId, emittedBy, result, publishedArtifactStorageKeys } = params
292
+
293
+ return Effect.gen(function* () {
294
+ const attempt = yield* createAttempt({
295
+ tx,
296
+ run: submission.run,
297
+ nodeRun: submission.nodeRun,
298
+ emittedBy,
299
+ result,
300
+ status: submission.validation.blocking.length > 0 ? 'failed' : 'completed',
301
+ failureClass: submission.validation.failureClass,
302
+ })
303
+
304
+ const issues = yield* persistValidationIssues({
305
+ tx,
306
+ run: submission.run,
307
+ spec: submission.spec,
308
+ attemptId: attempt.id,
309
+ nodeId,
310
+ issues: [...submission.validation.blocking, ...submission.validation.warnings],
311
+ })
312
+
313
+ void (yield* fromPromise(() =>
314
+ tx
315
+ .update(ensureRecordId(attempt.id, TABLES.PLAN_NODE_ATTEMPT))
316
+ .content({
317
+ runId: ensureRecordId(attempt.runId, TABLES.PLAN_RUN),
318
+ nodeRunId: ensureRecordId(attempt.nodeRunId, TABLES.PLAN_NODE_RUN),
319
+ nodeId: attempt.nodeId,
320
+ emittedBy: attempt.emittedBy,
321
+ status: attempt.status,
322
+ ...(attempt.structuredOutput ? { structuredOutput: attempt.structuredOutput } : {}),
323
+ ...(attempt.notes ? { notes: attempt.notes } : {}),
324
+ validationIssueIds: issues.map((issue: { id: RecordIdInput }) =>
325
+ ensureRecordId(issue.id, TABLES.PLAN_VALIDATION_ISSUE),
326
+ ),
327
+ ...(attempt.failureClass ? { failureClass: attempt.failureClass } : {}),
328
+ })
329
+ .output('after'),
330
+ ).pipe(Effect.map((row) => PlanNodeAttemptSchema.parse(row))))
331
+
332
+ const publishedArtifacts =
333
+ submission.validation.blocking.length > 0
334
+ ? result.artifacts
335
+ : yield* publishNodeResultArtifactsEffect({
336
+ artifactService: context.artifactService,
337
+ tx,
338
+ run: submission.run,
339
+ nodeSpec: submission.nodeSpec,
340
+ nodeId,
341
+ emittedBy,
342
+ result,
343
+ publishedArtifactStorageKeys,
344
+ })
345
+
346
+ const persistedArtifacts = yield* context.planArtifactService.persistArtifacts({
347
+ tx,
348
+ runId: submission.run.id,
349
+ attemptId: attempt.id,
350
+ nodeId,
351
+ artifacts: publishedArtifacts,
352
+ })
353
+
354
+ const nextNodeRun = PlanNodeRunSchema.parse(
355
+ yield* fromPromise(() =>
356
+ tx
357
+ .update(ensureRecordId(submission.nodeRun.id, TABLES.PLAN_NODE_RUN))
358
+ .content(
359
+ toNodeRunData(submission.nodeRun, {
360
+ attemptCount: submission.nodeRun.attemptCount + 1,
361
+ latestAttemptId: attempt.id,
362
+ latestStructuredOutput: result.structuredOutput ?? null,
363
+ latestNotes: result.notes,
364
+ }),
365
+ )
366
+ .output('after'),
367
+ ),
368
+ )
369
+
370
+ const nodeRuns = yield* context.planRunService.listNodeRuns(submission.run.id)
371
+
372
+ return {
373
+ attemptId: attempt.id,
374
+ nextNodeRun,
375
+ withUpdatedNodeRuns: replaceNodeRunInList(nodeRuns, nextNodeRun),
376
+ nextArtifacts: [...submission.existingArtifacts, ...persistedArtifacts],
377
+ } satisfies PersistedNodeResultAttempt
378
+ })
379
+ }
380
+
381
+ function handleRetryNodeResultEffect(
382
+ context: PlanExecutorContext,
383
+ params: {
384
+ tx: DatabaseTransaction
385
+ emittedEvents: CapturedPlanEvents
386
+ submission: LoadedNodeResultSubmission
387
+ persisted: PersistedNodeResultAttempt
388
+ emittedBy: string
389
+ },
390
+ ) {
391
+ const { tx, emittedEvents, submission, persisted, emittedBy } = params
392
+
393
+ return Effect.gen(function* () {
394
+ const retryNodeRun = PlanNodeRunSchema.parse(
395
+ yield* fromPromise(() =>
396
+ tx
397
+ .update(ensureRecordId(persisted.nextNodeRun.id, TABLES.PLAN_NODE_RUN))
398
+ .content(
399
+ toNodeRunData(persisted.nextNodeRun, {
400
+ status: 'ready',
401
+ retryCount: persisted.nextNodeRun.retryCount + 1,
402
+ failureClass: submission.validation.failureClass,
403
+ blockedReason: submission.validation.blocking[0]?.message ?? null,
404
+ readyAt: nowDate(),
405
+ startedAt: null,
406
+ completedAt: null,
407
+ }),
408
+ )
409
+ .output('after'),
410
+ ),
411
+ )
412
+
413
+ yield* emitEvent({
414
+ tx,
415
+ run: submission.run,
416
+ spec: submission.spec,
417
+ nodeId: retryNodeRun.nodeId,
418
+ attemptId: persisted.attemptId,
419
+ eventType: 'validation-reported',
420
+ message: `Validation failed for node "${submission.nodeSpec.label}", scheduling retry.`,
421
+ detail: { issues: submission.validation.blocking.map((issue) => issue.code) },
422
+ emittedBy,
423
+ capturedEvents: emittedEvents,
424
+ })
425
+
426
+ yield* emitEvent({
427
+ tx,
428
+ run: submission.run,
429
+ spec: submission.spec,
430
+ nodeId: retryNodeRun.nodeId,
431
+ attemptId: persisted.attemptId,
432
+ eventType: 'node-unblocked',
433
+ fromStatus: submission.nodeRun.status,
434
+ toStatus: retryNodeRun.status,
435
+ message: `Node "${submission.nodeSpec.label}" is ready for another attempt.`,
436
+ detail: { retryCount: retryNodeRun.retryCount },
437
+ emittedBy,
438
+ capturedEvents: emittedEvents,
439
+ })
440
+
441
+ const synced = yield* fromPromise(() =>
442
+ syncRunGraph(context, {
443
+ tx,
444
+ run: submission.run,
445
+ spec: submission.spec,
446
+ nodeSpecs: submission.nodeSpecs,
447
+ nodeRuns: replaceNodeRunInList(persisted.withUpdatedNodeRuns, retryNodeRun),
448
+ artifacts: persisted.nextArtifacts,
449
+ emittedBy,
450
+ capturedEvents: emittedEvents,
451
+ }),
452
+ )
453
+
454
+ yield* saveAndAttachCheckpointEffect(context, {
455
+ tx,
456
+ run: synced.run,
457
+ spec: submission.spec,
458
+ nodeRuns: synced.nodeRuns,
459
+ artifacts: synced.artifacts,
460
+ sequence: submission.nextCheckpointSequence,
461
+ reason: 'node-result-retry',
462
+ capturedEvents: emittedEvents,
463
+ })
464
+ })
465
+ }
466
+
467
+ function handleHumanReviewNodeResultEffect(
468
+ context: PlanExecutorContext,
469
+ params: {
470
+ tx: DatabaseTransaction
471
+ emittedEvents: CapturedPlanEvents
472
+ submission: LoadedNodeResultSubmission
473
+ persisted: PersistedNodeResultAttempt
474
+ emittedBy: string
475
+ },
476
+ ) {
477
+ const { tx, emittedEvents, submission, persisted, emittedBy } = params
478
+
479
+ return Effect.gen(function* () {
480
+ const awaitingHumanNodeRun = PlanNodeRunSchema.parse(
481
+ yield* fromPromise(() =>
482
+ tx
483
+ .update(ensureRecordId(persisted.nextNodeRun.id, TABLES.PLAN_NODE_RUN))
484
+ .content(
485
+ toNodeRunData(persisted.nextNodeRun, {
486
+ status: 'awaiting-human',
487
+ retryCount: persisted.nextNodeRun.retryCount + 1,
488
+ failureClass: submission.validation.failureClass,
489
+ blockedReason: submission.validation.blocking[0]?.message ?? null,
490
+ startedAt: persisted.nextNodeRun.startedAt ?? nowDate(),
491
+ }),
492
+ )
493
+ .output('after'),
494
+ ),
495
+ )
496
+
497
+ const approval = yield* context.planApprovalService
498
+ .createPendingApproval({
499
+ tx,
500
+ runId: submission.run.id,
501
+ nodeRunId: awaitingHumanNodeRun.id,
502
+ nodeId: awaitingHumanNodeRun.nodeId,
503
+ requestedBy: emittedBy,
504
+ presented: {
505
+ nodeId: submission.nodeSpec.nodeId,
506
+ label: submission.nodeSpec.label,
507
+ objective: submission.nodeSpec.objective,
508
+ instructions: submission.nodeSpec.instructions,
509
+ validationIssues: submission.validation.blocking,
510
+ },
511
+ })
512
+ .pipe(Effect.mapError((cause) => new PlanExecutorInternalError({ message: toError(cause).message, cause })))
513
+
514
+ const awaitingHumanRun = yield* replaceRun(tx, submission.run, {
515
+ status: 'awaiting-human',
516
+ currentNodeId: awaitingHumanNodeRun.nodeId,
517
+ waitingNodeId: awaitingHumanNodeRun.nodeId,
518
+ readyNodeIds: [],
519
+ failureCount: submission.run.failureCount + 1,
520
+ })
521
+
522
+ yield* emitEvent({
523
+ tx,
524
+ run: awaitingHumanRun,
525
+ spec: submission.spec,
526
+ nodeId: awaitingHumanNodeRun.nodeId,
527
+ attemptId: persisted.attemptId,
528
+ approvalId: approval.id,
529
+ eventType: 'approval-requested',
530
+ fromStatus: submission.run.status,
531
+ toStatus: awaitingHumanRun.status,
532
+ message: `Node "${submission.nodeSpec.label}" requires human review before continuing.`,
533
+ detail: { issues: submission.validation.blocking.map((issue) => issue.code) },
534
+ emittedBy,
535
+ capturedEvents: emittedEvents,
536
+ })
537
+
538
+ yield* saveAndAttachCheckpointEffect(context, {
539
+ tx,
540
+ run: awaitingHumanRun,
541
+ spec: submission.spec,
542
+ nodeRuns: replaceNodeRunInList(persisted.withUpdatedNodeRuns, awaitingHumanNodeRun),
543
+ artifacts: persisted.nextArtifacts,
544
+ sequence: submission.nextCheckpointSequence,
545
+ reason: 'node-result-human-review',
546
+ capturedEvents: emittedEvents,
547
+ })
548
+ })
549
+ }
550
+
551
+ function handleReplanNodeResultEffect(
552
+ context: PlanExecutorContext,
553
+ params: {
554
+ tx: DatabaseTransaction
555
+ emittedEvents: CapturedPlanEvents
556
+ submission: LoadedNodeResultSubmission
557
+ persisted: PersistedNodeResultAttempt
558
+ emittedBy: string
559
+ },
560
+ ) {
561
+ const { tx, emittedEvents, submission, persisted, emittedBy } = params
562
+
563
+ return Effect.gen(function* () {
564
+ const blockedNodeRun = PlanNodeRunSchema.parse(
565
+ yield* fromPromise(() =>
566
+ tx
567
+ .update(ensureRecordId(persisted.nextNodeRun.id, TABLES.PLAN_NODE_RUN))
568
+ .content(
569
+ toNodeRunData(persisted.nextNodeRun, {
570
+ status: 'blocked',
571
+ retryCount: persisted.nextNodeRun.retryCount + 1,
572
+ failureClass: submission.validation.failureClass,
573
+ blockedReason: submission.validation.blocking[0]?.message ?? null,
574
+ }),
575
+ )
576
+ .output('after'),
577
+ ),
578
+ )
579
+
580
+ const blockedRun = yield* replaceRun(tx, submission.run, {
581
+ status: 'blocked',
582
+ currentNodeId: blockedNodeRun.nodeId,
583
+ waitingNodeId: null,
584
+ readyNodeIds: [],
585
+ failureCount: submission.run.failureCount + 1,
586
+ })
587
+
588
+ yield* emitEvent({
589
+ tx,
590
+ run: blockedRun,
591
+ spec: submission.spec,
592
+ nodeId: blockedNodeRun.nodeId,
593
+ attemptId: persisted.attemptId,
594
+ eventType: 'node-blocked',
595
+ fromStatus: submission.nodeRun.status,
596
+ toStatus: blockedNodeRun.status,
597
+ message: `Node "${submission.nodeSpec.label}" failed validation and requires replanning.`,
598
+ detail: {
599
+ failureClass: submission.validation.failureClass,
600
+ issues: submission.validation.blocking.map((issue) => issue.code),
601
+ },
602
+ emittedBy,
603
+ capturedEvents: emittedEvents,
604
+ })
605
+
606
+ yield* saveAndAttachCheckpointEffect(context, {
607
+ tx,
608
+ run: blockedRun,
609
+ spec: submission.spec,
610
+ nodeRuns: replaceNodeRunInList(persisted.withUpdatedNodeRuns, blockedNodeRun),
611
+ artifacts: persisted.nextArtifacts,
612
+ sequence: submission.nextCheckpointSequence,
613
+ reason: 'node-result-replan',
614
+ capturedEvents: emittedEvents,
615
+ })
616
+ })
617
+ }
618
+
619
+ function handleFailedNodeResultEffect(
620
+ context: PlanExecutorContext,
621
+ params: {
622
+ tx: DatabaseTransaction
623
+ emittedEvents: CapturedPlanEvents
624
+ submission: LoadedNodeResultSubmission
625
+ persisted: PersistedNodeResultAttempt
626
+ emittedBy: string
627
+ },
628
+ ) {
629
+ const { tx, emittedEvents, submission, persisted, emittedBy } = params
630
+
631
+ return Effect.gen(function* () {
632
+ const failedNodeRun = PlanNodeRunSchema.parse(
633
+ yield* fromPromise(() =>
634
+ tx
635
+ .update(ensureRecordId(persisted.nextNodeRun.id, TABLES.PLAN_NODE_RUN))
636
+ .content(
637
+ toNodeRunData(persisted.nextNodeRun, {
638
+ status: 'failed',
639
+ retryCount: persisted.nextNodeRun.retryCount + 1,
640
+ failureClass: submission.validation.failureClass,
641
+ blockedReason: submission.validation.blocking[0]?.message ?? null,
642
+ completedAt: nowDate(),
643
+ }),
644
+ )
645
+ .output('after'),
646
+ ),
647
+ )
648
+
649
+ const failedRun = yield* replaceRun(tx, submission.run, {
650
+ status: 'failed',
651
+ currentNodeId: null,
652
+ waitingNodeId: null,
653
+ readyNodeIds: [],
654
+ failureCount: submission.run.failureCount + 1,
655
+ completedAt: nowDate(),
656
+ })
657
+
658
+ yield* emitEvent({
659
+ tx,
660
+ run: failedRun,
661
+ spec: submission.spec,
662
+ nodeId: failedNodeRun.nodeId,
663
+ attemptId: persisted.attemptId,
664
+ eventType: 'node-failed',
665
+ fromStatus: submission.nodeRun.status,
666
+ toStatus: failedNodeRun.status,
667
+ message: `Node "${submission.nodeSpec.label}" failed validation and the run has been aborted.`,
668
+ detail: {
669
+ failureClass: submission.validation.failureClass,
670
+ issues: submission.validation.blocking.map((issue) => issue.code),
671
+ },
672
+ emittedBy,
673
+ capturedEvents: emittedEvents,
674
+ })
675
+
676
+ yield* saveAndAttachCheckpointEffect(context, {
677
+ tx,
678
+ run: failedRun,
679
+ spec: submission.spec,
680
+ nodeRuns: replaceNodeRunInList(persisted.withUpdatedNodeRuns, failedNodeRun),
681
+ artifacts: persisted.nextArtifacts,
682
+ sequence: submission.nextCheckpointSequence,
683
+ reason: 'node-result-failed',
684
+ capturedEvents: emittedEvents,
685
+ })
686
+ })
687
+ }
688
+
689
+ function handleBlockedNodeResultEffect(
690
+ context: PlanExecutorContext,
691
+ params: {
692
+ tx: DatabaseTransaction
693
+ emittedEvents: CapturedPlanEvents
694
+ submission: LoadedNodeResultSubmission
695
+ persisted: PersistedNodeResultAttempt
696
+ emittedBy: string
697
+ },
698
+ ) {
699
+ const { submission, persisted } = params
700
+
701
+ return Effect.gen(function* () {
702
+ const shouldRetry =
703
+ submission.validation.failureClass &&
704
+ submission.nodeSpec.retryPolicy.maxAttempts > persisted.nextNodeRun.retryCount &&
705
+ (submission.nodeSpec.retryPolicy.retryOn.length === 0 ||
706
+ submission.nodeSpec.retryPolicy.retryOn.includes(submission.validation.failureClass))
707
+
708
+ if (shouldRetry) {
709
+ return yield* handleRetryNodeResultEffect(context, params)
710
+ }
711
+
712
+ const failureAction = resolveFailureAction(submission.nodeSpec, submission.validation.failureClass)
713
+ if (failureAction === 'human-review') {
714
+ return yield* handleHumanReviewNodeResultEffect(context, params)
715
+ }
716
+ if (failureAction === 'replan') {
717
+ return yield* handleReplanNodeResultEffect(context, params)
718
+ }
719
+
720
+ return yield* handleFailedNodeResultEffect(context, params)
721
+ })
722
+ }
723
+
724
+ function handleSuccessfulNodeResultEffect(
725
+ context: PlanExecutorContext,
726
+ params: {
727
+ tx: DatabaseTransaction
728
+ emittedEvents: CapturedPlanEvents
729
+ submission: LoadedNodeResultSubmission
730
+ persisted: PersistedNodeResultAttempt
731
+ emittedBy: string
732
+ result: PlanNodeResultSubmission
733
+ },
734
+ ) {
735
+ const { tx, emittedEvents, submission, persisted, emittedBy, result } = params
736
+
737
+ return Effect.gen(function* () {
738
+ const completedNodeRun = PlanNodeRunSchema.parse(
739
+ yield* fromPromise(() =>
740
+ tx
741
+ .update(ensureRecordId(persisted.nextNodeRun.id, TABLES.PLAN_NODE_RUN))
742
+ .content(
743
+ toNodeRunData(persisted.nextNodeRun, {
744
+ status: submission.validation.warnings.length > 0 ? 'partial' : 'completed',
745
+ latestAttemptId: persisted.attemptId,
746
+ latestStructuredOutput: result.structuredOutput ?? null,
747
+ latestNotes: result.notes,
748
+ blockedReason: null,
749
+ failureClass: null,
750
+ completedAt: nowDate(),
751
+ }),
752
+ )
753
+ .output('after'),
754
+ ),
755
+ )
756
+
757
+ yield* emitEvent({
758
+ tx,
759
+ run: submission.run,
760
+ spec: submission.spec,
761
+ nodeId: completedNodeRun.nodeId,
762
+ attemptId: persisted.attemptId,
763
+ eventType: submission.validation.warnings.length > 0 ? 'node-partial' : 'node-completed',
764
+ fromStatus: submission.nodeRun.status,
765
+ toStatus: completedNodeRun.status,
766
+ message:
767
+ submission.validation.warnings.length > 0
768
+ ? `Node "${submission.nodeSpec.label}" completed with warnings.`
769
+ : `Node "${submission.nodeSpec.label}" completed successfully.`,
770
+ detail: { warningCount: submission.validation.warnings.length },
771
+ emittedBy,
772
+ capturedEvents: emittedEvents,
773
+ })
774
+
775
+ const synced = yield* fromPromise(() =>
776
+ syncRunGraph(context, {
777
+ tx,
778
+ run: submission.run,
779
+ spec: submission.spec,
780
+ nodeSpecs: submission.nodeSpecs,
781
+ nodeRuns: replaceNodeRunInList(persisted.withUpdatedNodeRuns, completedNodeRun),
782
+ artifacts: persisted.nextArtifacts,
783
+ emittedBy,
784
+ capturedEvents: emittedEvents,
785
+ }),
786
+ )
787
+
788
+ yield* saveAndAttachCheckpointEffect(context, {
789
+ tx,
790
+ run: synced.run,
791
+ spec: submission.spec,
792
+ nodeRuns: synced.nodeRuns,
793
+ artifacts: synced.artifacts,
794
+ sequence: submission.nextCheckpointSequence,
795
+ reason: 'node-result-complete',
796
+ capturedEvents: emittedEvents,
797
+ })
798
+ })
799
+ }
800
+
801
+ function resolveApprovalForWaitingNodeEffect(
802
+ context: Pick<PlanExecutorContext, 'planApprovalService'>,
803
+ params: { approvalId?: string; nodeRun: PlanNodeRunRecord; nodeLabel: string },
804
+ ) {
805
+ return Effect.gen(function* () {
806
+ const approvalId = params.approvalId ?? null
807
+ const explicitApproval = approvalId
808
+ ? yield* context.planApprovalService
809
+ .getApprovalById(approvalId)
810
+ .pipe(Effect.mapError((cause) => new PlanExecutorInternalError({ message: toError(cause).message, cause })))
811
+ : null
812
+
813
+ if (approvalId && !explicitApproval) {
814
+ return yield* new NotFoundError({
815
+ resource: 'plan-approval',
816
+ id: approvalId,
817
+ message: `Approval "${approvalId}" does not exist.`,
818
+ })
819
+ }
820
+
821
+ if (
822
+ explicitApproval &&
823
+ recordIdToString(explicitApproval.nodeRunId, TABLES.PLAN_NODE_RUN) !==
824
+ recordIdToString(params.nodeRun.id, TABLES.PLAN_NODE_RUN)
825
+ ) {
826
+ return yield* new BadRequestError({
827
+ message: `Approval "${approvalId}" does not belong to node "${params.nodeLabel}".`,
828
+ })
829
+ }
830
+
831
+ if (explicitApproval && explicitApproval.status !== 'pending') {
832
+ return yield* new BadRequestError({ message: `Approval "${approvalId}" is no longer pending.` })
833
+ }
834
+
835
+ const approval =
836
+ explicitApproval ??
837
+ (yield* context.planApprovalService
838
+ .getPendingApprovalForNodeRun(params.nodeRun.id)
839
+ .pipe(Effect.mapError((cause) => new PlanExecutorInternalError({ message: toError(cause).message, cause }))))
840
+ if (!approval) {
841
+ return yield* new BadRequestError({ message: `No pending approval exists for node "${params.nodeLabel}".` })
842
+ }
843
+
844
+ return approval satisfies PlanApprovalRecord
845
+ })
846
+ }
847
+
848
+ function loadHumanNodeResponseContextEffect(
849
+ context: Pick<PlanExecutorContext, 'planApprovalService' | 'planRunService' | 'planValidatorService'>,
850
+ params: { threadId: RecordIdInput; approvalId?: string; response: HumanNodeResponsePayload },
851
+ ) {
852
+ return Effect.gen(function* () {
853
+ const run = yield* context.planRunService.getActiveRunRecord(params.threadId)
854
+ if (!run || run.status !== 'awaiting-human' || !run.waitingNodeId) {
855
+ return null
856
+ }
857
+
858
+ const responseComments = readHumanResponseComments(params.response)
859
+ const submittedResult: PlanNodeResultSubmission = {
860
+ structuredOutput: params.response,
861
+ artifacts: [],
862
+ notes: responseComments ?? 'Human response submitted.',
863
+ }
864
+ const waitingNodeId = run.waitingNodeId
865
+ const spec = yield* context.planRunService.getPlanSpecById(run.planSpecId)
866
+ const nodeSpecs = yield* context.planRunService.listNodeSpecs(spec.id)
867
+ const nodeSpec = nodeSpecs.find((candidate) => candidate.nodeId === waitingNodeId)
868
+ if (!nodeSpec) {
869
+ return yield* new NotFoundError({
870
+ resource: 'plan-node',
871
+ id: waitingNodeId,
872
+ message: `Waiting node "${waitingNodeId}" does not exist.`,
873
+ })
874
+ }
875
+
876
+ const nodeRun = yield* context.planRunService.getNodeRunByNodeId(run.id, waitingNodeId)
877
+ const approval = yield* resolveApprovalForWaitingNodeEffect(context, {
878
+ approvalId: params.approvalId,
879
+ nodeRun,
880
+ nodeLabel: nodeSpec.label,
881
+ })
882
+ const existingArtifacts = yield* context.planRunService.listArtifacts(run.id)
883
+ const nextCheckpointSequence = yield* context.planRunService.getNextCheckpointSequence(run.id)
884
+ const validation = context.planValidatorService.validateNodeResult({
885
+ draft: { schemas: spec.schemaRegistry },
886
+ node: toPlanNodeValidationSpec(nodeSpec),
887
+ result: submittedResult,
888
+ })
889
+
890
+ return {
891
+ run,
892
+ spec,
893
+ nodeSpecs,
894
+ nodeSpec,
895
+ nodeRun,
896
+ approval,
897
+ existingArtifacts,
898
+ nextCheckpointSequence,
899
+ validation,
900
+ responseComments,
901
+ submittedResult,
902
+ } satisfies LoadedHumanNodeResponseSubmission
903
+ })
904
+ }
905
+
906
+ function persistHumanNodeResponseAttemptEffect(
907
+ context: Pick<PlanExecutorContext, 'planApprovalService' | 'planRunService'>,
908
+ params: {
909
+ tx: DatabaseTransaction
910
+ submission: LoadedHumanNodeResponseSubmission
911
+ respondedBy: string
912
+ approvalMessageId?: string
913
+ response: HumanNodeResponsePayload
914
+ },
915
+ ) {
916
+ const { tx, submission, respondedBy, approvalMessageId, response } = params
917
+
918
+ return Effect.gen(function* () {
919
+ const approvalStatus = deriveApprovalStatus(response)
920
+ yield* context.planApprovalService
921
+ .updateApprovalResponse({
922
+ tx,
923
+ approval: submission.approval,
924
+ status: approvalStatus,
925
+ response,
926
+ respondedBy,
927
+ approvalMessageId,
928
+ comments: submission.responseComments,
929
+ requiredEdits: readHumanRequiredEdits(response),
930
+ })
931
+ .pipe(Effect.mapError((cause) => new PlanExecutorInternalError({ message: toError(cause).message, cause })))
932
+
933
+ const attempt = yield* createAttempt({
934
+ tx,
935
+ run: submission.run,
936
+ nodeRun: submission.nodeRun,
937
+ emittedBy: respondedBy,
938
+ result: submission.submittedResult,
939
+ status: submission.validation.blocking.length > 0 ? 'failed' : 'completed',
940
+ failureClass: submission.validation.failureClass,
941
+ })
942
+
943
+ const issues = yield* persistValidationIssues({
944
+ tx,
945
+ run: submission.run,
946
+ spec: submission.spec,
947
+ attemptId: attempt.id,
948
+ nodeId: submission.nodeRun.nodeId,
949
+ issues: [...submission.validation.blocking, ...submission.validation.warnings],
950
+ })
951
+
952
+ void (yield* fromPromise(() =>
953
+ tx
954
+ .update(ensureRecordId(attempt.id, TABLES.PLAN_NODE_ATTEMPT))
955
+ .content({
956
+ runId: ensureRecordId(attempt.runId, TABLES.PLAN_RUN),
957
+ nodeRunId: ensureRecordId(attempt.nodeRunId, TABLES.PLAN_NODE_RUN),
958
+ nodeId: attempt.nodeId,
959
+ emittedBy: attempt.emittedBy,
960
+ status: attempt.status,
961
+ structuredOutput: response,
962
+ validationIssueIds: issues.map((issue: { id: RecordIdInput }) =>
963
+ ensureRecordId(issue.id, TABLES.PLAN_VALIDATION_ISSUE),
964
+ ),
965
+ ...(attempt.notes ? { notes: attempt.notes } : {}),
966
+ ...(attempt.failureClass ? { failureClass: attempt.failureClass } : {}),
967
+ })
968
+ .output('after'),
969
+ ).pipe(Effect.map((row) => PlanNodeAttemptSchema.parse(row))))
970
+
971
+ const nextNodeRun = PlanNodeRunSchema.parse(
972
+ yield* fromPromise(() =>
973
+ tx
974
+ .update(ensureRecordId(submission.nodeRun.id, TABLES.PLAN_NODE_RUN))
975
+ .content(
976
+ toNodeRunData(submission.nodeRun, {
977
+ status: submission.validation.blocking.length > 0 ? 'blocked' : 'completed',
978
+ attemptCount: submission.nodeRun.attemptCount + 1,
979
+ latestAttemptId: attempt.id,
980
+ latestStructuredOutput: response,
981
+ latestNotes: submission.responseComments ?? null,
982
+ blockedReason: submission.validation.blocking[0]?.message ?? null,
983
+ failureClass: submission.validation.blocking.length > 0 ? submission.validation.failureClass : null,
984
+ ...(submission.validation.blocking.length > 0 ? {} : { completedAt: nowDate() }),
985
+ }),
986
+ )
987
+ .output('after'),
988
+ ),
989
+ )
990
+
991
+ const nodeRuns = replaceNodeRunInList(yield* context.planRunService.listNodeRuns(submission.run.id), nextNodeRun)
992
+
993
+ return { attemptId: attempt.id, approvalStatus, nextNodeRun, nodeRuns } satisfies PersistedHumanNodeResponseAttempt
994
+ })
995
+ }
996
+
997
+ function handleBlockedHumanNodeResponseEffect(
998
+ context: PlanExecutorContext,
999
+ params: {
1000
+ tx: DatabaseTransaction
1001
+ emittedEvents: CapturedPlanEvents
1002
+ submission: LoadedHumanNodeResponseSubmission
1003
+ persisted: PersistedHumanNodeResponseAttempt
1004
+ respondedBy: string
1005
+ },
1006
+ ) {
1007
+ const { tx, emittedEvents, submission, persisted, respondedBy } = params
1008
+
1009
+ return Effect.gen(function* () {
1010
+ const blockedRun = yield* replaceRun(tx, submission.run, {
1011
+ status: 'blocked',
1012
+ currentNodeId: persisted.nextNodeRun.nodeId,
1013
+ waitingNodeId: null,
1014
+ readyNodeIds: [],
1015
+ failureCount: submission.run.failureCount + 1,
1016
+ })
1017
+
1018
+ yield* emitEvent({
1019
+ tx,
1020
+ run: blockedRun,
1021
+ spec: submission.spec,
1022
+ nodeId: persisted.nextNodeRun.nodeId,
1023
+ attemptId: persisted.attemptId,
1024
+ approvalId: submission.approval.id,
1025
+ eventType: 'approval-resolved',
1026
+ fromStatus: submission.run.status,
1027
+ toStatus: blockedRun.status,
1028
+ message: `Human response for node "${submission.nodeSpec.label}" blocked execution.`,
1029
+ detail: { issues: submission.validation.blocking.map((issue) => issue.code) },
1030
+ emittedBy: respondedBy,
1031
+ capturedEvents: emittedEvents,
1032
+ })
1033
+
1034
+ yield* saveAndAttachCheckpointEffect(context, {
1035
+ tx,
1036
+ run: blockedRun,
1037
+ spec: submission.spec,
1038
+ nodeRuns: persisted.nodeRuns,
1039
+ artifacts: submission.existingArtifacts,
1040
+ sequence: submission.nextCheckpointSequence,
1041
+ reason: 'human-response-blocked',
1042
+ capturedEvents: emittedEvents,
1043
+ })
1044
+
1045
+ const latestRun = yield* context.planRunService.getRunById(submission.run.id)
1046
+ return yield* serializeRunFull(context.planRunService, latestRun)
1047
+ })
1048
+ }
1049
+
1050
+ function handleAcceptedHumanNodeResponseEffect(
1051
+ context: PlanExecutorContext,
1052
+ params: {
1053
+ tx: DatabaseTransaction
1054
+ emittedEvents: CapturedPlanEvents
1055
+ submission: LoadedHumanNodeResponseSubmission
1056
+ persisted: PersistedHumanNodeResponseAttempt
1057
+ respondedBy: string
1058
+ },
1059
+ ) {
1060
+ const { tx, emittedEvents, submission, persisted, respondedBy } = params
1061
+
1062
+ return Effect.gen(function* () {
1063
+ const synced = yield* fromPromise(() =>
1064
+ syncRunGraph(context, {
1065
+ tx,
1066
+ run: submission.run,
1067
+ spec: submission.spec,
1068
+ nodeSpecs: submission.nodeSpecs,
1069
+ nodeRuns: persisted.nodeRuns,
1070
+ artifacts: submission.existingArtifacts,
1071
+ emittedBy: respondedBy,
1072
+ capturedEvents: emittedEvents,
1073
+ }),
1074
+ )
1075
+
1076
+ yield* emitEvent({
1077
+ tx,
1078
+ run: synced.run,
1079
+ spec: submission.spec,
1080
+ nodeId: persisted.nextNodeRun.nodeId,
1081
+ attemptId: persisted.attemptId,
1082
+ approvalId: submission.approval.id,
1083
+ eventType: 'approval-resolved',
1084
+ fromStatus: submission.run.status,
1085
+ toStatus: synced.run.status,
1086
+ message: `Human response for node "${submission.nodeSpec.label}" accepted.`,
1087
+ detail: { approvalStatus: persisted.approvalStatus },
1088
+ emittedBy: respondedBy,
1089
+ capturedEvents: emittedEvents,
1090
+ })
1091
+
1092
+ yield* saveAndAttachCheckpointEffect(context, {
1093
+ tx,
1094
+ run: synced.run,
1095
+ spec: submission.spec,
1096
+ nodeRuns: synced.nodeRuns,
1097
+ artifacts: synced.artifacts,
1098
+ sequence: submission.nextCheckpointSequence,
1099
+ reason: 'human-response-complete',
1100
+ capturedEvents: emittedEvents,
1101
+ })
1102
+
1103
+ const latestRun = yield* context.planRunService.getRunById(submission.run.id)
1104
+ return yield* serializeRunFull(context.planRunService, latestRun)
1105
+ })
1106
+ }
1107
+
1108
+ /** @lintignore */
1109
+ function submitNodeResult(
1110
+ context: PlanExecutorContext,
1111
+ params: {
1112
+ threadId: RecordIdInput
1113
+ runId: string
1114
+ nodeId: string
1115
+ emittedBy: string
1116
+ result: PlanNodeResultSubmission
1117
+ },
1118
+ ): Promise<ExecutionPlanToolResultData> {
1119
+ const {
1120
+ databaseService,
1121
+ generatedDocumentStorageService,
1122
+ planCompletionSideEffects,
1123
+ planEventDeliveryService,
1124
+ planRunService,
1125
+ } = context
1126
+
1127
+ return Effect.gen(function* () {
1128
+ const submission = yield* loadNodeResultSubmissionContextEffect(context, params)
1129
+ const publishedArtifactStorageKeys: string[] = []
1130
+
1131
+ yield* withTransactionAndEventsEffect({
1132
+ db: databaseService,
1133
+ planEventDeliveryService,
1134
+ run: (tx, emittedEvents) =>
1135
+ Effect.gen(function* () {
1136
+ const persisted = yield* persistNodeResultAttemptEffect(context, {
1137
+ tx,
1138
+ submission,
1139
+ nodeId: params.nodeId,
1140
+ emittedBy: params.emittedBy,
1141
+ result: params.result,
1142
+ publishedArtifactStorageKeys,
1143
+ })
1144
+
1145
+ if (submission.validation.blocking.length > 0) {
1146
+ return yield* handleBlockedNodeResultEffect(context, {
1147
+ tx,
1148
+ emittedEvents,
1149
+ submission,
1150
+ persisted,
1151
+ emittedBy: params.emittedBy,
1152
+ })
1153
+ }
1154
+
1155
+ return yield* handleSuccessfulNodeResultEffect(context, {
1156
+ tx,
1157
+ emittedEvents,
1158
+ submission,
1159
+ persisted,
1160
+ emittedBy: params.emittedBy,
1161
+ result: params.result,
1162
+ })
1163
+ }),
1164
+ }).pipe(
1165
+ Effect.tapError(() =>
1166
+ Effect.forEach(publishedArtifactStorageKeys, (storageKey) =>
1167
+ generatedDocumentStorageService.deleteTextArtifact(storageKey).pipe(Effect.ignore),
1168
+ ).pipe(Effect.asVoid),
1169
+ ),
1170
+ )
1171
+
1172
+ const orgId = recordIdToString(submission.run.organizationId, TABLES.ORGANIZATION)
1173
+ const runIdStr = recordIdToString(submission.run.id, TABLES.PLAN_RUN)
1174
+
1175
+ yield* fromPromise(() =>
1176
+ planCompletionSideEffects.runPlanNodeCompletionSideEffects({
1177
+ runId: runIdStr,
1178
+ organizationId: orgId,
1179
+ nodeId: params.nodeId,
1180
+ nodeLabel: submission.nodeSpec.label,
1181
+ nodeOwnerRef: submission.nodeSpec.owner.ref,
1182
+ nodeOwnerType: submission.nodeSpec.owner.executorType,
1183
+ nodeType: submission.nodeSpec.type,
1184
+ nodeStartedAt: submission.nodeRun.startedAt,
1185
+ nodeAttemptCount: submission.nodeRun.attemptCount + 1,
1186
+ artifactCount: params.result.artifacts.length,
1187
+ validationIssues: [...submission.validation.blocking, ...submission.validation.warnings],
1188
+ }),
1189
+ ).pipe(
1190
+ Effect.catchTag('PlanExecutorInternalError', (error) =>
1191
+ Effect.sync(() => {
1192
+ aiLogger.warn`Failed to record node completion metrics for run ${runIdStr} node ${params.nodeId}: ${error.message}`
1193
+ }),
1194
+ ),
1195
+ Effect.forkDetach,
1196
+ )
1197
+
1198
+ const updatedRun = yield* planRunService.getRunById(submission.run.id)
1199
+ if (updatedRun.status === 'completed') {
1200
+ yield* fromPromise(() =>
1201
+ planCompletionSideEffects.runPlanCompletionSideEffectsSafely({ runId: runIdStr, organizationId: orgId }),
1202
+ ).pipe(
1203
+ Effect.catchTag('PlanExecutorInternalError', () => Effect.void),
1204
+ Effect.forkDetach,
1205
+ )
1206
+ }
1207
+
1208
+ const snapshot = yield* serializeRunFull(planRunService, updatedRun)
1209
+
1210
+ return buildExecutionPlanToolResult({
1211
+ action: 'node-result-submitted',
1212
+ plan: snapshot,
1213
+ message: `Submitted result for node "${submission.nodeSpec.label}".`,
1214
+ })
1215
+ }).pipe(runPromise)
1216
+ }
1217
+
1218
+ /** @lintignore */
1219
+ function submitHumanNodeResponse(
1220
+ context: PlanExecutorContext,
1221
+ params: {
1222
+ threadId: RecordIdInput
1223
+ approvalId?: string
1224
+ respondedBy: string
1225
+ response: HumanNodeResponsePayload
1226
+ approvalMessageId?: string
1227
+ },
1228
+ ): Promise<SerializableExecutionPlan | null> {
1229
+ const { databaseService, planEventDeliveryService } = context
1230
+
1231
+ return Effect.gen(function* () {
1232
+ const submission = yield* loadHumanNodeResponseContextEffect(context, params)
1233
+ if (!submission) {
1234
+ return null
1235
+ }
1236
+
1237
+ return yield* withTransactionAndEventsEffect({
1238
+ db: databaseService,
1239
+ planEventDeliveryService,
1240
+ run: (tx, emittedEvents) =>
1241
+ Effect.gen(function* () {
1242
+ const persisted = yield* persistHumanNodeResponseAttemptEffect(context, {
1243
+ tx,
1244
+ submission,
1245
+ respondedBy: params.respondedBy,
1246
+ approvalMessageId: params.approvalMessageId,
1247
+ response: params.response,
1248
+ })
1249
+
1250
+ if (submission.validation.blocking.length > 0) {
1251
+ return yield* handleBlockedHumanNodeResponseEffect(context, {
1252
+ tx,
1253
+ emittedEvents,
1254
+ submission,
1255
+ persisted,
1256
+ respondedBy: params.respondedBy,
1257
+ })
1258
+ }
1259
+
1260
+ return yield* handleAcceptedHumanNodeResponseEffect(context, {
1261
+ tx,
1262
+ emittedEvents,
1263
+ submission,
1264
+ persisted,
1265
+ respondedBy: params.respondedBy,
1266
+ })
1267
+ }),
1268
+ })
1269
+ }).pipe(runPromise)
1270
+ }
1271
+
1272
+ /** @lintignore */
1273
+ function resumeRun(
1274
+ context: PlanExecutorContext,
1275
+ params: { threadId: RecordIdInput; runId: string; emittedBy: string },
1276
+ ): Promise<ExecutionPlanToolResultData> {
1277
+ const { databaseService, planEventDeliveryService, planRunService } = context
1278
+
1279
+ return Effect.gen(function* () {
1280
+ const run = yield* planRunService.getRunById(params.runId)
1281
+ const spec = yield* planRunService.getPlanSpecById(run.planSpecId)
1282
+ const nodeSpecs = yield* planRunService.listNodeSpecs(spec.id)
1283
+ const nodeRuns = yield* planRunService.listNodeRuns(run.id)
1284
+ const artifacts = yield* planRunService.listArtifacts(run.id)
1285
+ const latestCheckpoint = yield* planRunService.getLatestCheckpoint(run.id)
1286
+ const nextCheckpointSequence = yield* planRunService.getNextCheckpointSequence(run.id)
1287
+
1288
+ const snapshot = yield* withTransactionAndEventsEffect({
1289
+ db: databaseService,
1290
+ planEventDeliveryService,
1291
+ run: (tx, emittedEvents) =>
1292
+ Effect.gen(function* () {
1293
+ let currentNodeRuns = [...nodeRuns]
1294
+ for (const currentNodeRun of currentNodeRuns.filter((candidate) => candidate.status === 'running')) {
1295
+ const resetNodeRun = PlanNodeRunSchema.parse(
1296
+ yield* fromPromise(() =>
1297
+ tx
1298
+ .update(ensureRecordId(currentNodeRun.id, TABLES.PLAN_NODE_RUN))
1299
+ .content(
1300
+ toNodeRunData(currentNodeRun, {
1301
+ status: 'ready',
1302
+ readyAt: nowDate(),
1303
+ startedAt: currentNodeRun.startedAt ?? nowDate(),
1304
+ }),
1305
+ )
1306
+ .output('after'),
1307
+ ),
1308
+ )
1309
+ currentNodeRuns = currentNodeRuns.map((candidate) =>
1310
+ candidate.nodeId === resetNodeRun.nodeId ? resetNodeRun : candidate,
1311
+ )
1312
+ }
1313
+
1314
+ const resetRun = yield* replaceRun(tx, run, {
1315
+ status: run.status === 'awaiting-human' ? 'awaiting-human' : 'running',
1316
+ currentNodeId: run.status === 'awaiting-human' ? (run.currentNodeId ?? null) : null,
1317
+ waitingNodeId: run.status === 'awaiting-human' ? (run.waitingNodeId ?? null) : null,
1318
+ readyNodeIds: currentNodeRuns
1319
+ .filter((candidate) => candidate.status === 'ready')
1320
+ .map((candidate) => candidate.nodeId),
1321
+ })
1322
+
1323
+ yield* emitEvent({
1324
+ tx,
1325
+ run: resetRun,
1326
+ spec,
1327
+ eventType: 'run-resumed',
1328
+ fromStatus: run.status,
1329
+ toStatus: resetRun.status,
1330
+ message: `Run "${spec.title}" resumed from the latest checkpoint.`,
1331
+ detail: latestCheckpoint
1332
+ ? { checkpointId: recordIdToString(latestCheckpoint.id, TABLES.PLAN_CHECKPOINT) }
1333
+ : {},
1334
+ emittedBy: params.emittedBy,
1335
+ capturedEvents: emittedEvents,
1336
+ })
1337
+
1338
+ const synced =
1339
+ resetRun.status === 'awaiting-human'
1340
+ ? { run: resetRun, nodeRuns: currentNodeRuns, artifacts }
1341
+ : yield* fromPromise(() =>
1342
+ syncRunGraph(context, {
1343
+ tx,
1344
+ run: resetRun,
1345
+ spec,
1346
+ nodeSpecs,
1347
+ nodeRuns: currentNodeRuns,
1348
+ artifacts,
1349
+ emittedBy: params.emittedBy,
1350
+ capturedEvents: emittedEvents,
1351
+ }),
1352
+ )
1353
+
1354
+ const checkpoint = yield* saveCheckpointWithContext(context, {
1355
+ tx,
1356
+ run: synced.run,
1357
+ spec,
1358
+ nodeRuns: synced.nodeRuns,
1359
+ artifacts: synced.artifacts,
1360
+ sequence: nextCheckpointSequence,
1361
+ reason: 'run-resumed',
1362
+ capturedEvents: emittedEvents,
1363
+ })
1364
+ yield* attachCheckpoint(tx, synced.run, checkpoint)
1365
+ const latestRun = yield* planRunService.getRunById(run.id)
1366
+ return yield* serializeRunFull(planRunService, latestRun)
1367
+ }),
1368
+ })
1369
+
1370
+ return buildExecutionPlanToolResult({
1371
+ action: 'run-resumed',
1372
+ plan: snapshot,
1373
+ message: `Resumed execution run "${snapshot.title}".`,
1374
+ })
1375
+ }).pipe(runPromise)
1376
+ }
1377
+
1378
+ /** @lintignore */
1379
+ function transitionNodeToRunning(
1380
+ context: PlanExecutorContext,
1381
+ params: { runId: string; nodeId: string },
1382
+ ): Promise<void> {
1383
+ const { databaseService, planRunService } = context
1384
+
1385
+ return Effect.gen(function* () {
1386
+ const run = yield* planRunService.getRunById(params.runId)
1387
+ const nodeRun = yield* planRunService.getNodeRunByNodeId(run.id, params.nodeId)
1388
+ if (nodeRun.status !== 'ready') return
1389
+
1390
+ yield* withDatabaseTransactionEffect(databaseService, (tx) =>
1391
+ Effect.gen(function* () {
1392
+ const runningNodeRun = PlanNodeRunSchema.parse(
1393
+ yield* fromPromise(() =>
1394
+ tx
1395
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1396
+ .content(toNodeRunData(nodeRun, { status: 'running', startedAt: nodeRun.startedAt ?? nowDate() }))
1397
+ .output('after'),
1398
+ ),
1399
+ )
1400
+
1401
+ const nodeRuns = yield* planRunService.listNodeRuns(run.id)
1402
+ yield* replaceRun(tx, run, {
1403
+ status: 'running',
1404
+ currentNodeId: runningNodeRun.nodeId,
1405
+ waitingNodeId: null,
1406
+ readyNodeIds: nodeRuns
1407
+ .filter((candidate) => candidate.status === 'ready' && candidate.nodeId !== runningNodeRun.nodeId)
1408
+ .map((candidate) => candidate.nodeId),
1409
+ })
1410
+ }),
1411
+ )
1412
+ }).pipe(runPromise)
1413
+ }
1414
+
1415
+ /** @lintignore */
1416
+ function blockNodeOnDispatchFailure(
1417
+ context: PlanExecutorContext,
1418
+ params: {
1419
+ threadId: RecordIdInput
1420
+ runId: string
1421
+ nodeId: string
1422
+ emittedBy: string
1423
+ message: string
1424
+ failureClass: PlanFailureClass
1425
+ },
1426
+ ): Promise<SerializableExecutionPlan> {
1427
+ const { databaseService, planEventDeliveryService, planRunService } = context
1428
+
1429
+ return Effect.gen(function* () {
1430
+ const run = yield* planRunService.getRunById(params.runId)
1431
+ const spec = yield* planRunService.getPlanSpecById(run.planSpecId)
1432
+ const nodeSpec = yield* planRunService.getNodeSpecByNodeId(spec.id, params.nodeId)
1433
+ const nodeRun = yield* planRunService.getNodeRunByNodeId(run.id, params.nodeId)
1434
+ const artifacts = yield* planRunService.listArtifacts(run.id)
1435
+ const nextCheckpointSequence = yield* planRunService.getNextCheckpointSequence(run.id)
1436
+
1437
+ return yield* withTransactionAndEventsEffect({
1438
+ db: databaseService,
1439
+ planEventDeliveryService,
1440
+ run: (tx, emittedEvents) =>
1441
+ Effect.gen(function* () {
1442
+ const blockedNodeRun =
1443
+ nodeRun.status === 'blocked'
1444
+ ? nodeRun
1445
+ : PlanNodeRunSchema.parse(
1446
+ yield* fromPromise(() =>
1447
+ tx
1448
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1449
+ .content(
1450
+ toNodeRunData(nodeRun, {
1451
+ status: 'blocked',
1452
+ blockedReason: params.message,
1453
+ failureClass: params.failureClass,
1454
+ }),
1455
+ )
1456
+ .output('after'),
1457
+ ),
1458
+ )
1459
+
1460
+ const blockedRun = yield* replaceRun(tx, run, {
1461
+ status: 'blocked',
1462
+ currentNodeId: blockedNodeRun.nodeId,
1463
+ waitingNodeId: null,
1464
+ readyNodeIds: [],
1465
+ failureCount: run.failureCount + 1,
1466
+ })
1467
+
1468
+ yield* emitEvent({
1469
+ tx,
1470
+ run: blockedRun,
1471
+ spec,
1472
+ nodeId: blockedNodeRun.nodeId,
1473
+ eventType: 'node-blocked',
1474
+ fromStatus: nodeRun.status,
1475
+ toStatus: blockedNodeRun.status,
1476
+ message: `Node "${nodeSpec.label}" failed during owner dispatch.`,
1477
+ detail: { failureClass: params.failureClass, phase: 'dispatch', error: params.message },
1478
+ emittedBy: params.emittedBy,
1479
+ capturedEvents: emittedEvents,
1480
+ })
1481
+
1482
+ const checkpointNodeRuns = (yield* planRunService.listNodeRuns(run.id)).map((candidate) =>
1483
+ candidate.nodeId === blockedNodeRun.nodeId ? blockedNodeRun : candidate,
1484
+ )
1485
+ const checkpoint = yield* saveCheckpointWithContext(context, {
1486
+ tx,
1487
+ run: blockedRun,
1488
+ spec,
1489
+ nodeRuns: checkpointNodeRuns,
1490
+ artifacts,
1491
+ sequence: nextCheckpointSequence,
1492
+ reason: 'owner-dispatch-failed',
1493
+ capturedEvents: emittedEvents,
1494
+ })
1495
+ yield* attachCheckpoint(tx, blockedRun, checkpoint)
1496
+ const latestRun = yield* planRunService.getRunById(run.id)
1497
+ return yield* serializeRunFull(planRunService, latestRun)
1498
+ }),
1499
+ })
1500
+ }).pipe(runPromise)
1501
+ }
1502
+
1503
+ /** @lintignore */
1504
+ function promoteDelayedNode(
1505
+ context: PlanExecutorContext,
1506
+ params: { runId: string; nodeId: string; emittedBy: string },
1507
+ ): Promise<void> {
1508
+ const { databaseService, planEventDeliveryService, planRunService } = context
1509
+
1510
+ return Effect.gen(function* () {
1511
+ const run = yield* planRunService.getRunById(params.runId)
1512
+ if (run.status === 'completed' || run.status === 'failed' || run.status === 'aborted') {
1513
+ return
1514
+ }
1515
+
1516
+ const spec = yield* planRunService.getPlanSpecById(run.planSpecId)
1517
+ const nodeSpecs = yield* planRunService.listNodeSpecs(spec.id)
1518
+ const nodeRun = yield* planRunService.getNodeRunByNodeId(run.id, params.nodeId)
1519
+ if (nodeRun.status !== 'scheduled') return
1520
+
1521
+ const nodeRuns = yield* planRunService.listNodeRuns(run.id)
1522
+ const artifacts = yield* planRunService.listArtifacts(run.id)
1523
+ const nextCheckpointSequence = yield* planRunService.getNextCheckpointSequence(run.id)
1524
+
1525
+ yield* withTransactionAndEventsEffect({
1526
+ db: databaseService,
1527
+ planEventDeliveryService,
1528
+ run: (tx, emittedEvents) =>
1529
+ Effect.gen(function* () {
1530
+ const readyNodeRun = PlanNodeRunSchema.parse(
1531
+ yield* fromPromise(() =>
1532
+ tx
1533
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1534
+ .content(toNodeRunData(nodeRun, { status: 'ready', readyAt: nowDate() }))
1535
+ .output('after'),
1536
+ ),
1537
+ )
1538
+
1539
+ const updatedNodeRuns = nodeRuns.map((candidate) =>
1540
+ candidate.nodeId === readyNodeRun.nodeId ? readyNodeRun : candidate,
1541
+ )
1542
+
1543
+ const currentNodeSpec = nodeSpecs.find((s) => s.nodeId === params.nodeId)
1544
+ yield* emitEvent({
1545
+ tx,
1546
+ run,
1547
+ spec,
1548
+ nodeId: readyNodeRun.nodeId,
1549
+ eventType: 'node-ready',
1550
+ fromStatus: nodeRun.status,
1551
+ toStatus: readyNodeRun.status,
1552
+ message: `Node "${currentNodeSpec?.label ?? params.nodeId}" promoted to ready after delay.`,
1553
+ emittedBy: params.emittedBy,
1554
+ capturedEvents: emittedEvents,
1555
+ })
1556
+
1557
+ const synced = yield* fromPromise(() =>
1558
+ syncRunGraph(context, {
1559
+ tx,
1560
+ run,
1561
+ spec,
1562
+ nodeSpecs,
1563
+ nodeRuns: updatedNodeRuns,
1564
+ artifacts,
1565
+ emittedBy: params.emittedBy,
1566
+ capturedEvents: emittedEvents,
1567
+ }),
1568
+ )
1569
+
1570
+ const checkpoint = yield* saveCheckpointWithContext(context, {
1571
+ tx,
1572
+ run: synced.run,
1573
+ spec,
1574
+ nodeRuns: synced.nodeRuns,
1575
+ artifacts: synced.artifacts,
1576
+ sequence: nextCheckpointSequence,
1577
+ reason: 'delayed-node-promoted',
1578
+ capturedEvents: emittedEvents,
1579
+ })
1580
+ yield* attachCheckpoint(tx, synced.run, checkpoint)
1581
+ }),
1582
+ })
1583
+ }).pipe(runPromise)
1584
+ }
1585
+
1586
+ export function makePlanExecutorService(deps: PlanExecutorDeps) {
1587
+ const context: PlanExecutorContext = {
1588
+ databaseService: deps.db,
1589
+ generatedDocumentStorageService: deps.storage,
1590
+ artifactService: deps.artifactService,
1591
+ feedbackLoopService: deps.feedbackLoopService,
1592
+ institutionalMemoryService: deps.institutionalMemoryService,
1593
+ planApprovalService: deps.planApprovalService,
1594
+ planArtifactService: deps.planArtifactService,
1595
+ planCheckpointService: deps.planCheckpointService,
1596
+ planCoordinationService: deps.planCoordinationService,
1597
+ planEventDeliveryService: deps.planEventDeliveryService,
1598
+ planRunService: deps.planRunService,
1599
+ planSchedulerService: deps.planSchedulerService,
1600
+ planValidatorService: deps.planValidatorService,
1601
+ qualityMetricsService: deps.qualityMetricsService,
1602
+ planCompletionSideEffects: makePlanCompletionSideEffects({
1603
+ databaseService: deps.db,
1604
+ feedbackLoopService: deps.feedbackLoopService,
1605
+ institutionalMemoryService: deps.institutionalMemoryService,
1606
+ planEventDeliveryService: deps.planEventDeliveryService,
1607
+ planRunService: deps.planRunService,
1608
+ qualityMetricsService: deps.qualityMetricsService,
1609
+ }),
1610
+ }
1611
+
1612
+ return {
1613
+ submitNodeResult: (params: Parameters<typeof submitNodeResult>[1]) => submitNodeResult(context, params),
1614
+ submitHumanNodeResponse: (params: Parameters<typeof submitHumanNodeResponse>[1]) =>
1615
+ submitHumanNodeResponse(context, params),
1616
+ resumeRun: (params: Parameters<typeof resumeRun>[1]) => resumeRun(context, params),
1617
+ transitionNodeToRunning: (params: Parameters<typeof transitionNodeToRunning>[1]) =>
1618
+ transitionNodeToRunning(context, params),
1619
+ blockNodeOnDispatchFailure: (params: Parameters<typeof blockNodeOnDispatchFailure>[1]) =>
1620
+ blockNodeOnDispatchFailure(context, params),
1621
+ promoteDelayedNode: (params: Parameters<typeof promoteDelayedNode>[1]) => promoteDelayedNode(context, params),
1622
+ syncRunGraph: (params: Parameters<typeof syncRunGraph>[1]) => syncRunGraph(context, params),
1623
+ resolveFailureAction,
1624
+ buildResolvedInput,
1625
+ }
1626
+ }
1627
+
1628
+ export class PlanExecutorServiceTag extends Context.Service<PlanExecutorServiceTag, PlanExecutorService>()(
1629
+ 'PlanExecutorService',
1630
+ ) {}
1631
+
1632
+ export const PlanExecutorServiceLive = Layer.effect(
1633
+ PlanExecutorServiceTag,
1634
+ Effect.gen(function* () {
1635
+ const db = yield* DatabaseServiceTag
1636
+ const storage = yield* GeneratedDocumentStorageServiceTag
1637
+ return makePlanExecutorService({
1638
+ db,
1639
+ storage,
1640
+ artifactService: yield* ArtifactServiceTag,
1641
+ feedbackLoopService: yield* FeedbackLoopServiceTag,
1642
+ institutionalMemoryService: yield* InstitutionalMemoryServiceTag,
1643
+ planApprovalService: yield* PlanApprovalServiceTag,
1644
+ planArtifactService: yield* PlanArtifactServiceTag,
1645
+ planCheckpointService: yield* PlanCheckpointServiceTag,
1646
+ planCoordinationService: yield* PlanCoordinationServiceTag,
1647
+ planEventDeliveryService: yield* PlanEventDeliveryServiceTag,
1648
+ planRunService: yield* PlanRunServiceTag,
1649
+ planSchedulerService: yield* PlanSchedulerServiceTag,
1650
+ planValidatorService: yield* PlanValidatorServiceTag,
1651
+ qualityMetricsService: yield* QualityMetricsServiceTag,
1652
+ })
1653
+ }),
1654
+ )