@gotcos/glasses-server 6.50.1 → 6.50.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 6.50.3
|
|
2
|
+
|
|
3
|
+
- **The server referees between copies of the COS Glasses app.** 2026-09-18 10:06: the Even Realities app kept an older WebView copy of the plugin alive (hidden since at least 09:30) behind the one Miles opened, both answered one Start Meeting to the millisecond, and the same 90-minute meeting was recorded and HQ-polished twice. Glasses 6.9.505's claim in the Even app's storage could not stop it: the new copy read the old copy's claim 36 minutes stale, and the hidden copy never refreshed its claim or read the new one, while its HTTP calls to this server kept working (it uploaded all 827 chunks). `POST /api/client-instance/claim` `{ id, bootAt, version }` answers `{ verdict: 'owner' | 'yield', owner }`: the newest boot that is still posting owns the ring (ties by id); a claim from an older boot takes over only after the owner has been quiet for 45 s (`CLIENT_INSTANCE_LIVE_MS`, three 15 s ticks). `check: true` answers without taking, so a copy that already yielded can ask whether the owner is gone without stealing the ring after a phone lock froze both copies' timers. Behind the API token. In memory: a restart forgets the owner and the next posts re-establish it within a tick. Glasses 6.9.507 asks this route first and falls back to the storage claim when the Mac is unreachable or older.
|
|
4
|
+
- Tests (`client-instance.test.ts`): the arbitration executed (the zombie yields, the owner refreshes, the older reclaims one ms past the live edge, a same-millisecond tie has one owner, malformed claims refused) and over HTTP (state kept across requests, a 400 changes nothing); the mount sits after `requireApiToken` and is not a public path. Gate: `referee-dead-owner-never-released`, `referee-older-boot-wins`, `referee-not-mounted`, 3/3.
|
|
5
|
+
|
|
6
|
+
## 6.50.2
|
|
7
|
+
|
|
8
|
+
- **The Turbo meeting preview no longer switches itself off for good.** Miles, 2026-09-18 08:43: the `~` provisional line on the lens was gone, Mac-reachable, Turbo configured. The preview route had answered "no text" to 5,719 requests since 2026-09-17 16:00 (in under a millisecond) while the Turbo sidecar itself transcribed the same audio in 0.27 s. One failed request (`The operation was aborted due to timeout`, then `fetch failed`, with the Mac under build load) set `previewAvailable = false`, and the only code that sets it back is the sidecar's own start, which runs once per server boot. Now a failure against a sidecar that is still running is a 10 s cooldown (`PREVIEW_FAILURE_COOLDOWN_MS`) and the next request after it tries again; five failures in a row (`PREVIEW_RECYCLE_AFTER_FAILURES`) recycle the sidecar; a sidecar that exited is restarted from the request path, at most once per 60 s window, doubling to 15 min while restarts keep failing and resetting on any answer. A missing model, a missing binary or a foreign listener on port 8177 is never restarted (a restart cannot fix it). After the server's own shutdown stop, nothing revives the sidecar (a request arriving during shutdown could otherwise have spawned one that outlived the process). Prompt previews were never affected (they fall back to the canonical worker); canonical meeting ASR never was. The preview log lines now carry an ISO timestamp: the lines that recorded this failure had none.
|
|
9
|
+
- Tests (`whisper-preview.test.ts`, executed against spawn and fetch fakes and a fake clock): a timeout is a cooldown and the next request after it gets text; a success resets the count so scattered misses never recycle; five in a row recycle and the new sidecar answers; an exited sidecar restarts from the request path and the backoff doubles across failed restarts; a foreign listener is never re-probed. Eight mutants (latch restored, success never resets, no revive, backoff never doubles, cooldown ignored, foreign port restarted, revive after shutdown, start after shutdown) each turn the suite red; three of them survived the first draft of the tests and the tests were tightened (a recycle's kill is synchronous, its respawn is not, so negatives now wait for the loop to settle).
|
|
10
|
+
- **Coming back to a Desktop tab no longer blocks a Continue for 30 seconds.** Miles, 2026-09-17 07:00: five sends from the glasses refused "Your Mac is writing to this thread right now" until he clicked away from the tab. Claude Desktop had written its away summary (the note it drops when you return to a tab you left) to the transcript 11 s earlier, and the sub-agent that wrote it fired `SubagentStop`. The attach gate reads a transcript written inside 30 s as working unless the hooks vouch that the turn ended, and the vouch required the newest hook event to be the `Stop` itself, so the desk's own bookkeeping after it made the gate strict. The vouch now reads "no turn since the Stop": a `SubagentStop` with no sub-agent open, or a notification, keeps it; anything that opens a turn (a prompt, a tool, a sub-agent starting) still refuses, and a newer Stop still moves the clock.
|
|
11
|
+
- Tests: the two bookkeeping events keep the vouch and each other guard still holds behind them; a tool, a prompt, a sub-agent start and a permission request stay strict. Gate: `turns-b6-desk-bookkeeping-is-strict-again` and `turns-b6-any-event-vouches` added; the four B6 cases killed.
|
|
12
|
+
|
|
1
13
|
## 6.50.1
|
|
2
14
|
|
|
3
15
|
What the QA pass on 6.50.0 found in the history it serves (6.50.0 was published before the pass finished; nothing it changed regressed, and a client that does not ask for `?turns` sees no difference).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.50.
|
|
3
|
+
"version": "6.50.3",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { execSync } from 'node:child_process'
|
|
|
14
14
|
import { randomBytes } from 'node:crypto'
|
|
15
15
|
import { healthRouter } from './routes/health.js'
|
|
16
16
|
import { diagRouter } from './routes/diag.js'
|
|
17
|
+
import { createClientInstanceRouter } from './routes/client-instance.js'
|
|
17
18
|
import { queryRouter } from './routes/query.js'
|
|
18
19
|
import { providerProofRouter } from './routes/provider-proof.js'
|
|
19
20
|
import { transcribeRouter } from './routes/transcribe.js'
|
|
@@ -530,6 +531,8 @@ const forkThreadForRoute = (request: {
|
|
|
530
531
|
// API routes
|
|
531
532
|
app.use('/api', healthRouter)
|
|
532
533
|
app.use('/api', diagRouter)
|
|
534
|
+
// 6.50.3: the referee between copies of the COS Glasses app (routes/client-instance.ts).
|
|
535
|
+
app.use('/api', createClientInstanceRouter())
|
|
533
536
|
app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
|
|
534
537
|
prepareAdmission: preparePublicDurableQueryAdmission,
|
|
535
538
|
}))
|
|
@@ -858,7 +861,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
858
861
|
try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
|
|
859
862
|
stopCodexModelCatalogRefresh()
|
|
860
863
|
stopWhisperServer()
|
|
861
|
-
await stopWhisperPreviewServer()
|
|
864
|
+
await stopWhisperPreviewServer({ final: true })
|
|
862
865
|
stopLocalTtsServer()
|
|
863
866
|
clearTimeout(forceExit)
|
|
864
867
|
process.exit(0)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Which copy of the COS Glasses app owns the ring (6.50.3). PURE: the clock is passed in.
|
|
2
|
+
//
|
|
3
|
+
// WHY THE SERVER. On 2026-09-18 the Even Realities app kept an older WebView copy of the
|
|
4
|
+
// plugin alive behind a newly opened one and delivered every ring event to both, so one
|
|
5
|
+
// Start Meeting recorded the same 90 minutes twice. Glasses 6.9.505 put a claim in the Even
|
|
6
|
+
// app's own storage: the newest boot wins, the copy behind it yields. It failed in the field
|
|
7
|
+
// because the hidden copy's storage calls went unanswered (its claim sat 36 min stale and it
|
|
8
|
+
// never read the newer one), while its HTTP calls to this server kept working (it uploaded
|
|
9
|
+
// all 827 chunks). This server is the one party every copy can still reach.
|
|
10
|
+
//
|
|
11
|
+
// RULE. One owner at a time. A claim from the owner refreshes it. A claim from a NEWER boot
|
|
12
|
+
// (ties broken by id) takes ownership. A claim from an older boot takes ownership only when
|
|
13
|
+
// the owner has gone quiet for CLIENT_INSTANCE_LIVE_MS (the newer copy died). In memory: a
|
|
14
|
+
// server restart forgets the owner, and the next posts re-establish it within one tick.
|
|
15
|
+
|
|
16
|
+
export const CLIENT_INSTANCE_TICK_MS = 15_000
|
|
17
|
+
/** Three missed ticks: the owner is gone. */
|
|
18
|
+
export const CLIENT_INSTANCE_LIVE_MS = 3 * CLIENT_INSTANCE_TICK_MS
|
|
19
|
+
|
|
20
|
+
export interface ClientInstanceClaim {
|
|
21
|
+
id: string
|
|
22
|
+
bootAt: number
|
|
23
|
+
version: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ClientInstanceOwner extends ClientInstanceClaim {
|
|
27
|
+
seenAt: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ClientInstanceVerdict = 'owner' | 'yield'
|
|
31
|
+
|
|
32
|
+
const ID_PATTERN = /^[a-z0-9]{1,16}-[a-z0-9]{1,8}$/
|
|
33
|
+
|
|
34
|
+
/** A body the route may act on, or null. Never throws. */
|
|
35
|
+
export function parseClientInstanceClaim(body: unknown): ClientInstanceClaim | null {
|
|
36
|
+
if (!body || typeof body !== 'object') return null
|
|
37
|
+
const o = body as Record<string, unknown>
|
|
38
|
+
if (typeof o.id !== 'string' || !ID_PATTERN.test(o.id)) return null
|
|
39
|
+
if (typeof o.bootAt !== 'number' || !Number.isFinite(o.bootAt) || o.bootAt <= 0) return null
|
|
40
|
+
const version = typeof o.version === 'string' ? o.version.slice(0, 32) : ''
|
|
41
|
+
return { id: o.id, bootAt: o.bootAt, version }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isNewer(a: ClientInstanceClaim, b: ClientInstanceClaim): boolean {
|
|
45
|
+
return a.bootAt > b.bootAt || (a.bootAt === b.bootAt && a.id > b.id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The owner after `claim` arrives at `now`, and what to tell its sender. */
|
|
49
|
+
export function arbitrateClientInstance(
|
|
50
|
+
owner: ClientInstanceOwner | null,
|
|
51
|
+
claim: ClientInstanceClaim,
|
|
52
|
+
now: number,
|
|
53
|
+
): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean } {
|
|
54
|
+
const fresh: ClientInstanceOwner = { ...claim, seenAt: now }
|
|
55
|
+
if (!owner) return { owner: fresh, verdict: 'owner', took: true }
|
|
56
|
+
if (owner.id === claim.id) return { owner: { ...owner, seenAt: now }, verdict: 'owner', took: false }
|
|
57
|
+
if (isNewer(claim, owner)) return { owner: fresh, verdict: 'owner', took: true }
|
|
58
|
+
if (now - owner.seenAt > CLIENT_INSTANCE_LIVE_MS) return { owner: fresh, verdict: 'owner', took: true }
|
|
59
|
+
return { owner, verdict: 'yield', took: false }
|
|
60
|
+
}
|
|
@@ -792,15 +792,28 @@ export function buildOccupancyProbes(
|
|
|
792
792
|
*
|
|
793
793
|
* `signalFor` is the signal store's reader, taking a full session id or the registry's
|
|
794
794
|
* 8-character form; `registryIdleAfterStop` reads the registry record for the session.
|
|
795
|
-
* The probe answers a Stop time only when BOTH vouch:
|
|
796
|
-
*
|
|
797
|
-
*
|
|
795
|
+
* The probe answers a Stop time only when BOTH vouch: nothing since the Stop has opened
|
|
796
|
+
* a turn (the desk's own bookkeeping after it does not count: the sub-agent that writes
|
|
797
|
+
* Claude Desktop's away summary, an idle notification), the turn is closed, no sub-agent
|
|
798
|
+
* is open, the session has not ended, and the registry says idle at or after the Stop
|
|
799
|
+
* (the hooks have returned). Codex never
|
|
798
800
|
* asks (its ids are not Claude sessions, and a Codex thread id sharing a Claude prefix
|
|
799
801
|
* must not read a Claude signal); `readHolderActivity` guards the provider too.
|
|
800
802
|
*
|
|
801
803
|
* Wired at the composition root only when `COS_SESSION_HOOKS` is on. Absent, the
|
|
802
804
|
* gate is byte-for-byte 6.48.0.
|
|
803
805
|
*/
|
|
806
|
+
/**
|
|
807
|
+
* Hook events that can follow a Stop without a turn having started (6.50.2). Miles,
|
|
808
|
+
* 2026-09-17 07:00: five sends refused `native_thread_working` because Claude Desktop
|
|
809
|
+
* wrote its `away_summary` system row to the transcript 11 s earlier and the sub-agent
|
|
810
|
+
* that wrote it fired SubagentStop, so the newest event was not 'Stop' and the 30 s
|
|
811
|
+
* transcript window held. Every event that DOES mean a turn sets `turnOpen`
|
|
812
|
+
* (UserPromptSubmit, a tool) or `subagentsOpen` (SubagentStart), which the guards
|
|
813
|
+
* behind this set still read; a newer Stop replaces `stopAt`.
|
|
814
|
+
*/
|
|
815
|
+
const AFTER_THE_TURN: ReadonlySet<string> = new Set(['Stop', 'SubagentStop', 'Notification'])
|
|
816
|
+
|
|
804
817
|
export function withHookTurnClock(
|
|
805
818
|
probes: OccupancyProbes,
|
|
806
819
|
signalFor: (sessionId: string) => HookTurnSignal | undefined,
|
|
@@ -812,7 +825,7 @@ export function withHookTurnClock(
|
|
|
812
825
|
if (provider !== 'claude') return null
|
|
813
826
|
if (!NATIVE_THREAD_ID_RE.test(threadId)) return null
|
|
814
827
|
const signal = signalFor(threadId)
|
|
815
|
-
if (!signal || signal.lastEvent
|
|
828
|
+
if (!signal || !AFTER_THE_TURN.has(signal.lastEvent) || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
|
|
816
829
|
if (typeof signal.stopAt !== 'number') return null
|
|
817
830
|
// The engine's own end of turn: the registry flipped idle AFTER this Stop, which
|
|
818
831
|
// happens only once every Stop hook has returned. Anything else is strict.
|
|
@@ -69,6 +69,82 @@ let previewFailure: WhisperPreviewReason = null
|
|
|
69
69
|
let previewWorkerModel: WhisperPreviewSidecarModel | null = null
|
|
70
70
|
let warnedInvalidChoice = false
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 6.50.2: a request that fails against a sidecar that is STILL RUNNING is a miss, not a
|
|
74
|
+
* death. Before this, one timeout (the Mac under load) or one refused fetch set
|
|
75
|
+
* `previewAvailable = false`, and its only writer back to true is the sidecar's own start,
|
|
76
|
+
* which runs once per server boot, so the meeting preview lane stayed silent for the rest
|
|
77
|
+
* of the process: 2026-09-17 16:00 to 2026-09-18 08:48 (34 h), 5,719 requests answered 204
|
|
78
|
+
* in under a millisecond while Turbo itself answered the same audio in 0.27 s.
|
|
79
|
+
*
|
|
80
|
+
* Now: a failure against a live sidecar starts a short cooldown and the next request after
|
|
81
|
+
* it tries again. PREVIEW_RECYCLE_AFTER_FAILURES in a row recycles the sidecar (stop, then
|
|
82
|
+
* start). A sidecar that is gone is restarted from the request path, backing off from
|
|
83
|
+
* PREVIEW_RESTART_BASE_MS to PREVIEW_RESTART_MAX_MS so a model that cannot load is not
|
|
84
|
+
* reloaded every minute. Any successful answer resets both counters.
|
|
85
|
+
*/
|
|
86
|
+
export const PREVIEW_FAILURE_COOLDOWN_MS = 10_000
|
|
87
|
+
export const PREVIEW_RECYCLE_AFTER_FAILURES = 5
|
|
88
|
+
export const PREVIEW_RESTART_BASE_MS = 60_000
|
|
89
|
+
export const PREVIEW_RESTART_MAX_MS = 15 * 60_000
|
|
90
|
+
let previewCooldownUntil = 0
|
|
91
|
+
let previewConsecutiveFailures = 0
|
|
92
|
+
let previewLastRestartAt = 0
|
|
93
|
+
let previewRestartBackoffMs = PREVIEW_RESTART_BASE_MS
|
|
94
|
+
/** Set by the server's shutdown: no request may revive the sidecar after it (QA, 6.50.2). */
|
|
95
|
+
let previewShutdown = false
|
|
96
|
+
|
|
97
|
+
function previewSidecarAlive(): boolean {
|
|
98
|
+
const child = previewProcess
|
|
99
|
+
return !!child && child.exitCode === null && child.signalCode === null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function notePreviewRequestSuccess(): void {
|
|
103
|
+
previewConsecutiveFailures = 0
|
|
104
|
+
previewCooldownUntil = 0
|
|
105
|
+
previewRestartBackoffMs = PREVIEW_RESTART_BASE_MS
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A request to the sidecar failed (not a preemption). Cooldown, recycle, or mark it gone. */
|
|
109
|
+
function notePreviewRequestFailure(error: unknown, label: string): void {
|
|
110
|
+
previewConsecutiveFailures++
|
|
111
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
112
|
+
const at = new Date().toISOString()
|
|
113
|
+
if (previewSidecarAlive() && previewConsecutiveFailures < PREVIEW_RECYCLE_AFTER_FAILURES) {
|
|
114
|
+
previewCooldownUntil = Date.now() + PREVIEW_FAILURE_COOLDOWN_MS
|
|
115
|
+
console.warn(`[whisper-preview] ${at} ${label} failed (${previewConsecutiveFailures} in a row); next try in ${PREVIEW_FAILURE_COOLDOWN_MS / 1000} s: ${message}`)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const alive = previewSidecarAlive()
|
|
119
|
+
previewAvailable = false
|
|
120
|
+
previewWorkerModel = null
|
|
121
|
+
previewFailure = 'preview_sidecar_unavailable'
|
|
122
|
+
console.warn(`[whisper-preview] ${at} ${label} failed (${previewConsecutiveFailures} in a row); ${alive ? 'recycling the sidecar' : 'the sidecar is gone'}: ${message}`)
|
|
123
|
+
maybeRevivePreviewSidecar()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Bring a failed sidecar back, from the request path. Only for a sidecar that ran and then
|
|
128
|
+
* failed or exited: a missing model, a missing binary or a foreign listener on the port
|
|
129
|
+
* cannot be fixed by a restart and stay as they are.
|
|
130
|
+
*/
|
|
131
|
+
function maybeRevivePreviewSidecar(): void {
|
|
132
|
+
if (previewShutdown || previewAvailable || previewStarting) return
|
|
133
|
+
if (previewFailure !== 'preview_sidecar_unavailable' && previewFailure !== 'preview_start_failed') return
|
|
134
|
+
const now = Date.now()
|
|
135
|
+
if (previewLastRestartAt && now - previewLastRestartAt < previewRestartBackoffMs) return
|
|
136
|
+
if (previewLastRestartAt) previewRestartBackoffMs = Math.min(previewRestartBackoffMs * 2, PREVIEW_RESTART_MAX_MS)
|
|
137
|
+
previewLastRestartAt = now
|
|
138
|
+
previewConsecutiveFailures = 0
|
|
139
|
+
console.log(`[whisper-preview] ${new Date(now).toISOString()} restarting the preview sidecar (${previewFailure}); next restart no sooner than ${Math.round(previewRestartBackoffMs / 1000)} s`)
|
|
140
|
+
void (async () => {
|
|
141
|
+
if (previewProcess) await stopWhisperPreviewServer()
|
|
142
|
+
await startWhisperPreviewServer()
|
|
143
|
+
})().catch(error => {
|
|
144
|
+
console.warn(`[whisper-preview] restart failed: ${error instanceof Error ? error.message : error}`)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
72
148
|
interface ProcessEntry {
|
|
73
149
|
pid: number
|
|
74
150
|
ppid: number
|
|
@@ -260,6 +336,9 @@ async function endpointReady(path: '/health' | '/inference', init?: RequestInit,
|
|
|
260
336
|
* uses Turbo while its canonical live worker remains Large-v3. Failure is
|
|
261
337
|
* cosmetic: every recovery/finalization path stays untouched. */
|
|
262
338
|
export async function startWhisperPreviewServer(): Promise<void> {
|
|
339
|
+
// A revive already past its stop when shutdown landed must not spawn an orphan that
|
|
340
|
+
// outlives the server.
|
|
341
|
+
if (previewShutdown) return
|
|
263
342
|
const requested = requestedPreviewModel()
|
|
264
343
|
const selected = selectedPreviewModel(requested)
|
|
265
344
|
if (!needsPreviewSidecar(selected) || previewProcess || previewAvailable || previewStarting) return
|
|
@@ -359,7 +438,8 @@ function waitForPreviewClose(child: ChildProcess, timeoutMs: number): Promise<bo
|
|
|
359
438
|
})
|
|
360
439
|
}
|
|
361
440
|
|
|
362
|
-
export async function stopWhisperPreviewServer(): Promise<void> {
|
|
441
|
+
export async function stopWhisperPreviewServer(options: { final?: boolean } = {}): Promise<void> {
|
|
442
|
+
if (options.final) previewShutdown = true
|
|
363
443
|
const child = previewProcess
|
|
364
444
|
previewProcess = null
|
|
365
445
|
previewAvailable = false
|
|
@@ -395,27 +475,23 @@ export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
|
|
|
395
475
|
model: 'small.en' | WhisperCommitModel
|
|
396
476
|
backend: 'whisper-preview-server' | 'whisper-server'
|
|
397
477
|
}> {
|
|
398
|
-
if (previewAvailable
|
|
478
|
+
if (!previewAvailable) maybeRevivePreviewSidecar()
|
|
479
|
+
if (previewAvailable && previewWorkerModel && Date.now() >= previewCooldownUntil) {
|
|
399
480
|
const workerModel = previewWorkerModel
|
|
400
481
|
const previewLease = tryAcquireMetalPreview()
|
|
401
482
|
if (!previewLease) {
|
|
402
483
|
return { text: '', model: workerModel, backend: 'whisper-preview-server' }
|
|
403
484
|
}
|
|
404
485
|
try {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
backend: 'whisper-preview-server',
|
|
409
|
-
}
|
|
486
|
+
const text = await transcribeViaPreviewServer(audioBuffer, previewLease.signal)
|
|
487
|
+
notePreviewRequestSuccess()
|
|
488
|
+
return { text, model: workerModel, backend: 'whisper-preview-server' }
|
|
410
489
|
} catch (error) {
|
|
411
490
|
if (previewLease.signal.aborted) {
|
|
412
491
|
return { text: '', model: workerModel, backend: 'whisper-preview-server' }
|
|
413
492
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
previewWorkerModel = null
|
|
417
|
-
previewFailure = 'preview_sidecar_unavailable'
|
|
418
|
-
console.warn(`[whisper-preview] ${failedModel} preview failed; falling back to canonical worker: ${error instanceof Error ? error.message : error}`)
|
|
493
|
+
// Falls through to the canonical worker below for THIS request either way.
|
|
494
|
+
notePreviewRequestFailure(error, `${workerModel} preview`)
|
|
419
495
|
} finally {
|
|
420
496
|
previewLease.release()
|
|
421
497
|
}
|
|
@@ -448,21 +524,20 @@ export async function transcribeWhisperMeetingPreview(audioBuffer: Buffer): Prom
|
|
|
448
524
|
model: 'large-v3-turbo'
|
|
449
525
|
backend: 'whisper-preview-server'
|
|
450
526
|
} | null> {
|
|
527
|
+
if (!previewAvailable) maybeRevivePreviewSidecar()
|
|
451
528
|
if (!previewAvailable || previewWorkerModel !== 'large-v3-turbo') return null
|
|
529
|
+
// A cooldown after a failed request is a quiet miss, never a latch (see above).
|
|
530
|
+
if (Date.now() < previewCooldownUntil) return null
|
|
452
531
|
const previewLease = tryAcquireMetalPreview()
|
|
453
532
|
if (!previewLease) return null
|
|
454
533
|
try {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
backend: 'whisper-preview-server',
|
|
459
|
-
}
|
|
534
|
+
const text = await transcribeViaPreviewServer(audioBuffer, previewLease.signal)
|
|
535
|
+
notePreviewRequestSuccess()
|
|
536
|
+
return { text, model: 'large-v3-turbo', backend: 'whisper-preview-server' }
|
|
460
537
|
} catch (error) {
|
|
461
538
|
if (previewLease.signal.aborted) return null
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
previewFailure = 'preview_sidecar_unavailable'
|
|
465
|
-
console.warn(`[whisper-preview] meeting Turbo preview failed; canonical meeting ASR was not affected: ${error instanceof Error ? error.message : error}`)
|
|
539
|
+
// Canonical meeting ASR is not affected by any of this: the preview is cosmetic.
|
|
540
|
+
notePreviewRequestFailure(error, 'meeting Turbo preview')
|
|
466
541
|
return null
|
|
467
542
|
} finally {
|
|
468
543
|
previewLease.release()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// POST /api/client-instance/claim — the referee between copies of the COS Glasses app (6.50.3).
|
|
2
|
+
// See lib/client-instance-claim.ts for the why. Behind the global API token like every /api
|
|
3
|
+
// route except health and diag. Body: { id, bootAt, version, check? }. Answer: { verdict, owner }.
|
|
4
|
+
//
|
|
5
|
+
// `check: true` answers without taking: a copy that already yielded asks whether the owner
|
|
6
|
+
// is still there, and must not steal the ring by asking (after a phone lock both copies'
|
|
7
|
+
// timers were frozen, so the owner only LOOKS quiet; the older copy's first tick would
|
|
8
|
+
// otherwise take it).
|
|
9
|
+
|
|
10
|
+
import { Router } from 'express'
|
|
11
|
+
import { arbitrateClientInstance, parseClientInstanceClaim, type ClientInstanceOwner } from '../lib/client-instance-claim.js'
|
|
12
|
+
|
|
13
|
+
export function createClientInstanceRouter(now: () => number = Date.now): Router {
|
|
14
|
+
const router = Router()
|
|
15
|
+
let owner: ClientInstanceOwner | null = null
|
|
16
|
+
router.post('/client-instance/claim', (req, res) => {
|
|
17
|
+
const claim = parseClientInstanceClaim(req.body)
|
|
18
|
+
if (!claim) return void res.status(400).json({ error: 'invalid_claim' })
|
|
19
|
+
const previous = owner
|
|
20
|
+
const result = arbitrateClientInstance(owner, claim, now())
|
|
21
|
+
if (req.body?.check === true) {
|
|
22
|
+
const shown = previous ?? result.owner
|
|
23
|
+
return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true })
|
|
24
|
+
}
|
|
25
|
+
owner = result.owner
|
|
26
|
+
if (result.took && previous && previous.id !== claim.id) {
|
|
27
|
+
console.log(`[client-instance] ${new Date(now()).toISOString()} ${claim.id} (${claim.version}) took the ring from ${previous.id}`)
|
|
28
|
+
}
|
|
29
|
+
res.json({
|
|
30
|
+
verdict: result.verdict,
|
|
31
|
+
owner: { id: owner.id, bootAt: owner.bootAt, version: owner.version, seenAt: owner.seenAt },
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
return router
|
|
35
|
+
}
|