@lota-sdk/core 0.4.8 → 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 (272) hide show
  1. package/package.json +11 -12
  2. package/src/ai/embedding-cache.ts +96 -22
  3. package/src/ai-gateway/ai-gateway.ts +766 -223
  4. package/src/config/agent-defaults.ts +189 -75
  5. package/src/config/agent-types.ts +54 -4
  6. package/src/config/background-processing.ts +1 -1
  7. package/src/config/constants.ts +8 -2
  8. package/src/config/index.ts +0 -1
  9. package/src/config/logger.ts +299 -19
  10. package/src/config/thread-defaults.ts +40 -20
  11. package/src/create-runtime.ts +200 -449
  12. package/src/db/base.service.ts +52 -28
  13. package/src/db/cursor-pagination.ts +71 -30
  14. package/src/db/memory-query-builder.ts +2 -1
  15. package/src/db/memory-store.helpers.ts +4 -7
  16. package/src/db/memory-store.ts +868 -601
  17. package/src/db/memory.ts +396 -280
  18. package/src/db/record-id.ts +32 -10
  19. package/src/db/schema-fingerprint.ts +30 -12
  20. package/src/db/service-normalization.ts +288 -0
  21. package/src/db/service.ts +912 -779
  22. package/src/db/startup.ts +153 -68
  23. package/src/db/transaction-conflict.ts +15 -0
  24. package/src/effect/awaitable-effect.ts +96 -0
  25. package/src/effect/errors.ts +121 -0
  26. package/src/effect/helpers.ts +123 -0
  27. package/src/effect/index.ts +24 -0
  28. package/src/effect/layers.ts +238 -0
  29. package/src/effect/runtime-ref.ts +25 -0
  30. package/src/effect/runtime.ts +46 -0
  31. package/src/effect/services.ts +61 -0
  32. package/src/effect/zod.ts +43 -0
  33. package/src/embeddings/provider.ts +128 -83
  34. package/src/index.ts +48 -1
  35. package/src/openrouter/direct-provider.ts +11 -35
  36. package/src/queues/autonomous-job.queue.ts +117 -73
  37. package/src/queues/context-compaction.queue.ts +50 -17
  38. package/src/queues/delayed-node-promotion.queue.ts +46 -17
  39. package/src/queues/document-processor.queue.ts +52 -77
  40. package/src/queues/memory-consolidation.queue.ts +47 -32
  41. package/src/queues/organization-learning.queue.ts +26 -4
  42. package/src/queues/plan-agent-heartbeat.queue.ts +71 -24
  43. package/src/queues/plan-scheduler.queue.ts +97 -33
  44. package/src/queues/post-chat-memory.queue.ts +56 -26
  45. package/src/queues/queue-factory.ts +227 -59
  46. package/src/queues/standalone-worker.ts +39 -0
  47. package/src/queues/title-generation.queue.ts +45 -11
  48. package/src/redis/connection.ts +182 -113
  49. package/src/redis/index.ts +6 -8
  50. package/src/redis/org-memory-lock.ts +60 -27
  51. package/src/redis/redis-lease-lock.ts +200 -121
  52. package/src/redis/runtime-connection.ts +20 -0
  53. package/src/redis/stream-context.ts +92 -46
  54. package/src/runtime/agent-identity-overrides.ts +2 -2
  55. package/src/runtime/agent-runtime-policy.ts +5 -2
  56. package/src/runtime/agent-stream-helpers.ts +24 -9
  57. package/src/runtime/chat-run-orchestration.ts +102 -19
  58. package/src/runtime/chat-run-registry.ts +36 -2
  59. package/src/runtime/context-compaction/context-compaction-runtime.ts +107 -0
  60. package/src/runtime/{context-compaction.ts → context-compaction/context-compaction.ts} +161 -94
  61. package/src/runtime/domain-layer.ts +192 -0
  62. package/src/runtime/execution-plan-visibility.ts +2 -2
  63. package/src/runtime/execution-plan.ts +42 -15
  64. package/src/runtime/graph-designer.ts +16 -4
  65. package/src/runtime/helper-model.ts +139 -48
  66. package/src/runtime/index.ts +7 -8
  67. package/src/runtime/indexed-repositories-policy.ts +3 -3
  68. package/src/runtime/{memory-block.ts → memory/memory-block.ts} +50 -36
  69. package/src/runtime/{memory-digest-policy.ts → memory/memory-digest-policy.ts} +1 -1
  70. package/src/runtime/{memory-pipeline.ts → memory/memory-pipeline.ts} +54 -67
  71. package/src/runtime/{memory-prompts-fact.ts → memory/memory-prompts-fact.ts} +2 -2
  72. package/src/runtime/memory/memory-scope.ts +53 -0
  73. package/src/runtime/plugin-resolution.ts +124 -25
  74. package/src/runtime/plugin-types.ts +9 -1
  75. package/src/runtime/post-turn-side-effects.ts +177 -130
  76. package/src/runtime/retrieval-adapters.ts +40 -6
  77. package/src/runtime/runtime-accessors.ts +92 -0
  78. package/src/runtime/runtime-config.ts +150 -61
  79. package/src/runtime/runtime-extensions.ts +23 -25
  80. package/src/runtime/runtime-lifecycle.ts +124 -0
  81. package/src/runtime/runtime-services.ts +386 -0
  82. package/src/runtime/runtime-token.ts +47 -0
  83. package/src/runtime/social-chat/social-chat-agent-runner.ts +159 -0
  84. package/src/runtime/{social-chat-history.ts → social-chat/social-chat-history.ts} +51 -20
  85. package/src/runtime/social-chat/social-chat.ts +630 -0
  86. package/src/runtime/specialist-runner.ts +36 -10
  87. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +433 -0
  88. package/src/runtime/{team-consultation-prompts.ts → team-consultation/team-consultation-prompts.ts} +6 -2
  89. package/src/runtime/thread-chat-helpers.ts +2 -2
  90. package/src/runtime/thread-plan-turn.ts +2 -1
  91. package/src/runtime/thread-turn-context.ts +183 -111
  92. package/src/runtime/turn-lifecycle.ts +93 -27
  93. package/src/services/agent-activity.service.ts +287 -203
  94. package/src/services/agent-executor.service.ts +253 -149
  95. package/src/services/artifact.service.ts +231 -149
  96. package/src/services/attachment.service.ts +171 -115
  97. package/src/services/autonomous-job.service.ts +890 -491
  98. package/src/services/background-work.service.ts +54 -0
  99. package/src/services/chat-run-registry.service.ts +13 -1
  100. package/src/services/context-compaction.service.ts +136 -86
  101. package/src/services/document-chunk.service.ts +151 -88
  102. package/src/services/execution-plan/execution-plan-approval.ts +26 -0
  103. package/src/services/execution-plan/execution-plan-context.ts +29 -0
  104. package/src/services/execution-plan/execution-plan-graph.ts +278 -0
  105. package/src/services/execution-plan/execution-plan-schedule.ts +84 -0
  106. package/src/services/execution-plan/execution-plan-spec.ts +75 -0
  107. package/src/services/execution-plan/execution-plan.service.ts +1041 -0
  108. package/src/services/feedback-loop.service.ts +132 -76
  109. package/src/services/global-orchestrator.service.ts +101 -168
  110. package/src/services/graph-full-routing.ts +193 -0
  111. package/src/services/index.ts +19 -21
  112. package/src/services/institutional-memory.service.ts +213 -125
  113. package/src/services/learned-skill.service.ts +368 -260
  114. package/src/services/memory/memory-conversation.ts +95 -0
  115. package/src/services/memory/memory-errors.ts +27 -0
  116. package/src/services/memory/memory-org-memory.ts +50 -0
  117. package/src/services/memory/memory-preseeded.ts +86 -0
  118. package/src/services/memory/memory-rerank.ts +297 -0
  119. package/src/services/{memory-utils.ts → memory/memory-utils.ts} +6 -5
  120. package/src/services/memory/memory.service.ts +674 -0
  121. package/src/services/memory/rerank.service.ts +201 -0
  122. package/src/services/monitoring-window.service.ts +92 -70
  123. package/src/services/mutating-approval.service.ts +62 -53
  124. package/src/services/node-workspace.service.ts +141 -98
  125. package/src/services/notification.service.ts +29 -16
  126. package/src/services/organization-member.service.ts +120 -66
  127. package/src/services/organization.service.ts +153 -77
  128. package/src/services/ownership-dispatcher.service.ts +456 -263
  129. package/src/services/plan/plan-agent-heartbeat.service.ts +234 -0
  130. package/src/services/plan/plan-agent-query.service.ts +322 -0
  131. package/src/services/{plan-approval.service.ts → plan/plan-approval.service.ts} +45 -22
  132. package/src/services/plan/plan-artifact.service.ts +60 -0
  133. package/src/services/plan/plan-builder.service.ts +76 -0
  134. package/src/services/plan/plan-checkpoint.service.ts +103 -0
  135. package/src/services/{plan-compiler.service.ts → plan/plan-compiler.service.ts} +26 -9
  136. package/src/services/plan/plan-completion-side-effects.ts +169 -0
  137. package/src/services/plan/plan-coordination.service.ts +181 -0
  138. package/src/services/plan/plan-cycle.service.ts +405 -0
  139. package/src/services/plan/plan-deadline.service.ts +533 -0
  140. package/src/services/plan/plan-event-delivery.service.ts +266 -0
  141. package/src/services/plan/plan-executor-context.ts +35 -0
  142. package/src/services/plan/plan-executor-graph.ts +522 -0
  143. package/src/services/plan/plan-executor-helpers.ts +307 -0
  144. package/src/services/plan/plan-executor-persistence.ts +209 -0
  145. package/src/services/plan/plan-executor.service.ts +1737 -0
  146. package/src/services/{plan-helpers.ts → plan/plan-helpers.ts} +1 -1
  147. package/src/services/{plan-run-data.ts → plan/plan-run-data.ts} +4 -4
  148. package/src/services/plan/plan-run-serialization.ts +15 -0
  149. package/src/services/plan/plan-run.service.ts +637 -0
  150. package/src/services/plan/plan-scheduler.service.ts +379 -0
  151. package/src/services/plan/plan-template.service.ts +224 -0
  152. package/src/services/plan/plan-transaction-events.ts +36 -0
  153. package/src/services/plan/plan-validator.service.ts +907 -0
  154. package/src/services/plan/plan-workspace.service.ts +131 -0
  155. package/src/services/plugin-executor.service.ts +102 -68
  156. package/src/services/quality-metrics.service.ts +112 -94
  157. package/src/services/queue-job.service.ts +288 -231
  158. package/src/services/recent-activity-title.service.ts +73 -36
  159. package/src/services/recent-activity.service.ts +274 -259
  160. package/src/services/skill-resolver.service.ts +38 -12
  161. package/src/services/social-chat-history.service.ts +190 -122
  162. package/src/services/system-executor.service.ts +96 -61
  163. package/src/services/thread/thread-active-run.ts +203 -0
  164. package/src/services/thread/thread-bootstrap.ts +385 -0
  165. package/src/services/thread/thread-listing.ts +199 -0
  166. package/src/services/thread/thread-memory-block.ts +130 -0
  167. package/src/services/thread/thread-message.service.ts +379 -0
  168. package/src/services/thread/thread-record-store.ts +155 -0
  169. package/src/services/thread/thread-title.service.ts +74 -0
  170. package/src/services/thread/thread-turn-execution.ts +280 -0
  171. package/src/services/thread/thread-turn-message-context.ts +73 -0
  172. package/src/services/thread/thread-turn-preparation.service.ts +1148 -0
  173. package/src/services/thread/thread-turn-streaming.ts +403 -0
  174. package/src/services/thread/thread-turn-tracing.ts +35 -0
  175. package/src/services/thread/thread-turn.ts +376 -0
  176. package/src/services/thread/thread.service.ts +344 -0
  177. package/src/services/user.service.ts +82 -32
  178. package/src/services/write-intent-validator.service.ts +63 -51
  179. package/src/storage/attachment-parser.ts +69 -27
  180. package/src/storage/attachment-storage.service.ts +334 -275
  181. package/src/storage/generated-document-storage.service.ts +66 -34
  182. package/src/system-agents/agent-result.ts +3 -1
  183. package/src/system-agents/context-compaction.agent.ts +3 -3
  184. package/src/system-agents/delegated-agent-factory.ts +159 -90
  185. package/src/system-agents/helper-agent-options.ts +1 -1
  186. package/src/system-agents/memory-reranker.agent.ts +3 -3
  187. package/src/system-agents/memory.agent.ts +3 -3
  188. package/src/system-agents/recent-activity-title-refiner.agent.ts +3 -3
  189. package/src/system-agents/regular-chat-memory-digest.agent.ts +3 -3
  190. package/src/system-agents/skill-extractor.agent.ts +3 -3
  191. package/src/system-agents/skill-manager.agent.ts +3 -3
  192. package/src/system-agents/thread-router.agent.ts +157 -113
  193. package/src/system-agents/title-generator.agent.ts +3 -3
  194. package/src/tools/execution-plan.tool.ts +241 -171
  195. package/src/tools/fetch-webpage.tool.ts +29 -18
  196. package/src/tools/firecrawl-client.ts +26 -6
  197. package/src/tools/index.ts +1 -0
  198. package/src/tools/memory-block.tool.ts +14 -6
  199. package/src/tools/plan-approval.tool.ts +57 -47
  200. package/src/tools/read-file-parts.tool.ts +44 -33
  201. package/src/tools/remember-memory.tool.ts +65 -45
  202. package/src/tools/search-web.tool.ts +33 -22
  203. package/src/tools/search.tool.ts +41 -29
  204. package/src/tools/team-think.tool.ts +125 -84
  205. package/src/tools/user-questions.tool.ts +4 -3
  206. package/src/tools/web-tool-shared.ts +6 -0
  207. package/src/utils/async.ts +25 -22
  208. package/src/utils/crypto.ts +21 -0
  209. package/src/utils/date-time.ts +40 -1
  210. package/src/utils/errors.ts +111 -20
  211. package/src/utils/hono-error-handler.ts +24 -39
  212. package/src/utils/index.ts +2 -1
  213. package/src/utils/null-proto-record.ts +41 -0
  214. package/src/utils/sse-keepalive.ts +124 -21
  215. package/src/workers/bootstrap.ts +164 -52
  216. package/src/workers/memory-consolidation.worker.ts +325 -237
  217. package/src/workers/organization-learning.worker.ts +50 -16
  218. package/src/workers/regular-chat-memory-digest.helpers.ts +28 -27
  219. package/src/workers/regular-chat-memory-digest.runner.ts +185 -114
  220. package/src/workers/skill-extraction.runner.ts +176 -93
  221. package/src/workers/utils/file-section-chunker.ts +8 -10
  222. package/src/workers/utils/repo-structure-extractor.ts +349 -260
  223. package/src/workers/utils/repomix-file-sections.ts +2 -2
  224. package/src/workers/utils/thread-message-query.ts +97 -38
  225. package/src/workers/worker-utils.ts +74 -31
  226. package/src/config/debug-logger.ts +0 -47
  227. package/src/config/search.ts +0 -3
  228. package/src/redis/connection-accessor.ts +0 -26
  229. package/src/runtime/agent-types.ts +0 -1
  230. package/src/runtime/context-compaction-runtime.ts +0 -87
  231. package/src/runtime/memory-scope.ts +0 -43
  232. package/src/runtime/social-chat-agent-runner.ts +0 -118
  233. package/src/runtime/social-chat.ts +0 -516
  234. package/src/runtime/team-consultation-orchestrator.ts +0 -272
  235. package/src/services/adaptive-playbook.service.ts +0 -152
  236. package/src/services/artifact-provenance.service.ts +0 -172
  237. package/src/services/chat-attachments.service.ts +0 -17
  238. package/src/services/context-compaction-runtime.singleton.ts +0 -13
  239. package/src/services/execution-plan.service.ts +0 -1118
  240. package/src/services/memory.service.ts +0 -914
  241. package/src/services/plan-agent-heartbeat.service.ts +0 -136
  242. package/src/services/plan-agent-query.service.ts +0 -267
  243. package/src/services/plan-artifact.service.ts +0 -50
  244. package/src/services/plan-builder.service.ts +0 -67
  245. package/src/services/plan-checkpoint.service.ts +0 -81
  246. package/src/services/plan-completion-side-effects.ts +0 -80
  247. package/src/services/plan-coordination.service.ts +0 -157
  248. package/src/services/plan-cycle.service.ts +0 -284
  249. package/src/services/plan-deadline.service.ts +0 -430
  250. package/src/services/plan-event-delivery.service.ts +0 -166
  251. package/src/services/plan-executor.service.ts +0 -1950
  252. package/src/services/plan-run.service.ts +0 -515
  253. package/src/services/plan-scheduler.service.ts +0 -240
  254. package/src/services/plan-template.service.ts +0 -177
  255. package/src/services/plan-validator.service.ts +0 -818
  256. package/src/services/plan-workspace.service.ts +0 -83
  257. package/src/services/rerank.service.ts +0 -156
  258. package/src/services/thread-message.service.ts +0 -275
  259. package/src/services/thread-plan-registry.service.ts +0 -22
  260. package/src/services/thread-title.service.ts +0 -39
  261. package/src/services/thread-turn-preparation.service.ts +0 -1147
  262. package/src/services/thread-turn.ts +0 -172
  263. package/src/services/thread.service.ts +0 -869
  264. package/src/utils/env.ts +0 -8
  265. /package/src/runtime/{context-compaction-constants.ts → context-compaction/context-compaction-constants.ts} +0 -0
  266. /package/src/runtime/{memory-format.ts → memory/memory-format.ts} +0 -0
  267. /package/src/runtime/{memory-prompts-parse.ts → memory/memory-prompts-parse.ts} +0 -0
  268. /package/src/runtime/{memory-prompts-update.ts → memory/memory-prompts-update.ts} +0 -0
  269. /package/src/runtime/{social-chat-prompts.ts → social-chat/social-chat-prompts.ts} +0 -0
  270. /package/src/services/{plan-node-spec.ts → plan/plan-node-spec.ts} +0 -0
  271. /package/src/services/{thread-constants.ts → thread/thread-constants.ts} +0 -0
  272. /package/src/services/{thread.types.ts → thread/thread.types.ts} +0 -0
