@lota-sdk/core 0.4.8 → 0.4.9

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 (259) hide show
  1. package/package.json +11 -12
  2. package/src/ai/embedding-cache.ts +94 -22
  3. package/src/ai-gateway/ai-gateway.ts +738 -223
  4. package/src/config/agent-defaults.ts +176 -75
  5. package/src/config/agent-types.ts +54 -4
  6. package/src/config/constants.ts +8 -2
  7. package/src/config/logger.ts +286 -19
  8. package/src/config/thread-defaults.ts +33 -21
  9. package/src/create-runtime.ts +725 -387
  10. package/src/db/base.service.ts +52 -28
  11. package/src/db/cursor-pagination.ts +71 -30
  12. package/src/db/memory-store.helpers.ts +4 -7
  13. package/src/db/memory-store.ts +856 -598
  14. package/src/db/memory.ts +398 -275
  15. package/src/db/record-id.ts +32 -10
  16. package/src/db/schema-fingerprint.ts +30 -12
  17. package/src/db/service-normalization.ts +255 -0
  18. package/src/db/service.ts +726 -761
  19. package/src/db/startup.ts +140 -66
  20. package/src/db/transaction-conflict.ts +15 -0
  21. package/src/effect/awaitable-effect.ts +87 -0
  22. package/src/effect/errors.ts +121 -0
  23. package/src/effect/helpers.ts +98 -0
  24. package/src/effect/index.ts +22 -0
  25. package/src/effect/layers.ts +228 -0
  26. package/src/effect/runtime-ref.ts +25 -0
  27. package/src/effect/runtime.ts +31 -0
  28. package/src/effect/services.ts +57 -0
  29. package/src/effect/zod.ts +43 -0
  30. package/src/embeddings/provider.ts +122 -76
  31. package/src/index.ts +46 -1
  32. package/src/openrouter/direct-provider.ts +11 -35
  33. package/src/queues/autonomous-job.queue.ts +130 -74
  34. package/src/queues/context-compaction.queue.ts +60 -15
  35. package/src/queues/delayed-node-promotion.queue.ts +52 -15
  36. package/src/queues/document-processor.queue.ts +52 -77
  37. package/src/queues/memory-consolidation.queue.ts +47 -32
  38. package/src/queues/organization-learning.queue.ts +13 -4
  39. package/src/queues/plan-agent-heartbeat.queue.ts +65 -21
  40. package/src/queues/plan-scheduler.queue.ts +107 -31
  41. package/src/queues/post-chat-memory.queue.ts +66 -24
  42. package/src/queues/queue-factory.ts +142 -52
  43. package/src/queues/standalone-worker.ts +39 -0
  44. package/src/queues/title-generation.queue.ts +54 -9
  45. package/src/redis/connection.ts +84 -32
  46. package/src/redis/index.ts +6 -8
  47. package/src/redis/org-memory-lock.ts +60 -27
  48. package/src/redis/redis-lease-lock.ts +200 -121
  49. package/src/redis/runtime-connection.ts +10 -0
  50. package/src/redis/stream-context.ts +84 -46
  51. package/src/runtime/agent-identity-overrides.ts +2 -2
  52. package/src/runtime/agent-runtime-policy.ts +4 -1
  53. package/src/runtime/agent-stream-helpers.ts +20 -9
  54. package/src/runtime/chat-run-orchestration.ts +102 -19
  55. package/src/runtime/chat-run-registry.ts +36 -2
  56. package/src/runtime/context-compaction/context-compaction-runtime.ts +107 -0
  57. package/src/runtime/{context-compaction.ts → context-compaction/context-compaction.ts} +114 -91
  58. package/src/runtime/execution-plan-visibility.ts +2 -2
  59. package/src/runtime/execution-plan.ts +42 -15
  60. package/src/runtime/graph-designer.ts +11 -7
  61. package/src/runtime/helper-model.ts +135 -48
  62. package/src/runtime/index.ts +7 -7
  63. package/src/runtime/indexed-repositories-policy.ts +3 -3
  64. package/src/runtime/{memory-block.ts → memory/memory-block.ts} +40 -36
  65. package/src/runtime/{memory-digest-policy.ts → memory/memory-digest-policy.ts} +1 -1
  66. package/src/runtime/{memory-pipeline.ts → memory/memory-pipeline.ts} +1 -1
  67. package/src/runtime/{memory-prompts-fact.ts → memory/memory-prompts-fact.ts} +2 -2
  68. package/src/runtime/{memory-scope.ts → memory/memory-scope.ts} +12 -6
  69. package/src/runtime/plugin-resolution.ts +144 -24
  70. package/src/runtime/plugin-types.ts +9 -1
  71. package/src/runtime/post-turn-side-effects.ts +197 -130
  72. package/src/runtime/retrieval-adapters.ts +38 -4
  73. package/src/runtime/runtime-config.ts +150 -61
  74. package/src/runtime/runtime-extensions.ts +21 -34
  75. package/src/runtime/social-chat/social-chat-agent-runner.ts +157 -0
  76. package/src/runtime/{social-chat-history.ts → social-chat/social-chat-history.ts} +42 -20
  77. package/src/runtime/social-chat/social-chat.ts +594 -0
  78. package/src/runtime/specialist-runner.ts +36 -10
  79. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +427 -0
  80. package/src/runtime/{team-consultation-prompts.ts → team-consultation/team-consultation-prompts.ts} +6 -2
  81. package/src/runtime/thread-chat-helpers.ts +2 -2
  82. package/src/runtime/thread-plan-turn.ts +2 -1
  83. package/src/runtime/thread-turn-context.ts +172 -94
  84. package/src/runtime/turn-lifecycle.ts +93 -27
  85. package/src/services/agent-activity.service.ts +287 -203
  86. package/src/services/agent-executor.service.ts +329 -217
  87. package/src/services/artifact.service.ts +225 -148
  88. package/src/services/attachment.service.ts +137 -115
  89. package/src/services/autonomous-job.service.ts +888 -491
  90. package/src/services/chat-run-registry.service.ts +11 -1
  91. package/src/services/context-compaction.service.ts +136 -86
  92. package/src/services/document-chunk.service.ts +162 -90
  93. package/src/services/execution-plan/execution-plan-approval.ts +26 -0
  94. package/src/services/execution-plan/execution-plan-context.ts +29 -0
  95. package/src/services/execution-plan/execution-plan-graph.ts +256 -0
  96. package/src/services/execution-plan/execution-plan-schedule.ts +84 -0
  97. package/src/services/execution-plan/execution-plan-spec.ts +75 -0
  98. package/src/services/execution-plan/execution-plan.service.ts +1041 -0
  99. package/src/services/feedback-loop.service.ts +132 -76
  100. package/src/services/global-orchestrator.service.ts +80 -170
  101. package/src/services/graph-full-routing.ts +182 -0
  102. package/src/services/index.ts +18 -21
  103. package/src/services/institutional-memory.service.ts +220 -123
  104. package/src/services/learned-skill.service.ts +364 -259
  105. package/src/services/memory/memory-conversation.ts +95 -0
  106. package/src/services/memory/memory-org-memory.ts +39 -0
  107. package/src/services/memory/memory-preseeded.ts +80 -0
  108. package/src/services/memory/memory-rerank.ts +297 -0
  109. package/src/services/{memory-utils.ts → memory/memory-utils.ts} +5 -5
  110. package/src/services/memory/memory.service.ts +692 -0
  111. package/src/services/memory/rerank.service.ts +209 -0
  112. package/src/services/monitoring-window.service.ts +92 -70
  113. package/src/services/mutating-approval.service.ts +62 -53
  114. package/src/services/node-workspace.service.ts +141 -98
  115. package/src/services/notification.service.ts +17 -16
  116. package/src/services/organization-member.service.ts +120 -66
  117. package/src/services/organization.service.ts +144 -51
  118. package/src/services/ownership-dispatcher.service.ts +415 -264
  119. package/src/services/plan/plan-agent-heartbeat.service.ts +234 -0
  120. package/src/services/plan/plan-agent-query.service.ts +322 -0
  121. package/src/services/plan/plan-approval.service.ts +102 -0
  122. package/src/services/plan/plan-artifact.service.ts +60 -0
  123. package/src/services/plan/plan-builder.service.ts +76 -0
  124. package/src/services/plan/plan-checkpoint.service.ts +103 -0
  125. package/src/services/{plan-compiler.service.ts → plan/plan-compiler.service.ts} +26 -9
  126. package/src/services/plan/plan-completion-side-effects.ts +175 -0
  127. package/src/services/plan/plan-coordination.service.ts +181 -0
  128. package/src/services/plan/plan-cycle.service.ts +398 -0
  129. package/src/services/plan/plan-deadline.service.ts +547 -0
  130. package/src/services/plan/plan-event-delivery.service.ts +261 -0
  131. package/src/services/plan/plan-executor-context.ts +35 -0
  132. package/src/services/plan/plan-executor-graph.ts +475 -0
  133. package/src/services/plan/plan-executor-helpers.ts +322 -0
  134. package/src/services/plan/plan-executor-persistence.ts +209 -0
  135. package/src/services/plan/plan-executor.service.ts +1654 -0
  136. package/src/services/{plan-helpers.ts → plan/plan-helpers.ts} +1 -1
  137. package/src/services/{plan-run-data.ts → plan/plan-run-data.ts} +4 -4
  138. package/src/services/plan/plan-run-serialization.ts +15 -0
  139. package/src/services/plan/plan-run.service.ts +644 -0
  140. package/src/services/plan/plan-scheduler.service.ts +385 -0
  141. package/src/services/plan/plan-template.service.ts +224 -0
  142. package/src/services/plan/plan-transaction-events.ts +33 -0
  143. package/src/services/plan/plan-validator.service.ts +907 -0
  144. package/src/services/plan/plan-workspace.service.ts +125 -0
  145. package/src/services/plugin-executor.service.ts +97 -68
  146. package/src/services/quality-metrics.service.ts +112 -94
  147. package/src/services/queue-job.service.ts +296 -230
  148. package/src/services/recent-activity-title.service.ts +65 -36
  149. package/src/services/recent-activity.service.ts +274 -259
  150. package/src/services/skill-resolver.service.ts +38 -12
  151. package/src/services/social-chat-history.service.ts +176 -125
  152. package/src/services/system-executor.service.ts +91 -61
  153. package/src/services/thread/thread-active-run.ts +203 -0
  154. package/src/services/thread/thread-bootstrap.ts +369 -0
  155. package/src/services/thread/thread-listing.ts +198 -0
  156. package/src/services/thread/thread-memory-block.ts +117 -0
  157. package/src/services/thread/thread-message.service.ts +363 -0
  158. package/src/services/thread/thread-record-store.ts +155 -0
  159. package/src/services/thread/thread-title.service.ts +74 -0
  160. package/src/services/thread/thread-turn-execution.ts +280 -0
  161. package/src/services/thread/thread-turn-message-context.ts +73 -0
  162. package/src/services/thread/thread-turn-preparation.service.ts +1146 -0
  163. package/src/services/thread/thread-turn-streaming.ts +402 -0
  164. package/src/services/thread/thread-turn-tracing.ts +35 -0
  165. package/src/services/thread/thread-turn.ts +343 -0
  166. package/src/services/thread/thread.service.ts +335 -0
  167. package/src/services/user.service.ts +82 -32
  168. package/src/services/write-intent-validator.service.ts +63 -51
  169. package/src/storage/attachment-parser.ts +69 -27
  170. package/src/storage/attachment-storage.service.ts +331 -275
  171. package/src/storage/generated-document-storage.service.ts +66 -34
  172. package/src/system-agents/agent-result.ts +3 -1
  173. package/src/system-agents/context-compaction.agent.ts +2 -2
  174. package/src/system-agents/delegated-agent-factory.ts +159 -90
  175. package/src/system-agents/memory-reranker.agent.ts +2 -2
  176. package/src/system-agents/memory.agent.ts +2 -2
  177. package/src/system-agents/recent-activity-title-refiner.agent.ts +2 -2
  178. package/src/system-agents/regular-chat-memory-digest.agent.ts +2 -2
  179. package/src/system-agents/skill-extractor.agent.ts +2 -2
  180. package/src/system-agents/skill-manager.agent.ts +2 -2
  181. package/src/system-agents/thread-router.agent.ts +157 -113
  182. package/src/system-agents/title-generator.agent.ts +2 -2
  183. package/src/tools/execution-plan.tool.ts +220 -161
  184. package/src/tools/fetch-webpage.tool.ts +21 -17
  185. package/src/tools/firecrawl-client.ts +16 -6
  186. package/src/tools/index.ts +1 -0
  187. package/src/tools/memory-block.tool.ts +14 -6
  188. package/src/tools/plan-approval.tool.ts +49 -47
  189. package/src/tools/read-file-parts.tool.ts +44 -33
  190. package/src/tools/remember-memory.tool.ts +65 -45
  191. package/src/tools/search-web.tool.ts +26 -22
  192. package/src/tools/search.tool.ts +41 -29
  193. package/src/tools/team-think.tool.ts +124 -83
  194. package/src/tools/user-questions.tool.ts +4 -3
  195. package/src/tools/web-tool-shared.ts +6 -0
  196. package/src/utils/async.ts +17 -23
  197. package/src/utils/crypto.ts +21 -0
  198. package/src/utils/date-time.ts +40 -1
  199. package/src/utils/errors.ts +95 -16
  200. package/src/utils/hono-error-handler.ts +24 -39
  201. package/src/utils/index.ts +2 -1
  202. package/src/utils/null-proto-record.ts +41 -0
  203. package/src/utils/sse-keepalive.ts +124 -21
  204. package/src/workers/bootstrap.ts +186 -51
  205. package/src/workers/memory-consolidation.worker.ts +325 -237
  206. package/src/workers/organization-learning.worker.ts +50 -16
  207. package/src/workers/regular-chat-memory-digest.helpers.ts +28 -27
  208. package/src/workers/regular-chat-memory-digest.runner.ts +175 -114
  209. package/src/workers/skill-extraction.runner.ts +176 -93
  210. package/src/workers/utils/file-section-chunker.ts +8 -10
  211. package/src/workers/utils/repo-structure-extractor.ts +349 -260
  212. package/src/workers/utils/repomix-file-sections.ts +2 -2
  213. package/src/workers/utils/thread-message-query.ts +97 -38
  214. package/src/workers/worker-utils.ts +56 -31
  215. package/src/config/debug-logger.ts +0 -47
  216. package/src/redis/connection-accessor.ts +0 -26
  217. package/src/runtime/context-compaction-runtime.ts +0 -87
  218. package/src/runtime/social-chat-agent-runner.ts +0 -118
  219. package/src/runtime/social-chat.ts +0 -516
  220. package/src/runtime/team-consultation-orchestrator.ts +0 -272
  221. package/src/services/adaptive-playbook.service.ts +0 -152
  222. package/src/services/artifact-provenance.service.ts +0 -172
  223. package/src/services/chat-attachments.service.ts +0 -17
  224. package/src/services/context-compaction-runtime.singleton.ts +0 -13
  225. package/src/services/execution-plan.service.ts +0 -1118
  226. package/src/services/memory.service.ts +0 -914
  227. package/src/services/plan-agent-heartbeat.service.ts +0 -136
  228. package/src/services/plan-agent-query.service.ts +0 -267
  229. package/src/services/plan-approval.service.ts +0 -83
  230. package/src/services/plan-artifact.service.ts +0 -50
  231. package/src/services/plan-builder.service.ts +0 -67
  232. package/src/services/plan-checkpoint.service.ts +0 -81
  233. package/src/services/plan-completion-side-effects.ts +0 -80
  234. package/src/services/plan-coordination.service.ts +0 -157
  235. package/src/services/plan-cycle.service.ts +0 -284
  236. package/src/services/plan-deadline.service.ts +0 -430
  237. package/src/services/plan-event-delivery.service.ts +0 -166
  238. package/src/services/plan-executor.service.ts +0 -1950
  239. package/src/services/plan-run.service.ts +0 -515
  240. package/src/services/plan-scheduler.service.ts +0 -240
  241. package/src/services/plan-template.service.ts +0 -177
  242. package/src/services/plan-validator.service.ts +0 -818
  243. package/src/services/plan-workspace.service.ts +0 -83
  244. package/src/services/rerank.service.ts +0 -156
  245. package/src/services/thread-message.service.ts +0 -275
  246. package/src/services/thread-plan-registry.service.ts +0 -22
  247. package/src/services/thread-title.service.ts +0 -39
  248. package/src/services/thread-turn-preparation.service.ts +0 -1147
  249. package/src/services/thread-turn.ts +0 -172
  250. package/src/services/thread.service.ts +0 -869
  251. package/src/utils/env.ts +0 -8
  252. /package/src/runtime/{context-compaction-constants.ts → context-compaction/context-compaction-constants.ts} +0 -0
  253. /package/src/runtime/{memory-format.ts → memory/memory-format.ts} +0 -0
  254. /package/src/runtime/{memory-prompts-parse.ts → memory/memory-prompts-parse.ts} +0 -0
  255. /package/src/runtime/{memory-prompts-update.ts → memory/memory-prompts-update.ts} +0 -0
  256. /package/src/runtime/{social-chat-prompts.ts → social-chat/social-chat-prompts.ts} +0 -0
  257. /package/src/services/{plan-node-spec.ts → plan/plan-node-spec.ts} +0 -0
  258. /package/src/services/{thread-constants.ts → thread/thread-constants.ts} +0 -0
  259. /package/src/services/{thread.types.ts → thread/thread.types.ts} +0 -0
