@gotcos/glasses-server 6.22.1 → 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 +91 -0
- package/package.json +2 -2
- package/server/lib/quarantine-auto-recover.ts +148 -0
- package/server/lib/session-heartbeats.ts +62 -0
- package/server/lib/stranded-session-actions.ts +302 -0
- package/server/lib/stranded-sessions.ts +125 -0
- package/server/routes/diag.ts +14 -0
- package/server/routes/health.ts +19 -0
- package/server/routes/meeting.ts +9 -0
- package/server/routes/transcribe-stream.ts +208 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
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
|
+
|
|
38
|
+
## 6.23.0
|
|
39
|
+
|
|
40
|
+
A recording whose phone goes away now becomes a meeting on its own. Miles: "we end
|
|
41
|
+
up with a meeting that is orphaned that we have no ability to keep."
|
|
42
|
+
|
|
43
|
+
Found live while writing this: two sessions stranded for 184 and 24 minutes, holding
|
|
44
|
+
the restart lock, while `GET /api/meeting/orphans` answered `count: 0`. Both saved at
|
|
45
|
+
100% transfer integrity (529/529 and 23/23 chunks). Nothing had been lost — but
|
|
46
|
+
nothing was going to turn them into meetings either.
|
|
47
|
+
|
|
48
|
+
- **The audio was never the problem.** A stranded capture stays live in memory for 4
|
|
49
|
+
hours, then closes as `expired` and its chunks move to quarantine for 72 more. A
|
|
50
|
+
76-hour window in which the audio exists and NOTHING converts it into a meeting
|
|
51
|
+
unless a human notices. Expiry produced preserved evidence, not a meeting.
|
|
52
|
+
- **The 60-second sweeper already existed and already detected these.** It called
|
|
53
|
+
`closeTranscriptSession(id, 'expired')`. The change is the disposition at the
|
|
54
|
+
cutoff, not new scheduling: it now finalizes through `POST /api/meeting/save`,
|
|
55
|
+
which keeps the live ASR transcript and its speaker labels. The quarantine recover
|
|
56
|
+
route was the wrong tool here — its output labels every speaker Unknown, because no
|
|
57
|
+
live ASR ever ran on it.
|
|
58
|
+
- **Staleness never closes a session early, and it must not.** The companion buffers
|
|
59
|
+
to IndexedDB while iOS suspends the WebView and drains on foreground, and it
|
|
60
|
+
restores `restoredSessionId` across a relaunch — so a phone silent for 30 minutes
|
|
61
|
+
can still deliver its tail into the same session id. Close it and `isSessionDeleted`
|
|
62
|
+
answers 410 Gone: a truncated meeting AND a second orphan. At the stale threshold a
|
|
63
|
+
readable draft is written and the session stays open.
|
|
64
|
+
- **`/api/meeting/orphans` and `/api/health` were blind to the state that matters.**
|
|
65
|
+
Both listed only QUARANTINED directories, and a stranded session is not quarantined
|
|
66
|
+
for four hours. New `stranded` / `stranded_captures` report idle minutes, captured
|
|
67
|
+
minutes, chunk count, when the sweeper will save it, and whether a draft exists.
|
|
68
|
+
- **Quiet is not failure.** A heartbeat carrying `audioState` is now kept per session
|
|
69
|
+
and can VETO a stale verdict — a phone that says it is recording is alive even with
|
|
70
|
+
no chunk arriving, because its uploads may merely be blocked. A BACKGROUNDED phone
|
|
71
|
+
counts as capturing; requiring `visibilityState: visible` would reap exactly the
|
|
72
|
+
sessions the drain path exists to rescue. Absence proves nothing in the other
|
|
73
|
+
direction: `clientLog` is fire-and-forget and lossy, so a missing heartbeat can
|
|
74
|
+
never itself mark a session dead. Chunk arrival decides.
|
|
75
|
+
- **One definition of stale.** `RECORDING_SESSION_STALE_MS` is now derived from
|
|
76
|
+
`STRANDED_STALE_MS` rather than being a second literal. Two subsystems disagreeing
|
|
77
|
+
about what a live recording is, is precisely how the panel could read "2
|
|
78
|
+
recording(s) active" while the orphan endpoint reported none.
|
|
79
|
+
- A failed auto-save does not throw away a savable capture: `no_token` and 5xx and
|
|
80
|
+
transport failures retry on the next sweep, 409 yields to the save that already owns
|
|
81
|
+
the session, and only a terminal 4xx (or an 8-hour backstop) falls back to the old
|
|
82
|
+
close-and-quarantine.
|
|
83
|
+
|
|
84
|
+
Coverage: 59 tests over the new modules, 23 mutations all caught. Three of those
|
|
85
|
+
initially SURVIVED — the whole stale branch deleted, the token gate deleted, and the
|
|
86
|
+
terminal draft cleanup deleted — because the loop lived inside a `setInterval` in a
|
|
87
|
+
module no test can import without executing boot recovery, a timer, and writes to the
|
|
88
|
+
real data home. It was extracted with injected dependencies rather than covered by
|
|
89
|
+
source-shape assertions. Full suite green serially (1411 tests); two unrelated files
|
|
90
|
+
flake under file parallelism, which is a pre-existing isolation bug.
|
|
91
|
+
|
|
1
92
|
## 6.22.1
|
|
2
93
|
|
|
3
94
|
Notes attached from somewhere else by a symlink are now read properly. Found by
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"description": "COS Glasses
|
|
3
|
+
"version": "6.23.1",
|
|
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": {
|
|
7
7
|
"glasses-server": "bin/cli.cjs",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Last-known companion state per recording session.
|
|
2
|
+
//
|
|
3
|
+
// The stranded-session sweeper needs POSITIVE evidence that a phone is still
|
|
4
|
+
// capturing before it declares a quiet session stale. That evidence already
|
|
5
|
+
// arrives on POST /api/diag/client as a heartbeat carrying `audioState` and
|
|
6
|
+
// `visibilityState`, but it was only ever appended to client-diagnostics.jsonl —
|
|
7
|
+
// a file the sweeper would have to parse on every 60s tick. This keeps the newest
|
|
8
|
+
// heartbeat per session in memory instead.
|
|
9
|
+
//
|
|
10
|
+
// DELIBERATELY LOSSY, and safe to be. `clientLog` is fire-and-forget with a 3s
|
|
11
|
+
// abort and a silent catch: 63% of heartbeats were measured missing during one
|
|
12
|
+
// 2026-07-27 session. So an ABSENT heartbeat proves nothing and must never by
|
|
13
|
+
// itself mark a session dead — the sweeper's staleness signal is chunk arrival,
|
|
14
|
+
// and a heartbeat can only ever VETO a stale verdict, never cause one.
|
|
15
|
+
|
|
16
|
+
import type { SessionHeartbeat } from './stranded-sessions.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bounded so a burst of zombie clients cannot grow this without limit. Well above
|
|
20
|
+
* any real fleet: a session is one phone, and the sweeper deletes entries as
|
|
21
|
+
* sessions close.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_TRACKED_SESSIONS = 64
|
|
24
|
+
|
|
25
|
+
const heartbeats = new Map<string, SessionHeartbeat>()
|
|
26
|
+
|
|
27
|
+
/** Record the newest heartbeat for a session. Older timestamps are ignored. */
|
|
28
|
+
export function recordSessionHeartbeat(
|
|
29
|
+
sessionId: string,
|
|
30
|
+
heartbeat: SessionHeartbeat,
|
|
31
|
+
): void {
|
|
32
|
+
if (!sessionId) return
|
|
33
|
+
if (!Number.isFinite(heartbeat.at)) return
|
|
34
|
+
const existing = heartbeats.get(sessionId)
|
|
35
|
+
if (existing && existing.at > heartbeat.at) return
|
|
36
|
+
heartbeats.set(sessionId, heartbeat)
|
|
37
|
+
if (heartbeats.size <= MAX_TRACKED_SESSIONS) return
|
|
38
|
+
// Evict oldest-first so the entries that matter (recent, therefore capable of
|
|
39
|
+
// vetoing a stale verdict) are the ones retained.
|
|
40
|
+
const ordered = [...heartbeats.entries()].sort((a, b) => a[1].at - b[1].at)
|
|
41
|
+
for (const [id] of ordered.slice(0, heartbeats.size - MAX_TRACKED_SESSIONS)) {
|
|
42
|
+
heartbeats.delete(id)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getSessionHeartbeat(sessionId: string): SessionHeartbeat | null {
|
|
47
|
+
return heartbeats.get(sessionId) ?? null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Called when a session reaches any terminal state. */
|
|
51
|
+
export function forgetSessionHeartbeat(sessionId: string): void {
|
|
52
|
+
heartbeats.delete(sessionId)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Test seam only. */
|
|
56
|
+
export function resetSessionHeartbeats(): void {
|
|
57
|
+
heartbeats.clear()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function trackedHeartbeatCount(): number {
|
|
61
|
+
return heartbeats.size
|
|
62
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// What the sweeper DOES about a stranded session: draft it, then promote it.
|
|
2
|
+
//
|
|
3
|
+
// Two actions, deliberately asymmetric.
|
|
4
|
+
//
|
|
5
|
+
// DRAFT (at the stale threshold) is additive and reversible. It writes the
|
|
6
|
+
// transcript the session already holds to a file and touches nothing else — no
|
|
7
|
+
// store write, no audio move, no session close. That matters because a phone that
|
|
8
|
+
// has been silent for 25 minutes can still drain its IndexedDB buffer into the
|
|
9
|
+
// same session id, and closing early would answer that drain with 410 Gone. A
|
|
10
|
+
// draft cannot truncate anything; it only makes the capture readable and visible
|
|
11
|
+
// while the session stays open for the tail.
|
|
12
|
+
//
|
|
13
|
+
// It also stays OUT of the meetings store on purpose. `POST /api/meeting/save` is
|
|
14
|
+
// idempotent by session id — a second save replays the first receipt. If a draft
|
|
15
|
+
// occupied that id, the real save arriving later with the drained tail would find
|
|
16
|
+
// `alreadySaved` and return the SHORTER checkpoint forever. The draft is a
|
|
17
|
+
// sidecar, never a meeting.
|
|
18
|
+
//
|
|
19
|
+
// PROMOTE (at the retention cutoff) goes through POST /api/meeting/save over
|
|
20
|
+
// loopback rather than calling the store directly. That route owns the maintenance
|
|
21
|
+
// lease, the save-in-progress lock, the receipt contract, domain inference and the
|
|
22
|
+
// two-phase finalization job; reimplementing any of it here would fork the most
|
|
23
|
+
// load-bearing path in the product. The session is still in memory at this point,
|
|
24
|
+
// so the save keeps the live ASR transcript and its speaker labels — which is why
|
|
25
|
+
// promote must not use the quarantine recover route, whose output labels every
|
|
26
|
+
// speaker Unknown because no live ASR ever ran on it.
|
|
27
|
+
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
29
|
+
import { resolve } from 'node:path'
|
|
30
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
31
|
+
import { forgetSessionHeartbeat } from './session-heartbeats.js'
|
|
32
|
+
import { classifyStrandedSession, type SessionHeartbeat } from './stranded-sessions.js'
|
|
33
|
+
|
|
34
|
+
/** Title given to a capture the sweeper saves on the user's behalf. */
|
|
35
|
+
export const STRANDED_PROMOTE_TITLE = 'Auto-saved capture'
|
|
36
|
+
|
|
37
|
+
const DRAFT_SUFFIX = '.draft.md'
|
|
38
|
+
/** A draft is a safety net, not an archive. Bounded so it cannot grow unwatched. */
|
|
39
|
+
const MAX_DRAFT_CHARS = 400_000
|
|
40
|
+
|
|
41
|
+
export interface StrandedDraftInput {
|
|
42
|
+
sessionId: string
|
|
43
|
+
transcript: string
|
|
44
|
+
chunkCount: number
|
|
45
|
+
startedAt: number
|
|
46
|
+
lastActivityAt: number
|
|
47
|
+
/** Injected for deterministic tests. */
|
|
48
|
+
now?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StrandedDraft {
|
|
52
|
+
sessionId: string
|
|
53
|
+
path: string
|
|
54
|
+
bytes: number
|
|
55
|
+
chunkCount: number
|
|
56
|
+
updatedAt: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function draftPath(dir: string, sessionId: string): string {
|
|
60
|
+
return resolve(dir, `${sessionId}${DRAFT_SUFFIX}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Write (or refresh) the draft for a stranded session.
|
|
65
|
+
*
|
|
66
|
+
* Returns null when there is nothing worth drafting, so an empty session cannot
|
|
67
|
+
* litter the directory with contentless files.
|
|
68
|
+
*/
|
|
69
|
+
export function writeStrandedDraft(dir: string, input: StrandedDraftInput): string | null {
|
|
70
|
+
const transcript = input.transcript.trim()
|
|
71
|
+
if (!transcript) return null
|
|
72
|
+
if (!input.sessionId) return null
|
|
73
|
+
const now = input.now ?? Date.now()
|
|
74
|
+
const idleMin = Math.round(Math.max(0, now - input.lastActivityAt) / 60_000)
|
|
75
|
+
const capturedMin = Math.round(Math.max(0, input.lastActivityAt - input.startedAt) / 60_000)
|
|
76
|
+
const body = transcript.length > MAX_DRAFT_CHARS
|
|
77
|
+
? `${transcript.slice(0, MAX_DRAFT_CHARS)}\n\n[draft truncated at ${MAX_DRAFT_CHARS} characters]`
|
|
78
|
+
: transcript
|
|
79
|
+
const doc = [
|
|
80
|
+
'---',
|
|
81
|
+
`session_id: ${input.sessionId}`,
|
|
82
|
+
`state: stranded_draft`,
|
|
83
|
+
`chunks_received: ${input.chunkCount}`,
|
|
84
|
+
`captured_minutes: ${capturedMin}`,
|
|
85
|
+
`idle_minutes: ${idleMin}`,
|
|
86
|
+
`drafted_at: ${new Date(now).toISOString()}`,
|
|
87
|
+
'---',
|
|
88
|
+
'',
|
|
89
|
+
'> Draft only. This capture stopped receiving audio and has not been saved as a',
|
|
90
|
+
'> meeting yet. The session is still open, so if the phone reconnects the rest of',
|
|
91
|
+
'> the audio will still arrive. It becomes a real meeting automatically at the',
|
|
92
|
+
`> retention cutoff, or immediately via POST /api/meeting/save.`,
|
|
93
|
+
'',
|
|
94
|
+
body,
|
|
95
|
+
'',
|
|
96
|
+
].join('\n')
|
|
97
|
+
try {
|
|
98
|
+
mkdirSync(dir, { recursive: true })
|
|
99
|
+
atomicWriteFileSync(draftPath(dir, input.sessionId), doc, { mode: 0o600 })
|
|
100
|
+
return draftPath(dir, input.sessionId)
|
|
101
|
+
} catch {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every draft currently on disk, newest first. */
|
|
107
|
+
export function listStrandedDrafts(dir: string): StrandedDraft[] {
|
|
108
|
+
if (!existsSync(dir)) return []
|
|
109
|
+
const out: StrandedDraft[] = []
|
|
110
|
+
let entries: string[]
|
|
111
|
+
try { entries = readdirSync(dir) } catch { return [] }
|
|
112
|
+
for (const name of entries) {
|
|
113
|
+
if (!name.endsWith(DRAFT_SUFFIX)) continue
|
|
114
|
+
const full = resolve(dir, name)
|
|
115
|
+
try {
|
|
116
|
+
const st = statSync(full)
|
|
117
|
+
if (!st.isFile()) continue
|
|
118
|
+
const head = readFileSync(full, 'utf8').slice(0, 800)
|
|
119
|
+
const chunks = /^chunks_received: (\d+)$/m.exec(head)
|
|
120
|
+
out.push({
|
|
121
|
+
sessionId: name.slice(0, -DRAFT_SUFFIX.length),
|
|
122
|
+
path: full,
|
|
123
|
+
bytes: st.size,
|
|
124
|
+
chunkCount: chunks ? Number(chunks[1]) : 0,
|
|
125
|
+
updatedAt: new Date(st.mtimeMs).toISOString(),
|
|
126
|
+
})
|
|
127
|
+
} catch { /* a draft we cannot stat is not worth failing the sweep over */ }
|
|
128
|
+
}
|
|
129
|
+
return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Called once a session reaches a real terminal state. Never throws. */
|
|
133
|
+
export function clearStrandedDraft(dir: string, sessionId: string): void {
|
|
134
|
+
try { rmSync(draftPath(dir, sessionId), { force: true }) } catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Drop every trace of stranded-state for a session that reached ANY terminal state.
|
|
139
|
+
*
|
|
140
|
+
* One function because the two halves must never diverge. A retained draft would
|
|
141
|
+
* keep /api/meeting/orphans advertising an unsaved capture that is now saved — the
|
|
142
|
+
* false alarm that trains a user to ignore the one channel built to report real
|
|
143
|
+
* losses. A retained heartbeat would let a dead session's last breath veto a stale
|
|
144
|
+
* verdict for a session id that no longer exists.
|
|
145
|
+
*/
|
|
146
|
+
export function releaseStrandedState(dir: string, sessionId: string): void {
|
|
147
|
+
clearStrandedDraft(dir, sessionId)
|
|
148
|
+
forgetSessionHeartbeat(sessionId)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface SweepableSession {
|
|
152
|
+
lastActivityAt: number
|
|
153
|
+
startTime: number
|
|
154
|
+
chunkCount: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface StrandedSweepInput {
|
|
158
|
+
now: number
|
|
159
|
+
/** Empty means this process does not own the API — see the guard below. */
|
|
160
|
+
token: string
|
|
161
|
+
draftDir: string
|
|
162
|
+
sessions: Iterable<[string, SweepableSession]>
|
|
163
|
+
getHeartbeat: (sessionId: string) => SessionHeartbeat | null
|
|
164
|
+
getTranscript: (sessionId: string) => string | null
|
|
165
|
+
/** Fire-and-forget: a promote can run for minutes and must not block the tick. */
|
|
166
|
+
onPromote: (sessionId: string, lastActivityAt: number) => void
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface StrandedSweepResult {
|
|
170
|
+
drafted: string[]
|
|
171
|
+
promoted: string[]
|
|
172
|
+
/** Sessions the sweep deliberately left alone. */
|
|
173
|
+
live: string[]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One pass over the live sessions: draft what has gone quiet, promote what is done.
|
|
178
|
+
*
|
|
179
|
+
* EXTRACTED FROM THE INTERVAL ON PURPOSE. While this loop lived inside the
|
|
180
|
+
* `setInterval` in routes/transcribe-stream.ts no test could reach it, because
|
|
181
|
+
* importing that module executes boot recovery, a 60s timer, and reads and writes
|
|
182
|
+
* against the real data home. Two mutations proved the cost: deleting the entire
|
|
183
|
+
* stale branch, and deleting the token gate, both SURVIVED the whole suite. A loop
|
|
184
|
+
* that decides whether a recording ever becomes a meeting cannot be covered by
|
|
185
|
+
* source-shape assertions alone.
|
|
186
|
+
*/
|
|
187
|
+
export function sweepStrandedSessions(input: StrandedSweepInput): StrandedSweepResult {
|
|
188
|
+
const result: StrandedSweepResult = { drafted: [], promoted: [], live: [] }
|
|
189
|
+
// Only the process that owns the API can save anything, and only it should be
|
|
190
|
+
// writing into the data home. No token means a test worker or a tool that
|
|
191
|
+
// imported the module, so the pass does nothing rather than half-acting.
|
|
192
|
+
if (!input.token) return result
|
|
193
|
+
for (const [sessionId, session] of input.sessions) {
|
|
194
|
+
const verdict = classifyStrandedSession(
|
|
195
|
+
{ lastActivityAt: session.lastActivityAt, heartbeat: input.getHeartbeat(sessionId) },
|
|
196
|
+
input.now,
|
|
197
|
+
)
|
|
198
|
+
if (verdict === 'live') { result.live.push(sessionId); continue }
|
|
199
|
+
if (verdict === 'stale') {
|
|
200
|
+
// Draft only. The session stays OPEN so a phone that reconnects can still
|
|
201
|
+
// drain its buffered tail into this same id.
|
|
202
|
+
const written = writeStrandedDraft(input.draftDir, {
|
|
203
|
+
sessionId,
|
|
204
|
+
transcript: input.getTranscript(sessionId) ?? '',
|
|
205
|
+
chunkCount: session.chunkCount,
|
|
206
|
+
startedAt: session.startTime,
|
|
207
|
+
lastActivityAt: session.lastActivityAt,
|
|
208
|
+
now: input.now,
|
|
209
|
+
})
|
|
210
|
+
if (written) result.drafted.push(sessionId)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
input.onPromote(sessionId, session.lastActivityAt)
|
|
214
|
+
result.promoted.push(sessionId)
|
|
215
|
+
}
|
|
216
|
+
return result
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface PromoteResult {
|
|
220
|
+
ok: boolean
|
|
221
|
+
status: number
|
|
222
|
+
filename?: string
|
|
223
|
+
reason?: string
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* After a failed auto-save, does the session close (quarantining its audio) or wait
|
|
228
|
+
* for the next sweep?
|
|
229
|
+
*
|
|
230
|
+
* Extracted from the sweeper because it is the decision with the real consequence:
|
|
231
|
+
* closing means the capture becomes quarantined audio and NOT a meeting, which is
|
|
232
|
+
* the exact outcome this release exists to prevent. Inside the route it was
|
|
233
|
+
* untestable without importing and therefore executing the whole module.
|
|
234
|
+
*
|
|
235
|
+
* @param idleForMs how long the session has been without a chunk
|
|
236
|
+
* @param giveUpAfterMs backstop so a permanently-failing save cannot keep a
|
|
237
|
+
* session in memory forever
|
|
238
|
+
*/
|
|
239
|
+
export function shouldCloseAfterFailedPromote(
|
|
240
|
+
result: PromoteResult,
|
|
241
|
+
idleForMs: number,
|
|
242
|
+
giveUpAfterMs: number,
|
|
243
|
+
): boolean {
|
|
244
|
+
if (result.ok) return false
|
|
245
|
+
// Never reached the server, so nothing was learned about whether it could be
|
|
246
|
+
// saved. Closing here would destroy a savable capture.
|
|
247
|
+
if (result.reason === 'no_token') return false
|
|
248
|
+
// Another save already owns this session. That one wins.
|
|
249
|
+
if (result.status === 409) return idleForMs >= giveUpAfterMs
|
|
250
|
+
// A transport failure, a timeout, or a 5xx is transient: retry next sweep.
|
|
251
|
+
const terminal = result.status >= 400 && result.status < 500
|
|
252
|
+
return terminal || idleForMs >= giveUpAfterMs
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface PromoteOptions {
|
|
256
|
+
port: number
|
|
257
|
+
token: string
|
|
258
|
+
/** Injected in tests; defaults to global fetch. */
|
|
259
|
+
fetchImpl?: typeof fetch
|
|
260
|
+
timeoutMs?: number
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Finalize a stranded session into a meeting through the real save route.
|
|
265
|
+
*
|
|
266
|
+
* Omits `domain` deliberately so the route's own keyword inference files it,
|
|
267
|
+
* rather than dumping every unattended capture into one folder.
|
|
268
|
+
*/
|
|
269
|
+
export async function promoteStrandedSession(
|
|
270
|
+
sessionId: string,
|
|
271
|
+
options: PromoteOptions,
|
|
272
|
+
): Promise<PromoteResult> {
|
|
273
|
+
// No token means this process is not the server that owns the API — a unit test
|
|
274
|
+
// worker, a tool importing the module. Refusing here matters because an
|
|
275
|
+
// unauthenticated POST returns 401, the caller would read a 4xx as terminal, and
|
|
276
|
+
// it would then close a session it could perfectly well have saved. `no_token`
|
|
277
|
+
// is deliberately NOT terminal.
|
|
278
|
+
if (!options.token) return { ok: false, status: 0, reason: 'no_token' }
|
|
279
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
280
|
+
const controller = new AbortController()
|
|
281
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 600_000)
|
|
282
|
+
try {
|
|
283
|
+
const res = await doFetch(`http://127.0.0.1:${options.port}/api/meeting/save`, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: { 'Content-Type': 'application/json', 'X-Cos-Token': options.token },
|
|
286
|
+
body: JSON.stringify({ sessionId, title: STRANDED_PROMOTE_TITLE }),
|
|
287
|
+
signal: controller.signal,
|
|
288
|
+
})
|
|
289
|
+
let payload: Record<string, unknown> = {}
|
|
290
|
+
try { payload = await res.json() as Record<string, unknown> } catch {}
|
|
291
|
+
return {
|
|
292
|
+
ok: res.ok,
|
|
293
|
+
status: res.status,
|
|
294
|
+
filename: typeof payload.filename === 'string' ? payload.filename : undefined,
|
|
295
|
+
reason: typeof payload.reason === 'string' ? payload.reason : undefined,
|
|
296
|
+
}
|
|
297
|
+
} catch (error: any) {
|
|
298
|
+
return { ok: false, status: 0, reason: error?.name === 'AbortError' ? 'timeout' : 'request_failed' }
|
|
299
|
+
} finally {
|
|
300
|
+
clearTimeout(timer)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// A recording whose phone went away must still become a meeting.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT THIS FIXES. When the companion quits, the bridge drops, or the G2
|
|
4
|
+
// disconnects mid-meeting, the server keeps the session in memory and keeps its
|
|
5
|
+
// ACK'd chunks on disk. Nothing then converts them into a meeting. At the 4-hour
|
|
6
|
+
// retention cutoff the session was closed as 'expired' and its audio moved to
|
|
7
|
+
// quarantine — preserved for 72 more hours, but still not a meeting, and visible
|
|
8
|
+
// only to whoever thought to look. Two sessions sat stranded for 184 and 24
|
|
9
|
+
// minutes on 2026-08-09 holding the restart lock while /api/meeting/orphans
|
|
10
|
+
// reported nothing at all, because that endpoint lists only QUARANTINED dirs and
|
|
11
|
+
// a stranded session is not quarantined for four hours.
|
|
12
|
+
//
|
|
13
|
+
// WHY THE OBVIOUS FIX IS WRONG. "Close idle sessions sooner" loses data. The
|
|
14
|
+
// companion buffers capture to IndexedDB while iOS suspends the WebView and
|
|
15
|
+
// drains on foreground (local-first-meeting-uploader.ts keeps a retry queue), and
|
|
16
|
+
// it restores `restoredSessionId` across a relaunch — so a phone that has been
|
|
17
|
+
// silent for 25 minutes can still deliver its tail into the SAME session id. Once
|
|
18
|
+
// the session is closed, `isSessionDeleted` answers 410 Gone and that tail is
|
|
19
|
+
// gone, leaving a truncated meeting AND a second orphan. So staleness must never
|
|
20
|
+
// close a session early. It may only make the state visible and durable.
|
|
21
|
+
//
|
|
22
|
+
// QUIET IS NOT FAILURE. `lastActivityAt` is bumped in exactly one place —
|
|
23
|
+
// `processStreamChunk` — so it is a pure chunk-ARRIVAL signal and a zombie
|
|
24
|
+
// client's heartbeats cannot mask a dead session. The inverse also has to hold:
|
|
25
|
+
// a phone that reports it is recording right now is alive even with no chunk
|
|
26
|
+
// arriving, because its uploads may merely be blocked. That is why a fresh
|
|
27
|
+
// heartbeat suppresses the stale verdict, and why a BACKGROUNDED phone is not
|
|
28
|
+
// treated as dead — buffering while suspended is designed behavior, not a fault.
|
|
29
|
+
|
|
30
|
+
import { LOCAL_FIRST_MEETING_IDLE_RETENTION_MS } from './local-first-meetings-contract.js'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* No ACK'd chunk for this long and the session is worth telling the user about.
|
|
34
|
+
*
|
|
35
|
+
* MUST equal `RECORDING_SESSION_STALE_MS` in routes/transcribe-stream.ts, which
|
|
36
|
+
* already defines staleness for the maintenance drain gate and for aborting batch
|
|
37
|
+
* work while a recording is live. A second, different threshold is exactly the bug
|
|
38
|
+
* this release fixes: the session counter said "2 recordings active" while
|
|
39
|
+
* /api/meeting/orphans said nothing, because two subsystems disagreed about what
|
|
40
|
+
* counts as a real recording. One definition, or the disagreement comes back.
|
|
41
|
+
* `stranded-sessions.test.ts` imports both and fails if they drift.
|
|
42
|
+
*
|
|
43
|
+
* It lives here rather than being imported from the route because lib must not
|
|
44
|
+
* depend on routes; the test is what keeps them married.
|
|
45
|
+
*
|
|
46
|
+
* Chunk cadence is roughly 10s, so 30 minutes is ~180 missed chunks: decisively
|
|
47
|
+
* not a hiccup. It is deliberately NOT a close — see the header. Nothing is
|
|
48
|
+
* destroyed at this threshold; a draft is written and the session stays open so a
|
|
49
|
+
* late drain still lands.
|
|
50
|
+
*/
|
|
51
|
+
export const STRANDED_STALE_MS = 30 * 60_000
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The session is finished and becomes a meeting.
|
|
55
|
+
*
|
|
56
|
+
* Identical to the cutoff that already closed sessions as 'expired', so this
|
|
57
|
+
* changes the DISPOSITION (save instead of quarantine) and not the timing. Do not
|
|
58
|
+
* shorten it without re-reading the drain discussion above.
|
|
59
|
+
*/
|
|
60
|
+
export const STRANDED_PROMOTE_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
61
|
+
|
|
62
|
+
/** A heartbeat older than this proves nothing about the phone's state now. */
|
|
63
|
+
export const LIVENESS_GRACE_MS = 3 * 60_000
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pipeline states that mean audio is being captured this moment.
|
|
67
|
+
*
|
|
68
|
+
* Sourced from the companion's audio pipeline, whose recording state is
|
|
69
|
+
* `recording_continuous` for a meeting; `recording` covers the dictation path.
|
|
70
|
+
*/
|
|
71
|
+
const CAPTURING_AUDIO_STATES = new Set(['recording_continuous', 'recording'])
|
|
72
|
+
|
|
73
|
+
export type StrandedVerdict = 'live' | 'stale' | 'promote'
|
|
74
|
+
|
|
75
|
+
export interface SessionHeartbeat {
|
|
76
|
+
/** ms since epoch when the heartbeat arrived. */
|
|
77
|
+
at: number
|
|
78
|
+
audioState?: string | null
|
|
79
|
+
visibilityState?: string | null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SessionActivity {
|
|
83
|
+
/** ms since epoch of the last ACK'd chunk. NOT bumped by heartbeats. */
|
|
84
|
+
lastActivityAt: number
|
|
85
|
+
/** Most recent heartbeat for this session, when one has ever arrived. */
|
|
86
|
+
heartbeat?: SessionHeartbeat | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** How long this session has been without an ACK'd chunk. Never negative. */
|
|
90
|
+
export function strandedForMs(activity: SessionActivity, now: number): number {
|
|
91
|
+
return Math.max(0, now - activity.lastActivityAt)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Is the phone capturing audio right now?
|
|
96
|
+
*
|
|
97
|
+
* Deliberately does NOT require `visibilityState === 'visible'`. A backgrounded
|
|
98
|
+
* companion buffering to IndexedDB is the single most common legitimate reason
|
|
99
|
+
* for quiet, and demanding visibility here would reap exactly the sessions the
|
|
100
|
+
* drain path exists to rescue.
|
|
101
|
+
*/
|
|
102
|
+
export function capturingNow(heartbeat: SessionHeartbeat | null | undefined, now: number): boolean {
|
|
103
|
+
if (!heartbeat) return false
|
|
104
|
+
if (!Number.isFinite(heartbeat.at)) return false
|
|
105
|
+
if (now - heartbeat.at > LIVENESS_GRACE_MS) return false
|
|
106
|
+
// A heartbeat from the future is a clock artifact, not evidence of capture.
|
|
107
|
+
if (heartbeat.at - now > LIVENESS_GRACE_MS) return false
|
|
108
|
+
return CAPTURING_AUDIO_STATES.has((heartbeat.audioState ?? '').trim())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What should the sweeper do with this session?
|
|
113
|
+
*
|
|
114
|
+
* `promote` is unconditional at the retention cutoff, matching the close it
|
|
115
|
+
* replaces — a session that has been silent for four hours becomes a meeting even
|
|
116
|
+
* if something is still heartbeating, because that is already the behavior today
|
|
117
|
+
* and weakening it would strand audio for longer than the old code did.
|
|
118
|
+
*/
|
|
119
|
+
export function classifyStrandedSession(activity: SessionActivity, now: number): StrandedVerdict {
|
|
120
|
+
const idleFor = strandedForMs(activity, now)
|
|
121
|
+
if (idleFor >= STRANDED_PROMOTE_MS) return 'promote'
|
|
122
|
+
if (capturingNow(activity.heartbeat, now)) return 'live'
|
|
123
|
+
if (idleFor >= STRANDED_STALE_MS) return 'stale'
|
|
124
|
+
return 'live'
|
|
125
|
+
}
|
package/server/routes/diag.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
18
18
|
|
|
19
19
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
20
20
|
import { dataPath } from '../lib/data-dir.js'
|
|
21
|
+
import { recordSessionHeartbeat } from '../lib/session-heartbeats.js'
|
|
21
22
|
const DIAG_DIR = dataPath()
|
|
22
23
|
const DIAG_FILE = resolve(DIAG_DIR, 'client-diagnostics.jsonl')
|
|
23
24
|
const MAX_FILE_BYTES = 10 * 1024 * 1024 // 10 MB cap
|
|
@@ -75,6 +76,19 @@ diagRouter.post('/diag/client', async (req, res) => {
|
|
|
75
76
|
return res.status(410).json({ error: 'session_deleted', sessionId })
|
|
76
77
|
}
|
|
77
78
|
} catch { /* import failure: fall through to normal logging */ }
|
|
79
|
+
// Positive liveness for the stranded-session sweeper. A phone can be capturing
|
|
80
|
+
// while its uploads are blocked, so chunk silence alone must not mark a
|
|
81
|
+
// session stale — this is the only signal that can say "still recording".
|
|
82
|
+
// Lossy by nature (fire-and-forget, 3s abort, silent catch), which is why it
|
|
83
|
+
// may only VETO a stale verdict and never cause one.
|
|
84
|
+
const heartbeatData = data as Record<string, unknown>
|
|
85
|
+
recordSessionHeartbeat(sessionId, {
|
|
86
|
+
at: ts,
|
|
87
|
+
audioState: typeof heartbeatData.audioState === 'string' ? heartbeatData.audioState : null,
|
|
88
|
+
visibilityState: typeof heartbeatData.visibilityState === 'string'
|
|
89
|
+
? heartbeatData.visibilityState
|
|
90
|
+
: null,
|
|
91
|
+
})
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
// Rate limit per sessionId (or remote address as fallback)
|
package/server/routes/health.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
|
|
|
41
41
|
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
42
42
|
import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
|
|
43
43
|
import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
|
|
44
|
+
import { getStrandedCaptures } from './transcribe-stream.js'
|
|
44
45
|
import { getWhisperPreviewCapability } from '../lib/whisper-preview.js'
|
|
45
46
|
import { getTranscriptionProfileStatus } from '../lib/profile.js'
|
|
46
47
|
import { getHealthStaticProbes } from '../lib/health-static-probes.js'
|
|
@@ -235,6 +236,23 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
235
236
|
recovered: item.recovered,
|
|
236
237
|
})),
|
|
237
238
|
}
|
|
239
|
+
// Captures that stopped receiving audio but are NOT yet quarantined, so they do
|
|
240
|
+
// not appear in unsaved_captures for up to four hours. This is the state COS
|
|
241
|
+
// Control could not see on 2026-08-09: its panel read "2 recording(s) active.
|
|
242
|
+
// Restart is locked." while both had been silent for 184 and 24 minutes. Compact
|
|
243
|
+
// here; full detail on the authenticated orphans route.
|
|
244
|
+
const strandedList = getStrandedCaptures()
|
|
245
|
+
const stranded_captures = {
|
|
246
|
+
count: strandedList.length,
|
|
247
|
+
items: strandedList.slice(0, 10).map(item => ({
|
|
248
|
+
sessionId: item.sessionId,
|
|
249
|
+
idleMinutes: item.idleMinutes,
|
|
250
|
+
capturedMinutes: item.capturedMinutes,
|
|
251
|
+
chunks: item.chunks,
|
|
252
|
+
promotesAt: item.promotesAt,
|
|
253
|
+
hasDraft: item.draftPath != null,
|
|
254
|
+
})),
|
|
255
|
+
}
|
|
238
256
|
const meetingLibrary = resolveMeetingLibrary()
|
|
239
257
|
res.json({
|
|
240
258
|
...checks,
|
|
@@ -257,6 +275,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
257
275
|
warningCount: meetingLibrary.warnings.length,
|
|
258
276
|
},
|
|
259
277
|
unsaved_captures,
|
|
278
|
+
stranded_captures,
|
|
260
279
|
chunk_embeddings: chunkEmbeddings,
|
|
261
280
|
speaker_corrections: speakerCorrections,
|
|
262
281
|
review_audio: reviewAudio,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -101,6 +101,7 @@ import {
|
|
|
101
101
|
getSessionStartTime,
|
|
102
102
|
getSessionTranscript,
|
|
103
103
|
getMeetingSessionStatus,
|
|
104
|
+
getStrandedCaptures,
|
|
104
105
|
getTranscriptionSessionLiveness,
|
|
105
106
|
hasSessionAudio,
|
|
106
107
|
moveSessionAudioToPending,
|
|
@@ -1664,8 +1665,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1664
1665
|
? { segmentsDone: progress.segmentsDone, segmentsTotal: progress.segmentsTotal }
|
|
1665
1666
|
: null
|
|
1666
1667
|
}
|
|
1668
|
+
// `stranded` is the state this endpoint used to be blind to: a capture whose
|
|
1669
|
+
// phone went away, still live in memory, audio intact, NOT yet quarantined and
|
|
1670
|
+
// therefore absent from `items` for a full four hours. That blindness is why
|
|
1671
|
+
// two stranded sessions could hold the restart lock on 2026-08-09 while this
|
|
1672
|
+
// route answered count: 0.
|
|
1673
|
+
const stranded = getStrandedCaptures()
|
|
1667
1674
|
res.json({
|
|
1668
1675
|
count: items.filter(item => !item.recovered).length,
|
|
1676
|
+
strandedCount: stranded.length,
|
|
1677
|
+
stranded,
|
|
1669
1678
|
recovering: [...recoveringOrphans],
|
|
1670
1679
|
recoveringProgress,
|
|
1671
1680
|
items,
|
|
@@ -12,6 +12,25 @@ import { fileURLToPath } from 'node:url'
|
|
|
12
12
|
import { getVocabulary, getOwnerName } from '../lib/profile.js'
|
|
13
13
|
import { getOpenAIKey } from '../lib/openai-key.js'
|
|
14
14
|
import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
|
|
15
|
+
import { STRANDED_STALE_MS } from '../lib/stranded-sessions.js'
|
|
16
|
+
import {
|
|
17
|
+
clearStrandedDraft,
|
|
18
|
+
listStrandedDrafts,
|
|
19
|
+
releaseStrandedState,
|
|
20
|
+
shouldCloseAfterFailedPromote,
|
|
21
|
+
sweepStrandedSessions,
|
|
22
|
+
promoteStrandedSession,
|
|
23
|
+
writeStrandedDraft,
|
|
24
|
+
} from '../lib/stranded-session-actions.js'
|
|
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'
|
|
15
34
|
import { TranscriptionUnavailableError } from '../lib/transcribe-audio.js'
|
|
16
35
|
|
|
17
36
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
@@ -45,6 +64,7 @@ import {
|
|
|
45
64
|
import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
|
|
46
65
|
import {
|
|
47
66
|
countChunkWavs,
|
|
67
|
+
listUnsavedCaptures,
|
|
48
68
|
purgeExpiredQuarantine,
|
|
49
69
|
quarantineSessionAudio,
|
|
50
70
|
sweepOrphanedSessionAudio,
|
|
@@ -168,6 +188,9 @@ const _unused_hallucination_constants_placeholder = 0 as const
|
|
|
168
188
|
const SESSION_AUDIO_DIR = dataPath('session-audio')
|
|
169
189
|
ensurePrivateDirectory(SESSION_AUDIO_DIR)
|
|
170
190
|
const PENDING_BATCH_DIR = dataPath('pending-batch')
|
|
191
|
+
// Readable text for a capture that stopped receiving audio. A sidecar, never a
|
|
192
|
+
// meeting — see lib/stranded-session-actions.ts for why it stays out of the store.
|
|
193
|
+
const STRANDED_DRAFT_DIR = dataPath('stranded-drafts')
|
|
171
194
|
ensurePrivateDirectory(PENDING_BATCH_DIR)
|
|
172
195
|
const MAX_SESSION_AUDIO_BYTES = 500 * 1024 * 1024 // 500MB cap per session (~2hr meeting ≈ 260MB)
|
|
173
196
|
const PRESERVED_SESSION_AUDIO_MARKER = '_meeting_save_preserved.marker'
|
|
@@ -354,8 +377,15 @@ export function getActiveTranscriptionSessionCount(): number {
|
|
|
354
377
|
* run to minutes. It sits deliberately far below
|
|
355
378
|
* LOCAL_FIRST_MEETING_IDLE_RETENTION_MS (4h), which governs how long chunks stay
|
|
356
379
|
* recoverable and is far too long to hold a restart on.
|
|
380
|
+
*
|
|
381
|
+
* DERIVED, never a second literal. `STRANDED_STALE_MS` is the one definition of
|
|
382
|
+
* "this recording has gone quiet", and the stranded sweeper, the drain gate and
|
|
383
|
+
* the batch-abort check must all mean the same thing by it. Two independent
|
|
384
|
+
* numbers here is exactly the defect this release fixes: on 2026-08-09 the
|
|
385
|
+
* session counter reported two active recordings while /api/meeting/orphans
|
|
386
|
+
* reported none, because two subsystems disagreed on what a live recording is.
|
|
357
387
|
*/
|
|
358
|
-
export const RECORDING_SESSION_STALE_MS =
|
|
388
|
+
export const RECORDING_SESSION_STALE_MS = STRANDED_STALE_MS
|
|
359
389
|
|
|
360
390
|
export interface TranscriptionSessionLiveness {
|
|
361
391
|
/** Sessions still plausibly recording. These block a restart. */
|
|
@@ -397,6 +427,53 @@ export interface TranscriptionSessionLiveness {
|
|
|
397
427
|
*/
|
|
398
428
|
export const __sessionsForTests = sessions
|
|
399
429
|
|
|
430
|
+
export interface StrandedCapture {
|
|
431
|
+
sessionId: string
|
|
432
|
+
/** Minutes since the last ACK'd chunk. */
|
|
433
|
+
idleMinutes: number
|
|
434
|
+
/** Minutes of audio actually captured before it went quiet. */
|
|
435
|
+
capturedMinutes: number
|
|
436
|
+
chunks: number
|
|
437
|
+
/** When the sweeper will save this on the user's behalf. */
|
|
438
|
+
promotesAt: string
|
|
439
|
+
/** Readable draft on disk, once the capture has been stale long enough. */
|
|
440
|
+
draftPath: string | null
|
|
441
|
+
draftBytes: number | null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Captures that stopped receiving audio but have NOT been saved yet.
|
|
446
|
+
*
|
|
447
|
+
* This is the row that was missing. `/api/meeting/orphans` listed only
|
|
448
|
+
* QUARANTINED directories, and a stranded session is not quarantined until the
|
|
449
|
+
* 4-hour cutoff — so on 2026-08-09 it answered `count: 0` while two sessions sat
|
|
450
|
+
* stranded for 184 and 24 minutes holding the restart lock. Nothing is wrong with
|
|
451
|
+
* the quarantine list; it was simply blind to the state that matters most, the one
|
|
452
|
+
* where the audio is still live and still rescuable.
|
|
453
|
+
*
|
|
454
|
+
* Reuses `getTranscriptionSessionLiveness().staleSessions` rather than re-deriving
|
|
455
|
+
* staleness, so this view and the maintenance drain gate can never disagree.
|
|
456
|
+
*/
|
|
457
|
+
export function getStrandedCaptures(now = Date.now()): StrandedCapture[] {
|
|
458
|
+
const drafts = new Map(listStrandedDrafts(STRANDED_DRAFT_DIR).map(d => [d.sessionId, d]))
|
|
459
|
+
return getTranscriptionSessionLiveness(now).staleSessions.map(stale => {
|
|
460
|
+
const session = sessions.get(stale.sessionId)
|
|
461
|
+
const lastActivityAt = session?.lastActivityAt ?? now - stale.silentForMs
|
|
462
|
+
const draft = drafts.get(stale.sessionId) ?? null
|
|
463
|
+
return {
|
|
464
|
+
sessionId: stale.sessionId,
|
|
465
|
+
idleMinutes: Math.round(stale.silentForMs / 60_000),
|
|
466
|
+
capturedMinutes: Math.round(
|
|
467
|
+
Math.max(0, lastActivityAt - (session?.startTime ?? lastActivityAt)) / 60_000,
|
|
468
|
+
),
|
|
469
|
+
chunks: stale.chunks,
|
|
470
|
+
promotesAt: new Date(lastActivityAt + LOCAL_FIRST_MEETING_IDLE_RETENTION_MS).toISOString(),
|
|
471
|
+
draftPath: draft?.path ?? null,
|
|
472
|
+
draftBytes: draft?.bytes ?? null,
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
400
477
|
export function getTranscriptionSessionLiveness(now = Date.now()): TranscriptionSessionLiveness {
|
|
401
478
|
let live = 0
|
|
402
479
|
const staleSessions: TranscriptionSessionLiveness['staleSessions'] = []
|
|
@@ -768,15 +845,134 @@ if (maintenanceAdmissionsOpen()) {
|
|
|
768
845
|
recoverSessions()
|
|
769
846
|
}
|
|
770
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
|
+
|
|
899
|
+
/** Sessions with an auto-save in flight, so a later tick cannot start a second. */
|
|
900
|
+
const promotingStranded = new Set<string>()
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Hard backstop for a capture whose save keeps failing.
|
|
904
|
+
*
|
|
905
|
+
* Without it a session that the save route refuses transiently, forever, would
|
|
906
|
+
* live in memory forever. At twice the cutoff it takes the historical disposition
|
|
907
|
+
* — close and quarantine — so the audio is at least preserved on the unsaved-audio
|
|
908
|
+
* clock rather than held hostage by a retry loop.
|
|
909
|
+
*/
|
|
910
|
+
const STRANDED_PROMOTE_GIVE_UP_MS = 2 * LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Turn an unattended capture into a meeting, and fall back to the old behavior if
|
|
914
|
+
* that proves impossible.
|
|
915
|
+
*
|
|
916
|
+
* Fire-and-forget on purpose: the sweep tick must not await an HQ finalization
|
|
917
|
+
* that can run for minutes. `promotingStranded` is what keeps the next tick from
|
|
918
|
+
* starting a duplicate.
|
|
919
|
+
*/
|
|
920
|
+
function promoteStrandedCapture(sessionId: string, lastActivityAt: number): void {
|
|
921
|
+
if (promotingStranded.has(sessionId)) return
|
|
922
|
+
promotingStranded.add(sessionId)
|
|
923
|
+
void promoteStrandedSession(sessionId, {
|
|
924
|
+
port: parseInt(process.env.PORT ?? '3141', 10),
|
|
925
|
+
token: process.env.COS_API_TOKEN ?? '',
|
|
926
|
+
})
|
|
927
|
+
.then(result => {
|
|
928
|
+
if (result.ok) {
|
|
929
|
+
console.warn(
|
|
930
|
+
`[stranded] Auto-saved unattended capture ${sessionId} \u2192 ${result.filename ?? 'saved'}`,
|
|
931
|
+
)
|
|
932
|
+
clearStrandedDraft(STRANDED_DRAFT_DIR, sessionId)
|
|
933
|
+
return
|
|
934
|
+
}
|
|
935
|
+
// Delegated, not restated: closing means the capture becomes quarantined
|
|
936
|
+
// audio instead of a meeting, which is the outcome this release exists to
|
|
937
|
+
// prevent, so the rule lives in a module a test can reach.
|
|
938
|
+
const idleForMs = Date.now() - lastActivityAt
|
|
939
|
+
if (!shouldCloseAfterFailedPromote(result, idleForMs, STRANDED_PROMOTE_GIVE_UP_MS)) {
|
|
940
|
+
console.warn(
|
|
941
|
+
`[stranded] Auto-save deferred for ${sessionId} `
|
|
942
|
+
+ `(${result.status} ${result.reason ?? ''}); retrying next sweep`,
|
|
943
|
+
)
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
console.error(
|
|
947
|
+
`[stranded] Auto-save failed for ${sessionId} (${result.status} ${result.reason ?? ''}); `
|
|
948
|
+
+ 'closing as expired so the audio reaches quarantine',
|
|
949
|
+
)
|
|
950
|
+
if (sessions.has(sessionId)) closeTranscriptSession(sessionId, 'expired')
|
|
951
|
+
})
|
|
952
|
+
.finally(() => promotingStranded.delete(sessionId))
|
|
953
|
+
}
|
|
954
|
+
|
|
771
955
|
// Auto-cleanup sessions idle for the advertised retention horizon.
|
|
772
956
|
setInterval(() => {
|
|
773
957
|
if (!maintenanceAdmissionsOpen()) return
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
958
|
+
// A capture whose phone went away must still become a meeting. This loop used
|
|
959
|
+
// to close every idle session as 'expired', which quarantined its audio and
|
|
960
|
+
// produced NO meeting — recoverable for 72h, but only by whoever thought to
|
|
961
|
+
// look. See lib/stranded-sessions.ts for why staleness may not close a session
|
|
962
|
+
// early and why the cutoff is unchanged.
|
|
963
|
+
sweepStrandedSessions({
|
|
964
|
+
now: Date.now(),
|
|
965
|
+
token: process.env.COS_API_TOKEN ?? '',
|
|
966
|
+
draftDir: STRANDED_DRAFT_DIR,
|
|
967
|
+
sessions: [...sessions].map(([id, session]) => [id, {
|
|
968
|
+
lastActivityAt: session.lastActivityAt,
|
|
969
|
+
startTime: session.startTime,
|
|
970
|
+
chunkCount: session.chunks.filter(Boolean).length,
|
|
971
|
+
}] as const),
|
|
972
|
+
getHeartbeat: getSessionHeartbeat,
|
|
973
|
+
getTranscript: id => getSessionTranscript(id),
|
|
974
|
+
onPromote: promoteStrandedCapture,
|
|
975
|
+
})
|
|
780
976
|
// Orphaned session-audio dirs (no matching active session): quarantine any
|
|
781
977
|
// dir still holding chunk audio; delete only chunk-less dirs. Quarantine
|
|
782
978
|
// itself expires on the unsaved-audio retention clock, the ONLY place
|
|
@@ -795,6 +991,7 @@ setInterval(() => {
|
|
|
795
991
|
}
|
|
796
992
|
}
|
|
797
993
|
purgeExpiredQuarantine()
|
|
994
|
+
autoRecoverOneQuarantinedCapture()
|
|
798
995
|
} catch {}
|
|
799
996
|
// Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
|
|
800
997
|
// restart can exceed 2h (2026-07-27: two sessions purged before batch).
|
|
@@ -1307,6 +1504,10 @@ function finishClosingTranscriptSession(
|
|
|
1307
1504
|
clearSessionHallucinationState(sessionId)
|
|
1308
1505
|
// Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
|
|
1309
1506
|
rememberDeletedSession(sessionId)
|
|
1507
|
+
// A session that reached ANY terminal state keeps neither a liveness veto nor a
|
|
1508
|
+
// draft. Leaving the draft would advertise an unsaved capture that is now saved,
|
|
1509
|
+
// which is the false-alarm that trains the user to ignore the channel.
|
|
1510
|
+
releaseStrandedState(STRANDED_DRAFT_DIR, sessionId)
|
|
1310
1511
|
persistClosedSessions()
|
|
1311
1512
|
// Clean up persisted file
|
|
1312
1513
|
try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
|