@gotcos/glasses-server 6.43.3 → 6.44.0
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.
- package/CHANGELOG.md +50 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +2 -0
- package/server/lib/archive.ts +7 -3
- package/server/lib/claude-bridge.ts +30 -12
- package/server/lib/conversation.ts +27 -0
- package/server/lib/maintenance-lifecycle.ts +6 -0
- package/server/lib/model-router.ts +4 -0
- package/server/lib/morning-brief-config.ts +22 -1
- package/server/lib/morning-brief-prompt.ts +16 -2
- package/server/lib/morning-brief-runtime.ts +32 -1
- package/server/lib/morning-brief-schedule.ts +19 -2
- package/server/lib/morning-brief-scheduler.ts +111 -9
- package/server/lib/python-bridge.ts +18 -4
- package/server/lib/query-job-coordinator.ts +8 -2
- package/server/lib/query-job-runtime.ts +22 -2
- package/server/lib/query-job-store.ts +33 -3
- package/server/lib/query-job-types.ts +147 -2
- package/server/lib/task-dispatcher.ts +642 -0
- package/server/lib/task-store.ts +624 -0
- package/server/routes/health.ts +11 -1
- package/server/routes/message-ref.ts +2 -1
- package/server/routes/query-jobs.ts +1 -1
- package/server/routes/sessions.ts +10 -1
- package/server/routes/tasks.ts +152 -0
|
@@ -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
|
+
}
|