@lota-sdk/core 0.4.13 → 0.4.15

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 (139) hide show
  1. package/package.json +4 -4
  2. package/src/ai/embedding-cache.ts +17 -11
  3. package/src/ai-gateway/ai-gateway.ts +164 -94
  4. package/src/ai-gateway/index.ts +4 -1
  5. package/src/config/agent-defaults.ts +2 -2
  6. package/src/config/agent-types.ts +1 -1
  7. package/src/config/constants.ts +1 -1
  8. package/src/create-runtime.ts +259 -200
  9. package/src/db/cursor-pagination.ts +2 -9
  10. package/src/db/memory-store.ts +194 -175
  11. package/src/db/memory.ts +125 -71
  12. package/src/db/schema-fingerprint.ts +5 -4
  13. package/src/db/service-normalization.ts +4 -3
  14. package/src/db/service.ts +3 -2
  15. package/src/db/startup.ts +15 -16
  16. package/src/effect/errors.ts +161 -21
  17. package/src/effect/index.ts +0 -1
  18. package/src/embeddings/provider.ts +15 -7
  19. package/src/queues/autonomous-job.queue.ts +10 -22
  20. package/src/queues/delayed-node-promotion.queue.ts +8 -14
  21. package/src/queues/document-processor.queue.ts +13 -4
  22. package/src/queues/memory-consolidation.queue.ts +26 -14
  23. package/src/queues/plan-agent-heartbeat.queue.ts +48 -31
  24. package/src/queues/plan-scheduler.queue.ts +37 -15
  25. package/src/queues/queue-factory.ts +59 -35
  26. package/src/queues/standalone-worker.ts +3 -2
  27. package/src/redis/connection.ts +10 -3
  28. package/src/redis/org-memory-lock.ts +1 -1
  29. package/src/redis/redis-lease-lock.ts +5 -5
  30. package/src/redis/stream-context.ts +1 -1
  31. package/src/runtime/chat-message.ts +64 -1
  32. package/src/runtime/chat-run-orchestration.ts +33 -20
  33. package/src/runtime/context-compaction/context-compaction-runtime.ts +14 -7
  34. package/src/runtime/context-compaction/context-compaction.ts +78 -66
  35. package/src/runtime/domain-layer.ts +19 -13
  36. package/src/runtime/execution-plan.ts +7 -3
  37. package/src/runtime/memory/memory-block.ts +3 -9
  38. package/src/runtime/memory/memory-scope.ts +3 -1
  39. package/src/runtime/plugin-resolution.ts +2 -1
  40. package/src/runtime/post-turn-side-effects.ts +6 -5
  41. package/src/runtime/retrieval-adapters.ts +8 -20
  42. package/src/runtime/runtime-config.ts +3 -9
  43. package/src/runtime/runtime-extensions.ts +2 -4
  44. package/src/runtime/runtime-lifecycle.ts +56 -16
  45. package/src/runtime/runtime-services.ts +180 -102
  46. package/src/runtime/runtime-worker-registry.ts +3 -1
  47. package/src/runtime/social-chat/social-chat-agent-runner.ts +1 -1
  48. package/src/runtime/social-chat/social-chat-history.ts +21 -18
  49. package/src/runtime/social-chat/social-chat.ts +356 -223
  50. package/src/runtime/specialist-runner.ts +3 -1
  51. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +3 -2
  52. package/src/runtime/thread-turn-context.ts +142 -102
  53. package/src/runtime/turn-lifecycle.ts +15 -46
  54. package/src/services/agent-activity.service.ts +1 -1
  55. package/src/services/agent-executor.service.ts +107 -77
  56. package/src/services/autonomous-job.service.ts +354 -293
  57. package/src/services/background-work.service.ts +3 -3
  58. package/src/services/context-compaction.service.ts +7 -2
  59. package/src/services/document-chunk.service.ts +50 -32
  60. package/src/services/execution-plan/execution-plan-schedule.ts +5 -3
  61. package/src/services/execution-plan/execution-plan.service.ts +162 -179
  62. package/src/services/feedback-loop.service.ts +5 -4
  63. package/src/services/graph-full-routing.ts +37 -36
  64. package/src/services/institutional-memory.service.ts +28 -30
  65. package/src/services/learned-skill.service.ts +107 -72
  66. package/src/services/memory/memory-errors.ts +4 -23
  67. package/src/services/memory/memory-org-memory.ts +10 -5
  68. package/src/services/memory/memory-rerank.ts +18 -6
  69. package/src/services/memory/memory.service.ts +170 -111
  70. package/src/services/memory/rerank.service.ts +29 -20
  71. package/src/services/organization-member.service.ts +1 -1
  72. package/src/services/organization.service.ts +69 -75
  73. package/src/services/ownership-dispatcher.service.ts +40 -39
  74. package/src/services/plan/plan-agent-heartbeat.service.ts +22 -24
  75. package/src/services/plan/plan-agent-query.service.ts +39 -31
  76. package/src/services/plan/plan-completion-side-effects.ts +13 -17
  77. package/src/services/plan/plan-coordination.service.ts +2 -1
  78. package/src/services/plan/plan-cycle.service.ts +6 -5
  79. package/src/services/plan/plan-deadline.service.ts +57 -54
  80. package/src/services/plan/plan-event-delivery.service.ts +5 -4
  81. package/src/services/plan/plan-executor-graph.ts +18 -15
  82. package/src/services/plan/plan-executor.service.ts +235 -262
  83. package/src/services/plan/plan-run.service.ts +169 -93
  84. package/src/services/plan/plan-scheduler.service.ts +192 -202
  85. package/src/services/plan/plan-template.service.ts +1 -1
  86. package/src/services/plan/plan-transaction-events.ts +1 -1
  87. package/src/services/plan/plan-workspace.service.ts +23 -14
  88. package/src/services/plugin-executor.service.ts +5 -9
  89. package/src/services/queue-job.service.ts +117 -59
  90. package/src/services/recent-activity-title.service.ts +13 -12
  91. package/src/services/recent-activity.service.ts +6 -1
  92. package/src/services/social-chat-history.service.ts +29 -25
  93. package/src/services/system-executor.service.ts +5 -9
  94. package/src/services/thread/thread-active-run.ts +2 -2
  95. package/src/services/thread/thread-listing.ts +61 -57
  96. package/src/services/thread/thread-memory-block.ts +73 -48
  97. package/src/services/thread/thread-message.service.ts +76 -65
  98. package/src/services/thread/thread-record-store.ts +8 -8
  99. package/src/services/thread/thread-title.service.ts +10 -4
  100. package/src/services/thread/thread-turn-execution.ts +43 -45
  101. package/src/services/thread/thread-turn-preparation.service.ts +257 -135
  102. package/src/services/thread/thread-turn-streaming.ts +83 -92
  103. package/src/services/thread/thread-turn.ts +18 -16
  104. package/src/services/thread/thread.service.ts +135 -100
  105. package/src/services/user.service.ts +45 -48
  106. package/src/storage/attachment-parser.ts +6 -2
  107. package/src/storage/attachment-storage.service.ts +5 -6
  108. package/src/storage/generated-document-storage.service.ts +1 -1
  109. package/src/system-agents/context-compaction.agent.ts +10 -9
  110. package/src/system-agents/delegated-agent-factory.ts +30 -6
  111. package/src/system-agents/memory-reranker.agent.ts +10 -9
  112. package/src/system-agents/memory.agent.ts +10 -9
  113. package/src/system-agents/recent-activity-title-refiner.agent.ts +13 -15
  114. package/src/system-agents/regular-chat-memory-digest.agent.ts +13 -12
  115. package/src/system-agents/skill-extractor.agent.ts +13 -12
  116. package/src/system-agents/skill-manager.agent.ts +13 -12
  117. package/src/system-agents/thread-router.agent.ts +11 -7
  118. package/src/system-agents/title-generator.agent.ts +13 -12
  119. package/src/tools/fetch-webpage.tool.ts +13 -13
  120. package/src/tools/memory-block.tool.ts +3 -1
  121. package/src/tools/plan-approval.tool.ts +4 -2
  122. package/src/tools/read-file-parts.tool.ts +10 -4
  123. package/src/tools/remember-memory.tool.ts +3 -1
  124. package/src/tools/research-topic.tool.ts +9 -5
  125. package/src/tools/search-web.tool.ts +16 -16
  126. package/src/tools/search.tool.ts +20 -5
  127. package/src/tools/team-think.tool.ts +61 -38
  128. package/src/utils/async.ts +5 -5
  129. package/src/utils/errors.ts +19 -18
  130. package/src/utils/sse-keepalive.ts +28 -25
  131. package/src/workers/bootstrap.ts +75 -11
  132. package/src/workers/memory-consolidation.worker.ts +82 -91
  133. package/src/workers/organization-learning.worker.ts +14 -4
  134. package/src/workers/regular-chat-memory-digest.runner.ts +105 -67
  135. package/src/workers/skill-extraction.runner.ts +97 -61
  136. package/src/workers/utils/repo-structure-extractor.ts +13 -8
  137. package/src/workers/utils/thread-message-query.ts +24 -24
  138. package/src/workers/worker-utils.ts +23 -4
  139. package/src/effect/helpers.ts +0 -123
