@gotcos/glasses-server 6.77.0 → 6.78.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.
Files changed (34) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +96 -10
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/env-switch.ts +17 -0
  13. package/server/lib/managed-restart.ts +8 -0
  14. package/server/lib/native-head.ts +65 -6
  15. package/server/lib/provider-actions.ts +18 -0
  16. package/server/lib/query-job-coordinator.ts +300 -0
  17. package/server/lib/query-job-feature.ts +39 -0
  18. package/server/lib/query-job-resume.ts +269 -0
  19. package/server/lib/query-job-runtime.ts +23 -3
  20. package/server/lib/query-job-store.ts +516 -7
  21. package/server/lib/query-job-types.ts +210 -1
  22. package/server/lib/queue-turn-evidence.ts +213 -0
  23. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  24. package/server/lib/thread-turn-queue-store.ts +13 -4
  25. package/server/lib/thread-turn-queue.ts +151 -1
  26. package/server/routes/agent-session-bindings.ts +169 -11
  27. package/server/routes/health.ts +26 -1
  28. package/server/routes/maintenance.ts +34 -0
  29. package/server/routes/meeting.ts +27 -9
  30. package/server/routes/query-jobs.ts +107 -13
  31. package/server/routes/recovery.ts +4 -0
  32. package/server/routes/thread-turn-queue.ts +371 -10
  33. package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +0 -170
  34. package/server/scripts/transcript-cleanup-mutation-gate.mjs +0 -219
@@ -181,6 +181,7 @@ import {
181
181
  type MeetingFinalizationJob,
182
182
  } from '../lib/meeting-finalization-jobs.js'
183
183
  import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
184
+ import { bootResumeGate, type BootResumeGate } from '../lib/boot-resume.js'
184
185
  import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
185
186
  import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
186
187
  import { sweepTranscriptCleanupData } from '../lib/transcript-cleanup-screen.js'
@@ -312,6 +313,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
312
313
  let current = runtime.finalizationJobs.get(job.sessionId) ?? job
313
314
  if (canonicalFinalizationIsComplete(current)) {
314
315
  runtime.finalizationJobs.remove(current.sessionId)
316
+ bootResumeGate().forget('meeting_finalization', current.sessionId)
315
317
  return
316
318
  }
