@lota-sdk/core 0.1.20 → 0.1.22
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 +6 -6
- package/src/ai-gateway/ai-gateway.ts +2 -4
- package/src/create-runtime.ts +8 -0
- package/src/queues/document-processor.queue.ts +11 -8
- package/src/queues/index.ts +1 -0
- package/src/queues/plan-agent-heartbeat.queue.ts +100 -0
- package/src/queues/queue-factory.ts +12 -11
- 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
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
GetActiveExecutionPlanArgs,
|
|
6
6
|
ListExecutionPlansSummary,
|
|
7
7
|
ListExecutionPlansToolResultData,
|
|
8
|
+
PlanEventRecord,
|
|
8
9
|
PlanNodeRunRecord,
|
|
9
10
|
PlanNodeSpecRecord,
|
|
10
11
|
PlanRunRecord,
|
|
@@ -12,6 +13,7 @@ import type {
|
|
|
12
13
|
ReplaceExecutionPlanArgs,
|
|
13
14
|
ResumeExecutionPlanRunArgs,
|
|
14
15
|
SerializableExecutionPlan,
|
|
16
|
+
SubmitPlanTurnResultArgs,
|
|
15
17
|
SubmitExecutionNodeResultArgs,
|
|
16
18
|
} from '@lota-sdk/shared'
|
|
17
19
|
import {
|
|
@@ -38,21 +40,12 @@ import { ownershipDispatcherService } from './ownership-dispatcher.service'
|
|
|
38
40
|
import { planBuilderService } from './plan-builder.service'
|
|
39
41
|
import type { CompiledPlanNode } from './plan-compiler.service'
|
|
40
42
|
import { planCompilerService } from './plan-compiler.service'
|
|
43
|
+
import { planEventDeliveryService } from './plan-event-delivery.service'
|
|
41
44
|
import { planExecutorService } from './plan-executor.service'
|
|
42
45
|
import { planRunService } from './plan-run.service'
|
|
43
46
|
import { planSchedulerService } from './plan-scheduler.service'
|
|
44
47
|
import { planValidatorService } from './plan-validator.service'
|
|
45
48
|
|
|
46
|
-
export type ExecutionPlanDispatchMode = 'deferred' | 'stable-boundary'
|
|
47
|
-
|
|
48
|
-
const TOOL_RESULT_SERIALIZE_OPTIONS = {
|
|
49
|
-
includeEvents: true,
|
|
50
|
-
includeArtifacts: true,
|
|
51
|
-
includeApprovals: true,
|
|
52
|
-
includeCheckpoints: true,
|
|
53
|
-
includeValidationIssues: true,
|
|
54
|
-
} as const
|
|
55
|
-
|
|
56
49
|
function buildToolResult(params: {
|
|
57
50
|
action: ExecutionPlanToolResultData['action']
|
|
58
51
|
plan: SerializableExecutionPlan | null
|
|
@@ -85,6 +78,7 @@ function toSpecData(spec: PlanSpecRecord, patch: Partial<PlanSpecRecord> & { rep
|
|
|
85
78
|
schemaRegistry: patch.schemaRegistry ? structuredClone(patch.schemaRegistry) : structuredClone(spec.schemaRegistry),
|
|
86
79
|
edges: patch.edges ? [...patch.edges] : [...spec.edges],
|
|
87
80
|
entryNodeIds: patch.entryNodeIds ? [...patch.entryNodeIds] : [...spec.entryNodeIds],
|
|
81
|
+
defaultExecutionVisibility: patch.defaultExecutionVisibility ?? spec.defaultExecutionVisibility,
|
|
88
82
|
executionMode: patch.executionMode ?? spec.executionMode,
|
|
89
83
|
...(patch.schedule !== undefined ? { schedule: patch.schedule } : spec.schedule ? { schedule: spec.schedule } : {}),
|
|
90
84
|
...(patch.dependencies !== undefined
|
|
@@ -315,7 +309,6 @@ class ExecutionPlanService {
|
|
|
315
309
|
organizationId: RecordIdInput
|
|
316
310
|
workstreamId: RecordIdInput
|
|
317
311
|
leadAgentId: string
|
|
318
|
-
dispatchMode: ExecutionPlanDispatchMode
|
|
319
312
|
input: CreateExecutionPlanArgs
|
|
320
313
|
}): Promise<ExecutionPlanToolResultData> {
|
|
321
314
|
const preparedDraft = planBuilderService.prepareDraft(params.input)
|
|
@@ -339,6 +332,7 @@ class ExecutionPlanService {
|
|
|
339
332
|
|
|
340
333
|
const specId = new RecordId(TABLES.PLAN_SPEC, Bun.randomUUIDv7())
|
|
341
334
|
const runId = new RecordId(TABLES.PLAN_RUN, Bun.randomUUIDv7())
|
|
335
|
+
const emittedEvents: PlanEventRecord[] = []
|
|
342
336
|
|
|
343
337
|
await databaseService.withTransaction(async (tx) => {
|
|
344
338
|
const spec = PlanSpecSchema.parse(
|
|
@@ -353,6 +347,7 @@ class ExecutionPlanService {
|
|
|
353
347
|
status: 'compiled',
|
|
354
348
|
leadAgentId: params.leadAgentId,
|
|
355
349
|
schemaRegistry: structuredClone(compiled.draft.schemas),
|
|
350
|
+
defaultExecutionVisibility: compiled.draft.defaultExecutionVisibility,
|
|
356
351
|
...(enrichments.length > 0
|
|
357
352
|
? { contextEnrichments: enrichments.map((e) => ({ type: e.domain, content: JSON.stringify(e.data) })) }
|
|
358
353
|
: {}),
|
|
@@ -392,6 +387,7 @@ class ExecutionPlanService {
|
|
|
392
387
|
nodeRuns,
|
|
393
388
|
artifacts: [],
|
|
394
389
|
emittedBy: params.leadAgentId,
|
|
390
|
+
capturedEvents: emittedEvents,
|
|
395
391
|
})
|
|
396
392
|
|
|
397
393
|
const event = await tx
|
|
@@ -405,7 +401,7 @@ class ExecutionPlanService {
|
|
|
405
401
|
detail: { title: spec.title, objective: spec.objective, nodeCount: nodeSpecs.length },
|
|
406
402
|
})
|
|
407
403
|
.output('after')
|
|
408
|
-
PlanEventSchema.parse(event)
|
|
404
|
+
emittedEvents.push(PlanEventSchema.parse(event))
|
|
409
405
|
|
|
410
406
|
const checkpoint = PlanCheckpointSchema.parse(
|
|
411
407
|
await tx
|
|
@@ -448,9 +444,11 @@ class ExecutionPlanService {
|
|
|
448
444
|
detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: 'plan-created' },
|
|
449
445
|
})
|
|
450
446
|
.output('after')
|
|
451
|
-
PlanEventSchema.parse(checkpointEvent)
|
|
447
|
+
emittedEvents.push(PlanEventSchema.parse(checkpointEvent))
|
|
452
448
|
})
|
|
453
449
|
|
|
450
|
+
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
451
|
+
|
|
454
452
|
// Create a plan-level schedule record if the draft specifies a schedule
|
|
455
453
|
if (compiled.draft.schedule) {
|
|
456
454
|
const schedule = await planSchedulerService.createSchedule({
|
|
@@ -472,11 +470,7 @@ class ExecutionPlanService {
|
|
|
472
470
|
)
|
|
473
471
|
}
|
|
474
472
|
|
|
475
|
-
const plan = await this.finalizePlanSnapshot({
|
|
476
|
-
runId,
|
|
477
|
-
emittedBy: params.leadAgentId,
|
|
478
|
-
dispatchMode: params.dispatchMode,
|
|
479
|
-
})
|
|
473
|
+
const plan = await this.finalizePlanSnapshot({ runId, emittedBy: params.leadAgentId })
|
|
480
474
|
|
|
481
475
|
return buildToolResult({ action: 'created', plan, message: `Created execution plan "${plan.title}".` })
|
|
482
476
|
}
|
|
@@ -485,7 +479,6 @@ class ExecutionPlanService {
|
|
|
485
479
|
workstreamId: RecordIdInput
|
|
486
480
|
organizationId: RecordIdInput
|
|
487
481
|
leadAgentId: string
|
|
488
|
-
dispatchMode: ExecutionPlanDispatchMode
|
|
489
482
|
input: ReplaceExecutionPlanArgs
|
|
490
483
|
}): Promise<ExecutionPlanToolResultData> {
|
|
491
484
|
const activeRun = await planRunService.getRunById(params.input.runId)
|
|
@@ -512,6 +505,7 @@ class ExecutionPlanService {
|
|
|
512
505
|
edges: params.input.edges,
|
|
513
506
|
entryNodeIds: params.input.entryNodeIds,
|
|
514
507
|
schemas: params.input.schemas,
|
|
508
|
+
defaultExecutionVisibility: params.input.defaultExecutionVisibility,
|
|
515
509
|
executionMode: params.input.executionMode,
|
|
516
510
|
schedule: params.input.schedule,
|
|
517
511
|
dependencies: params.input.dependencies,
|
|
@@ -525,6 +519,7 @@ class ExecutionPlanService {
|
|
|
525
519
|
|
|
526
520
|
const specId = new RecordId(TABLES.PLAN_SPEC, Bun.randomUUIDv7())
|
|
527
521
|
const runId = new RecordId(TABLES.PLAN_RUN, Bun.randomUUIDv7())
|
|
522
|
+
const emittedEvents: PlanEventRecord[] = []
|
|
528
523
|
|
|
529
524
|
await databaseService.withTransaction(async (tx) => {
|
|
530
525
|
const supersededSpec = PlanSpecSchema.parse(
|
|
@@ -561,6 +556,7 @@ class ExecutionPlanService {
|
|
|
561
556
|
status: 'compiled',
|
|
562
557
|
leadAgentId: params.leadAgentId,
|
|
563
558
|
schemaRegistry: structuredClone(compiled.draft.schemas),
|
|
559
|
+
defaultExecutionVisibility: compiled.draft.defaultExecutionVisibility,
|
|
564
560
|
edges: [...compiled.draft.edges],
|
|
565
561
|
entryNodeIds: [...(compiled.draft.entryNodeIds ?? [])],
|
|
566
562
|
executionMode: compiled.draft.executionMode ?? 'linear',
|
|
@@ -599,6 +595,7 @@ class ExecutionPlanService {
|
|
|
599
595
|
nodeRuns,
|
|
600
596
|
artifacts: [],
|
|
601
597
|
emittedBy: params.leadAgentId,
|
|
598
|
+
capturedEvents: emittedEvents,
|
|
602
599
|
})
|
|
603
600
|
|
|
604
601
|
const replaceEvent = await tx
|
|
@@ -612,7 +609,7 @@ class ExecutionPlanService {
|
|
|
612
609
|
detail: { reason: params.input.reason, replacedRunId: recordIdToString(abortedRun.id, TABLES.PLAN_RUN) },
|
|
613
610
|
})
|
|
614
611
|
.output('after')
|
|
615
|
-
PlanEventSchema.parse(replaceEvent)
|
|
612
|
+
emittedEvents.push(PlanEventSchema.parse(replaceEvent))
|
|
616
613
|
|
|
617
614
|
const checkpoint = PlanCheckpointSchema.parse(
|
|
618
615
|
await tx
|
|
@@ -655,14 +652,12 @@ class ExecutionPlanService {
|
|
|
655
652
|
detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: 'plan-replaced' },
|
|
656
653
|
})
|
|
657
654
|
.output('after')
|
|
658
|
-
PlanEventSchema.parse(checkpointEvent)
|
|
655
|
+
emittedEvents.push(PlanEventSchema.parse(checkpointEvent))
|
|
659
656
|
})
|
|
660
657
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
dispatchMode: params.dispatchMode,
|
|
665
|
-
})
|
|
658
|
+
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
659
|
+
|
|
660
|
+
const plan = await this.finalizePlanSnapshot({ runId, emittedBy: params.leadAgentId })
|
|
666
661
|
|
|
667
662
|
return buildToolResult({ action: 'replaced', plan, message: `Replaced execution plan with "${plan.title}".` })
|
|
668
663
|
}
|
|
@@ -692,6 +687,33 @@ class ExecutionPlanService {
|
|
|
692
687
|
})
|
|
693
688
|
}
|
|
694
689
|
|
|
690
|
+
async submitPlanTurnResult(params: {
|
|
691
|
+
workstreamId: RecordIdInput
|
|
692
|
+
runId: string
|
|
693
|
+
nodeId: string
|
|
694
|
+
emittedBy: string
|
|
695
|
+
input: SubmitPlanTurnResultArgs
|
|
696
|
+
}): Promise<ExecutionPlanToolResultData> {
|
|
697
|
+
const result = await planExecutorService.submitNodeResult({
|
|
698
|
+
workstreamId: params.workstreamId,
|
|
699
|
+
runId: params.runId,
|
|
700
|
+
nodeId: params.nodeId,
|
|
701
|
+
emittedBy: params.emittedBy,
|
|
702
|
+
result: params.input,
|
|
703
|
+
})
|
|
704
|
+
const plan = await ownershipDispatcherService.dispatchRunToStableBoundary({
|
|
705
|
+
runId: params.runId,
|
|
706
|
+
emittedBy: params.emittedBy,
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
return buildToolResult({
|
|
710
|
+
action: result.action,
|
|
711
|
+
plan,
|
|
712
|
+
message: result.message ?? `Submitted result for node "${params.nodeId}".`,
|
|
713
|
+
changedNodeId: result.changedNodeId ?? undefined,
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
695
717
|
async resumeRun(params: {
|
|
696
718
|
workstreamId: RecordIdInput
|
|
697
719
|
emittedBy: string
|
|
@@ -804,17 +826,8 @@ class ExecutionPlanService {
|
|
|
804
826
|
private async finalizePlanSnapshot(params: {
|
|
805
827
|
runId: RecordIdInput
|
|
806
828
|
emittedBy: string
|
|
807
|
-
dispatchMode: ExecutionPlanDispatchMode
|
|
808
829
|
}): Promise<SerializableExecutionPlan> {
|
|
809
|
-
|
|
810
|
-
return ownershipDispatcherService.dispatchRunToStableBoundary({
|
|
811
|
-
runId: params.runId,
|
|
812
|
-
emittedBy: params.emittedBy,
|
|
813
|
-
})
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const run = await planRunService.getRunById(params.runId)
|
|
817
|
-
return planRunService.toSerializablePlan(run, TOOL_RESULT_SERIALIZE_OPTIONS)
|
|
830
|
+
return ownershipDispatcherService.dispatchRunToStableBoundary({ runId: params.runId, emittedBy: params.emittedBy })
|
|
818
831
|
}
|
|
819
832
|
|
|
820
833
|
private async createNodeSpecs(
|
|
@@ -852,8 +865,10 @@ class ExecutionPlanService {
|
|
|
852
865
|
attachmentPolicy: compiledNode.node.contextPolicy.attachmentPolicy,
|
|
853
866
|
webPolicy: compiledNode.node.contextPolicy.webPolicy,
|
|
854
867
|
},
|
|
868
|
+
executionVisibility: compiledNode.node.executionVisibility,
|
|
855
869
|
...(compiledNode.node.schedule ? { schedule: compiledNode.node.schedule } : {}),
|
|
856
870
|
...(compiledNode.node.deadline ? { deadline: compiledNode.node.deadline } : {}),
|
|
871
|
+
...(compiledNode.node.escalation ? { escalation: compiledNode.node.escalation } : {}),
|
|
857
872
|
...(compiledNode.node.monitoringConfig ? { monitoringConfig: compiledNode.node.monitoringConfig } : {}),
|
|
858
873
|
...(compiledNode.node.delayAfterPredecessorMs
|
|
859
874
|
? { delayAfterPredecessorMs: compiledNode.node.delayAfterPredecessorMs }
|
package/src/services/index.ts
CHANGED
|
@@ -20,6 +20,9 @@ export * from './organization.service'
|
|
|
20
20
|
export * from './plan-coordination.service'
|
|
21
21
|
export * from './plan-cycle.service'
|
|
22
22
|
export * from './plan-deadline.service'
|
|
23
|
+
export * from './plan-agent-query.service'
|
|
24
|
+
export * from './plan-agent-heartbeat.service'
|
|
25
|
+
export * from './plan-event-delivery.service'
|
|
23
26
|
export * from './plan-workspace.service'
|
|
24
27
|
export * from './plan-run.service'
|
|
25
28
|
export * from './plan-scheduler.service'
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
PlanSpecRecord,
|
|
14
14
|
PlanDraft,
|
|
15
15
|
SerializableExecutionPlan,
|
|
16
|
+
UpstreamHandoff,
|
|
16
17
|
} from '@lota-sdk/shared'
|
|
17
18
|
|
|
18
19
|
import { agentRoster } from '../config/agent-defaults'
|
|
@@ -20,6 +21,7 @@ import type { RecordIdInput } from '../db/record-id'
|
|
|
20
21
|
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
21
22
|
import { databaseService } from '../db/service'
|
|
22
23
|
import { TABLES } from '../db/tables'
|
|
24
|
+
import { resolvePlanNodeExecutionVisibility, shouldPlanNodeUseVisibleTurn } from '../runtime/execution-plan-visibility'
|
|
23
25
|
import { getRuntimeAdapters } from '../runtime/runtime-extensions'
|
|
24
26
|
import { agentExecutorService } from './agent-executor.service'
|
|
25
27
|
import { domainAgentExecutorService } from './domain-agent-executor.service'
|
|
@@ -58,8 +60,10 @@ function toPlanNodeSpec(nodeSpec: PlanNodeSpecRecord): PlanNodeSpec {
|
|
|
58
60
|
attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
|
|
59
61
|
webPolicy: nodeSpec.contextPolicy.webPolicy,
|
|
60
62
|
},
|
|
63
|
+
executionVisibility: nodeSpec.executionVisibility,
|
|
61
64
|
...(nodeSpec.schedule ? { schedule: nodeSpec.schedule } : {}),
|
|
62
65
|
...(nodeSpec.deadline ? { deadline: nodeSpec.deadline } : {}),
|
|
66
|
+
...(nodeSpec.escalation ? { escalation: nodeSpec.escalation } : {}),
|
|
63
67
|
...(nodeSpec.monitoringConfig ? { monitoringConfig: nodeSpec.monitoringConfig } : {}),
|
|
64
68
|
...(nodeSpec.delayAfterPredecessorMs ? { delayAfterPredecessorMs: nodeSpec.delayAfterPredecessorMs } : {}),
|
|
65
69
|
...(nodeSpec.deliberationConfig ? { deliberationConfig: nodeSpec.deliberationConfig } : {}),
|
|
@@ -174,10 +178,13 @@ class OwnershipDispatcherService {
|
|
|
174
178
|
if (nodeRun.status !== 'running') {
|
|
175
179
|
return this.serializeRun(run.id)
|
|
176
180
|
}
|
|
181
|
+
if (shouldPlanNodeUseVisibleTurn(spec, nodeSpecRecord)) {
|
|
182
|
+
return this.serializeRun(run.id)
|
|
183
|
+
}
|
|
177
184
|
|
|
178
185
|
const [artifacts, dispatchContext] = await Promise.all([
|
|
179
186
|
planRunService.listArtifacts(run.id),
|
|
180
|
-
this.buildDispatchContext(run),
|
|
187
|
+
this.buildDispatchContext(run, spec, nodeSpecRecord),
|
|
181
188
|
])
|
|
182
189
|
const inputArtifacts = artifacts
|
|
183
190
|
.filter((artifact) => nodeSpecRecord.upstreamNodeIds.includes(artifact.nodeId))
|
|
@@ -230,10 +237,16 @@ class OwnershipDispatcherService {
|
|
|
230
237
|
spec: PlanSpecRecord
|
|
231
238
|
executionModeOverride?: ExecutionMode
|
|
232
239
|
}): Promise<PlanNodeResultSubmission> {
|
|
240
|
+
if (shouldPlanNodeUseVisibleTurn(params.spec, params.nodeSpecRecord)) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Node "${params.nodeSpecRecord.nodeId}" requires a visible plan turn and cannot be silently dispatched.`,
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
233
246
|
const planNode = toPlanNodeSpec(params.nodeSpecRecord)
|
|
234
247
|
const [artifacts, dispatchContext] = await Promise.all([
|
|
235
248
|
planRunService.listArtifacts(params.run.id),
|
|
236
|
-
this.buildDispatchContext(params.run),
|
|
249
|
+
this.buildDispatchContext(params.run, params.spec, params.nodeSpecRecord),
|
|
237
250
|
])
|
|
238
251
|
const inputArtifacts = artifacts
|
|
239
252
|
.filter((artifact) => params.nodeSpecRecord.upstreamNodeIds.includes(artifact.nodeId))
|
|
@@ -261,15 +274,27 @@ class OwnershipDispatcherService {
|
|
|
261
274
|
return lifecycleState?.bootstrapActive !== true
|
|
262
275
|
}
|
|
263
276
|
|
|
264
|
-
|
|
277
|
+
resolveExecutionVisibility(params: { spec: PlanSpecRecord; nodeSpecRecord: PlanNodeSpecRecord }) {
|
|
278
|
+
return resolvePlanNodeExecutionVisibility(params.spec, params.nodeSpecRecord)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async buildDispatchContext(
|
|
282
|
+
run: PlanRunRecord,
|
|
283
|
+
spec: PlanSpecRecord,
|
|
284
|
+
nodeSpecRecord: PlanNodeSpecRecord,
|
|
285
|
+
): Promise<Omit<OwnershipDispatchContext, 'nodeId'>> {
|
|
265
286
|
const organizationId = recordIdToString(run.organizationId, TABLES.ORGANIZATION)
|
|
266
287
|
const workstreamId = recordIdToString(run.workstreamId, TABLES.WORKSTREAM)
|
|
267
288
|
const planId = recordIdToString(run.id, TABLES.PLAN_RUN)
|
|
268
|
-
const workstream = await
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
289
|
+
const [workstream, nodeSpecs, nodeRuns] = await Promise.all([
|
|
290
|
+
databaseService.findOne(
|
|
291
|
+
TABLES.WORKSTREAM,
|
|
292
|
+
{ id: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM) },
|
|
293
|
+
WorkstreamSchema,
|
|
294
|
+
),
|
|
295
|
+
planRunService.listNodeSpecs(spec.id),
|
|
296
|
+
planRunService.listNodeRuns(run.id),
|
|
297
|
+
])
|
|
273
298
|
const userId = workstream?.userId ? recordIdToString(workstream.userId, TABLES.USER) : undefined
|
|
274
299
|
const userName = userId
|
|
275
300
|
? await userService
|
|
@@ -277,6 +302,22 @@ class OwnershipDispatcherService {
|
|
|
277
302
|
.then((user) => user.name)
|
|
278
303
|
.catch(() => undefined)
|
|
279
304
|
: undefined
|
|
305
|
+
const nodeSpecsById = new Map(nodeSpecs.map((candidate) => [candidate.nodeId, candidate]))
|
|
306
|
+
const upstreamHandoffs: UpstreamHandoff[] = nodeRuns
|
|
307
|
+
.filter(
|
|
308
|
+
(candidate): candidate is typeof candidate & { handoffContext: NonNullable<typeof candidate.handoffContext> } =>
|
|
309
|
+
nodeSpecRecord.upstreamNodeIds.includes(candidate.nodeId) && candidate.handoffContext !== undefined,
|
|
310
|
+
)
|
|
311
|
+
.map((candidate) => {
|
|
312
|
+
const upstreamNodeSpec = nodeSpecsById.get(candidate.nodeId)
|
|
313
|
+
return {
|
|
314
|
+
nodeId: candidate.nodeId,
|
|
315
|
+
label: upstreamNodeSpec?.label ?? candidate.nodeId,
|
|
316
|
+
ownerRef: upstreamNodeSpec?.owner.ref ?? 'unknown',
|
|
317
|
+
ownerType: upstreamNodeSpec?.owner.executorType ?? 'system',
|
|
318
|
+
handoffContext: candidate.handoffContext,
|
|
319
|
+
}
|
|
320
|
+
})
|
|
280
321
|
|
|
281
322
|
return {
|
|
282
323
|
organizationId,
|
|
@@ -285,6 +326,7 @@ class OwnershipDispatcherService {
|
|
|
285
326
|
leadAgentId: run.leadAgentId,
|
|
286
327
|
...(userId ? { userId } : {}),
|
|
287
328
|
...(userName ? { userName } : {}),
|
|
329
|
+
...(upstreamHandoffs.length > 0 ? { upstreamHandoffs } : {}),
|
|
288
330
|
}
|
|
289
331
|
}
|
|
290
332
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { serverLogger } from '../config/logger'
|
|
2
|
+
import { ensureRecordId } from '../db/record-id'
|
|
3
|
+
import { TABLES } from '../db/tables'
|
|
4
|
+
import { getRedisConnection, withRedisLeaseLock } from '../redis'
|
|
5
|
+
import { resolvePlanNodeExecutionVisibility } from '../runtime/execution-plan-visibility'
|
|
6
|
+
import { planAgentQueryService } from './plan-agent-query.service'
|
|
7
|
+
import { planExecutorService } from './plan-executor.service'
|
|
8
|
+
import { planRunService } from './plan-run.service'
|
|
9
|
+
import { workstreamService } from './workstream.service'
|
|
10
|
+
|
|
11
|
+
const PLAN_AGENT_HEARTBEAT_LOCK_TTL_MS = 300_000
|
|
12
|
+
const PLAN_AGENT_HEARTBEAT_LOCK_REFRESH_MS = 20_000
|
|
13
|
+
|
|
14
|
+
function buildHeartbeatLockKey(runId: string, nodeId: string): string {
|
|
15
|
+
return `plan-agent-heartbeat:${runId}:${nodeId}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildWakeDedupeKey(params: {
|
|
19
|
+
organizationId: string
|
|
20
|
+
workstreamId: string
|
|
21
|
+
runId: string
|
|
22
|
+
nodeId: string
|
|
23
|
+
agentId: string
|
|
24
|
+
}) {
|
|
25
|
+
return `${params.organizationId}:${params.workstreamId}:${params.runId}:${params.nodeId}:${params.agentId}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class PlanAgentHeartbeatService {
|
|
29
|
+
async wakeNode(params: {
|
|
30
|
+
organizationId: string
|
|
31
|
+
workstreamId: string
|
|
32
|
+
runId: string
|
|
33
|
+
nodeId: string
|
|
34
|
+
agentId: string
|
|
35
|
+
reason: string
|
|
36
|
+
}): Promise<boolean> {
|
|
37
|
+
const workstreamRef = ensureRecordId(params.workstreamId, TABLES.WORKSTREAM)
|
|
38
|
+
await workstreamService.clearStaleActiveRunIfMissingFromRegistry(workstreamRef)
|
|
39
|
+
if (await workstreamService.getActiveRunId(workstreamRef)) {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return withRedisLeaseLock(
|
|
44
|
+
{
|
|
45
|
+
redis: getRedisConnection(),
|
|
46
|
+
lockKey: buildHeartbeatLockKey(params.runId, params.nodeId),
|
|
47
|
+
lockTtlMs: PLAN_AGENT_HEARTBEAT_LOCK_TTL_MS,
|
|
48
|
+
refreshIntervalMs: PLAN_AGENT_HEARTBEAT_LOCK_REFRESH_MS,
|
|
49
|
+
label: 'plan agent heartbeat',
|
|
50
|
+
maxWaitMs: 5_000,
|
|
51
|
+
logger: serverLogger,
|
|
52
|
+
},
|
|
53
|
+
async () => {
|
|
54
|
+
await workstreamService.clearStaleActiveRunIfMissingFromRegistry(workstreamRef)
|
|
55
|
+
if (await workstreamService.hasActiveRunLease(workstreamRef)) {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
if (await workstreamService.getActiveRunId(workstreamRef)) {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const run = await planRunService.getRunById(params.runId)
|
|
63
|
+
const spec = await planRunService.getPlanSpecById(run.planSpecId)
|
|
64
|
+
const [nodeSpec, nodeRun] = await Promise.all([
|
|
65
|
+
planRunService.getNodeSpecByNodeId(spec.id, params.nodeId),
|
|
66
|
+
planRunService.getNodeRunByNodeId(run.id, params.nodeId),
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
if (nodeSpec.owner.executorType !== 'agent' || nodeSpec.owner.ref !== params.agentId) {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const visibility = resolvePlanNodeExecutionVisibility(spec, nodeSpec)
|
|
74
|
+
if (visibility !== 'visible') {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (nodeRun.status !== 'running' && nodeRun.status !== 'ready') {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (nodeRun.status === 'ready' && run.currentNodeId === params.nodeId) {
|
|
83
|
+
await planExecutorService.transitionNodeToRunning({ runId: params.runId, nodeId: params.nodeId })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { triggerPlanNodeTurn } = await import('./workstream-turn')
|
|
87
|
+
|
|
88
|
+
await triggerPlanNodeTurn({ runId: params.runId, nodeId: params.nodeId })
|
|
89
|
+
return true
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async sweep(params?: { organizationId?: string }): Promise<void> {
|
|
95
|
+
const { enqueuePlanAgentHeartbeatWake } = await import('../queues/plan-agent-heartbeat.queue')
|
|
96
|
+
const [actionable, recentlyUnblocked, approachingDeadlines] = await Promise.all([
|
|
97
|
+
planAgentQueryService.getActionableNodesForAgent({ organizationId: params?.organizationId }),
|
|
98
|
+
planAgentQueryService.getRecentlyUnblockedNodes({ organizationId: params?.organizationId }),
|
|
99
|
+
planAgentQueryService.getApproachingDeadlines({ organizationId: params?.organizationId, withinMinutes: 60 }),
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
const wakeTargets = new Map<
|
|
103
|
+
string,
|
|
104
|
+
{ organizationId: string; workstreamId: string; runId: string; nodeId: string; agentId: string; reason: string }
|
|
105
|
+
>()
|
|
106
|
+
|
|
107
|
+
for (const node of actionable) {
|
|
108
|
+
wakeTargets.set(buildWakeDedupeKey(node), { ...node, reason: 'heartbeat-actionable' })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const node of recentlyUnblocked) {
|
|
112
|
+
wakeTargets.set(buildWakeDedupeKey(node), { ...node, reason: node.sourceEventType })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const node of approachingDeadlines) {
|
|
116
|
+
if (!node.agentId || node.visibility !== 'visible') {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const wakeTarget = {
|
|
120
|
+
organizationId: node.organizationId,
|
|
121
|
+
workstreamId: node.workstreamId,
|
|
122
|
+
runId: node.runId,
|
|
123
|
+
nodeId: node.nodeId,
|
|
124
|
+
agentId: node.agentId,
|
|
125
|
+
reason: `deadline-${node.status}`,
|
|
126
|
+
}
|
|
127
|
+
wakeTargets.set(buildWakeDedupeKey(wakeTarget), wakeTarget)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const target of wakeTargets.values()) {
|
|
131
|
+
await enqueuePlanAgentHeartbeatWake(target)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const planAgentHeartbeatService = new PlanAgentHeartbeatService()
|