@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.
Files changed (30) hide show
  1. package/infrastructure/schema/02_execution_plan.surql +4 -0
  2. package/package.json +3 -3
  3. package/src/ai-gateway/ai-gateway.ts +4 -0
  4. package/src/create-runtime.ts +8 -0
  5. package/src/queues/index.ts +1 -0
  6. package/src/queues/plan-agent-heartbeat.queue.ts +100 -0
  7. package/src/redis/redis-lease-lock.ts +1 -1
  8. package/src/runtime/agent-runtime-policy.ts +41 -4
  9. package/src/runtime/execution-plan-visibility.ts +23 -0
  10. package/src/runtime/execution-plan.ts +1 -0
  11. package/src/runtime/runtime-extensions.ts +26 -0
  12. package/src/runtime/runtime-worker-registry.ts +9 -1
  13. package/src/services/agent-executor.service.ts +6 -0
  14. package/src/services/execution-plan.service.ts +51 -36
  15. package/src/services/index.ts +3 -0
  16. package/src/services/ownership-dispatcher.service.ts +50 -8
  17. package/src/services/plan-agent-heartbeat.service.ts +136 -0
  18. package/src/services/plan-agent-query.service.ts +238 -0
  19. package/src/services/plan-builder.service.ts +11 -1
  20. package/src/services/plan-compiler.service.ts +2 -0
  21. package/src/services/plan-deadline.service.ts +186 -44
  22. package/src/services/plan-event-delivery.service.ts +170 -0
  23. package/src/services/plan-executor.service.ts +107 -3
  24. package/src/services/plan-helpers.ts +13 -0
  25. package/src/services/plan-run.service.ts +4 -0
  26. package/src/services/plan-template.service.ts +0 -1
  27. package/src/services/workstream-turn-preparation.service.ts +452 -176
  28. package/src/services/workstream-turn.ts +101 -1
  29. package/src/services/workstream.service.ts +76 -16
  30. 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
- const plan = await this.finalizePlanSnapshot({
662
- runId,
663
- emittedBy: params.leadAgentId,
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
- if (params.dispatchMode === 'stable-boundary') {
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 }
@@ -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
- private async buildDispatchContext(run: PlanRunRecord): Promise<Omit<OwnershipDispatchContext, 'nodeId'>> {
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 databaseService.findOne(
269
- TABLES.WORKSTREAM,
270
- { id: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM) },
271
- WorkstreamSchema,
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()