@gotcos/glasses-server 6.9.0 → 6.11.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.
@@ -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
+ }