@gotcos/glasses-server 6.43.4 → 6.44.1

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.
@@ -0,0 +1,642 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import {
3
+ acquireMaintenanceWork,
4
+ MaintenanceLifecycleError,
5
+ type MaintenanceWorkLease,
6
+ } from './maintenance-lifecycle.js'
7
+ import {
8
+ loadMorningBriefConfig,
9
+ morningBriefPaths,
10
+ type MorningBriefConfig,
11
+ } from './morning-brief-config.js'
12
+ import { localClock, shiftDay } from './morning-brief-schedule.js'
13
+ import {
14
+ QueryJobActiveGenerationError,
15
+ QueryJobAnswerCommittingError,
16
+ QueryJobGenerationOrderError,
17
+ QueryJobIdentityConflictError,
18
+ QueryJobNotFoundError,
19
+ QueryJobPersistenceError,
20
+ QueryJobProviderOrphanFenceError,
21
+ type QueryJobAdmissionResult,
22
+ type QueryJobMutationResult,
23
+ } from './query-job-store.js'
24
+ import { QueryJobCoordinatorError } from './query-job-coordinator.js'
25
+ import {
26
+ DISPATCH_ALLOWED_TOOLS,
27
+ isTerminalQueryJobStatus,
28
+ type QueryJobRequest,
29
+ type QueryJobSnapshot,
30
+ } from './query-job-types.js'
31
+ import {
32
+ FULL_DOMAINS,
33
+ TASK_DISPATCH_LIMITS,
34
+ TASK_DISPATCH_WALL_MS,
35
+ TASK_JOB_LOST_MS,
36
+ TASK_LEASE_CEILING_MS,
37
+ TASK_RECONCILE_WALL_MS,
38
+ TASK_TODAY_PURGE_HORIZON_DAYS,
39
+ TaskRunError,
40
+ composeTaskDispatchPrompt,
41
+ findTaskRow,
42
+ isCatchUpDue,
43
+ liveLedgerFor,
44
+ liveTaskReservations,
45
+ loadAllRows,
46
+ loadDispatchCap,
47
+ loadDomainRows,
48
+ loadTaskLedger,
49
+ parseRunAt,
50
+ saveTaskLedger,
51
+ serializeTaskWork,
52
+ setTaskMarker,
53
+ taskDispatchModel,
54
+ taskStorePaths,
55
+ type BridgeTaskRow,
56
+ type TaskDomain,
57
+ type TaskRun,
58
+ type TaskStorePaths,
59
+ } from './task-store.js'
60
+
61
+ export interface TaskDispatcherDeps {
62
+ now?: () => number
63
+ paths?: TaskStorePaths
64
+ submit: (request: QueryJobRequest) => Promise<QueryJobAdmissionResult>
65
+ findByClientGeneration: (clientJobId: string, generation: number) => Promise<QueryJobSnapshot | undefined>
66
+ getSnapshot: (jobId: string) => Promise<QueryJobSnapshot>
67
+ getExecution: (jobId: string) => Promise<{ request: QueryJobRequest }>
68
+ cancel: (jobId: string, generation: number) => Promise<QueryJobMutationResult>
69
+ complete: (jobId: string, input: { text: string }) => Promise<QueryJobMutationResult>
70
+ createSession: () => string
71
+ currentMessageEra: () => string
72
+ currentMessageMax: () => number
73
+ durableJobsEnabled: () => boolean
74
+ admissionsOpen: () => boolean
75
+ loadRows?: (day: string) => Promise<BridgeTaskRow[]>
76
+ setMarker?: (domain: string, id: string, marker: string | null) => Promise<void>
77
+ projectTerminal?: (job: QueryJobSnapshot, request: QueryJobRequest) => Promise<void>
78
+ finishIfActive?: (jobId: string) => void
79
+ acquireDispatchLease?: () => MaintenanceWorkLease
80
+ config?: () => MorningBriefConfig
81
+ }
82
+
83
+ type RestrictedRequest = QueryJobRequest & { dispatch: NonNullable<QueryJobRequest['dispatch']> }
84
+
85
+ const submitLive = new Set<string>()
86
+ let reservedManual = 0
87
+ let backgroundSlots = 0
88
+ let reconcileCursor = 0
89
+ let lastTodayPurgeDay = ''
90
+ let bound: TaskDispatcherDeps | null = null
91
+
92
+ export function bindTaskDispatcher(deps: TaskDispatcherDeps): void {
93
+ bound = deps
94
+ }
95
+
96
+ function requireBound(): TaskDispatcherDeps {
97
+ if (!bound) throw new Error('task dispatcher not bound')
98
+ return bound
99
+ }
100
+
101
+ export function taskClientJobId(taskId: string, day: string, attempt: number, tz: string): string {
102
+ const digest = createHash('sha256').update(`task|${taskId}|${tz}|${day}|${attempt}`).digest()
103
+ const bytes = Buffer.from(digest.subarray(0, 16))
104
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
105
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
106
+ const hex = bytes.toString('hex')
107
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
108
+ }
109
+
110
+ function submitKey(taskId: string, day: string, attempt: number): string {
111
+ return `${taskId}|${day}|${attempt}`
112
+ }
113
+
114
+ function nowMs(deps: TaskDispatcherDeps): number {
115
+ return (deps.now ?? Date.now)()
116
+ }
117
+
118
+ function pathsOf(deps: TaskDispatcherDeps): TaskStorePaths {
119
+ return deps.paths ?? taskStorePaths()
120
+ }
121
+
122
+ function configOf(deps: TaskDispatcherDeps): MorningBriefConfig {
123
+ return deps.config?.() ?? loadMorningBriefConfig(morningBriefPaths(), new Date(nowMs(deps))).config
124
+ }
125
+
126
+ function buildTaskRequest(run: TaskRun, tz: string): RestrictedRequest {
127
+ return {
128
+ clientJobId: run.clientJobId,
129
+ generation: 1,
130
+ query: composeTaskDispatchPrompt(run.line, run.day, tz),
131
+ sessionId: run.sessionId,
132
+ model: run.model,
133
+ ...(run.effort ? { effort: run.effort } : {}),
134
+ activityToolMode: 'status',
135
+ attachmentIds: [],
136
+ attachmentRefs: [],
137
+ origin: { kind: 'task', id: run.taskId },
138
+ dispatch: { restricted: true, tools: [...DISPATCH_ALLOWED_TOOLS] },
139
+ ...(run.messageEra ? { messageEra: run.messageEra } : {}),
140
+ ...(run.globalMsgNum != null ? { globalMsgNum: run.globalMsgNum } : {}),
141
+ }
142
+ }
143
+
144
+ function classifySubmitError(error: unknown): 'identity_conflict' | 'adopt' | 'fence' | 'drain' | 'transient' {
145
+ if (error instanceof QueryJobIdentityConflictError) return 'identity_conflict'
146
+ if (error instanceof QueryJobActiveGenerationError || error instanceof QueryJobGenerationOrderError) return 'adopt'
147
+ if (error instanceof QueryJobProviderOrphanFenceError) return 'fence'
148
+ if (error instanceof MaintenanceLifecycleError) return 'drain'
149
+ if (error instanceof QueryJobCoordinatorError && error.code === 'query_job_coordinator_shutting_down') return 'transient'
150
+ return 'transient'
151
+ }
152
+
153
+ async function mintRun(
154
+ deps: TaskDispatcherDeps,
155
+ row: BridgeTaskRow,
156
+ trigger: 'manual' | 'scheduled',
157
+ catchUp: boolean,
158
+ ): Promise<TaskRun> {
159
+ const config = configOf(deps)
160
+ const clock = localClock(nowMs(deps), config.timezone)
161
+ return serializeTaskWork(() => {
162
+ const paths = pathsOf(deps)
163
+ const ledger = loadTaskLedger(paths)
164
+ if (ledger.some(run => run.taskId === row.id && run.day === clock.day && (run.status === 'dispatching' || run.status === 'running'))) {
165
+ throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
166
+ }
167
+ const attempt = 1 + ledger.filter(run => run.taskId === row.id && run.day === clock.day).length
168
+ const model = taskDispatchModel(config)
169
+ const run: TaskRun = {
170
+ id: randomUUID(),
171
+ identity: `${row.id}|${clock.day}|${attempt}`,
172
+ taskId: row.id,
173
+ ref: row.ref,
174
+ domain: row.domain as TaskDomain,
175
+ line: row.description,
176
+ ...(row.run_at ? { scheduledFor: row.run_at } : {}),
177
+ day: clock.day,
178
+ attempt,
179
+ trigger,
180
+ firedAt: new Date(nowMs(deps)).toISOString(),
181
+ clientJobId: taskClientJobId(row.id, clock.day, attempt, config.timezone),
182
+ generation: 1,
183
+ sessionId: deps.createSession(),
184
+ messageEra: deps.currentMessageEra(),
185
+ globalMsgNum: deps.currentMessageMax() + 1,
186
+ status: 'dispatching',
187
+ submitAttempts: 0,
188
+ retryAfter: nowMs(deps) + TASK_DISPATCH_WALL_MS,
189
+ ...(catchUp ? { catchUp: true } : {}),
190
+ model,
191
+ activityToolMode: 'status',
192
+ }
193
+ saveTaskLedger(paths, [...ledger, run])
194
+ return run
195
+ })
196
+ }
197
+
198
+ async function persistRun(deps: TaskDispatcherDeps, runId: string, patch: Partial<TaskRun>): Promise<TaskRun | undefined> {
199
+ return serializeTaskWork(() => {
200
+ const paths = pathsOf(deps)
201
+ const ledger = loadTaskLedger(paths)
202
+ const index = ledger.findIndex(run => run.id === runId)
203
+ if (index < 0) return undefined
204
+ const next = { ...ledger[index], ...patch }
205
+ ledger[index] = next
206
+ saveTaskLedger(paths, ledger)
207
+ return next
208
+ })
209
+ }
210
+
211
+ async function adoptOrSubmit(deps: TaskDispatcherDeps, run: TaskRun): Promise<TaskRun> {
212
+ const config = configOf(deps)
213
+ const key = submitKey(run.taskId, run.day, run.attempt)
214
+ const found = await deps.findByClientGeneration(run.clientJobId, 1).catch(() => undefined)
215
+ if (found && !isTerminalQueryJobStatus(found.status)) {
216
+ const accepted = await persistRun(deps, run.id, {
217
+ jobId: found.jobId,
218
+ generation: 1,
219
+ lastKnownStatus: found.status,
220
+ status: 'running',
221
+ })
222
+ return accepted ?? { ...run, jobId: found.jobId, status: 'running', lastKnownStatus: found.status }
223
+ }
224
+
225
+ submitLive.add(key)
226
+ try {
227
+ const admission = await deps.submit(buildTaskRequest(run, config.timezone))
228
+ const accepted = await persistRun(deps, run.id, {
229
+ jobId: admission.job.jobId,
230
+ generation: 1,
231
+ lastKnownStatus: admission.job.status,
232
+ status: 'running',
233
+ })
234
+ return accepted ?? { ...run, jobId: admission.job.jobId, status: 'running', lastKnownStatus: admission.job.status }
235
+ } catch (error) {
236
+ const kind = classifySubmitError(error)
237
+ if (kind === 'identity_conflict') {
238
+ const failed = await persistRun(deps, run.id, {
239
+ status: 'failed',
240
+ error: { code: 'identity_conflict', message: error instanceof Error ? error.message : 'identity conflict' },
241
+ completedAt: new Date(nowMs(deps)).toISOString(),
242
+ })
243
+ throw new TaskRunError(503, 'identity_conflict', failed?.error?.message ?? 'identity conflict')
244
+ }
245
+ if (kind === 'adopt') {
246
+ const existing = await deps.findByClientGeneration(run.clientJobId, 1).catch(() => undefined)
247
+ if (existing && !isTerminalQueryJobStatus(existing.status)) {
248
+ const accepted = await persistRun(deps, run.id, {
249
+ jobId: existing.jobId,
250
+ lastKnownStatus: existing.status,
251
+ status: 'running',
252
+ })
253
+ return accepted ?? run
254
+ }
255
+ }
256
+ const fenceMs = error instanceof QueryJobProviderOrphanFenceError ? error.retryAfterMs : TASK_DISPATCH_LIMITS.submitSpacingMs
257
+ const consume = kind !== 'drain'
258
+ await persistRun(deps, run.id, {
259
+ status: 'dispatching',
260
+ retryAfter: nowMs(deps) + fenceMs,
261
+ ...(consume ? { submitAttempts: run.submitAttempts + 1 } : {}),
262
+ })
263
+ throw new TaskRunError(503, kind === 'drain' ? 'admissions_closed' : 'submit_failed', error instanceof Error ? error.message : 'submit failed')
264
+ } finally {
265
+ submitLive.delete(key)
266
+ }
267
+ }
268
+
269
+ async function markRunning(deps: TaskDispatcherDeps, run: TaskRun): Promise<void> {
270
+ const setMarker = deps.setMarker ?? setTaskMarker
271
+ await setMarker(run.domain, run.taskId, 'running')
272
+ }
273
+
274
+ function pickEligible(
275
+ rows: BridgeTaskRow[],
276
+ ledger: TaskRun[],
277
+ day: string,
278
+ now: number,
279
+ tz: string,
280
+ catchUpMinutes: number,
281
+ capPerDay: number,
282
+ ): BridgeTaskRow[] {
283
+ const todayRuns = ledger.filter(run => run.day === day)
284
+ if (todayRuns.length >= capPerDay) return []
285
+ return rows.filter(row => {
286
+ if (row.archived || row.delegated || row.is_checked) return false
287
+ if (!isCatchUpDue(row, now, tz, catchUpMinutes)) return false
288
+ if (row.agent_state === 'running') return false
289
+ const inDay = todayRuns.filter(run => run.taskId === row.id)
290
+ if (inDay.some(run => run.status === 'dispatching' || run.status === 'running')) return false
291
+ if (inDay.some(run => run.status === 'failed' && run.error?.code === 'submit_exhausted')) return false
292
+ if (inDay.length >= TASK_DISPATCH_LIMITS.runsPerDay) return false
293
+ const latest = [...inDay].sort((a, b) => b.firedAt.localeCompare(a.firedAt))[0]
294
+ if (latest && now - Date.parse(latest.firedAt) < TASK_DISPATCH_LIMITS.runSpacingMs) return false
295
+ return true
296
+ })
297
+ }
298
+
299
+ function acquireSlots(kind: 'manual' | 'background'): void {
300
+ if (kind === 'manual') {
301
+ if (reservedManual >= 1) throw new TaskRunError(503, 'dispatch_slots_busy', 'Another manual run is already using the reserved slot.')
302
+ reservedManual += 1
303
+ return
304
+ }
305
+ if (backgroundSlots >= TASK_DISPATCH_LIMITS.perTick) {
306
+ throw new TaskRunError(503, 'dispatch_slots_busy', 'Background dispatch slots are busy.')
307
+ }
308
+ backgroundSlots += 1
309
+ }
310
+
311
+ function releaseSlots(kind: 'manual' | 'background'): void {
312
+ if (kind === 'manual') reservedManual = Math.max(0, reservedManual - 1)
313
+ else backgroundSlots = Math.max(0, backgroundSlots - 1)
314
+ }
315
+
316
+ function withLease<T>(deps: TaskDispatcherDeps, work: (remaining: () => number) => Promise<T>): Promise<T> {
317
+ let lease: MaintenanceWorkLease
318
+ try {
319
+ lease = deps.acquireDispatchLease?.() ?? acquireMaintenanceWork('task_dispatch')
320
+ } catch (error) {
321
+ if (error instanceof MaintenanceLifecycleError) {
322
+ return Promise.reject(new TaskRunError(503, 'admissions_closed', 'The server is in maintenance. Try again in a moment.'))
323
+ }
324
+ throw error
325
+ }
326
+ const started = Date.now()
327
+ const remaining = () => Math.max(0, TASK_DISPATCH_WALL_MS - (Date.now() - started))
328
+ let released = false
329
+ const release = () => {
330
+ if (released) return
331
+ released = true
332
+ lease.release()
333
+ }
334
+ const timer = setTimeout(release, TASK_LEASE_CEILING_MS)
335
+ return work(remaining).finally(() => {
336
+ clearTimeout(timer)
337
+ release()
338
+ })
339
+ }
340
+
341
+ export async function runTaskNow(id: string, domain: string, injected?: TaskDispatcherDeps): Promise<{ runId: string }> {
342
+ const deps = injected ?? requireBound()
343
+ if (!deps.durableJobsEnabled()) {
344
+ throw new TaskRunError(409, 'durable_jobs_off', 'Turn on Background jobs in COS Control to run a task.')
345
+ }
346
+ if (!deps.admissionsOpen()) {
347
+ throw new TaskRunError(503, 'admissions_closed', 'The server is in maintenance. Try again in a moment.')
348
+ }
349
+ const config = configOf(deps)
350
+ const clock = localClock(nowMs(deps), config.timezone)
351
+ if (liveLedgerFor(id, clock.day, pathsOf(deps))) {
352
+ throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
353
+ }
354
+ const row = deps.loadRows
355
+ ? (await deps.loadRows(clock.day)).find(item => item.id === id && item.domain === domain)
356
+ : await findTaskRow(domain, id, clock.day)
357
+ if (!row) throw new TaskRunError(404, 'task_not_found', `no task ${id} in ${domain}`)
358
+ if (row.agent_state === 'running') {
359
+ throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
360
+ }
361
+ const runAt = parseRunAt(row.run_at)
362
+ if (runAt && runAt.day > clock.day) {
363
+ throw new TaskRunError(409, 'scheduled_future', 'Run now is not allowed on a future scheduled task.')
364
+ }
365
+ const todayRuns = loadTaskLedger(pathsOf(deps)).filter(run => run.taskId === id && run.day === clock.day)
366
+ if (todayRuns.length >= TASK_DISPATCH_LIMITS.runsPerDay) {
367
+ throw new TaskRunError(429, 'runs_exhausted', `Run now is limited to ${TASK_DISPATCH_LIMITS.runsPerDay} attempts a day.`)
368
+ }
369
+ try {
370
+ acquireSlots('manual')
371
+ } catch (error) {
372
+ if (error instanceof TaskRunError && error.code === 'dispatch_slots_busy') {
373
+ const live = liveLedgerFor(id, clock.day, pathsOf(deps))
374
+ if (live) throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
375
+ }
376
+ throw error
377
+ }
378
+ try {
379
+ return await withLease(deps, async () => {
380
+ const minted = await mintRun(deps, row, 'manual', false)
381
+ const running = await adoptOrSubmit(deps, minted)
382
+ await markRunning(deps, running)
383
+ return { runId: running.id }
384
+ })
385
+ } finally {
386
+ releaseSlots('manual')
387
+ }
388
+ }
389
+
390
+ export async function dispatchDueTasks(injected?: TaskDispatcherDeps): Promise<{ fired: number; reason?: string }> {
391
+ const deps = injected ?? requireBound()
392
+ if (!deps.durableJobsEnabled()) return { fired: 0, reason: 'durable_jobs_off' }
393
+ if (!deps.admissionsOpen()) return { fired: 0, reason: 'admissions_closed' }
394
+ const config = configOf(deps)
395
+ const clock = localClock(nowMs(deps), config.timezone)
396
+ const cap = loadDispatchCap(pathsOf(deps))
397
+ if (cap === 0) return { fired: 0, reason: 'cap_off' }
398
+ try {
399
+ return await withLease(deps, async () => {
400
+ const rows = await (deps.loadRows ?? loadAllRows)(clock.day)
401
+ const ledger = loadTaskLedger(pathsOf(deps))
402
+ const eligible = pickEligible(rows, ledger, clock.day, nowMs(deps), config.timezone, config.taskCatchUpMinutes, cap)
403
+ let fired = 0
404
+ for (const row of eligible) {
405
+ if (fired >= TASK_DISPATCH_LIMITS.perTick) break
406
+ if (backgroundSlots >= TASK_DISPATCH_LIMITS.perTick) break
407
+ try {
408
+ acquireSlots('background')
409
+ const minted = await mintRun(deps, row, 'scheduled', true)
410
+ const running = await adoptOrSubmit(deps, minted)
411
+ await markRunning(deps, running)
412
+ fired += 1
413
+ } catch (error) {
414
+ if (error instanceof TaskRunError && (error.code === 'task_running' || error.code === 'dispatch_slots_busy' || error.code === 'admissions_closed')) {
415
+ break
416
+ }
417
+ } finally {
418
+ releaseSlots('background')
419
+ }
420
+ }
421
+ return { fired }
422
+ })
423
+ } catch (error) {
424
+ if (error instanceof TaskRunError && error.code === 'admissions_closed') {
425
+ return { fired: 0, reason: 'maintenance_drain_active' }
426
+ }
427
+ throw error
428
+ }
429
+ }
430
+
431
+ async function saveRow(deps: TaskDispatcherDeps, next: TaskRun): Promise<void> {
432
+ await serializeTaskWork(() => {
433
+ const paths = pathsOf(deps)
434
+ const ledger = loadTaskLedger(paths)
435
+ const index = ledger.findIndex(run => run.id === next.id)
436
+ if (index >= 0) ledger[index] = next
437
+ else ledger.push(next)
438
+ saveTaskLedger(paths, ledger)
439
+ })
440
+ }
441
+
442
+ async function decideDispatchingAction(
443
+ deps: TaskDispatcherDeps,
444
+ run: TaskRun,
445
+ day: string,
446
+ ): Promise<'redrive' | 'done'> {
447
+ return serializeTaskWork(() => {
448
+ const paths = pathsOf(deps)
449
+ const ledger = loadTaskLedger(paths)
450
+ const current = ledger.find(item => item.id === run.id)
451
+ if (!current || current.status !== 'dispatching') return 'done'
452
+ if (current.day !== day) {
453
+ const index = ledger.findIndex(item => item.id === run.id)
454
+ ledger[index] = {
455
+ ...current,
456
+ status: 'failed',
457
+ error: { code: 'expired', message: 'Dispatch expired' },
458
+ completedAt: new Date(nowMs(deps)).toISOString(),
459
+ }
460
+ saveTaskLedger(paths, ledger)
461
+ return 'done'
462
+ }
463
+ if (current.submitAttempts >= TASK_DISPATCH_LIMITS.submitAttempts && !current.jobId) {
464
+ const index = ledger.findIndex(item => item.id === run.id)
465
+ ledger[index] = {
466
+ ...current,
467
+ status: 'failed',
468
+ error: { code: 'submit_exhausted', message: 'Submit exhausted' },
469
+ completedAt: new Date(nowMs(deps)).toISOString(),
470
+ }
471
+ saveTaskLedger(paths, ledger)
472
+ return 'done'
473
+ }
474
+ if (submitLive.has(submitKey(current.taskId, current.day, current.attempt))) return 'done'
475
+ if (current.retryAfter && current.retryAfter > nowMs(deps)) return 'done'
476
+ return 'redrive'
477
+ })
478
+ }
479
+
480
+ async function reconcileOne(
481
+ deps: TaskDispatcherDeps,
482
+ run: TaskRun,
483
+ rows: BridgeTaskRow[],
484
+ day: string,
485
+ ): Promise<void> {
486
+ const setMarker = deps.setMarker ?? setTaskMarker
487
+ const row = rows.find(item => item.id === run.taskId)
488
+
489
+ if (run.status === 'running') {
490
+ if (!row) {
491
+ await saveRow(deps, { ...run, status: 'orphaned', error: { code: 'orphaned', message: 'Task row gone' }, completedAt: new Date(nowMs(deps)).toISOString() })
492
+ return
493
+ }
494
+ if (row.agent_state !== 'running' && run.scheduledFor && row.run_at && row.run_at !== run.scheduledFor && run.jobId) {
495
+ try {
496
+ const result = await deps.cancel(run.jobId, run.generation)
497
+ if (result.applied) {
498
+ await saveRow(deps, { ...run, status: 'superseded', error: { code: 'superseded', message: 'Rescheduled before it ran' }, completedAt: new Date(nowMs(deps)).toISOString() })
499
+ return
500
+ }
501
+ if (result.job.status === 'completed') {
502
+ await saveRow(deps, { ...run, status: 'done', completedAt: result.job.completedAt ?? new Date(nowMs(deps)).toISOString() })
503
+ return
504
+ }
505
+ if (result.job.status === 'failed' || result.job.status === 'canceled' || result.job.status === 'interrupted') {
506
+ await saveRow(deps, {
507
+ ...run,
508
+ status: 'superseded',
509
+ error: { code: result.job.status, message: result.job.error?.message ?? result.job.status },
510
+ completedAt: new Date(nowMs(deps)).toISOString(),
511
+ })
512
+ return
513
+ }
514
+ } catch (error) {
515
+ if (error instanceof QueryJobAnswerCommittingError && run.jobId) {
516
+ const snapshot = await deps.getSnapshot(run.jobId).catch(() => undefined)
517
+ const completed = await deps.complete(run.jobId, { text: snapshot?.response ?? snapshot?.partialText ?? '' })
518
+ const execution = await deps.getExecution(run.jobId).catch(() => undefined)
519
+ if (execution) await deps.projectTerminal?.(completed.job, execution.request)
520
+ deps.finishIfActive?.(run.jobId)
521
+ await saveRow(deps, { ...run, status: 'done', completedAt: completed.job.completedAt ?? new Date(nowMs(deps)).toISOString() })
522
+ return
523
+ }
524
+ if (error instanceof QueryJobNotFoundError) {
525
+ await saveRow(deps, { ...run, status: 'orphaned', error: { code: 'orphaned', message: 'Job not found' }, completedAt: new Date(nowMs(deps)).toISOString() })
526
+ return
527
+ }
528
+ if (error instanceof QueryJobPersistenceError) return
529
+ }
530
+ return
531
+ }
532
+ if (row.agent_state !== 'running') {
533
+ try {
534
+ await setMarker(run.domain, run.taskId, 'running')
535
+ } catch {
536
+ /* next pass */
537
+ }
538
+ return
539
+ }
540
+ if (!run.jobId) {
541
+ if (nowMs(deps) - Date.parse(run.firedAt) > TASK_JOB_LOST_MS) {
542
+ await saveRow(deps, { ...run, status: 'failed', error: { code: 'job_lost', message: 'Job lost' }, completedAt: new Date(nowMs(deps)).toISOString() })
543
+ await setMarker(run.domain, run.taskId, 'failed').catch(() => undefined)
544
+ }
545
+ return
546
+ }
547
+ const snapshot = await deps.getSnapshot(run.jobId).catch(() => undefined)
548
+ if (!snapshot) {
549
+ if (nowMs(deps) - Date.parse(run.firedAt) > TASK_JOB_LOST_MS) {
550
+ await saveRow(deps, { ...run, status: 'failed', error: { code: 'job_lost', message: 'Job lost' }, completedAt: new Date(nowMs(deps)).toISOString() })
551
+ await setMarker(run.domain, run.taskId, 'failed').catch(() => undefined)
552
+ }
553
+ return
554
+ }
555
+ if (snapshot.status === 'completed') {
556
+ const n = snapshot.globalMsgNum ?? run.globalMsgNum
557
+ await saveRow(deps, { ...run, status: 'done', lastKnownStatus: snapshot.status, completedAt: snapshot.completedAt ?? new Date(nowMs(deps)).toISOString() })
558
+ await setMarker(run.domain, run.taskId, n != null ? `done:${n}` : 'failed').catch(() => undefined)
559
+ return
560
+ }
561
+ if (snapshot.status === 'failed' || snapshot.status === 'canceled' || snapshot.status === 'interrupted') {
562
+ const n = snapshot.globalMsgNum ?? run.globalMsgNum
563
+ await saveRow(deps, {
564
+ ...run,
565
+ status: 'failed',
566
+ lastKnownStatus: snapshot.status,
567
+ error: { code: snapshot.error?.code ?? snapshot.status, message: snapshot.error?.message ?? snapshot.status },
568
+ completedAt: snapshot.completedAt ?? new Date(nowMs(deps)).toISOString(),
569
+ })
570
+ await setMarker(run.domain, run.taskId, n != null ? `failed:${n}` : 'failed').catch(() => undefined)
571
+ }
572
+ return
573
+ }
574
+
575
+ if (run.status === 'dispatching') {
576
+ const action = await decideDispatchingAction(deps, run, day)
577
+ if (action !== 'redrive') return
578
+ try {
579
+ const running = await adoptOrSubmit(deps, run)
580
+ if (running.status === 'running') await markRunning(deps, running)
581
+ } catch {
582
+ /* next pass */
583
+ }
584
+ }
585
+ }
586
+
587
+ export async function reconcileDispatch(injected?: TaskDispatcherDeps): Promise<{ fired: number; reason?: string }> {
588
+ const deps = injected ?? requireBound()
589
+ const config = configOf(deps)
590
+ const clock = localClock(nowMs(deps), config.timezone)
591
+ let lease: MaintenanceWorkLease | undefined
592
+ try {
593
+ lease = deps.acquireDispatchLease?.() ?? acquireMaintenanceWork('task_dispatch')
594
+ } catch (error) {
595
+ if (error instanceof MaintenanceLifecycleError) return { fired: 0, reason: 'maintenance_drain_active' }
596
+ throw error
597
+ }
598
+ const timer = setTimeout(() => lease?.release(), TASK_LEASE_CEILING_MS)
599
+ try {
600
+ const groups = await Promise.all(FULL_DOMAINS.map(domain => (deps.loadRows ? deps.loadRows(clock.day) : loadDomainRows(domain, clock.day))))
601
+ const rows = groups.flat()
602
+ const ledger = loadTaskLedger(pathsOf(deps))
603
+ const live = ledger.filter(run => run.status === 'dispatching' || run.status === 'running')
604
+ const deadline = Date.now() + TASK_RECONCILE_WALL_MS
605
+ const start = live.length ? reconcileCursor % live.length : 0
606
+ const ordered = live.length ? [...live.slice(start), ...live.slice(0, start)] : []
607
+ let fired = 0
608
+ for (const run of ordered.slice(0, TASK_DISPATCH_LIMITS.reconcilePerTick)) {
609
+ if (Date.now() > deadline) break
610
+ await reconcileOne(deps, run, rows, clock.day)
611
+ fired += 1
612
+ }
613
+ reconcileCursor += TASK_DISPATCH_LIMITS.reconcilePerTick
614
+ if (lastTodayPurgeDay !== clock.day) {
615
+ lastTodayPurgeDay = clock.day
616
+ void shiftDay(clock.day, -TASK_TODAY_PURGE_HORIZON_DAYS)
617
+ }
618
+ const markers = rows.filter(row => row.agent_state && !ledger.some(run => run.taskId === row.id))
619
+ for (const row of markers.slice(0, 1)) {
620
+ const setMarker = deps.setMarker ?? setTaskMarker
621
+ await setMarker(row.domain, row.id, 'failed').catch(() => undefined)
622
+ }
623
+ return { fired }
624
+ } finally {
625
+ clearTimeout(timer)
626
+ lease.release()
627
+ }
628
+ }
629
+
630
+ export function reservationsForEra(era: string, injected?: TaskDispatcherDeps) {
631
+ const deps = injected ?? bound
632
+ return liveTaskReservations(era, deps ? nowMs(deps) : Date.now(), deps?.paths)
633
+ }
634
+
635
+ export function __resetTaskDispatcherForTests(): void {
636
+ submitLive.clear()
637
+ reservedManual = 0
638
+ backgroundSlots = 0
639
+ reconcileCursor = 0
640
+ lastTodayPurgeDay = ''
641
+ bound = null
642
+ }