@@ -18,32 +18,57 @@ import type {
18
18
  ChatMessage,
19
19
  CreateAutonomousJobInput,
20
20
  QueueJobError,
21
+ SerializableExecutionPlan,
21
22
  UpdateAutonomousJobInput,
22
23
  } from '@lota-sdk/shared'
23
24
  import type { Job } from 'bullmq'
24
- import { CronExpressionParser } from 'cron-parser'
25
+ import { Context, Cron, Schema, Effect, Layer } from 'effect'
25
26
  import { BoundQuery, RecordId } from 'surrealdb'
26
27
  import { z } from 'zod'
27
28
 
28
29
  import { ensureRecordId, recordIdToString } from '../db/record-id'
29
30
  import type { RecordIdInput } from '../db/record-id'
30
- import { databaseService } from '../db/service'
31
+ import type { SurrealDBService } from '../db/service'
31
32
  import { TABLES } from '../db/tables'
33
+ import { makeEffectTryPromiseWithMessage } from '../effect/helpers'
34
+ import { DatabaseServiceTag, RuntimeConfigServiceTag } from '../effect/services'
32
35
  import type { AutonomousJobQueuePayload } from '../queues/autonomous-job.queue'
36
+ import type { ResolvedLotaRuntimeConfig } from '../runtime/runtime-config'
33
37
  import { extractMessageText } from '../runtime/thread-chat-helpers'
34
38
  import { buildAutonomousAtJobId, encodeBullmqId } from '../utils/autonomous-job-ids'
35
- import { toIsoDateTimeString, toOptionalIsoDateTimeString } from '../utils/date-time'
39
+ import {
40
+ nowDate,
41
+ nowEpochMillis,
42
+ toIsoDateTimeString,
43
+ toOptionalIsoDateTimeString,
44
+ unsafeDateFrom,
45
+ } from '../utils/date-time'
36
46
  import { compactRecord, compactWhitespace, stringifyUnknown, truncateText } from '../utils/string'
37
- import { executionPlanService } from './execution-plan.service'
38
- import { getNotificationService } from './notification.service'
39
- import { queueJobService } from './queue-job.service'
40
- import { runThreadTurnInBackground } from './thread-turn'
41
- import { threadService } from './thread.service'
47
+ import type { makeExecutionPlanService } from './execution-plan/execution-plan.service'
48
+ import { ExecutionPlanServiceTag } from './execution-plan/execution-plan.service'
49
+ import type { makeQueueJobService } from './queue-job.service'
50
+ import { QueueJobServiceTag } from './queue-job.service'
51
+ import type { PreparedThreadTurnResult } from './thread/thread-turn'
52
+ import type { ThreadTurnParams } from './thread/thread-turn-preparation.service'
53
+ import type { makeThreadService } from './thread/thread.service'
54
+ import { ThreadServiceTag } from './thread/thread.service'
55
+ // runThreadTurnInBackground is imported dynamically below
56
+
57
+ type ThreadTurnModule = { runThreadTurnInBackground(params: ThreadTurnParams): Promise<PreparedThreadTurnResult> }
42
58
 
43
59
  const AUTONOMOUS_JOB_QUEUE_NAME = 'autonomous-job'
44
60
 
61
+ class AutonomousJobServiceError extends Schema.TaggedErrorClass<AutonomousJobServiceError>()(
62
+ 'AutonomousJobServiceError',
63
+ { message: Schema.String, cause: Schema.optional(Schema.Defect) },
64
+ ) {}
65
+
66
+ const effectTryPromise = makeEffectTryPromiseWithMessage(
67
+ (message, cause) => new AutonomousJobServiceError({ message, cause }),
68
+ )
69
+
45
70
  function buildAutonomousManualJobId(autonomousJobId: string): string {
46
- return `autonomous-manual-${encodeBullmqId(autonomousJobId)}-${Date.now()}`
71
+ return `autonomous-manual-${encodeBullmqId(autonomousJobId)}-${nowEpochMillis()}`
47
72
  }
48
73
 
