@gotcos/glasses-server 6.8.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 +44 -0
- package/README.md +13 -2
- 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/display-bus.ts +1 -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/prompt-drafts.ts +12 -1
- package/server/routes/query-jobs.ts +294 -0
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from './codex-run-ledger.js'
|
|
52
52
|
import { codexActivityPreviewLines } from './activity-preview.js'
|
|
53
53
|
import {
|
|
54
|
+
collectRunOutputImagesBounded,
|
|
54
55
|
createRunOutputImagePublisher,
|
|
55
56
|
isRunOutputImagePublisherCommand,
|
|
56
57
|
type RunOutputImageCollectionStats,
|
|
@@ -283,7 +284,12 @@ export async function callCodexStreaming(
|
|
|
283
284
|
}
|
|
284
285
|
}
|
|
285
286
|
let codexThreadId: string | undefined = engineSession?.codexThreadId
|
|
286
|
-
callbacks.onStart?.(model, sid, undefined, {
|
|
287
|
+
callbacks.onStart?.(model, sid, undefined, {
|
|
288
|
+
codexRunId: run.runId,
|
|
289
|
+
codexThreadId,
|
|
290
|
+
clientJobId: options?.clientJobId,
|
|
291
|
+
generation: options?.generation,
|
|
292
|
+
})
|
|
287
293
|
|
|
288
294
|
let phase: Phase = 'context'
|
|
289
295
|
let systemPrompt: string
|
|
@@ -312,7 +318,18 @@ export async function callCodexStreaming(
|
|
|
312
318
|
const isFirstQuery = isNewSession(sid)
|
|
313
319
|
const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
|
|
314
320
|
const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
|
|
315
|
-
const
|
|
321
|
+
const exchangeProvenance = {
|
|
322
|
+
clientJobId: options?.clientJobId,
|
|
323
|
+
generation: options?.generation,
|
|
324
|
+
}
|
|
325
|
+
const pendingUserExchange = addExchange(
|
|
326
|
+
sid,
|
|
327
|
+
'user',
|
|
328
|
+
historyQuery,
|
|
329
|
+
globalMsgNum,
|
|
330
|
+
undefined,
|
|
331
|
+
exchangeProvenance,
|
|
332
|
+
)
|
|
316
333
|
|
|
317
334
|
let fullQuery: string
|
|
318
335
|
if (imagePaths.length === 1) {
|
|
@@ -363,6 +380,41 @@ export async function callCodexStreaming(
|
|
|
363
380
|
options?.abortSignal?.removeEventListener('abort', handleAbort)
|
|
364
381
|
}
|
|
365
382
|
|
|
383
|
+
function clearEngineSessionBestEffort(reason: string) {
|
|
384
|
+
if (!engineSession) return
|
|
385
|
+
try {
|
|
386
|
+
clearCodexEngineSession(sid, model)
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.error(`[codex-bridge] engine session clear failed (${reason}):`, error)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function saveEngineSessionBestEffort() {
|
|
393
|
+
if (!persistentCodexSession || !codexThreadId) return
|
|
394
|
+
try {
|
|
395
|
+
const saved = saveCodexEngineSession({
|
|
396
|
+
cosSessionId: sid,
|
|
397
|
+
model,
|
|
398
|
+
codexThreadId,
|
|
399
|
+
cwd: codexCwd,
|
|
400
|
+
trustMode: codexTrustMode,
|
|
401
|
+
})
|
|
402
|
+
updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
|
|
403
|
+
} catch (error) {
|
|
404
|
+
// The resumable-thread cache is an optimization. Its filesystem failure
|
|
405
|
+
// must never suppress the durable query terminal callback.
|
|
406
|
+
console.error('[codex-bridge] engine session save failed:', error)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function finishRunBestEffort(input: Parameters<typeof finishCodexRun>[1]) {
|
|
411
|
+
try {
|
|
412
|
+
finishCodexRun(run.runId, input)
|
|
413
|
+
} catch (error) {
|
|
414
|
+
console.error('[codex-bridge] run ledger finalization failed:', error)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
366
418
|
function emitText(text: string) {
|
|
367
419
|
if (!text || emittedBlocks.has(text)) return
|
|
368
420
|
emittedBlocks.add(text)
|
|
@@ -377,7 +429,49 @@ export async function callCodexStreaming(
|
|
|
377
429
|
cleanup()
|
|
378
430
|
cleanupImages()
|
|
379
431
|
|
|
380
|
-
|
|
432
|
+
// The coordinator persists the final provider text before conversation
|
|
433
|
+
// mutation, condensation, or output-image normalization can stall/crash.
|
|
434
|
+
try {
|
|
435
|
+
const answerOwned = await callbacks.onAnswerReady?.(text)
|
|
436
|
+
if (answerOwned === false) {
|
|
437
|
+
outputImagePublisher?.cleanup()
|
|
438
|
+
removeExchange(sid, pendingUserExchange)
|
|
439
|
+
clearEngineSessionBestEffort('answer_ownership_lost')
|
|
440
|
+
finishRunBestEffort({
|
|
441
|
+
status: 'failed',
|
|
442
|
+
startedAtMs: startTime,
|
|
443
|
+
error: 'codex-bridge: durable answer ownership was lost.',
|
|
444
|
+
exitCode: null,
|
|
445
|
+
})
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
console.error('[codex-bridge] durable answer barrier failed:', error)
|
|
450
|
+
outputImagePublisher?.cleanup()
|
|
451
|
+
removeExchange(sid, pendingUserExchange)
|
|
452
|
+
clearEngineSessionBestEffort('answer_barrier')
|
|
453
|
+
finishRunBestEffort({
|
|
454
|
+
status: 'failed',
|
|
455
|
+
startedAtMs: startTime,
|
|
456
|
+
error: 'codex-bridge: durable answer persistence failed.',
|
|
457
|
+
exitCode: null,
|
|
458
|
+
})
|
|
459
|
+
try {
|
|
460
|
+
await callbacks.onError('codex-bridge: durable answer persistence failed.')
|
|
461
|
+
} catch (callbackError) {
|
|
462
|
+
console.error('[codex-bridge] durable barrier error callback failed:', callbackError)
|
|
463
|
+
}
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const assistantExchange = addExchange(
|
|
468
|
+
sid,
|
|
469
|
+
'assistant',
|
|
470
|
+
text,
|
|
471
|
+
globalMsgNum,
|
|
472
|
+
undefined,
|
|
473
|
+
exchangeProvenance,
|
|
474
|
+
)
|
|
381
475
|
if (imagePaths.length > 0) {
|
|
382
476
|
replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
|
|
383
477
|
}
|
|
@@ -389,7 +483,9 @@ export async function callCodexStreaming(
|
|
|
389
483
|
const preparingHeartbeat = setInterval(() => callbacks.onToolStatus?.('Preparing images...'), HEARTBEAT_INTERVAL_MS)
|
|
390
484
|
preparingHeartbeat.unref?.()
|
|
391
485
|
try {
|
|
392
|
-
outputAttachments = await outputImagePublisher
|
|
486
|
+
outputAttachments = await collectRunOutputImagesBounded(outputImagePublisher, {
|
|
487
|
+
signal: options?.abortSignal,
|
|
488
|
+
})
|
|
393
489
|
} catch (err) {
|
|
394
490
|
console.error('[codex-bridge] output image collection failed:', err)
|
|
395
491
|
} finally {
|
|
@@ -416,29 +512,27 @@ export async function callCodexStreaming(
|
|
|
416
512
|
durationMs: totalMs,
|
|
417
513
|
caller: options?.lightweight ? 'voice_query' : 'full_query',
|
|
418
514
|
})
|
|
419
|
-
|
|
420
|
-
const saved = saveCodexEngineSession({
|
|
421
|
-
cosSessionId: sid,
|
|
422
|
-
model,
|
|
423
|
-
codexThreadId,
|
|
424
|
-
cwd: codexCwd,
|
|
425
|
-
trustMode: codexTrustMode,
|
|
426
|
-
})
|
|
427
|
-
updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
|
|
428
|
-
}
|
|
515
|
+
saveEngineSessionBestEffort()
|
|
429
516
|
|
|
430
|
-
|
|
517
|
+
finishRunBestEffort({
|
|
431
518
|
status: 'completed',
|
|
432
519
|
startedAtMs: startTime,
|
|
433
520
|
output: text,
|
|
434
521
|
exitCode: 0,
|
|
435
522
|
})
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
523
|
+
try {
|
|
524
|
+
const terminalOwned = await callbacks.onDone(text, model, undefined, {
|
|
525
|
+
codexRunId: run.runId,
|
|
526
|
+
codexThreadId,
|
|
527
|
+
clientJobId: options?.clientJobId,
|
|
528
|
+
generation: options?.generation,
|
|
529
|
+
...(outputAttachments.length > 0 ? { outputAttachments } : {}),
|
|
530
|
+
...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
|
|
531
|
+
})
|
|
532
|
+
if (terminalOwned === false) return
|
|
533
|
+
} catch (error) {
|
|
534
|
+
console.error('[codex-bridge] terminal completion callback failed:', error)
|
|
535
|
+
}
|
|
442
536
|
|
|
443
537
|
if (isFirstQuery) {
|
|
444
538
|
notifySessionStart(sid, query)
|
|
@@ -447,23 +541,25 @@ export async function callCodexStreaming(
|
|
|
447
541
|
notifyExchange(sid, query, text)
|
|
448
542
|
}
|
|
449
543
|
|
|
450
|
-
function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CodexRunStatus, 'running'> = 'failed') {
|
|
544
|
+
async function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CodexRunStatus, 'running'> = 'failed') {
|
|
451
545
|
if (finalized) return
|
|
452
546
|
finalized = true
|
|
453
547
|
cleanup()
|
|
454
548
|
cleanupImages()
|
|
455
549
|
outputImagePublisher?.cleanup()
|
|
456
550
|
removeExchange(sid, pendingUserExchange)
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
finishCodexRun(run.runId, {
|
|
551
|
+
clearEngineSessionBestEffort('provider_error')
|
|
552
|
+
finishRunBestEffort({
|
|
461
553
|
status,
|
|
462
554
|
startedAtMs: startTime,
|
|
463
555
|
error: msg,
|
|
464
556
|
exitCode,
|
|
465
557
|
})
|
|
466
|
-
|
|
558
|
+
try {
|
|
559
|
+
await callbacks.onError(safeCodexUserError(msg))
|
|
560
|
+
} catch (error) {
|
|
561
|
+
console.error('[codex-bridge] terminal error callback failed:', error)
|
|
562
|
+
}
|
|
467
563
|
}
|
|
468
564
|
|
|
469
565
|
function handleAbort() {
|
|
@@ -580,11 +676,38 @@ export async function callCodexStreaming(
|
|
|
580
676
|
}
|
|
581
677
|
|
|
582
678
|
try {
|
|
679
|
+
const providerOwned = await callbacks.onProviderProcess?.({
|
|
680
|
+
provider: 'codex',
|
|
681
|
+
runId: run.runId,
|
|
682
|
+
pid: proc.pid,
|
|
683
|
+
clientJobId: options?.clientJobId,
|
|
684
|
+
generation: options?.generation,
|
|
685
|
+
})
|
|
686
|
+
if (providerOwned === false) {
|
|
687
|
+
proc.kill('SIGTERM')
|
|
688
|
+
finalized = true
|
|
689
|
+
cleanup()
|
|
690
|
+
cleanupImages()
|
|
691
|
+
outputImagePublisher?.cleanup()
|
|
692
|
+
removeExchange(sid, pendingUserExchange)
|
|
693
|
+
clearEngineSessionBestEffort('provider_ownership_lost')
|
|
694
|
+
finishRunBestEffort({
|
|
695
|
+
status: 'failed',
|
|
696
|
+
startedAtMs: startTime,
|
|
697
|
+
error: 'codex-bridge: durable provider ownership was lost.',
|
|
698
|
+
exitCode: null,
|
|
699
|
+
})
|
|
700
|
+
return sid
|
|
701
|
+
}
|
|
702
|
+
if (finalized) return sid
|
|
583
703
|
proc.stdin.write(prompt)
|
|
584
704
|
proc.stdin.end()
|
|
585
705
|
} catch (err) {
|
|
586
706
|
const message = err instanceof Error ? err.message : String(err)
|
|
587
|
-
|
|
707
|
+
if (!finalized) {
|
|
708
|
+
proc.kill('SIGTERM')
|
|
709
|
+
await finalizeError(`codex-bridge: provider start failed — ${message}`)
|
|
710
|
+
}
|
|
588
711
|
}
|
|
589
712
|
|
|
590
713
|
return sid
|
|
@@ -10,7 +10,7 @@ import { notifySessionStart, notifySessionEnd } from './telegram-notify.js'
|
|
|
10
10
|
import { appendToArchive, type SessionToArchive } from './archive.js'
|
|
11
11
|
import { updateGlassesSessionCache, scheduleCacheUpdate, setSessionProvider } from './session-cache-writer.js'
|
|
12
12
|
import { logSessionEnd, buildSessionLogEntry, writeSessionLog } from './session-log.js'
|
|
13
|
-
import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
13
|
+
import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
14
14
|
import { localDay } from './local-day.js'
|
|
15
15
|
import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
|
|
16
16
|
import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
@@ -22,12 +22,32 @@ export interface Exchange {
|
|
|
22
22
|
content: string
|
|
23
23
|
timestamp: number
|
|
24
24
|
globalMsgNum?: number // Client's global message number for this Q&A pair
|
|
25
|
+
/** Durable query provenance. Legacy exchanges omit both fields. */
|
|
26
|
+
clientJobId?: string
|
|
27
|
+
generation?: number
|
|
25
28
|
/** Public attachment refs may live on either half of a Q&A pair: request
|
|
26
29
|
* photos on the user exchange, model-published images on the assistant
|
|
27
30
|
* exchange. Bytes, filesystem paths, and capabilities never persist here. */
|
|
28
31
|
attachments?: MediaAttachmentRef[]
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
export interface ExchangeJobProvenance {
|
|
35
|
+
clientJobId?: string
|
|
36
|
+
generation?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A durable job generation is the exact reconciliation/removal identity.
|
|
40
|
+
* Requiring both fields prevents a stale generation from mutating its retry. */
|
|
41
|
+
export interface ExchangeJobIdentity {
|
|
42
|
+
clientJobId: string
|
|
43
|
+
generation: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ReconciledExchange {
|
|
47
|
+
exchange: Exchange
|
|
48
|
+
created: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
interface Session {
|
|
32
52
|
id: string
|
|
33
53
|
exchanges: Exchange[]
|
|
@@ -58,6 +78,46 @@ interface SessionsFile {
|
|
|
58
78
|
savedAt: string
|
|
59
79
|
}
|
|
60
80
|
|
|
81
|
+
const MAX_CLIENT_JOB_ID_LENGTH = 128
|
|
82
|
+
const SAFE_CLIENT_JOB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
|
|
83
|
+
|
|
84
|
+
function validClientJobId(value: unknown): value is string {
|
|
85
|
+
return typeof value === 'string'
|
|
86
|
+
&& value.length > 0
|
|
87
|
+
&& value.length <= MAX_CLIENT_JOB_ID_LENGTH
|
|
88
|
+
&& SAFE_CLIENT_JOB_ID_RE.test(value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validGeneration(value: unknown): value is number {
|
|
92
|
+
return typeof value === 'number'
|
|
93
|
+
&& Number.isSafeInteger(value)
|
|
94
|
+
&& value > 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizedExchangeProvenance(
|
|
98
|
+
provenance: ExchangeJobProvenance | undefined,
|
|
99
|
+
): ExchangeJobProvenance {
|
|
100
|
+
if (!provenance || !validClientJobId(provenance.clientJobId)) return {}
|
|
101
|
+
return {
|
|
102
|
+
clientJobId: provenance.clientJobId,
|
|
103
|
+
...(validGeneration(provenance.generation) ? { generation: provenance.generation } : {}),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateLoadedExchangeProvenance(exchange: Exchange): void {
|
|
108
|
+
if (!validClientJobId(exchange.clientJobId)) {
|
|
109
|
+
delete exchange.clientJobId
|
|
110
|
+
delete exchange.generation
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (!validGeneration(exchange.generation)) delete exchange.generation
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function matchesJobIdentity(exchange: Exchange, identity: ExchangeJobIdentity): boolean {
|
|
117
|
+
return exchange.clientJobId === identity.clientJobId
|
|
118
|
+
&& exchange.generation === identity.generation
|
|
119
|
+
}
|
|
120
|
+
|
|
61
121
|
function loadFromDisk(): void {
|
|
62
122
|
const result = loadJsonOrQuarantine<SessionsFile>(SESSION_FILE)
|
|
63
123
|
if (result.status === 'missing') return // fresh start
|
|
@@ -81,6 +141,7 @@ function loadFromDisk(): void {
|
|
|
81
141
|
// Persistence boundary: malformed refs are dropped without sacrificing
|
|
82
142
|
// the surrounding exchange or the rest of the recovered session.
|
|
83
143
|
for (const exchange of session.exchanges) {
|
|
144
|
+
validateLoadedExchangeProvenance(exchange)
|
|
84
145
|
if ('attachments' in exchange && exchange.attachments !== undefined) {
|
|
85
146
|
const refs = parseMediaAttachmentRefs(exchange.attachments)
|
|
86
147
|
if (refs.length > 0) exchange.attachments = refs
|
|
@@ -121,6 +182,29 @@ function saveToDisk(): void {
|
|
|
121
182
|
}
|
|
122
183
|
}
|
|
123
184
|
|
|
185
|
+
/** Force the current in-memory conversation projection across an fsync-backed
|
|
186
|
+
* durability boundary. Durable query jobs call this only at terminal/recovery
|
|
187
|
+
* boundaries; the existing 500ms coalesced writer remains the normal hot path.
|
|
188
|
+
* Unlike saveToDisk(), failures are surfaced to the caller so it can suppress
|
|
189
|
+
* a compatibility notification and leave boot reconciliation to repair it. */
|
|
190
|
+
export function flushConversationToDisk(): void {
|
|
191
|
+
if (saveTimer) {
|
|
192
|
+
clearTimeout(saveTimer)
|
|
193
|
+
saveTimer = null
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
mkdirSync(dirname(SESSION_FILE), { recursive: true })
|
|
197
|
+
const data: SessionsFile = {
|
|
198
|
+
sessions: Object.fromEntries(sessions),
|
|
199
|
+
savedAt: new Date().toISOString(),
|
|
200
|
+
}
|
|
201
|
+
durableAtomicWriteFileSync(SESSION_FILE, JSON.stringify(data, null, 2), { mode: 0o600 })
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error('[conversation] Failed durable terminal flush:', err)
|
|
204
|
+
throw err
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
124
208
|
// Restore on module load
|
|
125
209
|
loadFromDisk()
|
|
126
210
|
|
|
@@ -316,6 +400,7 @@ export function addExchange(
|
|
|
316
400
|
content: string,
|
|
317
401
|
globalMsgNum?: number,
|
|
318
402
|
attachments?: MediaAttachmentRef[],
|
|
403
|
+
provenance?: ExchangeJobProvenance,
|
|
319
404
|
): Exchange {
|
|
320
405
|
let session = sessions.get(sessionId)
|
|
321
406
|
if (!session) {
|
|
@@ -328,6 +413,7 @@ export function addExchange(
|
|
|
328
413
|
content,
|
|
329
414
|
timestamp: Date.now(),
|
|
330
415
|
globalMsgNum,
|
|
416
|
+
...normalizedExchangeProvenance(provenance),
|
|
331
417
|
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
|
332
418
|
}
|
|
333
419
|
session.exchanges.push(exchange)
|
|
@@ -377,6 +463,113 @@ export function removeExchange(sessionId: string, exchange: Exchange): boolean {
|
|
|
377
463
|
return true
|
|
378
464
|
}
|
|
379
465
|
|
|
466
|
+
/** Return the exchanges owned by one exact durable job generation. The
|
|
467
|
+
* returned objects are the live exchange objects, matching getHistory's
|
|
468
|
+
* existing shallow-copy behavior. */
|
|
469
|
+
export function findExchangesByJobIdentity(
|
|
470
|
+
sessionId: string,
|
|
471
|
+
identity: ExchangeJobIdentity,
|
|
472
|
+
): Exchange[] {
|
|
473
|
+
const session = sessions.get(sessionId)
|
|
474
|
+
if (!session || !validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) return []
|
|
475
|
+
return session.exchanges.filter(exchange => matchesJobIdentity(exchange, identity))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Remove only exchanges owned by one exact job generation. A role filter is
|
|
479
|
+
* used by failed runs to roll back their pending user turn without touching a
|
|
480
|
+
* completed assistant exchange or a newer retry generation. */
|
|
481
|
+
export function removeExchangesByJobIdentity(
|
|
482
|
+
sessionId: string,
|
|
483
|
+
identity: ExchangeJobIdentity,
|
|
484
|
+
role?: Exchange['role'],
|
|
485
|
+
): number {
|
|
486
|
+
const session = sessions.get(sessionId)
|
|
487
|
+
if (!session || !validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) return 0
|
|
488
|
+
|
|
489
|
+
const before = session.exchanges.length
|
|
490
|
+
session.exchanges = session.exchanges.filter(exchange => (
|
|
491
|
+
!matchesJobIdentity(exchange, identity) || (role !== undefined && exchange.role !== role)
|
|
492
|
+
))
|
|
493
|
+
const removed = before - session.exchanges.length
|
|
494
|
+
if (removed === 0) return 0
|
|
495
|
+
|
|
496
|
+
session.lastActivity = Date.now()
|
|
497
|
+
scheduleSave()
|
|
498
|
+
scheduleCacheUpdate()
|
|
499
|
+
return removed
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Idempotently create or update one side of a durable job exchange pair.
|
|
503
|
+
* Duplicate same-role rows for the exact identity are collapsed in the same
|
|
504
|
+
* synchronous mutation before the existing coalesced save/cache path runs.
|
|
505
|
+
* Other jobs, generations, and the opposite role are never changed. */
|
|
506
|
+
export function reconcileExchangeByJobIdentity(
|
|
507
|
+
sessionId: string,
|
|
508
|
+
identity: ExchangeJobIdentity,
|
|
509
|
+
role: Exchange['role'],
|
|
510
|
+
content: string,
|
|
511
|
+
globalMsgNum?: number,
|
|
512
|
+
attachments?: MediaAttachmentRef[],
|
|
513
|
+
): ReconciledExchange {
|
|
514
|
+
if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
|
|
515
|
+
throw new Error('conversation: invalid durable job identity')
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let session = sessions.get(sessionId)
|
|
519
|
+
if (!session) {
|
|
520
|
+
const now = Date.now()
|
|
521
|
+
session = {
|
|
522
|
+
id: sessionId,
|
|
523
|
+
exchanges: [],
|
|
524
|
+
lastActivity: now,
|
|
525
|
+
createdAt: now,
|
|
526
|
+
modelPreference: null,
|
|
527
|
+
contextBreaks: [],
|
|
528
|
+
}
|
|
529
|
+
sessions.set(sessionId, session)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const matchingIndexes: number[] = []
|
|
533
|
+
for (let i = 0; i < session.exchanges.length; i++) {
|
|
534
|
+
const exchange = session.exchanges[i]
|
|
535
|
+
if (exchange.role === role && matchesJobIdentity(exchange, identity)) matchingIndexes.push(i)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const validatedAttachments = parseMediaAttachmentRefs(attachments)
|
|
539
|
+
let exchange: Exchange
|
|
540
|
+
const created = matchingIndexes.length === 0
|
|
541
|
+
if (created) {
|
|
542
|
+
exchange = {
|
|
543
|
+
role,
|
|
544
|
+
content,
|
|
545
|
+
timestamp: Date.now(),
|
|
546
|
+
globalMsgNum,
|
|
547
|
+
clientJobId: identity.clientJobId,
|
|
548
|
+
generation: identity.generation,
|
|
549
|
+
...(validatedAttachments.length > 0 ? { attachments: validatedAttachments } : {}),
|
|
550
|
+
}
|
|
551
|
+
session.exchanges.push(exchange)
|
|
552
|
+
} else {
|
|
553
|
+
exchange = session.exchanges[matchingIndexes[0]]
|
|
554
|
+
exchange.content = content
|
|
555
|
+
exchange.globalMsgNum = globalMsgNum
|
|
556
|
+
if (validatedAttachments.length > 0) exchange.attachments = validatedAttachments
|
|
557
|
+
else delete exchange.attachments
|
|
558
|
+
|
|
559
|
+
// Remove from the end so earlier indexes stay stable; retain the oldest
|
|
560
|
+
// row's timestamp and position to preserve conversation ordering.
|
|
561
|
+
for (let i = matchingIndexes.length - 1; i >= 1; i--) {
|
|
562
|
+
session.exchanges.splice(matchingIndexes[i], 1)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
while (session.exchanges.length > MAX_EXCHANGES) session.exchanges.shift()
|
|
567
|
+
session.lastActivity = Date.now()
|
|
568
|
+
scheduleSave()
|
|
569
|
+
scheduleCacheUpdate()
|
|
570
|
+
return { exchange, created }
|
|
571
|
+
}
|
|
572
|
+
|
|
380
573
|
/** Clear a session — archive + log BEFORE deleting from the live Map.
|
|
381
574
|
* Async so callers can `await` archive completion before considering the
|
|
382
575
|
* session durable. If archive fails the session stays in the Map (retryable
|
|
@@ -8,7 +8,7 @@ const bus = new EventEmitter()
|
|
|
8
8
|
bus.setMaxListeners(20) // Multiple glasses clients
|
|
9
9
|
|
|
10
10
|
export interface DisplayEvent {
|
|
11
|
-
type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'recording_start' | 'recording_stop' | 'coaching_nudge'
|
|
11
|
+
type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'prompt_transcript' | 'recording_start' | 'recording_stop' | 'coaching_nudge'
|
|
12
12
|
data: Record<string, unknown>
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ import type { ModelImageInput } from './model-image-input.js'
|
|
|
16
16
|
// mutating the same conversation/CLI session concurrently.
|
|
17
17
|
const sessionRunTails = new Map<string, Promise<void>>()
|
|
18
18
|
|
|
19
|
-
async function
|
|
19
|
+
export async function acquireModelSessionRunLock(sessionId: string): Promise<() => void> {
|
|
20
20
|
const previous = sessionRunTails.get(sessionId) ?? Promise.resolve()
|
|
21
21
|
let openGate!: () => void
|
|
22
22
|
const gate = new Promise<void>(resolve => { openGate = resolve })
|
|
@@ -47,7 +47,7 @@ export async function callModelStreaming(
|
|
|
47
47
|
options?: CallOptions,
|
|
48
48
|
): Promise<string> {
|
|
49
49
|
const sid = getOrCreateSession(sessionId)
|
|
50
|
-
const release = await
|
|
50
|
+
const release = options?.sessionLockHeld ? (() => {}) : await acquireModelSessionRunLock(sid)
|
|
51
51
|
if (options?.abortSignal?.aborted) {
|
|
52
52
|
release()
|
|
53
53
|
throw new Error('model-router: request aborted before the model run started.')
|
|
@@ -70,16 +70,16 @@ export async function callModelStreaming(
|
|
|
70
70
|
}
|
|
71
71
|
const lockedCallbacks: StreamCallbacks = {
|
|
72
72
|
...callbacks,
|
|
73
|
-
onDone: (fullText, completedModel, cliSessionId, metadata) => {
|
|
73
|
+
onDone: async (fullText, completedModel, cliSessionId, metadata) => {
|
|
74
74
|
try {
|
|
75
|
-
callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
|
|
75
|
+
return await callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
|
|
76
76
|
} finally {
|
|
77
77
|
releaseTerminal()
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
|
-
onError: (error) => {
|
|
80
|
+
onError: async (error) => {
|
|
81
81
|
try {
|
|
82
|
-
callbacks.onError(error)
|
|
82
|
+
await callbacks.onError(error)
|
|
83
83
|
} finally {
|
|
84
84
|
releaseTerminal()
|
|
85
85
|
}
|