@gotcos/glasses-server 6.23.0 → 6.23.1
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,40 @@
|
|
|
1
|
+
## 6.23.1
|
|
2
|
+
|
|
3
|
+
Closes the hole 6.23.0 left open, plus a lockfile version that 6.23.0 shipped out of
|
|
4
|
+
sync with package.json.
|
|
5
|
+
|
|
6
|
+
- **A restart used to re-open the bug.** 6.23.0 saves a stranded capture at the
|
|
7
|
+
4-hour cutoff, but only while the server stays up. `recoverSessions()` refuses to
|
|
8
|
+
load any session already past that cutoff at boot — it tombstones it — so a
|
|
9
|
+
restart, a COS Control update, or a crash at the wrong moment meant the sweeper
|
|
10
|
+
never saw the session and its audio landed in quarantine with no meeting. Not
|
|
11
|
+
hypothetical: `meeting_1786237535593` (139 chunks, 31 MB, `idle_expiry_unsaved`)
|
|
12
|
+
arrived there that way.
|
|
13
|
+
- **Quarantined audio now recovers itself.** The same 60-second tick picks ONE
|
|
14
|
+
unrecovered capture with chunks and asks the real
|
|
15
|
+
`POST /api/meeting/orphans/:id/recover` to turn it into a meeting. One at a time
|
|
16
|
+
because that route runs a full batch transcription — real GPU work, minutes for a
|
|
17
|
+
long capture — and a parallel backlog would starve a live recording. Oldest first,
|
|
18
|
+
since it is closest to the 72-hour purge.
|
|
19
|
+
- **It gives up rather than looping.** Three attempts per capture, then it stops and
|
|
20
|
+
says so. A capture with unreadable chunks would otherwise be retried every 60
|
|
21
|
+
seconds for three days. The audio stays quarantined and recoverable by hand, which
|
|
22
|
+
beats a retry loop that never converges. A 409 from a manual recovery does not burn
|
|
23
|
+
the budget.
|
|
24
|
+
- Recovered captures are titled "Recovered capture (audio only)", distinct from a
|
|
25
|
+
promoted session's "Auto-saved capture", because a quarantine recovery has no live
|
|
26
|
+
ASR and every speaker comes back Unknown. The library should say which is which
|
|
27
|
+
without opening the file.
|
|
28
|
+
- **`package-lock.json` was still on 6.22.1 while package.json said 6.23.0.** Caught
|
|
29
|
+
by the repo's own `launcher-contract` test, which I did not re-run after bumping the
|
|
30
|
+
version. The published 6.23.0 code is unaffected; the lockfile is now aligned and
|
|
31
|
+
the suite runs after the bump, not before it.
|
|
32
|
+
|
|
33
|
+
Verified live before this release: a backdated synthetic session was recovered at
|
|
34
|
+
boot, drafted by the sweeper within 60s, and promoted to a meeting at the cutoff
|
|
35
|
+
about 40s later, with the draft cleared and the domain inferred rather than
|
|
36
|
+
hardcoded. 14 new tests here, full suite 1425 serially.
|
|
37
|
+
|
|
1
38
|
## 6.23.0
|
|
2
39
|
|
|
3
40
|
A recording whose phone goes away now becomes a meeting on its own. Miles: "we end
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.23.
|
|
3
|
+
"version": "6.23.1",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Quarantined audio that nobody asked to recover still has to become a meeting.
|
|
2
|
+
//
|
|
3
|
+
// THE HOLE THIS CLOSES. 6.23.0 made a stranded session save itself at the 4-hour
|
|
4
|
+
// cutoff, but that only works while the server stays up. `recoverSessions()`
|
|
5
|
+
// refuses to load any session already past the cutoff at boot — it tombstones it —
|
|
6
|
+
// so a restart, a COS Control update, or a crash at the wrong moment means the
|
|
7
|
+
// sweeper never sees the session, its audio dir is orphaned, and it lands in
|
|
8
|
+
// quarantine with no meeting. That is exactly the old behavior, reached by a
|
|
9
|
+
// different door, and it is not hypothetical: `meeting_1786237535593` (139 chunks,
|
|
10
|
+
// 31 MB, reason `idle_expiry_unsaved`) came through it.
|
|
11
|
+
//
|
|
12
|
+
// Quarantine already keeps the audio for 72 hours and `POST
|
|
13
|
+
// /api/meeting/orphans/:id/recover` already turns it into a meeting. The only thing
|
|
14
|
+
// missing was that a human had to notice and press it. This picks one per sweep.
|
|
15
|
+
//
|
|
16
|
+
// WHY ONE AT A TIME. Recovery runs a full batch transcription over every chunk WAV
|
|
17
|
+
// — real GPU and CPU work, minutes for a long capture. Recovering a backlog in
|
|
18
|
+
// parallel would starve a live recording, so the sweep takes the oldest and leaves
|
|
19
|
+
// the rest for the next tick. The route's own `shouldAbort` already yields to a live
|
|
20
|
+
// recording once started.
|
|
21
|
+
//
|
|
22
|
+
// WHY THE ATTEMPT LEDGER. A capture that cannot be recovered — unreadable chunks, a
|
|
23
|
+
// codec the batch path rejects — would otherwise be retried every 60 seconds for 72
|
|
24
|
+
// hours, burning the machine and drowning the log. After a few failures it is left
|
|
25
|
+
// alone: the audio is still preserved and still recoverable by hand, which is
|
|
26
|
+
// strictly better than a retry loop that never converges.
|
|
27
|
+
|
|
28
|
+
import type { UnsavedCapture } from './unsaved-audio-quarantine.js'
|
|
29
|
+
|
|
30
|
+
/** Attempts per capture before the sweep stops trying on its own. */
|
|
31
|
+
export const MAX_AUTO_RECOVER_ATTEMPTS = 3
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Title for a capture the sweep recovers.
|
|
35
|
+
*
|
|
36
|
+
* Distinct from the promote title on purpose. A promoted session carried a live ASR
|
|
37
|
+
* transcript with speaker labels; a quarantine recovery has neither, because no live
|
|
38
|
+
* ASR ever ran on it — every speaker comes back Unknown. The user should be able to
|
|
39
|
+
* tell those two apart in the library without opening them.
|
|
40
|
+
*/
|
|
41
|
+
export const AUTO_RECOVER_TITLE = 'Recovered capture (audio only)'
|
|
42
|
+
|
|
43
|
+
export interface AutoRecoverState {
|
|
44
|
+
/** sessionId → attempts already made this process lifetime. */
|
|
45
|
+
attempts: Map<string, number>
|
|
46
|
+
/** Recoveries currently running, from the route's own set. */
|
|
47
|
+
inFlight: ReadonlySet<string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which quarantined capture, if any, should the sweep recover next?
|
|
52
|
+
*
|
|
53
|
+
* Returns null when there is nothing to do — the common case — so the caller does no
|
|
54
|
+
* work on a quiet tick.
|
|
55
|
+
*/
|
|
56
|
+
export function pickQuarantineToRecover(
|
|
57
|
+
items: readonly UnsavedCapture[],
|
|
58
|
+
state: AutoRecoverState,
|
|
59
|
+
): UnsavedCapture | null {
|
|
60
|
+
const eligible = items.filter(item => {
|
|
61
|
+
// Already a meeting. Recovering again would duplicate it.
|
|
62
|
+
if (item.recovered) return false
|
|
63
|
+
// Nothing to transcribe: a chunk-less dir is residue, not evidence.
|
|
64
|
+
if (item.chunkFiles <= 0) return false
|
|
65
|
+
// Another recovery owns this one.
|
|
66
|
+
if (state.inFlight.has(item.sessionId)) return false
|
|
67
|
+
return (state.attempts.get(item.sessionId) ?? 0) < MAX_AUTO_RECOVER_ATTEMPTS
|
|
68
|
+
})
|
|
69
|
+
if (eligible.length === 0) return null
|
|
70
|
+
// Oldest first: it is closest to the 72-hour purge, so it has the least time left.
|
|
71
|
+
// `ageHours` can be null when the marker is unreadable — treat that as oldest
|
|
72
|
+
// rather than newest, because an unreadable marker is itself a sign of an old dir.
|
|
73
|
+
return [...eligible].sort((a, b) => (b.ageHours ?? Number.MAX_SAFE_INTEGER)
|
|
74
|
+
- (a.ageHours ?? Number.MAX_SAFE_INTEGER))[0] ?? null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Record an attempt. Called BEFORE the request, so a hang still counts. */
|
|
78
|
+
export function noteRecoverAttempt(state: AutoRecoverState, sessionId: string): void {
|
|
79
|
+
state.attempts.set(sessionId, (state.attempts.get(sessionId) ?? 0) + 1)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Clear the ledger for a capture that succeeded, so a later re-quarantine is fresh. */
|
|
83
|
+
export function clearRecoverAttempts(state: AutoRecoverState, sessionId: string): void {
|
|
84
|
+
state.attempts.delete(sessionId)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function autoRecoverExhausted(state: AutoRecoverState, sessionId: string): boolean {
|
|
88
|
+
return (state.attempts.get(sessionId) ?? 0) >= MAX_AUTO_RECOVER_ATTEMPTS
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RecoverRequestResult {
|
|
92
|
+
ok: boolean
|
|
93
|
+
status: number
|
|
94
|
+
filename?: string
|
|
95
|
+
reason?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface RecoverRequestOptions {
|
|
99
|
+
port: number
|
|
100
|
+
token: string
|
|
101
|
+
fetchImpl?: typeof fetch
|
|
102
|
+
timeoutMs?: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Ask the real recover route to turn a quarantined capture into a meeting.
|
|
107
|
+
*
|
|
108
|
+
* Loopback for the same reason promote is: that route owns the recovering-set
|
|
109
|
+
* guard, the batch transcription, the `shouldAbort` yield to a live recording, the
|
|
110
|
+
* receipt and the `markRecovered` stamp. Reimplementing any of it here would fork
|
|
111
|
+
* the path a user's own button press takes.
|
|
112
|
+
*
|
|
113
|
+
* A generous default timeout: batch transcription of a long capture is minutes of
|
|
114
|
+
* real work, and aborting early would leave the route running with no one reading
|
|
115
|
+
* the result.
|
|
116
|
+
*/
|
|
117
|
+
export async function requestQuarantineRecovery(
|
|
118
|
+
sessionId: string,
|
|
119
|
+
options: RecoverRequestOptions,
|
|
120
|
+
): Promise<RecoverRequestResult> {
|
|
121
|
+
if (!options.token) return { ok: false, status: 0, reason: 'no_token' }
|
|
122
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
123
|
+
const controller = new AbortController()
|
|
124
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 1_800_000)
|
|
125
|
+
try {
|
|
126
|
+
const res = await doFetch(
|
|
127
|
+
`http://127.0.0.1:${options.port}/api/meeting/orphans/${encodeURIComponent(sessionId)}/recover`,
|
|
128
|
+
{
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json', 'X-Cos-Token': options.token },
|
|
131
|
+
body: JSON.stringify({ title: AUTO_RECOVER_TITLE }),
|
|
132
|
+
signal: controller.signal,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
let payload: Record<string, unknown> = {}
|
|
136
|
+
try { payload = await res.json() as Record<string, unknown> } catch {}
|
|
137
|
+
return {
|
|
138
|
+
ok: res.ok,
|
|
139
|
+
status: res.status,
|
|
140
|
+
filename: typeof payload.filename === 'string' ? payload.filename : undefined,
|
|
141
|
+
reason: typeof payload.reason === 'string' ? payload.reason : undefined,
|
|
142
|
+
}
|
|
143
|
+
} catch (error: any) {
|
|
144
|
+
return { ok: false, status: 0, reason: error?.name === 'AbortError' ? 'timeout' : 'request_failed' }
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timer)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -23,6 +23,14 @@ import {
|
|
|
23
23
|
writeStrandedDraft,
|
|
24
24
|
} from '../lib/stranded-session-actions.js'
|
|
25
25
|
import { getSessionHeartbeat } from '../lib/session-heartbeats.js'
|
|
26
|
+
import {
|
|
27
|
+
MAX_AUTO_RECOVER_ATTEMPTS,
|
|
28
|
+
autoRecoverExhausted,
|
|
29
|
+
clearRecoverAttempts,
|
|
30
|
+
noteRecoverAttempt,
|
|
31
|
+
pickQuarantineToRecover,
|
|
32
|
+
requestQuarantineRecovery,
|
|
33
|
+
} from '../lib/quarantine-auto-recover.js'
|
|
26
34
|
import { TranscriptionUnavailableError } from '../lib/transcribe-audio.js'
|
|
27
35
|
|
|
28
36
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
@@ -56,6 +64,7 @@ import {
|
|
|
56
64
|
import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
|
|
57
65
|
import {
|
|
58
66
|
countChunkWavs,
|
|
67
|
+
listUnsavedCaptures,
|
|
59
68
|
purgeExpiredQuarantine,
|
|
60
69
|
quarantineSessionAudio,
|
|
61
70
|
sweepOrphanedSessionAudio,
|
|
@@ -836,6 +845,57 @@ if (maintenanceAdmissionsOpen()) {
|
|
|
836
845
|
recoverSessions()
|
|
837
846
|
}
|
|
838
847
|
|
|
848
|
+
/**
|
|
849
|
+
* Quarantined audio has no live session, so it needs its own pass.
|
|
850
|
+
*
|
|
851
|
+
* This closes the hole 6.23.0 left open: `recoverSessions()` tombstones any session
|
|
852
|
+
* already past the 4-hour cutoff at boot, so a restart while a capture is stranded
|
|
853
|
+
* means the promote above never runs and the audio lands here instead — preserved
|
|
854
|
+
* for 72 hours, but not a meeting, and only recoverable by hand. One per tick.
|
|
855
|
+
*/
|
|
856
|
+
const autoRecoverState = {
|
|
857
|
+
attempts: new Map<string, number>(),
|
|
858
|
+
inFlight: new Set<string>(),
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function autoRecoverOneQuarantinedCapture(): void {
|
|
862
|
+
const token = process.env.COS_API_TOKEN ?? ''
|
|
863
|
+
if (!token) return
|
|
864
|
+
const picked = pickQuarantineToRecover(listUnsavedCaptures(), autoRecoverState)
|
|
865
|
+
if (!picked) return
|
|
866
|
+
const sessionId = picked.sessionId
|
|
867
|
+
autoRecoverState.inFlight.add(sessionId)
|
|
868
|
+
noteRecoverAttempt(autoRecoverState, sessionId)
|
|
869
|
+
void requestQuarantineRecovery(sessionId, {
|
|
870
|
+
port: parseInt(process.env.PORT ?? '3141', 10),
|
|
871
|
+
token,
|
|
872
|
+
})
|
|
873
|
+
.then(result => {
|
|
874
|
+
if (result.ok) {
|
|
875
|
+
console.warn(
|
|
876
|
+
`[quarantine] Auto-recovered ${sessionId} \u2192 ${result.filename ?? 'saved'} `
|
|
877
|
+
+ `(${picked.chunkFiles} chunks, speakers unlabeled — no live ASR ran on it)`,
|
|
878
|
+
)
|
|
879
|
+
clearRecoverAttempts(autoRecoverState, sessionId)
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
// 409 means a manual recovery already owns it, which is not a failure and
|
|
883
|
+
// should not burn the budget.
|
|
884
|
+
if (result.status === 409) {
|
|
885
|
+
clearRecoverAttempts(autoRecoverState, sessionId)
|
|
886
|
+
return
|
|
887
|
+
}
|
|
888
|
+
const attempts = autoRecoverState.attempts.get(sessionId) ?? 0
|
|
889
|
+
console.error(
|
|
890
|
+
`[quarantine] Auto-recovery failed for ${sessionId} `
|
|
891
|
+
+ `(${result.status} ${result.reason ?? ''}), attempt ${attempts}/${MAX_AUTO_RECOVER_ATTEMPTS}`
|
|
892
|
+
+ `${autoRecoverExhausted(autoRecoverState, sessionId)
|
|
893
|
+
? ' — giving up, audio stays quarantined and recoverable by hand' : ''}`,
|
|
894
|
+
)
|
|
895
|
+
})
|
|
896
|
+
.finally(() => autoRecoverState.inFlight.delete(sessionId))
|
|
897
|
+
}
|
|
898
|
+
|
|
839
899
|
/** Sessions with an auto-save in flight, so a later tick cannot start a second. */
|
|
840
900
|
const promotingStranded = new Set<string>()
|
|
841
901
|
|
|
@@ -931,6 +991,7 @@ setInterval(() => {
|
|
|
931
991
|
}
|
|
932
992
|
}
|
|
933
993
|
purgeExpiredQuarantine()
|
|
994
|
+
autoRecoverOneQuarantinedCapture()
|
|
934
995
|
} catch {}
|
|
935
996
|
// Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
|
|
936
997
|
// restart can exceed 2h (2026-07-27: two sessions purged before batch).
|