@gotcos/glasses-server 6.15.3 → 6.16.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 +5 -1
- package/CHANGELOG.md +57 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/server/index.ts +48 -23
- package/server/lib/api-auth.ts +5 -1
- package/server/lib/audio-enhance.ts +15 -8
- package/server/lib/claude-bridge.ts +48 -18
- package/server/lib/claude-tool-access.ts +73 -7
- package/server/lib/codex-bridge.ts +60 -27
- package/server/lib/maintenance-lifecycle.ts +40 -0
- package/server/lib/prompt-draft-store.ts +1 -0
- package/server/lib/provider-process-lifecycle.ts +139 -0
- package/server/lib/provider-proof.ts +28 -10
- package/server/lib/transcribe-audio.ts +20 -3
- package/server/lib/whisper-local.ts +170 -20
- package/server/routes/health.ts +27 -3
- package/server/routes/openai-compat.ts +54 -27
- package/server/routes/prompt-drafts.ts +122 -9
- package/server/routes/provider-proof.ts +40 -2
- package/server/routes/transcribe.ts +9 -1
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
type MediaAttachmentRef,
|
|
62
62
|
} from '../../shared/media-attachment.js'
|
|
63
63
|
import { terminalProviderAuthFailure } from './provider-terminal-error.js'
|
|
64
|
+
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
64
65
|
|
|
65
66
|
const INACTIVITY_MS = 180_000
|
|
66
67
|
const WALL_MAX_MS = 900_000
|
|
@@ -361,12 +362,14 @@ export async function callCodexStreaming(
|
|
|
361
362
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
362
363
|
env,
|
|
363
364
|
cwd: codexCwd,
|
|
365
|
+
detached: true,
|
|
364
366
|
})
|
|
365
367
|
|
|
366
368
|
let fullText = ''
|
|
367
369
|
let stderr = ''
|
|
368
370
|
let buffer = ''
|
|
369
371
|
let finalized = false
|
|
372
|
+
let terminationRequested = false
|
|
370
373
|
let terminalTextError: string | null = null
|
|
371
374
|
let lastActivity = Date.now()
|
|
372
375
|
const emittedBlocks = new Set<string>()
|
|
@@ -571,10 +574,43 @@ export async function callCodexStreaming(
|
|
|
571
574
|
}
|
|
572
575
|
}
|
|
573
576
|
|
|
574
|
-
function
|
|
577
|
+
function abandonLostDurableOwnership(message: string) {
|
|
575
578
|
if (finalized) return
|
|
576
|
-
|
|
577
|
-
|
|
579
|
+
finalized = true
|
|
580
|
+
cleanup()
|
|
581
|
+
cleanupImages()
|
|
582
|
+
outputImagePublisher?.cleanup()
|
|
583
|
+
removeExchange(sid, pendingUserExchange)
|
|
584
|
+
clearEngineSessionBestEffort('provider_ownership_lost')
|
|
585
|
+
finishRunBestEffort({
|
|
586
|
+
status: 'failed',
|
|
587
|
+
startedAtMs: startTime,
|
|
588
|
+
error: message,
|
|
589
|
+
exitCode: null,
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function terminateForTerminal(
|
|
594
|
+
reason: string,
|
|
595
|
+
onClosed: (result: Awaited<ReturnType<typeof terminateProviderProcess>>) => void | Promise<void>,
|
|
596
|
+
) {
|
|
597
|
+
if (finalized || terminationRequested) return
|
|
598
|
+
terminationRequested = true
|
|
599
|
+
cleanup()
|
|
600
|
+
const result = await terminateProviderProcess(proc)
|
|
601
|
+
if (!result.closed) {
|
|
602
|
+
console.error(`[codex-bridge] provider did not close after SIGKILL (${reason}); retaining lifecycle ownership`)
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
await onClosed(result)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function handleAbort() {
|
|
609
|
+
void terminateForTerminal('client disconnect', result => finalizeError(
|
|
610
|
+
'codex-bridge: client disconnected before Codex completed.',
|
|
611
|
+
result.code,
|
|
612
|
+
'client_disconnected',
|
|
613
|
+
))
|
|
578
614
|
}
|
|
579
615
|
|
|
580
616
|
const heartbeat = setInterval(() => {
|
|
@@ -583,28 +619,30 @@ export async function callCodexStreaming(
|
|
|
583
619
|
}, HEARTBEAT_INTERVAL_MS)
|
|
584
620
|
|
|
585
621
|
let inactivityTimer = setTimeout(() => {
|
|
586
|
-
proc.kill('SIGTERM')
|
|
587
622
|
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
|
588
|
-
|
|
623
|
+
void terminateForTerminal('inactivity timeout', result => finalizeError(
|
|
624
|
+
`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`,
|
|
625
|
+
result.code,
|
|
626
|
+
))
|
|
589
627
|
}, INACTIVITY_MS)
|
|
590
628
|
|
|
591
629
|
function resetInactivity() {
|
|
592
630
|
lastActivity = Date.now()
|
|
593
631
|
clearTimeout(inactivityTimer)
|
|
594
632
|
inactivityTimer = setTimeout(() => {
|
|
595
|
-
proc.kill('SIGTERM')
|
|
596
633
|
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
|
597
|
-
|
|
634
|
+
void terminateForTerminal('inactivity timeout', result => finalizeError(
|
|
635
|
+
`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`,
|
|
636
|
+
result.code,
|
|
637
|
+
))
|
|
598
638
|
}, INACTIVITY_MS)
|
|
599
639
|
}
|
|
600
640
|
|
|
601
641
|
const wallTimer = setTimeout(() => {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
606
|
-
finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Codex process killed.`)
|
|
607
|
-
}
|
|
642
|
+
void terminateForTerminal('wall timeout', result => {
|
|
643
|
+
if (fullText) return finalize(fullText)
|
|
644
|
+
return finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Codex process killed.`, result.code)
|
|
645
|
+
})
|
|
608
646
|
}, WALL_MAX_MS)
|
|
609
647
|
|
|
610
648
|
function handleEvent(event: any) {
|
|
@@ -677,6 +715,7 @@ export async function callCodexStreaming(
|
|
|
677
715
|
})
|
|
678
716
|
|
|
679
717
|
proc.on('close', (code) => {
|
|
718
|
+
if (terminationRequested) return
|
|
680
719
|
if (buffer.trim()) {
|
|
681
720
|
try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
|
|
682
721
|
}
|
|
@@ -696,9 +735,11 @@ export async function callCodexStreaming(
|
|
|
696
735
|
})
|
|
697
736
|
|
|
698
737
|
proc.on('error', (err) => {
|
|
738
|
+
if (terminationRequested) return
|
|
699
739
|
finalizeError(`codex-bridge: ${err.message}`)
|
|
700
740
|
})
|
|
701
741
|
proc.stdin.on('error', (err) => {
|
|
742
|
+
if (terminationRequested) return
|
|
702
743
|
finalizeError(`codex-bridge: stdin failed — ${err.message}`)
|
|
703
744
|
})
|
|
704
745
|
|
|
@@ -719,18 +760,8 @@ export async function callCodexStreaming(
|
|
|
719
760
|
generation: options?.generation,
|
|
720
761
|
})
|
|
721
762
|
if (providerOwned === false) {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
cleanup()
|
|
725
|
-
cleanupImages()
|
|
726
|
-
outputImagePublisher?.cleanup()
|
|
727
|
-
removeExchange(sid, pendingUserExchange)
|
|
728
|
-
clearEngineSessionBestEffort('provider_ownership_lost')
|
|
729
|
-
finishRunBestEffort({
|
|
730
|
-
status: 'failed',
|
|
731
|
-
startedAtMs: startTime,
|
|
732
|
-
error: 'codex-bridge: durable provider ownership was lost.',
|
|
733
|
-
exitCode: null,
|
|
763
|
+
await terminateForTerminal('provider ownership lost', () => {
|
|
764
|
+
abandonLostDurableOwnership('codex-bridge: durable provider ownership was lost.')
|
|
734
765
|
})
|
|
735
766
|
return sid
|
|
736
767
|
}
|
|
@@ -740,8 +771,10 @@ export async function callCodexStreaming(
|
|
|
740
771
|
} catch (err) {
|
|
741
772
|
const message = err instanceof Error ? err.message : String(err)
|
|
742
773
|
if (!finalized) {
|
|
743
|
-
|
|
744
|
-
|
|
774
|
+
await terminateForTerminal('provider start failure', result => finalizeError(
|
|
775
|
+
`codex-bridge: provider start failed — ${message}`,
|
|
776
|
+
result.code,
|
|
777
|
+
))
|
|
745
778
|
}
|
|
746
779
|
}
|
|
747
780
|
|
|
@@ -310,6 +310,7 @@ export class MaintenanceLifecycle {
|
|
|
310
310
|
private blockedGateReason: BlockedGateReason | null = null
|
|
311
311
|
private blockedGateVersion: number | null = null
|
|
312
312
|
private readonly work = new Map<string, WorkEntry>()
|
|
313
|
+
private readonly admissionsOpenListeners = new Set<() => void>()
|
|
313
314
|
|
|
314
315
|
constructor(options: MaintenanceLifecycleOptions = {}) {
|
|
315
316
|
this.path = options.path ?? process.env.COS_MAINTENANCE_GATE_PATH?.trim() ?? DEFAULT_GATE_PATH
|
|
@@ -356,11 +357,44 @@ export class MaintenanceLifecycle {
|
|
|
356
357
|
try {
|
|
357
358
|
removeGate(this.path)
|
|
358
359
|
this.gate = null
|
|
360
|
+
this.notifyAdmissionsOpen()
|
|
359
361
|
} catch {
|
|
360
362
|
this.blockedGateReason = 'invalid_schema'
|
|
361
363
|
}
|
|
362
364
|
}
|
|
363
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Register deferred runtime initialization that is safe only after the
|
|
368
|
+
* durable maintenance gate opens. Delivery is asynchronous so the release
|
|
369
|
+
* response is never held open by model/cache initialization. The callback is
|
|
370
|
+
* also delivered for an already-open lifecycle, closing the boot/release
|
|
371
|
+
* race without forcing index.ts to poll.
|
|
372
|
+
*/
|
|
373
|
+
onAdmissionsOpen(listener: () => void): () => void {
|
|
374
|
+
this.expireSameBootGateIfPermitted()
|
|
375
|
+
this.admissionsOpenListeners.add(listener)
|
|
376
|
+
if (!this.gate && !this.blockedGateReason) this.deliverAdmissionsOpen(listener)
|
|
377
|
+
return () => { this.admissionsOpenListeners.delete(listener) }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private deliverAdmissionsOpen(listener: () => void): void {
|
|
381
|
+
queueMicrotask(() => {
|
|
382
|
+
if (!this.admissionsOpenListeners.has(listener)) return
|
|
383
|
+
// State may have closed again between scheduling and microtask delivery.
|
|
384
|
+
// Never start admitted runtime from a stale accepting notification.
|
|
385
|
+
this.expireSameBootGateIfPermitted()
|
|
386
|
+
if (this.gate || this.blockedGateReason) return
|
|
387
|
+
try { listener() } catch (error) {
|
|
388
|
+
console.error('[maintenance] admissions-open listener failed:', error)
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private notifyAdmissionsOpen(): void {
|
|
394
|
+
if (this.gate || this.blockedGateReason) return
|
|
395
|
+
for (const listener of this.admissionsOpenListeners) this.deliverAdmissionsOpen(listener)
|
|
396
|
+
}
|
|
397
|
+
|
|
364
398
|
private credentialsMatch(credentials: MaintenanceOperationCredentials): {
|
|
365
399
|
leaseMatches: boolean
|
|
366
400
|
operationMatches: boolean
|
|
@@ -615,6 +649,7 @@ export class MaintenanceLifecycle {
|
|
|
615
649
|
)
|
|
616
650
|
}
|
|
617
651
|
this.gate = null
|
|
652
|
+
this.notifyAdmissionsOpen()
|
|
618
653
|
}
|
|
619
654
|
|
|
620
655
|
cancelDrain(identity: MaintenanceOperationIdentity, credentials: MaintenanceOperationCredentials): void {
|
|
@@ -641,6 +676,7 @@ export class MaintenanceLifecycle {
|
|
|
641
676
|
)
|
|
642
677
|
}
|
|
643
678
|
this.gate = null
|
|
679
|
+
this.notifyAdmissionsOpen()
|
|
644
680
|
}
|
|
645
681
|
|
|
646
682
|
snapshot(credentials: MaintenanceOperationCredentials = {}, extraActiveByKind: Record<string, number> = {}) {
|
|
@@ -741,6 +777,10 @@ export function maintenanceAdmissionsOpen(): boolean {
|
|
|
741
777
|
return maintenanceLifecycle.snapshot().admissionsOpen
|
|
742
778
|
}
|
|
743
779
|
|
|
780
|
+
export function onMaintenanceAdmissionsOpen(listener: () => void): () => void {
|
|
781
|
+
return maintenanceLifecycle.onAdmissionsOpen(listener)
|
|
782
|
+
}
|
|
783
|
+
|
|
744
784
|
export function maintenanceErrorPayload(error: MaintenanceLifecycleError) {
|
|
745
785
|
return {
|
|
746
786
|
error: error.code,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export interface ProviderTerminationResult {
|
|
4
|
+
closed: boolean
|
|
5
|
+
escalated: boolean
|
|
6
|
+
code: number | null
|
|
7
|
+
signal: NodeJS.Signals | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ProviderTerminationOptions {
|
|
11
|
+
termGraceMs?: number
|
|
12
|
+
killWaitMs?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A detached provider's process group can outlive its CLI leader. ESRCH is
|
|
16
|
+
* the only proof that no member remains; EPERM and unknown probe failures are
|
|
17
|
+
* treated as alive so lifecycle ownership fails closed. */
|
|
18
|
+
function providerGroupAlive(pid: number | undefined): boolean {
|
|
19
|
+
if (!pid || process.platform === 'win32') return false
|
|
20
|
+
try {
|
|
21
|
+
process.kill(-pid, 0)
|
|
22
|
+
return true
|
|
23
|
+
} catch (error: any) {
|
|
24
|
+
return error?.code !== 'ESRCH'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Signal the detached provider process group so tool subprocesses cannot be
|
|
29
|
+
* orphaned behind the CLI wrapper. Direct-child signaling is a safe fallback
|
|
30
|
+
* for a process that failed before its process group was established. */
|
|
31
|
+
function signalProviderTree(proc: ChildProcess, signal: NodeJS.Signals): void {
|
|
32
|
+
const pid = proc.pid
|
|
33
|
+
if (pid && process.platform !== 'win32') {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(-pid, signal)
|
|
36
|
+
return
|
|
37
|
+
} catch {
|
|
38
|
+
// Fall through to the direct child. close/error remains authoritative.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try { proc.kill(signal) } catch { /* process already terminal */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Terminate provider work and resolve only after Node observes leader close
|
|
46
|
+
* and the detached process group no longer exists.
|
|
47
|
+
* A caller must retain its maintenance lease when `closed` is false: releasing
|
|
48
|
+
* without a close event could let Control restart while a tool is still alive.
|
|
49
|
+
*/
|
|
50
|
+
export function terminateProviderProcess(
|
|
51
|
+
proc: ChildProcess,
|
|
52
|
+
options: ProviderTerminationOptions = {},
|
|
53
|
+
): Promise<ProviderTerminationResult> {
|
|
54
|
+
const termGraceMs = Math.max(10, options.termGraceMs ?? 2_000)
|
|
55
|
+
const killWaitMs = Math.max(10, options.killWaitMs ?? 2_000)
|
|
56
|
+
const groupPid = proc.pid
|
|
57
|
+
const initiallyClosed = proc.exitCode !== null || proc.signalCode !== null
|
|
58
|
+
|
|
59
|
+
if (initiallyClosed && !providerGroupAlive(groupPid)) {
|
|
60
|
+
return Promise.resolve({
|
|
61
|
+
closed: true,
|
|
62
|
+
escalated: false,
|
|
63
|
+
code: proc.exitCode,
|
|
64
|
+
signal: proc.signalCode,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return new Promise(resolve => {
|
|
69
|
+
let settled = false
|
|
70
|
+
let escalated = false
|
|
71
|
+
let leaderClosed = initiallyClosed
|
|
72
|
+
let leaderCode = proc.exitCode
|
|
73
|
+
let leaderSignal = proc.signalCode
|
|
74
|
+
let escalationTimer: ReturnType<typeof setTimeout> | undefined
|
|
75
|
+
let terminalTimer: ReturnType<typeof setTimeout> | undefined
|
|
76
|
+
|
|
77
|
+
const finish = (result: ProviderTerminationResult) => {
|
|
78
|
+
if (settled) return
|
|
79
|
+
settled = true
|
|
80
|
+
if (escalationTimer) clearTimeout(escalationTimer)
|
|
81
|
+
if (terminalTimer) clearTimeout(terminalTimer)
|
|
82
|
+
proc.removeListener('close', onClose)
|
|
83
|
+
proc.removeListener('error', onError)
|
|
84
|
+
resolve(result)
|
|
85
|
+
}
|
|
86
|
+
const finishIfTreeClosed = (): boolean => {
|
|
87
|
+
if (!leaderClosed || providerGroupAlive(groupPid)) return false
|
|
88
|
+
finish({
|
|
89
|
+
closed: true,
|
|
90
|
+
escalated,
|
|
91
|
+
code: leaderCode,
|
|
92
|
+
signal: leaderSignal,
|
|
93
|
+
})
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
97
|
+
leaderClosed = true
|
|
98
|
+
leaderCode = code
|
|
99
|
+
leaderSignal = signal
|
|
100
|
+
finishIfTreeClosed()
|
|
101
|
+
}
|
|
102
|
+
const onError = () => {
|
|
103
|
+
// A spawn failure has no live process to retain. Running children still
|
|
104
|
+
// produce close after an error, so wait for that authoritative event.
|
|
105
|
+
if (!proc.pid) {
|
|
106
|
+
leaderClosed = true
|
|
107
|
+
leaderCode = null
|
|
108
|
+
leaderSignal = null
|
|
109
|
+
finishIfTreeClosed()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
proc.once('close', onClose)
|
|
114
|
+
proc.once('error', onError)
|
|
115
|
+
try { proc.stdin?.destroy() } catch { /* best effort */ }
|
|
116
|
+
signalProviderTree(proc, 'SIGTERM')
|
|
117
|
+
|
|
118
|
+
escalationTimer = setTimeout(() => {
|
|
119
|
+
if (finishIfTreeClosed()) return
|
|
120
|
+
escalated = true
|
|
121
|
+
signalProviderTree(proc, 'SIGKILL')
|
|
122
|
+
const deadline = Date.now() + killWaitMs
|
|
123
|
+
const pollForTreeExit = () => {
|
|
124
|
+
if (finishIfTreeClosed()) return
|
|
125
|
+
if (Date.now() < deadline) {
|
|
126
|
+
terminalTimer = setTimeout(pollForTreeExit, 25)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
finish({
|
|
130
|
+
closed: false,
|
|
131
|
+
escalated: true,
|
|
132
|
+
code: leaderCode,
|
|
133
|
+
signal: leaderSignal,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
terminalTimer = setTimeout(pollForTreeExit, 25)
|
|
137
|
+
}, termGraceMs)
|
|
138
|
+
})
|
|
139
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { cosBrainDir } from './launch-dir.js'
|
|
3
|
+
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
3
4
|
|
|
4
5
|
export type ProofProvider = 'claude' | 'codex'
|
|
5
6
|
|
|
@@ -21,6 +22,7 @@ interface ProcessResult {
|
|
|
21
22
|
stdout: string
|
|
22
23
|
stderr: string
|
|
23
24
|
timedOut: boolean
|
|
25
|
+
aborted: boolean
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
function runBounded(
|
|
@@ -28,15 +30,20 @@ function runBounded(
|
|
|
28
30
|
args: string[],
|
|
29
31
|
input: string,
|
|
30
32
|
timeoutMs = 120_000,
|
|
33
|
+
signal?: AbortSignal,
|
|
31
34
|
): Promise<ProcessResult> {
|
|
32
35
|
return new Promise((resolvePromise) => {
|
|
36
|
+
if (signal?.aborted) {
|
|
37
|
+
resolvePromise({ code: null, stdout: '', stderr: '', timedOut: false, aborted: true })
|
|
38
|
+
return
|
|
39
|
+
}
|
|
33
40
|
const env = { ...process.env }
|
|
34
41
|
delete env.CLAUDECODE
|
|
35
42
|
const child = spawn(command, args, {
|
|
36
43
|
cwd: cosBrainDir() ?? process.cwd(),
|
|
37
44
|
env,
|
|
38
45
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
39
|
-
detached:
|
|
46
|
+
detached: true,
|
|
40
47
|
})
|
|
41
48
|
let stdout = ''
|
|
42
49
|
let stderr = ''
|
|
@@ -48,15 +55,25 @@ function runBounded(
|
|
|
48
55
|
if (settled) return
|
|
49
56
|
settled = true
|
|
50
57
|
clearTimeout(timer)
|
|
58
|
+
signal?.removeEventListener('abort', abort)
|
|
51
59
|
resolvePromise(result)
|
|
52
60
|
}
|
|
53
61
|
const timer = setTimeout(() => {
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
void terminateProviderProcess(child, { termGraceMs: 50 }).then(result => {
|
|
63
|
+
if (result.closed) finish({ code: result.code, stdout, stderr, timedOut: true, aborted: false })
|
|
64
|
+
else console.error('[provider-proof] timed-out provider did not close after SIGKILL; retaining request ownership')
|
|
65
|
+
})
|
|
56
66
|
}, timeoutMs)
|
|
57
67
|
timer.unref?.()
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
const abort = () => {
|
|
69
|
+
void terminateProviderProcess(child).then(result => {
|
|
70
|
+
if (result.closed) finish({ code: result.code, stdout, stderr, timedOut: false, aborted: true })
|
|
71
|
+
else console.error('[provider-proof] canceled provider did not close after SIGKILL; retaining request ownership')
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
child.once('error', err => finish({ code: null, stdout, stderr: err.message, timedOut: false, aborted: false }))
|
|
75
|
+
child.once('close', code => finish({ code, stdout, stderr, timedOut: false, aborted: false }))
|
|
76
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
60
77
|
child.stdin.on('error', () => { /* close/error is authoritative */ })
|
|
61
78
|
child.stdin.end(input)
|
|
62
79
|
})
|
|
@@ -94,12 +111,13 @@ export function codexProofText(stdout: string): string {
|
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
function safeProofError(result: ProcessResult): string {
|
|
114
|
+
if (result.aborted) return 'provider proof canceled'
|
|
97
115
|
if (result.timedOut) return 'provider proof timed out'
|
|
98
116
|
if (result.code !== 0) return `provider process exited ${result.code ?? 'before launch'}`
|
|
99
117
|
return 'provider returned no valid proof response'
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
async function executeProof(provider: ProofProvider): Promise<ProviderProofResult> {
|
|
120
|
+
async function executeProof(provider: ProofProvider, signal?: AbortSignal): Promise<ProviderProofResult> {
|
|
103
121
|
const started = Date.now()
|
|
104
122
|
const result = provider === 'claude'
|
|
105
123
|
? await runBounded('claude', [
|
|
@@ -110,7 +128,7 @@ async function executeProof(provider: ProofProvider): Promise<ProviderProofResul
|
|
|
110
128
|
'--allowedTools', '',
|
|
111
129
|
'--system-prompt', PROOF_PROMPT,
|
|
112
130
|
PROOF_PROMPT,
|
|
113
|
-
], '')
|
|
131
|
+
], '', 120_000, signal)
|
|
114
132
|
: await runBounded('codex', [
|
|
115
133
|
'exec',
|
|
116
134
|
'--sandbox', 'read-only',
|
|
@@ -119,7 +137,7 @@ async function executeProof(provider: ProofProvider): Promise<ProviderProofResul
|
|
|
119
137
|
'--cd', cosBrainDir() ?? process.cwd(),
|
|
120
138
|
'--ephemeral',
|
|
121
139
|
'-',
|
|
122
|
-
], PROOF_PROMPT)
|
|
140
|
+
], PROOF_PROMPT, 120_000, signal)
|
|
123
141
|
const text = provider === 'claude'
|
|
124
142
|
? claudeProofText(result.stdout)
|
|
125
143
|
: codexProofText(result.stdout)
|
|
@@ -134,12 +152,12 @@ async function executeProof(provider: ProofProvider): Promise<ProviderProofResul
|
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
/** Actual no-tool model turn, cached only after success for this server boot. */
|
|
137
|
-
export async function runProviderProof(provider: ProofProvider): Promise<ProviderProofResult> {
|
|
155
|
+
export async function runProviderProof(provider: ProofProvider, signal?: AbortSignal): Promise<ProviderProofResult> {
|
|
138
156
|
const cached = successCache.get(provider)
|
|
139
157
|
if (cached) return { ...cached, cached: true }
|
|
140
158
|
const existing = inFlight.get(provider)
|
|
141
159
|
if (existing) return existing
|
|
142
|
-
const operation = executeProof(provider).then(result => {
|
|
160
|
+
const operation = executeProof(provider, signal).then(result => {
|
|
143
161
|
if (result.ok) successCache.set(provider, result)
|
|
144
162
|
return result
|
|
145
163
|
}).finally(() => {
|
|
@@ -34,6 +34,7 @@ export interface TranscribeAudioResult {
|
|
|
34
34
|
requestedMode: TranscribeMode
|
|
35
35
|
actualQuality: 'hq' | 'fast' | 'cloud'
|
|
36
36
|
degraded: boolean
|
|
37
|
+
degradationReason?: string
|
|
37
38
|
elapsedMs: number
|
|
38
39
|
audioBytes: number
|
|
39
40
|
}
|
|
@@ -62,6 +63,12 @@ export class NoSpeechDetectedError extends Error {
|
|
|
62
63
|
// ceiling (anything longer is a dictation, not a query — use meetings instead).
|
|
63
64
|
const HQ_MAX_SECONDS = 60
|
|
64
65
|
|
|
66
|
+
/** Short interactive clips use light enhance (highpass only). Override via env. */
|
|
67
|
+
function hqEnhanceLightMaxSeconds(): number {
|
|
68
|
+
const value = Number.parseInt(process.env.COS_HQ_ENHANCE_LIGHT_MAX_SEC || '15', 10)
|
|
69
|
+
return Number.isFinite(value) && value >= 0 ? value : 15
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
|
|
66
73
|
const fallback = getTranscriptionPolicySnapshot()
|
|
67
74
|
if (fallback.openaiFallbackReady) return null
|
|
@@ -154,17 +161,26 @@ export async function transcribeAudioBuffer(
|
|
|
154
161
|
let text: string
|
|
155
162
|
let backend: string
|
|
156
163
|
let actualQuality: 'hq' | 'fast' | 'cloud'
|
|
164
|
+
let degradationReason: string | undefined = effectiveMode !== requestedMode ? 'audio_too_long' : undefined
|
|
157
165
|
const tStart = performance.now()
|
|
158
166
|
|
|
159
167
|
if (effectiveMode === 'hq') {
|
|
160
168
|
try {
|
|
161
|
-
const
|
|
169
|
+
const lightMax = hqEnhanceLightMaxSeconds()
|
|
170
|
+
const enhanceProfile = audioSeconds < lightMax ? 'light' as const : 'full' as const
|
|
171
|
+
const enhanced = await enhanceAudio(audioBuffer, { profile: enhanceProfile })
|
|
162
172
|
const result = await transcribeHighQuality(enhanced)
|
|
163
173
|
text = result.text
|
|
164
|
-
|
|
165
|
-
actualQuality
|
|
174
|
+
actualQuality = result.actualQuality
|
|
175
|
+
if (result.actualQuality === 'hq') {
|
|
176
|
+
backend = enhanceProfile === 'light' ? 'hq-large-v3-light' : 'hq-large-v3'
|
|
177
|
+
} else {
|
|
178
|
+
backend = result.backend === 'whisper-cli' ? 'fast-cli-turbo' : 'fast-local-server'
|
|
179
|
+
degradationReason = result.degradationReason ?? 'hq_unavailable'
|
|
180
|
+
}
|
|
166
181
|
} catch (hqErr: any) {
|
|
167
182
|
console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
|
|
183
|
+
degradationReason = 'hq_decode_failed'
|
|
168
184
|
try {
|
|
169
185
|
const result = await transcribeLocal(audioBuffer)
|
|
170
186
|
text = result.text
|
|
@@ -260,6 +276,7 @@ export async function transcribeAudioBuffer(
|
|
|
260
276
|
requestedMode,
|
|
261
277
|
actualQuality,
|
|
262
278
|
degraded: requestedMode === 'hq' && actualQuality !== 'hq',
|
|
279
|
+
...(degradationReason ? { degradationReason } : {}),
|
|
263
280
|
elapsedMs,
|
|
264
281
|
audioBytes: audioBuffer.length,
|
|
265
282
|
}
|