@gotcos/glasses-server 6.53.1 → 6.53.4
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 +38 -0
- package/README.md +37 -1
- package/bin/hooks/cos-session-hook +88 -43
- package/package.json +1 -1
- package/server/index.ts +32 -61
- package/server/lib/attached-provider-adapter.ts +508 -176
- package/server/lib/claude-hooks-installer.ts +58 -0
- package/server/lib/fence-liveness.ts +76 -2
- package/server/lib/queue-turn-evidence.ts +137 -0
- package/server/lib/session-cancel.ts +70 -12
- package/server/lib/session-halt.ts +141 -0
- package/server/lib/session-hooks-runtime.ts +62 -2
- package/server/routes/agent-session-bindings.ts +265 -28
- package/server/routes/health.ts +2 -1
- package/server/routes/meeting.ts +13 -4
- package/server/routes/transcribe-stream.ts +29 -1
- package/server/scripts/hooks-cli.ts +6 -2
|
@@ -17,7 +17,8 @@ import { SessionHookLedger } from './session-hook-ledger.js'
|
|
|
17
17
|
import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
|
|
18
18
|
import { SessionSignalStore, type SessionSignal } from './session-signal-store.js'
|
|
19
19
|
import { OPEN_TURN_CEILING_MS, deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
|
|
20
|
-
import { clearHaltMarkerOnSessionEnd, sweepHaltMarkers } from './session-halt.js'
|
|
20
|
+
import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
|
|
21
|
+
import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
|
|
21
22
|
import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
|
|
22
23
|
|
|
23
24
|
export function sessionHooksEnabled(): boolean {
|
|
@@ -173,6 +174,29 @@ function pidAlive(pid: number): boolean {
|
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
/**
|
|
178
|
+
* 6.53.3: when the desk session's own engine said its run is over, AS IT STANDS NOW: the
|
|
179
|
+
* registry's `idle` flip (`statusUpdatedAt`), or the hooks' Stop / StopFailure / SessionEnd
|
|
180
|
+
* while no turn is open. Null when neither says so (a busy record, an open hook turn, no
|
|
181
|
+
* record at all). What a desk_run cancel waits for before it may discount the evidence
|
|
182
|
+
* that the run is still going (`cancelVoidsOpenTurn`, review A1).
|
|
183
|
+
*/
|
|
184
|
+
export function deskTurnEndedAt(sessionId: string, dir = claudeSessionsDir()): number | null {
|
|
185
|
+
let ended: number | null = null
|
|
186
|
+
const later = (at: unknown): void => {
|
|
187
|
+
if (typeof at === 'number' && Number.isFinite(at) && (ended === null || at > ended)) ended = at
|
|
188
|
+
}
|
|
189
|
+
const signal = signalFor(sessionId)
|
|
190
|
+
if (signal && !signal.turnOpen) {
|
|
191
|
+
later(signal.stopAt)
|
|
192
|
+
later(signal.failure?.at)
|
|
193
|
+
later(signal.ended?.at)
|
|
194
|
+
}
|
|
195
|
+
const record = registryRecordSync(sessionId, dir)
|
|
196
|
+
if (record && record.status === 'idle') later(record.statusUpdatedAt)
|
|
197
|
+
return ended
|
|
198
|
+
}
|
|
199
|
+
|
|
176
200
|
/**
|
|
177
201
|
* 6.53.0: is Claude working on this session AT THE DESK right now (a Desktop tab, a terminal,
|
|
178
202
|
* or a Continue delivered live into either)? The cancel route's `deskRunning`: a yes offers
|
|
@@ -183,15 +207,47 @@ function pidAlive(pid: number): boolean {
|
|
|
183
207
|
* own in-flight turn) says `busy`, and its pid is alive.
|
|
184
208
|
* Canary C9 (2026-09-21): after a hook stop the registry read idle within a second, so a
|
|
185
209
|
* cancelled desk run stops reading as running at once.
|
|
210
|
+
*
|
|
211
|
+
* 6.53.3 (review A5): the hooks' open turn is weighed against the cancel on record, by the
|
|
212
|
+
* rule the turn queue uses. A `claude -p` COS child killed by a cancel fires no Stop or
|
|
213
|
+
* SessionEnd; its open turn with nothing since the cancel is the dead child's, and must not
|
|
214
|
+
* put a phantom "Cancel run" (desk_run) on a thread where nothing runs.
|
|
186
215
|
*/
|
|
187
216
|
export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = claudeSessionsDir()): boolean {
|
|
188
217
|
const signal = signalFor(sessionId)
|
|
189
|
-
if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS)
|
|
218
|
+
if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) {
|
|
219
|
+
const cancel = threadCancel('claude', sessionId)
|
|
220
|
+
if (cancel === null || !cancelVoidsOpenTurn({
|
|
221
|
+
cancel,
|
|
222
|
+
lastActivityAt: signal.lastEventAt,
|
|
223
|
+
turnStartedAt: signal.turnStartedAt,
|
|
224
|
+
deskEndedAt: deskTurnEndedAt(sessionId, dir),
|
|
225
|
+
})) return true
|
|
226
|
+
}
|
|
190
227
|
const record = registryRecordSync(sessionId, dir)
|
|
191
228
|
if (!record || record.status !== 'busy' || record.pid === null || isCosSpawnedPid(record.pid, now)) return false
|
|
192
229
|
return pidAlive(record.pid)
|
|
193
230
|
}
|
|
194
231
|
|
|
232
|
+
/**
|
|
233
|
+
* 6.53.3 (review A6): stop a turn COS has just handed into the open session, because a
|
|
234
|
+
* cancel arrived while the hand-off was in flight. The marker is written now; when the
|
|
235
|
+
* hooks do not yet show the turn started (its UserPromptSubmit at or after `sentAt`), it is
|
|
236
|
+
* re-armed once when that prompt's own UserPromptSubmit deletes it (`haltDeliveredTurn`).
|
|
237
|
+
*/
|
|
238
|
+
export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { prompt: string; sentAt: number; unverified?: boolean }, now = Date.now()): boolean {
|
|
239
|
+
const signal = signalFor(sessionId)
|
|
240
|
+
const started = !!signal && typeof signal.turnStartedAt === 'number' && signal.turnStartedAt >= turn.sentAt
|
|
241
|
+
return haltDeliveredTurn(sessionId, marker, {
|
|
242
|
+
prompt: turn.prompt,
|
|
243
|
+
after: turn.sentAt,
|
|
244
|
+
rearm: !started,
|
|
245
|
+
now,
|
|
246
|
+
// 6.53.3 /qa: an unverified hand-off (`maybe`) holds its re-arm two minutes, not an hour.
|
|
247
|
+
windowMs: turn.unverified === true ? HALT_REARM_UNVERIFIED_MS : HALT_MARKER_TTL_MS,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
195
251
|
let ledger: SessionHookLedger | null = null
|
|
196
252
|
let ingester: SpoolIngester | null = null
|
|
197
253
|
let replayed: { rows: number; applied: number } | null = null
|
|
@@ -233,6 +289,10 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
233
289
|
// clear a cancel written since; the TTL sweep covers anything this misses.
|
|
234
290
|
const unsubscribeHalt = enabled ? sessionSignalStore.subscribe((_signal, env, child) => {
|
|
235
291
|
if (env.event === 'SessionEnd' && !child) clearHaltMarkerOnSessionEnd(env.sessionId, env.ts)
|
|
292
|
+
// 6.53.3 (review A6): a turn COS handed into the open session while a cancel was on its
|
|
293
|
+
// way. Its own UserPromptSubmit just deleted the marker; write it back (once).
|
|
294
|
+
if (env.event === 'UserPromptSubmit' && !child) rearmHaltOnPrompt(env.sessionId, env.ts, env.payload.prompt)
|
|
295
|
+
if (env.event === 'SessionEnd' && !child) dropHaltRearm(env.sessionId)
|
|
236
296
|
}) : () => {}
|
|
237
297
|
sweepHaltMarkers(Date.now())
|
|
238
298
|
// Runtime files the script reads. The port can change per install; the token never does.
|
|
@@ -96,8 +96,7 @@
|
|
|
96
96
|
// and answer 400. They never treat an unparsed body as an empty one.
|
|
97
97
|
|
|
98
98
|
import type { FenceRecord } from '../lib/thread-fence-store.js'
|
|
99
|
-
import {
|
|
100
|
-
import { fenceLiveness, makePidStartProbe, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
|
|
99
|
+
import { defaultPidStartProbe, fenceLiveness, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
|
|
101
100
|
import { Router, type Request, type Response } from 'express'
|
|
102
101
|
import { createHash, randomUUID } from 'node:crypto'
|
|
103
102
|
import {
|
|
@@ -450,8 +449,17 @@ export interface CancelDeps {
|
|
|
450
449
|
settlePermissions?: (sessionId: string) => number
|
|
451
450
|
/** Waiting turns parked on the thread (they hold for CANCEL_QUEUE_HOLD_MS). */
|
|
452
451
|
queuedWaiting?: (provider: string, threadId: string) => number
|
|
453
|
-
/**
|
|
454
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Record the cancel the turn queue reads (`noteThreadCancelled`). 6.53.3: with its TARGET,
|
|
454
|
+
* because a killed COS child's silence ends its turn and a desk run's does not (review A1).
|
|
455
|
+
*/
|
|
456
|
+
noteCancelled?: (provider: string, threadId: string, at: number, target: CancelTarget) => void
|
|
457
|
+
/**
|
|
458
|
+
* 6.53.3 (review A6): a cancel latched during a live hand-off whose turn reached (or may
|
|
459
|
+
* have reached) the open session. Writes the halt marker, and re-arms it once when that
|
|
460
|
+
* turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
|
|
461
|
+
*/
|
|
462
|
+
haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean }) => boolean
|
|
455
463
|
/** Append to `data/session-cancel.jsonl`. */
|
|
456
464
|
ledger?: (row: SessionCancelLedgerRow) => void
|
|
457
465
|
}
|
|
@@ -998,6 +1006,14 @@ export interface TargetFenceView {
|
|
|
998
1006
|
fencedReason(targetKey: string): WriteRefusal | null
|
|
999
1007
|
}
|
|
1000
1008
|
|
|
1009
|
+
/** 6.53.3: how often a read may re-probe a settled-cancel fence's children (`ps` per child). */
|
|
1010
|
+
export const CANCEL_FENCE_RECHECK_MS = 30_000
|
|
1011
|
+
|
|
1012
|
+
/** A fence a cancel left at the delivery-ambiguous site: the only kind `releaseSettledCancelFences` may touch. */
|
|
1013
|
+
function isSettledCancelCandidate(row: FenceRecord): boolean {
|
|
1014
|
+
return row.adapterReason === 'cancelled' && row.fenceSite === 'ambiguous'
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1001
1017
|
export class TargetGuard {
|
|
1002
1018
|
/** targetKey -> turnId of the single COS turn allowed to be in flight. */
|
|
1003
1019
|
private readonly claims = new Map<string, string>()
|
|
@@ -1006,6 +1022,9 @@ export class TargetGuard {
|
|
|
1006
1022
|
private readonly fences = new Map<string, FenceRecord>()
|
|
1007
1023
|
private readonly persistence: FencePersistence | null
|
|
1008
1024
|
private persistDegraded = false
|
|
1025
|
+
/** 6.53.3: set by the router; null keeps `fencedReason` a pure read (tests, older wiring). */
|
|
1026
|
+
private cancelLiveness: FenceLivenessDeps | null = null
|
|
1027
|
+
private cancelSweepAt = Number.NEGATIVE_INFINITY
|
|
1009
1028
|
|
|
1010
1029
|
constructor(persistence: FencePersistence | null = null) {
|
|
1011
1030
|
this.persistence = persistence
|
|
@@ -1099,7 +1118,63 @@ export class TargetGuard {
|
|
|
1099
1118
|
|
|
1100
1119
|
fencedReason(targetKey: string): WriteRefusal | null {
|
|
1101
1120
|
const row = this.fences.get(targetKey)
|
|
1102
|
-
|
|
1121
|
+
if (row === undefined) return null
|
|
1122
|
+
// 6.53.3: a settled-cancel fence is re-checked here too, throttled, so a thread whose
|
|
1123
|
+
// cancelled child has since exited opens on its next use, not only on a restart or when
|
|
1124
|
+
// someone opens the Fences card.
|
|
1125
|
+
if (this.cancelLiveness !== null && isSettledCancelCandidate(row)) {
|
|
1126
|
+
const now = Date.now()
|
|
1127
|
+
if (now - this.cancelSweepAt >= CANCEL_FENCE_RECHECK_MS) {
|
|
1128
|
+
this.cancelSweepAt = now
|
|
1129
|
+
this.releaseSettledCancelFences(this.cancelLiveness)
|
|
1130
|
+
if (!this.fences.has(targetKey)) return null
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return row.reason as WriteRefusal
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* 6.53.3 (review A4): release every fence a CANCEL left behind whose recorded children
|
|
1138
|
+
* are all provably gone (`fenceLiveness` says `none_running`), and log each one with its
|
|
1139
|
+
* whole evidence record, as the operator release does.
|
|
1140
|
+
*
|
|
1141
|
+
* WHY THIS ONE CLASS, AND ONLY IT. A fence exists because a turn's delivery is unknowable
|
|
1142
|
+
* and a second copy could reach a real conversation. For a cancel that question is moot:
|
|
1143
|
+
* the person asked for the turn to stop, and the route settles it cancelled, never
|
|
1144
|
+
* retried. What remains is a second WRITER, and only the recorded child (the CLI, the one
|
|
1145
|
+
* process that writes the transcript) can be one. 6.53.1 fenced every Claude, Codex and
|
|
1146
|
+
* Cursor cancel whose tool was churning subprocesses (review A4: 10/10 false fences per
|
|
1147
|
+
* variant), and those fences are durable across restarts and upgrades.
|
|
1148
|
+
*
|
|
1149
|
+
* NEVER: a fence whose adapter reason is not `cancelled` (a timeout had 21 minutes of
|
|
1150
|
+
* tool calls; that is still a person's call), one set at the route-error site (a route
|
|
1151
|
+
* bug around a delivery), one whose children were never recorded or could not be probed
|
|
1152
|
+
* (`unknown`), or one with a child still alive (`running`). Returns what was released.
|
|
1153
|
+
*/
|
|
1154
|
+
releaseSettledCancelFences(deps: FenceLivenessDeps, log: (line: string) => void = line => console.warn(line)): FenceRecord[] {
|
|
1155
|
+
const released: FenceRecord[] = []
|
|
1156
|
+
for (const row of [...this.fences.values()]) {
|
|
1157
|
+
if (!isSettledCancelCandidate(row)) continue
|
|
1158
|
+
let state: string
|
|
1159
|
+
try {
|
|
1160
|
+
state = fenceLiveness(row.spawns, deps).state
|
|
1161
|
+
} catch {
|
|
1162
|
+
continue
|
|
1163
|
+
}
|
|
1164
|
+
if (state !== 'none_running') continue
|
|
1165
|
+
const target = opaqueRevision(row.targetKey)
|
|
1166
|
+
const outcome = this.releaseFence(target)
|
|
1167
|
+
if (!outcome.ok) continue
|
|
1168
|
+
const ev = outcome.row
|
|
1169
|
+
released.push(ev)
|
|
1170
|
+
log(`[agent-session-bindings] fence RELEASED automatically (cancelled turn, every recorded child gone) target=${target} provider=${ev.provider} fencedAt=${ev.fencedAt} fenceSite=${ev.fenceSite ?? 'unknown'} adapterReason=${ev.adapterReason ?? 'unknown'} detail=${ev.adapterDetail ?? 'none'} exitCode=${ev.exitCode ?? 'null'} childReaped=${ev.childReaped ?? 'unknown'} stderrClass=${ev.stderrClass ?? 'none'} durationMs=${ev.durationMs ?? 'unknown'} spawnCount=${ev.spawns?.length ?? 0}`)
|
|
1171
|
+
}
|
|
1172
|
+
return released
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** 6.53.3: the probe the lazy re-check in `fencedReason` uses; the router adopts its own. */
|
|
1176
|
+
adoptCancelLiveness(deps: FenceLivenessDeps): void {
|
|
1177
|
+
this.cancelLiveness = deps
|
|
1103
1178
|
}
|
|
1104
1179
|
|
|
1105
1180
|
/** Every fence, REDACTED for the wire: the raw targetKey embeds the private
|
|
@@ -1190,6 +1265,21 @@ interface InFlightTurn {
|
|
|
1190
1265
|
startedAt: number
|
|
1191
1266
|
/** False while a live hand-off is crossing its irreversible write boundary. */
|
|
1192
1267
|
cancellable: boolean
|
|
1268
|
+
/**
|
|
1269
|
+
* 6.53.3 (review A6): a cancel that arrived while `cancellable` was false. It is LATCHED
|
|
1270
|
+
* (answered 202 `stopping`) and settled by the turn route once the hop says where the
|
|
1271
|
+
* turn went: nothing reached, `turn_cancelled` and no spawn; reached, the desk halt
|
|
1272
|
+
* marker. Until 6.53.2 it was refused 409 `cancel_failed`, that refusal was cached for the
|
|
1273
|
+
* tap, and a hop that reached nothing went on to spawn the turn the person had stopped.
|
|
1274
|
+
*/
|
|
1275
|
+
latched: LatchedCancel | null
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
interface LatchedCancel {
|
|
1279
|
+
at: number
|
|
1280
|
+
clientCancelId: string
|
|
1281
|
+
/** Every tap latched onto this turn: each one's cached answer is rewritten to the outcome. */
|
|
1282
|
+
replayKeys: string[]
|
|
1193
1283
|
}
|
|
1194
1284
|
|
|
1195
1285
|
type Delivery =
|
|
@@ -1469,16 +1559,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1469
1559
|
const guard = deps.guard ?? new TargetGuard(deps.fencePersistence ?? null)
|
|
1470
1560
|
// `ps -o lstart=` is the only start-time keyword macOS ps offers. Injectable so a
|
|
1471
1561
|
// test drives recycled and unreadable pids without spawning real processes.
|
|
1472
|
-
const livenessDeps: FenceLivenessDeps = deps.liveness ?? {
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
}
|
|
1481
|
-
}),
|
|
1562
|
+
const livenessDeps: FenceLivenessDeps = deps.liveness ?? { pidStartMs: defaultPidStartProbe() }
|
|
1563
|
+
// 6.53.3 (review A4): at boot, release the fences a cancel left whose children are all
|
|
1564
|
+
// gone (6.53.1's false "unreaped" among them), then keep re-checking on every fence read.
|
|
1565
|
+
guard.adoptCancelLiveness(livenessDeps)
|
|
1566
|
+
try {
|
|
1567
|
+
guard.releaseSettledCancelFences(livenessDeps)
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
console.error(`[agent-session-bindings] settled-cancel fence sweep failed: ${error instanceof Error ? error.message : error}`)
|
|
1482
1570
|
}
|
|
1483
1571
|
const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
|
|
1484
1572
|
// One per router. Injectable so the follow-on (attach accepting a `forkRef`)
|
|
@@ -1524,10 +1612,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1524
1612
|
*/
|
|
1525
1613
|
const cancelFactsFor = (provider: string, threadId: string, verdictReason: string | null): CancelFacts => {
|
|
1526
1614
|
const entry = inFlight.get(targetKey(provider, threadId))
|
|
1527
|
-
|
|
1615
|
+
// 6.53.3: a live hand-off in flight is still OUR turn, and a cancel on it is latched
|
|
1616
|
+
// (review A6), so it reads cos_turn too, never as a desk run while it commits.
|
|
1617
|
+
const cosTurnInFlight = entry !== undefined
|
|
1528
1618
|
let runningOutsideCos = false
|
|
1529
|
-
// A live hand-off that has started is still ours, but cannot honestly be
|
|
1530
|
-
// cancelled: do not misclassify the same run as a desk run while it commits.
|
|
1531
1619
|
if (!entry) {
|
|
1532
1620
|
runningOutsideCos = provider === 'claude' && typeof cancelDeps.deskRunning === 'function'
|
|
1533
1621
|
? cancelProbe(() => cancelDeps.deskRunning!(threadId) === true, false)
|
|
@@ -1548,6 +1636,93 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1548
1636
|
}
|
|
1549
1637
|
/** Idempotency for POST .../cancel: the answer each (thread, clientCancelId) got. */
|
|
1550
1638
|
const cancelReplies = new Map<string, { status: number; body: Record<string, unknown>; at: number }>()
|
|
1639
|
+
const queuedHeldFor = (provider: string, threadId: string): number => {
|
|
1640
|
+
const n = cancelProbe(() => cancelDeps.queuedWaiting?.(provider, threadId) ?? 0, 0)
|
|
1641
|
+
return Number.isInteger(n) && n > 0 ? n : 0
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* 6.53.3 (review A6): settle a cancel LATCHED during a live hand-off, once the hop has said
|
|
1646
|
+
* where the turn went, and rewrite the answer every latched tap replays to that outcome
|
|
1647
|
+
* (never the transient 202 alone, never a refusal from before the latch).
|
|
1648
|
+
*
|
|
1649
|
+
* none nothing reached a session: the turn route answers `turn_cancelled` and never
|
|
1650
|
+
* spawns. The cached 202 `stopping` already says so.
|
|
1651
|
+
* reached the turn is in the open session (or MAY be: an unverified write). What stops it
|
|
1652
|
+
* / maybe there is what stops any desk run: the halt marker, re-armed once through the
|
|
1653
|
+
* turn's own UserPromptSubmit. Codex and Cursor have no marker, so the answer is
|
|
1654
|
+
* the ordinary `cancel_unsupported` ("Stop it there"); hooks that cannot stop a
|
|
1655
|
+
* desk run answer their own reason; a marker that could not be written is
|
|
1656
|
+
* `cancel_failed` 500, exactly as on the desk path.
|
|
1657
|
+
*/
|
|
1658
|
+
const settleLatchedCancel = (
|
|
1659
|
+
entry: InFlightTurn,
|
|
1660
|
+
provider: string,
|
|
1661
|
+
threadId: string,
|
|
1662
|
+
reached: 'none' | 'reached' | 'maybe',
|
|
1663
|
+
turn: { prompt: string; sentAt: number },
|
|
1664
|
+
): void => {
|
|
1665
|
+
const latch = entry.latched
|
|
1666
|
+
if (latch === null) return
|
|
1667
|
+
const now = readNow() ?? Date.now()
|
|
1668
|
+
const common = { turnId: entry.turnId, queuedHeld: queuedHeldFor(provider, threadId), queuedHoldMs: CANCEL_QUEUE_HOLD_MS }
|
|
1669
|
+
let status = 202
|
|
1670
|
+
let target: CancelTarget = 'cos_turn'
|
|
1671
|
+
let outcome: 'accepted' | CancelRefusal = 'accepted'
|
|
1672
|
+
let body: Record<string, unknown> = { cancelled: true, target, state: 'stopping', ...common }
|
|
1673
|
+
if (reached !== 'none') {
|
|
1674
|
+
target = cancelTargetFor({
|
|
1675
|
+
provider,
|
|
1676
|
+
cosTurnInFlight: false,
|
|
1677
|
+
runningOutsideCos: true,
|
|
1678
|
+
hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
|
|
1679
|
+
hooksReady: cancelProbe(() => cancelDeps.hooksReady?.() === true, false),
|
|
1680
|
+
}) ?? 'unsupported'
|
|
1681
|
+
if (target === 'desk_run') {
|
|
1682
|
+
const armed = cancelProbe(() => cancelDeps.haltHandedOffTurn?.(threadId, { at: latch.at, clientCancelId: latch.clientCancelId }, {
|
|
1683
|
+
// 6.53.3 /qa: the WHOLE delivered prompt (the re-arm matches it exactly), and whether
|
|
1684
|
+
// the hand-off was only possibly delivered (`maybe`: a two-minute re-arm, not an hour).
|
|
1685
|
+
prompt: turn.prompt,
|
|
1686
|
+
sentAt: turn.sentAt,
|
|
1687
|
+
unverified: reached === 'maybe',
|
|
1688
|
+
}) === true, false)
|
|
1689
|
+
if (armed) {
|
|
1690
|
+
const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
|
|
1691
|
+
body = {
|
|
1692
|
+
cancelled: true,
|
|
1693
|
+
target,
|
|
1694
|
+
effective: 'next_tool_call',
|
|
1695
|
+
...common,
|
|
1696
|
+
settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
|
|
1697
|
+
}
|
|
1698
|
+
} else {
|
|
1699
|
+
status = 500
|
|
1700
|
+
outcome = 'cancel_failed'
|
|
1701
|
+
}
|
|
1702
|
+
} else {
|
|
1703
|
+
status = 409
|
|
1704
|
+
outcome = target === 'hooks_outdated' || target === 'hooks_disabled' ? target : 'cancel_unsupported'
|
|
1705
|
+
}
|
|
1706
|
+
if (outcome !== 'accepted') {
|
|
1707
|
+
body = {
|
|
1708
|
+
cancelled: false,
|
|
1709
|
+
reason: outcome,
|
|
1710
|
+
...(outcome === 'cancel_unsupported' ? { provider } : {}),
|
|
1711
|
+
reasonCopy: cancelRefusalCopy(outcome, provider),
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
for (const replayKey of latch.replayKeys) {
|
|
1716
|
+
const previous = cancelReplies.get(replayKey)
|
|
1717
|
+
cancelReplies.set(replayKey, { status, body, at: previous?.at ?? now })
|
|
1718
|
+
}
|
|
1719
|
+
// The first answer was ledgered `accepted`; a cancel that then could not be carried out
|
|
1720
|
+
// gets a line of its own, so the ledger never records a stop that did not happen.
|
|
1721
|
+
if (outcome !== 'accepted') {
|
|
1722
|
+
cancelProbe(() => cancelDeps.ledger?.({ at: new Date(now).toISOString(), provider, threadId, target, outcome, clientCancelId: latch.clientCancelId }), undefined)
|
|
1723
|
+
}
|
|
1724
|
+
console.log(`[agent-session-bindings] latched cancel settled provider=${provider} turnId=${entry.turnId} hop=${reached} target=${target} outcome=${outcome} status=${status} taps=${latch.replayKeys.length}`)
|
|
1725
|
+
}
|
|
1551
1726
|
|
|
1552
1727
|
/**
|
|
1553
1728
|
* One owner for "is this thread free right now", shared by the probe and both
|
|
@@ -1630,6 +1805,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1630
1805
|
// `degraded` is reported, never inferred: a memory-only fence set behaves
|
|
1631
1806
|
// identically to a durable one until the process restarts, so a silent
|
|
1632
1807
|
// fallback would be indistinguishable from working.
|
|
1808
|
+
// 6.53.3: a read releases the settled-cancel fences first (logged), so the card never
|
|
1809
|
+
// offers a person a fence the server can prove is safe to lift.
|
|
1810
|
+
try {
|
|
1811
|
+
guard.releaseSettledCancelFences(livenessDeps)
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
console.error(`[agent-session-bindings] settled-cancel fence sweep failed: ${error instanceof Error ? error.message : error}`)
|
|
1814
|
+
}
|
|
1633
1815
|
res.json({ fences: guard.listFences(livenessDeps), degraded: guard.degraded() })
|
|
1634
1816
|
})
|
|
1635
1817
|
|
|
@@ -1795,17 +1977,33 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1795
1977
|
// The occupancy verdict is read only when the decision needs it: never for a COS turn
|
|
1796
1978
|
// (the map knows) and never for Claude with a desk probe wired (the hooks know).
|
|
1797
1979
|
const key = targetKey(provider, threadId)
|
|
1980
|
+
const queuedHeld = (): number => queuedHeldFor(provider, threadId)
|
|
1798
1981
|
const activeEntry = inFlight.get(key)
|
|
1799
1982
|
if (activeEntry && activeEntry.cancellable !== true) {
|
|
1800
|
-
|
|
1983
|
+
// 6.53.3 (review A6): the turn is crossing a live hand-off's write boundary, where no
|
|
1984
|
+
// abort can recall it. LATCH the cancel on the turn and answer as for any COS turn;
|
|
1985
|
+
// the turn route settles it when the hop reports (`settleLatchedCancel`). The abort
|
|
1986
|
+
// is still signalled, so nothing after the hop can start the turn either.
|
|
1987
|
+
if (activeEntry.latched === null) {
|
|
1988
|
+
activeEntry.latched = { at: now, clientCancelId, replayKeys: [] }
|
|
1989
|
+
activeEntry.controller.abort()
|
|
1990
|
+
// Noted as what the run becomes if the hop lands it: a desk run (Claude) or one only
|
|
1991
|
+
// its app can stop. Either way only the engine's own end discounts its open turn.
|
|
1992
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, provider === 'claude' ? 'desk_run' : 'unsupported'), undefined)
|
|
1993
|
+
}
|
|
1994
|
+
activeEntry.latched.replayKeys.push(replayKey)
|
|
1995
|
+
return answer(202, 'cos_turn', 'accepted', {
|
|
1996
|
+
cancelled: true,
|
|
1997
|
+
target: 'cos_turn',
|
|
1998
|
+
state: 'stopping',
|
|
1999
|
+
turnId: activeEntry.turnId,
|
|
2000
|
+
queuedHeld: queuedHeld(),
|
|
2001
|
+
queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
|
|
2002
|
+
})
|
|
1801
2003
|
}
|
|
1802
2004
|
const needsVerdict = !inFlight.has(key) && !(provider === 'claude' && typeof cancelDeps.deskRunning === 'function')
|
|
1803
2005
|
const verdictReason = needsVerdict && canDetect ? projectAttachability(detectOccupancy(provider, threadId)).reason : null
|
|
1804
2006
|
const target = cancelTargetFor(cancelFactsFor(provider, threadId, verdictReason))
|
|
1805
|
-
const queuedHeld = (): number => {
|
|
1806
|
-
const n = cancelProbe(() => cancelDeps.queuedWaiting?.(provider, threadId) ?? 0, 0)
|
|
1807
|
-
return Number.isInteger(n) && n > 0 ? n : 0
|
|
1808
|
-
}
|
|
1809
2007
|
|
|
1810
2008
|
if (target === 'cos_turn') {
|
|
1811
2009
|
const entry = inFlight.get(key)
|
|
@@ -1813,7 +2011,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1813
2011
|
// The adapter stops the process group (SIGTERM, then SIGKILL after 5 s) and the turn
|
|
1814
2012
|
// route records the outcome; this answer does not wait for either.
|
|
1815
2013
|
entry.controller.abort()
|
|
1816
|
-
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
|
|
2014
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, target), undefined)
|
|
1817
2015
|
return answer(202, target, 'accepted', {
|
|
1818
2016
|
cancelled: true,
|
|
1819
2017
|
target,
|
|
@@ -1831,7 +2029,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1831
2029
|
// run waiting for its answer, and then show the Mac's own dialog. Deny it, with
|
|
1832
2030
|
// interrupt, so the run ends now rather than at a tool that never comes.
|
|
1833
2031
|
const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
|
|
1834
|
-
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
|
|
2032
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, target), undefined)
|
|
1835
2033
|
return answer(202, target, 'accepted', {
|
|
1836
2034
|
cancelled: true,
|
|
1837
2035
|
target,
|
|
@@ -2452,7 +2650,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2452
2650
|
if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
|
|
2453
2651
|
claimedKey = key
|
|
2454
2652
|
// 6.53.0: cancellable from here, with the claim. Same key, same lifetime.
|
|
2455
|
-
inFlight.set(key, { turnId, bindingId, clientTurnId, controller: cancelController, startedAt: now, cancellable: true })
|
|
2653
|
+
inFlight.set(key, { turnId, bindingId, clientTurnId, controller: cancelController, startedAt: now, cancellable: true, latched: null })
|
|
2456
2654
|
|
|
2457
2655
|
// Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
|
|
2458
2656
|
// session started in the gap is exactly the residual risk option B leaves
|
|
@@ -2521,6 +2719,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2521
2719
|
if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
|
|
2522
2720
|
const liveEntry = inFlight.get(key)
|
|
2523
2721
|
if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
|
|
2722
|
+
/** 6.53.3: a cancel latched onto THIS turn while the hop was in flight (review A6). */
|
|
2723
|
+
const latchedHere = (): InFlightTurn | null => (liveEntry?.turnId === turnId && liveEntry.latched !== null ? liveEntry : null)
|
|
2724
|
+
const handOffAt = Date.now()
|
|
2524
2725
|
let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
|
|
2525
2726
|
try {
|
|
2526
2727
|
live = await deps.deliverLiveTurn({
|
|
@@ -2538,6 +2739,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2538
2739
|
}
|
|
2539
2740
|
stage(`live(${live?.reason ?? 'threw'})`)
|
|
2540
2741
|
if (live?.ok) {
|
|
2742
|
+
// 6.53.3: delivered, so the turn runs in the open session now, and what stops it
|
|
2743
|
+
// there is the desk halt marker. The turn itself WAS delivered and says so.
|
|
2744
|
+
const latched = latchedHere()
|
|
2745
|
+
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
|
|
2541
2746
|
console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
|
|
2542
2747
|
respond(200, {
|
|
2543
2748
|
turnId,
|
|
@@ -2553,11 +2758,27 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2553
2758
|
return
|
|
2554
2759
|
}
|
|
2555
2760
|
if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
|
|
2761
|
+
// 6.53.3: something MAY be in the session and the person asked for it to stop: arm
|
|
2762
|
+
// the marker as for a landed turn, and settle this turn as cancelled, never as a
|
|
2763
|
+
// retryable hold that the queue would send again after the cancel's two minutes.
|
|
2764
|
+
const latched = latchedHere()
|
|
2765
|
+
if (latched) {
|
|
2766
|
+
settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
|
|
2767
|
+
return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'unknown' })
|
|
2768
|
+
}
|
|
2556
2769
|
// Something may be in the session. Hold, never spawn over it.
|
|
2557
2770
|
return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
|
|
2558
2771
|
}
|
|
2559
|
-
// Every remaining result is documented as "nothing reached a session"
|
|
2560
|
-
//
|
|
2772
|
+
// Every remaining result is documented as "nothing reached a session".
|
|
2773
|
+
// 6.53.3: with a cancel latched on the way, that is the whole turn: cancelled, no
|
|
2774
|
+
// spawn. Until 6.53.2 the refused cancel was forgotten here and the child ran.
|
|
2775
|
+
const latched = latchedHere()
|
|
2776
|
+
if (latched) {
|
|
2777
|
+
settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
|
|
2778
|
+
console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} hop=${live?.reason ?? 'threw'}`)
|
|
2779
|
+
return refuseTurn('turn_cancelled', { retryable: false })
|
|
2780
|
+
}
|
|
2781
|
+
// The abortable child path is safe to expose again before it starts.
|
|
2561
2782
|
if (liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
2562
2783
|
// disabled / not_claude / no_record / protocol_unsupported / connect_failed /
|
|
2563
2784
|
// suspect_expired: nothing reached a session. Spawn as always.
|
|
@@ -2573,6 +2794,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2573
2794
|
if (binding.provider === 'codex' && foreignHolder && typeof deps.deliverCodexLiveTurn === 'function') {
|
|
2574
2795
|
const liveEntry = inFlight.get(key)
|
|
2575
2796
|
if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
|
|
2797
|
+
/** 6.53.3: a cancel latched onto THIS turn while the hop was in flight (review A6). */
|
|
2798
|
+
const latchedHere = (): InFlightTurn | null => (liveEntry?.turnId === turnId && liveEntry.latched !== null ? liveEntry : null)
|
|
2799
|
+
const handOffAt = Date.now()
|
|
2576
2800
|
let live: { ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null } | null = null
|
|
2577
2801
|
try {
|
|
2578
2802
|
live = await deps.deliverCodexLiveTurn({
|
|
@@ -2587,6 +2811,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2587
2811
|
}
|
|
2588
2812
|
stage(`codex-live(${live?.reason ?? 'threw'})`)
|
|
2589
2813
|
if (live?.ok) {
|
|
2814
|
+
// 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
|
|
2815
|
+
const latched = latchedHere()
|
|
2816
|
+
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
|
|
2590
2817
|
console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
|
|
2591
2818
|
respond(200, {
|
|
2592
2819
|
turnId,
|
|
@@ -2601,6 +2828,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2601
2828
|
})
|
|
2602
2829
|
return
|
|
2603
2830
|
}
|
|
2831
|
+
// 6.53.3: anything but a proven or possible queue row reached nothing; with a cancel
|
|
2832
|
+
// latched on the way that is the whole turn: cancelled, and no child.
|
|
2833
|
+
const codexLatched = latchedHere()
|
|
2834
|
+
if (codexLatched && live !== null && live.reason !== 'unverified') {
|
|
2835
|
+
settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
|
|
2836
|
+
console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
|
|
2837
|
+
return refuseTurn('turn_cancelled', { retryable: false })
|
|
2838
|
+
}
|
|
2604
2839
|
// The operator switched this off: nothing was queued, so the abortable
|
|
2605
2840
|
// 6.50 child path is cancellable again before it starts.
|
|
2606
2841
|
if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
@@ -2624,6 +2859,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2624
2859
|
spawns: [],
|
|
2625
2860
|
})
|
|
2626
2861
|
console.warn(`[agent-session-bindings] fence set site=codex_live provider=codex target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest} adapterReason=codex_queue_unverified`)
|
|
2862
|
+
// 6.53.3: a row MAY be in the Codex app's queue; a latched cancel says where to stop it.
|
|
2863
|
+
if (codexLatched) settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
|
|
2627
2864
|
return reportAmbiguous()
|
|
2628
2865
|
}
|
|
2629
2866
|
// Nothing was queued, and the child cannot run against a held thread. Not
|
package/server/routes/health.ts
CHANGED
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
} from '../lib/thread-attach-capability.js'
|
|
83
83
|
import { cachedHookStatus, sessionHooksEnabled, sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
|
|
84
84
|
import { sessionCancelFeature } from '../lib/session-cancel.js'
|
|
85
|
+
import { hookHaltReady } from '../lib/claude-hooks-installer.js'
|
|
85
86
|
import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
|
|
86
87
|
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
87
88
|
import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
|
|
@@ -297,7 +298,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
297
298
|
// 6.53.0: cancel a session run. `cosTurn` always; `deskClaude` only once the hooks are
|
|
298
299
|
// applied and the 6.53 hook is installed (Install hooks in COS Control after updating).
|
|
299
300
|
// The status read is the same cached one `sessionHooks` below reports.
|
|
300
|
-
sessionCancel: sessionCancelFeature(sessionHooksEnabled(), cachedHookStatus()
|
|
301
|
+
sessionCancel: sessionCancelFeature(sessionHooksEnabled(), hookHaltReady(cachedHookStatus())),
|
|
301
302
|
}
|
|
302
303
|
const voice = {
|
|
303
304
|
available: keyStatus.hasKey || tts_local.ready,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -182,8 +182,12 @@ interface MeetingSessionSource {
|
|
|
182
182
|
hasAudio(sessionId: string): boolean
|
|
183
183
|
moveAudioToPending(sessionId: string): string | null
|
|
184
184
|
delete(sessionId: string, options?: { preserveAudio?: boolean }): void
|
|
185
|
+
/** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
|
|
186
|
+
getState?(sessionId: string): 'active' | 'closed' | 'missing'
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
const defaultSessionState = (sessionId: string): 'active' | 'closed' | 'missing' => getMeetingSessionStatus(sessionId).state
|
|
190
|
+
|
|
187
191
|
export interface MeetingRouteDependencies {
|
|
188
192
|
store?: MeetingStore
|
|
189
193
|
sessions?: MeetingSessionSource
|
|
@@ -404,6 +408,7 @@ const defaultSessionSource: MeetingSessionSource = {
|
|
|
404
408
|
hasAudio: hasSessionAudio,
|
|
405
409
|
moveAudioToPending: moveSessionAudioToPending,
|
|
406
410
|
delete: deleteSession,
|
|
411
|
+
getState: defaultSessionState,
|
|
407
412
|
}
|
|
408
413
|
|
|
409
414
|
function countWords(text: string): number {
|
|
@@ -566,10 +571,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
566
571
|
|
|
567
572
|
const transcript = sessions.getTranscript(sessionId)
|
|
568
573
|
if (!transcript?.trim()) {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
574
|
+
// 6.53.3 (review D3): a session the server KNOWS (live, or closed within its
|
|
575
|
+
// retention) that holds no transcript is not one it never heard of. Its own reason
|
|
576
|
+
// lets a client stop asking ("nothing to save") instead of retrying a 404 all day.
|
|
577
|
+
// Still 404, so every existing client reads it exactly as before.
|
|
578
|
+
const known = (sessions.getState ?? defaultSessionState)(sessionId)
|
|
579
|
+
res.status(404).json(known === 'active' || known === 'closed'
|
|
580
|
+
? { error: `Nothing to save for session ${sessionId}`, reason: 'nothing_to_save', state: known }
|
|
581
|
+
: { error: `No transcript found for session ${sessionId}`, reason: 'session_not_found' })
|
|
573
582
|
return
|
|
574
583
|
}
|
|
575
584
|
|
|
@@ -437,6 +437,9 @@ export interface TranscriptionSessionLiveness {
|
|
|
437
437
|
*/
|
|
438
438
|
export const __sessionsForTests = sessions
|
|
439
439
|
|
|
440
|
+
/** 6.53.3: the closed-session records, for tests only (same reasoning as above). */
|
|
441
|
+
export const __closedSessionRecordsForTests = closedSessionRecords
|
|
442
|
+
|
|
440
443
|
export interface StrandedCapture {
|
|
441
444
|
sessionId: string
|
|
442
445
|
/** Minutes since the last ACK'd chunk. */
|
|
@@ -1151,6 +1154,8 @@ setInterval(() => {
|
|
|
1151
1154
|
}
|
|
1152
1155
|
}
|
|
1153
1156
|
} catch {}
|
|
1157
|
+
// 6.53.3 (review D3): the in-memory records age out with the file's.
|
|
1158
|
+
try { sweepExpiredClosedSessions(Date.now()) } catch {}
|
|
1154
1159
|
persistClosedSessions()
|
|
1155
1160
|
}, 60_000)
|
|
1156
1161
|
|
|
@@ -1455,7 +1460,30 @@ export interface MeetingSessionStatusSnapshot {
|
|
|
1455
1460
|
retainedUntil: string | null
|
|
1456
1461
|
}
|
|
1457
1462
|
|
|
1458
|
-
|
|
1463
|
+
/**
|
|
1464
|
+
* 6.53.3 (review D3): forget a closed session once its retention has passed, in memory as
|
|
1465
|
+
* well as on disk. Until 6.53.2 only the persisted file was pruned, so a closed session read
|
|
1466
|
+
* `closed` until the next restart, and a phone whose recovery hint named it POSTed save
|
|
1467
|
+
* (which can only 404) on every boot and foreground, all day. Past `retainedUntil` the
|
|
1468
|
+
* server now answers exactly as it would after a restart: `missing`, which every app since
|
|
1469
|
+
* 6.9.465 settles as terminal ("the server no longer retains it").
|
|
1470
|
+
*/
|
|
1471
|
+
function evictExpiredClosedSession(sessionId: string, now: number): boolean {
|
|
1472
|
+
const closed = closedSessionRecords.get(sessionId)
|
|
1473
|
+
if (!closed || now - closed.closedAt <= CLOSED_SESSION_TTL_MS) return false
|
|
1474
|
+
closedSessionRecords.delete(sessionId)
|
|
1475
|
+
deletedSessions.delete(sessionId)
|
|
1476
|
+
return true
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
export function sweepExpiredClosedSessions(now = Date.now()): number {
|
|
1480
|
+
let evicted = 0
|
|
1481
|
+
for (const id of [...closedSessionRecords.keys()]) if (evictExpiredClosedSession(id, now)) evicted++
|
|
1482
|
+
return evicted
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
export function getMeetingSessionStatus(sessionId: string, now = Date.now()): MeetingSessionStatusSnapshot {
|
|
1486
|
+
evictExpiredClosedSession(sessionId, now)
|
|
1459
1487
|
const active = sessions.get(sessionId)
|
|
1460
1488
|
if (active) {
|
|
1461
1489
|
const received = active.receivedIndices ?? []
|