317
319
  if (current.phase === 'capture_pending') {
@@ -415,6 +417,8 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
415
417
  // capture, and never throws back at the finalization path.
416
418
  if (!MAC_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
417
419
  runtime.finalizationJobs.remove(current.sessionId)
420
+ // 6.78.0: finished, so its crash-loop count (lib/boot-resume.ts) is done.
421
+ bootResumeGate().forget('meeting_finalization', current.sessionId)
418
422
  finalizationRetryCounts.delete(key)
419
423
  }).catch(error => {
420
424
  const current = runtime.finalizationJobs.get(job.sessionId) ?? job
@@ -457,16 +461,28 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
457
461
  }
458
462
 
459
463
  /** Boot hook: replay post-save HQ/operations work whose response already
460
- * succeeded before a prior process exited. Safe to call more than once. */
461
- export function resumeMeetingFinalizationJobs(): void {
462
- const finalizationJobs = new MeetingFinalizationJobStore()
464
+ * succeeded before a prior process exited. Safe to call more than once.
465
+ * 6.78.0: `sessionId` resumes one job (the maintenance Retry); the rest are seams for tests. */
466
+ export function resumeMeetingFinalizationJobs(options: {
467
+ sessionId?: string
468
+ gate?: BootResumeGate
469
+ finalizationJobs?: MeetingFinalizationJobStore
470
+ schedule?: (job: MeetingFinalizationJob) => void
471
+ } = {}): void {
472
+ const finalizationJobs = options.finalizationJobs ?? new MeetingFinalizationJobStore()
463
473
  finalizationJobs.reconcileCanonicalSidecars()
474
+ const gate = options.gate ?? bootResumeGate()
475
+ const schedule = options.schedule ?? ((job: MeetingFinalizationJob) => scheduleFinalizationJob(job, {
476
+ finalizationJobs,
477
+ runBatch: runMeetingBatchPipeline,
478
+ scheduleBackground: task => { void task },
479
+ }))
464
480
  for (const job of finalizationJobs.list()) {
465
- scheduleFinalizationJob(job, {
466
- finalizationJobs,
467
- runBatch: runMeetingBatchPipeline,
468
- scheduleBackground: task => { void task },
469
- })
481
+ if (options.sessionId !== undefined && job.sessionId !== options.sessionId) continue
482
+ // 6.78.0: counted (durably) BEFORE it runs, so a job that kills the process is parked
483
+ // after RESTART_LIMIT crashed boots. A parked job is skipped and stays in its store.
484
+ if (!gate.admit('meeting_finalization', job.sessionId, job.phase)) continue
485
+ schedule(job)
470
486
  }
471
487
  }
472
488
 
@@ -808,7 +824,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
808
824
  if (alreadySaved) {
809
825
  finalizationJobs.reconcileCanonicalSidecars()
810
826
  const pendingJob = finalizationJobs.get(sessionId)
811
- if (pendingJob) scheduleFinalizationJob(pendingJob, {
827
+ // 6.78.0: a replayed save never re-runs a job parked for crash-looping the server.
828
+ if (pendingJob && !bootResumeGate().isParked('meeting_finalization', sessionId)) scheduleFinalizationJob(pendingJob, {
812
829
  transcriptCleanup,
813
830
  finalizationJobs,
814
831
  runBatch,
@@ -973,6 +990,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
973
990
  markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
974
991
  if (!readMacRecording(sessionId)) triggerMeetingMergeRun('g2_finalized')
975
992
  finalizationJobs.remove(sessionId)
993
+ bootResumeGate().forget('meeting_finalization', sessionId)
976
994
  finalizationJob = null
977
995
  }
978
996
 
@@ -1,8 +1,10 @@
1
1
  import { Router, type Request, type Response } from 'express'
2
- import { durableQueryJobsEnabled } from '../lib/query-job-feature.js'
2
+ import { durableQueryJobsEnabled, messagesSteerEnabled } from '../lib/query-job-feature.js'
3
3
  import {
4
4
  QueryJobCoordinator,
5
5
  QueryJobCoordinatorError,
6
+ QueryJobSteerLimitError,
7
+ QueryJobSteerUnknownError,
6
8
  queryJobErrorCode,
7
9
  } from '../lib/query-job-coordinator.js'
8
10
  import {
@@ -15,13 +17,16 @@ import {
15
17
  QueryJobNotTerminalError,
16
18
  QueryJobPersistenceError,
17
19
  QueryJobProviderOrphanFenceError,
20
+ QueryJobSteerConflictError,
18
21
  QueryJobStoreError,
22
+ type QueryJobSteerFrame,
19
23
  type QueryJobTrailFrame,
20
24
  } from '../lib/query-job-store.js'
21
25
  import {
22
26
  isTerminalQueryJobStatus,
23
27
  normalizeQueryJobError,
24
28
  parsePositiveInteger,
29
+ steerReadState,
25
30
  QueryJobValidationError,
26
31
  type QueryJobEvent,
27
32
  type QueryJobSnapshot,
@@ -33,6 +38,8 @@ export interface QueryJobsRouterOptions {
33
38
  enabled?: () => boolean
34
39
  heartbeatMs?: number
35
40
  prepareAdmission?: (raw: unknown) => unknown | Promise<unknown>
41
+ /** 6.78.0 Messages steer. Defaults to COS_MESSAGES_STEER (off). */
42
+ steerEnabled?: () => boolean
36
43
  }
37
44
 
38
45
  interface WireError {
@@ -104,13 +111,50 @@ function wantsTrail(req: Request): boolean {
104
111
  return req.query.trail === '1'
105
112
  }
106
113
 
107
- function forClient(job: QueryJobSnapshot, trail: boolean): QueryJobSnapshot {
114
+ function forClient(job: QueryJobSnapshot, trail: boolean, steer = false): QueryJobSnapshot {
115
+ return steerView(trailView(job, trail), steer)
116
+ }
117
+
118
+ function trailView(job: QueryJobSnapshot, trail: boolean): QueryJobSnapshot {
108
119
  if (trail || !('trail' in job)) return job
109
120
  const { trail: _trail, ...rest } = job
110
121
  return rest as QueryJobSnapshot
111
122
  }
112
123
 
113
- function snapshotEvent(job: QueryJobSnapshot, reason: string, trail: boolean): Record<string, unknown> {
124
+ /**
125
+ * 6.78.0: STEERS ARE OPT-IN, like the trail. Without `?steer=1` a snapshot carries no `steers`
126
+ * (exactly the 6.77.0 shape); with it, each steer reads as a client should see it now
127
+ * (`not_applied` for one an ended job did not get to apply).
128
+ */
129
+ function wantsSteer(req: Request): boolean {
130
+ return req.query.steer === '1'
131
+ }
132
+
133
+ function steerView(job: QueryJobSnapshot, steer: boolean): QueryJobSnapshot {
134
+ if (!('steers' in job)) return job
135
+ const { steers, ...rest } = job
136
+ if (!steer || !steers) return rest as QueryJobSnapshot
137
+ return { ...rest, steers: steers.map(item => ({ ...item, state: steerReadState(item.state, job.status) })) } as QueryJobSnapshot
138
+ }
139
+
140
+ /** A steer frame on the wire (contract messages-steer.json `sse_frame`): no `id:` line, no eventSeq. */
141
+ function writeSteer(res: Response, frame: Pick<QueryJobSteerFrame, 'steerId' | 'state' | 'at'>): void {
142
+ res.write(`event: steer\ndata: ${JSON.stringify({ steerId: frame.steerId, state: frame.state, at: frame.at })}\n\n`)
143
+ }
144
+
145
+ /** The steer routes' error bodies (contract messages-steer.json `errors`). */
146
+ function steerWireError(error: unknown): { status: number; body: WireError } {
147
+ if (error instanceof QueryJobSteerConflictError) return { status: 409, body: { error: { code: error.code, message: error.message } } }
148
+ if (error instanceof QueryJobSteerLimitError) return { status: 429, body: { error: { code: error.code, message: error.message, retryable: false } } }
149
+ if (error instanceof QueryJobSteerUnknownError) return { status: 404, body: { error: { code: error.code, message: error.message } } }
150
+ if (error instanceof QueryJobNotFoundError) return { status: 404, body: { error: { code: 'query_job_not_found', message: 'Query job not found' } } }
151
+ if (error instanceof QueryJobGenerationMismatchError) return { status: 409, body: { error: { code: 'query_job_generation_mismatch', message: 'Generation mismatch' } } }
152
+ return wireError(error)
153
+ }
154
+
155
+ const STEER_DISABLED: WireError = { error: { code: 'steer_disabled', message: 'Steering is off on this Mac.' } }
156
+
157
+ function snapshotEvent(job: QueryJobSnapshot, reason: string, trail: boolean, steer = false): Record<string, unknown> {
114
158
  return {
115
159
  type: 'snapshot',
116
160
  eventSeq: job.eventSeq,
@@ -119,7 +163,7 @@ function snapshotEvent(job: QueryJobSnapshot, reason: string, trail: boolean): R
119
163
  generation: job.generation,
120
164
  status: job.status,
121
165
  at: job.updatedAt,
122
- data: { reason, job: forClient(job, trail) },
166
+ data: { reason, job: forClient(job, trail, steer) },
123
167
  }
124
168
  }
125
169
 
@@ -129,6 +173,9 @@ export function createQueryJobsRouter(
129
173
  ): Router {
130
174
  const router = Router()
131
175
  const enabled = options.enabled ?? durableQueryJobsEnabled
176
+ const steerEnabled = options.steerEnabled ?? messagesSteerEnabled
177
+ // Steers reach a client only when it asks AND the switch is on.
178
+ const steerRead = (req: Request) => wantsSteer(req) && steerEnabled()
132
179
  const heartbeatMs = Math.max(1_000, options.heartbeatMs ?? 15_000)
133
180
 
134
181
  router.post('/query-jobs', async (req, res) => {
@@ -143,7 +190,7 @@ export function createQueryJobsRouter(
143
190
  try {
144
191
  const prepared = options.prepareAdmission ? await options.prepareAdmission(req.body) : req.body
145
192
  const admission = await coordinator.submit(prepared)
146
- return res.status(202).json({ job: forClient(admission.job, wantsTrail(req)) })
193
+ return res.status(202).json({ job: forClient(admission.job, wantsTrail(req), steerRead(req)) })
147
194
  } catch (error) {
148
195
  const wire = wireError(error)
149
196
  return res.status(wire.status).json(wire.body)
@@ -159,19 +206,32 @@ export function createQueryJobsRouter(
159
206
  const generation = requiredGeneration(req.query.generation)
160
207
  const job = await coordinator.getByClientGeneration(clientJobId, generation)
161
208
  if (!job) throw new QueryJobNotFoundError(`${clientJobId}:${generation}`)
162
- return res.json({ job: forClient(job, wantsTrail(req)) })
209
+ return res.json({ job: forClient(job, wantsTrail(req), steerRead(req)) })
163
210
  } catch (error) {
164
211
  const wire = wireError(error)
165
212
  return res.status(wire.status).json(wire.body)
166
213
  }
167
214
  })
168
215
 
216
+ // 6.78.0: a steer by its id and lineage, from the newest generation that holds it. Ahead of
217
+ // the dynamic job routes, like by-client.
218
+ router.get('/query-jobs/steers/:steerId', async (req, res) => {
219
+ if (!enabled() || !steerEnabled()) return res.status(404).json(STEER_DISABLED)
220
+ try {
221
+ const clientJobId = validClientJobId(req.query.clientJobId)
222
+ return res.json(await coordinator.steerStatus(clientJobId, req.params.steerId))
223
+ } catch (error) {
224
+ const wire = steerWireError(error)
225
+ return res.status(wire.status).json(wire.body)
226
+ }
227
+ })
228
+
169
229
  router.get('/query-jobs/:jobId', async (req, res) => {
170
230
  try {
171
231
  const jobId = validJobId(req.params.jobId)
172
232
  const generation = requiredGeneration(req.query.generation)
173
233
  const job = await coordinator.getSnapshot(jobId, generation)
174
- return res.json({ job: forClient(job, wantsTrail(req)) })
234
+ return res.json({ job: forClient(job, wantsTrail(req), steerRead(req)) })
175
235
  } catch (error) {
176
236
  const wire = wireError(error)
177
237
  return res.status(wire.status).json(wire.body)
@@ -188,6 +248,9 @@ export function createQueryJobsRouter(
188
248
  const pendingLive: QueryJobEvent[] = []
189
249
  const pendingTrail: QueryJobTrailFrame[] = []
190
250
  const trail = wantsTrail(req)
251
+ // 6.78.0: `?steer=1` adds `steer` frames; without it the stream is the 6.77.0 one.
252
+ const steer = steerRead(req)
253
+ const pendingSteer: QueryJobSteerFrame[] = []
191
254
  const writeTrail = (frame: QueryJobTrailFrame) => {
192
255
  if (closed || frame.trailSeq <= sentTrailSeq) return
193
256
  sentTrailSeq = frame.trailSeq
@@ -243,8 +306,22 @@ export function createQueryJobsRouter(
243
306
  writeSse(res, event.type, event as unknown as Record<string, unknown>)
244
307
  if (isTerminalQueryJobStatus(event.status)) finish()
245
308
  }
309
+ const steerSubscription = steer
310
+ ? {
311
+ listener: (frame: QueryJobSteerFrame) => {
312
+ if (closed) return
313
+ if (!streamReady) {
314
+ pendingSteer.push(frame)
315
+ return
316
+ }
317
+ writeSteer(res, frame)
318
+ },
319
+ }
320
+ : undefined
246
321
  // Without `?trail=1` the call is exactly the 6.51.0 one.
247
- const subscription = trailSubscription
322
+ const subscription = steerSubscription
323
+ ? await coordinator.subscribe(jobId, generation, after, onEvent, trailSubscription, steerSubscription)
324
+ : trailSubscription
248
325
  ? await coordinator.subscribe(jobId, generation, after, onEvent, trailSubscription)
249
326
  : await coordinator.subscribe(jobId, generation, after, onEvent)
250
327
  unsubscribe = subscription.unsubscribe
@@ -263,7 +340,10 @@ export function createQueryJobsRouter(
263
340
  res.flushHeaders()
264
341
  res.write(': keepalive\n\n')
265
342
 
266
- const { replay, trailReplay } = subscription
343
+ const { replay, trailReplay, steerReplay } = subscription
344
+ // 6.78.0: every steer as it reads now, before any job event (a state only moves forward,
345
+ // so a client that saw some of these simply sees them again).
346
+ if (steer && steerReplay) for (const item of steerReplay) writeSteer(res, item)
267
347
  // The trail first: every entry precedes the terminal event that may end this stream.
268
348
  if (trail && trailReplay) {
269
349
  if (trailReplay.gap) {
@@ -289,7 +369,7 @@ export function createQueryJobsRouter(
289
369
  }
290
370
  }
291
371
  if (replay.gap) {
292
- writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap', trail))
372
+ writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap', trail, steer))
293
373
  sentSeq = replay.snapshot.eventSeq
294
374
  } else {
295
375
  for (const event of replay.events) {
@@ -301,7 +381,7 @@ export function createQueryJobsRouter(
301
381
 
302
382
  const replayTerminal = replay.events.some(event => isTerminalQueryJobStatus(event.status))
303
383
  if (isTerminalQueryJobStatus(replay.snapshot.status) && !replayTerminal && !replay.gap) {
304
- writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot', trail))
384
+ writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot', trail, steer))
305
385
  sentSeq = replay.snapshot.eventSeq
306
386
  }
307
387
  if (isTerminalQueryJobStatus(replay.snapshot.status)) {
@@ -312,6 +392,8 @@ export function createQueryJobsRouter(
312
392
  streamReady = true
313
393
  for (const frame of pendingTrail) writeTrail(frame)
314
394
  pendingTrail.length = 0
395
+ for (const frame of pendingSteer) writeSteer(res, frame)
396
+ pendingSteer.length = 0
315
397
  for (const event of pendingLive) {
316
398
  if (closed || event.eventSeq <= sentSeq) continue
317
399
  sentSeq = event.eventSeq
@@ -344,7 +426,7 @@ export function createQueryJobsRouter(
344
426
  const jobId = validJobId(req.params.jobId)
345
427
  const generation = requiredGeneration(req.body?.generation)
346
428
  const { job } = await coordinator.cancel(jobId, generation)
347
- return res.json({ job: forClient(job, wantsTrail(req)) })
429
+ return res.json({ job: forClient(job, wantsTrail(req), steerRead(req)) })
348
430
  } catch (error) {
349
431
  const wire = wireError(error)
350
432
  return res.status(wire.status).json(wire.body)
@@ -356,12 +438,24 @@ export function createQueryJobsRouter(
356
438
  const jobId = validJobId(req.params.jobId)
357
439
  const generation = requiredGeneration(req.body?.generation)
358
440
  const job = await coordinator.acknowledge(jobId, generation)
359
- return res.json({ job: forClient(job, wantsTrail(req)) })
441
+ return res.json({ job: forClient(job, wantsTrail(req), steerRead(req)) })
360
442
  } catch (error) {
361
443
  const wire = wireError(error)
362
444
  return res.status(wire.status).json(wire.body)
363
445
  }
364
446
  })
365
447
 
448
+ // 6.78.0 Messages steer (contract messages-steer.json). 404 steer_disabled when off.
449
+ router.post('/query-jobs/:jobId/steer', async (req, res) => {
450
+ if (!enabled() || !steerEnabled()) return res.status(404).json(STEER_DISABLED)
451
+ try {
452
+ const jobId = validJobId(req.params.jobId)
453
+ return res.json(await coordinator.steer(jobId, req.body))
454
+ } catch (error) {
455
+ const wire = steerWireError(error)
456
+ return res.status(wire.status).json(wire.body)
457
+ }
458
+ })
459
+
366
460
  return router
367
461
  }
@@ -2,6 +2,7 @@ import { Router } from 'express'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
4
  import { atomicWriteFileSync } from '../lib/atomic-fs.js'
5
+ import { bootResumeGate } from '../lib/boot-resume.js'
5
6
  import { scheduleManagedServerRestart } from '../lib/managed-restart.js'
6
7
  import { isManagedRuntime } from '../lib/managed-runtime.js'
7
8
  import { acquireMaintenance, getRecoveryActivityStatus } from '../lib/recovery-activity.js'
@@ -72,6 +73,9 @@ recoveryRouter.post('/recovery/server/restart', (_req, res) => {
72
73
  // SuccessfulExit:false, so a clean exit leaves the service STOPPED and the
73
74
  // phone shows "Restart requested; reconnect is taking longer than expected."
74
75
  scheduleManagedServerRestart({
76
+ // 6.78.0 (P7 S4): a requested restart is a clean exit, whichever way the process ends
77
+ // (SIGTERM from kickstart -k, or the 2 s exit(1) fallback): it never counts as a crash.
78
+ beforeRestart: () => bootResumeGate().markCleanShutdown(),
75
79
  onError: (error) => {
76
80
  gate.release()
77
81
  console.error('[recovery] Failed to schedule managed server restart:', error)