@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
@@ -17,6 +17,7 @@ DEFINE FIELD IF NOT EXISTS edges.*.target ON TABLE planSpec TYPE string;
17
17
  DEFINE FIELD IF NOT EXISTS edges.*.when ON TABLE planSpec TYPE option<string>;
18
18
  DEFINE FIELD IF NOT EXISTS edges.*.map ON TABLE planSpec TYPE object FLEXIBLE DEFAULT {};
19
19
  DEFINE FIELD IF NOT EXISTS entryNodeIds ON TABLE planSpec TYPE array<string>;
20
+ DEFINE FIELD IF NOT EXISTS defaultExecutionVisibility ON TABLE planSpec TYPE string DEFAULT 'auto';
20
21
  DEFINE FIELD IF NOT EXISTS executionMode ON TABLE planSpec TYPE string DEFAULT 'linear';
21
22
  DEFINE FIELD IF NOT EXISTS contextEnrichments ON TABLE planSpec TYPE option<array<object>> FLEXIBLE;
22
23
  DEFINE FIELD OVERWRITE contextEnrichments.* ON TABLE planSpec TYPE object FLEXIBLE;
@@ -65,8 +66,10 @@ DEFINE FIELD IF NOT EXISTS failurePolicy.*.note ON TABLE planNodeSpec TYPE strin
65
66
  DEFINE FIELD IF NOT EXISTS timeoutMs ON TABLE planNodeSpec TYPE option<int>;
66
67
  DEFINE FIELD IF NOT EXISTS toolPolicy ON TABLE planNodeSpec TYPE object FLEXIBLE;
67
68
  DEFINE FIELD IF NOT EXISTS contextPolicy ON TABLE planNodeSpec TYPE object FLEXIBLE;
69
+ DEFINE FIELD IF NOT EXISTS executionVisibility ON TABLE planNodeSpec TYPE string DEFAULT 'auto';
68
70
  DEFINE FIELD IF NOT EXISTS schedule ON TABLE planNodeSpec TYPE option<object> FLEXIBLE;
69
71
  DEFINE FIELD IF NOT EXISTS deadline ON TABLE planNodeSpec TYPE option<object> FLEXIBLE;
72
+ DEFINE FIELD IF NOT EXISTS escalation ON TABLE planNodeSpec TYPE option<object> FLEXIBLE;
70
73
  DEFINE FIELD IF NOT EXISTS monitoringConfig ON TABLE planNodeSpec TYPE option<object> FLEXIBLE;
71
74
  DEFINE FIELD IF NOT EXISTS delayAfterPredecessorMs ON TABLE planNodeSpec TYPE option<int>;
72
75
  DEFINE FIELD IF NOT EXISTS deliberationConfig ON TABLE planNodeSpec TYPE option<object> FLEXIBLE;
@@ -113,6 +116,7 @@ DEFINE FIELD IF NOT EXISTS retryCount ON TABLE planNodeRun TYPE int DEFAULT 0;
113
116
  DEFINE FIELD IF NOT EXISTS resolvedInput ON TABLE planNodeRun TYPE option<object> FLEXIBLE;
114
117
  DEFINE FIELD IF NOT EXISTS latestStructuredOutput ON TABLE planNodeRun TYPE option<object> FLEXIBLE;
115
118
  DEFINE FIELD IF NOT EXISTS latestNotes ON TABLE planNodeRun TYPE option<string>;
119
+ DEFINE FIELD IF NOT EXISTS handoffContext ON TABLE planNodeRun TYPE option<object> FLEXIBLE;
116
120
  DEFINE FIELD IF NOT EXISTS latestAttemptId ON TABLE planNodeRun TYPE option<record<planNodeAttempt>>;
117
121
  DEFINE FIELD IF NOT EXISTS blockedReason ON TABLE planNodeRun TYPE option<string>;
118
122
  DEFINE FIELD IF NOT EXISTS failureClass ON TABLE planNodeRun TYPE option<string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -19,7 +19,7 @@
19
19
  "lint": "bunx oxlint --fix -c ../oxlint.config.ts src",
20
20
  "format": "bunx oxfmt src",
21
21
  "typecheck": "bunx tsgo --noEmit",
22
- "test:unit": "bun test ../tests/unit/core",
22
+ "test:unit": "bun test --max-concurrency=1 ../tests/unit/core",
23
23
  "test:coverage": "bun test --coverage ../tests/unit/core"
24
24
  },
