@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.
Files changed (32) hide show
  1. package/infrastructure/schema/02_execution_plan.surql +4 -0
  2. package/package.json +6 -6
  3. package/src/ai-gateway/ai-gateway.ts +2 -4
  4. package/src/create-runtime.ts +8 -0
  5. package/src/queues/document-processor.queue.ts +11 -8
  6. package/src/queues/index.ts +1 -0
  7. package/src/queues/plan-agent-heartbeat.queue.ts +100 -0
  8. package/src/queues/queue-factory.ts +12 -11
  9. package/src/redis/redis-lease-lock.ts +1 -1
  10. package/src/runtime/agent-runtime-policy.ts +41 -4
  11. package/src/runtime/execution-plan-visibility.ts +23 -0
  12. package/src/runtime/execution-plan.ts +1 -0
  13. package/src/runtime/runtime-extensions.ts +26 -0
  14. package/src/runtime/runtime-worker-registry.ts +9 -1
  15. package/src/services/agent-executor.service.ts +6 -0
  16. package/src/services/execution-plan.service.ts +51 -36
  17. package/src/services/index.ts +3 -0
  18. package/src/services/ownership-dispatcher.service.ts +50 -8
  19. package/src/services/plan-agent-heartbeat.service.ts +136 -0
  20. package/src/services/plan-agent-query.service.ts +238 -0
  21. package/src/services/plan-builder.service.ts +11 -1
  22. package/src/services/plan-compiler.service.ts +2 -0
  23. package/src/services/plan-deadline.service.ts +186 -44
  24. package/src/services/plan-event-delivery.service.ts +170 -0
  25. package/src/services/plan-executor.service.ts +107 -3
  26. package/src/services/plan-helpers.ts +13 -0
  27. package/src/services/plan-run.service.ts +4 -0
  28. package/src/services/plan-template.service.ts +0 -1
  29. package/src/services/workstream-turn-preparation.service.ts +452 -176
  30. package/src/services/workstream-turn.ts +101 -1
  31. package/src/services/workstream.service.ts +76 -16
  32. package/src/tools/execution-plan.tool.ts +0 -2
@@ -1,19 +1,28 @@
1
- import type { ChatMessage } from '@lota-sdk/shared'
1
+ import type { ChatMessage, PlanNodeHandoffContext } from '@lota-sdk/shared'
2
2
  import { createUIMessageStream } from 'ai'
3
3
 
4
4
  import { lotaDebugLogger } from '../config/debug-logger'
5
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
6
+ import { TABLES } from '../db/tables'
5
7
  import { hasApprovalRespondedParts, isApprovalContinuationRequest } from '../runtime/approval-continuation'
8
+ import { shouldPlanNodeUseVisibleTurn } from '../runtime/execution-plan-visibility'
6
9
  import { wrapResponseWithKeepalive } from '../utils/sse-keepalive'
10
+ import { planExecutorService } from './plan-executor.service'
11
+ import { planRunService } from './plan-run.service'
12
+ import { userService } from './user.service'
7
13
  import { prepareWorkstreamRunCore } from './workstream-turn-preparation.service'
8
14
  import type {
9
15
  PreparedWorkstreamTurnResult,
10
16
  WorkstreamApprovalContinuationParams,
17
+ WorkstreamPlanTurnParams,
11
18
  WorkstreamTurnParams,
12
19
  } from './workstream-turn-preparation.service'
20
+ import { workstreamService } from './workstream.service'
13
21
 
14
22
  export { hasApprovalRespondedParts, isApprovalContinuationRequest }
15
23
  export { wrapResponseWithKeepalive }
16
24
  export type { PreparedWorkstreamTurnResult }
25
+ export type { WorkstreamPlanTurnParams }
17
26
 
