@lota-sdk/core 0.1.21 → 0.1.23
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.
- package/infrastructure/schema/02_execution_plan.surql +4 -0
- package/package.json +3 -3
- package/src/ai-gateway/ai-gateway.ts +4 -0
- package/src/create-runtime.ts +8 -0
- package/src/queues/index.ts +1 -0
- package/src/queues/plan-agent-heartbeat.queue.ts +100 -0
- package/src/redis/redis-lease-lock.ts +1 -1
- package/src/runtime/agent-runtime-policy.ts +41 -4
- package/src/runtime/execution-plan-visibility.ts +23 -0
- package/src/runtime/execution-plan.ts +1 -0
- package/src/runtime/runtime-extensions.ts +26 -0
- package/src/runtime/runtime-worker-registry.ts +9 -1
- package/src/services/agent-executor.service.ts +6 -0
- package/src/services/execution-plan.service.ts +51 -36
- package/src/services/index.ts +3 -0
- package/src/services/ownership-dispatcher.service.ts +50 -8
- package/src/services/plan-agent-heartbeat.service.ts +136 -0
- package/src/services/plan-agent-query.service.ts +238 -0
- package/src/services/plan-builder.service.ts +11 -1
- package/src/services/plan-compiler.service.ts +2 -0
- package/src/services/plan-deadline.service.ts +186 -44
- package/src/services/plan-event-delivery.service.ts +170 -0
- package/src/services/plan-executor.service.ts +107 -3
- package/src/services/plan-helpers.ts +13 -0
- package/src/services/plan-run.service.ts +4 -0
- package/src/services/plan-template.service.ts +0 -1
- package/src/services/workstream-turn-preparation.service.ts +452 -176
- package/src/services/workstream-turn.ts +101 -1
- package/src/services/workstream.service.ts +76 -16
- package/src/tools/execution-plan.tool.ts +0 -2
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { PlanEventSchema } from '@lota-sdk/shared'
|
|
2
|
+
import type { PlanEventRecord } from '@lota-sdk/shared'
|
|
3
|
+
|
|
4
|
+
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
5
|
+
import { databaseService } from '../db/service'
|
|
6
|
+
import { TABLES } from '../db/tables'
|
|
7
|
+
import { getRedisConnection } from '../redis'
|
|
8
|
+
import { resolvePlanNodeExecutionVisibility } from '../runtime/execution-plan-visibility'
|
|
9
|
+
import { getRuntimeAdapters } from '../runtime/runtime-extensions'
|
|
10
|
+
import type { LotaRuntimePlanEventEnvelope } from '../runtime/runtime-extensions'
|
|
11
|
+
import { planRunService } from './plan-run.service'
|
|
12
|
+
import { userService } from './user.service'
|
|
13
|
+
import { WorkstreamSchema } from './workstream.types'
|
|
14
|
+
|
|
15
|
+
const PLAN_EVENT_DELIVERED_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
16
|
+
|
|
17
|
+
function buildDeliveredKey(eventId: string): string {
|
|
18
|
+
return `plan-event:delivered:${eventId}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class PlanEventDeliveryService {
|
|
22
|
+
async dispatchEvents(events: PlanEventRecord[]): Promise<void> {
|
|
23
|
+
const runIds = new Set(events.map((event) => recordIdToString(event.runId, TABLES.PLAN_RUN)))
|
|
24
|
+
for (const runId of runIds) {
|
|
25
|
+
await this.dispatchUndeliveredEvents(runId)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async dispatchEvent(rawEvent: PlanEventRecord): Promise<void> {
|
|
30
|
+
const event = PlanEventSchema.parse(rawEvent)
|
|
31
|
+
const eventId = recordIdToString(event.id, TABLES.PLAN_EVENT)
|
|
32
|
+
const deliveredKey = buildDeliveredKey(eventId)
|
|
33
|
+
if (await getRedisConnection().exists(deliveredKey)) {
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await this.deliverEvent(event)
|
|
38
|
+
await getRedisConnection().set(deliveredKey, '1', 'PX', PLAN_EVENT_DELIVERED_TTL_MS)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async deliverEvent(rawEvent: PlanEventRecord): Promise<void> {
|
|
42
|
+
const event = PlanEventSchema.parse(rawEvent)
|
|
43
|
+
const run = await planRunService.getRunById(event.runId)
|
|
44
|
+
const [spec, nodeSpecs, nodeRuns, workstream] = await Promise.all([
|
|
45
|
+
planRunService.getPlanSpecById(run.planSpecId),
|
|
46
|
+
planRunService.listNodeSpecs(run.planSpecId),
|
|
47
|
+
planRunService.listNodeRuns(run.id),
|
|
48
|
+
databaseService.findOne(
|
|
49
|
+
TABLES.WORKSTREAM,
|
|
50
|
+
{ id: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM) },
|
|
51
|
+
WorkstreamSchema,
|
|
52
|
+
),
|
|
53
|
+
])
|
|
54
|
+
const nodeSpecsById = new Map(nodeSpecs.map((nodeSpec) => [nodeSpec.nodeId, nodeSpec]))
|
|
55
|
+
const nodeRunsById = new Map(nodeRuns.map((nodeRun) => [nodeRun.nodeId, nodeRun]))
|
|
56
|
+
const organizationId = recordIdToString(run.organizationId, TABLES.ORGANIZATION)
|
|
57
|
+
const workstreamId = recordIdToString(run.workstreamId, TABLES.WORKSTREAM)
|
|
58
|
+
const runIdString = recordIdToString(run.id, TABLES.PLAN_RUN)
|
|
59
|
+
const planSpecId = recordIdToString(spec.id, TABLES.PLAN_SPEC)
|
|
60
|
+
const userId = workstream?.userId
|
|
61
|
+
? recordIdToString(ensureRecordId(workstream.userId, TABLES.USER), TABLES.USER)
|
|
62
|
+
: undefined
|
|
63
|
+
const userName =
|
|
64
|
+
userId === undefined
|
|
65
|
+
? undefined
|
|
66
|
+
: await userService
|
|
67
|
+
.getUser(userId)
|
|
68
|
+
.then((user) => user.name)
|
|
69
|
+
.catch(() => undefined)
|
|
70
|
+
|
|
71
|
+
const envelope: LotaRuntimePlanEventEnvelope = {
|
|
72
|
+
event,
|
|
73
|
+
spec,
|
|
74
|
+
run,
|
|
75
|
+
...(event.nodeId ? { nodeSpec: nodeSpecsById.get(event.nodeId) } : {}),
|
|
76
|
+
...(event.nodeId ? { nodeRun: nodeRunsById.get(event.nodeId) } : {}),
|
|
77
|
+
organizationId,
|
|
78
|
+
workstreamId,
|
|
79
|
+
runId: runIdString,
|
|
80
|
+
planSpecId,
|
|
81
|
+
...(userId ? { userId } : {}),
|
|
82
|
+
...(userName ? { userName } : {}),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await this.dispatchAdapterEvent(envelope)
|
|
86
|
+
await this.enqueueWakeIfNeeded(envelope, nodeSpecsById)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async dispatchUndeliveredEvents(runId: string, options?: { limit?: number }): Promise<void> {
|
|
90
|
+
const run = await planRunService.getRunById(runId)
|
|
91
|
+
const events = await planRunService.listEvents(run.id, options?.limit ?? 200)
|
|
92
|
+
|
|
93
|
+
for (const rawEvent of events) {
|
|
94
|
+
const event = PlanEventSchema.parse(rawEvent)
|
|
95
|
+
const eventId = recordIdToString(event.id, TABLES.PLAN_EVENT)
|
|
96
|
+
const deliveredKey = buildDeliveredKey(eventId)
|
|
97
|
+
if (await getRedisConnection().exists(deliveredKey)) {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await this.dispatchEvent(event)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async dispatchAdapterEvent(envelope: LotaRuntimePlanEventEnvelope): Promise<void> {
|
|
106
|
+
const adapter = getRuntimeAdapters().events?.planEventAdapter
|
|
107
|
+
if (!adapter) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await adapter.onPlanEvent(envelope)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async enqueueWakeIfNeeded(
|
|
115
|
+
envelope: LotaRuntimePlanEventEnvelope,
|
|
116
|
+
nodeSpecsById: Map<string, NonNullable<LotaRuntimePlanEventEnvelope['nodeSpec']>>,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const wakeTarget = this.resolveWakeTarget(envelope, nodeSpecsById)
|
|
119
|
+
if (!wakeTarget) {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { enqueuePlanAgentHeartbeatWake } = await import('../queues/plan-agent-heartbeat.queue')
|
|
124
|
+
await enqueuePlanAgentHeartbeatWake({
|
|
125
|
+
organizationId: envelope.organizationId,
|
|
126
|
+
workstreamId: envelope.workstreamId,
|
|
127
|
+
runId: envelope.runId,
|
|
128
|
+
nodeId: wakeTarget.nodeId,
|
|
129
|
+
agentId: wakeTarget.agentId,
|
|
130
|
+
reason: wakeTarget.reason,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private resolveWakeTarget(
|
|
135
|
+
envelope: LotaRuntimePlanEventEnvelope,
|
|
136
|
+
nodeSpecsById: Map<string, NonNullable<LotaRuntimePlanEventEnvelope['nodeSpec']>>,
|
|
137
|
+
): { nodeId: string; agentId: string; reason: string } | null {
|
|
138
|
+
const currentNodeId = envelope.run.currentNodeId ?? envelope.event.nodeId
|
|
139
|
+
if (!currentNodeId) {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const effectiveNodeSpec =
|
|
144
|
+
(envelope.event.nodeId === currentNodeId ? envelope.nodeSpec : undefined) ?? nodeSpecsById.get(currentNodeId)
|
|
145
|
+
if (!effectiveNodeSpec || effectiveNodeSpec.owner.executorType !== 'agent') {
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const isVisible = resolvePlanNodeExecutionVisibility(envelope.spec, effectiveNodeSpec) === 'visible'
|
|
150
|
+
if (!isVisible) {
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const supportedEventTypes = new Set([
|
|
155
|
+
'node-ready',
|
|
156
|
+
'node-unblocked',
|
|
157
|
+
'approval-resolved',
|
|
158
|
+
'run-resumed',
|
|
159
|
+
'deadline-warning',
|
|
160
|
+
'escalation-triggered',
|
|
161
|
+
])
|
|
162
|
+
if (!supportedEventTypes.has(envelope.event.eventType)) {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { nodeId: currentNodeId, agentId: effectiveNodeSpec.owner.ref, reason: envelope.event.eventType }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const planEventDeliveryService = new PlanEventDeliveryService()
|
|
@@ -35,7 +35,8 @@ import { planApprovalService } from './plan-approval.service'
|
|
|
35
35
|
import { planArtifactService } from './plan-artifact.service'
|
|
36
36
|
import { planCheckpointService } from './plan-checkpoint.service'
|
|
37
37
|
import { planCoordinationService } from './plan-coordination.service'
|
|
38
|
-
import {
|
|
38
|
+
import { planEventDeliveryService } from './plan-event-delivery.service'
|
|
39
|
+
import { isExecutableConditionExpression, readPathValue } from './plan-helpers'
|
|
39
40
|
import { planRunService } from './plan-run.service'
|
|
40
41
|
import { planSchedulerService } from './plan-scheduler.service'
|
|
41
42
|
import type { PlanValidationIssueInput } from './plan-validator.service'
|
|
@@ -117,6 +118,9 @@ function evaluateCondition(expression: string | undefined, context: Record<strin
|
|
|
117
118
|
|
|
118
119
|
const match = normalized.match(/^([a-zA-Z0-9_.]+)\s*(==|!=|>=|<=|>|<)\s*(.+)$/)
|
|
119
120
|
if (!match) {
|
|
121
|
+
if (!isExecutableConditionExpression(normalized)) {
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
120
124
|
return Boolean(readPathValue(context, normalized))
|
|
121
125
|
}
|
|
122
126
|
|
|
@@ -164,6 +168,7 @@ type PlanNodeRunUpdate = Omit<
|
|
|
164
168
|
| 'resolvedInput'
|
|
165
169
|
| 'latestStructuredOutput'
|
|
166
170
|
| 'latestNotes'
|
|
171
|
+
| 'handoffContext'
|
|
167
172
|
| 'latestAttemptId'
|
|
168
173
|
| 'scheduledAt'
|
|
169
174
|
| 'readyAt'
|
|
@@ -175,6 +180,7 @@ type PlanNodeRunUpdate = Omit<
|
|
|
175
180
|
resolvedInput?: Record<string, unknown> | null
|
|
176
181
|
latestStructuredOutput?: Record<string, unknown> | null
|
|
177
182
|
latestNotes?: string | null
|
|
183
|
+
handoffContext?: Record<string, unknown> | null
|
|
178
184
|
latestAttemptId?: RecordIdInput | null
|
|
179
185
|
scheduledAt?: string | Date | null
|
|
180
186
|
readyAt?: string | Date | null
|
|
@@ -265,6 +271,13 @@ function toNodeRunData(nodeRun: PlanNodeRunRecord, patch: PlanNodeRunUpdate) {
|
|
|
265
271
|
: nodeRun.latestNotes
|
|
266
272
|
? { latestNotes: nodeRun.latestNotes }
|
|
267
273
|
: {}),
|
|
274
|
+
...(patch.handoffContext === null
|
|
275
|
+
? {}
|
|
276
|
+
: patch.handoffContext !== undefined
|
|
277
|
+
? { handoffContext: patch.handoffContext }
|
|
278
|
+
: nodeRun.handoffContext
|
|
279
|
+
? { handoffContext: nodeRun.handoffContext }
|
|
280
|
+
: {}),
|
|
268
281
|
...(patch.latestAttemptId === null
|
|
269
282
|
? {}
|
|
270
283
|
: patch.latestAttemptId !== undefined
|
|
@@ -404,9 +417,12 @@ class PlanExecutorService {
|
|
|
404
417
|
attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
|
|
405
418
|
webPolicy: nodeSpec.contextPolicy.webPolicy,
|
|
406
419
|
},
|
|
420
|
+
executionVisibility: nodeSpec.executionVisibility,
|
|
421
|
+
...(nodeSpec.escalation ? { escalation: nodeSpec.escalation } : {}),
|
|
407
422
|
},
|
|
408
423
|
result: params.result,
|
|
409
424
|
})
|
|
425
|
+
const emittedEvents: PlanEventRecord[] = []
|
|
410
426
|
|
|
411
427
|
await databaseService.withTransaction(async (tx) => {
|
|
412
428
|
const attempt = await this.createAttempt({
|
|
@@ -462,6 +478,7 @@ class PlanExecutorService {
|
|
|
462
478
|
latestAttemptId: finalizedAttempt.id,
|
|
463
479
|
latestStructuredOutput: params.result.structuredOutput ?? null,
|
|
464
480
|
latestNotes: params.result.notes ?? null,
|
|
481
|
+
handoffContext: params.result.handoffContext ?? null,
|
|
465
482
|
}),
|
|
466
483
|
)
|
|
467
484
|
.output('after'),
|
|
@@ -507,6 +524,21 @@ class PlanExecutorService {
|
|
|
507
524
|
message: `Validation failed for node "${nodeSpec.label}", scheduling retry.`,
|
|
508
525
|
detail: { issues: validation.blocking.map((issue) => issue.code) },
|
|
509
526
|
emittedBy: params.emittedBy,
|
|
527
|
+
capturedEvents: emittedEvents,
|
|
528
|
+
})
|
|
529
|
+
await this.emitEvent({
|
|
530
|
+
tx,
|
|
531
|
+
run,
|
|
532
|
+
spec,
|
|
533
|
+
nodeId: nextNodeRun.nodeId,
|
|
534
|
+
attemptId: finalizedAttempt.id,
|
|
535
|
+
eventType: 'node-unblocked',
|
|
536
|
+
fromStatus: nodeRun.status,
|
|
537
|
+
toStatus: nextNodeRun.status,
|
|
538
|
+
message: `Node "${nodeSpec.label}" is ready for another attempt.`,
|
|
539
|
+
detail: { retryCount: nextNodeRun.retryCount },
|
|
540
|
+
emittedBy: params.emittedBy,
|
|
541
|
+
capturedEvents: emittedEvents,
|
|
510
542
|
})
|
|
511
543
|
|
|
512
544
|
const synced = await this.syncRunGraph({
|
|
@@ -519,6 +551,7 @@ class PlanExecutorService {
|
|
|
519
551
|
),
|
|
520
552
|
artifacts: nextArtifacts,
|
|
521
553
|
emittedBy: params.emittedBy,
|
|
554
|
+
capturedEvents: emittedEvents,
|
|
522
555
|
})
|
|
523
556
|
|
|
524
557
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -528,6 +561,7 @@ class PlanExecutorService {
|
|
|
528
561
|
artifacts: synced.artifacts,
|
|
529
562
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
530
563
|
reason: 'node-result-retry',
|
|
564
|
+
capturedEvents: emittedEvents,
|
|
531
565
|
})
|
|
532
566
|
|
|
533
567
|
await this.attachCheckpoint(tx, synced.run, checkpoint)
|
|
@@ -587,6 +621,7 @@ class PlanExecutorService {
|
|
|
587
621
|
message: `Node "${nodeSpec.label}" requires human review before continuing.`,
|
|
588
622
|
detail: { issues: validation.blocking.map((issue) => issue.code) },
|
|
589
623
|
emittedBy: params.emittedBy,
|
|
624
|
+
capturedEvents: emittedEvents,
|
|
590
625
|
})
|
|
591
626
|
|
|
592
627
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -598,6 +633,7 @@ class PlanExecutorService {
|
|
|
598
633
|
artifacts: nextArtifacts,
|
|
599
634
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
600
635
|
reason: 'node-result-human-review',
|
|
636
|
+
capturedEvents: emittedEvents,
|
|
601
637
|
})
|
|
602
638
|
|
|
603
639
|
await this.attachCheckpoint(tx, failedRun, checkpoint)
|
|
@@ -639,6 +675,7 @@ class PlanExecutorService {
|
|
|
639
675
|
message: `Node "${nodeSpec.label}" failed validation and requires replanning.`,
|
|
640
676
|
detail: { failureClass: validation.failureClass, issues: validation.blocking.map((issue) => issue.code) },
|
|
641
677
|
emittedBy: params.emittedBy,
|
|
678
|
+
capturedEvents: emittedEvents,
|
|
642
679
|
})
|
|
643
680
|
|
|
644
681
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -650,6 +687,7 @@ class PlanExecutorService {
|
|
|
650
687
|
artifacts: nextArtifacts,
|
|
651
688
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
652
689
|
reason: 'node-result-replan',
|
|
690
|
+
capturedEvents: emittedEvents,
|
|
653
691
|
})
|
|
654
692
|
|
|
655
693
|
await this.attachCheckpoint(tx, blockedRun, checkpoint)
|
|
@@ -692,6 +730,7 @@ class PlanExecutorService {
|
|
|
692
730
|
message: `Node "${nodeSpec.label}" failed validation and the run has been aborted.`,
|
|
693
731
|
detail: { failureClass: validation.failureClass, issues: validation.blocking.map((issue) => issue.code) },
|
|
694
732
|
emittedBy: params.emittedBy,
|
|
733
|
+
capturedEvents: emittedEvents,
|
|
695
734
|
})
|
|
696
735
|
|
|
697
736
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -703,6 +742,7 @@ class PlanExecutorService {
|
|
|
703
742
|
artifacts: nextArtifacts,
|
|
704
743
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
705
744
|
reason: 'node-result-failed',
|
|
745
|
+
capturedEvents: emittedEvents,
|
|
706
746
|
})
|
|
707
747
|
|
|
708
748
|
await this.attachCheckpoint(tx, failedRun, checkpoint)
|
|
@@ -718,6 +758,7 @@ class PlanExecutorService {
|
|
|
718
758
|
latestAttemptId: finalizedAttempt.id,
|
|
719
759
|
latestStructuredOutput: params.result.structuredOutput ?? null,
|
|
720
760
|
latestNotes: params.result.notes ?? null,
|
|
761
|
+
handoffContext: params.result.handoffContext ?? null,
|
|
721
762
|
blockedReason: null,
|
|
722
763
|
failureClass: null,
|
|
723
764
|
completedAt: new Date(),
|
|
@@ -741,6 +782,7 @@ class PlanExecutorService {
|
|
|
741
782
|
: `Node "${nodeSpec.label}" completed successfully.`,
|
|
742
783
|
detail: { warningCount: validation.warnings.length },
|
|
743
784
|
emittedBy: params.emittedBy,
|
|
785
|
+
capturedEvents: emittedEvents,
|
|
744
786
|
})
|
|
745
787
|
|
|
746
788
|
const synced = await this.syncRunGraph({
|
|
@@ -753,6 +795,7 @@ class PlanExecutorService {
|
|
|
753
795
|
),
|
|
754
796
|
artifacts: nextArtifacts,
|
|
755
797
|
emittedBy: params.emittedBy,
|
|
798
|
+
capturedEvents: emittedEvents,
|
|
756
799
|
})
|
|
757
800
|
|
|
758
801
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -762,6 +805,7 @@ class PlanExecutorService {
|
|
|
762
805
|
artifacts: synced.artifacts,
|
|
763
806
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
764
807
|
reason: 'node-result-complete',
|
|
808
|
+
capturedEvents: emittedEvents,
|
|
765
809
|
})
|
|
766
810
|
|
|
767
811
|
await this.attachCheckpoint(tx, synced.run, checkpoint)
|
|
@@ -802,7 +846,7 @@ class PlanExecutorService {
|
|
|
802
846
|
.then(async (recommendations) => {
|
|
803
847
|
if (recommendations.length === 0) return
|
|
804
848
|
const specRecord = await planRunService.getPlanSpecById(updatedRun.planSpecId)
|
|
805
|
-
await databaseService.create(
|
|
849
|
+
const event = await databaseService.create(
|
|
806
850
|
TABLES.PLAN_EVENT,
|
|
807
851
|
{
|
|
808
852
|
planSpecId: ensureRecordId(specRecord.id, TABLES.PLAN_SPEC),
|
|
@@ -814,6 +858,7 @@ class PlanExecutorService {
|
|
|
814
858
|
},
|
|
815
859
|
PlanEventSchema,
|
|
816
860
|
)
|
|
861
|
+
await planEventDeliveryService.dispatchEvent(event)
|
|
817
862
|
})
|
|
818
863
|
.catch((error) => {
|
|
819
864
|
aiLogger.warn`Failed to analyze feedback outcomes for run ${runIdStr}: ${error instanceof Error ? error.message : String(error)}`
|
|
@@ -823,6 +868,8 @@ class PlanExecutorService {
|
|
|
823
868
|
})
|
|
824
869
|
}
|
|
825
870
|
|
|
871
|
+
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
872
|
+
|
|
826
873
|
const snapshot = await planRunService.toSerializablePlan(updatedRun, {
|
|
827
874
|
includeEvents: true,
|
|
828
875
|
includeArtifacts: true,
|
|
@@ -891,6 +938,8 @@ class PlanExecutorService {
|
|
|
891
938
|
attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
|
|
892
939
|
webPolicy: nodeSpec.contextPolicy.webPolicy,
|
|
893
940
|
},
|
|
941
|
+
executionVisibility: nodeSpec.executionVisibility,
|
|
942
|
+
...(nodeSpec.escalation ? { escalation: nodeSpec.escalation } : {}),
|
|
894
943
|
},
|
|
895
944
|
result: {
|
|
896
945
|
structuredOutput: params.response,
|
|
@@ -898,6 +947,7 @@ class PlanExecutorService {
|
|
|
898
947
|
notes: typeof params.response.comments === 'string' ? params.response.comments : undefined,
|
|
899
948
|
},
|
|
900
949
|
})
|
|
950
|
+
const emittedEvents: PlanEventRecord[] = []
|
|
901
951
|
|
|
902
952
|
await databaseService.withTransaction(async (tx) => {
|
|
903
953
|
const approvalStatus = deriveApprovalStatus(params.response)
|
|
@@ -964,6 +1014,7 @@ class PlanExecutorService {
|
|
|
964
1014
|
latestAttemptId: attempt.id,
|
|
965
1015
|
latestStructuredOutput: params.response,
|
|
966
1016
|
latestNotes: typeof params.response.comments === 'string' ? params.response.comments : null,
|
|
1017
|
+
handoffContext: null,
|
|
967
1018
|
blockedReason: validation.blocking[0]?.message ?? null,
|
|
968
1019
|
failureClass: validation.failureClass,
|
|
969
1020
|
}),
|
|
@@ -980,6 +1031,7 @@ class PlanExecutorService {
|
|
|
980
1031
|
latestAttemptId: attempt.id,
|
|
981
1032
|
latestStructuredOutput: params.response,
|
|
982
1033
|
latestNotes: typeof params.response.comments === 'string' ? params.response.comments : null,
|
|
1034
|
+
handoffContext: null,
|
|
983
1035
|
blockedReason: null,
|
|
984
1036
|
failureClass: null,
|
|
985
1037
|
completedAt: new Date(),
|
|
@@ -1014,6 +1066,7 @@ class PlanExecutorService {
|
|
|
1014
1066
|
message: `Human response for node "${nodeSpec.label}" blocked execution.`,
|
|
1015
1067
|
detail: { issues: validation.blocking.map((issue) => issue.code) },
|
|
1016
1068
|
emittedBy: params.respondedBy,
|
|
1069
|
+
capturedEvents: emittedEvents,
|
|
1017
1070
|
})
|
|
1018
1071
|
|
|
1019
1072
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -1023,6 +1076,7 @@ class PlanExecutorService {
|
|
|
1023
1076
|
artifacts: existingArtifacts,
|
|
1024
1077
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
1025
1078
|
reason: 'human-response-blocked',
|
|
1079
|
+
capturedEvents: emittedEvents,
|
|
1026
1080
|
})
|
|
1027
1081
|
await this.attachCheckpoint(tx, blockedRun, checkpoint)
|
|
1028
1082
|
return
|
|
@@ -1036,6 +1090,7 @@ class PlanExecutorService {
|
|
|
1036
1090
|
nodeRuns,
|
|
1037
1091
|
artifacts: existingArtifacts,
|
|
1038
1092
|
emittedBy: params.respondedBy,
|
|
1093
|
+
capturedEvents: emittedEvents,
|
|
1039
1094
|
})
|
|
1040
1095
|
|
|
1041
1096
|
await this.emitEvent({
|
|
@@ -1051,6 +1106,7 @@ class PlanExecutorService {
|
|
|
1051
1106
|
message: `Human response for node "${nodeSpec.label}" accepted.`,
|
|
1052
1107
|
detail: { approvalStatus: approvalStatus },
|
|
1053
1108
|
emittedBy: params.respondedBy,
|
|
1109
|
+
capturedEvents: emittedEvents,
|
|
1054
1110
|
})
|
|
1055
1111
|
|
|
1056
1112
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -1060,10 +1116,13 @@ class PlanExecutorService {
|
|
|
1060
1116
|
artifacts: synced.artifacts,
|
|
1061
1117
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
1062
1118
|
reason: 'human-response-complete',
|
|
1119
|
+
capturedEvents: emittedEvents,
|
|
1063
1120
|
})
|
|
1064
1121
|
await this.attachCheckpoint(tx, synced.run, checkpoint)
|
|
1065
1122
|
})
|
|
1066
1123
|
|
|
1124
|
+
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
1125
|
+
|
|
1067
1126
|
return planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
|
|
1068
1127
|
includeEvents: true,
|
|
1069
1128
|
includeArtifacts: true,
|
|
@@ -1090,6 +1149,7 @@ class PlanExecutorService {
|
|
|
1090
1149
|
const nodeRuns = await planRunService.listNodeRuns(run.id)
|
|
1091
1150
|
const artifacts = await planRunService.listArtifacts(run.id)
|
|
1092
1151
|
const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
|
|
1152
|
+
const emittedEvents: PlanEventRecord[] = []
|
|
1093
1153
|
|
|
1094
1154
|
await databaseService.withTransaction(async (tx) => {
|
|
1095
1155
|
let currentNodeRuns = [...nodeRuns]
|
|
@@ -1130,6 +1190,7 @@ class PlanExecutorService {
|
|
|
1130
1190
|
message: `Run "${spec.title}" resumed from the latest checkpoint.`,
|
|
1131
1191
|
detail: latestCheckpoint ? { checkpointId: recordIdToString(latestCheckpoint.id, TABLES.PLAN_CHECKPOINT) } : {},
|
|
1132
1192
|
emittedBy: params.emittedBy,
|
|
1193
|
+
capturedEvents: emittedEvents,
|
|
1133
1194
|
})
|
|
1134
1195
|
|
|
1135
1196
|
const synced =
|
|
@@ -1143,6 +1204,7 @@ class PlanExecutorService {
|
|
|
1143
1204
|
nodeRuns: currentNodeRuns,
|
|
1144
1205
|
artifacts,
|
|
1145
1206
|
emittedBy: params.emittedBy,
|
|
1207
|
+
capturedEvents: emittedEvents,
|
|
1146
1208
|
})
|
|
1147
1209
|
|
|
1148
1210
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -1152,10 +1214,13 @@ class PlanExecutorService {
|
|
|
1152
1214
|
artifacts: synced.artifacts,
|
|
1153
1215
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
1154
1216
|
reason: 'run-resumed',
|
|
1217
|
+
capturedEvents: emittedEvents,
|
|
1155
1218
|
})
|
|
1156
1219
|
await this.attachCheckpoint(tx, synced.run, checkpoint)
|
|
1157
1220
|
})
|
|
1158
1221
|
|
|
1222
|
+
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
1223
|
+
|
|
1159
1224
|
const snapshot = await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
|
|
1160
1225
|
includeEvents: true,
|
|
1161
1226
|
includeArtifacts: true,
|
|
@@ -1216,6 +1281,7 @@ class PlanExecutorService {
|
|
|
1216
1281
|
const nodeRun = await planRunService.getNodeRunByNodeId(run.id, params.nodeId)
|
|
1217
1282
|
const artifacts = await planRunService.listArtifacts(run.id)
|
|
1218
1283
|
const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
|
|
1284
|
+
const emittedEvents: PlanEventRecord[] = []
|
|
1219
1285
|
|
|
1220
1286
|
await databaseService.withTransaction(async (tx) => {
|
|
1221
1287
|
const blockedNodeRun =
|
|
@@ -1253,6 +1319,7 @@ class PlanExecutorService {
|
|
|
1253
1319
|
message: `Node "${nodeSpec.label}" failed during owner dispatch.`,
|
|
1254
1320
|
detail: { failureClass: params.failureClass, phase: 'dispatch', error: params.message },
|
|
1255
1321
|
emittedBy: params.emittedBy,
|
|
1322
|
+
capturedEvents: emittedEvents,
|
|
1256
1323
|
})
|
|
1257
1324
|
|
|
1258
1325
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -1264,10 +1331,13 @@ class PlanExecutorService {
|
|
|
1264
1331
|
artifacts,
|
|
1265
1332
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
1266
1333
|
reason: 'owner-dispatch-failed',
|
|
1334
|
+
capturedEvents: emittedEvents,
|
|
1267
1335
|
})
|
|
1268
1336
|
await this.attachCheckpoint(tx, blockedRun, checkpoint)
|
|
1269
1337
|
})
|
|
1270
1338
|
|
|
1339
|
+
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
1340
|
+
|
|
1271
1341
|
return planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
|
|
1272
1342
|
includeEvents: true,
|
|
1273
1343
|
includeArtifacts: true,
|
|
@@ -1293,6 +1363,7 @@ class PlanExecutorService {
|
|
|
1293
1363
|
const nodeRuns = await planRunService.listNodeRuns(run.id)
|
|
1294
1364
|
const artifacts = await planRunService.listArtifacts(run.id)
|
|
1295
1365
|
const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
|
|
1366
|
+
const emittedEvents: PlanEventRecord[] = []
|
|
1296
1367
|
|
|
1297
1368
|
await databaseService.withTransaction(async (tx) => {
|
|
1298
1369
|
const readyNodeRun = PlanNodeRunSchema.parse(
|
|
@@ -1317,6 +1388,7 @@ class PlanExecutorService {
|
|
|
1317
1388
|
toStatus: readyNodeRun.status,
|
|
1318
1389
|
message: `Node "${nodeSpec?.label ?? params.nodeId}" promoted to ready after delay.`,
|
|
1319
1390
|
emittedBy: params.emittedBy,
|
|
1391
|
+
capturedEvents: emittedEvents,
|
|
1320
1392
|
})
|
|
1321
1393
|
|
|
1322
1394
|
const synced = await this.syncRunGraph({
|
|
@@ -1327,6 +1399,7 @@ class PlanExecutorService {
|
|
|
1327
1399
|
nodeRuns: updatedNodeRuns,
|
|
1328
1400
|
artifacts,
|
|
1329
1401
|
emittedBy: params.emittedBy,
|
|
1402
|
+
capturedEvents: emittedEvents,
|
|
1330
1403
|
})
|
|
1331
1404
|
|
|
1332
1405
|
const checkpoint = await this.saveCheckpoint({
|
|
@@ -1336,9 +1409,12 @@ class PlanExecutorService {
|
|
|
1336
1409
|
artifacts: synced.artifacts,
|
|
1337
1410
|
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
1338
1411
|
reason: 'delayed-node-promoted',
|
|
1412
|
+
capturedEvents: emittedEvents,
|
|
1339
1413
|
})
|
|
1340
1414
|
await this.attachCheckpoint(tx, synced.run, checkpoint)
|
|
1341
1415
|
})
|
|
1416
|
+
|
|
1417
|
+
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
1342
1418
|
}
|
|
1343
1419
|
|
|
1344
1420
|
async syncRunGraph(params: {
|
|
@@ -1357,6 +1433,7 @@ class PlanExecutorService {
|
|
|
1357
1433
|
payload?: unknown
|
|
1358
1434
|
}>
|
|
1359
1435
|
emittedBy: string
|
|
1436
|
+
capturedEvents?: PlanEventRecord[]
|
|
1360
1437
|
}): Promise<{
|
|
1361
1438
|
run: PlanRunRecord
|
|
1362
1439
|
nodeRuns: PlanNodeRunRecord[]
|
|
@@ -1392,6 +1469,7 @@ class PlanExecutorService {
|
|
|
1392
1469
|
toStatus: currentRun.status,
|
|
1393
1470
|
message: `Run blocked: unresolved cross-plan dependencies (${unresolved.map((d) => d.sourcePlanTitle).join(', ')}).`,
|
|
1394
1471
|
emittedBy: params.emittedBy,
|
|
1472
|
+
capturedEvents: params.capturedEvents,
|
|
1395
1473
|
})
|
|
1396
1474
|
return { run: currentRun, nodeRuns: currentNodeRuns, artifacts: currentArtifacts }
|
|
1397
1475
|
}
|
|
@@ -1465,6 +1543,7 @@ class PlanExecutorService {
|
|
|
1465
1543
|
toStatus: skippedNodeRun.status,
|
|
1466
1544
|
message: `Node "${nodeSpec.label}" was skipped because no inbound branch was activated.`,
|
|
1467
1545
|
emittedBy: params.emittedBy,
|
|
1546
|
+
capturedEvents: params.capturedEvents,
|
|
1468
1547
|
})
|
|
1469
1548
|
changed = true
|
|
1470
1549
|
continue
|
|
@@ -1501,6 +1580,7 @@ class PlanExecutorService {
|
|
|
1501
1580
|
toStatus: scheduledNodeRun.status,
|
|
1502
1581
|
message: `Node "${nodeSpec.label}" is scheduled (${nodeSchedule.type}).`,
|
|
1503
1582
|
emittedBy: params.emittedBy,
|
|
1583
|
+
capturedEvents: params.capturedEvents,
|
|
1504
1584
|
})
|
|
1505
1585
|
changed = true
|
|
1506
1586
|
} else if (nodeSpec.delayAfterPredecessorMs) {
|
|
@@ -1531,6 +1611,7 @@ class PlanExecutorService {
|
|
|
1531
1611
|
toStatus: scheduledNodeRun.status,
|
|
1532
1612
|
message: `Node "${nodeSpec.label}" is delayed by ${nodeSpec.delayAfterPredecessorMs}ms after predecessor.`,
|
|
1533
1613
|
emittedBy: params.emittedBy,
|
|
1614
|
+
capturedEvents: params.capturedEvents,
|
|
1534
1615
|
})
|
|
1535
1616
|
changed = true
|
|
1536
1617
|
} else {
|
|
@@ -1551,6 +1632,7 @@ class PlanExecutorService {
|
|
|
1551
1632
|
toStatus: readyNodeRun.status,
|
|
1552
1633
|
message: `Node "${nodeSpec.label}" is ready to execute.`,
|
|
1553
1634
|
emittedBy: params.emittedBy,
|
|
1635
|
+
capturedEvents: params.capturedEvents,
|
|
1554
1636
|
})
|
|
1555
1637
|
changed = true
|
|
1556
1638
|
}
|
|
@@ -1588,6 +1670,7 @@ class PlanExecutorService {
|
|
|
1588
1670
|
toStatus: completedNodeRun.status,
|
|
1589
1671
|
message: `Structural node "${nodeSpec.label}" auto-completed.`,
|
|
1590
1672
|
emittedBy: params.emittedBy,
|
|
1673
|
+
capturedEvents: params.capturedEvents,
|
|
1591
1674
|
})
|
|
1592
1675
|
changed = true
|
|
1593
1676
|
}
|
|
@@ -1657,6 +1740,7 @@ class PlanExecutorService {
|
|
|
1657
1740
|
toStatus: currentRun.status,
|
|
1658
1741
|
message: `Node "${nextHumanNodeSpec.label}" is awaiting human input.`,
|
|
1659
1742
|
emittedBy: params.emittedBy,
|
|
1743
|
+
capturedEvents: params.capturedEvents,
|
|
1660
1744
|
})
|
|
1661
1745
|
} else {
|
|
1662
1746
|
const nextActionNodeSpec = sortedNodeSpecs.find((nodeSpec) => {
|
|
@@ -1696,6 +1780,20 @@ class PlanExecutorService {
|
|
|
1696
1780
|
toStatus: runningNodeRun.status,
|
|
1697
1781
|
message: `Node "${nextActionNodeSpec.label}" is now running.`,
|
|
1698
1782
|
emittedBy: params.emittedBy,
|
|
1783
|
+
capturedEvents: params.capturedEvents,
|
|
1784
|
+
})
|
|
1785
|
+
await this.emitEvent({
|
|
1786
|
+
tx: params.tx,
|
|
1787
|
+
run: currentRun,
|
|
1788
|
+
spec: params.spec,
|
|
1789
|
+
nodeId: runningNodeRun.nodeId,
|
|
1790
|
+
eventType: 'ownership-transition',
|
|
1791
|
+
fromStatus: params.run.currentNodeId ?? undefined,
|
|
1792
|
+
toStatus: runningNodeRun.nodeId,
|
|
1793
|
+
message: `Execution ownership transitioned to "${nextActionNodeSpec.label}".`,
|
|
1794
|
+
detail: { owner: nextActionNodeSpec.owner },
|
|
1795
|
+
emittedBy: params.emittedBy,
|
|
1796
|
+
capturedEvents: params.capturedEvents,
|
|
1699
1797
|
})
|
|
1700
1798
|
} else if (currentNodeRuns.every((nodeRun) => isSuccessfulTerminalStatus(nodeRun.status))) {
|
|
1701
1799
|
currentRun = await this.replaceRun(params.tx, currentRun, {
|
|
@@ -1715,6 +1813,7 @@ class PlanExecutorService {
|
|
|
1715
1813
|
toStatus: currentRun.status,
|
|
1716
1814
|
message: `Run "${params.spec.title}" completed.`,
|
|
1717
1815
|
emittedBy: params.emittedBy,
|
|
1816
|
+
capturedEvents: params.capturedEvents,
|
|
1718
1817
|
})
|
|
1719
1818
|
} else if (hasScheduledOrMonitoring) {
|
|
1720
1819
|
// Nodes are waiting on schedules/monitors — run stays active
|
|
@@ -1863,6 +1962,7 @@ class PlanExecutorService {
|
|
|
1863
1962
|
fromStatus?: string
|
|
1864
1963
|
toStatus?: string
|
|
1865
1964
|
detail?: Record<string, unknown>
|
|
1965
|
+
capturedEvents?: PlanEventRecord[]
|
|
1866
1966
|
}): Promise<PlanEventRecord> {
|
|
1867
1967
|
const eventId = new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7())
|
|
1868
1968
|
const created = await params.tx
|
|
@@ -1882,7 +1982,9 @@ class PlanExecutorService {
|
|
|
1882
1982
|
})
|
|
1883
1983
|
.output('after')
|
|
1884
1984
|
|
|
1885
|
-
|
|
1985
|
+
const event = PlanEventSchema.parse(created)
|
|
1986
|
+
params.capturedEvents?.push(event)
|
|
1987
|
+
return event
|
|
1886
1988
|
}
|
|
1887
1989
|
|
|
1888
1990
|
private async replaceRun(tx: DatabaseTransaction, run: PlanRunRecord, patch: PlanRunUpdate): Promise<PlanRunRecord> {
|
|
@@ -1899,6 +2001,7 @@ class PlanExecutorService {
|
|
|
1899
2001
|
sequence: number
|
|
1900
2002
|
reason: string
|
|
1901
2003
|
includeWorkspace?: boolean
|
|
2004
|
+
capturedEvents?: PlanEventRecord[]
|
|
1902
2005
|
}) {
|
|
1903
2006
|
const checkpoint = await planCheckpointService.createCheckpoint({
|
|
1904
2007
|
tx: params.tx,
|
|
@@ -1930,6 +2033,7 @@ class PlanExecutorService {
|
|
|
1930
2033
|
message: `Saved checkpoint ${checkpoint.sequence}.`,
|
|
1931
2034
|
detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: params.reason },
|
|
1932
2035
|
emittedBy: 'system',
|
|
2036
|
+
capturedEvents: params.capturedEvents,
|
|
1933
2037
|
})
|
|
1934
2038
|
|
|
1935
2039
|
return checkpoint
|
|
@@ -13,3 +13,16 @@ export function readPathValue(source: unknown, path: string): unknown {
|
|
|
13
13
|
}
|
|
14
14
|
return current
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
export function isExecutableConditionExpression(expression: string | undefined): boolean {
|
|
18
|
+
if (!expression?.trim()) return false
|
|
19
|
+
|
|
20
|
+
const normalized = expression.trim()
|
|
21
|
+
if (normalized === 'always') return true
|
|
22
|
+
|
|
23
|
+
if (/^[a-zA-Z0-9_.]+$/.test(normalized)) {
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return /^[a-zA-Z0-9_.]+\s*(==|!=|>=|<=|>|<)\s*.+$/.test(normalized)
|
|
28
|
+
}
|