@lota-sdk/core 0.4.9 → 0.4.10

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 (158) 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 +38 -10
  4. package/src/config/agent-defaults.ts +22 -9
  5. package/src/config/agent-types.ts +1 -1
  6. package/src/config/background-processing.ts +1 -1
  7. package/src/config/index.ts +0 -1
  8. package/src/config/logger.ts +20 -7
  9. package/src/config/thread-defaults.ts +12 -4
  10. package/src/create-runtime.ts +69 -656
  11. package/src/db/memory-query-builder.ts +2 -1
  12. package/src/db/memory-store.ts +29 -20
  13. package/src/db/memory.ts +188 -195
  14. package/src/db/service-normalization.ts +97 -64
  15. package/src/db/service.ts +706 -538
  16. package/src/db/startup.ts +30 -19
  17. package/src/effect/awaitable-effect.ts +46 -37
  18. package/src/effect/helpers.ts +30 -5
  19. package/src/effect/index.ts +7 -5
  20. package/src/effect/layers.ts +82 -72
  21. package/src/effect/runtime.ts +18 -3
  22. package/src/effect/services.ts +15 -11
  23. package/src/embeddings/provider.ts +65 -66
  24. package/src/index.ts +13 -11
  25. package/src/queues/autonomous-job.queue.ts +59 -71
  26. package/src/queues/context-compaction.queue.ts +6 -18
  27. package/src/queues/delayed-node-promotion.queue.ts +9 -17
  28. package/src/queues/organization-learning.queue.ts +17 -4
  29. package/src/queues/plan-agent-heartbeat.queue.ts +23 -20
  30. package/src/queues/plan-scheduler.queue.ts +6 -18
  31. package/src/queues/post-chat-memory.queue.ts +6 -18
  32. package/src/queues/queue-factory.ts +128 -50
  33. package/src/queues/title-generation.queue.ts +6 -17
  34. package/src/redis/connection.ts +181 -164
  35. package/src/redis/runtime-connection.ts +13 -3
  36. package/src/redis/stream-context.ts +17 -9
  37. package/src/runtime/agent-runtime-policy.ts +1 -1
  38. package/src/runtime/agent-stream-helpers.ts +15 -11
  39. package/src/runtime/chat-run-orchestration.ts +1 -1
  40. package/src/runtime/context-compaction/context-compaction-runtime.ts +1 -1
  41. package/src/runtime/context-compaction/context-compaction.ts +126 -82
  42. package/src/runtime/domain-layer.ts +192 -0
  43. package/src/runtime/graph-designer.ts +15 -7
  44. package/src/runtime/helper-model.ts +8 -4
  45. package/src/runtime/index.ts +0 -1
  46. package/src/runtime/memory/memory-block.ts +19 -9
  47. package/src/runtime/memory/memory-pipeline.ts +53 -66
  48. package/src/runtime/memory/memory-scope.ts +33 -29
  49. package/src/runtime/plugin-resolution.ts +33 -54
  50. package/src/runtime/post-turn-side-effects.ts +6 -26
  51. package/src/runtime/retrieval-adapters.ts +4 -4
  52. package/src/runtime/runtime-accessors.ts +92 -0
  53. package/src/runtime/runtime-config.ts +3 -3
  54. package/src/runtime/runtime-extensions.ts +20 -9
  55. package/src/runtime/runtime-lifecycle.ts +124 -0
  56. package/src/runtime/runtime-services.ts +386 -0
  57. package/src/runtime/runtime-token.ts +47 -0
  58. package/src/runtime/social-chat/social-chat-agent-runner.ts +7 -5
  59. package/src/runtime/social-chat/social-chat-history.ts +21 -12
  60. package/src/runtime/social-chat/social-chat.ts +401 -365
  61. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +58 -52
  62. package/src/runtime/thread-turn-context.ts +21 -27
  63. package/src/services/agent-activity.service.ts +1 -1
  64. package/src/services/agent-executor.service.ts +179 -187
  65. package/src/services/artifact.service.ts +10 -5
  66. package/src/services/attachment.service.ts +35 -1
  67. package/src/services/autonomous-job.service.ts +58 -56
  68. package/src/services/background-work.service.ts +54 -0
  69. package/src/services/chat-run-registry.service.ts +3 -1
  70. package/src/services/context-compaction.service.ts +1 -1
  71. package/src/services/document-chunk.service.ts +8 -17
  72. package/src/services/execution-plan/execution-plan-graph.ts +74 -52
  73. package/src/services/execution-plan/execution-plan.service.ts +1 -1
  74. package/src/services/feedback-loop.service.ts +1 -1
  75. package/src/services/global-orchestrator.service.ts +33 -10
  76. package/src/services/graph-full-routing.ts +44 -33
  77. package/src/services/index.ts +1 -0
  78. package/src/services/institutional-memory.service.ts +8 -17
  79. package/src/services/learned-skill.service.ts +38 -35
  80. package/src/services/memory/memory-errors.ts +27 -0
  81. package/src/services/memory/memory-org-memory.ts +14 -3
  82. package/src/services/memory/memory-preseeded.ts +10 -4
  83. package/src/services/memory/memory-utils.ts +2 -1
  84. package/src/services/memory/memory.service.ts +26 -44
  85. package/src/services/memory/rerank.service.ts +3 -11
  86. package/src/services/monitoring-window.service.ts +1 -1
  87. package/src/services/mutating-approval.service.ts +1 -1
  88. package/src/services/node-workspace.service.ts +2 -2
  89. package/src/services/notification.service.ts +16 -4
  90. package/src/services/organization-member.service.ts +1 -1
  91. package/src/services/organization.service.ts +34 -51
  92. package/src/services/ownership-dispatcher.service.ts +132 -90
  93. package/src/services/plan/plan-agent-heartbeat.service.ts +1 -1
  94. package/src/services/plan/plan-agent-query.service.ts +1 -1
  95. package/src/services/plan/plan-approval.service.ts +52 -48
  96. package/src/services/plan/plan-artifact.service.ts +2 -2
  97. package/src/services/plan/plan-builder.service.ts +2 -2
  98. package/src/services/plan/plan-checkpoint.service.ts +1 -1
  99. package/src/services/plan/plan-compiler.service.ts +1 -1
  100. package/src/services/plan/plan-completion-side-effects.ts +18 -24
  101. package/src/services/plan/plan-coordination.service.ts +1 -1
  102. package/src/services/plan/plan-cycle.service.ts +171 -164
  103. package/src/services/plan/plan-deadline.service.ts +290 -304
  104. package/src/services/plan/plan-event-delivery.service.ts +44 -39
  105. package/src/services/plan/plan-executor-graph.ts +114 -67
  106. package/src/services/plan/plan-executor-helpers.ts +60 -75
  107. package/src/services/plan/plan-executor.service.ts +550 -467
  108. package/src/services/plan/plan-run.service.ts +12 -19
  109. package/src/services/plan/plan-scheduler.service.ts +27 -33
  110. package/src/services/plan/plan-template.service.ts +1 -1
  111. package/src/services/plan/plan-transaction-events.ts +8 -5
  112. package/src/services/plan/plan-validator.service.ts +1 -1
  113. package/src/services/plan/plan-workspace.service.ts +17 -11
  114. package/src/services/plugin-executor.service.ts +26 -21
  115. package/src/services/quality-metrics.service.ts +1 -1
  116. package/src/services/queue-job.service.ts +8 -17
  117. package/src/services/recent-activity-title.service.ts +17 -9
  118. package/src/services/recent-activity.service.ts +1 -1
  119. package/src/services/skill-resolver.service.ts +1 -1
  120. package/src/services/social-chat-history.service.ts +37 -20
  121. package/src/services/system-executor.service.ts +25 -20
  122. package/src/services/thread/thread-bootstrap.ts +26 -10
  123. package/src/services/thread/thread-listing.ts +2 -1
  124. package/src/services/thread/thread-memory-block.ts +18 -5
  125. package/src/services/thread/thread-message.service.ts +24 -8
  126. package/src/services/thread/thread-title.service.ts +1 -1
  127. package/src/services/thread/thread-turn-execution.ts +1 -1
  128. package/src/services/thread/thread-turn-preparation.service.ts +18 -16
  129. package/src/services/thread/thread-turn-streaming.ts +12 -11
  130. package/src/services/thread/thread-turn.ts +43 -10
  131. package/src/services/thread/thread.service.ts +11 -2
  132. package/src/services/user.service.ts +1 -1
  133. package/src/services/write-intent-validator.service.ts +1 -1
  134. package/src/storage/attachment-storage.service.ts +7 -4
  135. package/src/storage/generated-document-storage.service.ts +1 -1
  136. package/src/system-agents/context-compaction.agent.ts +1 -1
  137. package/src/system-agents/helper-agent-options.ts +1 -1
  138. package/src/system-agents/memory-reranker.agent.ts +1 -1
  139. package/src/system-agents/memory.agent.ts +1 -1
  140. package/src/system-agents/recent-activity-title-refiner.agent.ts +1 -1
  141. package/src/system-agents/regular-chat-memory-digest.agent.ts +1 -1
  142. package/src/system-agents/skill-extractor.agent.ts +1 -1
  143. package/src/system-agents/skill-manager.agent.ts +1 -1
  144. package/src/system-agents/title-generator.agent.ts +1 -1
  145. package/src/tools/execution-plan.tool.ts +28 -17
  146. package/src/tools/fetch-webpage.tool.ts +20 -13
  147. package/src/tools/firecrawl-client.ts +13 -3
  148. package/src/tools/plan-approval.tool.ts +9 -1
  149. package/src/tools/search-web.tool.ts +16 -9
  150. package/src/tools/team-think.tool.ts +2 -2
  151. package/src/utils/async.ts +15 -6
  152. package/src/utils/errors.ts +27 -15
  153. package/src/workers/bootstrap.ts +25 -48
  154. package/src/workers/organization-learning.worker.ts +1 -1
  155. package/src/workers/regular-chat-memory-digest.runner.ts +25 -15
  156. package/src/workers/worker-utils.ts +20 -2
  157. package/src/config/search.ts +0 -3
  158. 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,10 +1,20 @@
