@lota-sdk/core 0.4.9 → 0.4.11

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 (182) hide show
  1. package/package.json +2 -2
  2. package/src/ai/embedding-cache.ts +3 -1
  3. package/src/ai-gateway/ai-gateway.ts +164 -82
  4. package/src/ai-gateway/index.ts +16 -1
  5. package/src/config/agent-defaults.ts +4 -107
  6. package/src/config/agent-types.ts +1 -1
  7. package/src/config/background-processing.ts +1 -1
  8. package/src/config/index.ts +0 -1
  9. package/src/config/logger.ts +22 -25
  10. package/src/config/thread-defaults.ts +1 -10
  11. package/src/create-runtime.ts +145 -670
  12. package/src/db/base.service.ts +30 -38
  13. package/src/db/memory-query-builder.ts +2 -1
  14. package/src/db/memory-store.ts +29 -20
  15. package/src/db/memory.ts +188 -195
  16. package/src/db/service-normalization.ts +97 -64
  17. package/src/db/service.ts +496 -384
  18. package/src/db/startup.ts +30 -19
  19. package/src/effect/helpers.ts +30 -5
  20. package/src/effect/index.ts +7 -7
  21. package/src/effect/layers.ts +75 -72
  22. package/src/effect/services.ts +15 -11
  23. package/src/embeddings/provider.ts +65 -71
  24. package/src/index.ts +13 -12
  25. package/src/queues/autonomous-job.queue.ts +177 -143
  26. package/src/queues/context-compaction.queue.ts +41 -39
  27. package/src/queues/delayed-node-promotion.queue.ts +61 -42
  28. package/src/queues/document-processor.queue.ts +5 -3
  29. package/src/queues/index.ts +1 -0
  30. package/src/queues/memory-consolidation.queue.ts +79 -53
  31. package/src/queues/organization-learning.queue.ts +70 -33
  32. package/src/queues/plan-agent-heartbeat.queue.ts +111 -83
  33. package/src/queues/plan-scheduler.queue.ts +101 -97
  34. package/src/queues/post-chat-memory.queue.ts +56 -46
  35. package/src/queues/queue-factory.ts +146 -69
  36. package/src/queues/queues.service.ts +61 -0
  37. package/src/queues/title-generation.queue.ts +44 -44
  38. package/src/redis/connection.ts +181 -164
  39. package/src/redis/org-memory-lock.ts +24 -9
  40. package/src/redis/redis-lease-lock.ts +8 -1
  41. package/src/redis/stream-context.ts +17 -9
  42. package/src/runtime/agent-identity-overrides.ts +7 -3
  43. package/src/runtime/agent-runtime-policy.ts +10 -5
  44. package/src/runtime/agent-stream-helpers.ts +24 -15
  45. package/src/runtime/chat-run-orchestration.ts +1 -1
  46. package/src/runtime/context-compaction/context-compaction-runtime.ts +28 -32
  47. package/src/runtime/context-compaction/context-compaction.ts +131 -85
  48. package/src/runtime/domain-layer.ts +203 -0
  49. package/src/runtime/execution-plan-visibility.ts +5 -2
  50. package/src/runtime/graph-designer.ts +0 -14
  51. package/src/runtime/helper-model.ts +8 -4
  52. package/src/runtime/index.ts +1 -1
  53. package/src/runtime/indexed-repositories-policy.ts +2 -6
  54. package/src/runtime/memory/memory-block.ts +19 -9
  55. package/src/runtime/memory/memory-pipeline.ts +53 -66
  56. package/src/runtime/memory/memory-scope.ts +33 -29
  57. package/src/runtime/plugin-resolution.ts +58 -62
  58. package/src/runtime/post-turn-side-effects.ts +139 -161
  59. package/src/runtime/retrieval-adapters.ts +4 -4
  60. package/src/runtime/runtime-config.ts +3 -9
  61. package/src/runtime/runtime-extensions.ts +0 -43
  62. package/src/runtime/runtime-lifecycle.ts +124 -0
  63. package/src/runtime/runtime-services.ts +455 -0
  64. package/src/runtime/runtime-worker-registry.ts +113 -30
  65. package/src/runtime/social-chat/social-chat-agent-runner.ts +13 -8
  66. package/src/runtime/social-chat/social-chat-history.ts +24 -13
  67. package/src/runtime/social-chat/social-chat.ts +420 -369
  68. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +64 -57
  69. package/src/runtime/team-consultation/team-consultation-prompts.ts +11 -6
  70. package/src/runtime/thread-chat-helpers.ts +18 -9
  71. package/src/runtime/thread-turn-context.ts +28 -74
  72. package/src/runtime/turn-lifecycle.ts +6 -14
  73. package/src/services/agent-activity.service.ts +169 -176
  74. package/src/services/agent-executor.service.ts +207 -196
  75. package/src/services/artifact.service.ts +10 -5
  76. package/src/services/attachment.service.ts +16 -48
  77. package/src/services/autonomous-job.service.ts +81 -87
  78. package/src/services/background-work.service.ts +54 -0
  79. package/src/services/chat-run-registry.service.ts +3 -1
  80. package/src/services/context-compaction.service.ts +8 -10
  81. package/src/services/document-chunk.service.ts +8 -17
  82. package/src/services/execution-plan/execution-plan-graph.ts +122 -109
  83. package/src/services/execution-plan/execution-plan-schedule.ts +1 -15
  84. package/src/services/execution-plan/execution-plan.service.ts +68 -51
  85. package/src/services/feedback-loop.service.ts +1 -1
  86. package/src/services/global-orchestrator.service.ts +49 -15
  87. package/src/services/graph-full-routing.ts +49 -37
  88. package/src/services/index.ts +1 -0
  89. package/src/services/institutional-memory.service.ts +8 -17
  90. package/src/services/learned-skill.service.ts +38 -35
  91. package/src/services/memory/memory-conversation.ts +10 -5
  92. package/src/services/memory/memory-errors.ts +27 -0
  93. package/src/services/memory/memory-org-memory.ts +14 -3
  94. package/src/services/memory/memory-preseeded.ts +10 -4
  95. package/src/services/memory/memory-utils.ts +2 -1
  96. package/src/services/memory/memory.service.ts +37 -52
  97. package/src/services/memory/rerank.service.ts +3 -11
  98. package/src/services/monitoring-window.service.ts +1 -1
  99. package/src/services/mutating-approval.service.ts +1 -1
  100. package/src/services/node-workspace.service.ts +2 -2
  101. package/src/services/notification.service.ts +16 -4
  102. package/src/services/organization-member.service.ts +1 -1
  103. package/src/services/organization.service.ts +34 -51
  104. package/src/services/ownership-dispatcher.service.ts +148 -95
  105. package/src/services/plan/plan-agent-heartbeat.service.ts +30 -16
  106. package/src/services/plan/plan-agent-query.service.ts +13 -9
  107. package/src/services/plan/plan-approval.service.ts +52 -48
  108. package/src/services/plan/plan-artifact.service.ts +2 -2
  109. package/src/services/plan/plan-builder.service.ts +2 -2
  110. package/src/services/plan/plan-checkpoint.service.ts +1 -1
  111. package/src/services/plan/plan-compiler.service.ts +1 -1
  112. package/src/services/plan/plan-completion-side-effects.ts +99 -113
  113. package/src/services/plan/plan-coordination.service.ts +1 -1
  114. package/src/services/plan/plan-cycle.service.ts +171 -202
  115. package/src/services/plan/plan-deadline.service.ts +304 -307
  116. package/src/services/plan/plan-event-delivery.service.ts +84 -72
  117. package/src/services/plan/plan-executor-context.ts +2 -0
  118. package/src/services/plan/plan-executor-graph.ts +375 -353
  119. package/src/services/plan/plan-executor-helpers.ts +60 -75
  120. package/src/services/plan/plan-executor.service.ts +494 -489
  121. package/src/services/plan/plan-run.service.ts +12 -19
  122. package/src/services/plan/plan-scheduler.service.ts +89 -82
  123. package/src/services/plan/plan-template.service.ts +1 -1
  124. package/src/services/plan/plan-transaction-events.ts +8 -5
  125. package/src/services/plan/plan-validator.service.ts +1 -1
  126. package/src/services/plan/plan-workspace.service.ts +17 -11
  127. package/src/services/plugin-executor.service.ts +26 -21
  128. package/src/services/quality-metrics.service.ts +1 -1
  129. package/src/services/queue-job.service.ts +8 -17
  130. package/src/services/recent-activity-title.service.ts +22 -10
  131. package/src/services/recent-activity.service.ts +1 -1
  132. package/src/services/skill-resolver.service.ts +1 -1
  133. package/src/services/social-chat-history.service.ts +37 -20
  134. package/src/services/system-executor.service.ts +25 -20
  135. package/src/services/thread/thread-bootstrap.ts +37 -19
  136. package/src/services/thread/thread-listing.ts +2 -1
  137. package/src/services/thread/thread-memory-block.ts +18 -5
  138. package/src/services/thread/thread-message.service.ts +30 -13
  139. package/src/services/thread/thread-title.service.ts +1 -1
  140. package/src/services/thread/thread-turn-execution.ts +87 -83
  141. package/src/services/thread/thread-turn-preparation.service.ts +65 -40
  142. package/src/services/thread/thread-turn-streaming.ts +32 -36
  143. package/src/services/thread/thread-turn.ts +43 -29
  144. package/src/services/thread/thread.service.ts +32 -8
  145. package/src/services/user.service.ts +1 -1
  146. package/src/services/write-intent-validator.service.ts +1 -1
  147. package/src/storage/attachment-storage.service.ts +7 -4
  148. package/src/storage/generated-document-storage.service.ts +1 -1
  149. package/src/system-agents/context-compaction.agent.ts +1 -1
  150. package/src/system-agents/helper-agent-options.ts +1 -1
  151. package/src/system-agents/memory-reranker.agent.ts +1 -1
  152. package/src/system-agents/memory.agent.ts +1 -1
  153. package/src/system-agents/recent-activity-title-refiner.agent.ts +9 -6
  154. package/src/system-agents/regular-chat-memory-digest.agent.ts +1 -1
  155. package/src/system-agents/skill-extractor.agent.ts +1 -1
  156. package/src/system-agents/skill-manager.agent.ts +1 -1
  157. package/src/system-agents/thread-router.agent.ts +23 -20
  158. package/src/system-agents/title-generator.agent.ts +1 -1
  159. package/src/tools/execution-plan.tool.ts +36 -20
  160. package/src/tools/fetch-webpage.tool.ts +30 -22
  161. package/src/tools/firecrawl-client.ts +1 -6
  162. package/src/tools/plan-approval.tool.ts +9 -1
  163. package/src/tools/remember-memory.tool.ts +3 -6
  164. package/src/tools/research-topic.tool.ts +12 -3
  165. package/src/tools/search-web.tool.ts +26 -18
  166. package/src/tools/search.tool.ts +4 -5
  167. package/src/tools/team-think.tool.ts +139 -121
  168. package/src/utils/async.ts +15 -6
  169. package/src/utils/errors.ts +27 -15
  170. package/src/workers/bootstrap.ts +34 -58
  171. package/src/workers/memory-consolidation.worker.ts +4 -1
  172. package/src/workers/organization-learning.worker.ts +16 -3
  173. package/src/workers/regular-chat-memory-digest.helpers.ts +3 -4
  174. package/src/workers/regular-chat-memory-digest.runner.ts +46 -29
  175. package/src/workers/skill-extraction.runner.ts +13 -15
  176. package/src/workers/worker-utils.ts +14 -8
  177. package/src/config/search.ts +0 -3
  178. package/src/effect/awaitable-effect.ts +0 -87
  179. package/src/effect/runtime-ref.ts +0 -25
  180. package/src/effect/runtime.ts +0 -31
  181. package/src/redis/runtime-connection.ts +0 -10
  182. package/src/runtime/agent-types.ts +0 -1