18
27
  export async function createWorkstreamApprovalContinuationStream(params: WorkstreamApprovalContinuationParams) {
19
28
  const timer = lotaDebugLogger.timer('turn:approval-continuation')
@@ -70,3 +79,94 @@ export async function runWorkstreamTurnInBackground(
70
79
  timer.step('run')
71
80
  return result
72
81
  }
82
+
83
+ export async function triggerPlanNodeTurn(params: {
84
+ runId: string
85
+ nodeId: string
86
+ abortSignal?: AbortSignal
87
+ streamId?: string
88
+ }): Promise<PreparedWorkstreamTurnResult> {
89
+ const timer = lotaDebugLogger.timer('turn:plan-turn')
90
+ let run = await planRunService.getRunById(params.runId)
91
+ const spec = await planRunService.getPlanSpecById(run.planSpecId)
92
+ const nodeSpec = await planRunService.getNodeSpecByNodeId(spec.id, params.nodeId)
93
+
94
+ if (!shouldPlanNodeUseVisibleTurn(spec, nodeSpec) || nodeSpec.owner.executorType !== 'agent') {
95
+ throw new Error(`Plan node "${params.nodeId}" is not eligible for a visible plan turn.`)
96
+ }
97
+
98
+ let nodeRun = await planRunService.getNodeRunByNodeId(run.id, params.nodeId)
99
+ if (nodeRun.status === 'ready') {
100
+ await planExecutorService.transitionNodeToRunning({ runId: params.runId, nodeId: params.nodeId })
101
+ run = await planRunService.getRunById(params.runId)
102
+ nodeRun = await planRunService.getNodeRunByNodeId(run.id, params.nodeId)
103
+ }
104
+
105
+ if (nodeRun.status !== 'running') {
106
+ return { assistantMessages: [], inputMessageId: undefined }
107
+ }
108
+
109
+ const workstream = await workstreamService.getWorkstream(run.workstreamId)
110
+ const userRef = ensureRecordId(workstream.userId, TABLES.USER)
111
+
112
+ const [artifacts, nodeRuns, userName] = await Promise.all([
113
+ planRunService.listArtifacts(run.id),
114
+ planRunService.listNodeRuns(run.id),
115
+ userService
116
+ .getUser(recordIdToString(userRef, TABLES.USER))
117
+ .then((user) => user.name)
118
+ .catch(() => undefined),
119
+ ])
120
+ const inputArtifacts = artifacts
121
+ .filter((artifact) => nodeSpec.upstreamNodeIds.includes(artifact.nodeId))
122
+ .map((artifact) => ({
123
+ name: artifact.name,
124
+ kind: artifact.kind,
125
+ pointer: artifact.pointer,
126
+ ...(artifact.schemaRef ? { schemaRef: artifact.schemaRef } : {}),
127
+ ...(artifact.description ? { description: artifact.description } : {}),
128
+ ...(artifact.payload !== undefined ? { payload: artifact.payload } : {}),
129
+ }))
130
+ const upstreamNodeSpecs = new Map(
131
+ (await planRunService.listNodeSpecs(spec.id)).map((upstreamNodeSpec) => [
132
+ upstreamNodeSpec.nodeId,
133
+ upstreamNodeSpec,
134
+ ]),
135
+ )
136
+ const upstreamHandoffs = nodeRuns
137
+ .filter((candidate) => nodeSpec.upstreamNodeIds.includes(candidate.nodeId) && candidate.handoffContext)
138
+ .map((candidate) => ({
139
+ nodeId: candidate.nodeId,
140
+ label: upstreamNodeSpecs.get(candidate.nodeId)?.label ?? candidate.nodeId,
141
+ ownerRef: upstreamNodeSpecs.get(candidate.nodeId)?.owner.ref ?? 'system',
142
+ ownerType: upstreamNodeSpecs.get(candidate.nodeId)?.owner.executorType ?? 'system',
143
+ handoffContext: candidate.handoffContext as PlanNodeHandoffContext,
144
+ }))
145
+ timer.step('load-plan-turn-context')
146
+
147
+ const prepared = await prepareWorkstreamRunCore({
148
+ kind: 'planTurn',
149
+ workstream,
150
+ workstreamRef: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM),
151
+ orgRef: ensureRecordId(run.organizationId, TABLES.ORGANIZATION),
152
+ userRef,
153
+ userName,
154
+ abortSignal: params.abortSignal,
155
+ streamId: params.streamId,
156
+ planTurn: {
157
+ runId: params.runId,
158
+ nodeId: params.nodeId,
159
+ planTitle: spec.title,
160
+ nodeSpec,
161
+ nodeRun,
162
+ resolvedInput: nodeRun.resolvedInput ?? {},
163
+ inputArtifacts,
164
+ upstreamHandoffs,
165
+ },
166
+ })
167
+ timer.step('prepare')
168
+
169
+ const result = await prepared.run()
170
+ timer.step('run')
171
+ return result
172
+ }
@@ -10,6 +10,7 @@ import type { RecordIdInput, RecordIdRef } from '../db/record-id'
10
10
  import { databaseService } from '../db/service'