1
- import { Effect } from 'effect'
2
1
  import type IORedis from 'ioredis'
3
2
 
4
- import { getCurrentRuntime } from '../effect/runtime-ref'
3
+ import { resolveLotaService } from '../effect/runtime'
5
4
  import { RedisServiceTag } from '../effect/services'
5
+ import type { RedisConnectionManager } from './connection'
6
+
7
+ let currentRedisManager: RedisConnectionManager | null = null
8
+
9
+ export function configureRuntimeRedisManager(redisManager: RedisConnectionManager): void {
10
+ currentRedisManager = redisManager
11
+ }
12
+
13
+ export function clearRuntimeRedisManager(): void {
14
+ currentRedisManager = null
15
+ }
6
16
 
7
17
  export function getRuntimeRedisConnection(): IORedis {
8
- const redis = getCurrentRuntime().runSync(Effect.service(RedisServiceTag))
18
+ const redis = currentRedisManager ?? resolveLotaService(RedisServiceTag)
9
19
  return redis.getConnection()
10
20
  }
@@ -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,5 @@
1
1
  import type {
2
+ ChatMode,
2
3
  ExecutionMode,
3
4
  OwnershipDispatchContext,
4
5
  PlanArtifactSubmission,
@@ -8,7 +9,6 @@ import type {
8
9
 
9
10
  import { getLeadAgentId } from '../config/agent-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
@@ -1,6 +1,6 @@
1
1
  import type { ChatMessage } from '@lota-sdk/shared'
2
2
  import type { LanguageModelUsage, UIMessageStreamOptions } from 'ai'
3
- import { Duration, Effect, Fiber } from 'effect'
3
+ import { Duration, Effect, Exit, Scope } from 'effect'
4
4
 
5
5
  import { getAgentDisplayNames, getLeadAgentDisplayName } from '../config/agent-defaults'
6
6
  import { nowEpochMillis } from '../utils/date-time'
@@ -106,17 +106,20 @@ export function createTimedAbortSignal(parentSignal: AbortSignal, timeoutMs: num
106
106
  const abortFromParent = () => {
107
107
  controller.abort((parentSignal as AbortSignal & { reason?: unknown }).reason)
108
108
  }
109
- const timeoutFiber = Effect.runFork(
110
- Effect.sleep(Duration.millis(timeoutMs)).pipe(
111
- Effect.tap(() =>
112
- Effect.sync(() => {
113
- if (disposed || controller.signal.aborted) return
114
- didTimeout = true
115
- controller.abort(new Error(`Timed out after ${timeoutMs}ms`))
116
- }),
117
- ),
109
+
110
+ // Own a scope so the timeout fiber is cleaned up via Scope.close() on dispose,
111
+ // matching Effect v4 best practices (forkScoped + scope-bound finalizers).
112
+ const scope = Scope.makeUnsafe()
113
+ const timeoutEffect = Effect.sleep(Duration.millis(timeoutMs)).pipe(
114
+ Effect.andThen(
115
+ Effect.sync(() => {
116
+ if (disposed || controller.signal.aborted) return
117
+ didTimeout = true
118
+ controller.abort(new Error(`Timed out after ${timeoutMs}ms`))
119
+ }),
118
120
  ),
119
121
  )
122
+ Effect.runFork(Scope.provide(Effect.forkScoped(timeoutEffect), scope))
120
123
 
121
124
  if (parentSignal.aborted) {
122
125
  abortFromParent()
@@ -128,9 +131,10 @@ export function createTimedAbortSignal(parentSignal: AbortSignal, timeoutMs: num
128
131
  signal: controller.signal,
129
132
  didTimeout: () => didTimeout,
130
133
  dispose: () => {
134
+ if (disposed) return
131
135
  disposed = true
132
136
  parentSignal.removeEventListener('abort', abortFromParent)
133
- void Effect.runFork(Fiber.interrupt(timeoutFiber))
137
+ void Effect.runPromise(Scope.close(scope, Exit.void).pipe(Effect.catchCause(() => Effect.void)))
134
138
  },
135
139
  }
136
140
  }
@@ -37,7 +37,7 @@ interface CompactionCoordination {
37
37
  }
38
38
 
39
39
  export class CompactionCoordinationTag extends Context.Service<CompactionCoordinationTag, CompactionCoordination>()(
40
- 'CompactionCoordination',
40
+ '@lota-sdk/core/CompactionCoordination',
41
41
  ) {}
42
42
 
43
43
  export const CompactionCoordinationLive = Layer.effect(
@@ -66,7 +66,7 @@ function runContextCompacter(helperModelRuntime: HelperModelRuntime, params: Con
66
66
  schema: ContextCompactionOutputSchema,
67
67
  maxOutputTokens: 8_000,
68
68
  }),
69
- ).pipe(Effect.map(parseCompactionOutput)),
69
+ ).pipe(Effect.flatMap(parseCompactionOutput)),
70
70
  )
71
71
  }
72
72