25
25
  "publishConfig": {
@@ -32,11 +32,11 @@
32
32
  "@chat-adapter/slack": "^4.23.0",
33
33
  "@chat-adapter/state-ioredis": "^4.23.0",
34
34
  "@logtape/logtape": "^2.0.5",
35
- "@lota-sdk/shared": "0.1.20",
36
- "@mendable/firecrawl-js": "^4.17.0",
35
+ "@lota-sdk/shared": "0.1.22",
36
+ "@mendable/firecrawl-js": "^4.18.0",
37
37
  "@surrealdb/node": "^3.0.3",
38
- "ai": "^6.0.137",
39
- "bullmq": "^5.71.0",
38
+ "ai": "^6.0.141",
39
+ "bullmq": "^5.71.1",
40
40
  "chat": "^4.23.0",
41
41
  "cron-parser": "^5.5.0",
42
42
  "hono": "^4.12.9",
@@ -349,7 +349,7 @@ function createAiGatewayFetch(extraParams?: AiGatewayExtraParams): typeof fetch
349
349
  ? injectAiGatewayExtraParamsRequestBody(bodyWithPromptCacheRetention, extraParams)
350
350
  : bodyWithPromptCacheRetention
351
351
 
352
- const headers = new Headers(init?.headers as HeadersInit | undefined)
352
+ const headers = new Headers(init?.headers)
353
353
  if (extraParams !== undefined || (isAiGatewayOpenAIModelRequest(body) && hasAiGatewayPromptCacheRetention(body))) {
354
354
  // Bifrost only forwards provider-specific extra params when passthrough is enabled.
355
355
  headers.set(AI_GATEWAY_EXTRA_PARAMS_HEADER, 'true')
@@ -370,9 +370,7 @@ function createAiGatewayProvider(extraParams?: AiGatewayExtraParams) {
370
370
  return createOpenAI({
371
371
  baseURL,
372
372
  apiKey,
373
- headers: {
374
- [AI_GATEWAY_VIRTUAL_KEY_HEADER]: apiKey,
375
- },
373
+ headers: { [AI_GATEWAY_VIRTUAL_KEY_HEADER]: apiKey },
376
374
  fetch: createAiGatewayFetch(extraParams),
377
375
  })
378
376
  }
@@ -46,6 +46,8 @@ import type { organizationMemberService } from './services/organization-member.s
46
46
  import { organizationMemberService as organizationMemberServiceSingleton } from './services/organization-member.service'
47
47
  import type { organizationService } from './services/organization.service'
48
48
  import { organizationService as organizationServiceSingleton } from './services/organization.service'
49
+ import type { planAgentQueryService } from './services/plan-agent-query.service'
50
+ import { planAgentQueryService as planAgentQueryServiceSingleton } from './services/plan-agent-query.service'
49
51
  import { playbookRegistryService } from './services/playbook-registry.service'
50
52
  import type { recentActivityTitleService } from './services/recent-activity-title.service'
51
53
  import { recentActivityTitleService as recentActivityTitleServiceSingleton } from './services/recent-activity-title.service'
@@ -67,6 +69,7 @@ import type {
67
69
  createWorkstreamNativeToolApprovalStream,
68
70
  createWorkstreamTurnStream,
69
71
  runWorkstreamTurnInBackground,
72
+ triggerPlanNodeTurn,
70
73
  } from './services/workstream-turn'
71
74
  import {
72
75
  createWorkstreamApprovalContinuationStream as createWorkstreamApprovalContinuationStreamSingleton,
@@ -74,6 +77,7 @@ import {
74
77
  createWorkstreamTurnStream as createWorkstreamTurnStreamSingleton,
75
78
  isApprovalContinuationRequest as isApprovalContinuationRequestSingleton,
76
79
  runWorkstreamTurnInBackground as runWorkstreamTurnInBackgroundSingleton,
80
+ triggerPlanNodeTurn as triggerPlanNodeTurnSingleton,
77
81
  } from './services/workstream-turn'
78
82
  import type { workstreamService } from './services/workstream.service'
79
83
  import { workstreamService as workstreamServiceSingleton } from './services/workstream.service'
@@ -126,6 +130,7 @@ export interface LotaRuntime {
126
130
  recentActivityTitleService: typeof recentActivityTitleService
127
131
  socialChatHistoryService: typeof socialChatHistoryServiceSingleton
128
132
  executionPlanService: typeof executionPlanService
133
+ planAgentQueryService: typeof planAgentQueryService
129
134
  workstreamMessageService: typeof workstreamMessageService
130
135
  workstreamService: typeof workstreamService
131
136
  workstreamTitleService: typeof workstreamTitleService
@@ -134,6 +139,7 @@ export interface LotaRuntime {
134
139
  createWorkstreamTurnStream: typeof createWorkstreamTurnStream
135
140
  isApprovalContinuationRequest: typeof isApprovalContinuationRequest
136
141
  runWorkstreamTurnInBackground: typeof runWorkstreamTurnInBackground
142
+ triggerPlanNodeTurn: typeof triggerPlanNodeTurn
137
143
  syncPlaybookTemplates: typeof playbookRegistryService.syncPlaybookTemplates
138
144
  }
139
145
  lota: {
@@ -397,6 +403,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
397
403
  recentActivityTitleService: recentActivityTitleServiceSingleton,
398
404
  socialChatHistoryService: socialChatHistoryServiceSingleton,
399
405
  executionPlanService: executionPlanServiceSingleton,
406
+ planAgentQueryService: planAgentQueryServiceSingleton,
400
407
  workstreamMessageService: workstreamMessageServiceSingleton,
401
408
  workstreamService: workstreamServiceSingleton,
402
409
  workstreamTitleService: workstreamTitleServiceSingleton,
@@ -405,6 +412,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
405
412
  createWorkstreamTurnStream: createWorkstreamTurnStreamSingleton,
406
413
  isApprovalContinuationRequest: isApprovalContinuationRequestSingleton,
407
414
  runWorkstreamTurnInBackground: runWorkstreamTurnInBackgroundSingleton,
415
+ triggerPlanNodeTurn: triggerPlanNodeTurnSingleton,
408
416
  syncPlaybookTemplates: playbookRegistryService.syncPlaybookTemplates.bind(playbookRegistryService),
409
417
  },
410
418
  lota,
@@ -1,4 +1,5 @@
1
1
  import { Queue, Worker } from 'bullmq'
2
+ import type { QueueOptions } from 'bullmq'
2
3
  import type IORedis from 'ioredis'
3
4
 
4
5
  import type { chatLogger } from '../config/logger'
@@ -70,22 +71,24 @@ export function createDocumentProcessorQueueRuntime<TJob extends DocumentProcess
70
71
  enqueue: (job: TJob) => Promise<unknown>
71
72
  startWorker: (options?: { registerSignals?: boolean }) => WorkerHandle
72
73
  } {
74
+ type QueueShape = Queue<TJob, unknown, string, TJob, unknown, string>
75
+
73
76
  const queueName = params.queueName ?? DEFAULT_DOCUMENT_PROCESSOR_QUEUE
74
77
  const workerName = params.workerName ?? DEFAULT_WORKER_NAME
75
78
  const concurrency = params.concurrency ?? 10
76
79
  const lockDuration = params.lockDuration ?? 300_000
77
- const jobName = 'process-document' as Parameters<Queue<TJob, unknown, string>['add']>[0]
78
- const toQueueData = (job: TJob): Parameters<Queue<TJob, unknown, string>['add']>[1] =>
79
- job as Parameters<Queue<TJob, unknown, string>['add']>[1]
80
- let queue: Queue<TJob, unknown, string> | null = null
80
+ const jobName = 'process-document' as Parameters<QueueShape['add']>[0]
81
+ const toQueueData = (job: TJob): Parameters<QueueShape['add']>[1] => job
82
+ let queue: QueueShape | null = null
83
+ const getConnection = (): IORedis => params.getConnectionForBullMQ()
81
84
 
82
- const getQueue = (): Queue<TJob, unknown, string> => {
85
+ const getQueue = (): QueueShape => {
83
86
  if (queue) {
84
87
  return queue
85
88
  }
86
89
 
87
- queue = new Queue<TJob, unknown, string>(queueName, {
88
- connection: params.getConnectionForBullMQ(),
90
+ queue = new Queue<TJob, unknown, string, TJob, unknown, string>(queueName, {
91
+ connection: getConnection() as QueueOptions['connection'],
89
92
  defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
90
93
  })
91
94
 
@@ -108,7 +111,7 @@ export function createDocumentProcessorQueueRuntime<TJob extends DocumentProcess
108
111
  startWorker: (options = {}) => {
109
112
  const { registerSignals = import.meta.main } = options
110
113
  const worker = new Worker(queueName, params.getWorkerPath(), {
111
- connection: params.getConnectionForBullMQ(),
114
+ connection: getConnection() as QueueOptions['connection'],
112
115
  concurrency,
113
116
  lockDuration,
114
117
  })
@@ -4,6 +4,7 @@ export * from './context-compaction.queue'
4
4
  export * from './delayed-node-promotion.queue'
5
5
  export * from './document-processor.queue'
6
6
  export * from './memory-consolidation.queue'
7
+ export * from './plan-agent-heartbeat.queue'
7
8
  export * from './plan-scheduler.queue'
8
9
  export * from './post-chat-memory.queue'
9
10
  export * from './recent-activity-title-refinement.queue'
@@ -0,0 +1,100 @@
1
+ import type { Job } from 'bullmq'
2
+
3
+ import { serverLogger } from '../config/logger'
4
+ import { databaseService } from '../db/service'
5
+ import { planAgentHeartbeatService } from '../services/plan-agent-heartbeat.service'
6
+ import type { WorkerHandle } from '../workers/worker-utils'
7
+ import { DEFAULT_JOB_RETENTION, LONG_JOB_LOCK_DURATION_MS } from '../workers/worker-utils'
8
+ import { createQueueFactory } from './queue-factory'
9
+
10
+ export interface PlanAgentHeartbeatWakeJob {
11
+ type: 'wake-node'
12
+ organizationId: string
13
+ workstreamId: string
14
+ runId: string
15
+ nodeId: string
16
+ agentId: string
17
+ reason: string
18
+ }
19
+
20
+ export interface PlanAgentHeartbeatSweepJob {
21
+ type: 'sweep'
22
+ organizationId?: string
23
+ }
24
+
25
+ export type PlanAgentHeartbeatJob = PlanAgentHeartbeatWakeJob | PlanAgentHeartbeatSweepJob
26
+
27
+ export const PLAN_AGENT_HEARTBEAT_QUEUE = 'plan-agent-heartbeat'
28
+
29
+ async function processPlanAgentHeartbeatJob(job: Job<PlanAgentHeartbeatJob>): Promise<void> {
30
+ await databaseService.connect()
31
+
32
+ if (job.data.type === 'wake-node') {
33
+ await planAgentHeartbeatService.wakeNode(job.data)
34
+ return
35
+ }
36
+
37
+ await planAgentHeartbeatService.sweep({ organizationId: job.data.organizationId })
38
+ }
39
+
40
+ const planAgentHeartbeatQueue = createQueueFactory<PlanAgentHeartbeatJob>({
41
+ name: PLAN_AGENT_HEARTBEAT_QUEUE,
42
+ displayName: 'Plan agent heartbeat',
43
+ jobName: 'plan-agent-heartbeat-job',
44
+ concurrency: 2,
45
+ lockDuration: LONG_JOB_LOCK_DURATION_MS,
46
+ stalledInterval: 120_000,
47
+ maxStalledCount: 5,
48
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 3, backoff: { type: 'exponential', delay: 5_000 } },
49
+ processor: processPlanAgentHeartbeatJob,
50
+ })
51
+
52
+ function buildWakeJobId(params: {
53
+ organizationId: string
54
+ workstreamId: string
55
+ runId: string
56
+ nodeId: string
57
+ agentId: string
58
+ reason: string
59
+ }): string {
60
+ const encode = (value: string) => Buffer.from(value).toString('base64url')
61
+ return `plan-agent-wake__${encode(params.runId)}__${encode(params.nodeId)}__${encode(params.agentId)}`
62
+ }
63
+
64
+ export async function enqueuePlanAgentHeartbeatWake(params: {
65
+ organizationId: string
66
+ workstreamId: string
67
+ runId: string
68
+ nodeId: string
69
+ agentId: string
70
+ reason: string
71
+ }): Promise<void> {
72
+ await planAgentHeartbeatQueue.enqueue({ type: 'wake-node', ...params }, { jobId: buildWakeJobId(params) })
73
+ }
74
+
75
+ const PLAN_AGENT_HEARTBEAT_SCHEDULER_ID = 'plan-agent-heartbeat-sweep'
76
+
77
+ export async function schedulePlanAgentHeartbeatSweep(params?: { everyMs?: number }): Promise<void> {
78
+ const everyMs = params?.everyMs ?? 30_000
79
+ await planAgentHeartbeatQueue
80
+ .getQueue()
81
+ .upsertJobScheduler(
82
+ PLAN_AGENT_HEARTBEAT_SCHEDULER_ID,
83
+ { every: everyMs },
84
+ { name: 'plan-agent-heartbeat-job', data: { type: 'sweep' }, opts: { ...DEFAULT_JOB_RETENTION } },
85
+ )
86
+ }
87
+
88
+ export function startPlanAgentHeartbeatWorker(options: { registerSignals?: boolean } = {}): WorkerHandle {
89
+ const handle = planAgentHeartbeatQueue.startWorker(options)
90
+
91
+ schedulePlanAgentHeartbeatSweep().catch((error: unknown) => {
92
+ serverLogger.error`Plan agent heartbeat scheduler setup failed: ${error}`
93
+ })
94
+
95
+ return handle
96
+ }
97
+
98
+ if (import.meta.main) {
99
+ startPlanAgentHeartbeatWorker()
100
+ }
@@ -1,5 +1,5 @@
1
1
  import { Queue, Worker } from 'bullmq'
2
- import type { Job, JobsOptions, WorkerOptions } from 'bullmq'
2
+ import type { Job, JobsOptions, QueueOptions, WorkerOptions } from 'bullmq'
3
3
  import type IORedis from 'ioredis'
4
4
 
5
5
  import { serverLogger } from '../config/logger'
@@ -39,18 +39,20 @@ interface QueueFactoryConfigFile extends QueueFactoryConfigBase {
39
39
  export type QueueFactoryConfig<TJob> = QueueFactoryConfigInline<TJob> | QueueFactoryConfigFile
40
40
 
41
41
  export interface QueueFactory<TJob> {
42
- getQueue: () => Queue<TJob, unknown, string>
42
+ getQueue: () => Queue<TJob, unknown, string, TJob, unknown, string>
43
43
  enqueue: (job: TJob, options?: JobsOptions) => Promise<void>
44
44
  startWorker: (options?: { registerSignals?: boolean }) => WorkerHandle
45
45
  }
46
46
 
47
47
  export function createQueueFactory<TJob>(config: QueueFactoryConfig<TJob>): QueueFactory<TJob> {
48
- let _queue: Queue<TJob, unknown, string> | null = null
48
+ type QueueShape = Queue<TJob, unknown, string, TJob, unknown, string>
49
+
50
+ let _queue: QueueShape | null = null
49
51
  let _queueConnection: IORedis | null = null
50
52
 
51
- const getConnection = () => config.connectionProvider?.() ?? getRedisConnectionForBullMQ()
53
+ const getConnection = (): IORedis => config.connectionProvider?.() ?? getRedisConnectionForBullMQ()
52
54
 
53
- const getQueue = (): Queue<TJob, unknown, string> => {
55
+ const getQueue = (): QueueShape => {
54
56
  const connection = getConnection()
55
57
  const shouldRecreateQueue =
56
58
  _queue === null ||
@@ -66,8 +68,8 @@ export function createQueueFactory<TJob>(config: QueueFactoryConfig<TJob>): Queu
66
68
  })
67
69
  }
68
70
 
69
- _queue = new Queue<TJob, unknown, string>(config.name, {
70
- connection,
71
+ _queue = new Queue<TJob, unknown, string, TJob, unknown, string>(config.name, {
72
+ connection: connection as QueueOptions['connection'],
71
73
  defaultJobOptions: { ...DEFAULT_JOB_RETENTION, ...config.defaultJobOptions },
72
74
  })
73
75
  _queueConnection = connection
@@ -78,9 +80,8 @@ export function createQueueFactory<TJob>(config: QueueFactoryConfig<TJob>): Queu
78
80
  return _queue
79
81
  }
80
82
 
81
- type QueueAdd = Queue<TJob, unknown, string>['add']
82
- const jobName = config.jobName as Parameters<QueueAdd>[0]
83
- const toData = (job: TJob) => job as Parameters<QueueAdd>[1]
83
+ const jobName = config.jobName
84
+ const toData = (job: TJob) => job
84
85
 
85
86
  const enqueue = async (job: TJob, options?: JobsOptions): Promise<void> => {
86
87
  const queuedJob = await getQueue().add(jobName, toData(job), options)
@@ -99,7 +100,7 @@ export function createQueueFactory<TJob>(config: QueueFactoryConfig<TJob>): Queu
99
100
  const { registerSignals = import.meta.main } = options
100
101
 
101
102
  const workerOptions: WorkerOptions = {
102
- connection: getConnection(),
103
+ connection: getConnection() as QueueOptions['connection'],
103
104
  concurrency: config.concurrency,
104
105
  ...(config.lockDuration !== undefined ? { lockDuration: config.lockDuration } : {}),
105
106
  ...(config.stalledInterval !== undefined ? { stalledInterval: config.stalledInterval } : {}),
@@ -43,7 +43,7 @@ end
43
43
  function log(logger: RedisLeaseLockLogger | undefined, level: keyof RedisLeaseLockLogger, message: string): void {
44
44
  const sink = logger?.[level]
45
45
  if (typeof sink === 'function') {
46
- sink(message)
46
+ sink.call(logger, message)
47
47
  }
48
48
  }
49
49
 
@@ -1,4 +1,10 @@
1
- import type { ExecutionMode, PlanArtifactSubmission, PlanNodeSpec } from '@lota-sdk/shared'
1
+ import type {
2
+ ExecutionMode,
3
+ OwnershipDispatchContext,
4
+ PlanArtifactSubmission,
5
+ PlanNodeSpec,
6
+ PlanNodeSpecRecord,
7
+ } from '@lota-sdk/shared'
2
8
 
3
9
  import { getLeadAgentId } from '../config/agent-defaults'
4
10
  import { resolveOnboardingOwnerAgentId } from '../config/workstream-defaults'
@@ -40,6 +46,27 @@ export const OWNERSHIP_DISPATCH_BLOCKED_TOOL_NAMES = Object.freeze([
40
46
  'teamThink',
41
47
  ])
42
48
 
49
+ export function buildCompletionCheckStructuredOutputHints(node: PlanNodeSpec | PlanNodeSpecRecord): string[] {
50
+ const hints: string[] = []
51
+
52
+ for (const check of node.completionChecks) {
53
+ if (check.type === 'llm-judge') {
54
+ const resultField = typeof check.config.resultField === 'string' ? check.config.resultField : 'passed'
55
+ hints.push(`Set structuredOutput.${resultField} = true only when this check is satisfied: ${check.description}`)
56
+ continue
57
+ }
58
+
59
+ if (check.type === 'human-approval') {
60
+ const approvedField = typeof check.config.approvedField === 'string' ? check.config.approvedField : 'approved'
61
+ hints.push(
62
+ `Set structuredOutput.${approvedField} = true only when this human approval check is satisfied: ${check.description}`,
63
+ )
64
+ }
65
+ }
66
+
67
+ return hints
68
+ }
69
+
43
70
  function buildOwnershipDispatchArtifactPayload(artifacts: PlanArtifactSubmission[]) {
44
71
  return artifacts.map((artifact) => ({
45
72
  name: artifact.name,
@@ -206,6 +233,7 @@ export function buildOwnershipDispatchContextSection(params: {
206
233
  node: PlanNodeSpec
207
234
  resolvedInput: Record<string, unknown>
208
235
  inputArtifacts: PlanArtifactSubmission[]
236
+ upstreamHandoffs?: OwnershipDispatchContext['upstreamHandoffs']
209
237
  }): string {
210
238
  const payload = {
211
239
  node: {
@@ -223,14 +251,15 @@ export function buildOwnershipDispatchContextSection(params: {
223
251
  },
224
252
  resolvedInput: params.resolvedInput,
225
253
  inputArtifacts: buildOwnershipDispatchArtifactPayload(params.inputArtifacts),
254
+ upstreamHandoffs: params.upstreamHandoffs ?? [],
226
255
  }
227
256
 
228
257
  return [
229
258
  '<ownership-dispatch-execution>',
230
259
  'You are executing a single isolated execution-plan node.',
231
260
  'Do not ask the user questions. Do not reference any hidden or prior workstream chat history.',
232
- 'Use only the provided node context, resolved input, and input artifacts.',
233
- 'Return only the final structured node result that satisfies the required output contract.',
261
+ 'Use only the provided node context, resolved input, input artifacts, and upstream handoff context.',
262
+ 'Return only the final structured node result that satisfies the required output contract, including durable handoffContext for downstream nodes.',
234
263
  JSON.stringify(payload, null, 2),
235
264
  '</ownership-dispatch-execution>',
236
265
  ].join('\n')
@@ -241,15 +270,20 @@ export function buildOwnershipDispatchResponseGuard(params: {
241
270
  executionMode?: ExecutionMode
242
271
  }): string {
243
272
  const mode = params.executionMode ?? 'linear'
273
+ const completionCheckHints = buildCompletionCheckStructuredOutputHints(params.node)
244
274
 
245
275
  if (mode === 'linear') {
246
276
  return [
247
277
  '<ownership-dispatch-result-contract>',
248
278
  'Return a single JSON object with this exact shape:',
249
- '{"structuredOutput"?: object, "artifacts": Array<{ "name": string, "kind": "json"|"markdown"|"file"|"external-ref"|"record", "pointer": string, "schemaRef"?: string, "description"?: string, "payload"?: object|array }>, "notes"?: string}',
279
+ '{"structuredOutput"?: object, "artifacts": Array<{ "name": string, "kind": "json"|"markdown"|"file"|"external-ref"|"record", "pointer": string, "schemaRef"?: string, "description"?: string, "payload"?: object|array }>, "notes"?: string, "handoffContext"?: { "summary": string, "keyDecisions"?: string[], "openQuestions"?: string[], "risks"?: string[], "recommendations"?: string[], "references"?: string[] }}',
250
280
  'Do not wrap the JSON in markdown or code fences.',
251
281
  `Node label: ${params.node.label}`,
252
282
  `Required deliverables: ${params.node.deliverables.length > 0 ? params.node.deliverables.map((item) => item.name).join(', ') : 'none'}`,
283
+ ...(completionCheckHints.length > 0
284
+ ? ['Structured output fields required by completion checks:', ...completionCheckHints]
285
+ : []),
286
+ 'If downstream nodes depend on this work, include handoffContext.',
253
287
  '</ownership-dispatch-result-contract>',
254
288
  ].join('\n')
255
289
  }
@@ -263,6 +297,9 @@ export function buildOwnershipDispatchResponseGuard(params: {
263
297
  .map((d) => d.name)
264
298
  .join(', ') || 'none'
265
299
  }`,
300
+ ...(completionCheckHints.length > 0
301
+ ? ['Write these structuredOutput fields when the checks are satisfied:', ...completionCheckHints]
302
+ : []),
266
303
  'If writeIntent returns validation_failed, correct and re-call.',
267
304
  'After all writes, return a brief summary.',
268
305
  '</ownership-dispatch-result-contract>',
@@ -0,0 +1,23 @@
1
+ import type { PlanExecutionVisibility, PlanNodeSpecRecord, PlanSpecRecord } from '@lota-sdk/shared'
2
+
3
+ import { agentRoster } from '../config/agent-defaults'
4
+
5
+ export function resolvePlanNodeExecutionVisibility(
6
+ plan: Pick<PlanSpecRecord, 'defaultExecutionVisibility'>,
7
+ node: Pick<PlanNodeSpecRecord, 'executionVisibility' | 'owner'>,
8
+ ): PlanExecutionVisibility {
9
+ const configuredVisibility =
10
+ node.executionVisibility === 'auto' ? plan.defaultExecutionVisibility : node.executionVisibility
11
+ if (configuredVisibility === 'visible' || configuredVisibility === 'silent') {
12
+ return configuredVisibility
13
+ }
14
+
15
+ return node.owner.executorType === 'agent' && agentRoster.includes(node.owner.ref) ? 'visible' : 'silent'
16
+ }
17
+
18
+ export function shouldPlanNodeUseVisibleTurn(
19
+ plan: Pick<PlanSpecRecord, 'defaultExecutionVisibility'>,
20
+ node: Pick<PlanNodeSpecRecord, 'executionVisibility' | 'owner'>,
21
+ ): boolean {
22
+ return resolvePlanNodeExecutionVisibility(plan, node) === 'visible'
23
+ }
@@ -7,6 +7,7 @@ const EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT = `<execution-plan-protocol>
7
7
  - The runtime executor owns lifecycle truth. Do not claim that a node is complete until submitExecutionNodeResult succeeds.
8
8
  - Use execution-plan tools to create, replace, inspect, and resume runs.
9
9
  - Visible workstream agents do not manually submit node results; dispatched execution nodes are completed by the runtime executor.
10
+ - When the runtime starts a plan-triggered visible execution turn, use the dedicated result-submission tool for that turn and include durable handoffContext for downstream nodes.
10
11
  - Treat the active execution runs in <execution-plan-state> as authoritative. Do not mutate run or node status in prose.
11
12
  - Work only on nodes that are active or explicitly ready for your executor. If a node is awaiting human input or approval, stop and let the runtime resume it.
12
13
  - If the graph, contracts, or success criteria materially change, replace the plan instead of silently drifting.
@@ -1,3 +1,10 @@
1
+ import type {
2
+ PlanEventRecord,
3
+ PlanNodeRunRecord,
4
+ PlanNodeSpecRecord,
5
+ PlanRunRecord,
6
+ PlanSpecRecord,
7
+ } from '@lota-sdk/shared'
1
8
  import type { ToolSet } from 'ai'
2
9
 
3
10
  import type { RecordIdRef } from '../db/record-id'
@@ -76,6 +83,24 @@ export interface LotaRuntimeTeamThinkToolsParams {
76
83
  toolProviders?: ToolSet
77
84
  }
78
85
 
86
+ export interface LotaRuntimePlanEventEnvelope {
87
+ event: PlanEventRecord
88
+ spec: PlanSpecRecord
89
+ run: PlanRunRecord
90
+ nodeSpec?: PlanNodeSpecRecord
91
+ nodeRun?: PlanNodeRunRecord
92
+ organizationId: string
93
+ workstreamId: string
94
+ runId: string
95
+ planSpecId: string
96
+ userId?: string
97
+ userName?: string
98
+ }
99
+
100
+ export interface LotaRuntimePlanEventAdapter {
101
+ onPlanEvent(event: LotaRuntimePlanEventEnvelope): Promise<void>
102
+ }
103
+
79
104
  export interface BuildContextParams {
80
105
  workstream: unknown
81
106
  workstreamRef: RecordIdRef
@@ -160,6 +185,7 @@ export interface LotaRuntimeTurnHooks {
160
185
 
161
186
  export interface LotaRuntimeAdapters {
162
187
  services?: { workspaceProvider?: LotaRuntimeWorkspaceProvider }
188
+ events?: { planEventAdapter?: LotaRuntimePlanEventAdapter }
163
189
  workstream?: {
164
190
  buildIndexedRepositoriesContext?: (workspaceId: string) => Promise<LotaRuntimeIndexedRepositoriesContext>
165
191
  buildTeamThinkAgentTools?: (params: LotaRuntimeTeamThinkToolsParams) => Promise<{ tools: ToolSet }>
@@ -2,6 +2,7 @@ import { startAutonomousJobWorker } from '../queues/autonomous-job.queue'
2
2
  import { startContextCompactionWorker } from '../queues/context-compaction.queue'
3
3
  import { startDelayedNodePromotionWorker } from '../queues/delayed-node-promotion.queue'
4
4
  import { scheduleRecurringConsolidation, startMemoryConsolidationWorker } from '../queues/memory-consolidation.queue'
5
+ import { schedulePlanAgentHeartbeatSweep, startPlanAgentHeartbeatWorker } from '../queues/plan-agent-heartbeat.queue'
5
6
  import { startPlanSchedulerWorker } from '../queues/plan-scheduler.queue'
6
7
  import { startPostChatMemoryWorker } from '../queues/post-chat-memory.queue'
7
8
  import { startRecentActivityTitleRefinementWorker } from '../queues/recent-activity-title-refinement.queue'
@@ -14,6 +15,7 @@ export interface LotaRuntimeWorkerStartRegistry {
14
15
  contextCompaction: typeof startContextCompactionWorker
15
16
  delayedNodePromotion: typeof startDelayedNodePromotionWorker
16
17
  memoryConsolidation: typeof startMemoryConsolidationWorker
18
+ planAgentHeartbeat: typeof startPlanAgentHeartbeatWorker
17
19
  planScheduler: typeof startPlanSchedulerWorker
18
20
  postChatMemory: typeof startPostChatMemoryWorker
19
21
  regularChatMemoryDigest: typeof startRegularChatMemoryDigestWorker
@@ -24,6 +26,7 @@ export interface LotaRuntimeWorkerStartRegistry {
24
26
 
25
27
  export interface LotaRuntimeWorkerScheduleRegistry {
26
28
  recurringConsolidation: typeof scheduleRecurringConsolidation
29
+ planAgentHeartbeatSweep: typeof schedulePlanAgentHeartbeatSweep
27
30
  }
28
31
 
29
32
  export interface LotaRuntimeWorkers {
@@ -43,6 +46,7 @@ export function buildRuntimeWorkerRegistry(extraWorkers?: LotaRuntimeWorkerExten
43
46
  contextCompaction: startContextCompactionWorker,
44
47
  delayedNodePromotion: startDelayedNodePromotionWorker,
45
48
  memoryConsolidation: startMemoryConsolidationWorker,
49
+ planAgentHeartbeat: startPlanAgentHeartbeatWorker,
46
50
  planScheduler: startPlanSchedulerWorker,
47
51
  postChatMemory: startPostChatMemoryWorker,
48
52
  regularChatMemoryDigest: startRegularChatMemoryDigestWorker,
@@ -51,6 +55,10 @@ export function buildRuntimeWorkerRegistry(extraWorkers?: LotaRuntimeWorkerExten
51
55
  recentActivityTitleRefinement: startRecentActivityTitleRefinementWorker,
52
56
  ...extraWorkers?.start,
53
57
  },
54
- schedule: { recurringConsolidation: scheduleRecurringConsolidation, ...extraWorkers?.schedule },
58
+ schedule: {
59
+ recurringConsolidation: scheduleRecurringConsolidation,
60
+ planAgentHeartbeatSweep: schedulePlanAgentHeartbeatSweep,
61
+ ...extraWorkers?.schedule,
62
+ },
55
63
  }
56
64
  }
@@ -17,6 +17,7 @@ import { databaseService } from '../db/service'
17
17
  import { TABLES } from '../db/tables'
18
18
  import {
19
19
  OWNERSHIP_DISPATCH_BLOCKED_TOOL_NAMES,
20
+ buildCompletionCheckStructuredOutputHints,
20
21
  buildOwnershipDispatchContextSection,
21
22
  buildOwnershipDispatchResponseGuard,
22
23
  } from '../runtime/agent-runtime-policy'
@@ -50,6 +51,7 @@ export function buildWriteIntentDispatchPrompt(nodeSpec: PlanNodeSpec): string {
50
51
  const deliverables = nodeSpec.deliverables
51
52
  .map((d) => `- ${d.name} (${d.kind}${d.required ? ', required' : ''})`)
52
53
  .join('\n')
54
+ const completionCheckHints = buildCompletionCheckStructuredOutputHints(nodeSpec)
53
55
  return [
54
56
  `Execute the execution-plan node "${nodeSpec.label}".`,
55
57
  `Objective: ${nodeSpec.objective}`,
@@ -59,6 +61,9 @@ export function buildWriteIntentDispatchPrompt(nodeSpec: PlanNodeSpec): string {
59
61
  deliverables,
60
62
  '',
61
63
  'For each, call writeIntent with targetPath matching the deliverable name.',
64
+ ...(completionCheckHints.length > 0
65
+ ? ['Also write these structuredOutput fields when the checks are satisfied:', ...completionCheckHints, '']
66
+ : []),
62
67
  'If writeIntent returns validation_failed, correct the payload and try again.',
63
68
  'When all deliverables are written, end with a brief completion summary.',
64
69
  ].join('\n')
@@ -151,6 +156,7 @@ class AgentExecutorService {
151
156
  node: params.nodeSpec,
152
157
  resolvedInput: params.resolvedInput,
153
158
  inputArtifacts: params.inputArtifacts,
159
+ upstreamHandoffs: params.context.upstreamHandoffs,
154
160
  }),
155
161
  ],
156
162
  responseGuardSection: buildOwnershipDispatchResponseGuard({ node: params.nodeSpec, executionMode: mode }),