11
11
  import type { DatabaseTable } from '../db/tables'
12
12
  import { TABLES } from '../db/tables'
13
+ import { getRedisConnection, withRedisLeaseLock } from '../redis'
13
14
  import {
14
15
  MEMORY_BLOCK_COMPACTION_CHUNK_ENTRIES,
15
16
  MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES,
@@ -62,6 +63,10 @@ WHERE userId = $userId
62
63
  AND status = "regular"
63
64
  ORDER BY updatedAt DESC`
64
65
 
66
+ const WORKSTREAM_ACTIVE_RUN_LOCK_TTL_MS = 90_000
67
+ const WORKSTREAM_ACTIVE_RUN_LOCK_MAX_WAIT_MS = 750
68
+ const WORKSTREAM_ACTIVE_RUN_LOCK_RETRY_DELAY_MS = 75
69
+
65
70
  function toSafeDirectIdSegment(value: string): string {
66
71
  return value.replace(/[^a-zA-Z0-9_-]/g, '_')
67
72
  }
@@ -107,6 +112,17 @@ function requireString(coreType: string | undefined): string {
107
112
  return coreType
108
113
  }
109
114
 
115
+ function buildActiveRunLockKey(workstreamId: RecordIdRef): string {
116
+ return `workstream-active-run:${recordIdToString(ensureRecordId(workstreamId, TABLES.WORKSTREAM), TABLES.WORKSTREAM)}`
117
+ }
118
+
119
+ export class ActiveWorkstreamRunConflictError extends Error {
120
+ constructor() {
121
+ super('A chat run is already active.')
122
+ this.name = 'ActiveWorkstreamRunConflictError'
123
+ }
124
+ }
125
+
110
126
  function buildDirectWorkstreamId({
111
127
  userId,
112
128
  orgId,
@@ -188,7 +204,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
188
204
 
189
205
  const existing = await this.findById(directWorkstreamId)
190
206
  if (existing) {
191
- return this.normalizeWorkstream(existing)
207
+ return await this.toNormalizedWorkstream(existing)
192
208
  }
193
209
 
194
210
  let createError: unknown = null
@@ -216,7 +232,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
216
232
  throw new Error('Failed to create or load direct workstream')
217
233
  }
218
234
 
219
- return this.normalizeWorkstream(workstream)
235
+ return await this.toNormalizedWorkstream(workstream)
220
236
  }
221
237
 
222
238
  if (core) {
@@ -225,7 +241,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
225
241
  const coreWorkstreamId = buildCoreWorkstreamId({ userId, orgId, coreType: resolvedCoreType })
226
242
  const existing = await this.findById(coreWorkstreamId)
227
243
  if (existing) {
228
- return this.normalizeWorkstream(existing)
244
+ return await this.toNormalizedWorkstream(existing)
229
245
  }
230
246
 
231
247
  let createError: unknown = null
@@ -263,7 +279,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
263
279
  throw new Error('Failed to create or load core workstream')
264
280
  }
265
281
 
266
- return this.normalizeWorkstream(workstream)
282
+ return await this.toNormalizedWorkstream(workstream)
267
283
  }
268
284
 
269
285
  const groupWorkstream = await this.create({
@@ -276,7 +292,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
276
292
  nameGenerated: options?.title !== undefined && options.title !== WORKSTREAM.DEFAULT_TITLE,
277
293
  })
278
294
 
279
- return this.normalizeWorkstream(groupWorkstream)
295
+ return await this.toNormalizedWorkstream(groupWorkstream)
280
296
  }
281
297
 
282
298
  async ensureBootstrapWorkstreams(
@@ -387,7 +403,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
387
403
  WorkstreamSchema,
388
404
  )
389
405
 
390
- return { workstreams: workstreams.map((workstream) => this.normalizeWorkstream(workstream)), hasMore: false }
406
+ return { workstreams: await this.toNormalizedWorkstreams(workstreams), hasMore: false }
391
407
  }
392
408
 
393
409
  const take = options.take ?? WORKSTREAM.DEFAULT_PAGE_LIMIT
@@ -408,7 +424,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
408
424
  const hasMore = workstreams.length > take
409
425
  const sliced = hasMore ? workstreams.slice(0, take) : workstreams
410
426
 
411
- return { workstreams: sliced.map((workstream) => this.normalizeWorkstream(workstream)), hasMore }
427
+ return { workstreams: await this.toNormalizedWorkstreams(sliced), hasMore }
412
428
  }
413
429
 
414
430
  async listOrganizationWorkstreams(params: {
@@ -450,19 +466,19 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
450
466
  WorkstreamSchema,
451
467
  )
452
468
 
453
- return workstreams.map((workstream) => this.normalizeWorkstream(workstream))
469
+ return await this.toNormalizedWorkstreams(workstreams)
454
470
  }
455
471
 
456
472
  async getWorkstream(workstreamId: RecordIdRef): Promise<NormalizedWorkstream> {
457
473
  const workstream = await this.getById(workstreamId)
458
- return this.normalizeWorkstream(workstream)
474
+ return await this.toNormalizedWorkstream(workstream)
459
475
  }
460
476
 
461
477
  async updateTitle(workstreamId: RecordIdRef, title: string): Promise<NormalizedWorkstream> {
462
478
  const existing = await this.getById(workstreamId)
463
479
  this.assertMutableWorkstream(existing, 'rename')
464
480
  const workstream = await this.update(workstreamId, { title, nameGenerated: true })
465
- return this.normalizeWorkstream(workstream)
481
+ return await this.toNormalizedWorkstream(workstream)
466
482
  }
467
483
 
468
484
  async updateStatus(workstreamId: RecordIdRef, status: string): Promise<NormalizedWorkstream> {
@@ -470,7 +486,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
470
486
  const existing = await this.getById(workstreamId)
471
487
  this.assertMutableWorkstream(existing, validStatus === 'archived' ? 'archive' : 'unarchive')
472
488
  const workstream = await this.update(workstreamId, { status: validStatus })
473
- return this.normalizeWorkstream(workstream)
489
+ return await this.toNormalizedWorkstream(workstream)
474
490
  }
475
491
 
476
492
  async setActiveRunId(workstreamId: RecordIdRef, runId: string | null): Promise<void> {
@@ -497,6 +513,33 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
497
513
  return normalized.length > 0 ? normalized : null
498
514
  }
499
515
 
516
+ async hasActiveRunLease(workstreamId: RecordIdRef): Promise<boolean> {
517
+ const count = await getRedisConnection().exists(buildActiveRunLockKey(workstreamId))
518
+ return count > 0
519
+ }
520
+
521
+ async withActiveRunLease<T>(workstreamId: RecordIdRef, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
522
+ try {
523
+ return await withRedisLeaseLock(
524
+ {
525
+ redis: getRedisConnection(),
526
+ lockKey: buildActiveRunLockKey(workstreamId),
527
+ lockTtlMs: WORKSTREAM_ACTIVE_RUN_LOCK_TTL_MS,
528
+ retryDelayMs: WORKSTREAM_ACTIVE_RUN_LOCK_RETRY_DELAY_MS,
529
+ maxWaitMs: WORKSTREAM_ACTIVE_RUN_LOCK_MAX_WAIT_MS,
530
+ label: 'workstream active run',
531
+ logger: serverLogger,
532
+ },
533
+ fn,
534
+ )
535
+ } catch (error) {
536
+ if (error instanceof Error && error.message.startsWith('Timed out waiting for workstream active run')) {
537
+ throw new ActiveWorkstreamRunConflictError()
538
+ }
539
+ throw error
540
+ }
541
+ }
542
+
500
543
  async clearActiveRunIdIfMatches(workstreamId: RecordIdRef, runId: string): Promise<void> {
501
544
  const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
502
545
  await databaseService.query(surql`UPDATE ONLY ${workstreamRef} SET activeRunId = NONE WHERE activeRunId = ${runId}`)
@@ -527,7 +570,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
527
570
 
528
571
  async clearStaleActiveRunIfMissingFromRegistry(workstreamId: RecordIdRef): Promise<boolean> {
529
572
  const activeRunId = await this.getActiveRunId(workstreamId)
530
- if (!activeRunId || chatRunRegistry.has(activeRunId)) {
573
+ if (!activeRunId || (await this.hasActiveRunLease(workstreamId))) {
531
574
  return false
532
575
  }
533
576
 
@@ -537,7 +580,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
537
580
  activeStreamId ? this.clearActiveStreamIdIfMatches(workstreamId, activeStreamId) : Promise.resolve(),
538
581
  ])
539
582
 
540
- serverLogger.warn`Cleared stale workstream run after process restart: workstream=${recordIdToString(ensureRecordId(workstreamId, TABLES.WORKSTREAM), TABLES.WORKSTREAM)} run=${activeRunId}`
583
+ serverLogger.warn`Cleared stale workstream run after lease expired: workstream=${recordIdToString(ensureRecordId(workstreamId, TABLES.WORKSTREAM), TABLES.WORKSTREAM)} run=${activeRunId}`
541
584
  return true
542
585
  }
543
586
 
@@ -656,7 +699,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
656
699
  WorkstreamSchema,
657
700
  )
658
701
 
659
- return workstreams.map((workstream) => this.normalizeWorkstream(workstream))
702
+ return await this.toNormalizedWorkstreams(workstreams)
660
703
  }
661
704
 
662
705
  private normalizeWorkstreamId(id: unknown): string {
@@ -686,12 +729,25 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
686
729
  return WORKSTREAM.DEFAULT_TITLE
687
730
  }
688
731
 
689
- normalizeWorkstream(workstream: WorkstreamRecord): NormalizedWorkstream {
732
+ private async computeIsRunning(workstream: Pick<WorkstreamRecord, 'id' | 'activeRunId'>): Promise<boolean> {
690
733
  const activeRunId =
691
734
  typeof workstream.activeRunId === 'string' && workstream.activeRunId.trim().length > 0
692
735
  ? workstream.activeRunId
693
736
  : null
694
- const isRunning = activeRunId !== null && chatRunRegistry.has(activeRunId)
737
+
738
+ if (activeRunId === null) {
739
+ return false
740
+ }
741
+
742
+ if (chatRunRegistry.has(activeRunId)) {
743
+ return true
744
+ }
745
+
746
+ return await this.hasActiveRunLease(ensureRecordId(workstream.id, TABLES.WORKSTREAM))
747
+ }
748
+
749
+ private async toNormalizedWorkstream(workstream: WorkstreamRecord): Promise<NormalizedWorkstream> {
750
+ const isRunning = await this.computeIsRunning(workstream)
695
751
  const isCompacting = workstream.isCompacting === true
696
752
  const mode = workstream.mode
697
753
  const core = workstream.core
@@ -716,6 +772,10 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
716
772
  }
717
773
  }
718
774
 
775
+ private async toNormalizedWorkstreams(workstreams: WorkstreamRecord[]): Promise<NormalizedWorkstream[]> {
776
+ return await Promise.all(workstreams.map(async (workstream) => await this.toNormalizedWorkstream(workstream)))
777
+ }
778
+
719
779
  toPublicWorkstream(workstream: NormalizedWorkstream) {
720
780
  return {
721
781
  id: workstream.id,
@@ -27,7 +27,6 @@ export function createCreateExecutionPlanTool(params: {
27
27
  organizationId: params.orgId,
28
28
  workstreamId: params.workstreamId,
29
29
  leadAgentId: params.agentId,
30
- dispatchMode: 'deferred',
31
30
  input,
32
31
  })
33
32
  params.onPlanChanged?.()
@@ -51,7 +50,6 @@ export function createReplaceExecutionPlanTool(params: {
51
50
  organizationId: params.orgId,
52
51
  workstreamId: params.workstreamId,
53
52
  leadAgentId: params.agentId,
54
- dispatchMode: 'deferred',
55
53
  input,
56
54
  })
57
55
  params.onPlanChanged?.()