@gotcos/glasses-server 6.9.0 → 6.10.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/.env.example +8 -0
- package/CHANGELOG.md +24 -0
- package/README.md +10 -1
- package/package.json +1 -1
- package/server/index.ts +40 -11
- package/server/lib/claude-bridge.ts +115 -12
- package/server/lib/codex-bridge.ts +151 -28
- package/server/lib/conversation.ts +194 -1
- package/server/lib/model-router.ts +6 -6
- package/server/lib/query-job-coordinator.ts +580 -0
- package/server/lib/query-job-feature.ts +20 -0
- package/server/lib/query-job-runtime.ts +255 -0
- package/server/lib/query-job-store.ts +1102 -0
- package/server/lib/query-job-types.ts +358 -0
- package/server/lib/run-output-images.ts +44 -0
- package/server/routes/health.ts +62 -2
- package/server/routes/query-jobs.ts +294 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { getOrCreateSession } from './conversation.js'
|
|
2
|
+
import {
|
|
3
|
+
QueryJobStore,
|
|
4
|
+
QueryJobStoreError,
|
|
5
|
+
type QueryJobAdmissionResult,
|
|
6
|
+
type QueryJobSubscription,
|
|
7
|
+
} from './query-job-store.js'
|
|
8
|
+
import {
|
|
9
|
+
QUERY_JOB_LIMITS,
|
|
10
|
+
isTerminalQueryJobStatus,
|
|
11
|
+
normalizeQueryJobError,
|
|
12
|
+
parsePositiveInteger,
|
|
13
|
+
type QueryJobProviderLinkage,
|
|
14
|
+
type QueryJobOutputImageStats,
|
|
15
|
+
type QueryJobRequest,
|
|
16
|
+
type QueryJobSnapshot,
|
|
17
|
+
type QueryJobStoreHealth,
|
|
18
|
+
} from './query-job-types.js'
|
|
19
|
+
|
|
20
|
+
const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
21
|
+
|
|
22
|
+
export interface QueryJobRunnerStart extends QueryJobProviderLinkage {
|
|
23
|
+
sessionId?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface QueryJobRunnerCompletion extends QueryJobProviderLinkage {
|
|
27
|
+
text: string
|
|
28
|
+
attachments?: unknown
|
|
29
|
+
outputImageStats?: QueryJobOutputImageStats
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QueryJobRunnerCallbacks {
|
|
33
|
+
onStart: (start: QueryJobRunnerStart) => void | Promise<void>
|
|
34
|
+
/** Persist provider-run ownership before the bridge writes the prompt to
|
|
35
|
+
* stdin. PIDs are deliberately excluded; only stable public ledger ids fit. */
|
|
36
|
+
/** True only when provider ownership was durably fsynced for this active
|
|
37
|
+
* generation. False means cancel/shutdown won; no prompt may be written. */
|
|
38
|
+
onProviderProcess: (linkage: QueryJobProviderLinkage) => boolean | Promise<boolean>
|
|
39
|
+
onChunk: (text: string) => void
|
|
40
|
+
onToolStatus: (text: string) => void
|
|
41
|
+
onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => void
|
|
42
|
+
/** True only when this callback durably won the answer-ready transition. */
|
|
43
|
+
onAnswerReady: (text: string, linkage?: QueryJobProviderLinkage) => boolean | Promise<boolean>
|
|
44
|
+
/** True only when this callback won and durably projected the terminal. */
|
|
45
|
+
onDone: (completion: QueryJobRunnerCompletion) => boolean | Promise<boolean>
|
|
46
|
+
onError: (error: unknown) => boolean | Promise<boolean>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface QueryJobRunnerContext {
|
|
50
|
+
jobId: string
|
|
51
|
+
turnId: string
|
|
52
|
+
request: QueryJobRequest
|
|
53
|
+
signal: AbortSignal
|
|
54
|
+
callbacks: QueryJobRunnerCallbacks
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type QueryJobRunner = (context: QueryJobRunnerContext) => Promise<void>
|
|
58
|
+
|
|
59
|
+
export interface QueryJobCoordinatorOptions {
|
|
60
|
+
/** Optional because the current model router remains the authoritative
|
|
61
|
+
* session-lock owner. A future provider runner may supply a lease here. */
|
|
62
|
+
acquireSessionLock?: (sessionId: string) => (() => void) | Promise<() => void>
|
|
63
|
+
/** Defaults to the exact legacy conversation allocator. */
|
|
64
|
+
resolveSessionId?: (requested?: string) => string
|
|
65
|
+
partialFlushMs?: number
|
|
66
|
+
partialFlushChars?: number
|
|
67
|
+
providerTimeoutMs?: number
|
|
68
|
+
/** Idempotent projection of a terminal journal record into canonical
|
|
69
|
+
* conversation state. The journal remains authoritative if this fails. */
|
|
70
|
+
projectTerminal?: (job: QueryJobSnapshot, request: QueryJobRequest) => void | Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ActiveRun {
|
|
74
|
+
jobId: string
|
|
75
|
+
generation: number
|
|
76
|
+
request: QueryJobRequest
|
|
77
|
+
controller: AbortController
|
|
78
|
+
release?: () => void
|
|
79
|
+
released: boolean
|
|
80
|
+
callbackTail: Promise<void>
|
|
81
|
+
partialText: string
|
|
82
|
+
partialTruncated: boolean
|
|
83
|
+
pendingDelta: string
|
|
84
|
+
partialTimer: ReturnType<typeof setTimeout> | null
|
|
85
|
+
terminalPersisted: boolean
|
|
86
|
+
persistenceFailed: boolean
|
|
87
|
+
terminalPromise: Promise<void>
|
|
88
|
+
resolveTerminal: () => void
|
|
89
|
+
providerTimeoutPromise: Promise<void>
|
|
90
|
+
resolveProviderTimeout: () => void
|
|
91
|
+
providerTimeoutTimer: ReturnType<typeof setTimeout> | null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface QueryJobCoordinatorHealth {
|
|
95
|
+
activeRuns: number
|
|
96
|
+
shuttingDown: boolean
|
|
97
|
+
callbackPersistenceFailures: number
|
|
98
|
+
terminalProjectionFailures: number
|
|
99
|
+
store: QueryJobStoreHealth
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class QueryJobCoordinatorError extends Error {
|
|
103
|
+
constructor(readonly code: string, message = code) {
|
|
104
|
+
super(message)
|
|
105
|
+
this.name = 'QueryJobCoordinatorError'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Owns provider lifetime independently of every HTTP subscriber. The runner
|
|
110
|
+
* sees one coordinator AbortSignal; route disconnects never reach it. */
|
|
111
|
+
export class QueryJobCoordinator {
|
|
112
|
+
private readonly resolveSessionId: (requested?: string) => string
|
|
113
|
+
private readonly partialFlushMs: number
|
|
114
|
+
private readonly partialFlushChars: number
|
|
115
|
+
private readonly providerTimeoutMs: number
|
|
116
|
+
private readonly active = new Map<string, ActiveRun>()
|
|
117
|
+
private admissionTail: Promise<void> = Promise.resolve()
|
|
118
|
+
private shuttingDown = false
|
|
119
|
+
private callbackPersistenceFailures = 0
|
|
120
|
+
private terminalProjectionFailures = 0
|
|
121
|
+
|
|
122
|
+
constructor(
|
|
123
|
+
readonly store: QueryJobStore,
|
|
124
|
+
private readonly runner: QueryJobRunner,
|
|
125
|
+
private readonly options: QueryJobCoordinatorOptions = {},
|
|
126
|
+
) {
|
|
127
|
+
this.resolveSessionId = options.resolveSessionId ?? getOrCreateSession
|
|
128
|
+
this.partialFlushMs = Math.max(0, options.partialFlushMs ?? 50)
|
|
129
|
+
this.partialFlushChars = Math.max(1, options.partialFlushChars ?? 1_024)
|
|
130
|
+
this.providerTimeoutMs = Math.max(1_000, options.providerTimeoutMs ?? 21 * 60_000)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async init(): Promise<QueryJobCoordinatorHealth> {
|
|
134
|
+
await this.store.init()
|
|
135
|
+
// The journal is authoritative. Repair the derived conversation
|
|
136
|
+
// projection after a crash before advertising the runtime as ready.
|
|
137
|
+
if (this.options.projectTerminal) {
|
|
138
|
+
for (const execution of await this.store.listRetainedExecutions()) {
|
|
139
|
+
if (isTerminalQueryJobStatus(execution.job.status)) {
|
|
140
|
+
await this.projectTerminal(execution.job, execution.request)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return this.getHealth()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Admission serialization makes first-run session allocation idempotent:
|
|
148
|
+
* two simultaneous retries without a sessionId cannot allocate two sessions
|
|
149
|
+
* and conflict solely because the client had not learned the first one. */
|
|
150
|
+
submit(raw: unknown): Promise<QueryJobAdmissionResult> {
|
|
151
|
+
let resolve!: (value: QueryJobAdmissionResult) => void
|
|
152
|
+
let reject!: (reason?: unknown) => void
|
|
153
|
+
const result = new Promise<QueryJobAdmissionResult>((res, rej) => {
|
|
154
|
+
resolve = res
|
|
155
|
+
reject = rej
|
|
156
|
+
})
|
|
157
|
+
const operation = this.admissionTail.catch(() => {}).then(async () => {
|
|
158
|
+
try {
|
|
159
|
+
if (this.shuttingDown) throw new QueryJobCoordinatorError('query_job_coordinator_shutting_down')
|
|
160
|
+
const normalized = await this.assignSession(raw)
|
|
161
|
+
const admission = await this.store.admit(normalized)
|
|
162
|
+
resolve(admission)
|
|
163
|
+
if (admission.created) queueMicrotask(() => { void this.execute(admission.job.jobId) })
|
|
164
|
+
} catch (error) {
|
|
165
|
+
reject(error)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
this.admissionTail = operation.then(() => {}, () => {})
|
|
169
|
+
return result
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async assignSession(raw: unknown): Promise<unknown> {
|
|
173
|
+
if (!raw || typeof raw !== 'object') return raw
|
|
174
|
+
const input = raw as Record<string, unknown>
|
|
175
|
+
if (typeof input.sessionId === 'string' && input.sessionId.trim()) return input
|
|
176
|
+
|
|
177
|
+
const clientJobId = typeof input.clientJobId === 'string' && CLIENT_JOB_ID_RE.test(input.clientJobId)
|
|
178
|
+
? input.clientJobId.toLowerCase() : undefined
|
|
179
|
+
const generation = parsePositiveInteger(input.generation)
|
|
180
|
+
let sessionId: string | undefined
|
|
181
|
+
if (clientJobId && generation != null && generation > 0) {
|
|
182
|
+
sessionId = (await this.store.findByClientGeneration(clientJobId, generation))?.sessionId
|
|
183
|
+
}
|
|
184
|
+
sessionId ??= this.resolveSessionId(undefined)
|
|
185
|
+
return { ...input, sessionId }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async execute(jobId: string): Promise<void> {
|
|
189
|
+
const starting = await this.store.markStarting(jobId).catch(() => null)
|
|
190
|
+
if (!starting?.applied) return
|
|
191
|
+
const execution = await this.store.getExecution(jobId)
|
|
192
|
+
let release: (() => void) | undefined
|
|
193
|
+
try {
|
|
194
|
+
release = await this.options.acquireSessionLock?.(execution.request.sessionId)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
await this.store.fail(jobId, error)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let resolveTerminal!: () => void
|
|
201
|
+
const terminalPromise = new Promise<void>(resolve => { resolveTerminal = resolve })
|
|
202
|
+
let resolveProviderTimeout!: () => void
|
|
203
|
+
const providerTimeoutPromise = new Promise<void>(resolve => { resolveProviderTimeout = resolve })
|
|
204
|
+
const active: ActiveRun = {
|
|
205
|
+
jobId,
|
|
206
|
+
generation: execution.job.generation,
|
|
207
|
+
request: execution.request,
|
|
208
|
+
controller: new AbortController(),
|
|
209
|
+
release,
|
|
210
|
+
released: false,
|
|
211
|
+
callbackTail: Promise.resolve(),
|
|
212
|
+
partialText: '',
|
|
213
|
+
partialTruncated: false,
|
|
214
|
+
pendingDelta: '',
|
|
215
|
+
partialTimer: null,
|
|
216
|
+
terminalPersisted: false,
|
|
217
|
+
persistenceFailed: false,
|
|
218
|
+
terminalPromise,
|
|
219
|
+
resolveTerminal,
|
|
220
|
+
providerTimeoutPromise,
|
|
221
|
+
resolveProviderTimeout,
|
|
222
|
+
providerTimeoutTimer: null,
|
|
223
|
+
}
|
|
224
|
+
this.active.set(jobId, active)
|
|
225
|
+
|
|
226
|
+
const callbacks = this.callbacksFor(active)
|
|
227
|
+
try {
|
|
228
|
+
const runnerPromise = this.runner({
|
|
229
|
+
jobId,
|
|
230
|
+
turnId: execution.job.turnId,
|
|
231
|
+
request: execution.request,
|
|
232
|
+
signal: active.controller.signal,
|
|
233
|
+
callbacks,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const runnerFailed = runnerPromise.then(
|
|
237
|
+
() => new Promise<never>(() => {}),
|
|
238
|
+
error => Promise.reject(error),
|
|
239
|
+
)
|
|
240
|
+
// Provider lifetime ends only at a terminal callback, never merely when
|
|
241
|
+
// runnerPromise resolves after child spawn. The timeout is armed by the
|
|
242
|
+
// durable onStart boundary, after model-router acquires its per-session
|
|
243
|
+
// lock; queued jobs therefore retain their full execution budget.
|
|
244
|
+
const outcome = await Promise.race([
|
|
245
|
+
active.terminalPromise.then(() => 'terminal' as const),
|
|
246
|
+
active.providerTimeoutPromise.then(() => 'timeout' as const),
|
|
247
|
+
runnerFailed,
|
|
248
|
+
])
|
|
249
|
+
if (outcome === 'timeout' && !active.terminalPersisted && !active.persistenceFailed) {
|
|
250
|
+
const snapshot = await this.store.getSnapshot(active.jobId)
|
|
251
|
+
if (snapshot.status === 'answer_ready') {
|
|
252
|
+
// Generation is finished; only bridge post-processing missed its
|
|
253
|
+
// deadline. Preserve the already-durable answer instead of turning
|
|
254
|
+
// a successful model run into a failure.
|
|
255
|
+
await this.completeActive(active, {
|
|
256
|
+
text: snapshot.partialText,
|
|
257
|
+
attachments: snapshot.attachments,
|
|
258
|
+
outputImageStats: snapshot.outputImageStats,
|
|
259
|
+
provider: snapshot.provider,
|
|
260
|
+
resolvedModel: snapshot.resolvedModel,
|
|
261
|
+
cliSessionId: snapshot.cliSessionId,
|
|
262
|
+
claudeRunId: snapshot.claudeRunId,
|
|
263
|
+
codexRunId: snapshot.codexRunId,
|
|
264
|
+
codexThreadId: snapshot.codexThreadId,
|
|
265
|
+
})
|
|
266
|
+
// Generation is already durable, but bridge post-processing missed
|
|
267
|
+
// its deadline. Abort that tail after committing the answer so the
|
|
268
|
+
// coordinator-owned session lease is always released.
|
|
269
|
+
active.controller.abort(Object.assign(new Error('Post-answer processing exceeded the durable job deadline.'), {
|
|
270
|
+
code: 'postprocess_timeout',
|
|
271
|
+
}))
|
|
272
|
+
} else {
|
|
273
|
+
const error = { code: 'provider_timeout', message: 'Provider exceeded the durable job deadline.', retryable: true }
|
|
274
|
+
await this.failActive(active, error)
|
|
275
|
+
// Timeout is an authorized coordinator abort. The failed terminal
|
|
276
|
+
// was durable and projected before the child receives cancellation.
|
|
277
|
+
active.controller.abort(Object.assign(new Error(error.message), { code: error.code }))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
await active.callbackTail.catch(() => {})
|
|
282
|
+
if (!active.persistenceFailed) await this.failActive(active, error).catch(() => {})
|
|
283
|
+
} finally {
|
|
284
|
+
if (active.providerTimeoutTimer) clearTimeout(active.providerTimeoutTimer)
|
|
285
|
+
active.providerTimeoutTimer = null
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private callbacksFor(active: ActiveRun): QueryJobRunnerCallbacks {
|
|
290
|
+
return {
|
|
291
|
+
onStart: async (start) => {
|
|
292
|
+
if (start.sessionId && start.sessionId !== active.request.sessionId) {
|
|
293
|
+
const error = Object.assign(new Error('Provider returned a different COS session.'), {
|
|
294
|
+
code: 'session_identity_mismatch',
|
|
295
|
+
})
|
|
296
|
+
active.controller.abort(error)
|
|
297
|
+
await this.failActive(active, error)
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
await this.enqueueCallback(active, async () => {
|
|
301
|
+
const result = await this.store.markRunning(active.jobId, start)
|
|
302
|
+
if (result.job.status === 'running') this.armProviderDeadline(active)
|
|
303
|
+
if (isTerminalQueryJobStatus(result.job.status)) this.finishActive(active)
|
|
304
|
+
})
|
|
305
|
+
},
|
|
306
|
+
onProviderProcess: linkage => this.providerProcessReady(active, linkage),
|
|
307
|
+
onChunk: (text) => { this.queuePartial(active, text) },
|
|
308
|
+
onToolStatus: (text) => {
|
|
309
|
+
if (active.request.activityToolMode === 'off') text = 'Processing...'
|
|
310
|
+
void this.enqueueCallback(active, async () => {
|
|
311
|
+
await this.store.appendActivity(active.jobId, 'status', text)
|
|
312
|
+
})
|
|
313
|
+
},
|
|
314
|
+
onActivityLine: (line) => {
|
|
315
|
+
if (active.request.activityToolMode !== 'preview') return
|
|
316
|
+
void this.enqueueCallback(active, async () => {
|
|
317
|
+
await this.store.appendActivity(active.jobId, line.kind, line.text)
|
|
318
|
+
})
|
|
319
|
+
},
|
|
320
|
+
onAnswerReady: (text, linkage = {}) => this.answerReady(active, text, linkage),
|
|
321
|
+
onDone: completion => this.completeActive(active, completion),
|
|
322
|
+
onError: error => this.failActive(active, error),
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private enqueueCallback(active: ActiveRun, operation: () => Promise<void>): Promise<void> {
|
|
327
|
+
if (active.terminalPersisted || active.persistenceFailed) return active.callbackTail
|
|
328
|
+
const result = active.callbackTail.then(operation)
|
|
329
|
+
active.callbackTail = result.catch(error => { this.handleCallbackPersistenceFailure(active, error) })
|
|
330
|
+
return result
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private handleCallbackPersistenceFailure(active: ActiveRun, error: unknown): void {
|
|
334
|
+
if (active.persistenceFailed) return
|
|
335
|
+
active.persistenceFailed = true
|
|
336
|
+
this.callbackPersistenceFailures++
|
|
337
|
+
if (active.partialTimer) clearTimeout(active.partialTimer)
|
|
338
|
+
active.partialTimer = null
|
|
339
|
+
// Persistence loss is an internal safety failure. Abort provider work, but
|
|
340
|
+
// do not publish or manufacture an unpersisted terminal state.
|
|
341
|
+
active.controller.abort(error)
|
|
342
|
+
active.resolveTerminal()
|
|
343
|
+
this.abandonActive(active)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private armProviderDeadline(active: ActiveRun): void {
|
|
347
|
+
if (active.providerTimeoutTimer || active.terminalPersisted || active.persistenceFailed) return
|
|
348
|
+
active.providerTimeoutTimer = setTimeout(() => active.resolveProviderTimeout(), this.providerTimeoutMs)
|
|
349
|
+
active.providerTimeoutTimer.unref?.()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private queuePartial(active: ActiveRun, value: string): void {
|
|
353
|
+
if (active.terminalPersisted || active.persistenceFailed || typeof value !== 'string' || value.length === 0) return
|
|
354
|
+
const remaining = Math.max(0, QUERY_JOB_LIMITS.partialChars - active.partialText.length)
|
|
355
|
+
if (remaining > 0) active.partialText += value.slice(0, remaining)
|
|
356
|
+
if (value.length > remaining) active.partialTruncated = true
|
|
357
|
+
active.pendingDelta += value
|
|
358
|
+
if (active.pendingDelta.length >= this.partialFlushChars || this.partialFlushMs === 0) {
|
|
359
|
+
void this.flushPartial(active)
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
if (active.partialTimer) return
|
|
363
|
+
active.partialTimer = setTimeout(() => {
|
|
364
|
+
active.partialTimer = null
|
|
365
|
+
void this.flushPartial(active)
|
|
366
|
+
}, this.partialFlushMs)
|
|
367
|
+
active.partialTimer.unref?.()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private flushPartial(active: ActiveRun, authoritativeText?: string): Promise<void> {
|
|
371
|
+
if (active.partialTimer) clearTimeout(active.partialTimer)
|
|
372
|
+
active.partialTimer = null
|
|
373
|
+
if (typeof authoritativeText === 'string') {
|
|
374
|
+
const bounded = authoritativeText.slice(0, QUERY_JOB_LIMITS.partialChars)
|
|
375
|
+
const previous = active.partialText
|
|
376
|
+
active.partialText = bounded
|
|
377
|
+
active.partialTruncated = authoritativeText.length > QUERY_JOB_LIMITS.partialChars
|
|
378
|
+
if (bounded.startsWith(previous)) active.pendingDelta += bounded.slice(previous.length)
|
|
379
|
+
else if (!active.pendingDelta) active.pendingDelta = bounded
|
|
380
|
+
}
|
|
381
|
+
if (!active.pendingDelta) return active.callbackTail
|
|
382
|
+
const delta = active.pendingDelta
|
|
383
|
+
active.pendingDelta = ''
|
|
384
|
+
return this.enqueueCallback(active, async () => {
|
|
385
|
+
await this.store.appendPartial(active.jobId, delta, active.partialText, active.partialTruncated)
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private async providerProcessReady(
|
|
390
|
+
active: ActiveRun,
|
|
391
|
+
linkage: QueryJobProviderLinkage,
|
|
392
|
+
): Promise<boolean> {
|
|
393
|
+
if (active.terminalPersisted || active.persistenceFailed) return false
|
|
394
|
+
let owned = false
|
|
395
|
+
await this.enqueueCallback(active, async () => {
|
|
396
|
+
const result = await this.store.updateLinkage(active.jobId, linkage)
|
|
397
|
+
owned = result.applied && result.job.status === 'running'
|
|
398
|
+
if (isTerminalQueryJobStatus(result.job.status)) this.finishActive(active)
|
|
399
|
+
})
|
|
400
|
+
return owned && !active.terminalPersisted && !active.persistenceFailed
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async answerReady(
|
|
404
|
+
active: ActiveRun,
|
|
405
|
+
text: string,
|
|
406
|
+
linkage: QueryJobProviderLinkage,
|
|
407
|
+
): Promise<boolean> {
|
|
408
|
+
if (active.terminalPersisted || active.persistenceFailed) return false
|
|
409
|
+
await this.flushPartial(active, text)
|
|
410
|
+
if (active.terminalPersisted || active.persistenceFailed) return false
|
|
411
|
+
let owned = false
|
|
412
|
+
await this.enqueueCallback(active, async () => {
|
|
413
|
+
const result = await this.store.markAnswerReady(active.jobId, text, linkage)
|
|
414
|
+
owned = result.applied && result.job.status === 'answer_ready'
|
|
415
|
+
if (isTerminalQueryJobStatus(result.job.status)) this.finishActive(active)
|
|
416
|
+
})
|
|
417
|
+
return owned && !active.terminalPersisted && !active.persistenceFailed
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async completeActive(active: ActiveRun, completion: QueryJobRunnerCompletion): Promise<boolean> {
|
|
421
|
+
try {
|
|
422
|
+
if (active.terminalPersisted || active.persistenceFailed) return false
|
|
423
|
+
await this.flushPartial(active, completion.text)
|
|
424
|
+
await active.callbackTail
|
|
425
|
+
if (active.persistenceFailed) return false
|
|
426
|
+
let snapshot = await this.store.getSnapshot(active.jobId)
|
|
427
|
+
if (isTerminalQueryJobStatus(snapshot.status)) {
|
|
428
|
+
this.finishActive(active)
|
|
429
|
+
return false
|
|
430
|
+
}
|
|
431
|
+
if (snapshot.status !== 'answer_ready') {
|
|
432
|
+
await this.store.markAnswerReady(active.jobId, completion.text, completion)
|
|
433
|
+
}
|
|
434
|
+
const result = await this.store.complete(active.jobId, completion)
|
|
435
|
+
snapshot = result.job
|
|
436
|
+
const projected = isTerminalQueryJobStatus(snapshot.status)
|
|
437
|
+
? await this.projectTerminal(snapshot, active.request)
|
|
438
|
+
: false
|
|
439
|
+
if (isTerminalQueryJobStatus(snapshot.status)) this.finishActive(active)
|
|
440
|
+
return result.applied && snapshot.status === 'completed' && projected
|
|
441
|
+
} catch (error) {
|
|
442
|
+
// Bridge finalize paths can be fire-and-forget. A journal failure must
|
|
443
|
+
// become coordinator health/abort state, never an unhandled rejection or
|
|
444
|
+
// a compatibility display for a reply that was not durably terminal.
|
|
445
|
+
this.handleCallbackPersistenceFailure(active, error)
|
|
446
|
+
return false
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private async failActive(active: ActiveRun, error: unknown): Promise<boolean> {
|
|
451
|
+
try {
|
|
452
|
+
if (active.terminalPersisted || active.persistenceFailed) return false
|
|
453
|
+
if (active.partialTimer) clearTimeout(active.partialTimer)
|
|
454
|
+
active.partialTimer = null
|
|
455
|
+
await active.callbackTail.catch(() => {})
|
|
456
|
+
if (active.persistenceFailed) return false
|
|
457
|
+
const snapshot = await this.store.getSnapshot(active.jobId)
|
|
458
|
+
if (isTerminalQueryJobStatus(snapshot.status)) {
|
|
459
|
+
this.finishActive(active)
|
|
460
|
+
return false
|
|
461
|
+
}
|
|
462
|
+
const result = await this.store.fail(active.jobId, normalizeQueryJobError(error))
|
|
463
|
+
const projected = isTerminalQueryJobStatus(result.job.status)
|
|
464
|
+
? await this.projectTerminal(result.job, active.request)
|
|
465
|
+
: false
|
|
466
|
+
if (isTerminalQueryJobStatus(result.job.status)) this.finishActive(active)
|
|
467
|
+
return result.applied && result.job.status === 'failed' && projected
|
|
468
|
+
} catch (persistenceError) {
|
|
469
|
+
this.handleCallbackPersistenceFailure(active, persistenceError)
|
|
470
|
+
return false
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async cancel(jobId: string, generation: number): Promise<QueryJobSnapshot> {
|
|
475
|
+
const result = await this.store.cancel(jobId, generation)
|
|
476
|
+
if (result.applied) {
|
|
477
|
+
const active = this.active.get(jobId)
|
|
478
|
+
const request = active?.request ?? (await this.store.getExecution(jobId)).request
|
|
479
|
+
await this.projectTerminal(result.job, request)
|
|
480
|
+
if (active && active.generation === generation) {
|
|
481
|
+
// The canceled record is durable and was published before this abort.
|
|
482
|
+
active.controller.abort(Object.assign(new Error('Canceled by user.'), { code: 'canceled' }))
|
|
483
|
+
this.finishActive(active)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return result.job
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
getSnapshot(jobId: string, generation?: number): Promise<QueryJobSnapshot> {
|
|
490
|
+
return this.store.getSnapshot(jobId, generation)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async getByClientGeneration(clientJobId: string, generation: number): Promise<QueryJobSnapshot | undefined> {
|
|
494
|
+
return this.store.findByClientGeneration(clientJobId, generation)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async acknowledge(jobId: string, generation: number): Promise<QueryJobSnapshot> {
|
|
498
|
+
return (await this.store.acknowledge(jobId, generation)).job
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
subscribe(
|
|
502
|
+
jobId: string,
|
|
503
|
+
generation: number,
|
|
504
|
+
after: number,
|
|
505
|
+
listener: Parameters<QueryJobStore['subscribe']>[3],
|
|
506
|
+
): Promise<QueryJobSubscription> {
|
|
507
|
+
return this.store.subscribe(jobId, generation, after, listener)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async shutdown(reason = 'server_shutdown'): Promise<void> {
|
|
511
|
+
this.shuttingDown = true
|
|
512
|
+
await this.admissionTail.catch(() => {})
|
|
513
|
+
for (const active of [...this.active.values()]) {
|
|
514
|
+
try {
|
|
515
|
+
const result = await this.store.interrupt(active.jobId, reason)
|
|
516
|
+
if (result.applied || isTerminalQueryJobStatus(result.job.status)) {
|
|
517
|
+
await this.projectTerminal(result.job, active.request)
|
|
518
|
+
active.controller.abort(Object.assign(new Error(reason), { code: 'interrupted' }))
|
|
519
|
+
this.finishActive(active)
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
this.handleCallbackPersistenceFailure(active, error)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private finishActive(active: ActiveRun): void {
|
|
528
|
+
if (active.terminalPersisted) return
|
|
529
|
+
active.terminalPersisted = true
|
|
530
|
+
active.resolveTerminal()
|
|
531
|
+
if (active.providerTimeoutTimer) clearTimeout(active.providerTimeoutTimer)
|
|
532
|
+
active.providerTimeoutTimer = null
|
|
533
|
+
if (active.partialTimer) clearTimeout(active.partialTimer)
|
|
534
|
+
active.partialTimer = null
|
|
535
|
+
this.active.delete(active.jobId)
|
|
536
|
+
if (!active.released) {
|
|
537
|
+
active.released = true
|
|
538
|
+
active.release?.()
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private abandonActive(active: ActiveRun): void {
|
|
543
|
+
if (active.providerTimeoutTimer) clearTimeout(active.providerTimeoutTimer)
|
|
544
|
+
active.providerTimeoutTimer = null
|
|
545
|
+
if (active.partialTimer) clearTimeout(active.partialTimer)
|
|
546
|
+
active.partialTimer = null
|
|
547
|
+
this.active.delete(active.jobId)
|
|
548
|
+
if (!active.released) {
|
|
549
|
+
active.released = true
|
|
550
|
+
active.release?.()
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private async projectTerminal(job: QueryJobSnapshot, request: QueryJobRequest): Promise<boolean> {
|
|
555
|
+
if (!this.options.projectTerminal || !isTerminalQueryJobStatus(job.status)) return true
|
|
556
|
+
try {
|
|
557
|
+
await this.options.projectTerminal(job, request)
|
|
558
|
+
return true
|
|
559
|
+
} catch (error) {
|
|
560
|
+
this.terminalProjectionFailures++
|
|
561
|
+
console.error(`[query-jobs] terminal conversation projection failed for ${job.jobId}:`, error)
|
|
562
|
+
return false
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
getHealth(): QueryJobCoordinatorHealth {
|
|
567
|
+
return {
|
|
568
|
+
activeRuns: this.active.size,
|
|
569
|
+
shuttingDown: this.shuttingDown,
|
|
570
|
+
callbackPersistenceFailures: this.callbackPersistenceFailures,
|
|
571
|
+
terminalProjectionFailures: this.terminalProjectionFailures,
|
|
572
|
+
store: this.store.getHealth(),
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function queryJobErrorCode(error: unknown): string {
|
|
578
|
+
if (error instanceof QueryJobStoreError || error instanceof QueryJobCoordinatorError) return error.code
|
|
579
|
+
return normalizeQueryJobError(error).code
|
|
580
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { QUERY_JOB_PROTOCOL_VERSION } from './query-job-types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Durable jobs ship dark. The private canary enables them explicitly with
|
|
5
|
+
* COS_DURABLE_QUERY_JOBS=1; removing the flag is an immediate server-side
|
|
6
|
+
* rollback that leaves the legacy /api/query path untouched.
|
|
7
|
+
*/
|
|
8
|
+
export function durableQueryJobsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
9
|
+
return env.COS_DURABLE_QUERY_JOBS === '1'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function durableQueryJobsCapability(): {
|
|
13
|
+
enabled: boolean
|
|
14
|
+
protocolVersion: number
|
|
15
|
+
} {
|
|
16
|
+
return {
|
|
17
|
+
enabled: durableQueryJobsEnabled(),
|
|
18
|
+
protocolVersion: QUERY_JOB_PROTOCOL_VERSION,
|
|
19
|
+
}
|
|
20
|
+
}
|