@@ -1,7 +1,8 @@
1
- import { setTimeout as delay } from 'node:timers/promises'
2
-
1
+ import { Clock, Deferred, Duration, Effect, Random, Schedule } from 'effect'
3
2
  import type IORedis from 'ioredis'
4
3
 
4
+ import { LockAcquisitionError, LockLostError, RedisError } from '../effect/errors'
5
+ import { RedisServiceTag } from '../effect/services'
5
6
  import { getErrorMessage } from '../utils/errors'
6
7
 
7
8
  interface RedisLeaseLockLogger {
@@ -10,8 +11,8 @@ interface RedisLeaseLockLogger {
10
11
  warn?: (message: string) => void
11
12
  }
12
13
 
13
- interface RedisLeaseLockOptions {
14
- redis: IORedis
14
+ export interface RedisLeaseLockOptions {
15
+ redis?: IORedis
15
16
  lockKey: string
16
17
  lockTtlMs: number
17
18
  retryDelayMs?: number
@@ -47,144 +48,222 @@ function log(logger: RedisLeaseLockLogger | undefined, level: keyof RedisLeaseLo
47
48
  }
48
49
  }
49
50
 
50
- async function acquireLeaseLock(
51
- options: Required<Pick<RedisLeaseLockOptions, 'retryDelayMs' | 'waitLogIntervalMs' | 'maxWaitMs'>> &
52
- RedisLeaseLockOptions & { lockValue: string; label: string },
53
- ): Promise<void> {
54
- const waitStart = Date.now()
55
- let lastWaitLogAt = waitStart
56
-
57
- for (;;) {
58
- const result = await options.redis.set(options.lockKey, options.lockValue, 'PX', options.lockTtlMs, 'NX')
59
-
60
- if (result === 'OK') {
61
- return
62
- }
51
+ function toRedisError(error: unknown, message: string): RedisError {
52
+ return new RedisError({ message: `${message}: ${getErrorMessage(error)}`, cause: error })
53
+ }
63
54
 
64
- const now = Date.now()
65
- const waitedMs = now - waitStart
66
- if (waitedMs >= options.maxWaitMs) {
67
- throw new Error(`Timed out waiting for ${options.label} (${options.lockKey}) waitedMs=${waitedMs}`)
68
- }
55
+ function getNormalizedOptions(options: RedisLeaseLockOptions) {
56
+ const normalizedLockTtlMs = Math.max(1_000, Math.trunc(options.lockTtlMs))
57
+ const requestedRefreshIntervalMs = options.refreshIntervalMs ?? Math.floor(normalizedLockTtlMs / 3)
58
+ const normalizedRefreshIntervalMs = Math.min(
59
+ Math.max(250, Math.trunc(requestedRefreshIntervalMs)),
60
+ Math.max(250, normalizedLockTtlMs - 250),
61
+ )
69
62
 
70
- if (now - lastWaitLogAt >= options.waitLogIntervalMs) {
71
- log(options.logger, 'info', `Waiting for ${options.label} (${options.lockKey}) waitedMs=${waitedMs}`)
72
- lastWaitLogAt = now
73
- }
63
+ return {
64
+ ...options,
65
+ lockKey: options.lockKey.trim(),
66
+ lockTtlMs: normalizedLockTtlMs,
67
+ retryDelayMs: options.retryDelayMs ?? 500,
68
+ refreshIntervalMs: normalizedRefreshIntervalMs,
69
+ waitLogIntervalMs: options.waitLogIntervalMs ?? 30_000,
70
+ maxWaitMs: options.maxWaitMs ?? 45_000,
71
+ acquiredWaitInfoThresholdMs: options.acquiredWaitInfoThresholdMs ?? 1_000,
72
+ heldInfoThresholdMs: options.heldInfoThresholdMs ?? 5_000,
73
+ label: options.label ?? 'redis lease lock',
74
+ }
75
+ }
74
76
 
75
- await delay(options.retryDelayMs)
77
+ function resolveRedisConnection(options: RedisLeaseLockOptions): Effect.Effect<IORedis, RedisError, RedisServiceTag> {
78
+ if (options.redis) {
79
+ return Effect.succeed(options.redis)
76
80
  }
81
+
82
+ return Effect.gen(function* () {
83
+ const redisService = yield* RedisServiceTag
84
+ return redisService.getConnection()
85
+ })
77
86
  }
78
87
 
79
- async function refreshLeaseLock(options: RedisLeaseLockOptions & { lockValue: string; label: string }): Promise<void> {
80
- const refreshed = await options.redis.eval(
81
- REFRESH_LOCK_SCRIPT,
82
- 1,
83
- options.lockKey,
84
- options.lockValue,
85
- options.lockTtlMs.toString(),
86
- )
88
+ function acquireLock(
89
+ redis: IORedis,
90
+ options: ReturnType<typeof getNormalizedOptions>,
91
+ lockValue: string,
92
+ ): Effect.Effect<void, LockAcquisitionError | RedisError> {
93
+ return Effect.gen(function* () {
94
+ const waitStart = yield* Clock.currentTimeMillis
95
+ let lastWaitLogAt = waitStart
87
96
 
88
- const refreshedCount = typeof refreshed === 'number' ? refreshed : Number(refreshed)
89
- if (refreshedCount === 1) return
90
- throw new Error(`${options.label} refresh was rejected for key ${options.lockKey}`)
97
+ const tryOnce = Effect.gen(function* () {
98
+ const result = yield* Effect.tryPromise({
99
+ try: () => redis.set(options.lockKey, lockValue, 'PX', options.lockTtlMs, 'NX'),
100
+ catch: (error) => toRedisError(error, `Failed to acquire ${options.label} (${options.lockKey})`),
101
+ })
102
+
103
+ if (result === 'OK') {
104
+ return
105
+ }
106
+
107
+ const now = yield* Clock.currentTimeMillis
108
+ const waitedMs = now - waitStart
109
+ if (now - lastWaitLogAt >= options.waitLogIntervalMs) {
110
+ log(options.logger, 'info', `Waiting for ${options.label} (${options.lockKey}) waitedMs=${waitedMs}`)
111
+ lastWaitLogAt = now
112
+ }
113
+
114
+ return yield* new LockAcquisitionError({ lockKey: options.lockKey, maxWaitMs: options.maxWaitMs })
115
+ })
116
+
117
+ return yield* Effect.retry(tryOnce, {
118
+ times: Math.max(0, Math.ceil(options.maxWaitMs / options.retryDelayMs) - 1),
119
+ schedule: Schedule.fixed(Duration.millis(options.retryDelayMs)),
120
+ }).pipe(
121
+ Effect.asVoid,
122
+ Effect.catchTag('LockAcquisitionError', () =>
123
+ Effect.fail(new LockAcquisitionError({ lockKey: options.lockKey, maxWaitMs: options.maxWaitMs })),
124
+ ),
125
+ )
126
+ })
91
127
  }
92
128
 
93
- async function releaseLeaseLock(options: RedisLeaseLockOptions & { lockValue: string }): Promise<void> {
94
- await options.redis.eval(RELEASE_LOCK_SCRIPT, 1, options.lockKey, options.lockValue)
129
+ function refreshLock(
130
+ redis: IORedis,
131
+ options: ReturnType<typeof getNormalizedOptions>,
132
+ lockValue: string,
133
+ ): Effect.Effect<void, LockLostError | RedisError> {
134
+ return Effect.tryPromise({
135
+ try: () => redis.eval(REFRESH_LOCK_SCRIPT, 1, options.lockKey, lockValue, options.lockTtlMs.toString()),
136
+ catch: (error) => toRedisError(error, `Failed to refresh ${options.label} (${options.lockKey})`),
137
+ }).pipe(
138
+ Effect.flatMap((refreshed) => {
139
+ const refreshedCount = typeof refreshed === 'number' ? refreshed : Number(refreshed)
140
+ if (refreshedCount === 1) {
141
+ return Effect.void
142
+ }
143
+ return Effect.fail(new LockLostError({ lockKey: options.lockKey }))
144
+ }),
145
+ )
95
146
  }
96
147
 
97
- export class LeaseLockLostError extends Error {
98
- constructor(message: string) {
99
- super(message)
100
- this.name = 'LeaseLockLostError'
101
- }
148
+ function releaseLock(
149
+ redis: IORedis,
150
+ options: ReturnType<typeof getNormalizedOptions>,
151
+ lockValue: string,
152
+ ): Effect.Effect<void, RedisError> {
153
+ return Effect.asVoid(
154
+ Effect.tryPromise({
155
+ try: () => redis.eval(RELEASE_LOCK_SCRIPT, 1, options.lockKey, lockValue),
156
+ catch: (error) => toRedisError(error, `Failed to release ${options.label} (${options.lockKey})`),
157
+ }),
158
+ )
102
159
  }
103
160
 
104
- function startLeaseLockRefreshLoop(
105
- options: Required<Pick<RedisLeaseLockOptions, 'refreshIntervalMs'>> &
106
- RedisLeaseLockOptions & { lockValue: string; label: string },
107
- ac: AbortController,
108
- ): () => void {
109
- let stopped = false
110
-
111
- const timer = setInterval(() => {
112
- if (stopped) return
113
- void refreshLeaseLock(options).catch((error: unknown) => {
114
- stopped = true
115
- clearInterval(timer)
116
- const message = `Failed to refresh ${options.label} (${options.lockKey}): ${getErrorMessage(error)}`
117
- log(options.logger, 'warn', message)
118
- if (!ac.signal.aborted) {
119
- ac.abort(new LeaseLockLostError(message))
120
- }
121
- })
122
- }, options.refreshIntervalMs)
161
+ function startRefreshFiber(
162
+ redis: IORedis,
163
+ options: ReturnType<typeof getNormalizedOptions>,
164
+ lockValue: string,
165
+ abortController: AbortController,
166
+ lockLost: Deferred.Deferred<never, LockLostError | RedisError>,
167
+ ) {
168
+ const handleLockLoss = (error: LockLostError | RedisError) =>
169
+ Deferred.fail(lockLost, error).pipe(
170
+ Effect.andThen(
171
+ Effect.sync(() => {
172
+ const message =
173
+ error._tag === 'LockLostError'
174
+ ? `${options.label} refresh was rejected for key ${options.lockKey}`
175
+ : error.message
123
176
 
124
- timer.unref()
177
+ log(options.logger, 'warn', message)
178
+ if (!abortController.signal.aborted) {
179
+ abortController.abort(error)
180
+ }
181
+ }),
182
+ ),
183
+ Effect.andThen(Effect.fail(error)),
184
+ )
125
185
 
126
- return () => {
127
- stopped = true
128
- clearInterval(timer)
129
- }
186
+ const refreshProgram = Effect.gen(function* () {
187
+ for (;;) {
188
+ yield* Effect.sleep(Duration.millis(options.refreshIntervalMs))
189
+
190
+ yield* refreshLock(redis, options, lockValue).pipe(
191
+ Effect.catchTag('LockLostError', handleLockLoss),
192
+ Effect.catchTag('RedisError', handleLockLoss),
193
+ )
194
+ }
195
+ })
196
+
197
+ return Effect.forkScoped(refreshProgram).pipe(Effect.asVoid)
130
198
  }
131
199
 
132
- export async function withRedisLeaseLock<T>(
200
+ export function withLeaseLock<A, E, R>(
201
+ options: RedisLeaseLockOptions & { redis: IORedis },
202
+ fn: (signal: AbortSignal) => Effect.Effect<A, E, R>,
203
+ ): Effect.Effect<A, E | LockAcquisitionError | LockLostError | RedisError, R>
204
+ export function withLeaseLock<A, E, R>(
133
205
  options: RedisLeaseLockOptions,
134
- fn: (signal: AbortSignal) => Promise<T>,
135
- ): Promise<T> {
136
- const lockKey = options.lockKey.trim()
137
- if (!lockKey) {
138
- throw new Error('Redis lease lock requires a non-empty lock key')
139
- }
206
+ fn: (signal: AbortSignal) => Effect.Effect<A, E, R>,
207
+ ): Effect.Effect<A, E | LockAcquisitionError | LockLostError | RedisError, RedisServiceTag | R> {
208
+ const normalized = getNormalizedOptions(options)
140
209
 
141
- const retryDelayMs = options.retryDelayMs ?? 500
142
- const refreshIntervalMs = options.refreshIntervalMs ?? 30_000
143
- const waitLogIntervalMs = options.waitLogIntervalMs ?? 30_000
144
- const maxWaitMs = options.maxWaitMs ?? 45_000
145
- const acquiredWaitInfoThresholdMs = options.acquiredWaitInfoThresholdMs ?? 1_000
146
- const heldInfoThresholdMs = options.heldInfoThresholdMs ?? 5_000
147
- const label = options.label ?? 'redis lease lock'
148
-
149
- const lockValue = crypto.randomUUID()
150
- const waitStart = Date.now()
151
- await acquireLeaseLock({ ...options, lockKey, lockValue, label, retryDelayMs, waitLogIntervalMs, maxWaitMs })
152
- const waitedMs = Date.now() - waitStart
153
- if (waitedMs >= acquiredWaitInfoThresholdMs) {
154
- log(options.logger, 'info', `Acquired ${label} (${lockKey}) after waiting waitedMs=${waitedMs}`)
155
- }
210
+ return Effect.gen(function* () {
211
+ if (!normalized.lockKey) {
212
+ return yield* new LockAcquisitionError({ lockKey: '', maxWaitMs: normalized.maxWaitMs })
213
+ }
156
214
 
157
- const ac = new AbortController()
158
- const stopRefreshLoop = startLeaseLockRefreshLoop({ ...options, lockKey, lockValue, label, refreshIntervalMs }, ac)
159
- const holdStart = Date.now()
215
+ const redis = yield* resolveRedisConnection(options)
216
+ const lockValue = yield* Random.nextUUIDv4
217
+ const waitStart = yield* Clock.currentTimeMillis
160
218
 
161
- try {
162
- const abortPromise = new Promise<never>((_, reject) => {
163
- if (ac.signal.aborted) {
164
- reject(ac.signal.reason as Error)
165
- return
166
- }
167
- ac.signal.addEventListener(
168
- 'abort',
169
- () => {
170
- reject(ac.signal.reason as Error)
171
- },
172
- { once: true },
219
+ yield* acquireLock(redis, normalized, lockValue)
220
+
221
+ const waitedMs = (yield* Clock.currentTimeMillis) - waitStart
222
+ if (waitedMs >= normalized.acquiredWaitInfoThresholdMs) {
223
+ log(
224
+ normalized.logger,
225
+ 'info',
226
+ `Acquired ${normalized.label} (${normalized.lockKey}) after waiting waitedMs=${waitedMs}`,
173
227
  )
174
- })
175
- return await Promise.race([fn(ac.signal), abortPromise])
176
- } finally {
177
- stopRefreshLoop()
178
- const heldMs = Date.now() - holdStart
179
- if (!ac.signal.aborted) {
180
- await releaseLeaseLock({ ...options, lockKey, lockValue }).catch((error: unknown) => {
181
- log(options.logger, 'warn', `Failed to release ${label} (${lockKey}): ${getErrorMessage(error)}`)
182
- })
183
- }
184
- if (heldMs >= heldInfoThresholdMs) {
185
- log(options.logger, 'info', `Released ${label} (${lockKey}) heldMs=${heldMs}`)
186
- } else {
187
- log(options.logger, 'debug', `Released ${label} (${lockKey}) heldMs=${heldMs}`)
188
228
  }
189
- }
229
+
230
+ const abortController = new AbortController()
231
+ const holdStart = yield* Clock.currentTimeMillis
232
+ const lockLost = yield* Deferred.make<never, LockLostError | RedisError>()
233
+
234
+ const cleanup = Effect.sync(() => {
235
+ if (!abortController.signal.aborted) {
236
+ abortController.abort()
237
+ }
238
+ }).pipe(
239
+ Effect.andThen(
240
+ releaseLock(redis, normalized, lockValue).pipe(
241
+ Effect.catch((error) =>
242
+ Effect.sync(() => {
243
+ log(normalized.logger, 'warn', error.message)
244
+ }),
245
+ ),
246
+ ),
247
+ ),
248
+ Effect.andThen(
249
+ Effect.gen(function* () {
250
+ const heldMs = (yield* Clock.currentTimeMillis) - holdStart
251
+ if (heldMs >= normalized.heldInfoThresholdMs) {
252
+ log(normalized.logger, 'info', `Released ${normalized.label} (${normalized.lockKey}) heldMs=${heldMs}`)
253
+ return
254
+ }
255
+
256
+ log(normalized.logger, 'debug', `Released ${normalized.label} (${normalized.lockKey}) heldMs=${heldMs}`)
257
+ }),
258
+ ),
259
+ Effect.ignore,
260
+ )
261
+
262
+ return yield* Effect.scoped(
263
+ startRefreshFiber(redis, normalized, lockValue, abortController, lockLost).pipe(
264
+ Effect.andThen(fn(abortController.signal).pipe(Effect.raceFirst(Deferred.await(lockLost)))),
265
+ Effect.ensuring(cleanup),
266
+ ),
267
+ )
268
+ })
190
269
  }
@@ -0,0 +1,20 @@
1
+ import type IORedis from 'ioredis'
2
+
3
+ import { resolveLotaService } from '../effect/runtime'
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
+ }
16
+
17
+ export function getRuntimeRedisConnection(): IORedis {
18
+ const redis = currentRedisManager ?? resolveLotaService(RedisServiceTag)
19
+ return redis.getConnection()
20
+ }
@@ -1,28 +1,13 @@
1
+ import { Context, Schema, Effect, Fiber, Layer, Queue, Stream } from 'effect'
1
2
  import type { Redis } from 'ioredis'
2
3
  import type { Publisher, Subscriber } from 'resumable-stream/ioredis'
3
4
  import { createResumableStreamContext } from 'resumable-stream/ioredis'
4
5
 
5
- import { getRedisConnection } from './connection-accessor'
6
+ import { serverLogger } from '../config/logger'
7
+ import { RedisServiceTag } from '../effect/services'
8
+ import type { RedisConnectionManager } from './connection'
6
9
 
7
- function toSubscriber(client: Redis): Subscriber {
8
- const handlers = new Map<string, (message: string) => void>()
9
- const messageListener = (channel: string, message: string) => {
10
- handlers.get(channel)?.(message)
11
- }
12
- return {
13
- connect: () => Promise.resolve(),
14
- subscribe: async (channel, callback) => {
15
- if (handlers.size === 0) client.on('message', messageListener)
16
- handlers.set(channel, callback)
17
- await client.subscribe(channel)
18
- },
19
- unsubscribe: async (channel) => {
20
- handlers.delete(channel)
21
- if (handlers.size === 0) client.removeListener('message', messageListener)
22
- return client.unsubscribe(channel)
23
- },
24
- }
25
- }
10
+ const SUBSCRIBER_QUEUE_CAPACITY = 1024
26
11
 
27
12
  function toPublisher(client: Redis): Publisher {
28
13
  return {
@@ -34,35 +19,96 @@ function toPublisher(client: Redis): Publisher {
34
19
  }
35
20
  }
36
21
 
37
- let sharedSubscriber: { client: Redis; subscriber: Subscriber } | undefined
22
+ type SharedSubscriberEvent = { readonly channel: string; readonly message: string }
23
+
24
+ class SharedSubscriberCloseError extends Schema.TaggedErrorClass<SharedSubscriberCloseError>()(
25
+ 'SharedSubscriberCloseError',
26
+ { message: Schema.String, cause: Schema.optional(Schema.Defect) },
27
+ ) {}
28
+
29
+ export class SharedThreadStreamSubscriberTag extends Context.Service<
30
+ SharedThreadStreamSubscriberTag,
31
+ { readonly subscriber: Subscriber }
32
+ >()('@lota-sdk/core/SharedThreadStreamSubscriber') {}
38
33
 
39
- function getSharedSubscriber(): Subscriber {
40
- if (!sharedSubscriber) {
34
+ export const SharedThreadStreamSubscriberLive = Layer.effect(
35
+ SharedThreadStreamSubscriberTag,
36
+ Effect.gen(function* () {
37
+ const redisManager = yield* RedisServiceTag
41
38
  // Disable enableReadyCheck — the ready check sends INFO which is rejected
42
39
  // on connections in subscribe mode, causing unhandled ioredis error events.
43
- const client = getRedisConnection().duplicate({ enableReadyCheck: false })
44
- client.on('error', () => {}) // prevent [ioredis] Unhandled error event logs
45
- sharedSubscriber = { client, subscriber: toSubscriber(client) }
46
- }
47
- return sharedSubscriber.subscriber
48
- }
40
+ const client = redisManager.getConnection().duplicate({ enableReadyCheck: false })
41
+ client.on('error', (error: unknown) => {
42
+ serverLogger.warn('Redis subscriber error', { cause: error })
43
+ })
49
44
 
50
- export async function closeSharedSubscriber(): Promise<void> {
51
- if (!sharedSubscriber) return
52
- const { client } = sharedSubscriber
53
- sharedSubscriber = undefined
54
- try {
55
- await client.quit()
56
- } catch {
57
- client.disconnect()
58
- }
59
- }
45
+ const events = yield* Queue.bounded<SharedSubscriberEvent>(SUBSCRIBER_QUEUE_CAPACITY)
46
+ const handlers = new Map<string, (message: string) => void>()
47
+
48
+ const dispatchFiber = yield* Effect.forkScoped(
49
+ Stream.fromQueue(events).pipe(
50
+ Stream.runForEach((event) =>
51
+ Effect.sync(() => {
52
+ handlers.get(event.channel)?.(event.message)
53
+ }),
54
+ ),
55
+ ),
56
+ )
57
+
58
+ const messageListener = (channel: string, message: string) => {
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
+ }
65
+ }
66
+ client.on('message', messageListener)
67
+
68
+ const subscribe = (channel: string, callback: (message: string) => void): Promise<void> => {
69
+ handlers.set(channel, callback)
70
+ return client.subscribe(channel).then(() => undefined)
71
+ }
72
+
73
+ const unsubscribe = (channel: string): Promise<void> => {
74
+ handlers.delete(channel)
75
+ return client.unsubscribe(channel).then(() => undefined)
76
+ }
77
+
78
+ const subscriber: Subscriber = {
79
+ connect: () => Promise.resolve(),
80
+ subscribe: (channel, callback) => subscribe(channel, callback),
81
+ unsubscribe: (channel) => unsubscribe(channel),
82
+ }
83
+
84
+ yield* Effect.addFinalizer(() =>
85
+ Effect.gen(function* () {
86
+ handlers.clear()
87
+ client.removeListener('message', messageListener)
88
+ yield* Fiber.interrupt(dispatchFiber)
89
+ yield* Queue.shutdown(events)
90
+ yield* Effect.tryPromise({
91
+ try: () => client.quit(),
92
+ catch: (cause) =>
93
+ new SharedSubscriberCloseError({ message: 'Failed to quit shared stream subscriber.', cause }),
94
+ }).pipe(
95
+ Effect.catch(() =>
96
+ Effect.sync(() => {
97
+ client.disconnect()
98
+ }),
99
+ ),
100
+ )
101
+ }),
102
+ )
103
+
104
+ return { subscriber }
105
+ }),
106
+ )
60
107
 
61
- export function createThreadResumableContext() {
62
- const redis = getRedisConnection()
63
- return createResumableStreamContext({
64
- waitUntil: null,
65
- subscriber: getSharedSubscriber(),
66
- publisher: toPublisher(redis),
67
- })
108
+ export function createThreadResumableContext(
109
+ redisManager: Pick<RedisConnectionManager, 'getConnection'>,
110
+ subscriber: Subscriber,
111
+ ) {
112
+ const redis = redisManager.getConnection()
113
+ return createResumableStreamContext({ waitUntil: null, subscriber, publisher: toPublisher(redis) })
68
114
  }
@@ -1,4 +1,4 @@
1
- import { agentDisplayNames } from '../config/agent-defaults'
1
+ import { getAgentDisplayNames } from '../config/agent-defaults'
2
2
  import { asRecord, readOptionalString } from './thread-chat-helpers'
3
3
 
4
4
  interface RuntimeAgentIdentityOverrides {
@@ -58,5 +58,5 @@ export function resolveRuntimeAgentDisplayName(overrides: RuntimeAgentIdentityOv
58
58
  return override
59
59
  }
60
60
 
61
- return agentDisplayNames[agentId] ?? agentId
61
+ return getAgentDisplayNames()[agentId] ?? agentId
62
62
  }
@@ -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
@@ -162,10 +162,13 @@ export function buildThreadAgentToolPolicy<TAgent extends string, TSkill extends
162
162
  linearInstalled: boolean
163
163
  githubInstalled: boolean
164
164
  provideRepoTool: boolean
165
+ leadAgentId?: TAgent
166
+ onboardingOwnerAgentId?: TAgent
165
167
  getAgentSkills: (agentId: TAgent, mode: ChatMode) => TSkill[]
166
168
  }): AgentToolPolicy<TSkill> {
167
169
  const resolvedMode = params.mode ?? toChatMode(params.threadType)
168
- const onboardingOwnerAgentId = resolveOnboardingOwnerAgentId(getLeadAgentId()) as TAgent
170
+ const leadAgentId = params.leadAgentId ?? (getLeadAgentId() as TAgent)
171
+ const onboardingOwnerAgentId = params.onboardingOwnerAgentId ?? (resolveOnboardingOwnerAgentId(leadAgentId) as TAgent)
169
172
  const skills = resolveActiveAgentSkills({
170
173
  agentId: params.agentId,
171
174
  threadType: params.threadType,
@@ -1,7 +1,9 @@
1
1
  import type { ChatMessage } from '@lota-sdk/shared'
2
2
  import type { LanguageModelUsage, UIMessageStreamOptions } from 'ai'
3
+ import { Duration, Effect, Exit, Scope } from 'effect'
3
4
 
4
- import { agentDisplayNames, getLeadAgentDisplayName } from '../config/agent-defaults'
5
+ import { getAgentDisplayNames, getLeadAgentDisplayName } from '../config/agent-defaults'
6
+ import { nowEpochMillis } from '../utils/date-time'
5
7
  import { readRecord as _readRecord } from '../utils/string'
6
8
 
7
9
  export function readFiniteNumber(value: unknown): number | undefined {
@@ -32,7 +34,7 @@ export function createAgentMessageMetadata(params: {
32
34
  }): NonNullable<UIMessageStreamOptions<ChatMessage>['messageMetadata']> {
33
35
  return ({ part }) => {
34
36
  if (part.type === 'start') {
35
- return { agentId: params.agentId, agentName: params.agentName, createdAt: Date.now() }
37
+ return { agentId: params.agentId, agentName: params.agentName, createdAt: nowEpochMillis() }
36
38
  }
37
39
 
38
40
  if (part.type === 'finish') {
@@ -100,13 +102,24 @@ export function createServerRunAbortController(externalAbortSignal?: AbortSignal
100
102
  export function createTimedAbortSignal(parentSignal: AbortSignal, timeoutMs: number) {
101
103
  const controller = new AbortController()
102
104
  let didTimeout = false
105
+ let disposed = false
103
106
  const abortFromParent = () => {
104
107
  controller.abort((parentSignal as AbortSignal & { reason?: unknown }).reason)
105
108
  }
106
- const timeoutId = setTimeout(() => {
107
- didTimeout = true
108
- controller.abort(new Error(`Timed out after ${timeoutMs}ms`))
109
- }, timeoutMs)
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
+ }),
120
+ ),
121
+ )
122
+ Effect.runFork(Scope.provide(Effect.forkScoped(timeoutEffect), scope))
110
123
 
111
124
  if (parentSignal.aborted) {
112
125
  abortFromParent()
@@ -118,14 +131,16 @@ export function createTimedAbortSignal(parentSignal: AbortSignal, timeoutMs: num
118
131
  signal: controller.signal,
119
132
  didTimeout: () => didTimeout,
120
133
  dispose: () => {
121
- clearTimeout(timeoutId)
134
+ if (disposed) return
135
+ disposed = true
122
136
  parentSignal.removeEventListener('abort', abortFromParent)
137
+ void Effect.runPromise(Scope.close(scope, Exit.void).pipe(Effect.catchCause(() => Effect.void)))
123
138
  },
124
139
  }
125
140
  }
126
141
 
127
142
  export function buildSpecialistTaskMessage(params: { agentId: string; task: string }): ChatMessage {
128
- const displayName = agentDisplayNames[params.agentId] ?? params.agentId
143
+ const displayName = getAgentDisplayNames()[params.agentId] ?? params.agentId
129
144
  const leadAgentDisplayName = getLeadAgentDisplayName()
130
145
  return {
131
146
  id: Bun.randomUUIDv7(),
@@ -133,6 +148,6 @@ export function buildSpecialistTaskMessage(params: { agentId: string; task: stri
133
148
  parts: [
134
149
  { type: 'text', text: [`${leadAgentDisplayName} request for ${displayName}:`, params.task.trim()].join('\n') },
135
150
  ],
136
- metadata: { createdAt: Date.now() },
151
+ metadata: { createdAt: nowEpochMillis() },
137
152
  }
138
153
  }