@gotcos/glasses-server 6.76.1 → 6.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -122,7 +122,10 @@ import {
|
|
|
122
122
|
type NativeBinding,
|
|
123
123
|
} from '../lib/agent-session-binding-store.js'
|
|
124
124
|
import { boundedContinueNote, CONTINUE_NOTE_HEADER, continueNoteAcknowledged, fallbackContinueNote } from '../lib/continue-plan.js'
|
|
125
|
-
import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
|
|
125
|
+
import type { PinExtras, RegistryCheck, RegistryRejection, RegistryResult, UnpinExtras } from '../lib/agent-session-binding-registry.js'
|
|
126
|
+
import { ORPHAN_CHILD_RUNNING_COPY } from '../lib/agent-session-binding-registry.js'
|
|
127
|
+
import { pinBootReapEnabled, queueableRefusal, queueReconcileEnabled, sessionSteerEnabled, type SessionSteerAvailability } from '../lib/thread-turn-queue.js'
|
|
128
|
+
import type { TurnReceipt } from '../lib/queue-turn-evidence.js'
|
|
126
129
|
import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
|
|
127
130
|
import { isValidNativeThreadId } from '../lib/native-thread-id.js'
|
|
128
131
|
import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
|
|
@@ -212,9 +215,24 @@ export interface BindingRegistry {
|
|
|
212
215
|
claim: { bindingId: string; epoch: number; targetKey: string },
|
|
213
216
|
now: number,
|
|
214
217
|
) => RegistryCheck
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
/**
|
|
219
|
+
* A rejection here is FATAL to the turn, per the registry's own caller contract.
|
|
220
|
+
* 6.78.0: `extras` carries the pin's client turn and opens its attempt in the same commit;
|
|
221
|
+
* passed only while COS_PIN_BOOT_REAP or COS_QUEUE_RECONCILE is on.
|
|
222
|
+
*/
|
|
223
|
+
pin?: (bindingId: string, jobId: string, now: number, extras?: PinExtras) => RegistryResult
|
|
224
|
+
/** 6.78.0: `extras` records how the turn's delivery ended, in the same commit as the release. */
|
|
225
|
+
unpin?: (bindingId: string, jobId: string, now: number, extras?: UnpinExtras) => RegistryResult
|
|
226
|
+
/**
|
|
227
|
+
* 6.78.0: is this target held only by pins a dead boot left, and until when? Optional; the
|
|
228
|
+
* refusal copy says when the thread frees, or keeps its 6.77 words without it.
|
|
229
|
+
*/
|
|
230
|
+
orphanPinHold?: (provider: string, nativeThreadId: string, now: number) => { releasesAt: number; childRunning?: true } | null
|
|
231
|
+
/**
|
|
232
|
+
* 6.78.0 (P7 S6): record the resume child a pinned turn spawned (pid and measured start),
|
|
233
|
+
* so a crash's orphan pin waits for that process. Optional; a failure changes nothing.
|
|
234
|
+
*/
|
|
235
|
+
notePinChild?: (bindingId: string, jobId: string, child: { pid: number; startMs: number }) => RegistryResult
|
|
218
236
|
}
|
|
219
237
|
|
|
220
238
|
/** What attach resolves server-side because plan 4.2 forbids the client sending it. */
|
|
@@ -411,6 +429,21 @@ export interface AgentSessionBindingsDeps {
|
|
|
411
429
|
/** Overrides the env gate. Tests only; production reads threadAttachEnabled(). */
|
|
412
430
|
attachEnabled?: boolean
|
|
413
431
|
|
|
432
|
+
/**
|
|
433
|
+
* 6.78.0 (COS_QUEUE_RECONCILE): was this client turn already sent, or may it have been?
|
|
434
|
+
* `makeTurnReceiptLookup`: the queue's SETTLED rows (never waiting or delivering), the
|
|
435
|
+
* attempt journal, and the transcript for an unknown. Consulted after the ledger replay
|
|
436
|
+
* and before the claim; a hit is replayed, never sent again. Absent: 6.77 behaviour.
|
|
437
|
+
*/
|
|
438
|
+
turnReceipt?: (provider: string, nativeThreadId: string, clientTurnId: string, prompt: string) => TurnReceipt | null
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 6.78.0 (COS_SESSION_STEER): could a turn go INTO this thread's running turn? Added to an
|
|
442
|
+
* attach refusal as `steer`. The router also checks the flag itself, so with it off the
|
|
443
|
+
* field is absent whatever is wired.
|
|
444
|
+
*/
|
|
445
|
+
sessionSteer?: (provider: string, nativeThreadId: string, gateReason: string) => SessionSteerAvailability | null
|
|
446
|
+
|
|
414
447
|
ownership?: {
|
|
415
448
|
record: (pid: number, startMs: number) => string
|
|
416
449
|
release: (pid: number) => boolean
|
|
@@ -1607,6 +1640,17 @@ export const CONTINUE_NOTE_BUDGET_MS = 1_500
|
|
|
1607
1640
|
*/
|
|
1608
1641
|
export const TURN_QUEUED_COPY = 'Queued to the original thread. It keeps running if you put your phone away.'
|
|
1609
1642
|
|
|
1643
|
+
/**
|
|
1644
|
+
* 6.78.0: the refusal copy while a pin a dead boot left holds the thread. The time is the
|
|
1645
|
+
* Mac's local clock, rounded UP to the minute, so the thread is never shown free early.
|
|
1646
|
+
* 62 columns or fewer, no em dash, no arrow.
|
|
1647
|
+
*/
|
|
1648
|
+
export function orphanPinWaitCopy(releasesAt: number): string {
|
|
1649
|
+
const at = new Date(Math.ceil(releasesAt / 60_000) * 60_000)
|
|
1650
|
+
const hour = at.getHours() % 12 || 12
|
|
1651
|
+
return `An earlier run may still be running · frees ${hour}:${String(at.getMinutes()).padStart(2, '0')}`
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1610
1654
|
/** Status of a turn that is admitted and still running. */
|
|
1611
1655
|
export const TURN_PENDING_COPY = 'Still working on your Mac.'
|
|
1612
1656
|
|
|
@@ -1878,14 +1922,39 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1878
1922
|
const runOccupancy = (provider: string, threadId: string): AttachabilityBody =>
|
|
1879
1923
|
projectAttachability(routeOccupancy(provider, threadId))
|
|
1880
1924
|
|
|
1881
|
-
const refuseAttach = (res: Response, reason: WriteRefusal): void => {
|
|
1925
|
+
const refuseAttach = (res: Response, reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
|
|
1882
1926
|
res.status(refusalStatus(reason)).json({
|
|
1883
1927
|
attached: false,
|
|
1884
1928
|
reason,
|
|
1885
1929
|
reasonCopy: writeReasonCopy(reason),
|
|
1930
|
+
...extra,
|
|
1886
1931
|
})
|
|
1887
1932
|
}
|
|
1888
1933
|
|
|
1934
|
+
/**
|
|
1935
|
+
* 6.78.0: what an attach refusal on THIS thread adds. `steer` (COS_SESSION_STEER) on a
|
|
1936
|
+
* refusal a turn may queue behind, from the shared rule. And when the holder is a pin a
|
|
1937
|
+
* dead boot left (COS_PIN_BOOT_REAP), the copy says when it frees instead of telling the
|
|
1938
|
+
* person to detach a chat that no longer exists. Empty with the flags off.
|
|
1939
|
+
*/
|
|
1940
|
+
const attachRefusalExtras = (provider: string, threadId: string, reason: WriteRefusal, now: number | null): Record<string, unknown> => {
|
|
1941
|
+
const extra: Record<string, unknown> = {}
|
|
1942
|
+
if (reason === 'native_target_busy' && now !== null && typeof deps.bindings?.orphanPinHold === 'function') {
|
|
1943
|
+
let hold: { releasesAt: number; childRunning?: true } | null = null
|
|
1944
|
+
try { hold = deps.bindings.orphanPinHold(provider, threadId, now) } catch { hold = null }
|
|
1945
|
+
if (hold && Number.isFinite(hold.releasesAt)) extra.reasonCopy = orphanPinWaitCopy(hold.releasesAt)
|
|
1946
|
+
// P7 S6: past the window, the dead boot's child itself still runs: no time to promise
|
|
1947
|
+
// (such a hold carries no finite releasesAt, so the line above never wrote).
|
|
1948
|
+
if (hold?.childRunning === true) extra.reasonCopy = ORPHAN_CHILD_RUNNING_COPY
|
|
1949
|
+
}
|
|
1950
|
+
if (sessionSteerEnabled() && typeof deps.sessionSteer === 'function' && queueableRefusal(reason)) {
|
|
1951
|
+
let steer: SessionSteerAvailability | null = null
|
|
1952
|
+
try { steer = deps.sessionSteer(provider, threadId, reason) } catch { steer = null }
|
|
1953
|
+
if (steer) extra.steer = steer
|
|
1954
|
+
}
|
|
1955
|
+
return extra
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1889
1958
|
/** Read a finite clock, or null. A route that cannot tell the time refuses. */
|
|
1890
1959
|
const readNow = (): number | null => {
|
|
1891
1960
|
try {
|
|
@@ -2335,7 +2404,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2335
2404
|
// or any doubt about whether it has one — is Fork-only, and attach is the
|
|
2336
2405
|
// gate that makes that true.
|
|
2337
2406
|
const verdict = runOccupancy(providerParam, threadIdParam)
|
|
2338
|
-
if (!verdict.attachable)
|
|
2407
|
+
if (!verdict.attachable) {
|
|
2408
|
+
const refused = verdict.reason ?? 'probe_failed'
|
|
2409
|
+
return refuseAttach(res, refused, attachRefusalExtras(providerParam, threadIdParam, refused, now))
|
|
2410
|
+
}
|
|
2339
2411
|
|
|
2340
2412
|
// `runOccupancy` already proved both, provider first. These narrow the types
|
|
2341
2413
|
// rather than re-deciding the rule — a second copy of the id rule is how the
|
|
@@ -2352,7 +2424,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2352
2424
|
// a per-binding fence.
|
|
2353
2425
|
if (fenced !== null) {
|
|
2354
2426
|
console.warn(`[agent-session-bindings] fence hit route=attach provider=${providerParam} target=${opaqueRevision(key)}`)
|
|
2355
|
-
return refuseAttach(res, fenced)
|
|
2427
|
+
return refuseAttach(res, fenced, attachRefusalExtras(providerParam, threadIdParam, fenced, now))
|
|
2356
2428
|
}
|
|
2357
2429
|
|
|
2358
2430
|
const resolve = deps.resolveTarget
|
|
@@ -2391,7 +2463,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2391
2463
|
ttlMs: attachTtlMs,
|
|
2392
2464
|
now,
|
|
2393
2465
|
})
|
|
2394
|
-
if (!created?.binding)
|
|
2466
|
+
if (!created?.binding) {
|
|
2467
|
+
const refused = registryRefusal(created?.reason)
|
|
2468
|
+
return refuseAttach(res, refused, attachRefusalExtras(providerParam, threadIdParam, refused, now))
|
|
2469
|
+
}
|
|
2395
2470
|
|
|
2396
2471
|
const activated = deps.bindings.activate!(bindingId, now)
|
|
2397
2472
|
if (!activated?.binding) {
|
|
@@ -2775,6 +2850,17 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2775
2850
|
* alone is backgrounded once the last gate passes.
|
|
2776
2851
|
*/
|
|
2777
2852
|
let queued = false
|
|
2853
|
+
/**
|
|
2854
|
+
* 6.78.0 (COS_QUEUE_RECONCILE): the attempt journal. Opened with the pin (same commit),
|
|
2855
|
+
* settled with the unpin. Where the turn MAY have gone: `proven` once a hop showed it
|
|
2856
|
+
* arrived; `liveMay` / `spawnMay` once a hop that could have written was entered and did
|
|
2857
|
+
* not prove nothing reached. `not_sent` only when every hop entered said nothing did.
|
|
2858
|
+
*/
|
|
2859
|
+
let attemptOpened = false
|
|
2860
|
+
let proven = false
|
|
2861
|
+
let liveMay = false
|
|
2862
|
+
let spawnMay = false
|
|
2863
|
+
const attemptExit = (): 'sent' | 'not_sent' | 'unknown' => (proven ? 'sent' : liveMay || spawnMay ? 'unknown' : 'not_sent')
|
|
2778
2864
|
|
|
2779
2865
|
const respond = (status: number, payload: Record<string, unknown>): void => {
|
|
2780
2866
|
if (!res.headersSent) {
|
|
@@ -2919,6 +3005,46 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2919
3005
|
return
|
|
2920
3006
|
}
|
|
2921
3007
|
|
|
3008
|
+
// 6.78.0: RECEIPTS, before any gate, claim, pin or hop. The ledger above is dropped
|
|
3009
|
+
// with its binding and records nothing for a pre-202 `live_unverified`; a crash after
|
|
3010
|
+
// the pin leaves no record at all. The receipt covers those (target-scoped attempt
|
|
3011
|
+
// journal, the queue's settled rows), so a phone that retries a key after a reconnect
|
|
3012
|
+
// or a restart can never put the same words in twice. A hit is replayed, never sent:
|
|
3013
|
+
// `delivered` as a completed turn, `unknown` as the ambiguous outcome every client
|
|
3014
|
+
// already treats as "do not send again".
|
|
3015
|
+
if (queueReconcileEnabled() && typeof deps.turnReceipt === 'function') {
|
|
3016
|
+
const owner = deps.bindings.get?.(bindingId) ?? null
|
|
3017
|
+
if (owner && isBindableProvider(owner.provider) && isValidNativeThreadId(owner.nativeThreadId)) {
|
|
3018
|
+
let receipt: TurnReceipt | null
|
|
3019
|
+
try {
|
|
3020
|
+
receipt = deps.turnReceipt(owner.provider, owner.nativeThreadId, clientTurnId, prompt)
|
|
3021
|
+
} catch (error) {
|
|
3022
|
+
// A receipt we could not read is not "never sent". Fail closed: unknown.
|
|
3023
|
+
console.error(`[agent-session-bindings] turn receipt read failed: ${error instanceof Error ? error.message : error}`)
|
|
3024
|
+
receipt = { kind: 'unknown', source: 'attempt' }
|
|
3025
|
+
}
|
|
3026
|
+
if (receipt !== null) {
|
|
3027
|
+
const delivered = receipt.kind === 'delivered'
|
|
3028
|
+
if (!res.headersSent) {
|
|
3029
|
+
res.status(delivered ? 200 : refusalStatus('delivery_ambiguous')).json({
|
|
3030
|
+
turnId,
|
|
3031
|
+
outcome: delivered ? 'completed' : 'ambiguous',
|
|
3032
|
+
deliveryState: delivered ? 'delivered' : 'unknown',
|
|
3033
|
+
retryable: false,
|
|
3034
|
+
changed: false,
|
|
3035
|
+
revision: null,
|
|
3036
|
+
reason: delivered ? null : 'delivery_ambiguous',
|
|
3037
|
+
reasonCopy: delivered ? TURN_SENT_COPY : writeReasonCopy('delivery_ambiguous'),
|
|
3038
|
+
replayed: true,
|
|
3039
|
+
receipt: receipt.source,
|
|
3040
|
+
})
|
|
3041
|
+
console.log(turnTiming(`receipt/${receipt.kind}/${receipt.source}`))
|
|
3042
|
+
}
|
|
3043
|
+
return
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
|
|
2922
3048
|
// The client-queued-prompt gate, used rather than reimplemented: it is the
|
|
2923
3049
|
// one place that orders state before epoch before target, and a second
|
|
2924
3050
|
// opinion here is how the store's own header says the two drifted apart.
|
|
@@ -3004,10 +3130,19 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3004
3130
|
|
|
3005
3131
|
// A failed pin is FATAL, per the registry's own caller contract: an unpinned
|
|
3006
3132
|
// binding can expire or be detached mid-turn, which defeats the lease.
|
|
3007
|
-
|
|
3133
|
+
// 6.78.0: the pin names its client turn (COS_PIN_BOOT_REAP) and opens the attempt
|
|
3134
|
+
// (COS_QUEUE_RECONCILE) in its own commit: on disk before any frame or spawn below.
|
|
3135
|
+
const pinExtras: PinExtras = {
|
|
3136
|
+
...(pinBootReapEnabled() ? { pinMeta: { clientTurnId } } : {}),
|
|
3137
|
+
...(queueReconcileEnabled() ? { attempt: { clientTurnId, mode: 'turn' as const } } : {}),
|
|
3138
|
+
}
|
|
3139
|
+
const pinned = Object.keys(pinExtras).length > 0
|
|
3140
|
+
? deps.bindings.pin!(bindingId, turnId, now, pinExtras)
|
|
3141
|
+
: deps.bindings.pin!(bindingId, turnId, now)
|
|
3008
3142
|
stage('pin')
|
|
3009
3143
|
if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
|
|
3010
3144
|
pinnedBindingId = bindingId
|
|
3145
|
+
attemptOpened = pinExtras.attempt !== undefined
|
|
3011
3146
|
|
|
3012
3147
|
// 6.53.0: a cancel that landed during the gate or the head read. Before the live hop,
|
|
3013
3148
|
// which cannot be recalled once it writes into the open session. Nothing was sent;
|
|
@@ -3051,7 +3186,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3051
3186
|
live = null
|
|
3052
3187
|
}
|
|
3053
3188
|
stage(`live(${live?.reason ?? 'threw'})`)
|
|
3189
|
+
// 6.78.0: a throw is a transport bug, not proof the frame never left.
|
|
3190
|
+
if (live === null) liveMay = true
|
|
3054
3191
|
if (live?.ok) {
|
|
3192
|
+
proven = true
|
|
3055
3193
|
// 6.53.3: delivered, so the turn runs in the open session now, and what stops it
|
|
3056
3194
|
// there is the desk halt marker. The turn itself WAS delivered and says so.
|
|
3057
3195
|
const latched = latchedHere()
|
|
@@ -3071,6 +3209,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3071
3209
|
return
|
|
3072
3210
|
}
|
|
3073
3211
|
if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
|
|
3212
|
+
liveMay = true
|
|
3074
3213
|
// 6.53.3: something MAY be in the session and the person asked for it to stop: arm
|
|
3075
3214
|
// the marker as for a landed turn, and settle this turn as cancelled, never as a
|
|
3076
3215
|
// retryable hold that the queue would send again after the cancel's two minutes.
|
|
@@ -3123,7 +3262,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3123
3262
|
live = null
|
|
3124
3263
|
}
|
|
3125
3264
|
stage(`codex-live(${live?.reason ?? 'threw'})`)
|
|
3265
|
+
// 6.78.0: a row MAY be in the Codex app's queue after a throw or an unverified write.
|
|
3266
|
+
if (live === null || live.reason === 'unverified') liveMay = true
|
|
3126
3267
|
if (live?.ok) {
|
|
3268
|
+
proven = true
|
|
3127
3269
|
// 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
|
|
3128
3270
|
const latched = latchedHere()
|
|
3129
3271
|
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt, busy: busyCodexHop })
|
|
@@ -3221,6 +3363,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3221
3363
|
|
|
3222
3364
|
let delivery: Delivery
|
|
3223
3365
|
deliveryAttempted = true
|
|
3366
|
+
spawnMay = true
|
|
3224
3367
|
try {
|
|
3225
3368
|
const result = await deliver({
|
|
3226
3369
|
turnId,
|
|
@@ -3261,6 +3404,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3261
3404
|
// occupancy check.
|
|
3262
3405
|
if (outcome !== 'recorded') return false
|
|
3263
3406
|
recordedPids.push({ pid, startMs })
|
|
3407
|
+
// 6.78.0 (P7 S6): the pin names this child, so after a crash the thread waits for
|
|
3408
|
+
// THIS process to be gone, not only for the window. Never fails the turn.
|
|
3409
|
+
if (pinBootReapEnabled() && typeof deps.bindings.notePinChild === 'function') {
|
|
3410
|
+
try { deps.bindings.notePinChild(bindingId, turnId, { pid, startMs }) } catch { /* the window still bounds the pin */ }
|
|
3411
|
+
}
|
|
3264
3412
|
return true
|
|
3265
3413
|
},
|
|
3266
3414
|
})
|
|
@@ -3310,7 +3458,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3310
3458
|
return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'cancelled' })
|
|
3311
3459
|
}
|
|
3312
3460
|
|
|
3313
|
-
if (delivery.kind === 'aborted')
|
|
3461
|
+
if (delivery.kind === 'aborted') {
|
|
3462
|
+
// The adapter's own proof that the child never opened the session.
|
|
3463
|
+
spawnMay = false
|
|
3464
|
+
return refuseTurn('provider_never_opened')
|
|
3465
|
+
}
|
|
3314
3466
|
|
|
3315
3467
|
if (delivery.kind === 'ambiguous') {
|
|
3316
3468
|
// Plan 4.6: the reservation is HELD, not released. A hand-crafted next
|
|
@@ -3356,6 +3508,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3356
3508
|
after = reread === null ? null : reread.digest
|
|
3357
3509
|
}
|
|
3358
3510
|
if (after !== null) guard.acknowledgeHead(bindingId, after)
|
|
3511
|
+
proven = true
|
|
3359
3512
|
|
|
3360
3513
|
// Phase 4 owns Message persistence. Nothing about the prompt or the reply is
|
|
3361
3514
|
// written, logged or echoed here; the terminal outcome is the whole result.
|
|
@@ -3411,7 +3564,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3411
3564
|
}
|
|
3412
3565
|
if (pinnedBindingId !== null) {
|
|
3413
3566
|
try {
|
|
3414
|
-
|
|
3567
|
+
// 6.78.0: the attempt's exit rides the release, in one commit.
|
|
3568
|
+
if (attemptOpened && clientTurnId !== null) {
|
|
3569
|
+
deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow, { attemptExit: { clientTurnId, exit: attemptExit() } })
|
|
3570
|
+
} else {
|
|
3571
|
+
deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow)
|
|
3572
|
+
}
|
|
3415
3573
|
} catch (error) {
|
|
3416
3574
|
console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
|
|
3417
3575
|
}
|
|
@@ -198,7 +198,7 @@ function commitWords(words: GlossaryWords, nextLog: CorrectionLogEntry[], previo
|
|
|
198
198
|
resetVocabEchoCache()
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
type Outcome = { status: number; body: unknown }
|
|
201
|
+
export type Outcome = { status: number; body: unknown }
|
|
202
202
|
|
|
203
203
|
function serialized(work: () => Outcome): Promise<Outcome> {
|
|
204
204
|
const run = putChain.catch(() => {}).then(work)
|
|
@@ -206,17 +206,27 @@ function serialized(work: () => Outcome): Promise<Outcome> {
|
|
|
206
206
|
return run
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Learn one correction inside the glossary write chain, exactly as POST
|
|
211
|
+
* /transcription-glossary/correction does (logged, undoable). Exported for the transcript
|
|
212
|
+
* cleanup's dictionary suggestions (6.77.0), so an accepted suggestion uses this writer and
|
|
213
|
+
* never a second copy of it.
|
|
214
|
+
*/
|
|
215
|
+
export function glossaryLearnCorrection(body: unknown): Promise<Outcome> {
|
|
216
|
+
return serialized(() => {
|
|
217
|
+
const previousLog = readCorrectionsLog()
|
|
218
|
+
const learned = learnCorrection(storedWords(), (body ?? {}) as { heard: unknown; written: unknown; source: unknown }, { id: randomUUID(), at: new Date().toISOString() })
|
|
219
|
+
if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
|
|
220
|
+
if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
|
|
221
|
+
commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
|
|
222
|
+
return { status: 200, body: { unchanged: false, entry: learned.entry, glossary: currentGlossary() } }
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
209
226
|
glossaryRouter.post('/transcription-glossary/correction', async (req, res) => {
|
|
210
227
|
const body = req.body ?? {}
|
|
211
228
|
try {
|
|
212
|
-
const outcome = await
|
|
213
|
-
const previousLog = readCorrectionsLog()
|
|
214
|
-
const learned = learnCorrection(storedWords(), body, { id: randomUUID(), at: new Date().toISOString() })
|
|
215
|
-
if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
|
|
216
|
-
if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
|
|
217
|
-
commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
|
|
218
|
-
return { status: 200, body: { unchanged: false, entry: learned.entry, glossary: currentGlossary() } }
|
|
219
|
-
})
|
|
229
|
+
const outcome = await glossaryLearnCorrection(body)
|
|
220
230
|
return res.status(outcome.status).json(outcome.body)
|
|
221
231
|
} catch (err) {
|
|
222
232
|
return res.status(500).json({ error: errMsg(err) })
|
|
@@ -250,20 +260,29 @@ glossaryRouter.post('/transcription-glossary/correction/undo', async (req, res)
|
|
|
250
260
|
// ── Single-item hand edits (6.73.12) ─────────────────────────────────────────
|
|
251
261
|
// One word or one correction at a time, inside the same chain as PUT and learn,
|
|
252
262
|
// so COS Control never has to read the whole map and write it back.
|
|
263
|
+
function runSingleEdit(edit: (words: GlossaryWords, raw: unknown) => ReturnType<typeof addVocabulary>, raw: unknown): Promise<Outcome> {
|
|
264
|
+
return serialized(() => {
|
|
265
|
+
const result = edit(storedWords(), raw)
|
|
266
|
+
if (!result.ok) return { status: result.status, body: { error: result.error } }
|
|
267
|
+
if (!result.unchanged) {
|
|
268
|
+
updateProfileFields({ vocabulary: result.words.vocabulary, whisper_corrections: JSON.stringify(result.words.corrections) })
|
|
269
|
+
resetDecoderCaches()
|
|
270
|
+
resetVocabEchoCache()
|
|
271
|
+
}
|
|
272
|
+
return { status: 200, body: { unchanged: result.unchanged, glossary: currentGlossary() } }
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Add one vocabulary word inside the glossary write chain (same as POST /transcription-glossary/vocabulary/add). */
|
|
277
|
+
export function glossaryAddVocabulary(term: unknown): Promise<Outcome> {
|
|
278
|
+
return runSingleEdit(addVocabulary, term)
|
|
279
|
+
}
|
|
280
|
+
|
|
253
281
|
function singleEdit(path: string, field: 'term' | 'heard', edit: (words: GlossaryWords, raw: unknown) => ReturnType<typeof addVocabulary>) {
|
|
254
282
|
glossaryRouter.post(path, async (req, res) => {
|
|
255
283
|
const raw = (req.body ?? {})[field]
|
|
256
284
|
try {
|
|
257
|
-
const outcome = await
|
|
258
|
-
const result = edit(storedWords(), raw)
|
|
259
|
-
if (!result.ok) return { status: result.status, body: { error: result.error } }
|
|
260
|
-
if (!result.unchanged) {
|
|
261
|
-
updateProfileFields({ vocabulary: result.words.vocabulary, whisper_corrections: JSON.stringify(result.words.corrections) })
|
|
262
|
-
resetDecoderCaches()
|
|
263
|
-
resetVocabEchoCache()
|
|
264
|
-
}
|
|
265
|
-
return { status: 200, body: { unchanged: result.unchanged, glossary: currentGlossary() } }
|
|
266
|
-
})
|
|
285
|
+
const outcome = await runSingleEdit(edit, raw)
|
|
267
286
|
return res.status(outcome.status).json(outcome.body)
|
|
268
287
|
} catch (err) {
|
|
269
288
|
return res.status(500).json({ error: errMsg(err) })
|
package/server/routes/health.ts
CHANGED
|
@@ -57,7 +57,8 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
|
57
57
|
// The flag lives beside the endpoints it describes, so it cannot drift from
|
|
58
58
|
// whether they are actually registered.
|
|
59
59
|
import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
|
|
60
|
-
import { durableQueryJobsCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
|
|
60
|
+
import { durableQueryJobsCapability, messagesSteerCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
|
|
61
|
+
import { claudeSteerSupportKnown } from '../lib/claude-cli-support.js'
|
|
61
62
|
import { jobTrailStats } from '../lib/job-trail.js'
|
|
62
63
|
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
63
64
|
import { getMorningBriefScheduler } from '../lib/morning-brief-runtime.js'
|
|
@@ -69,6 +70,7 @@ import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
|
|
|
69
70
|
import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
|
|
70
71
|
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
71
72
|
import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
|
|
73
|
+
import { transcriptCleanupAdvertised } from './transcript-cleanup.js'
|
|
72
74
|
import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
|
|
73
75
|
import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
|
|
74
76
|
import { getStrandedCaptures } from './transcribe-stream.js'
|
|
@@ -99,6 +101,7 @@ import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib
|
|
|
99
101
|
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
100
102
|
import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
|
|
101
103
|
import { codexLiveQueueEnabled } from '../lib/codex-live-queue.js'
|
|
104
|
+
import { bootResumeGate } from '../lib/boot-resume.js'
|
|
102
105
|
|
|
103
106
|
/**
|
|
104
107
|
* 6.49.0: which last hop a Continue takes, and how often each fell back.
|
|
@@ -497,6 +500,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
497
500
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
498
501
|
liveCues,
|
|
499
502
|
...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
|
|
503
|
+
// 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
|
|
504
|
+
transcriptCleanup: transcriptCleanupAdvertised(),
|
|
500
505
|
richMedia: {
|
|
501
506
|
text: true,
|
|
502
507
|
pdf: richMedia.pdf,
|
|
@@ -541,9 +546,23 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
541
546
|
enabled: durableJobs.enabled && messagesTrailEnabled(),
|
|
542
547
|
...jobTrailStats(),
|
|
543
548
|
},
|
|
549
|
+
// 6.78.0 (P7 S5): how much boot-resumed work is parked for crash-looping the server. A
|
|
550
|
+
// count only (health is public); the items and their Retry are behind the token at
|
|
551
|
+
// GET /api/maintenance/status.
|
|
552
|
+
crash_loop_parking: crashLoopParkingHealth(),
|
|
544
553
|
})
|
|
545
554
|
})
|
|
546
555
|
|
|
556
|
+
/** Never throws: health must not 500 because of the ledger. */
|
|
557
|
+
function crashLoopParkingHealth(): { enabled: boolean; parked: number } {
|
|
558
|
+
try {
|
|
559
|
+
const status = bootResumeGate().status()
|
|
560
|
+
return { enabled: status.enabled, parked: status.parked.length }
|
|
561
|
+
} catch {
|
|
562
|
+
return { enabled: false, parked: 0 }
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
547
566
|
// Stable app slots backed by Codex + Cursor live catalogs. Authenticated by
|
|
548
567
|
// global /api middleware; ?refresh=1 forces discovery.
|
|
549
568
|
healthRouter.get('/models', async (req, res) => {
|
|
@@ -644,6 +663,9 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
644
663
|
// THIS surface (not /api/health) and falls back to the activity log without it.
|
|
645
664
|
// False whenever jobs themselves are off or COS_MESSAGES_TRAIL=0.
|
|
646
665
|
trail: durableJobs.enabled && messagesTrailEnabled(),
|
|
666
|
+
// 6.78.0 Messages steer: present only with COS_MESSAGES_STEER on and a Claude CLI that
|
|
667
|
+
// takes stream-json input (probed once). Absent, the app shows today's UI.
|
|
668
|
+
...steerCapabilityField(durableJobs.enabled),
|
|
647
669
|
},
|
|
648
670
|
// 6.52.0: the session questions API and its client contract. A client polls
|
|
649
671
|
// `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
|
|
@@ -692,6 +714,12 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
692
714
|
},
|
|
693
715
|
})
|
|
694
716
|
})
|
|
717
|
+
/** 6.78.0: `{ steer }` for /api/models `durableQueryJobs`, or nothing at all. */
|
|
718
|
+
export function steerCapabilityField(jobsEnabled: boolean): { steer?: { protocolVersion: 1; providers: ['claude'] } } {
|
|
719
|
+
const steer = messagesSteerCapability({ jobsEnabled, cliSupported: claudeSteerSupportKnown() })
|
|
720
|
+
return steer ? { steer } : {}
|
|
721
|
+
}
|
|
722
|
+
|
|
695
723
|
// GET /api/cli-session — returns current CLI session ID for cross-device resume
|
|
696
724
|
healthRouter.get('/cli-session', (req, res) => {
|
|
697
725
|
const cosSessionId = req.query.sid as string | undefined
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// POST /live-cues/start { sessionId } -> { armed, sessionId, pipelinesUsed }
|
|
5
5
|
// POST /live-cues/stop { sessionId } -> { nudgesGenerated, nudges }
|
|
6
6
|
// GET /live-cues/status -> engine snapshot + capability
|
|
7
|
+
// GET /live-cues/v2/settings -> { enabled, source: env|setting|default, capability } (6.76.2)
|
|
8
|
+
// POST /live-cues/v2/settings { enabled } -> same shape; 409 { error: 'env_forced' } when env forces v2 on
|
|
7
9
|
|
|
8
10
|
import { Router } from 'express'
|
|
9
11
|
import { errMsg } from '../lib/utils.js'
|
|
@@ -14,7 +16,7 @@ import {
|
|
|
14
16
|
LiveCuesArmError,
|
|
15
17
|
} from '../lib/live-cues-engine.js'
|
|
16
18
|
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
17
|
-
import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled } from '../lib/live-cues-v2.js'
|
|
19
|
+
import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
|
|
18
20
|
|
|
19
21
|
const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
|
|
20
22
|
const CUE_ID = /^[A-Za-z0-9-]{8,64}$/
|
|
@@ -22,6 +24,10 @@ function bodySessionId(body: unknown): string {
|
|
|
22
24
|
const raw = (body as { sessionId?: unknown })?.sessionId
|
|
23
25
|
return typeof raw === 'string' ? raw.trim() : ''
|
|
24
26
|
}
|
|
27
|
+
/** v2 owns this meeting: v2 is on, or it armed the meeting before v2 was switched off at runtime. */
|
|
28
|
+
function v2Owns(sessionId: string): boolean {
|
|
29
|
+
return liveCuesV2Enabled() || (peekLiveCuesV2()?.knows(sessionId) ?? false)
|
|
30
|
+
}
|
|
25
31
|
|
|
26
32
|
export const liveCuesRouter = Router()
|
|
27
33
|
|
|
@@ -59,14 +65,14 @@ liveCuesRouter.post('/live-cues/stop', (req, res) => {
|
|
|
59
65
|
// Shape matches the existing client stop handler: it reads nudgesGenerated
|
|
60
66
|
// for its log line and hydrates meetingNudgeHistory from nudges when SSE
|
|
61
67
|
// missed events (the 200-event replay buffer is flooded by transcript_chunk).
|
|
62
|
-
if (
|
|
68
|
+
if (v2Owns(sessionId)) return res.json(liveCuesV2().end(sessionId))
|
|
63
69
|
return res.json(disarmLiveCues(sessionId))
|
|
64
70
|
})
|
|
65
71
|
|
|
66
72
|
liveCuesRouter.get('/live-cues/status', (req, res) => {
|
|
67
73
|
const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId.trim() : ''
|
|
68
74
|
// v2 with a sessionId answers the per-meeting shape; without one the v1 shape is unchanged (plus v2 capability).
|
|
69
|
-
if (sessionId &&
|
|
75
|
+
if (sessionId && v2Owns(sessionId)) {
|
|
70
76
|
if (!SESSION_ID.test(sessionId)) return res.status(400).json({ error: 'invalid sessionId', reason: 'invalid_session_id' })
|
|
71
77
|
return res.json(liveCuesV2().status(sessionId))
|
|
72
78
|
}
|
|
@@ -101,3 +107,26 @@ liveCuesRouter.post('/live-cues/feedback', (req, res) => {
|
|
|
101
107
|
if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
|
|
102
108
|
return res.json(liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined }))
|
|
103
109
|
})
|
|
110
|
+
|
|
111
|
+
// COS Control's live cues v2 toggle (6.76.2). Token-authenticated like every /api route. The setting lives in this server's
|
|
112
|
+
// data dir (live-cues-v2-settings.json, atomic write) and applies without a restart: /api/health and /api/models advertise
|
|
113
|
+
// liveCuesV2 only while it is on. capability.memory and capability.decisionProvider say where notes and the gate come from.
|
|
114
|
+
liveCuesRouter.get('/live-cues/v2/settings', (_req, res) => {
|
|
115
|
+
res.set('Cache-Control', 'private, no-store')
|
|
116
|
+
try { return res.json(liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
|
|
120
|
+
res.set('Cache-Control', 'private, no-store')
|
|
121
|
+
const body = req.body as { enabled?: unknown } | undefined
|
|
122
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean') {
|
|
123
|
+
return res.status(400).json({ error: 'invalid_request' })
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const result = setLiveCuesV2Enabled(body.enabled)
|
|
127
|
+
if (!result.ok) return res.status(409).json({ error: result.error })
|
|
128
|
+
return res.json(result.view)
|
|
129
|
+
} catch {
|
|
130
|
+
return res.status(500).json({ error: 'settings_write_failed' })
|
|
131
|
+
}
|
|
132
|
+
})
|
|
@@ -17,6 +17,7 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
17
17
|
import { getWhisperHealth } from '../lib/whisper-local.js'
|
|
18
18
|
import { getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './transcribe-stream.js'
|
|
19
19
|
import { getVideoUploadRegistry } from '../lib/video-upload-v2.js'
|
|
20
|
+
import { bootResumeGate, isBootResumeKind, resumeRetriedItem, type ParkedBootResume } from '../lib/boot-resume.js'
|
|
20
21
|
|
|
21
22
|
export const maintenanceRouter = Router()
|
|
22
23
|
|
|
@@ -69,6 +70,15 @@ function operationCredentials(req: Request): MaintenanceOperationCredentials {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** 6.78.0: boot-resumed work parked for crash-looping the server. Never throws. */
|
|
74
|
+
function parkedSnapshot(): ParkedBootResume[] {
|
|
75
|
+
try {
|
|
76
|
+
return bootResumeGate().status().parked
|
|
77
|
+
} catch {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
73
83
|
const jobs = getQueryJobRuntimeHealth()
|
|
74
84
|
const activeTranscriptionSessions = getActiveTranscriptionSessionCount()
|
|
@@ -133,6 +143,9 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
|
133
143
|
// block a binary downgrade, where an old server could erase V2 evidence.
|
|
134
144
|
safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown && !videoUploads.blocksRestart,
|
|
135
145
|
whisper: getWhisperHealth(),
|
|
146
|
+
// 6.78.0, additive (contractVersion unchanged): work a boot no longer resumes because
|
|
147
|
+
// it crashed the server on RESTART_LIMIT boots in a row. Retry with the route below.
|
|
148
|
+
parked: parkedSnapshot(),
|
|
136
149
|
}
|
|
137
150
|
}
|
|
138
151
|
|
|
@@ -155,6 +168,27 @@ maintenanceRouter.get('/maintenance/status', (req, res) => {
|
|
|
155
168
|
res.json(statusSnapshot(operationCredentials(req)))
|
|
156
169
|
})
|
|
157
170
|
|
|
171
|
+
// 6.78.0: the person's Retry of parked boot-resumed work (lib/boot-resume.ts). The API
|
|
172
|
+
// token, like GET /maintenance/status, and not loopback-only, so the phone and the lens can
|
|
173
|
+
// offer it; the /api mutation lease still refuses it during a drain. The count starts over;
|
|
174
|
+
// a meeting finalization is resumed again now, and a query job (id = its clientJobId) runs
|
|
175
|
+
// again when the app Retries it (a parked Ask is never resumable: it never started).
|
|
176
|
+
maintenanceRouter.post('/maintenance/parked/:kind/:id/retry', (req, res) => {
|
|
177
|
+
const { kind, id } = req.params
|
|
178
|
+
if (!isBootResumeKind(kind)) {
|
|
179
|
+
res.status(400).json({ error: 'unknown_parked_kind', retryable: false })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
const gate = bootResumeGate()
|
|
183
|
+
if (!gate.isParked(kind, id)) {
|
|
184
|
+
res.status(404).json({ error: 'not_parked', retryable: false })
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
const entry = gate.retry(kind, id)
|
|
188
|
+
const resumed = resumeRetriedItem(kind, id)
|
|
189
|
+
res.json({ retried: true, resumed, entry })
|
|
190
|
+
})
|
|
191
|
+
|
|
158
192
|
maintenanceRouter.post('/maintenance/drain', (req, res) => {
|
|
159
193
|
if (!requireLoopback(req, res)) return
|
|
160
194
|
try {
|