@@ -1,23 +1,23 @@
1
- import type { Job } from 'bullmq'
1
+ import type { Job, JobsOptions } from 'bullmq'
2
2
  import { Effect, Schema } from 'effect'
3
3
  import type { Context } from 'effect'
4
4
  import type IORedis from 'ioredis'
5
5
 
6
6
  import { serverLogger } from '../config/logger'
7
7
  import { DatabaseServiceTag, RedisServiceTag } from '../effect/services'
8
+ import type { makePlanAgentHeartbeatService } from '../services/plan/plan-agent-heartbeat.service'
8
9
  import { QueueJobServiceTag } from '../services/queue-job.service'
9
10
  import type { QueueJobService, WorkerHandle } from '../workers/worker-utils'
10
11
  import { DEFAULT_JOB_RETENTION, LONG_JOB_LOCK_DURATION_MS } from '../workers/worker-utils'
11
12
  import { createQueueFactoryWithDeps } from './queue-factory'
12
13
  import { runStandaloneQueueWorker } from './standalone-worker'
13
14
 
14
- // Minimal service shape used by the worker processor. Declared structurally to
15
- // avoid importing the service tag — which would form a dependency cycle since
16
- // PlanAgentHeartbeatServiceLive depends on LotaQueuesServiceTag.
17
- interface PlanAgentHeartbeatWorkerServiceShape {
18
- wakeNode(params: PlanAgentHeartbeatWakeJob): Effect.Effect<unknown, unknown, unknown>
19
- sweep(params: { organizationId?: string }): Effect.Effect<void, unknown, unknown>
20
- }
15
+ type PlanAgentHeartbeatWorkerServiceMethods = Pick<
16
+ ReturnType<typeof makePlanAgentHeartbeatService>,
17
+ 'wakeNode' | 'sweep'
18
+ >
19
+
20
+ type PlanAgentHeartbeatWorkerServiceShape = PlanAgentHeartbeatWorkerServiceMethods
21
21
 