@@ -1,8 +1,8 @@
1
- import { Duration, Effect, Exit, Fiber } from 'effect'
1
+ import { Duration, Effect, Exit, Scope } from 'effect'
2
2
  import IORedis from 'ioredis'
3
3
  import type { RedisOptions } from 'ioredis'
4
4
 
5
- import { ConfigurationError } from '../effect/errors'
5
+ import { RedisError } from '../effect/errors'
6
6
  import { effectTryServicePromise } from '../effect/helpers'
7
7
  import { getErrorMessage } from '../utils/errors'
8
8
 
@@ -32,8 +32,6 @@ const REDIS_RETRY_STEP_MS = 50
32
32
  const REDIS_RETRY_MAX_DELAY_MS = 2000
33
33
  const REDIS_CONNECT_TIMEOUT_MS = 10_000
34
34
 
35
- type HealthCheckFiber = ReturnType<typeof Effect.runFork>
36
-
37
35
  export const DEFAULT_REDIS_OPTIONS: RedisOptions = {
38
36
  maxRetriesPerRequest: null,
39
37
  enableReadyCheck: true,
@@ -57,181 +55,200 @@ function log(logger: RedisConnectionLogger | undefined, level: keyof RedisConnec
57
55
  }
58
56
  }
59
57
 
60
- class RedisConnectionManagerImpl implements RedisConnectionManager {
61
- private redis: IORedis | null = null
62
- private isHealthy = false
63
- private isInitialized = false
64
- private isClosing = false
65
- private isHealthCheckRunning = false
66
- private healthChecksActive = false
67
- private healthCheckFiber: HealthCheckFiber | null = null
68
-
69
- constructor(private readonly options: CreateRedisConnectionManagerOptions) {
70
- this.initializeConnection()
71
- }
72
-
73
- private initializeConnection(): void {
74
- if (this.isInitialized) {
75
- return
76
- }
77
-
78
- this.isInitialized = true
79
-
80
- try {
81
- const redisOptions = this.options.redisOptions ?? DEFAULT_REDIS_OPTIONS
82
- this.redis = new IORedis(this.options.url, redisOptions)
83
- this.setupEventHandlers()
84
- this.startHealthChecks()
85
- } catch (error) {
86
- log(this.options.logger, 'error', `Failed to initialize Redis connection: ${getErrorMessage(error)}`)
87
- }
88
- }
89
-
90
- private setupEventHandlers(): void {
91
- if (!this.redis) return
92
-
93
- this.redis.on('connect', () => {
94
- log(this.options.logger, 'info', 'Redis connected')
95
- this.isHealthy = true
96
- })
97
-
98
- this.redis.on('ready', () => {
99
- log(this.options.logger, 'info', 'Redis ready')
100
- this.isHealthy = true
101
- })
102
-
103
- this.redis.on('error', (error: Error) => {
104
- log(this.options.logger, 'error', `Redis error: ${error.message}`)
105
- this.isHealthy = false
106
- })
107
-
108
- this.redis.on('close', () => {
109
- log(
110
- this.options.logger,
111
- this.isClosing ? 'info' : 'warn',
112
- this.isClosing ? 'Redis connection closed during shutdown' : 'Redis connection closed',
113
- )
114
- this.isHealthy = false
115
- })
116
-
117
- this.redis.on('reconnecting', () => {
118
- log(this.options.logger, 'info', 'Redis reconnecting...')
119
- })
120
-
121
- this.redis.on('end', () => {
122
- log(
123
- this.options.logger,
124
- this.isClosing ? 'info' : 'warn',
125
- this.isClosing ? 'Redis connection ended during shutdown' : 'Redis connection ended',
126
- )
127
- this.isHealthy = false
128
- })
129
- }
58
+ interface ManagerState {
59
+ isHealthy: boolean
60
+ isClosing: boolean
61
+ }
130
62
 
131
- private startHealthChecks(): void {
132
- if (this.healthChecksActive || this.healthCheckFiber) {
133
- return
134
- }
63
+ /**
64
+ * Build the IORedis client + listeners as an `Effect.acquireRelease`. Each
65
+ * handler is held as a const ref so the release step can pass the same
66
+ * function to `client.off(...)`, then `quit()` (falling back to
67
+ * `disconnect()`) the client. Prevents tracked-handler leaks on disposal.
68
+ */
69
+ function acquireRedisClient(
70
+ options: CreateRedisConnectionManagerOptions,
71
+ state: ManagerState,
72
+ ): Effect.Effect<IORedis, RedisError, Scope.Scope> {
73
+ return Effect.acquireRelease(
74
+ Effect.try({
75
+ try: () => {
76
+ const redisOptions = options.redisOptions ?? DEFAULT_REDIS_OPTIONS
77
+ const client = new IORedis(options.url, redisOptions)
78
+
79
+ const onConnect = () => {
80
+ log(options.logger, 'info', 'Redis connected')
81
+ state.isHealthy = true
82
+ }
83
+ const onReady = () => {
84
+ log(options.logger, 'info', 'Redis ready')
85
+ state.isHealthy = true
86
+ }
87
+ const onError = (error: Error) => {
88
+ log(options.logger, 'error', `Redis error: ${error.message}`)
89
+ state.isHealthy = false
90
+ }
91
+ const onClose = () => {
92
+ log(
93
+ options.logger,
94
+ state.isClosing ? 'info' : 'warn',
95
+ state.isClosing ? 'Redis connection closed during shutdown' : 'Redis connection closed',
96
+ )
97
+ state.isHealthy = false
98
+ }
99
+ const onReconnecting = () => {
100
+ log(options.logger, 'info', 'Redis reconnecting...')
101
+ }
102
+ const onEnd = () => {
103
+ log(
104
+ options.logger,
105
+ state.isClosing ? 'info' : 'warn',
106
+ state.isClosing ? 'Redis connection ended during shutdown' : 'Redis connection ended',
107
+ )
108
+ state.isHealthy = false
109
+ }
135
110
 
136
- this.healthChecksActive = true
137
- const intervalMs = this.options.healthCheckIntervalMs ?? DEFAULT_HEALTH_CHECK_INTERVAL_MS
138
- const logger = this.options.logger
139
- const isHealthChecksActive = () => this.healthChecksActive
140
- const isHealthCheckRunning = () => this.isHealthCheckRunning
141
- const setHealthCheckRunning = (value: boolean) => {
142
- this.isHealthCheckRunning = value
143
- }
144
- const getRedis = () => this.redis
145
- const setHealthy = (value: boolean) => {
146
- this.isHealthy = value
147
- }
111
+ client.on('connect', onConnect)
112
+ client.on('ready', onReady)
113
+ client.on('error', onError)
114
+ client.on('close', onClose)
115
+ client.on('reconnecting', onReconnecting)
116
+ client.on('end', onEnd)
117
+
118
+ // Carry the handler refs alongside the client so the release closure
119
+ // can detach exactly what was attached.
120
+ ;(client as ClientWithHandlers).__lotaHandlers = {
121
+ connect: onConnect,
122
+ ready: onReady,
123
+ error: onError,
124
+ close: onClose,
125
+ reconnecting: onReconnecting,
126
+ end: onEnd,
127
+ }
148
128
 
149
- this.healthCheckFiber = Effect.runFork(
129
+ return client
130
+ },
131
+ catch: (cause) =>
132
+ new RedisError({ message: `Failed to initialize Redis connection: ${getErrorMessage(cause)}`, cause }),
133
+ }),
134
+ (client) =>
150
135
  Effect.gen(function* () {
151
- for (;;) {
152
- yield* Effect.sleep(Duration.millis(intervalMs))
153
- if (!isHealthChecksActive()) break
154
- if (isHealthCheckRunning()) continue
155
-
156
- setHealthCheckRunning(true)
157
- try {
158
- const redis = getRedis()
159
- if (redis && redis.status === 'ready') {
160
- const pingExit = yield* Effect.exit(
161
- effectTryServicePromise(() => redis.ping(), 'Redis health check failed'),
162
- )
163
- if (Exit.isFailure(pingExit)) {
164
- log(logger, 'warn', `Redis health check failed: ${getErrorMessage(pingExit.cause)}`)
165
- setHealthy(false)
166
- } else {
167
- setHealthy(true)
168
- }
169
- }
170
- } finally {
171
- setHealthCheckRunning(false)
136
+ state.isClosing = true
137
+
138
+ const handlers = (client as ClientWithHandlers).__lotaHandlers
139
+ if (handlers) {
140
+ client.off('connect', handlers.connect)
141
+ client.off('ready', handlers.ready)
142
+ client.off('error', handlers.error)
143
+ client.off('close', handlers.close)
144
+ client.off('reconnecting', handlers.reconnecting)
145
+ client.off('end', handlers.end)
146
+ }
147
+
148
+ if (client.status !== 'end') {
149
+ const quitExit = yield* Effect.exit(
150
+ effectTryServicePromise(() => client.quit(), 'Failed to close Redis connection manager'),
151
+ )
152
+ if (Exit.isFailure(quitExit)) {
153
+ log(options.logger, 'warn', `Redis quit failed, forcing disconnect: ${getErrorMessage(quitExit.cause)}`)
154
+ client.disconnect()
172
155
  }
173
156
  }
174
157
  }),
175
- )
176
- }
177
-
178
- private stopHealthChecks(): Promise<void> {
179
- this.healthChecksActive = false
180
-
181
- const fiber = this.healthCheckFiber
182
- if (!fiber) {
183
- return Promise.resolve()
184
- }
158
+ )
159
+ }
185
160
 
186
- this.healthCheckFiber = null
187
- return Effect.runPromise(Fiber.interrupt(fiber)).then(
188
- () => undefined,
189
- () => undefined,
190
- )
191
- }
161
+ interface RedisHandlers {
162
+ readonly connect: () => void
163
+ readonly ready: () => void
164
+ readonly error: (error: Error) => void
165
+ readonly close: () => void
166
+ readonly reconnecting: () => void
167
+ readonly end: () => void
168
+ }
192
169
 
193
- getConnection(): IORedis {
194
- if (!this.redis) {
195
- throw new ConfigurationError({ message: 'Redis connection not initialized', key: 'redis' })
170
+ type ClientWithHandlers = IORedis & { __lotaHandlers?: RedisHandlers }
171
+
172
+ /**
173
+ * Health-check loop wired via `Effect.forkScoped` so interruption is automatic
174
+ * when the owning scope closes. No more imperative fiber tracking.
175
+ */
176
+ function startHealthCheckFiber(
177
+ client: IORedis,
178
+ options: CreateRedisConnectionManagerOptions,
179
+ state: ManagerState,
180
+ ): Effect.Effect<void, never, Scope.Scope> {
181
+ const intervalMs = options.healthCheckIntervalMs ?? DEFAULT_HEALTH_CHECK_INTERVAL_MS
182
+ const logger = options.logger
183
+ let isHealthCheckRunning = false
184
+
185
+ const loop = Effect.gen(function* () {
186
+ for (;;) {
187
+ yield* Effect.sleep(Duration.millis(intervalMs))
188
+ if (isHealthCheckRunning) continue
189
+
190
+ isHealthCheckRunning = true
191
+ try {
192
+ if (client.status === 'ready') {
193
+ const pingExit = yield* Effect.exit(effectTryServicePromise(() => client.ping(), 'Redis health check failed'))
194
+ if (Exit.isFailure(pingExit)) {
195
+ log(logger, 'warn', `Redis health check failed: ${getErrorMessage(pingExit.cause)}`)
196
+ state.isHealthy = false
197
+ } else {
198
+ state.isHealthy = true
199
+ }
200
+ }
201
+ } finally {
202
+ isHealthCheckRunning = false
203
+ }
196
204
  }
197
- return this.redis
198
- }
205
+ })
199
206
 
200
- getConnectionForBullMQ(): IORedis {
201
- return this.getConnection()
202
- }
203
-
204
- isConnectionHealthy(): boolean {
205
- return this.isHealthy && this.redis?.status === 'ready'
206
- }
207
+ return Effect.asVoid(Effect.forkScoped(loop))
208
+ }
207
209
 
208
- closeConnection(): Promise<void> {
209
- this.isClosing = true
210
- const logger = this.options.logger
211
- const getRedis = () => this.redis
212
- const stopHealthChecks = () => this.stopHealthChecks()
213
- const disconnect = () => {
214
- this.redis?.disconnect()
210
+ /**
211
+ * Scoped builder for the Redis connection manager. The IORedis client and
212
+ * health-check fiber are both bound to the surrounding scope, so closing the
213
+ * scope tears down listeners + quits the client + interrupts the fiber.
214
+ */
215
+ export function makeRedisConnectionManager(
216
+ options: CreateRedisConnectionManagerOptions,
217
+ ): Effect.Effect<RedisConnectionManager, RedisError, Scope.Scope> {
218
+ return Effect.gen(function* () {
219
+ const state: ManagerState = { isHealthy: false, isClosing: false }
220
+ const client = yield* acquireRedisClient(options, state)
221
+ yield* startHealthCheckFiber(client, options, state)
222
+
223
+ const manager: RedisConnectionManager = {
224
+ getConnection: () => client,
225
+ getConnectionForBullMQ: () => client,
226
+ isConnectionHealthy: () => state.isHealthy && client.status === 'ready',
227
+ closeConnection: () => Promise.resolve(),
215
228
  }
216
229
 
217
- return Effect.runPromise(
218
- Effect.gen(function* () {
219
- yield* Effect.tryPromise(() => stopHealthChecks())
220
- const redis = getRedis()
221
- if (redis && redis.status !== 'end') {
222
- const quitExit = yield* Effect.exit(
223
- effectTryServicePromise(() => redis.quit(), 'Failed to close Redis connection manager'),
224
- )
225
- if (Exit.isFailure(quitExit)) {
226
- log(logger, 'warn', `Redis quit failed, forcing disconnect: ${getErrorMessage(quitExit.cause)}`)
227
- disconnect()
228
- }
229
- }
230
- }),
231
- )
232
- }
230
+ return manager
231
+ })
233
232
  }
234
233
 
234
+ /**
235
+ * Standalone Promise-friendly factory for hosts that build the manager outside
236
+ * a Layer. Owns its own `Scope`; `closeConnection()` closes that scope, which
237
+ * runs the same finalizer chain (listener removal, quit/disconnect, fiber
238
+ * interrupt) that `Layer.effect` runs at runtime shutdown.
239
+ */
235
240
  export function createRedisConnectionManager(options: CreateRedisConnectionManagerOptions): RedisConnectionManager {
236
- return new RedisConnectionManagerImpl(options)
241
+ const scope = Scope.makeUnsafe()
242
+ const built = Effect.runSync(Scope.provide(makeRedisConnectionManager(options), scope))
243
+ let closePromise: Promise<void> | null = null
244
+
245
+ return {
246
+ ...built,
247
+ closeConnection: () => {
248
+ if (!closePromise) {
249
+ closePromise = Effect.runPromise(Scope.close(scope, Exit.void).pipe(Effect.catchCause(() => Effect.void)))
250
+ }
251
+ return closePromise
252
+ },
253
+ }
237
254
  }
@@ -1,12 +1,12 @@
1
1
  import { Schema, Effect } from 'effect'
2
- import type IORedis from 'ioredis'
3
2
 
4
3
  import { serverLogger } from '../config/logger'
5
4
  import { LockAcquisitionError } from '../effect/errors'
6
5
  import type { LockLostError, RedisError } from '../effect/errors'
6
+ import { RedisServiceTag } from '../effect/services'
7
+ import type { RedisConnectionManager } from './connection'
7
8
  import { withLeaseLock } from './redis-lease-lock'
8
9
  import type { RedisLeaseLockOptions } from './redis-lease-lock'
9
- import { getRuntimeRedisConnection } from './runtime-connection'
10
10
 
11
11
  const ORG_MEMORY_LOCK_PREFIX = 'lock:org-memory:org:'
12
12
  const ORG_MEMORY_LOCK_TTL_MS = 120_000
@@ -20,9 +20,8 @@ class OrgMemoryLockCallbackError extends Schema.TaggedErrorClass<OrgMemoryLockCa
20
20
  { message: Schema.String, cause: Schema.Defect },
21
21
  ) {}
22
22
 
23
- function createOrgMemoryLockOptions(redis: IORedis, orgId: string): RedisLeaseLockOptions & { redis: IORedis } {
23
+ function createOrgMemoryLockOptions(orgId: string): RedisLeaseLockOptions {
24
24
  return {
25
- redis,
26
25
  lockKey: `${ORG_MEMORY_LOCK_PREFIX}${orgId}`,
27
26
  lockTtlMs: ORG_MEMORY_LOCK_TTL_MS,
28
27
  retryDelayMs: ORG_MEMORY_LOCK_RETRY_DELAY_MS,
@@ -44,13 +43,28 @@ function createOrgMemoryLockOptions(redis: IORedis, orgId: string): RedisLeaseLo
44
43
  }
45
44
  }
46
45
 
47
- export function withOrgMemoryLock<T>(orgId: string, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
46
+ /**
47
+ * Promise-returning wrapper for hosts that live outside Effect.
48
+ *
49
+ * Callers must pass in a `RedisConnectionManager` (usually
50
+ * `runtime.redis.manager`) so the wrapper can build a standalone Redis layer
51
+ * instead of relying on an ambient managed runtime.
52
+ */
53
+ export function withOrgMemoryLock<T>(
54
+ orgId: string,
55
+ fn: (signal: AbortSignal) => Promise<T>,
56
+ redisManager: RedisConnectionManager,
57
+ ): Promise<T> {
48
58
  return Effect.runPromise(
49
59
  withOrgMemoryLockEffect(orgId, (signal) =>
50
60
  Effect.tryPromise({
51
61
  try: () => fn(signal),
52
62
  catch: (cause) => new OrgMemoryLockCallbackError({ message: 'Org memory lock callback failed.', cause }),
53
63
  }),
64
+ ).pipe(
65
+ // Provide the redis service synchronously so the Effect has no residual
66
+ // requirements; the outer Promise call edge needs `R = never`.
67
+ Effect.provideService(RedisServiceTag, redisManager),
54
68
  ),
55
69
  )
56
70
  }
@@ -58,7 +72,7 @@ export function withOrgMemoryLock<T>(orgId: string, fn: (signal: AbortSignal) =>
58
72
  export function withOrgMemoryLockEffect<A, E>(
59
73
  orgId: string,
60
74
  fn: (signal: AbortSignal) => Effect.Effect<A, E>,
61
- ): Effect.Effect<A, E | LockAcquisitionError | LockLostError | RedisError> {
75
+ ): Effect.Effect<A, E | LockAcquisitionError | LockLostError | RedisError, RedisServiceTag> {
62
76
  const normalizedOrgId = orgId.trim()
63
77
 
64
78
  if (!normalizedOrgId) {
@@ -70,7 +84,8 @@ export function withOrgMemoryLockEffect<A, E>(
70
84
  )
71
85
  }
72
86
 
73
- return Effect.sync(() => getRuntimeRedisConnection()).pipe(
74
- Effect.flatMap((redis) => withLeaseLock(createOrgMemoryLockOptions(redis, normalizedOrgId), fn)),
75
- )
87
+ return Effect.gen(function* () {
88
+ const redis = yield* RedisServiceTag
89
+ return yield* withLeaseLock({ ...createOrgMemoryLockOptions(normalizedOrgId), redis: redis.getConnection() }, fn)
90
+ })
76
91
  }
@@ -259,9 +259,16 @@ export function withLeaseLock<A, E, R>(
259
259
  Effect.ignore,
260
260
  )
261
261
 
262
+ // The refresh fiber races the user's effect against a `lockLost` Deferred
263
+ // so callers can observe Redis-side expirations. We pass the abort signal
264
+ // to `fn` so it can cooperatively wind down on lost-lock or external stop,
265
+ // but do NOT interrupt `fn` via `raceFirst` on `lockLost` — aborting a
266
+ // turn that has already produced tokens would discard the response. The
267
+ // caller owns the post-lease cleanup; the signal is enough for anyone who
268
+ // needs to bail out early.
262
269
  return yield* Effect.scoped(
263
270
  startRefreshFiber(redis, normalized, lockValue, abortController, lockLost).pipe(
264
- Effect.andThen(fn(abortController.signal).pipe(Effect.raceFirst(Deferred.await(lockLost)))),
271
+ Effect.andThen(fn(abortController.signal)),
265
272
  Effect.ensuring(cleanup),
266
273
  ),
267
274
  )
@@ -1,11 +1,14 @@
1
- import { Context, Schema, Effect, Fiber, Layer, PubSub, Stream } from 'effect'
1
+ import { Context, Schema, Effect, Fiber, Layer, Queue, Stream } from 'effect'
2
2
  import type { Redis } from 'ioredis'
3
3
  import type { Publisher, Subscriber } from 'resumable-stream/ioredis'
4
4
  import { createResumableStreamContext } from 'resumable-stream/ioredis'
5
5
 
6
+ import { serverLogger } from '../config/logger'
6
7
  import { RedisServiceTag } from '../effect/services'
7
8
  import type { RedisConnectionManager } from './connection'
8
9
 
10
+ const SUBSCRIBER_QUEUE_CAPACITY = 1024
11
+
9
12
  function toPublisher(client: Redis): Publisher {
10
13
  return {
11
14
  connect: () => Promise.resolve(),
@@ -26,7 +29,7 @@ class SharedSubscriberCloseError extends Schema.TaggedErrorClass<SharedSubscribe
26
29
  export class SharedThreadStreamSubscriberTag extends Context.Service<
27
30
  SharedThreadStreamSubscriberTag,
28
31
  { readonly subscriber: Subscriber }
29
- >()('SharedThreadStreamSubscriber') {}
32
+ >()('@lota-sdk/core/SharedThreadStreamSubscriber') {}
30
33
 
31
34
  export const SharedThreadStreamSubscriberLive = Layer.effect(
32
35
  SharedThreadStreamSubscriberTag,
@@ -35,13 +38,15 @@ export const SharedThreadStreamSubscriberLive = Layer.effect(
35
38
  // Disable enableReadyCheck — the ready check sends INFO which is rejected
36
39
  // on connections in subscribe mode, causing unhandled ioredis error events.
37
40
  const client = redisManager.getConnection().duplicate({ enableReadyCheck: false })
38
- client.on('error', () => {}) // prevent [ioredis] Unhandled error event logs
41
+ client.on('error', (error: unknown) => {
42
+ serverLogger.warn('Redis subscriber error', { cause: error })
43
+ })
39
44
 
40
- const events = yield* PubSub.unbounded<SharedSubscriberEvent>()
45
+ const events = yield* Queue.bounded<SharedSubscriberEvent>(SUBSCRIBER_QUEUE_CAPACITY)
41
46
  const handlers = new Map<string, (message: string) => void>()
42
47
 
43
48
  const dispatchFiber = yield* Effect.forkScoped(
44
- Stream.fromPubSub(events).pipe(
49
+ Stream.fromQueue(events).pipe(
45
50
  Stream.runForEach((event) =>
46
51
  Effect.sync(() => {
47
52
  handlers.get(event.channel)?.(event.message)
@@ -50,10 +55,13 @@ export const SharedThreadStreamSubscriberLive = Layer.effect(
50
55
  ),
51
56
  )
52
57
 
53
- const currentContext = yield* Effect.context()
54
- const runFork = Effect.runForkWith(currentContext)
55
58
  const messageListener = (channel: string, message: string) => {
56
- void runFork(PubSub.publish(events, { channel, message }).pipe(Effect.asVoid))
59
+ // Synchronous bounded enqueue drops (returns false) when consumer is slow,
60
+ // surfacing back-pressure instead of growing memory unbounded.
61
+ const accepted = Queue.offerUnsafe(events, { channel, message })
62
+ if (!accepted) {
63
+ serverLogger.warn('Redis subscriber queue full, dropping message', { channel })
64
+ }
57
65
  }
58
66
  client.on('message', messageListener)
59
67
 
@@ -78,7 +86,7 @@ export const SharedThreadStreamSubscriberLive = Layer.effect(
78
86
  handlers.clear()
79
87
  client.removeListener('message', messageListener)
80
88
  yield* Fiber.interrupt(dispatchFiber)
81
- yield* PubSub.shutdown(events)
89
+ yield* Queue.shutdown(events)
82
90
  yield* Effect.tryPromise({
83
91
  try: () => client.quit(),
84
92
  catch: (cause) =>
@@ -1,4 +1,4 @@
1
- import { getAgentDisplayNames } from '../config/agent-defaults'
1
+ import type { ResolvedAgentConfig } from '../config/agent-defaults'
2
2
  import { asRecord, readOptionalString } from './thread-chat-helpers'
3
3
 
4
4
  interface RuntimeAgentIdentityOverrides {
@@ -52,11 +52,15 @@ export function readRuntimeAgentIdentityOverrides(
52
52
  }
53
53
  }
54
54
 
55
- export function resolveRuntimeAgentDisplayName(overrides: RuntimeAgentIdentityOverrides, agentId: string): string {
55
+ export function resolveRuntimeAgentDisplayName(
56
+ agentConfig: ResolvedAgentConfig,
57
+ overrides: RuntimeAgentIdentityOverrides,
58
+ agentId: string,
59
+ ): string {
56
60
  const override = readStringOverride(overrides.displayNamesById, agentId)
57
61
  if (override !== undefined) {
58
62
  return override
59
63
  }
60
64
 
61
- return getAgentDisplayNames()[agentId] ?? agentId
65
+ return agentConfig.displayNames[agentId] ?? agentId
62
66
  }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ChatMode,
2
3
  ExecutionMode,
3
4
  OwnershipDispatchContext,
4
5
  PlanArtifactSubmission,
@@ -6,9 +7,8 @@ import type {
6
7
  PlanNodeSpecRecord,
7
8
  } from '@lota-sdk/shared'
8
9
 
9
- import { getLeadAgentId } from '../config/agent-defaults'
10
+ import type { ResolvedThreadBootstrapConfig } from '../config/thread-defaults'
10
11
  import { resolveOnboardingOwnerAgentId } from '../config/thread-defaults'
11
- import type { ChatMode } from './agent-types'
12
12
  export interface AgentRuntimeConfig<TAgent extends string> {
13
13
  id: TAgent
14
14
  displayName: string
@@ -162,13 +162,18 @@ export function buildThreadAgentToolPolicy<TAgent extends string, TSkill extends
162
162
  linearInstalled: boolean
163
163
  githubInstalled: boolean
164
164
  provideRepoTool: boolean
165
- leadAgentId?: TAgent
165
+ leadAgentId: TAgent
166
166
  onboardingOwnerAgentId?: TAgent
167
+ threadBootstrapConfig?: ResolvedThreadBootstrapConfig
167
168
  getAgentSkills: (agentId: TAgent, mode: ChatMode) => TSkill[]
168
169
  }): AgentToolPolicy<TSkill> {
169
170
  const resolvedMode = params.mode ?? toChatMode(params.threadType)
170
- const leadAgentId = params.leadAgentId ?? (getLeadAgentId() as TAgent)
171
- const onboardingOwnerAgentId = params.onboardingOwnerAgentId ?? (resolveOnboardingOwnerAgentId(leadAgentId) as TAgent)
171
+ const leadAgentId = params.leadAgentId
172
+ const onboardingOwnerAgentId =
173
+ params.onboardingOwnerAgentId ??
174
+ (params.threadBootstrapConfig
175
+ ? (resolveOnboardingOwnerAgentId(leadAgentId, params.threadBootstrapConfig) as TAgent)
176
+ : leadAgentId)
172
177
  const skills = resolveActiveAgentSkills({
173
178
  agentId: params.agentId,
174
179
  threadType: params.threadType,