49
74
  const AutonomousJobRowSchema = z.object({
@@ -89,6 +114,20 @@ const AutonomousJobRunRowSchema = z.object({
89
114
 
90
115
  type AutonomousJobRow = z.infer<typeof AutonomousJobRowSchema>
91
116
  type AutonomousJobRunRow = z.infer<typeof AutonomousJobRunRowSchema>
117
+ type ActiveExecutionPlan = SerializableExecutionPlan | null
118
+
119
+ function computeNextCronDate(cronExpression: string, baseTime: Date): Date | null {
120
+ const parsedCron = Cron.parse(cronExpression)
121
+ if (parsedCron._tag === 'Failure') {
122
+ return null
123
+ }
124
+
125
+ try {
126
+ return Cron.next(parsedCron.success, baseTime)
127
+ } catch {
128
+ return null
129
+ }
130
+ }
92
131
 
93
132
  function toQueueJobError(error: unknown): QueueJobError {
94
133
  if (error instanceof Error) {
@@ -104,583 +143,941 @@ function toQueueJobError(error: unknown): QueueJobError {
104
143
  return QueueJobErrorSchema.parse({ message: truncateText(stringifyUnknown(error) ?? 'Unknown error', 5_000) })
105
144
  }
106
145
 
107
- class AutonomousJobService {
108
- private toPublicJob(row: AutonomousJobRow): AutonomousJob {
109
- return AutonomousJobSchema.parse({
110
- id: recordIdToString(row.id, TABLES.AUTONOMOUS_JOB),
111
- organizationId: recordIdToString(row.organizationId, TABLES.ORGANIZATION),
112
- ownerUserId: recordIdToString(row.ownerUserId, TABLES.USER),
113
- ownerUserName: row.ownerUserName,
114
- threadId: recordIdToString(row.threadId, TABLES.THREAD),
115
- agentId: row.agentId,
116
- title: row.title,
117
- prompt: row.prompt,
118
- schedule: row.schedule,
119
- status: row.status,
120
- autoPauseThreshold: row.autoPauseThreshold,
121
- consecutiveErrorCount: row.consecutiveErrorCount,
122
- lastRunStatus: row.lastRunStatus,
123
- lastRunAt: toOptionalIsoDateTimeString(row.lastRunAt),
124
- nextRunAt: toOptionalIsoDateTimeString(row.nextRunAt),
125
- linkedPlanSpecId: row.linkedPlanSpecId ? recordIdToString(row.linkedPlanSpecId, TABLES.PLAN_SPEC) : undefined,
126
- linkedPlanRunId: row.linkedPlanRunId ? recordIdToString(row.linkedPlanRunId, TABLES.PLAN_RUN) : undefined,
127
- lastError: row.lastError,
128
- createdAt: toIsoDateTimeString(row.createdAt),
129
- updatedAt: toIsoDateTimeString(row.updatedAt),
130
- })
131
- }
146
+ function toPublicJob(row: AutonomousJobRow): AutonomousJob {
147
+ return AutonomousJobSchema.parse({
148
+ id: recordIdToString(row.id, TABLES.AUTONOMOUS_JOB),
149
+ organizationId: recordIdToString(row.organizationId, TABLES.ORGANIZATION),
150
+ ownerUserId: recordIdToString(row.ownerUserId, TABLES.USER),
151
+ ownerUserName: row.ownerUserName,
152
+ threadId: recordIdToString(row.threadId, TABLES.THREAD),
153
+ agentId: row.agentId,
154
+ title: row.title,
155
+ prompt: row.prompt,
156
+ schedule: row.schedule,
157
+ status: row.status,
158
+ autoPauseThreshold: row.autoPauseThreshold,
159
+ consecutiveErrorCount: row.consecutiveErrorCount,
160
+ lastRunStatus: row.lastRunStatus,
161
+ lastRunAt: toOptionalIsoDateTimeString(row.lastRunAt),
162
+ nextRunAt: toOptionalIsoDateTimeString(row.nextRunAt),
163
+ linkedPlanSpecId: row.linkedPlanSpecId ? recordIdToString(row.linkedPlanSpecId, TABLES.PLAN_SPEC) : undefined,
164
+ linkedPlanRunId: row.linkedPlanRunId ? recordIdToString(row.linkedPlanRunId, TABLES.PLAN_RUN) : undefined,
165
+ lastError: row.lastError,
166
+ createdAt: toIsoDateTimeString(row.createdAt),
167
+ updatedAt: toIsoDateTimeString(row.updatedAt),
168
+ })
169
+ }
132
170
 
133
- private toPublicRun(row: AutonomousJobRunRow): AutonomousJobRun {
134
- return AutonomousJobRunSchema.parse({
135
- id: recordIdToString(row.id, TABLES.AUTONOMOUS_JOB_RUN),
136
- autonomousJobId: recordIdToString(row.autonomousJobId, TABLES.AUTONOMOUS_JOB),
137
- threadId: recordIdToString(row.threadId, TABLES.THREAD),
138
- queueJobId: row.queueJobId ? recordIdToString(row.queueJobId, TABLES.QUEUE_JOB) : undefined,
139
- status: row.status,
140
- inputMessageId: row.inputMessageId,
141
- assistantMessageIds: row.assistantMessageIds,
142
- summary: row.summary,
143
- error: row.error,
144
- linkedPlanSpecId: row.linkedPlanSpecId ? recordIdToString(row.linkedPlanSpecId, TABLES.PLAN_SPEC) : undefined,
145
- linkedPlanRunId: row.linkedPlanRunId ? recordIdToString(row.linkedPlanRunId, TABLES.PLAN_RUN) : undefined,
146
- startedAt: toOptionalIsoDateTimeString(row.startedAt),
147
- completedAt: toOptionalIsoDateTimeString(row.completedAt),
148
- createdAt: toIsoDateTimeString(row.createdAt),
149
- updatedAt: toIsoDateTimeString(row.updatedAt),
150
- })
151
- }
171
+ function toPublicRun(row: AutonomousJobRunRow): AutonomousJobRun {
172
+ return AutonomousJobRunSchema.parse({
173
+ id: recordIdToString(row.id, TABLES.AUTONOMOUS_JOB_RUN),
174
+ autonomousJobId: recordIdToString(row.autonomousJobId, TABLES.AUTONOMOUS_JOB),
175
+ threadId: recordIdToString(row.threadId, TABLES.THREAD),
176
+ queueJobId: row.queueJobId ? recordIdToString(row.queueJobId, TABLES.QUEUE_JOB) : undefined,
177
+ status: row.status,
178
+ inputMessageId: row.inputMessageId,
179
+ assistantMessageIds: row.assistantMessageIds,
180
+ summary: row.summary,
181
+ error: row.error,
182
+ linkedPlanSpecId: row.linkedPlanSpecId ? recordIdToString(row.linkedPlanSpecId, TABLES.PLAN_SPEC) : undefined,
183
+ linkedPlanRunId: row.linkedPlanRunId ? recordIdToString(row.linkedPlanRunId, TABLES.PLAN_RUN) : undefined,
184
+ startedAt: toOptionalIsoDateTimeString(row.startedAt),
185
+ completedAt: toOptionalIsoDateTimeString(row.completedAt),
186
+ createdAt: toIsoDateTimeString(row.createdAt),
187
+ updatedAt: toIsoDateTimeString(row.updatedAt),
188
+ })
189
+ }
152
190
 
153
- computeNextRunAt(schedule: AutonomousJobSchedule, baseTime: Date = new Date()): Date | null {
154
- switch (schedule.kind) {
155
- case 'cron': {
156
- try {
157
- const expr = CronExpressionParser.parse(schedule.cron, { currentDate: baseTime })
158
- return expr.next().toDate()
159
- } catch {
160
- return null
161
- }
162
- }
163
- case 'every':
164
- return new Date(baseTime.getTime() + schedule.intervalMs)
165
- case 'at': {
166
- const at = new Date(schedule.at)
191
+ export function computeNextRunAt(schedule: AutonomousJobSchedule, baseTime: Date = nowDate()): Date | null {
192
+ switch (schedule.kind) {
193
+ case 'cron': {
194
+ return computeNextCronDate(schedule.cron, baseTime)
195
+ }
196
+ case 'every':
197
+ return unsafeDateFrom(baseTime.getTime() + schedule.intervalMs)
198
+ case 'at': {
199
+ try {
200
+ const at = unsafeDateFrom(schedule.at)
167
201
  return Number.isNaN(at.getTime()) ? null : at
168
- }
169
- default:
202
+ } catch {
170
203
  return null
204
+ }
171
205
  }
206
+ default:
207
+ return null
172
208
  }
209
+ }
173
210
 
174
- private async maybeNotify(
175
- kind: 'notify',
176
- params: {
177
- organizationId: string
178
- threadId: string
179
- title: string
180
- body: string
181
- severity: 'info' | 'warning'
182
- metadata?: Record<string, unknown>
183
- },
184
- ): Promise<void> {
185
- try {
186
- const service = getNotificationService()
187
- await service[kind](params)
188
- } catch (error) {
189
- if (error instanceof Error && error.message === 'Notification service is not configured.') {
190
- return
191
- }
192
- throw error
193
- }
211
+ function maybeNotifyEffect(
212
+ deps: AutonomousJobDeps,
213
+ params: {
214
+ organizationId: string
215
+ threadId: string
216
+ title: string
217
+ body: string
218
+ severity: 'info' | 'warning'
219
+ metadata?: Record<string, unknown>
220
+ },
221
+ ): Effect.Effect<void, AutonomousJobServiceError> {
222
+ const notificationService = deps.config.notificationService
223
+ if (!notificationService) {
224
+ return Effect.void
194
225
  }
195
226
 
196
- private buildSyntheticUserMessage(prompt: string): ChatMessage {
197
- return {
198
- id: Bun.randomUUIDv7(),
199
- role: 'user',
200
- parts: [{ type: 'text', text: prompt }],
201
- metadata: { createdAt: Date.now() },
202
- }
227
+ return effectTryPromise(() => notificationService.notify(params), 'Failed to send autonomous job notification.')
228
+ }
229
+
230
+ function buildSyntheticUserMessage(prompt: string): ChatMessage {
231
+ return {
232
+ id: Bun.randomUUIDv7(),
233
+ role: 'user',
234
+ parts: [{ type: 'text', text: prompt }],
235
+ metadata: { createdAt: nowEpochMillis() },
203
236
  }
237
+ }
204
238
 
205
- private async createRunRow(params: {
239
+ function createRunRowEffect(
240
+ deps: AutonomousJobDeps,
241
+ params: {
206
242
  autonomousJobId: RecordIdInput
207
243
  threadId: RecordIdInput
208
244
  queueJobId?: RecordIdInput
209
245
  status?: AutonomousJobRunStatus
210
- }): Promise<AutonomousJobRunRow> {
211
- const runId = new RecordId(TABLES.AUTONOMOUS_JOB_RUN, Bun.randomUUIDv7())
212
- return databaseService.createWithId(
213
- TABLES.AUTONOMOUS_JOB_RUN,
214
- runId,
215
- compactRecord({
216
- autonomousJobId: ensureRecordId(params.autonomousJobId, TABLES.AUTONOMOUS_JOB),
217
- threadId: ensureRecordId(params.threadId, TABLES.THREAD),
218
- queueJobId: params.queueJobId ? ensureRecordId(params.queueJobId, TABLES.QUEUE_JOB) : undefined,
219
- status: params.status ?? 'queued',
220
- assistantMessageIds: [],
221
- }),
222
- AutonomousJobRunRowSchema,
223
- )
224
- }
246
+ },
247
+ ): Effect.Effect<AutonomousJobRunRow, AutonomousJobServiceError> {
248
+ const runId = new RecordId(TABLES.AUTONOMOUS_JOB_RUN, Bun.randomUUIDv7())
249
+ return effectTryPromise(
250
+ () =>
251
+ deps.db.createWithId(
252
+ TABLES.AUTONOMOUS_JOB_RUN,
253
+ runId,
254
+ compactRecord({
255
+ autonomousJobId: ensureRecordId(params.autonomousJobId, TABLES.AUTONOMOUS_JOB),
256
+ threadId: ensureRecordId(params.threadId, TABLES.THREAD),
257
+ queueJobId: params.queueJobId ? ensureRecordId(params.queueJobId, TABLES.QUEUE_JOB) : undefined,
258
+ status: params.status ?? 'queued',
259
+ assistantMessageIds: [],
260
+ }),
261
+ AutonomousJobRunRowSchema,
262
+ ),
263
+ 'Failed to create autonomous job run.',
264
+ )
265
+ }
225
266
 
226
- private async getRow(jobId: RecordIdInput): Promise<AutonomousJobRow> {
227
- await databaseService.connect()
228
- const row = await databaseService.findOne(
229
- TABLES.AUTONOMOUS_JOB,
230
- { id: ensureRecordId(jobId, TABLES.AUTONOMOUS_JOB) },
231
- AutonomousJobRowSchema,
267
+ function getRowEffect(
268
+ deps: AutonomousJobDeps,
269
+ jobId: RecordIdInput,
270
+ ): Effect.Effect<AutonomousJobRow, AutonomousJobServiceError> {
271
+ return Effect.gen(function* () {
272
+ yield* effectTryPromise(() => deps.db.connect(), 'Failed to connect to autonomous job database.')
273
+ const row = yield* effectTryPromise(
274
+ () =>
275
+ deps.db.findOne(
276
+ TABLES.AUTONOMOUS_JOB,
277
+ { id: ensureRecordId(jobId, TABLES.AUTONOMOUS_JOB) },
278
+ AutonomousJobRowSchema,
279
+ ),
280
+ 'Failed to load autonomous job.',
232
281
  )
233
282
  if (!row) {
234
- throw new Error(`Autonomous job not found: ${recordIdToString(jobId, TABLES.AUTONOMOUS_JOB)}`)
283
+ return yield* new AutonomousJobServiceError({
284
+ message: `Autonomous job not found: ${recordIdToString(jobId, TABLES.AUTONOMOUS_JOB)}`,
285
+ })
235
286
  }
236
287
  return row
237
- }
288
+ })
289
+ }
238
290
 
239
- private async getRunRow(runId: RecordIdInput): Promise<AutonomousJobRunRow> {
240
- await databaseService.connect()
241
- const row = await databaseService.findOne(
242
- TABLES.AUTONOMOUS_JOB_RUN,
243
- { id: ensureRecordId(runId, TABLES.AUTONOMOUS_JOB_RUN) },
244
- AutonomousJobRunRowSchema,
291
+ function getRunRowEffect(
292
+ deps: AutonomousJobDeps,
293
+ runId: RecordIdInput,
294
+ ): Effect.Effect<AutonomousJobRunRow, AutonomousJobServiceError> {
295
+ return Effect.gen(function* () {
296
+ yield* effectTryPromise(() => deps.db.connect(), 'Failed to connect to autonomous job database.')
297
+ const row = yield* effectTryPromise(
298
+ () =>
299
+ deps.db.findOne(
300
+ TABLES.AUTONOMOUS_JOB_RUN,
301
+ { id: ensureRecordId(runId, TABLES.AUTONOMOUS_JOB_RUN) },
302
+ AutonomousJobRunRowSchema,
303
+ ),
304
+ 'Failed to load autonomous job run.',
245
305
  )
246
306
  if (!row) {
247
- throw new Error(`Autonomous job run not found: ${recordIdToString(runId, TABLES.AUTONOMOUS_JOB_RUN)}`)
307
+ return yield* new AutonomousJobServiceError({
308
+ message: `Autonomous job run not found: ${recordIdToString(runId, TABLES.AUTONOMOUS_JOB_RUN)}`,
309
+ })
248
310
  }
249
311
  return row
250
- }
251
-
252
- private async unscheduleRow(row: AutonomousJobRow): Promise<void> {
253
- if (row.schedule.kind === 'at') {
254
- const { removeAutonomousAtJob } = await import('../queues/autonomous-job.queue')
255
- await removeAutonomousAtJob(recordIdToString(row.id, TABLES.AUTONOMOUS_JOB))
256
- return
257
- }
312
+ })
313
+ }
258
314
 
259
- const { removeAutonomousJobScheduler } = await import('../queues/autonomous-job.queue')
260
- await removeAutonomousJobScheduler(recordIdToString(row.id, TABLES.AUTONOMOUS_JOB))
315
+ function unscheduleRowEffect(row: AutonomousJobRow): Effect.Effect<void, AutonomousJobServiceError> {
316
+ if (row.schedule.kind === 'at') {
317
+ return Effect.gen(function* () {
318
+ const { removeAutonomousAtJob } = yield* effectTryPromise(
319
+ () => import('../queues/autonomous-job.queue'),
320
+ 'Failed to load autonomous job queue helpers.',
321
+ )
322
+ yield* effectTryPromise(
323
+ () => removeAutonomousAtJob(recordIdToString(row.id, TABLES.AUTONOMOUS_JOB)),
324
+ 'Failed to remove autonomous at-job.',
325
+ )
326
+ })
261
327
  }
262
328
 
263
- private async findRecoverableRunRow(autonomousJobId: RecordIdInput): Promise<AutonomousJobRunRow | null> {
264
- const rows = await databaseService.queryMany(
265
- new BoundQuery(
266
- `SELECT * FROM ${TABLES.AUTONOMOUS_JOB_RUN}
267
- WHERE autonomousJobId = $autonomousJobId
268
- AND status IN $statuses
269
- ORDER BY createdAt DESC
270
- LIMIT 1`,
271
- { autonomousJobId: ensureRecordId(autonomousJobId, TABLES.AUTONOMOUS_JOB), statuses: ['queued', 'running'] },
272
- ),
273
- AutonomousJobRunRowSchema,
329
+ return Effect.gen(function* () {
330
+ const { removeAutonomousJobScheduler } = yield* effectTryPromise(
331
+ () => import('../queues/autonomous-job.queue'),
332
+ 'Failed to load autonomous job queue helpers.',
333
+ )
334
+ yield* effectTryPromise(
335
+ () => removeAutonomousJobScheduler(recordIdToString(row.id, TABLES.AUTONOMOUS_JOB)),
336
+ 'Failed to remove autonomous job scheduler.',
274
337
  )
338
+ })
339
+ }
275
340
 
341
+ function findRecoverableRunRowEffect(
342
+ deps: AutonomousJobDeps,
343
+ autonomousJobId: RecordIdInput,
344
+ ): Effect.Effect<AutonomousJobRunRow | null, AutonomousJobServiceError> {
345
+ return Effect.gen(function* () {
346
+ const rows = yield* effectTryPromise(
347
+ () =>
348
+ deps.db.queryMany(
349
+ new BoundQuery(
350
+ `SELECT * FROM ${TABLES.AUTONOMOUS_JOB_RUN}
351
+ WHERE autonomousJobId = $autonomousJobId
352
+ AND status IN $statuses
353
+ ORDER BY createdAt DESC
354
+ LIMIT 1`,
355
+ {
356
+ autonomousJobId: ensureRecordId(autonomousJobId, TABLES.AUTONOMOUS_JOB),
357
+ statuses: ['queued', 'running'],
358
+ },
359
+ ),
360
+ AutonomousJobRunRowSchema,
361
+ ),
362
+ 'Failed to find recoverable autonomous job run.',
363
+ )
276
364
  return rows[0] ?? null
277
- }
365
+ })
366
+ }
278
367
 
279
- private async scheduleRow(
280
- row: AutonomousJobRow,
281
- options: { runImmediate?: boolean; referenceTime?: Date; reusePendingAtRun?: boolean } = {},
282
- ): Promise<void> {
283
- const jobId = recordIdToString(row.id, TABLES.AUTONOMOUS_JOB)
284
- const referenceTime = options.referenceTime ?? new Date()
285
- const nextRunAt =
286
- row.schedule.kind === 'at'
287
- ? (row.nextRunAt ?? this.computeNextRunAt(row.schedule, referenceTime))
288
- : this.computeNextRunAt(row.schedule, referenceTime)
289
-
290
- if (row.schedule.kind === 'at') {
291
- const queuedRun = options.reusePendingAtRun
292
- ? ((await this.findRecoverableRunRow(row.id)) ??
293
- (await this.createRunRow({ autonomousJobId: row.id, threadId: row.threadId })))
294
- : await this.createRunRow({ autonomousJobId: row.id, threadId: row.threadId })
295
- const { enqueueAutonomousJobRun } = await import('../queues/autonomous-job.queue')
296
- const enqueueResult = await enqueueAutonomousJobRun({
297
- payload: {
298
- autonomousJobId: jobId,
299
- autonomousJobRunId: recordIdToString(queuedRun.id, TABLES.AUTONOMOUS_JOB_RUN),
300
- trigger: 'scheduled',
301
- },
302
- delayMs: Math.max(0, (nextRunAt ?? referenceTime).getTime() - referenceTime.getTime()),
303
- jobId: buildAutonomousAtJobId(jobId),
304
- })
368
+ function scheduleRowEffect(
369
+ deps: AutonomousJobDeps,
370
+ row: AutonomousJobRow,
371
+ options: { runImmediate?: boolean; referenceTime?: Date; reusePendingAtRun?: boolean } = {},
372
+ ): Effect.Effect<void, AutonomousJobServiceError> {
373
+ const jobId = recordIdToString(row.id, TABLES.AUTONOMOUS_JOB)
374
+ const referenceTime = options.referenceTime ?? nowDate()
375
+ const nextRunAt =
376
+ row.schedule.kind === 'at'
377
+ ? (row.nextRunAt ?? computeNextRunAt(row.schedule, referenceTime))
378
+ : computeNextRunAt(row.schedule, referenceTime)
305
379
 
306
- if (enqueueResult.queueJobId) {
307
- await databaseService.update(
308
- TABLES.AUTONOMOUS_JOB_RUN,
309
- queuedRun.id,
310
- { queueJobId: ensureRecordId(enqueueResult.queueJobId, TABLES.QUEUE_JOB) },
311
- AutonomousJobRunRowSchema,
380
+ if (row.schedule.kind === 'at') {
381
+ return Effect.gen(function* () {
382
+ const queuedRun = yield* options.reusePendingAtRun
383
+ ? Effect.gen(function* () {
384
+ const recoverable = yield* findRecoverableRunRowEffect(deps, row.id)
385
+ return recoverable ?? (yield* createRunRowEffect(deps, { autonomousJobId: row.id, threadId: row.threadId }))
386
+ })
387
+ : createRunRowEffect(deps, { autonomousJobId: row.id, threadId: row.threadId })
388
+
389
+ const { enqueueAutonomousJobRun } = yield* effectTryPromise(
390
+ () => import('../queues/autonomous-job.queue'),
391
+ 'Failed to load autonomous job queue helpers.',
392
+ )
393
+
394
+ const enqueueResult = yield* effectTryPromise(
395
+ () =>
396
+ enqueueAutonomousJobRun({
397
+ payload: {
398
+ autonomousJobId: jobId,
399
+ autonomousJobRunId: recordIdToString(queuedRun.id, TABLES.AUTONOMOUS_JOB_RUN),
400
+ trigger: 'scheduled',
401
+ },
402
+ delayMs: Math.max(0, (nextRunAt ?? referenceTime).getTime() - referenceTime.getTime()),
403
+ jobId: buildAutonomousAtJobId(jobId),
404
+ }),
405
+ 'Failed to enqueue autonomous job run.',
406
+ )
407
+
408
+ const queueJobId = enqueueResult.queueJobId
409
+ if (queueJobId) {
410
+ yield* effectTryPromise(
411
+ () =>
412
+ deps.db.update(
413
+ TABLES.AUTONOMOUS_JOB_RUN,
414
+ queuedRun.id,
415
+ { queueJobId: ensureRecordId(queueJobId, TABLES.QUEUE_JOB) },
416
+ AutonomousJobRunRowSchema,
417
+ ),
418
+ 'Failed to persist autonomous job run queue job id.',
312
419
  )
313
420
  }
314
- } else {
315
- const { upsertAutonomousJobScheduler } = await import('../queues/autonomous-job.queue')
316
- await upsertAutonomousJobScheduler({ autonomousJobId: jobId, schedule: row.schedule })
317
421
 
318
- if (options.runImmediate ?? row.schedule.immediately) {
319
- await this.runNow(jobId)
422
+ yield* effectTryPromise(
423
+ () =>
424
+ deps.db.update(TABLES.AUTONOMOUS_JOB, row.id, { nextRunAt: nextRunAt ?? undefined }, AutonomousJobRowSchema),
425
+ 'Failed to update autonomous job next run time.',
426
+ )
427
+ })
428
+ } else {
429
+ const recurringSchedule = row.schedule
430
+ return Effect.gen(function* () {
431
+ const { upsertAutonomousJobScheduler } = yield* effectTryPromise(
432
+ () => import('../queues/autonomous-job.queue'),
433
+ 'Failed to load autonomous job queue helpers.',
434
+ )
435
+
436
+ yield* effectTryPromise(
437
+ () => upsertAutonomousJobScheduler({ autonomousJobId: jobId, schedule: recurringSchedule }),
438
+ 'Failed to upsert autonomous job scheduler.',
439
+ )
440
+
441
+ if (options.runImmediate ?? recurringSchedule.immediately) {
442
+ yield* runNowEffect(deps, jobId).pipe(Effect.asVoid)
320
443
  }
321
- }
322
444
 
323
- await databaseService.update(
324
- TABLES.AUTONOMOUS_JOB,
325
- row.id,
326
- { nextRunAt: nextRunAt ?? undefined },
327
- AutonomousJobRowSchema,
328
- )
445
+ yield* effectTryPromise(
446
+ () =>
447
+ deps.db.update(TABLES.AUTONOMOUS_JOB, row.id, { nextRunAt: nextRunAt ?? undefined }, AutonomousJobRowSchema),
448
+ 'Failed to update autonomous job next run time.',
449
+ )
450
+ })
329
451
  }
452
+ }
330
453
 
331
- async create(input: CreateAutonomousJobInput): Promise<AutonomousJob> {
332
- await databaseService.connect()
454
+ function createEffect(
455
+ deps: AutonomousJobDeps,
456
+ input: CreateAutonomousJobInput,
457
+ ): Effect.Effect<AutonomousJob, AutonomousJobServiceError> {
458
+ return Effect.gen(function* () {
459
+ yield* effectTryPromise(() => deps.db.connect(), 'Failed to connect to autonomous job database.')
333
460
  const parsed = CreateAutonomousJobInputSchema.parse(input)
334
461
  const organizationId = ensureRecordId(parsed.organizationId, TABLES.ORGANIZATION)
335
462
  const ownerUserId = ensureRecordId(parsed.ownerUserId, TABLES.USER)
336
- const thread = await threadService.createThread({
337
- userId: ownerUserId,
338
- organizationId,
339
- type: 'group',
340
- title: parsed.threadTitle ?? parsed.title,
341
- })
463
+ const thread = yield* deps.thread
464
+ .createThread({ userId: ownerUserId, organizationId, type: 'group', title: parsed.threadTitle ?? parsed.title })
465
+ .pipe(
466
+ Effect.mapError(
467
+ (cause) => new AutonomousJobServiceError({ message: 'Failed to create autonomous job thread.', cause }),
468
+ ),
469
+ )
470
+
342
471
  const jobId = new RecordId(TABLES.AUTONOMOUS_JOB, Bun.randomUUIDv7())
343
- const nextRunAt = this.computeNextRunAt(parsed.schedule)
344
- const created = await databaseService.createWithId(
345
- TABLES.AUTONOMOUS_JOB,
346
- jobId,
347
- compactRecord({
348
- organizationId,
349
- ownerUserId,
350
- ownerUserName: parsed.ownerUserName,
351
- threadId: ensureRecordId(thread.id, TABLES.THREAD),
352
- agentId: parsed.agentId,
353
- title: parsed.title,
354
- prompt: parsed.prompt,
355
- schedule: parsed.schedule,
356
- status: 'active',
357
- autoPauseThreshold: parsed.autoPauseThreshold,
358
- consecutiveErrorCount: 0,
359
- nextRunAt: nextRunAt ?? undefined,
360
- }),
361
- AutonomousJobRowSchema,
472
+ const nextRunAt = computeNextRunAt(parsed.schedule)
473
+ const created = yield* effectTryPromise(
474
+ () =>
475
+ deps.db.createWithId(
476
+ TABLES.AUTONOMOUS_JOB,
477
+ jobId,
478
+ compactRecord({
479
+ organizationId,
480
+ ownerUserId,
481
+ ownerUserName: parsed.ownerUserName,
482
+ threadId: ensureRecordId(thread.id, TABLES.THREAD),
483
+ agentId: parsed.agentId,
484
+ title: parsed.title,
485
+ prompt: parsed.prompt,
486
+ schedule: parsed.schedule,
487
+ status: 'active',
488
+ autoPauseThreshold: parsed.autoPauseThreshold,
489
+ consecutiveErrorCount: 0,
490
+ nextRunAt: nextRunAt ?? undefined,
491
+ }),
492
+ AutonomousJobRowSchema,
493
+ ),
494
+ 'Failed to create autonomous job.',
362
495
  )
363
496
 
364
- await this.scheduleRow(created)
365
- return this.toPublicJob(await this.getRow(created.id))
366
- }
497
+ yield* scheduleRowEffect(deps, created)
498
+ const row = yield* getRowEffect(deps, created.id)
499
+ return toPublicJob(row)
500
+ })
501
+ }
367
502
 
368
- async recoverActiveJobs(now = new Date()): Promise<void> {
369
- await databaseService.connect()
370
- const activeRows = await databaseService.queryMany(
371
- new BoundQuery(`SELECT * FROM ${TABLES.AUTONOMOUS_JOB} WHERE status = $status ORDER BY createdAt ASC`, {
372
- status: 'active',
373
- }),
374
- AutonomousJobRowSchema,
503
+ function recoverActiveJobsEffect(
504
+ deps: AutonomousJobDeps,
505
+ now = nowDate(),
506
+ ): Effect.Effect<void, AutonomousJobServiceError> {
507
+ return Effect.gen(function* () {
508
+ yield* effectTryPromise(() => deps.db.connect(), 'Failed to connect to autonomous job database.')
509
+ const activeRows = yield* effectTryPromise(
510
+ () =>
511
+ deps.db.queryMany(
512
+ new BoundQuery(`SELECT * FROM ${TABLES.AUTONOMOUS_JOB} WHERE status = $status ORDER BY createdAt ASC`, {
513
+ status: 'active',
514
+ }),
515
+ AutonomousJobRowSchema,
516
+ ),
517
+ 'Failed to load active autonomous jobs.',
375
518
  )
519
+ yield* Effect.forEach(activeRows, (row) =>
520
+ scheduleRowEffect(deps, row, { runImmediate: false, referenceTime: now, reusePendingAtRun: true }),
521
+ )
522
+ })
523
+ }
376
524
 
377
- for (const row of activeRows) {
378
- await this.scheduleRow(row, { runImmediate: false, referenceTime: now, reusePendingAtRun: true })
379
- }
380
- }
381
-
382
- async get(jobId: RecordIdInput): Promise<AutonomousJob> {
383
- return this.toPublicJob(await this.getRow(jobId))
384
- }
525
+ function getEffect(
526
+ deps: AutonomousJobDeps,
527
+ jobId: RecordIdInput,
528
+ ): Effect.Effect<AutonomousJob, AutonomousJobServiceError> {
529
+ return Effect.gen(function* () {
530
+ const row = yield* getRowEffect(deps, jobId)
531
+ return toPublicJob(row)
532
+ })
533
+ }
385
534
 
386
- async list(params: {
387
- organizationId: RecordIdInput
388
- ownerUserId?: RecordIdInput
389
- status?: AutonomousJobStatus
390
- }): Promise<AutonomousJob[]> {
391
- await databaseService.connect()
392
- const rows = await databaseService.findMany(
393
- TABLES.AUTONOMOUS_JOB,
394
- compactRecord({
395
- organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
396
- ownerUserId: params.ownerUserId ? ensureRecordId(params.ownerUserId, TABLES.USER) : undefined,
397
- status: params.status,
398
- }),
399
- AutonomousJobRowSchema,
400
- { orderBy: 'createdAt', orderDir: 'DESC' },
535
+ function listEffect(
536
+ deps: AutonomousJobDeps,
537
+ params: { organizationId: RecordIdInput; ownerUserId?: RecordIdInput; status?: AutonomousJobStatus },
538
+ ): Effect.Effect<AutonomousJob[], AutonomousJobServiceError> {
539
+ return Effect.gen(function* () {
540
+ yield* effectTryPromise(() => deps.db.connect(), 'Failed to connect to autonomous job database.')
541
+ const rows = yield* effectTryPromise(
542
+ () =>
543
+ deps.db.findMany(
544
+ TABLES.AUTONOMOUS_JOB,
545
+ compactRecord({
546
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
547
+ ownerUserId: params.ownerUserId ? ensureRecordId(params.ownerUserId, TABLES.USER) : undefined,
548
+ status: params.status,
549
+ }),
550
+ AutonomousJobRowSchema,
551
+ { orderBy: 'createdAt', orderDir: 'DESC' },
552
+ ),
553
+ 'Failed to list autonomous jobs.',
401
554
  )
402
- return rows.map((row) => this.toPublicJob(row))
403
- }
555
+ return rows.map((row) => toPublicJob(row))
556
+ })
557
+ }
404
558
 
405
- async update(jobId: RecordIdInput, input: UpdateAutonomousJobInput): Promise<AutonomousJob> {
559
+ function updateEffect(
560
+ deps: AutonomousJobDeps,
561
+ jobId: RecordIdInput,
562
+ input: UpdateAutonomousJobInput,
563
+ ): Effect.Effect<AutonomousJob, AutonomousJobServiceError> {
564
+ return Effect.gen(function* () {
406
565
  const parsed = UpdateAutonomousJobInputSchema.parse(input)
407
- const existing = await this.getRow(jobId)
408
- await this.unscheduleRow(existing)
409
-
410
- if (parsed.title && compactWhitespace(parsed.title) !== compactWhitespace(existing.title)) {
411
- await threadService.updateTitle(existing.threadId, parsed.title)
566
+ const existing = yield* getRowEffect(deps, jobId)
567
+ yield* unscheduleRowEffect(existing)
568
+ const nextTitle = parsed.title
569
+ if (typeof nextTitle === 'string' && compactWhitespace(nextTitle) !== compactWhitespace(existing.title)) {
570
+ const title = nextTitle
571
+ yield* deps.thread
572
+ .updateTitle(existing.threadId, title)
573
+ .pipe(
574
+ Effect.mapError(
575
+ (cause) =>
576
+ new AutonomousJobServiceError({ message: 'Failed to update autonomous job thread title.', cause }),
577
+ ),
578
+ )
412
579
  }
413
580
 
414
- const nextRunAt = this.computeNextRunAt(parsed.schedule ?? existing.schedule)
415
- const updated = await databaseService.update(
416
- TABLES.AUTONOMOUS_JOB,
417
- existing.id,
418
- compactRecord({
419
- title: parsed.title,
420
- prompt: parsed.prompt,
421
- schedule: parsed.schedule,
422
- autoPauseThreshold: parsed.autoPauseThreshold,
423
- nextRunAt: existing.status === 'active' ? (nextRunAt ?? undefined) : undefined,
424
- }),
425
- AutonomousJobRowSchema,
581
+ const nextRunAt = computeNextRunAt(parsed.schedule ?? existing.schedule)
582
+ const updated = yield* effectTryPromise(
583
+ () =>
584
+ deps.db.update(
585
+ TABLES.AUTONOMOUS_JOB,
586
+ existing.id,
587
+ compactRecord({
588
+ title: parsed.title,
589
+ prompt: parsed.prompt,
590
+ schedule: parsed.schedule,
591
+ autoPauseThreshold: parsed.autoPauseThreshold,
592
+ nextRunAt: existing.status === 'active' ? (nextRunAt ?? undefined) : undefined,
593
+ }),
594
+ AutonomousJobRowSchema,
595
+ ),
596
+ 'Failed to update autonomous job.',
426
597
  )
427
598
  if (!updated) {
428
- throw new Error(`Failed to update autonomous job: ${recordIdToString(existing.id, TABLES.AUTONOMOUS_JOB)}`)
599
+ return yield* new AutonomousJobServiceError({
600
+ message: `Failed to update autonomous job: ${recordIdToString(existing.id, TABLES.AUTONOMOUS_JOB)}`,
601
+ })
429
602
  }
430
603
 
431
604
  if (updated.status === 'active') {
432
- await this.scheduleRow(updated)
605
+ yield* scheduleRowEffect(deps, updated)
433
606
  }
434
- return this.toPublicJob(await this.getRow(updated.id))
435
- }
436
607
 
437
- async pause(jobId: RecordIdInput): Promise<AutonomousJob> {
438
- const row = await this.getRow(jobId)
439
- await this.unscheduleRow(row)
440
- await databaseService.update(
441
- TABLES.AUTONOMOUS_JOB,
442
- row.id,
443
- { status: 'paused', nextRunAt: undefined },
444
- AutonomousJobRowSchema,
608
+ const row = yield* getRowEffect(deps, updated.id)
609
+ return toPublicJob(row)
610
+ })
611
+ }
612
+
613
+ function pauseEffect(
614
+ deps: AutonomousJobDeps,
615
+ jobId: RecordIdInput,
616
+ ): Effect.Effect<AutonomousJob, AutonomousJobServiceError> {
617
+ return Effect.gen(function* () {
618
+ const row = yield* getRowEffect(deps, jobId)
619
+ yield* unscheduleRowEffect(row)
620
+ yield* effectTryPromise(
621
+ () =>
622
+ deps.db.update(
623
+ TABLES.AUTONOMOUS_JOB,
624
+ row.id,
625
+ { status: 'paused', nextRunAt: undefined },
626
+ AutonomousJobRowSchema,
627
+ ),
628
+ 'Failed to pause autonomous job.',
445
629
  )
446
- return this.toPublicJob(await this.getRow(row.id))
447
- }
630
+ const updatedRow = yield* getRowEffect(deps, row.id)
631
+ return toPublicJob(updatedRow)
632
+ })
633
+ }
448
634
 
449
- async resume(jobId: RecordIdInput): Promise<AutonomousJob> {
450
- const row = await this.getRow(jobId)
451
- const nextRunAt = this.computeNextRunAt(row.schedule)
452
- const resumed = await databaseService.update(
453
- TABLES.AUTONOMOUS_JOB,
454
- row.id,
455
- { status: 'active', nextRunAt: nextRunAt ?? undefined },
456
- AutonomousJobRowSchema,
635
+ function resumeEffect(
636
+ deps: AutonomousJobDeps,
637
+ jobId: RecordIdInput,
638
+ ): Effect.Effect<AutonomousJob, AutonomousJobServiceError> {
639
+ return Effect.gen(function* () {
640
+ const row = yield* getRowEffect(deps, jobId)
641
+ const nextRunAt = computeNextRunAt(row.schedule)
642
+ const resumed = yield* effectTryPromise(
643
+ () =>
644
+ deps.db.update(
645
+ TABLES.AUTONOMOUS_JOB,
646
+ row.id,
647
+ { status: 'active', nextRunAt: nextRunAt ?? undefined },
648
+ AutonomousJobRowSchema,
649
+ ),
650
+ 'Failed to resume autonomous job.',
457
651
  )
458
652
  if (!resumed) {
459
- throw new Error(`Failed to resume autonomous job: ${recordIdToString(row.id, TABLES.AUTONOMOUS_JOB)}`)
653
+ return yield* new AutonomousJobServiceError({
654
+ message: `Failed to resume autonomous job: ${recordIdToString(row.id, TABLES.AUTONOMOUS_JOB)}`,
655
+ })
460
656
  }
461
- await this.scheduleRow(resumed)
462
- return this.toPublicJob(await this.getRow(resumed.id))
463
- }
657
+ yield* scheduleRowEffect(deps, resumed)
658
+ const updatedRow = yield* getRowEffect(deps, resumed.id)
659
+ return toPublicJob(updatedRow)
660
+ })
661
+ }
464
662
 
465
- async runNow(jobId: RecordIdInput): Promise<AutonomousJobRun> {
466
- const row = await this.getRow(jobId)
467
- const queuedRun = await this.createRunRow({ autonomousJobId: row.id, threadId: row.threadId })
468
- const { enqueueAutonomousJobRun } = await import('../queues/autonomous-job.queue')
469
- const enqueueResult = await enqueueAutonomousJobRun({
470
- payload: {
471
- autonomousJobId: recordIdToString(row.id, TABLES.AUTONOMOUS_JOB),
472
- autonomousJobRunId: recordIdToString(queuedRun.id, TABLES.AUTONOMOUS_JOB_RUN),
473
- trigger: 'manual',
474
- },
475
- jobId: buildAutonomousManualJobId(recordIdToString(row.id, TABLES.AUTONOMOUS_JOB)),
476
- })
663
+ function runNowEffect(
664
+ deps: AutonomousJobDeps,
665
+ jobId: RecordIdInput,
666
+ ): Effect.Effect<AutonomousJobRun, AutonomousJobServiceError> {
667
+ return Effect.gen(function* () {
668
+ const row = yield* getRowEffect(deps, jobId)
669
+ const queuedRun = yield* createRunRowEffect(deps, { autonomousJobId: row.id, threadId: row.threadId })
670
+ const { enqueueAutonomousJobRun } = yield* effectTryPromise(
671
+ () => import('../queues/autonomous-job.queue'),
672
+ 'Failed to load autonomous job queue helpers.',
673
+ )
477
674
 
478
- if (enqueueResult.queueJobId) {
479
- await databaseService.update(
480
- TABLES.AUTONOMOUS_JOB_RUN,
481
- queuedRun.id,
482
- { queueJobId: ensureRecordId(enqueueResult.queueJobId, TABLES.QUEUE_JOB) },
483
- AutonomousJobRunRowSchema,
675
+ const enqueueResult = yield* effectTryPromise(
676
+ () =>
677
+ enqueueAutonomousJobRun({
678
+ payload: {
679
+ autonomousJobId: recordIdToString(row.id, TABLES.AUTONOMOUS_JOB),
680
+ autonomousJobRunId: recordIdToString(queuedRun.id, TABLES.AUTONOMOUS_JOB_RUN),
681
+ trigger: 'manual',
682
+ },
683
+ jobId: buildAutonomousManualJobId(recordIdToString(row.id, TABLES.AUTONOMOUS_JOB)),
684
+ }),
685
+ 'Failed to enqueue autonomous job run.',
686
+ )
687
+
688
+ const queueJobId = enqueueResult.queueJobId
689
+ if (queueJobId) {
690
+ yield* effectTryPromise(
691
+ () =>
692
+ deps.db.update(
693
+ TABLES.AUTONOMOUS_JOB_RUN,
694
+ queuedRun.id,
695
+ { queueJobId: ensureRecordId(queueJobId, TABLES.QUEUE_JOB) },
696
+ AutonomousJobRunRowSchema,
697
+ ),
698
+ 'Failed to persist autonomous job run queue job id.',
484
699
  )
485
700
  }
486
701
 
487
- return this.toPublicRun(await this.getRunRow(queuedRun.id))
488
- }
702
+ const runRow = yield* getRunRowEffect(deps, queuedRun.id)
703
+ return toPublicRun(runRow)
704
+ })
705
+ }
489
706
 
490
- async cancel(jobId: RecordIdInput): Promise<AutonomousJob> {
491
- const row = await this.getRow(jobId)
492
- await this.unscheduleRow(row)
493
- await databaseService.update(
494
- TABLES.AUTONOMOUS_JOB,
495
- row.id,
496
- { status: 'cancelled', nextRunAt: undefined },
497
- AutonomousJobRowSchema,
707
+ function cancelEffect(
708
+ deps: AutonomousJobDeps,
709
+ jobId: RecordIdInput,
710
+ ): Effect.Effect<AutonomousJob, AutonomousJobServiceError> {
711
+ return Effect.gen(function* () {
712
+ const row = yield* getRowEffect(deps, jobId)
713
+ yield* unscheduleRowEffect(row)
714
+ yield* effectTryPromise(
715
+ () =>
716
+ deps.db.update(
717
+ TABLES.AUTONOMOUS_JOB,
718
+ row.id,
719
+ { status: 'cancelled', nextRunAt: undefined },
720
+ AutonomousJobRowSchema,
721
+ ),
722
+ 'Failed to cancel autonomous job.',
498
723
  )
499
- return this.toPublicJob(await this.getRow(row.id))
500
- }
724
+ const updatedRow = yield* getRowEffect(deps, row.id)
725
+ return toPublicJob(updatedRow)
726
+ })
727
+ }
501
728
 
502
- async delete(jobId: RecordIdInput): Promise<AutonomousJob> {
503
- const row = await this.getRow(jobId)
504
- const cancelled = await this.cancel(row.id)
505
- await threadService.updateStatus(row.threadId, 'archived')
729
+ function deleteJobEffect(
730
+ deps: AutonomousJobDeps,
731
+ jobId: RecordIdInput,
732
+ ): Effect.Effect<AutonomousJob, AutonomousJobServiceError> {
733
+ return Effect.gen(function* () {
734
+ const row = yield* getRowEffect(deps, jobId)
735
+ const cancelled = yield* cancelEffect(deps, row.id)
736
+ yield* deps.thread
737
+ .updateStatus(row.threadId, 'archived')
738
+ .pipe(
739
+ Effect.mapError(
740
+ (cause) => new AutonomousJobServiceError({ message: 'Failed to archive autonomous job thread.', cause }),
741
+ ),
742
+ )
506
743
  return cancelled
507
- }
744
+ })
745
+ }
508
746
 
509
- async listRuns(jobId: RecordIdInput): Promise<AutonomousJobRun[]> {
510
- await databaseService.connect()
511
- const rows = await databaseService.findMany(
512
- TABLES.AUTONOMOUS_JOB_RUN,
513
- { autonomousJobId: ensureRecordId(jobId, TABLES.AUTONOMOUS_JOB) },
514
- AutonomousJobRunRowSchema,
515
- { orderBy: 'createdAt', orderDir: 'DESC' },
747
+ function listRunsEffect(
748
+ deps: AutonomousJobDeps,
749
+ jobId: RecordIdInput,
750
+ ): Effect.Effect<AutonomousJobRun[], AutonomousJobServiceError> {
751
+ return Effect.gen(function* () {
752
+ yield* effectTryPromise(() => deps.db.connect(), 'Failed to connect to autonomous job database.')
753
+ const rows = yield* effectTryPromise(
754
+ () =>
755
+ deps.db.findMany(
756
+ TABLES.AUTONOMOUS_JOB_RUN,
757
+ { autonomousJobId: ensureRecordId(jobId, TABLES.AUTONOMOUS_JOB) },
758
+ AutonomousJobRunRowSchema,
759
+ { orderBy: 'createdAt', orderDir: 'DESC' },
760
+ ),
761
+ 'Failed to list autonomous job runs.',
516
762
  )
517
- return rows.map((row) => this.toPublicRun(row))
518
- }
519
-
520
- async executeQueuedRun(job: Job<AutonomousJobQueuePayload>): Promise<{ status: string; summary?: string }> {
521
- await databaseService.connect()
763
+ return rows.map((row) => toPublicRun(row))
764
+ })
765
+ }
522
766
 
523
- const autonomousJobRow = await this.getRow(job.data.autonomousJobId)
524
- const queueJobId = queueJobService.getQueueJobId(AUTONOMOUS_JOB_QUEUE_NAME, String(job.id))
525
- let runRow =
767
+ function getOrCreateQueuedRunRowEffect(
768
+ deps: AutonomousJobDeps,
769
+ job: Job<AutonomousJobQueuePayload>,
770
+ currentJobRow: AutonomousJobRow,
771
+ ): Effect.Effect<{ queueJobId: string; runRow: AutonomousJobRunRow }, AutonomousJobServiceError> {
772
+ return Effect.gen(function* () {
773
+ const queueJobId = deps.queueJob.getQueueJobId(AUTONOMOUS_JOB_QUEUE_NAME, String(job.id))
774
+ const runRow =
526
775
  job.data.autonomousJobRunId !== undefined
527
- ? await this.getRunRow(job.data.autonomousJobRunId)
528
- : await this.createRunRow({
529
- autonomousJobId: autonomousJobRow.id,
530
- threadId: autonomousJobRow.threadId,
776
+ ? yield* getRunRowEffect(deps, job.data.autonomousJobRunId)
777
+ : yield* createRunRowEffect(deps, {
778
+ autonomousJobId: currentJobRow.id,
779
+ threadId: currentJobRow.threadId,
531
780
  queueJobId,
532
781
  status: 'queued',
533
782
  })
534
783
 
535
- const activeStatus = autonomousJobRow.status
536
- if (activeStatus !== 'active' && job.data.trigger === 'scheduled') {
537
- return { status: 'skipped' }
784
+ return { queueJobId, runRow }
785
+ })
786
+ }
787
+
788
+ function markQueuedRunRunningEffect(
789
+ deps: AutonomousJobDeps,
790
+ runRow: AutonomousJobRunRow,
791
+ queueJobId: string,
792
+ ): Effect.Effect<AutonomousJobRunRow, AutonomousJobServiceError> {
793
+ const startedAt = nowDate()
794
+
795
+ return Effect.gen(function* () {
796
+ const updatedRunRow = yield* effectTryPromise(
797
+ () =>
798
+ deps.db.update(
799
+ TABLES.AUTONOMOUS_JOB_RUN,
800
+ runRow.id,
801
+ { queueJobId: ensureRecordId(queueJobId, TABLES.QUEUE_JOB), status: 'running', startedAt },
802
+ AutonomousJobRunRowSchema,
803
+ ),
804
+ 'Failed to mark autonomous job run as running.',
805
+ )
806
+
807
+ return updatedRunRow ?? runRow
808
+ })
809
+ }
810
+
811
+ function completeQueuedRunEffect(
812
+ deps: AutonomousJobDeps,
813
+ params: {
814
+ job: Job<AutonomousJobQueuePayload>
815
+ currentJobRow: AutonomousJobRow
816
+ currentRunRow: AutonomousJobRunRow
817
+ turnResult: PreparedThreadTurnResult
818
+ activePlan: ActiveExecutionPlan
819
+ },
820
+ ): Effect.Effect<{ status: AutonomousJobRunStatus; summary: string }, AutonomousJobServiceError> {
821
+ return Effect.gen(function* () {
822
+ const { job, currentJobRow, currentRunRow, turnResult, activePlan } = params
823
+ const runStatus: AutonomousJobRunStatus = activePlan?.status === 'awaiting-human' ? 'awaiting-human' : 'completed'
824
+ const summary = truncateText(
825
+ turnResult.assistantMessages
826
+ .map((message) => extractMessageText(message))
827
+ .filter(Boolean)
828
+ .join('\n\n')
829
+ .trim() || `${currentJobRow.title} completed.`,
830
+ 2_000,
831
+ )
832
+ const completedAt = nowDate()
833
+
834
+ void (yield* effectTryPromise(
835
+ () =>
836
+ deps.db.update(
837
+ TABLES.AUTONOMOUS_JOB_RUN,
838
+ currentRunRow.id,
839
+ compactRecord({
840
+ status: runStatus,
841
+ inputMessageId: turnResult.inputMessageId,
842
+ assistantMessageIds: turnResult.assistantMessages.map((message) => message.id),
843
+ summary,
844
+ linkedPlanSpecId: activePlan?.specId ? ensureRecordId(activePlan.specId, TABLES.PLAN_SPEC) : undefined,
845
+ linkedPlanRunId: activePlan?.runId ? ensureRecordId(activePlan.runId, TABLES.PLAN_RUN) : undefined,
846
+ completedAt,
847
+ }),
848
+ AutonomousJobRunRowSchema,
849
+ ),
850
+ 'Failed to update autonomous job run completion.',
851
+ ))
852
+
853
+ const nextRunAt =
854
+ job.data.trigger === 'scheduled' && currentJobRow.schedule.kind !== 'at'
855
+ ? computeNextRunAt(currentJobRow.schedule, completedAt)
856
+ : currentJobRow.nextRunAt
857
+ const nextStatus =
858
+ currentJobRow.schedule.kind === 'at' && job.data.trigger === 'scheduled' ? 'completed' : currentJobRow.status
859
+
860
+ yield* effectTryPromise(
861
+ () =>
862
+ deps.db.update(
863
+ TABLES.AUTONOMOUS_JOB,
864
+ currentJobRow.id,
865
+ compactRecord({
866
+ status: nextStatus,
867
+ consecutiveErrorCount: 0,
868
+ lastRunStatus: runStatus,
869
+ lastRunAt: completedAt,
870
+ nextRunAt: nextStatus === 'active' ? (nextRunAt ?? undefined) : undefined,
871
+ linkedPlanSpecId: activePlan?.specId ? ensureRecordId(activePlan.specId, TABLES.PLAN_SPEC) : undefined,
872
+ linkedPlanRunId: activePlan?.runId ? ensureRecordId(activePlan.runId, TABLES.PLAN_RUN) : undefined,
873
+ lastError: undefined,
874
+ }),
875
+ AutonomousJobRowSchema,
876
+ ),
877
+ 'Failed to update autonomous job after successful run.',
878
+ )
879
+
880
+ yield* maybeNotifyEffect(deps, {
881
+ organizationId: recordIdToString(currentJobRow.organizationId, TABLES.ORGANIZATION),
882
+ threadId: recordIdToString(currentJobRow.threadId, TABLES.THREAD),
883
+ severity: 'info',
884
+ title: `${currentJobRow.title} completed`,
885
+ body: summary,
886
+ metadata: {
887
+ autonomousJobId: params.job.data.autonomousJobId,
888
+ runId: recordIdToString(currentRunRow.id, TABLES.AUTONOMOUS_JOB_RUN),
889
+ },
890
+ })
891
+
892
+ return { status: runStatus, summary }
893
+ })
894
+ }
895
+
896
+ function failQueuedRunEffect(
897
+ deps: AutonomousJobDeps,
898
+ params: {
899
+ job: Job<AutonomousJobQueuePayload>
900
+ currentJobRow: AutonomousJobRow
901
+ currentRunRow: AutonomousJobRunRow
902
+ error: unknown
903
+ },
904
+ ): Effect.Effect<never, AutonomousJobServiceError> {
905
+ return Effect.gen(function* () {
906
+ const { job, currentJobRow, currentRunRow, error } = params
907
+ const normalizedError = toQueueJobError(error)
908
+ const completedAt = nowDate()
909
+ const nextConsecutiveErrorCount = currentJobRow.consecutiveErrorCount + 1
910
+ const autoPause = nextConsecutiveErrorCount >= currentJobRow.autoPauseThreshold
911
+ const terminalOneShot = currentJobRow.schedule.kind === 'at' && job.data.trigger === 'scheduled'
912
+ const nextStatus: AutonomousJobStatus = terminalOneShot ? 'failed' : autoPause ? 'paused' : currentJobRow.status
913
+
914
+ yield* effectTryPromise(
915
+ () =>
916
+ deps.db.update(
917
+ TABLES.AUTONOMOUS_JOB_RUN,
918
+ currentRunRow.id,
919
+ { status: 'failed', error: normalizedError, completedAt },
920
+ AutonomousJobRunRowSchema,
921
+ ),
922
+ 'Failed to persist failed autonomous job run.',
923
+ )
924
+
925
+ if (autoPause || terminalOneShot) {
926
+ yield* unscheduleRowEffect(currentJobRow)
538
927
  }
539
928
 
540
- const startedAt = new Date()
541
- runRow =
542
- (await databaseService.update(
543
- TABLES.AUTONOMOUS_JOB_RUN,
544
- runRow.id,
545
- { queueJobId: ensureRecordId(queueJobId, TABLES.QUEUE_JOB), status: 'running', startedAt },
546
- AutonomousJobRunRowSchema,
547
- )) ?? runRow
548
-
549
- try {
550
- const thread = await threadService.getThread(autonomousJobRow.threadId)
551
- const inputMessage = this.buildSyntheticUserMessage(autonomousJobRow.prompt)
552
- const turnResult = await runThreadTurnInBackground({
553
- thread,
554
- threadRef: ensureRecordId(autonomousJobRow.threadId, TABLES.THREAD),
555
- orgRef: ensureRecordId(autonomousJobRow.organizationId, TABLES.ORGANIZATION),
556
- userRef: ensureRecordId(autonomousJobRow.ownerUserId, TABLES.USER),
557
- userName: autonomousJobRow.ownerUserName,
558
- agentIdOverride: autonomousJobRow.agentId,
559
- inputMessage,
560
- })
561
- const activePlan = await executionPlanService.getActivePlanForThread(autonomousJobRow.threadId)
562
- const runStatus: AutonomousJobRunStatus = activePlan?.status === 'awaiting-human' ? 'awaiting-human' : 'completed'
563
- const summary = truncateText(
564
- turnResult.assistantMessages
565
- .map((message) => extractMessageText(message))
566
- .filter(Boolean)
567
- .join('\n\n')
568
- .trim() || `${autonomousJobRow.title} completed.`,
569
- 2_000,
570
- )
571
- const completedAt = new Date()
929
+ yield* effectTryPromise(
930
+ () =>
931
+ deps.db.update(
932
+ TABLES.AUTONOMOUS_JOB,
933
+ currentJobRow.id,
934
+ compactRecord({
935
+ status: nextStatus,
936
+ consecutiveErrorCount: nextConsecutiveErrorCount,
937
+ lastRunStatus: 'failed',
938
+ lastRunAt: completedAt,
939
+ nextRunAt:
940
+ nextStatus === 'active' && currentJobRow.schedule.kind !== 'at'
941
+ ? (computeNextRunAt(currentJobRow.schedule, completedAt) ?? undefined)
942
+ : undefined,
943
+ lastError: normalizedError,
944
+ }),
945
+ AutonomousJobRowSchema,
946
+ ),
947
+ 'Failed to update autonomous job failure state.',
948
+ )
572
949
 
573
- await databaseService.update(
574
- TABLES.AUTONOMOUS_JOB_RUN,
575
- runRow.id,
576
- compactRecord({
577
- status: runStatus,
578
- inputMessageId: turnResult.inputMessageId,
579
- assistantMessageIds: turnResult.assistantMessages.map((message) => message.id),
580
- summary,
581
- linkedPlanSpecId: activePlan?.specId ? ensureRecordId(activePlan.specId, TABLES.PLAN_SPEC) : undefined,
582
- linkedPlanRunId: activePlan?.runId ? ensureRecordId(activePlan.runId, TABLES.PLAN_RUN) : undefined,
583
- completedAt,
584
- }),
585
- AutonomousJobRunRowSchema,
586
- )
950
+ yield* maybeNotifyEffect(deps, {
951
+ organizationId: recordIdToString(currentJobRow.organizationId, TABLES.ORGANIZATION),
952
+ threadId: recordIdToString(currentJobRow.threadId, TABLES.THREAD),
953
+ severity: 'warning',
954
+ title: autoPause ? `${currentJobRow.title} paused after repeated failures` : `${currentJobRow.title} failed`,
955
+ body: normalizedError.message,
956
+ metadata: {
957
+ autonomousJobId: job.data.autonomousJobId,
958
+ runId: recordIdToString(currentRunRow.id, TABLES.AUTONOMOUS_JOB_RUN),
959
+ autoPaused: autoPause,
960
+ },
961
+ })
587
962
 
588
- const nextRunAt =
589
- job.data.trigger === 'scheduled' && autonomousJobRow.schedule.kind !== 'at'
590
- ? this.computeNextRunAt(autonomousJobRow.schedule, completedAt)
591
- : autonomousJobRow.nextRunAt
592
- const nextStatus =
593
- autonomousJobRow.schedule.kind === 'at' && job.data.trigger === 'scheduled'
594
- ? 'completed'
595
- : autonomousJobRow.status
596
-
597
- await databaseService.update(
598
- TABLES.AUTONOMOUS_JOB,
599
- autonomousJobRow.id,
600
- compactRecord({
601
- status: nextStatus,
602
- consecutiveErrorCount: 0,
603
- lastRunStatus: runStatus,
604
- lastRunAt: completedAt,
605
- nextRunAt: nextStatus === 'active' ? (nextRunAt ?? undefined) : undefined,
606
- linkedPlanSpecId: activePlan?.specId ? ensureRecordId(activePlan.specId, TABLES.PLAN_SPEC) : undefined,
607
- linkedPlanRunId: activePlan?.runId ? ensureRecordId(activePlan.runId, TABLES.PLAN_RUN) : undefined,
608
- lastError: undefined,
609
- }),
610
- AutonomousJobRowSchema,
611
- )
963
+ return yield* new AutonomousJobServiceError({ message: normalizedError.message, cause: error })
964
+ })
965
+ }
612
966
 
613
- await this.maybeNotify('notify', {
614
- organizationId: recordIdToString(autonomousJobRow.organizationId, TABLES.ORGANIZATION),
615
- threadId: recordIdToString(autonomousJobRow.threadId, TABLES.THREAD),
616
- severity: 'info',
617
- title: `${autonomousJobRow.title} completed`,
618
- body: summary,
619
- metadata: {
620
- autonomousJobId: job.data.autonomousJobId,
621
- runId: recordIdToString(runRow.id, TABLES.AUTONOMOUS_JOB_RUN),
622
- },
623
- })
967
+ function executeQueuedRunEffect(
968
+ deps: AutonomousJobDeps,
969
+ job: Job<AutonomousJobQueuePayload>,
970
+ ): Effect.Effect<{ status: string; summary?: string }, AutonomousJobServiceError> {
971
+ let autonomousJobRow: AutonomousJobRow | null = null
972
+ let runRow: AutonomousJobRunRow | null = null
624
973
 
625
- return { status: runStatus, summary }
626
- } catch (error) {
627
- const normalizedError = toQueueJobError(error)
628
- const completedAt = new Date()
629
- const nextConsecutiveErrorCount = autonomousJobRow.consecutiveErrorCount + 1
630
- const autoPause = nextConsecutiveErrorCount >= autonomousJobRow.autoPauseThreshold
631
- const terminalOneShot = autonomousJobRow.schedule.kind === 'at' && job.data.trigger === 'scheduled'
632
- const nextStatus: AutonomousJobStatus = terminalOneShot
633
- ? 'failed'
634
- : autoPause
635
- ? 'paused'
636
- : autonomousJobRow.status
637
-
638
- await databaseService.update(
639
- TABLES.AUTONOMOUS_JOB_RUN,
640
- runRow.id,
641
- { status: 'failed', error: normalizedError, completedAt },
642
- AutonomousJobRunRowSchema,
643
- )
974
+ return Effect.gen(function* () {
975
+ yield* effectTryPromise(() => deps.db.connect(), 'Failed to connect to autonomous job database.')
976
+ autonomousJobRow = yield* getRowEffect(deps, job.data.autonomousJobId)
977
+ const currentJobRow = autonomousJobRow
978
+ const { queueJobId, runRow: initialRunRow } = yield* getOrCreateQueuedRunRowEffect(deps, job, currentJobRow)
644
979
 
645
- if (autoPause || terminalOneShot) {
646
- await this.unscheduleRow(autonomousJobRow)
647
- }
980
+ runRow = initialRunRow
981
+ if (currentJobRow.status !== 'active' && job.data.trigger === 'scheduled') {
982
+ return { status: 'skipped' }
983
+ }
648
984
 
649
- await databaseService.update(
650
- TABLES.AUTONOMOUS_JOB,
651
- autonomousJobRow.id,
652
- compactRecord({
653
- status: nextStatus,
654
- consecutiveErrorCount: nextConsecutiveErrorCount,
655
- lastRunStatus: 'failed',
656
- lastRunAt: completedAt,
657
- nextRunAt:
658
- nextStatus === 'active' && autonomousJobRow.schedule.kind !== 'at'
659
- ? (this.computeNextRunAt(autonomousJobRow.schedule, completedAt) ?? undefined)
660
- : undefined,
661
- lastError: normalizedError,
985
+ runRow = yield* markQueuedRunRunningEffect(deps, initialRunRow, queueJobId)
986
+ const currentRunRow = runRow
987
+ const inputMessage = buildSyntheticUserMessage(currentJobRow.prompt)
988
+
989
+ const [thread, turnModule] = yield* Effect.all([
990
+ deps.thread
991
+ .getThread(currentJobRow.threadId)
992
+ .pipe(
993
+ Effect.mapError(
994
+ (cause) => new AutonomousJobServiceError({ message: 'Failed to load autonomous job thread.', cause }),
995
+ ),
996
+ ),
997
+ effectTryPromise<ThreadTurnModule>(() => import('./thread/thread-turn'), 'Failed to load thread turn runtime.'),
998
+ ])
999
+
1000
+ const turnResult = yield* effectTryPromise(
1001
+ () =>
1002
+ turnModule.runThreadTurnInBackground({
1003
+ thread,
1004
+ threadRef: ensureRecordId(currentJobRow.threadId, TABLES.THREAD),
1005
+ orgRef: ensureRecordId(currentJobRow.organizationId, TABLES.ORGANIZATION),
1006
+ userRef: ensureRecordId(currentJobRow.ownerUserId, TABLES.USER),
1007
+ userName: currentJobRow.ownerUserName,
1008
+ agentIdOverride: currentJobRow.agentId,
1009
+ inputMessage,
662
1010
  }),
663
- AutonomousJobRowSchema,
1011
+ 'Failed to run autonomous job thread turn.',
1012
+ )
1013
+
1014
+ const activePlan = yield* deps.executionPlan
1015
+ .getActivePlanForThread(currentJobRow.threadId)
1016
+ .pipe(
1017
+ Effect.mapError(
1018
+ (cause) => new AutonomousJobServiceError({ message: 'Failed to load active execution plan.', cause }),
1019
+ ),
664
1020
  )
665
1021
 
666
- await this.maybeNotify('notify', {
667
- organizationId: recordIdToString(autonomousJobRow.organizationId, TABLES.ORGANIZATION),
668
- threadId: recordIdToString(autonomousJobRow.threadId, TABLES.THREAD),
669
- severity: 'warning',
670
- title: autoPause
671
- ? `${autonomousJobRow.title} paused after repeated failures`
672
- : `${autonomousJobRow.title} failed`,
673
- body: normalizedError.message,
674
- metadata: {
675
- autonomousJobId: job.data.autonomousJobId,
676
- runId: recordIdToString(runRow.id, TABLES.AUTONOMOUS_JOB_RUN),
677
- autoPaused: autoPause,
678
- },
679
- })
1022
+ return yield* completeQueuedRunEffect(deps, { job, currentJobRow, currentRunRow, turnResult, activePlan })
1023
+ }).pipe(
1024
+ Effect.catch((error: unknown) =>
1025
+ Effect.gen(function* () {
1026
+ const currentJobRow = autonomousJobRow
1027
+ const currentRunRow = runRow
1028
+ if (!currentJobRow || !currentRunRow) {
1029
+ return yield* new AutonomousJobServiceError({ message: 'Failed to execute autonomous job.', cause: error })
1030
+ }
1031
+ return yield* failQueuedRunEffect(deps, { job, currentJobRow, currentRunRow, error })
1032
+ }),
1033
+ ),
1034
+ )
1035
+ }
680
1036
 
681
- throw error
682
- }
683
- }
1037
+ function createAutonomousJobService(deps: AutonomousJobDeps) {
1038
+ return {
1039
+ computeNextRunAt,
1040
+ create: (input: CreateAutonomousJobInput) => createEffect(deps, input),
1041
+ recoverActiveJobs: (now = nowDate()) => recoverActiveJobsEffect(deps, now),
1042
+ get: (jobId: RecordIdInput) => getEffect(deps, jobId),
1043
+ list: (params: { organizationId: RecordIdInput; ownerUserId?: RecordIdInput; status?: AutonomousJobStatus }) =>
1044
+ listEffect(deps, params),
1045
+ update: (jobId: RecordIdInput, input: UpdateAutonomousJobInput) => updateEffect(deps, jobId, input),
1046
+ pause: (jobId: RecordIdInput) => pauseEffect(deps, jobId),
1047
+ resume: (jobId: RecordIdInput) => resumeEffect(deps, jobId),
1048
+ runNow: (jobId: RecordIdInput) => runNowEffect(deps, jobId),
1049
+ cancel: (jobId: RecordIdInput) => cancelEffect(deps, jobId),
1050
+ delete: (jobId: RecordIdInput) => deleteJobEffect(deps, jobId),
1051
+ listRuns: (jobId: RecordIdInput) => listRunsEffect(deps, jobId),
1052
+ executeQueuedRun: (job: Job<AutonomousJobQueuePayload>) => executeQueuedRunEffect(deps, job),
1053
+ } as const
1054
+ }
1055
+
1056
+ interface AutonomousJobDeps {
1057
+ db: SurrealDBService
1058
+ config: ResolvedLotaRuntimeConfig
1059
+ executionPlan: ReturnType<typeof makeExecutionPlanService>
1060
+ queueJob: ReturnType<typeof makeQueueJobService>
1061
+ thread: ReturnType<typeof makeThreadService>
684
1062
  }
685
1063
 
686
- export const autonomousJobService = new AutonomousJobService()
1064
+ export function makeAutonomousJobService(deps: AutonomousJobDeps) {
1065
+ return createAutonomousJobService(deps)
1066
+ }
1067
+
1068
+ export class AutonomousJobServiceTag extends Context.Service<
1069
+ AutonomousJobServiceTag,
1070
+ ReturnType<typeof makeAutonomousJobService>
1071
+ >()('AutonomousJobService') {}
1072
+
1073
+ export const AutonomousJobServiceLive = Layer.effect(
1074
+ AutonomousJobServiceTag,
1075
+ Effect.gen(function* () {
1076
+ const db = yield* DatabaseServiceTag
1077
+ const config = yield* RuntimeConfigServiceTag
1078
+ const executionPlan = yield* ExecutionPlanServiceTag
1079
+ const queueJob = yield* QueueJobServiceTag
1080
+ const thread = yield* ThreadServiceTag
1081
+ return makeAutonomousJobService({ db, config, executionPlan, queueJob, thread })
1082
+ }),
1083
+ )