@lota-sdk/core 0.1.18 → 0.1.20

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 (40) hide show
  1. package/infrastructure/schema/09_queue_job.surql +38 -0
  2. package/infrastructure/schema/10_autonomous_job.surql +44 -0
  3. package/package.json +2 -2
  4. package/src/ai-gateway/ai-gateway.ts +130 -21
  5. package/src/ai-gateway/cache-headers.ts +26 -1
  6. package/src/create-runtime.ts +10 -1
  7. package/src/db/base.service.ts +6 -1
  8. package/src/db/tables.ts +4 -0
  9. package/src/queues/autonomous-job.queue.ts +134 -0
  10. package/src/queues/document-processor.queue.ts +13 -2
  11. package/src/queues/index.ts +1 -0
  12. package/src/queues/memory-consolidation.queue.ts +22 -3
  13. package/src/queues/queue-factory.ts +33 -4
  14. package/src/runtime/chat-run-registry.ts +4 -0
  15. package/src/runtime/context-compaction.ts +100 -12
  16. package/src/runtime/memory-prompts-fact.ts +3 -1
  17. package/src/runtime/runtime-config.ts +1 -1
  18. package/src/runtime/runtime-worker-registry.ts +3 -0
  19. package/src/services/autonomous-job.service.ts +692 -0
  20. package/src/services/index.ts +2 -0
  21. package/src/services/plan-deadline.service.ts +6 -4
  22. package/src/services/queue-job.service.ts +356 -0
  23. package/src/services/workstream-message.service.ts +25 -14
  24. package/src/services/workstream-title.service.ts +1 -1
  25. package/src/services/workstream-turn-preparation.service.ts +22 -6
  26. package/src/services/workstream-turn.ts +11 -3
  27. package/src/services/workstream.service.ts +19 -2
  28. package/src/system-agents/context-compaction.agent.ts +2 -2
  29. package/src/system-agents/delegated-agent-factory.ts +2 -9
  30. package/src/system-agents/memory-reranker.agent.ts +2 -2
  31. package/src/system-agents/memory.agent.ts +2 -2
  32. package/src/system-agents/recent-activity-title-refiner.agent.ts +2 -2
  33. package/src/system-agents/regular-chat-memory-digest.agent.ts +2 -2
  34. package/src/system-agents/skill-extractor.agent.ts +2 -2
  35. package/src/system-agents/skill-manager.agent.ts +2 -2
  36. package/src/system-agents/title-generator.agent.ts +2 -2
  37. package/src/tools/research-topic.tool.ts +2 -2
  38. package/src/utils/date-time.ts +11 -0
  39. package/src/workers/utils/file-section-chunker.ts +1 -1
  40. package/src/workers/worker-utils.ts +35 -7
@@ -130,14 +130,15 @@ class PlanDeadlineService {
130
130
  }
131
131
  }
132
132
 
133
- const nextTriggerAt: Date | null =
133
+ const nextTriggerAt =
134
134
  sweep.entries
135
135
  .map((entry) => entry.evaluation.nextTriggerAt)
136
136
  .filter((value): value is Date => {
137
137
  if (!value) return false
138
138
  return value.getTime() > currentTime.getTime()
139
139
  })
140
- .sort((a, b) => a.getTime() - b.getTime())[0] ?? null
140
+ .sort((a, b) => a.getTime() - b.getTime())
141
+ .at(0) ?? null
141
142
 