22
22
  class PlanAgentHeartbeatQueueError extends Schema.TaggedErrorClass<PlanAgentHeartbeatQueueError>()(
23
23
  '@lota-sdk/core/PlanAgentHeartbeatQueueError',
@@ -44,6 +44,7 @@ export type PlanAgentHeartbeatJob = PlanAgentHeartbeatWakeJob | PlanAgentHeartbe
44
44
  export const PLAN_AGENT_HEARTBEAT_QUEUE = 'plan-agent-heartbeat'
45
45
  const PLAN_AGENT_HEARTBEAT_SWEEP_INTERVAL_MS = 30_000
46
46
  const PLAN_AGENT_HEARTBEAT_SWEEP_JOB_ID = 'plan-agent-heartbeat-sweep'
47
+ const REUSABLE_HEARTBEAT_JOB_STATES = new Set(['completed', 'failed'])
47
48
 
48
49
  export interface PlanAgentHeartbeatWorkerDeps {
49
50
  databaseService: Context.Service.Shape<typeof DatabaseServiceTag>
@@ -84,35 +85,50 @@ export function makePlanAgentHeartbeatQueueRuntime(
84
85
  ): PlanAgentHeartbeatQueueRuntime {
85
86
  const { connectionProvider, queueJobService } = params
86
87
 
88
+ const enqueueReusableHeartbeatJob = async (job: PlanAgentHeartbeatJob, options: JobsOptions): Promise<void> => {
89
+ const jobId = typeof options.jobId === 'string' ? options.jobId : null
90
+ if (jobId) {
91
+ const existing = await queue.getQueue().getJob(jobId)
92
+ if (existing) {
93
+ const state = await existing.getState()
94
+ if (REUSABLE_HEARTBEAT_JOB_STATES.has(state)) {
95
+ await existing.remove()
96
+ serverLogger.info`Removed terminal Plan agent heartbeat job before re-enqueue (${jobId}, state=${state})`
97
+ }
98
+ }
99
+ }
100
+
101
+ await queue.enqueue(job, options)
102
+ }
103
+
87
104
  const enqueueDelayedSweep = (delayMs = PLAN_AGENT_HEARTBEAT_SWEEP_INTERVAL_MS): Promise<void> =>
88
- queue.enqueue({ type: 'sweep' }, { delay: delayMs, jobId: PLAN_AGENT_HEARTBEAT_SWEEP_JOB_ID })
105
+ enqueueReusableHeartbeatJob({ type: 'sweep' }, { delay: delayMs, jobId: PLAN_AGENT_HEARTBEAT_SWEEP_JOB_ID })
89
106
 
90
107
  const processPlanAgentHeartbeatJob = (
91
108
  deps: PlanAgentHeartbeatWorkerDeps,
92
109
  job: Job<PlanAgentHeartbeatJob>,
93
110
  ): Promise<void> => {
94
111
  const { planAgentHeartbeatService: service } = deps
95
- return Effect.runPromise(
96
- Effect.gen(function* () {
97
- if (job.data.type === 'wake-node') {
98
- const wakeJob = job.data
99
- yield* service.wakeNode(wakeJob)
100
- return
101
- }
102
-
103
- yield* service.sweep({ organizationId: job.data.organizationId })
104
- if (!job.data.organizationId) {
105
- yield* Effect.tryPromise({
106
- try: () => enqueueDelayedSweep(),
107
- catch: (cause) =>
108
- new PlanAgentHeartbeatQueueError({
109
- message: 'Failed to enqueue delayed plan-agent heartbeat sweep.',
110
- cause,
111
- }),
112
- })
113
- }
114
- }) as Effect.Effect<void, never, never>,
115
- )
112
+ const program: Effect.Effect<void, unknown, never> = Effect.gen(function* () {
113
+ if (job.data.type === 'wake-node') {
114
+ const wakeJob: PlanAgentHeartbeatWakeJob = job.data
115
+ yield* service.wakeNode(wakeJob)
116
+ return
117
+ }
118
+
119
+ yield* service.sweep({ organizationId: job.data.organizationId })
120
+ if (!job.data.organizationId) {
121
+ yield* Effect.tryPromise({
122
+ try: () => enqueueDelayedSweep(),
123
+ catch: (cause) =>
124
+ new PlanAgentHeartbeatQueueError({
125
+ message: 'Failed to enqueue delayed plan-agent heartbeat sweep.',
126
+ cause,
127
+ }),
128
+ })
129
+ }
130
+ })
131
+ return Effect.runPromise(program)
116
132
  }
117
133
 
118
134
  const queue = createQueueFactoryWithDeps<PlanAgentHeartbeatJob, PlanAgentHeartbeatWorkerDeps>({
@@ -130,7 +146,7 @@ export function makePlanAgentHeartbeatQueueRuntime(
130
146
 
131
147
  return {
132
148
  enqueuePlanAgentHeartbeatWake: (wakeParams) =>
133
- queue.enqueue({ type: 'wake-node', ...wakeParams }, { jobId: buildWakeJobId(wakeParams) }),
149
+ enqueueReusableHeartbeatJob({ type: 'wake-node', ...wakeParams }, { jobId: buildWakeJobId(wakeParams) }),
134
150
  startWorker: (options) => {
135
151
  const handle = queue.startWorker({
136
152
  deps: options.deps,
@@ -148,6 +164,7 @@ export function makePlanAgentHeartbeatQueueRuntime(
148
164
  }
149
165
 
150
166
  runStandaloneQueueWorker((runtime) => {
167
+ // @effect-diagnostics-next-line asyncFunction:off -- BullMQ standalone worker bootstrap.
151
168
  void (async () => {
152
169
  const { PlanAgentHeartbeatServiceTag } = await import('../services/plan/plan-agent-heartbeat.service')
153
170
  const resolve = <I, T>(tag: Context.Key<I, T>): T => runtime.runSync(Effect.service(tag))
@@ -4,10 +4,15 @@ import type { Context } from 'effect'
4
4
  import type IORedis from 'ioredis'
5
5
 
6
6
  import { serverLogger } from '../config/logger'
7
+ import { ERROR_TAGS } from '../effect/errors'
7
8
  import { DatabaseServiceTag, RedisServiceTag } from '../effect/services'
9
+ import type { makePlanCycleService } from '../services/plan/plan-cycle.service'
8
10
  import { PlanCycleServiceTag } from '../services/plan/plan-cycle.service'
11
+ import type { makePlanDeadlineService } from '../services/plan/plan-deadline.service'
9
12
  import { PlanDeadlineServiceTag } from '../services/plan/plan-deadline.service'
13
+ import type { makePlanExecutorService } from '../services/plan/plan-executor.service'
10
14
  import { PlanExecutorServiceTag } from '../services/plan/plan-executor.service'
15
+ import type { makePlanSchedulerService } from '../services/plan/plan-scheduler.service'
11
16
  import { PlanSchedulerServiceTag } from '../services/plan/plan-scheduler.service'
12
17
  import { QueueJobServiceTag } from '../services/queue-job.service'
13
18
  import { nowEpochMillis } from '../utils/date-time'
@@ -15,6 +20,20 @@ import type { QueueJobService, WorkerHandle } from '../workers/worker-utils'
15
20
  import { createQueueFactoryWithDeps } from './queue-factory'
16
21
  import { runStandaloneQueueWorker } from './standalone-worker'
17
22
 
23
+ type PlanSchedulerWorkerServiceMethods = Pick<
24
+ ReturnType<typeof makePlanSchedulerService>,
25
+ 'fireScheduleById' | 'recoverActiveSchedules'
26
+ >
27
+
28
+ type PlanDeadlineWorkerServiceMethods = Pick<
29
+ ReturnType<typeof makePlanDeadlineService>,
30
+ 'checkDeadlines' | 'recoverDeadlineChecks'
31
+ >
32
+
33
+ type PlanExecutorWorkerServiceMethods = Pick<ReturnType<typeof makePlanExecutorService>, 'promoteDelayedNode'>
34
+
35
+ type PlanCycleWorkerServiceMethods = Pick<ReturnType<typeof makePlanCycleService>, 'advanceCycle'>
36
+
18
37
  export interface PlanSchedulerFireJob {
19
38
  type: 'fire-schedule'
20
39
  scheduleId: string
@@ -31,17 +50,21 @@ export const PLAN_SCHEDULER_QUEUE = 'plan-scheduler'
31
50
 
32
51
  export interface PlanSchedulerWorkerDeps {
33
52
  databaseService: Context.Service.Shape<typeof DatabaseServiceTag>
34
- planSchedulerService: Context.Service.Shape<typeof PlanSchedulerServiceTag>
35
- planDeadlineService: Context.Service.Shape<typeof PlanDeadlineServiceTag>
36
- planExecutorService: Context.Service.Shape<typeof PlanExecutorServiceTag>
37
- planCycleService: Context.Service.Shape<typeof PlanCycleServiceTag>
53
+ runPromise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
54
+ planSchedulerService: PlanSchedulerWorkerServiceMethods
55
+ planDeadlineService: PlanDeadlineWorkerServiceMethods
56
+ planExecutorService: PlanExecutorWorkerServiceMethods
57
+ planCycleService: PlanCycleWorkerServiceMethods
38
58
  }
39
59
 
40
- class PlanSchedulerQueueError extends Schema.TaggedErrorClass<PlanSchedulerQueueError>()('PlanSchedulerQueueError', {
41
- stage: Schema.Literals(['remove-schedule-fire-job', 'recover-active-schedules', 'recover-deadline-checks']),
42
- message: Schema.String,
43
- cause: Schema.Defect,
44
- }) {}
60
+ class PlanSchedulerQueueError extends Schema.TaggedErrorClass<PlanSchedulerQueueError>()(
61
+ ERROR_TAGS.PlanSchedulerQueueError,
62
+ {
63
+ stage: Schema.Literals(['remove-schedule-fire-job', 'recover-active-schedules', 'recover-deadline-checks']),
64
+ message: Schema.String,
65
+ cause: Schema.Defect,
66
+ },
67
+ ) {}
45
68
 
46
69
  function toPlanSchedulerQueueError(stage: PlanSchedulerQueueError['stage'], cause: unknown): PlanSchedulerQueueError {
47
70
  return new PlanSchedulerQueueError({ stage, message: cause instanceof Error ? cause.message : String(cause), cause })
@@ -49,10 +72,8 @@ function toPlanSchedulerQueueError(stage: PlanSchedulerQueueError['stage'], caus
49
72
 
50
73
  function processPlanSchedulerJob(deps: PlanSchedulerWorkerDeps, job: Job<PlanSchedulerJob>): Promise<void> {
51
74
  const { planSchedulerService, planDeadlineService, planExecutorService, planCycleService } = deps
52
- const runWithResolvedContext = <A, E>(effect: Effect.Effect<A, E, unknown>): Promise<void> =>
53
- // Service Effects carry their provided context through the managed runtime
54
- // that resolved these service tags, so residual R collapses at runtime.
55
- Effect.runPromise(Effect.asVoid(effect as Effect.Effect<A, E, never>))
75
+ const runWithResolvedContext = <A, E, R>(effect: Effect.Effect<A, E, R>): Promise<void> =>
76
+ deps.runPromise(Effect.asVoid(effect))
56
77
 
57
78
  switch (job.data.type) {
58
79
  case 'fire-schedule':
@@ -132,7 +153,7 @@ export function makePlanSchedulerQueueRuntime(params: MakePlanSchedulerQueueRunt
132
153
  void Effect.runFork(
133
154
  deps.planSchedulerService.recoverActiveSchedules().pipe(
134
155
  Effect.mapError((cause) => toPlanSchedulerQueueError('recover-active-schedules', cause)),
135
- Effect.catchTag('PlanSchedulerQueueError', (error) =>
156
+ Effect.catchTag(ERROR_TAGS.PlanSchedulerQueueError, (error) =>
136
157
  Effect.sync(() => {
137
158
  serverLogger.error`Plan scheduler startup recovery failed: ${error.message}`
138
159
  }),
@@ -144,7 +165,7 @@ export function makePlanSchedulerQueueRuntime(params: MakePlanSchedulerQueueRunt
144
165
  void Effect.runFork(
145
166
  deps.planDeadlineService.recoverDeadlineChecks().pipe(
146
167
  Effect.mapError((cause) => toPlanSchedulerQueueError('recover-deadline-checks', cause)),
147
- Effect.catchTag('PlanSchedulerQueueError', (error) =>
168
+ Effect.catchTag(ERROR_TAGS.PlanSchedulerQueueError, (error) =>
148
169
  Effect.sync(() => {
149
170
  serverLogger.error`Plan deadline recovery failed: ${error.message}`
150
171
  }),
@@ -168,6 +189,7 @@ runStandaloneQueueWorker((runtime) => {
168
189
  planSchedulerQueue.startWorker({
169
190
  deps: {
170
191
  databaseService: resolve(DatabaseServiceTag),
192
+ runPromise: (effect) => runtime.runPromise(effect),
171
193
  planSchedulerService: resolve(PlanSchedulerServiceTag),
172
194
  planDeadlineService: resolve(PlanDeadlineServiceTag),
173
195
  planExecutorService: resolve(PlanExecutorServiceTag),
@@ -5,6 +5,7 @@ import type IORedis from 'ioredis'
5
5
 
6
6
  import type { LotaLogger } from '../config/logger'
7
7
  import { serverLogger } from '../config/logger'
8
+ import { ERROR_TAGS } from '../effect/errors'
8
9
  import type { TrackedBullJobLike } from '../services/queue-job.service'
9
10
  import {
10
11
  attachWorkerEvents,
@@ -15,7 +16,7 @@ import {
15
16
  } from '../workers/worker-utils'
16
17
  import type { QueueJobService, WorkerHandle } from '../workers/worker-utils'
17
18
 
18
- class QueueFactoryError extends Schema.TaggedErrorClass<QueueFactoryError>()('@lota-sdk/core/QueueFactoryError', {
19
+ class QueueFactoryError extends Schema.TaggedErrorClass<QueueFactoryError>()(ERROR_TAGS.QueueFactoryError, {
19
20
  message: Schema.String,
20
21
  cause: Schema.optional(Schema.Defect),
21
22
  }) {}
@@ -148,7 +149,7 @@ function createQueueFactoryRuntime<TJob>(config: QueueFactoryConfigBase): {
148
149
  return Reflect.get(target, property, receiver) as unknown
149
150
  }
150
151
 
151
- const value = (target as unknown as Record<string, unknown>)[property]
152
+ const value = Reflect.get(target, property, receiver) as unknown
152
153
  if (typeof value !== 'function' || !queueMethodsThatWaitForClose.has(property as QueueMethod)) {
153
154
  return value
154
155
  }
@@ -229,46 +230,69 @@ function createQueueFactoryRuntime<TJob>(config: QueueFactoryConfigBase): {
229
230
  ...(config.maxStalledCount !== undefined ? { maxStalledCount: config.maxStalledCount } : {}),
230
231
  }
231
232
 
232
- const worker = workerConfig.processorPath
233
- ? new Worker(config.name, workerConfig.processorPath, workerOptions)
234
- : new Worker(
235
- config.name,
236
- createTracedWorkerProcessor(
233
+ let worker: Worker
234
+ try {
235
+ worker = workerConfig.processorPath
236
+ ? new Worker(config.name, workerConfig.processorPath, workerOptions)
237
+ : new Worker(
237
238
  config.name,
238
- (job) =>
239
- Effect.runPromise(
240
- Effect.gen(function* () {
241
- const inlineWorkerConfig = workerConfig as QueueWorkerConfigInline<TJob>
242
- const typedJob = job as Job<TJob>
243
- const prepare = inlineWorkerConfig.prepare
244
- if (prepare) {
245
- yield* Effect.tryPromise({
246
- try: () => prepare(typedJob),
239
+ createTracedWorkerProcessor(
240
+ config.name,
241
+ (job) =>
242
+ Effect.runPromise(
243
+ Effect.gen(function* () {
244
+ const inlineWorkerConfig = workerConfig as QueueWorkerConfigInline<TJob>
245
+ const typedJob = job as Job<TJob>
246
+ const prepare = inlineWorkerConfig.prepare
247
+ if (prepare) {
248
+ yield* Effect.tryPromise({
249
+ try: () => prepare(typedJob),
250
+ catch: (cause) =>
251
+ new QueueFactoryError({
252
+ message: `Worker prepare failed for queue "${config.name}".`,
253
+ cause,
254
+ }),
255
+ })
256
+ }
257
+ return yield* Effect.tryPromise({
258
+ try: () => inlineWorkerConfig.processor(typedJob),
247
259
  catch: (cause) =>
248
- new QueueFactoryError({ message: `Worker prepare failed for queue "${config.name}".`, cause }),
260
+ new QueueFactoryError({
261
+ message: `Worker processor failed for queue "${config.name}".`,
262
+ cause,
263
+ }),
249
264
  })
250
- }
251
- return yield* Effect.tryPromise({
252
- try: () => inlineWorkerConfig.processor(typedJob),
253
- catch: (cause) =>
254
- new QueueFactoryError({ message: `Worker processor failed for queue "${config.name}".`, cause }),
255
- })
256
- }),
257
- ),
258
- config.queueJobService,
259
- ),
260
- workerOptions,
261
- )
265
+ }),
266
+ ),
267
+ config.queueJobService,
268
+ ),
269
+ workerOptions,
270
+ )
271
+ } catch (error) {
272
+ // Construction threw — nothing to clean up since the Worker is created
273
+ // atomically by BullMQ; just rethrow so the caller can fail loudly.
274
+ logger.error`Failed to construct BullMQ worker "${config.displayName}": ${error}`
275
+ throw error
276
+ }
262
277
 
263
- attachWorkerEvents(worker, config.displayName, logger)
278
+ // Acquire-style setup — if ANY step below throws, best-effort close the
279
+ // already-constructed worker before bubbling the error so no connection
280
+ // leaks.
281
+ try {
282
+ attachWorkerEvents(worker, config.displayName, logger)
283
+ const shutdown = createWorkerShutdown(worker, config.displayName, logger)
264
284
 
265
- const shutdown = createWorkerShutdown(worker, config.displayName, logger)
285
+ if (registerSignals) {
286
+ registerShutdownSignals({ name: config.displayName, shutdown, logger })
287
+ }
266
288
 
267
- if (registerSignals) {
268
- registerShutdownSignals({ name: config.displayName, shutdown, logger })
289
+ return { worker, shutdown }
290
+ } catch (error) {
291
+ void worker.close().catch((closeError: unknown) => {
292
+ logger.warn`Failed to close BullMQ worker "${config.displayName}" after setup error: ${closeError}`
293
+ })
294
+ throw error
269
295
  }
270
-
271
- return { worker, shutdown }
272
296
  }
273
297
 
274
298
  return { getQueue, enqueue, startWorker }
@@ -2,10 +2,11 @@ import type { ManagedRuntime } from 'effect'
2
2
  import { Schema, Effect } from 'effect'
3
3
 
4
4
  import { serverLogger } from '../config/logger'
5
+ import { ERROR_TAGS } from '../effect/errors'
5
6
  import { initializeSandboxedWorkerRuntime } from '../workers/bootstrap'
6
7
 
7
8
  class StandaloneQueueWorkerError extends Schema.TaggedErrorClass<StandaloneQueueWorkerError>()(
8
- 'StandaloneQueueWorkerError',
9
+ ERROR_TAGS.StandaloneQueueWorkerError,
9
10
  { message: Schema.String, cause: Schema.Defect },
10
11
  ) {}
11
12
 
@@ -28,7 +29,7 @@ export function runStandaloneQueueWorker(start: (runtime: ManagedRuntime.Managed
28
29
 
29
30
  yield* Effect.sync(() => start(runtime))
30
31
  }).pipe(
31
- Effect.catchTag('StandaloneQueueWorkerError', (error) =>
32
+ Effect.catchTag(ERROR_TAGS.StandaloneQueueWorkerError, (error) =>
32
33
  Effect.sync(() => {
33
34
  serverLogger.error`Standalone queue worker failed: ${error.message}`
34
35
  process.exit(1)
@@ -3,7 +3,6 @@ import IORedis from 'ioredis'
3
3
  import type { RedisOptions } from 'ioredis'
4
4
 
5
5
  import { RedisError } from '../effect/errors'
6
- import { effectTryServicePromise } from '../effect/helpers'
7
6
  import { getErrorMessage } from '../utils/errors'
8
7
 
9
8
  export interface RedisConnectionLogger {
@@ -147,7 +146,10 @@ function acquireRedisClient(
147
146
 
148
147
  if (client.status !== 'end') {
149
148
  const quitExit = yield* Effect.exit(
150
- effectTryServicePromise(() => client.quit(), 'Failed to close Redis connection manager'),
149
+ Effect.tryPromise({
150
+ try: () => client.quit(),
151
+ catch: (cause) => new RedisError({ message: 'Failed to close Redis connection manager', cause }),
152
+ }),
151
153
  )
152
154
  if (Exit.isFailure(quitExit)) {
153
155
  log(options.logger, 'warn', `Redis quit failed, forcing disconnect: ${getErrorMessage(quitExit.cause)}`)
@@ -190,7 +192,12 @@ function startHealthCheckFiber(
190
192
  isHealthCheckRunning = true
191
193
  try {
192
194
  if (client.status === 'ready') {
193
- const pingExit = yield* Effect.exit(effectTryServicePromise(() => client.ping(), 'Redis health check failed'))
195
+ const pingExit = yield* Effect.exit(
196
+ Effect.tryPromise({
197
+ try: () => client.ping(),
198
+ catch: (cause) => new RedisError({ message: 'Redis health check failed', cause }),
199
+ }),
200
+ )
194
201
  if (Exit.isFailure(pingExit)) {
195
202
  log(logger, 'warn', `Redis health check failed: ${getErrorMessage(pingExit.cause)}`)
196
203
  state.isHealthy = false
@@ -16,7 +16,7 @@ const ORG_MEMORY_LOCK_WAIT_LOG_INTERVAL_MS = 30_000
16
16
  const ORG_MEMORY_LOCK_MAX_WAIT_MS = 15 * 60 * 1000
17
17
 
18
18
  class OrgMemoryLockCallbackError extends Schema.TaggedErrorClass<OrgMemoryLockCallbackError>()(
19
- 'OrgMemoryLockCallbackError',
19
+ '@lota-sdk/core/OrgMemoryLockCallbackError',
20
20
  { message: Schema.String, cause: Schema.Defect },
21
21
  ) {}
22
22
 
@@ -1,7 +1,7 @@
1
1
  import { Clock, Deferred, Duration, Effect, Random, Schedule } from 'effect'
2
2
  import type IORedis from 'ioredis'
3
3
 
4
- import { LockAcquisitionError, LockLostError, RedisError } from '../effect/errors'
4
+ import { ERROR_TAGS, LockAcquisitionError, LockLostError, RedisError } from '../effect/errors'
5
5
  import { RedisServiceTag } from '../effect/services'
6
6
  import { getErrorMessage } from '../utils/errors'
7
7
 
@@ -119,7 +119,7 @@ function acquireLock(
119
119
  schedule: Schedule.fixed(Duration.millis(options.retryDelayMs)),
120
120
  }).pipe(
121
121
  Effect.asVoid,
122
- Effect.catchTag('LockAcquisitionError', () =>
122
+ Effect.catchTag(ERROR_TAGS.LockAcquisitionError, () =>
123
123
  Effect.fail(new LockAcquisitionError({ lockKey: options.lockKey, maxWaitMs: options.maxWaitMs })),
124
124
  ),
125
125
  )
@@ -170,7 +170,7 @@ function startRefreshFiber(
170
170
  Effect.andThen(
171
171
  Effect.sync(() => {
172
172
  const message =
173
- error._tag === 'LockLostError'
173
+ error._tag === ERROR_TAGS.LockLostError
174
174
  ? `${options.label} refresh was rejected for key ${options.lockKey}`
175
175
  : error.message
176
176
 
@@ -188,8 +188,8 @@ function startRefreshFiber(
188
188
  yield* Effect.sleep(Duration.millis(options.refreshIntervalMs))
189
189
 
190
190
  yield* refreshLock(redis, options, lockValue).pipe(
191
- Effect.catchTag('LockLostError', handleLockLoss),
192
- Effect.catchTag('RedisError', handleLockLoss),
191
+ Effect.catchTag(ERROR_TAGS.LockLostError, handleLockLoss),
192
+ Effect.catchTag(ERROR_TAGS.RedisError, handleLockLoss),
193
193
  )
194
194
  }
195
195
  })
@@ -22,7 +22,7 @@ function toPublisher(client: Redis): Publisher {
22
22
  type SharedSubscriberEvent = { readonly channel: string; readonly message: string }
23
23
 
24
24
  class SharedSubscriberCloseError extends Schema.TaggedErrorClass<SharedSubscriberCloseError>()(
25
- 'SharedSubscriberCloseError',
25
+ '@lota-sdk/core/SharedSubscriberCloseError',
26
26
  { message: Schema.String, cause: Schema.optional(Schema.Defect) },
27
27
  ) {}
28
28
 
@@ -1,4 +1,15 @@
1
- import type { MessagePartLike } from './chat-types'
1
+ import { chatLogger } from '../config/logger'
2
+ import type { ChatMessageLike, MessagePartLike } from './chat-types'
3
+
4
+ const AI_SDK_SUPPORTED_MESSAGE_PART_TYPES = new Set([
5
+ 'text',
6
+ 'reasoning',
7
+ 'source-url',
8
+ 'source-document',
9
+ 'file',
10
+ 'step-start',
11
+ 'dynamic-tool',
12
+ ])
2
13
 
3
14
  export function hasMessageContent(parts: readonly MessagePartLike[]): boolean {
4
15
  for (const part of parts) {
@@ -8,3 +19,55 @@ export function hasMessageContent(parts: readonly MessagePartLike[]): boolean {
8
19
 
9
20
  return false
10
21
  }
22
+
23
+ function isAiSdkSupportedMessagePart(part: MessagePartLike): boolean {
24
+ if (typeof part.type !== 'string') {
25
+ return false
26
+ }
27
+
28
+ return (
29
+ AI_SDK_SUPPORTED_MESSAGE_PART_TYPES.has(part.type) || part.type.startsWith('data-') || part.type.startsWith('tool-')
30
+ )
31
+ }
32
+
33
+ function isSubstantiveAssistantMessagePart(part: MessagePartLike): boolean {
34
+ return isAiSdkSupportedMessagePart(part) && part.type !== 'step-start'
35
+ }
36
+
37
+ function sanitizeAssistantMessageParts<TPart extends MessagePartLike>(parts: readonly TPart[]): TPart[] {
38
+ return parts.filter((part): part is TPart => isSubstantiveAssistantMessagePart(part))
39
+ }
40
+
41
+ export function sanitizePersistedMessages<TMessage extends ChatMessageLike>(messages: readonly TMessage[]): TMessage[] {
42
+ const sanitizedMessages: TMessage[] = []
43
+
44
+ for (const message of messages) {
45
+ const rawMessageId = (message as Record<string, unknown>).id
46
+ const messageId = typeof rawMessageId === 'string' && rawMessageId.trim() ? rawMessageId : 'unknown'
47
+
48
+ if (message.parts.length === 0) {
49
+ chatLogger.warn`Dropping persisted thread message with empty parts during history sanitization (role=${message.role}, id=${messageId})`
50
+ continue
51
+ }
52
+
53
+ if (message.role !== 'assistant') {
54
+ sanitizedMessages.push(message)
55
+ continue
56
+ }
57
+
58
+ const sanitizedParts = sanitizeAssistantMessageParts(message.parts)
59
+ if (sanitizedParts.length === 0) {
60
+ chatLogger.warn`Dropping persisted assistant thread message without substantive parts during history sanitization (id=${messageId})`
61
+ continue
62
+ }
63
+
64
+ if (sanitizedParts.length === message.parts.length) {
65
+ sanitizedMessages.push(message)
66
+ continue
67
+ }
68
+
69
+ sanitizedMessages.push({ ...message, parts: sanitizedParts } as TMessage)
70
+ }
71
+
72
+ return sanitizedMessages
73
+ }
@@ -1,17 +1,20 @@
1
1
  import { Cause, Context, Schema, Duration, Effect, Latch, Layer } from 'effect'
2
2
 
3
- import { effectTryPromise } from '../effect/helpers'
3
+ import { ERROR_TAGS } from '../effect/errors'
4
4
  import { nowEpochMillis } from '../utils/date-time'
5
5
 
6
6
  const COMPACTION_WAIT_REFRESH_MS = 1_000
7
7
  const COMPACTION_MAX_WAIT_MS = 120_000
8
8
 
9
- class WaitForCompactionError extends Schema.TaggedErrorClass<WaitForCompactionError>()('WaitForCompactionError', {
10
- entityId: Schema.String,
11
- entityLabel: Schema.String,
12
- message: Schema.String,
13
- cause: Schema.optional(Schema.Defect),
14
- }) {}
9
+ class WaitForCompactionError extends Schema.TaggedErrorClass<WaitForCompactionError>()(
10
+ ERROR_TAGS.WaitForCompactionError,
11
+ {
12
+ entityId: Schema.String,
13
+ entityLabel: Schema.String,
14
+ message: Schema.String,
15
+ cause: Schema.optional(Schema.Defect),
16
+ },
17
+ ) {}
15
18
 
16
19
  function toWaitForCompactionError(params: {
17
20
  entityId: string
@@ -28,12 +31,12 @@ function toWaitForCompactionError(params: {
28
31
 
29
32
  interface CompactionCoordination {
30
33
  readonly signal: (entityId: string, compacting: boolean) => Effect.Effect<void>
31
- readonly waitIfNeeded: <TEntity>(params: {
34
+ readonly waitIfNeeded: <TEntity, TError, TRequirements>(params: {
32
35
  entityId: string
33
36
  entityLabel: string
34
- loadEntity: () => PromiseLike<TEntity> | Effect.Effect<TEntity, unknown>
37
+ loadEntity: () => Effect.Effect<TEntity, TError, TRequirements>
35
38
  isCompacting: (entity: TEntity) => boolean
36
- }) => Effect.Effect<TEntity, WaitForCompactionError>
39
+ }) => Effect.Effect<TEntity, WaitForCompactionError, TRequirements>
37
40
  }
38
41
 
39
42
  export class CompactionCoordinationTag extends Context.Service<CompactionCoordinationTag, CompactionCoordination>()(
@@ -65,18 +68,25 @@ export const CompactionCoordinationLive = Layer.effect(
65
68
  }
66
69
  }),
67
70
 
68
- waitIfNeeded: Effect.fn('CompactionCoordination.waitIfNeeded')(function* <TEntity>(params: {
71
+ waitIfNeeded: Effect.fn('CompactionCoordination.waitIfNeeded')(function* <
72
+ TEntity,
73
+ TError,
74
+ TRequirements,
75
+ >(params: {
69
76
  entityId: string
70
77
  entityLabel: string
71
- loadEntity: () => PromiseLike<TEntity> | Effect.Effect<TEntity, unknown>
78
+ loadEntity: () => Effect.Effect<TEntity, TError, TRequirements>
72
79
  isCompacting: (entity: TEntity) => boolean
73
80
  }) {
74
81
  const deadline = nowEpochMillis() + COMPACTION_MAX_WAIT_MS
75
82
  const latch = getLatch(params.entityId)
76
- let entity = yield* effectTryPromise(
77
- () => params.loadEntity(),
78
- (cause) => toWaitForCompactionError({ entityId: params.entityId, entityLabel: params.entityLabel, cause }),
79
- )
83
+ let entity = yield* params
84
+ .loadEntity()
85
+ .pipe(
86
+ Effect.mapError((cause) =>
87
+ toWaitForCompactionError({ entityId: params.entityId, entityLabel: params.entityLabel, cause }),
88
+ ),
89
+ )
80
90
 
81
91
  while (params.isCompacting(entity)) {
82
92
  Latch.closeUnsafe(latch)
@@ -94,10 +104,13 @@ export const CompactionCoordinationLive = Layer.effect(
94
104
  Effect.timeout(Duration.millis(refreshWindowMs)),
95
105
  Effect.catchIf(Cause.isTimeoutError, () => Effect.void),
96
106
  )
97
- entity = yield* effectTryPromise(
98
- () => params.loadEntity(),
99
- (cause) => toWaitForCompactionError({ entityId: params.entityId, entityLabel: params.entityLabel, cause }),
100
- )
107
+ entity = yield* params
108
+ .loadEntity()
109
+ .pipe(
110
+ Effect.mapError((cause) =>
111
+ toWaitForCompactionError({ entityId: params.entityId, entityLabel: params.entityLabel, cause }),
112
+ ),
113
+ )
101
114
  }
102
115
 
103
116
  Latch.openUnsafe(latch)