142
143
  if (nextTriggerAt) {
143
144
  await this.enqueueDeadlineCheck(nextTriggerAt)
@@ -152,14 +153,15 @@ class PlanDeadlineService {
152
153
  return
153
154
  }
154
155
 
155
- const nextTriggerAt: Date | null =
156
+ const nextTriggerAt =
156
157
  sweep.entries
157
158
  .map((entry) => entry.evaluation.nextTriggerAt)
158
159
  .filter((value): value is Date => {
159
160
  if (!value) return false
160
161
  return value.getTime() > now.getTime()
161
162
  })
162
- .sort((a, b) => a.getTime() - b.getTime())[0] ?? null
163
+ .sort((a, b) => a.getTime() - b.getTime())
164
+ .at(0) ?? null
163
165
 
164
166
  if (nextTriggerAt) {
165
167
  await this.enqueueDeadlineCheck(nextTriggerAt)
@@ -0,0 +1,356 @@
1
+ import {
2
+ QueueJobAttemptStatusSchema,
3
+ QueueJobErrorSchema,
4
+ QueueJobStatusSchema,
5
+ recordIdSchema,
6
+ } from '@lota-sdk/shared'
7
+ import type { QueueJobError, QueueJobStatus } from '@lota-sdk/shared'
8
+ import { RecordId } from 'surrealdb'
9
+ import { z } from 'zod'
10
+
11
+ import { recordIdToString } from '../db/record-id'
12
+ import { databaseService } from '../db/service'
13
+ import { TABLES } from '../db/tables'
14
+ import { compactRecord, readRecord, readString, readStringField, stringifyUnknown, truncateText } from '../utils/string'
15
+
16
+ const QueueJobRowSchema = z.object({
17
+ id: recordIdSchema,
18
+ queueName: z.string(),
19
+ jobName: z.string(),
20
+ bullmqJobId: z.string(),
21
+ status: QueueJobStatusSchema,
22
+ data: z.object({ value: z.unknown() }).passthrough().optional(),
23
+ options: z.record(z.string(), z.unknown()).optional(),
24
+ context: z.record(z.string(), z.unknown()).optional(),
25
+ deduplicationId: z.string().optional(),
26
+ schedulerId: z.string().optional(),
27
+ maxAttempts: z.number().int().nonnegative().optional(),
28
+ attemptCount: z.number().int().nonnegative().default(0),
29
+ result: z.object({ value: z.unknown() }).passthrough().optional(),
30
+ lastError: QueueJobErrorSchema.optional(),
31
+ queuedAt: z.coerce.date(),
32
+ startedAt: z.coerce.date().optional(),
33
+ completedAt: z.coerce.date().optional(),
34
+ failedAt: z.coerce.date().optional(),
35
+ createdAt: z.coerce.date(),
36
+ updatedAt: z.coerce.date(),
37
+ })
38
+
39
+ const QueueJobAttemptRowSchema = z.object({
40
+ id: recordIdSchema,
41
+ queueJobId: recordIdSchema,
42
+ attemptNumber: z.number().int().positive(),
43
+ status: QueueJobAttemptStatusSchema,
44
+ result: z.object({ value: z.unknown() }).passthrough().optional(),
45
+ error: QueueJobErrorSchema.optional(),
46
+ startedAt: z.coerce.date(),
47
+ completedAt: z.coerce.date().optional(),
48
+ durationMs: z.number().int().nonnegative().optional(),
49
+ createdAt: z.coerce.date(),
50
+ updatedAt: z.coerce.date(),
51
+ })
52
+
53
+ export interface TrackedBullJobLike {
54
+ queueName: string
55
+ id?: string | number
56
+ name: string
57
+ data?: unknown
58
+ opts?: unknown
59
+ attemptsMade: number | null | undefined
60
+ timestamp?: number
61
+ }
62
+
63
+ function buildDeterministicRecordId(table: string, key: string): RecordId {
64
+ const digest = new Bun.CryptoHasher('sha256').update(key).digest('hex')
65
+ return new RecordId(table, digest)
66
+ }
67
+
68
+ function sanitizeQueueValue(value: unknown, depth = 0): unknown {
69
+ if (value === null || value === undefined) return value
70
+ if (typeof value === 'string') return truncateText(value, 20_000)
71
+ if (typeof value === 'number' || typeof value === 'boolean') return value
72
+ if (typeof value === 'bigint') return value.toString()
73
+ if (value instanceof Date) return value.toISOString()
74
+
75
+ if (depth >= 6) {
76
+ return stringifyUnknown(value) ?? '[unserializable]'
77
+ }
78
+
79
+ if (Array.isArray(value)) {
80
+ return value.slice(0, 200).map((item) => sanitizeQueueValue(item, depth + 1))
81
+ }
82
+
83
+ const record = readRecord(value)
84
+ if (!record) {
85
+ return stringifyUnknown(value) ?? '[unserializable]'
86
+ }
87
+
88
+ return Object.fromEntries(
89
+ Object.entries(record)
90
+ .slice(0, 200)
91
+ .map(([key, entry]) => [key, sanitizeQueueValue(entry, depth + 1)]),
92
+ )
93
+ }
94
+
95
+ function wrapFlexibleValue(value: unknown): Record<string, unknown> | undefined {
96
+ if (value === undefined) return undefined
97
+ return { value: sanitizeQueueValue(value) }
98
+ }
99
+
100
+ function toQueueJobError(error: unknown): QueueJobError {
101
+ if (error instanceof Error) {
102
+ const maybeCode = readString((error as Error & { code?: unknown }).code)
103
+ return QueueJobErrorSchema.parse(
104
+ compactRecord({
105
+ name: error.name || undefined,
106
+ message: truncateText(error.message || 'Unknown error', 5_000),
107
+ stack: typeof error.stack === 'string' ? truncateText(error.stack, 20_000) : undefined,
108
+ code: maybeCode,
109
+ }),
110
+ )
111
+ }
112
+
113
+ return QueueJobErrorSchema.parse({ message: truncateText(stringifyUnknown(error) ?? 'Unknown error', 5_000) })
114
+ }
115
+
116
+ function getBullmqJobId(job: TrackedBullJobLike): string {
117
+ const id = job.id
118
+ if (typeof id === 'string' && id.length > 0) return id
119
+ if (typeof id === 'number') return String(id)
120
+ throw new Error(`BullMQ job for queue "${job.queueName}" is missing an id.`)
121
+ }
122
+
123
+ function getQueueJobRecordId(job: TrackedBullJobLike): RecordId {
124
+ return buildDeterministicRecordId(TABLES.QUEUE_JOB, `${job.queueName}:${getBullmqJobId(job)}`)
125
+ }
126
+
127
+ function getQueueJobAttemptRecordId(job: TrackedBullJobLike, attemptNumber: number): RecordId {
128
+ return buildDeterministicRecordId(
129
+ TABLES.QUEUE_JOB_ATTEMPT,
130
+ `${job.queueName}:${getBullmqJobId(job)}:${attemptNumber}`,
131
+ )
132
+ }
133
+
134
+ function resolveAttemptNumber(job: TrackedBullJobLike): number {
135
+ return (job.attemptsMade ?? 0) + 1
136
+ }
137
+
138
+ function extractJobContext(data: unknown): Record<string, unknown> | undefined {
139
+ const record = readRecord(data)
140
+ if (!record) return undefined
141
+
142
+ const context = compactRecord({
143
+ organizationId: readStringField(record, 'organizationId') ?? readStringField(record, 'orgId'),
144
+ workstreamId: readStringField(record, 'workstreamId'),
145
+ userId: readStringField(record, 'userId'),
146
+ agentId: readStringField(record, 'agentId'),
147
+ sourceId: readStringField(record, 'sourceId'),
148
+ runId: readStringField(record, 'runId'),
149
+ nodeId: readStringField(record, 'nodeId'),
150
+ scheduleId: readStringField(record, 'scheduleId'),
151
+ scopeId: readStringField(record, 'scopeId'),
152
+ autonomousJobId: readStringField(record, 'autonomousJobId'),
153
+ autonomousJobRunId: readStringField(record, 'autonomousJobRunId'),
154
+ })
155
+
156
+ return Object.keys(context).length > 0 ? context : undefined
157
+ }
158
+
159
+ function readDeduplicationId(job: TrackedBullJobLike): string | undefined {
160
+ const opts = readRecord(job.opts)
161
+ const deduplication = opts ? readRecord(opts.deduplication) : null
162
+ return deduplication ? readStringField(deduplication, 'id') : undefined
163
+ }
164
+
165
+ function readMaxAttempts(job: TrackedBullJobLike): number | undefined {
166
+ const opts = readRecord(job.opts)
167
+ const attempts = opts?.attempts
168
+ return typeof attempts === 'number' && Number.isInteger(attempts) && attempts >= 0 ? attempts : undefined
169
+ }
170
+
171
+ function getQueuedStatus(job: TrackedBullJobLike): QueueJobStatus {
172
+ const opts = readRecord(job.opts)
173
+ const delay = opts?.delay
174
+ return typeof delay === 'number' && delay > 0 ? 'delayed' : 'waiting'
175
+ }
176
+
177
+ class QueueJobService {
178
+ getQueueJobId(queueName: string, bullmqJobId: string): string {
179
+ return recordIdToString(
180
+ buildDeterministicRecordId(TABLES.QUEUE_JOB, `${queueName}:${bullmqJobId}`),
181
+ TABLES.QUEUE_JOB,
182
+ )
183
+ }
184
+
185
+ async recordEnqueued(job: TrackedBullJobLike, context?: Record<string, unknown>): Promise<string> {
186
+ await databaseService.connect()
187
+
188
+ const queueJobId = getQueueJobRecordId(job)
189
+ const queuedAt = typeof job.timestamp === 'number' ? new Date(job.timestamp) : new Date()
190
+ const mergedContext = compactRecord({ ...extractJobContext(job.data), ...context })
191
+
192
+ await databaseService.upsert(
193
+ TABLES.QUEUE_JOB,
194
+ queueJobId,
195
+ compactRecord({
196
+ queueName: job.queueName,
197
+ jobName: job.name,
198
+ bullmqJobId: getBullmqJobId(job),
199
+ status: getQueuedStatus(job),
200
+ data: wrapFlexibleValue(job.data),
201
+ options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
202
+ context: Object.keys(mergedContext).length > 0 ? sanitizeQueueValue(mergedContext) : undefined,
203
+ deduplicationId: readDeduplicationId(job),
204
+ maxAttempts: readMaxAttempts(job),
205
+ queuedAt,
206
+ }),
207
+ QueueJobRowSchema,
208
+ )
209
+
210
+ return recordIdToString(queueJobId, TABLES.QUEUE_JOB)
211
+ }
212
+
213
+ async markAttemptStarted(job: TrackedBullJobLike): Promise<string> {
214
+ await databaseService.connect()
215
+
216
+ const attemptNumber = resolveAttemptNumber(job)
217
+ const queueJobId = getQueueJobRecordId(job)
218
+ const startedAt = new Date()
219
+
220
+ await databaseService.upsert(
221
+ TABLES.QUEUE_JOB,
222
+ queueJobId,
223
+ compactRecord({
224
+ queueName: job.queueName,
225
+ jobName: job.name,
226
+ bullmqJobId: getBullmqJobId(job),
227
+ status: 'active',
228
+ data: wrapFlexibleValue(job.data),
229
+ options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
230
+ context: sanitizeQueueValue(extractJobContext(job.data)) as Record<string, unknown> | undefined,
231
+ deduplicationId: readDeduplicationId(job),
232
+ maxAttempts: readMaxAttempts(job),
233
+ attemptCount: attemptNumber,
234
+ queuedAt: typeof job.timestamp === 'number' ? new Date(job.timestamp) : startedAt,
235
+ startedAt,
236
+ }),
237
+ QueueJobRowSchema,
238
+ )
239
+
240
+ const attemptId = getQueueJobAttemptRecordId(job, attemptNumber)
241
+ await databaseService.upsert(
242
+ TABLES.QUEUE_JOB_ATTEMPT,
243
+ attemptId,
244
+ { queueJobId, attemptNumber, status: 'active', startedAt },
245
+ QueueJobAttemptRowSchema,
246
+ )
247
+
248
+ return recordIdToString(queueJobId, TABLES.QUEUE_JOB)
249
+ }
250
+
251
+ async markAttemptCompleted(job: TrackedBullJobLike, result: unknown): Promise<void> {
252
+ await databaseService.connect()
253
+
254
+ const attemptNumber = resolveAttemptNumber(job)
255
+ const queueJobId = getQueueJobRecordId(job)
256
+ const attemptId = getQueueJobAttemptRecordId(job, attemptNumber)
257
+ const completedAt = new Date()
258
+ const existingAttempt = await databaseService.findOne(
259
+ TABLES.QUEUE_JOB_ATTEMPT,
260
+ { id: attemptId },
261
+ QueueJobAttemptRowSchema,
262
+ )
263
+
264
+ await databaseService.upsert(
265
+ TABLES.QUEUE_JOB_ATTEMPT,
266
+ attemptId,
267
+ compactRecord({
268
+ queueJobId,
269
+ attemptNumber,
270
+ status: 'completed',
271
+ result: wrapFlexibleValue(result),
272
+ startedAt: existingAttempt?.startedAt ?? completedAt,
273
+ completedAt,
274
+ durationMs: existingAttempt ? Math.max(0, completedAt.getTime() - existingAttempt.startedAt.getTime()) : 0,
275
+ }),
276
+ QueueJobAttemptRowSchema,
277
+ )
278
+
279
+ await databaseService.upsert(
280
+ TABLES.QUEUE_JOB,
281
+ queueJobId,
282
+ compactRecord({
283
+ queueName: job.queueName,
284
+ jobName: job.name,
285
+ bullmqJobId: getBullmqJobId(job),
286
+ status: 'completed',
287
+ data: wrapFlexibleValue(job.data),
288
+ options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
289
+ context: sanitizeQueueValue(extractJobContext(job.data)) as Record<string, unknown> | undefined,
290
+ deduplicationId: readDeduplicationId(job),
291
+ maxAttempts: readMaxAttempts(job),
292
+ attemptCount: attemptNumber,
293
+ queuedAt: typeof job.timestamp === 'number' ? new Date(job.timestamp) : completedAt,
294
+ completedAt,
295
+ result: wrapFlexibleValue(result),
296
+ lastError: undefined,
297
+ }),
298
+ QueueJobRowSchema,
299
+ )
300
+ }
301
+
302
+ async markAttemptFailed(job: TrackedBullJobLike, error: unknown): Promise<void> {
303
+ await databaseService.connect()
304
+
305
+ const attemptNumber = resolveAttemptNumber(job)
306
+ const queueJobId = getQueueJobRecordId(job)
307
+ const attemptId = getQueueJobAttemptRecordId(job, attemptNumber)
308
+ const failedAt = new Date()
309
+ const existingAttempt = await databaseService.findOne(
310
+ TABLES.QUEUE_JOB_ATTEMPT,
311
+ { id: attemptId },
312
+ QueueJobAttemptRowSchema,
313
+ )
314
+ const normalizedError = toQueueJobError(error)
315
+ const maxAttempts = readMaxAttempts(job)
316
+ const terminal = typeof maxAttempts === 'number' ? attemptNumber >= maxAttempts : true
317
+
318
+ await databaseService.upsert(
319
+ TABLES.QUEUE_JOB_ATTEMPT,
320
+ attemptId,
321
+ compactRecord({
322
+ queueJobId,
323
+ attemptNumber,
324
+ status: 'failed',
325
+ error: normalizedError,
326
+ startedAt: existingAttempt?.startedAt ?? failedAt,
327
+ completedAt: failedAt,
328
+ durationMs: existingAttempt ? Math.max(0, failedAt.getTime() - existingAttempt.startedAt.getTime()) : 0,
329
+ }),
330
+ QueueJobAttemptRowSchema,
331
+ )
332
+
333
+ await databaseService.upsert(
334
+ TABLES.QUEUE_JOB,
335
+ queueJobId,
336
+ compactRecord({
337
+ queueName: job.queueName,
338
+ jobName: job.name,
339
+ bullmqJobId: getBullmqJobId(job),
340
+ status: terminal ? 'failed' : 'waiting',
341
+ data: wrapFlexibleValue(job.data),
342
+ options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
343
+ context: sanitizeQueueValue(extractJobContext(job.data)) as Record<string, unknown> | undefined,
344
+ deduplicationId: readDeduplicationId(job),
345
+ maxAttempts,
346
+ attemptCount: attemptNumber,
347
+ queuedAt: typeof job.timestamp === 'number' ? new Date(job.timestamp) : failedAt,
348
+ failedAt: terminal ? failedAt : undefined,
349
+ lastError: normalizedError,
350
+ }),
351
+ QueueJobRowSchema,
352
+ )
353
+ }
354
+ }
355
+
356
+ export const queueJobService = new QueueJobService()
@@ -6,7 +6,7 @@ import { z } from 'zod'
6
6
  import { agentDisplayNames } from '../config/agent-defaults'
7
7
  import { CursorRowSchema, listMessageHistoryPage } from '../db/cursor-pagination'
8
8
  import type { CursorPaginationConfig, MessageHistoryPage } from '../db/cursor-pagination'
9
- import { recordIdToString } from '../db/record-id'
9
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
10
10
  import type { RecordIdRef } from '../db/record-id'
11
11
  import { databaseService } from '../db/service'
12
12
  import { TABLES } from '../db/tables'
@@ -32,6 +32,10 @@ function toWorkstreamMessageRowId(workstreamId: RecordIdRef, messageId: string):
32
32
  return new RecordId(TABLES.WORKSTREAM_MESSAGE, digest)
33
33
  }
34
34
 
35
+ function toWorkstreamRef(workstreamId: RecordIdRef): RecordId {
36
+ return ensureRecordId(workstreamId, TABLES.WORKSTREAM)
37
+ }
38
+
35
39
  function toChatMessage(row: WorkstreamMessageRow): ChatMessage {
36
40
  const rowCreatedAt = requireTimestamp(row.createdAt)
37
41
  const metadata = withCreatedAtMetadata(parseRowMetadata(row.metadata), rowCreatedAt)
@@ -65,7 +69,7 @@ const workstreamPaginationConfig: CursorPaginationConfig = {
65
69
 
66
70
  class WorkstreamMessageService {
67
71
  async upsertMessages(params: { workstreamId: RecordIdRef; messages: ChatMessage[] }): Promise<void> {
68
- const workstreamId = params.workstreamId
72
+ const workstreamId = toWorkstreamRef(params.workstreamId)
69
73
 
70
74
  const upsertPromises = params.messages.map(async (message) => {
71
75
  const messageId = message.id.trim()
@@ -108,9 +112,10 @@ class WorkstreamMessageService {
108
112
  }
109
113
 
110
114
  async listMessages(workstreamId: RecordIdRef): Promise<ChatMessage[]> {
115
+ const workstreamRef = toWorkstreamRef(workstreamId)
111
116
  const rows = await databaseService.query<unknown>(surql`
112
117
  SELECT * FROM workstreamMessage
113
- WHERE workstreamId = ${workstreamId}
118
+ WHERE workstreamId = ${workstreamRef}
114
119
  ORDER BY createdAt ASC, id ASC
115
120
  `)
116
121
 
@@ -122,22 +127,24 @@ class WorkstreamMessageService {
122
127
  take: number
123
128
  beforeMessageId?: string
124
129
  }): Promise<MessageHistoryPage> {
130
+ const workstreamRef = toWorkstreamRef(params.workstreamId)
125
131
  return listMessageHistoryPage(workstreamPaginationConfig, {
126
- parentId: params.workstreamId,
132
+ parentId: workstreamRef,
127
133
  take: params.take,
128
134
  beforeMessageId: params.beforeMessageId,
129
135
  })
130
136
  }
131
137
 
132
138
  async listMessagesAfterCursor(workstreamId: RecordIdRef, afterMessageId?: string): Promise<ChatMessage[]> {
139
+ const workstreamRef = toWorkstreamRef(workstreamId)
133
140
  const cursorMessageId = afterMessageId?.trim()
134
141
  if (!cursorMessageId) {
135
- return this.listMessages(workstreamId)
142
+ return this.listMessages(workstreamRef)
136
143
  }
137
144
 
138
145
  const cursorRow = await databaseService.findOne(
139
146
  TABLES.WORKSTREAM_MESSAGE,
140
- { workstreamId, messageId: cursorMessageId },
147
+ { workstreamId: workstreamRef, messageId: cursorMessageId },
141
148
  CursorRowSchema,
142
149
  )
143
150
 
@@ -146,10 +153,10 @@ class WorkstreamMessageService {
146
153
  }
147
154
 
148
155
  const cursorCreatedAt = cursorRow.createdAt
149
- const cursorId = toWorkstreamMessageRowId(workstreamId, cursorMessageId)
156
+ const cursorId = toWorkstreamMessageRowId(workstreamRef, cursorMessageId)
150
157
  const rows = await databaseService.query<unknown>(surql`
151
158
  SELECT * FROM workstreamMessage
152
- WHERE workstreamId = ${workstreamId}
159
+ WHERE workstreamId = ${workstreamRef}
153
160
  AND (
154
161
  createdAt > ${cursorCreatedAt}
155
162
  OR (createdAt = ${cursorCreatedAt} AND id > ${cursorId})
@@ -161,9 +168,10 @@ class WorkstreamMessageService {
161
168
  }
162
169
 
163
170
  async listRecentMessages(workstreamId: RecordIdRef, limit: number): Promise<ChatMessage[]> {
171
+ const workstreamRef = toWorkstreamRef(workstreamId)
164
172
  const rows = await databaseService.query<unknown>(surql`
165
173
  SELECT * FROM workstreamMessage
166
- WHERE workstreamId = ${workstreamId}
174
+ WHERE workstreamId = ${workstreamRef}
167
175
  ORDER BY createdAt DESC, id DESC
168
176
  LIMIT ${Math.max(1, limit)}
169
177
  `)
@@ -183,7 +191,7 @@ class WorkstreamMessageService {
183
191
  const normalizedQuery = params.query.trim().toLowerCase()
184
192
  if (!normalizedQuery) return []
185
193
 
186
- const messages = await this.listMessages(params.workstreamId)
194
+ const messages = await this.listMessages(toWorkstreamRef(params.workstreamId))
187
195
  return messages
188
196
  .filter((message) => message.role === params.role)
189
197
  .map((message) => ({
@@ -204,6 +212,7 @@ class WorkstreamMessageService {
204
212
  workstreamId: RecordIdRef
205
213
  content: string
206
214
  }): Promise<ChatMessage> {
215
+ const workstreamRef = toWorkstreamRef(params.workstreamId)
207
216
  const message: ChatMessage = {
208
217
  id: toMessageId(params.messageId),
209
218
  role: 'user',
@@ -211,7 +220,7 @@ class WorkstreamMessageService {
211
220
  metadata: { createdAt: Date.now() },
212
221
  }
213
222
 
214
- await this.upsertMessages({ workstreamId: params.workstreamId, messages: [message] })
223
+ await this.upsertMessages({ workstreamId: workstreamRef, messages: [message] })
215
224
  return message
216
225
  }
217
226
 
@@ -221,6 +230,7 @@ class WorkstreamMessageService {
221
230
  parts: ChatMessage['parts']
222
231
  metadata?: ChatMessage['metadata']
223
232
  }): Promise<ChatMessage> {
233
+ const workstreamRef = toWorkstreamRef(params.workstreamId)
224
234
  const message: ChatMessage = {
225
235
  id: toMessageId(params.messageId),
226
236
  role: 'assistant',
@@ -228,7 +238,7 @@ class WorkstreamMessageService {
228
238
  metadata: withCreatedAtMetadata(params.metadata, Date.now()),
229
239
  }
230
240
 
231
- await this.upsertMessages({ workstreamId: params.workstreamId, messages: [message] })
241
+ await this.upsertMessages({ workstreamId: workstreamRef, messages: [message] })
232
242
  return message
233
243
  }
234
244
 
@@ -237,9 +247,10 @@ class WorkstreamMessageService {
237
247
  agentId: string
238
248
  text: string
239
249
  }): Promise<void> {
250
+ const workstreamRef = toWorkstreamRef(params.workstreamId)
240
251
  const existingRow = await databaseService.findOne(
241
252
  TABLES.WORKSTREAM_MESSAGE,
242
- { workstreamId: params.workstreamId },
253
+ { workstreamId: workstreamRef },
243
254
  WorkstreamMessageExistingRowSchema,
244
255
  )
245
256
  if (existingRow) return
@@ -248,7 +259,7 @@ class WorkstreamMessageService {
248
259
  if (!messageText) return
249
260
 
250
261
  await this.upsertMessages({
251
- workstreamId: params.workstreamId,
262
+ workstreamId: workstreamRef,
252
263
  messages: [
253
264
  {
254
265
  id: Bun.randomUUIDv7(),
@@ -10,7 +10,7 @@ import {
10
10
  } from '../system-agents/title-generator.agent'
11
11
  import { workstreamService } from './workstream.service'
12
12
 
13
- const WORKSTREAM_TITLE_TIMEOUT_MS = 5_000
13
+ const WORKSTREAM_TITLE_TIMEOUT_MS = 30_000
14
14
 
15
15
  class WorkstreamTitleService {
16
16
  helperRuntime = createHelperModelRuntime()
@@ -189,6 +189,7 @@ export interface WorkstreamTurnParams {
189
189
  orgRef: RecordIdRef
190
190
  userRef: RecordIdRef
191
191
  userName?: string | null
192
+ agentIdOverride?: string
192
193
  inputMessage: ChatMessage
193
194
  skipInputMessagePersistence?: boolean
194
195
  abortSignal?: AbortSignal
@@ -212,6 +213,7 @@ type WorkstreamRunCoreParams = {
212
213
  orgRef: RecordIdRef
213
214
  userRef: RecordIdRef
214
215
  userName?: string | null
216
+ agentIdOverride?: string
215
217
  abortSignal?: AbortSignal
216
218
  streamId?: string
217
219
  } & (
@@ -222,7 +224,12 @@ type WorkstreamRunCoreParams = {
222
224
 
223
225
  interface PreparedWorkstreamTurn {
224
226
  originalMessages: ChatMessage[]
225
- run: (writer?: UIMessageStreamWriter<ChatMessage>) => Promise<void>
227
+ run: (writer?: UIMessageStreamWriter<ChatMessage>) => Promise<PreparedWorkstreamTurnResult>
228
+ }
229
+
230
+ export interface PreparedWorkstreamTurnResult {
231
+ inputMessageId?: string
232
+ assistantMessages: ChatMessage[]
226
233
  }
227
234
 
228
235
  function upsertChatHistoryMessage(messages: ChatMessage[], nextMessage: ChatMessage): ChatMessage[] {
@@ -632,7 +639,10 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
632
639
  const workstreamRecord = await waitForWorkstreamCompactionIfNeeded(workstreamRef)
633
640
  timer.step('compaction-gate')
634
641
  if (toOptionalTrimmedString(workstreamRecord.activeRunId)) {
635
- throw new WorkstreamTurnError('A chat run is already active.', 409)
642
+ const clearedStaleRun = await workstreamService.clearStaleActiveRunIfMissingFromRegistry(workstreamRef)
643
+ if (!clearedStaleRun) {
644
+ throw new WorkstreamTurnError('A chat run is already active.', 409)
645
+ }
636
646
  }
637
647
 
638
648
  if (params.kind === 'approvalContinuation' || params.kind === 'nativeToolApprovalTurn') {
@@ -730,7 +740,8 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
730
740
  workstream.core && workstream.coreType ? getCoreWorkstreamProfile(workstream.coreType) : null
731
741
  const defaultLeadAgentId = getLeadAgentId()
732
742
  const visibleWorkstreamAgentId =
733
- workstream.mode === 'direct' ? workstream.agentId : (coreWorkstreamProfile?.config.agentId ?? defaultLeadAgentId)
743
+ params.agentIdOverride ??
744
+ (workstream.mode === 'direct' ? workstream.agentId : (coreWorkstreamProfile?.config.agentId ?? defaultLeadAgentId))
734
745
  const coreInstructionSections = coreWorkstreamProfile ? [coreWorkstreamProfile.instructions] : undefined
735
746
  const getLinearInstallationByOrgId = getPluginService([
736
747
  'linear',
@@ -936,7 +947,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
936
947
  return {
937
948
  originalMessages,
938
949
  run: async (writer?: UIMessageStreamWriter<ChatMessage>) => {
939
- const executeRun = async () => {
950
+ const executeRun = async (): Promise<PreparedWorkstreamTurnResult | void> => {
940
951
  const runTimer = lotaDebugLogger.timer('run')
941
952
  const serverRunId = Bun.randomUUIDv7()
942
953
  const runAbort = createServerRunAbortController(params.abortSignal)
@@ -1064,7 +1075,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1064
1075
  // Execution-plan approval continuations mutate plan state and persist the approval message,
1065
1076
  // but they do not begin a new visible agent turn.
1066
1077
  if (params.kind === 'approvalContinuation') {
1067
- return
1078
+ return { inputMessageId: referenceUserMessage?.id, assistantMessages: [] }
1068
1079
  }
1069
1080
 
1070
1081
  const consultSpecialistTool = createTool({
@@ -1322,7 +1333,12 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1322
1333
  }
1323
1334
  }
1324
1335
 
1325
- await executeRun()
1336
+ const runResult = await executeRun()
1337
+ if (runResult) {
1338
+ return runResult
1339
+ }
1340
+
1341
+ return { inputMessageId: referenceUserMessage?.id, assistantMessages: [...allAssistantMessages] }
1326
1342
  },
1327
1343
  }
1328
1344
  }
@@ -5,10 +5,15 @@ import { lotaDebugLogger } from '../config/debug-logger'
5
5
  import { hasApprovalRespondedParts, isApprovalContinuationRequest } from '../runtime/approval-continuation'
6
6
  import { wrapResponseWithKeepalive } from '../utils/sse-keepalive'
7
7
  import { prepareWorkstreamRunCore } from './workstream-turn-preparation.service'
8
- import type { WorkstreamTurnParams, WorkstreamApprovalContinuationParams } from './workstream-turn-preparation.service'
8
+ import type {
9
+ PreparedWorkstreamTurnResult,
10
+ WorkstreamApprovalContinuationParams,
11
+ WorkstreamTurnParams,
12
+ } from './workstream-turn-preparation.service'
9
13
 
10
14
  export { hasApprovalRespondedParts, isApprovalContinuationRequest }
11
15
  export { wrapResponseWithKeepalive }
16
+ export type { PreparedWorkstreamTurnResult }
12
17
 
13
18
  export async function createWorkstreamApprovalContinuationStream(params: WorkstreamApprovalContinuationParams) {
14
19
  const timer = lotaDebugLogger.timer('turn:approval-continuation')
@@ -55,10 +60,13 @@ export async function createWorkstreamTurnStream(params: WorkstreamTurnParams) {
55
60
  })
56
61
  }
57
62
 
58
- export async function runWorkstreamTurnInBackground(params: WorkstreamTurnParams): Promise<void> {
63
+ export async function runWorkstreamTurnInBackground(
64
+ params: WorkstreamTurnParams,
65
+ ): Promise<PreparedWorkstreamTurnResult> {
59
66
  const timer = lotaDebugLogger.timer('turn:background')
60
67
  const prepared = await prepareWorkstreamRunCore({ ...params, kind: 'userTurn' })
61
68
  timer.step('prepare')
62
- await prepared.run()
69
+ const result = await prepared.run()
63
70
  timer.step('run')
71
+ return result
64
72
  }