@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.
Files changed (52) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +17 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +98 -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/cos-bridge-notes.ts +73 -0
  13. package/server/lib/decision-provider.ts +9 -0
  14. package/server/lib/env-switch.ts +17 -0
  15. package/server/lib/fuzzy-correct.ts +77 -49
  16. package/server/lib/live-cos-ask.ts +41 -7
  17. package/server/lib/live-cues-v2-notes.ts +152 -0
  18. package/server/lib/live-cues-v2-settings.ts +63 -0
  19. package/server/lib/live-cues-v2.ts +198 -38
  20. package/server/lib/managed-restart.ts +8 -0
  21. package/server/lib/provider-actions.ts +18 -0
  22. package/server/lib/query-job-coordinator.ts +300 -0
  23. package/server/lib/query-job-feature.ts +39 -0
  24. package/server/lib/query-job-resume.ts +269 -0
  25. package/server/lib/query-job-runtime.ts +23 -3
  26. package/server/lib/query-job-store.ts +516 -7
  27. package/server/lib/query-job-types.ts +210 -1
  28. package/server/lib/queue-turn-evidence.ts +213 -0
  29. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  30. package/server/lib/thread-turn-queue-store.ts +13 -4
  31. package/server/lib/thread-turn-queue.ts +151 -1
  32. package/server/lib/transcript-cleanup-context.ts +234 -0
  33. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  34. package/server/lib/transcript-cleanup-model.ts +217 -0
  35. package/server/lib/transcript-cleanup-screen.ts +249 -0
  36. package/server/lib/transcript-cleanup-spans.ts +358 -0
  37. package/server/lib/transcript-cleanup-state.ts +201 -0
  38. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  39. package/server/lib/transcript-cleanup-text.ts +200 -0
  40. package/server/lib/transcript-cleanup.ts +427 -0
  41. package/server/routes/agent-session-bindings.ts +169 -11
  42. package/server/routes/glossary.ts +38 -19
  43. package/server/routes/health.ts +29 -1
  44. package/server/routes/live-cues.ts +32 -3
  45. package/server/routes/maintenance.ts +34 -0
  46. package/server/routes/meeting.ts +92 -9
  47. package/server/routes/query-jobs.ts +107 -13
  48. package/server/routes/recovery.ts +4 -0
  49. package/server/routes/thread-turn-queue.ts +371 -10
  50. package/server/routes/transcribe.ts +3 -0
  51. package/server/routes/transcript-cleanup.ts +237 -0
  52. package/shared/live-cues-v2.ts +9 -4
@@ -181,7 +181,14 @@ 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'
186
+ import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
187
+ import { sweepTranscriptCleanupData } from '../lib/transcript-cleanup-screen.js'
188
+ import type { TranscriptCleanupRouteDeps } from './transcript-cleanup.js'
189
+
190
+ /** 6.77.0: the post-save transcript cleanup. Injectable so tests never reach a model. */
191
+ export type TranscriptCleanupRunner = (input: { sessionId: string; meetingPath: string; sidecarPath: string }) => Promise<CleanupOutcome>
185
192
 
186
193
  /** 6.61.0: the save-time Nemotron pass. Injectable so tests never spawn the CLI. */
187
194
  export type FinalDiarize = (options: Pick<FinalPassOptions, 'meetingPath' | 'sidecarPath' | 'audioDir'>) => Promise<FinalPassOutcome>
@@ -243,6 +250,8 @@ export interface MeetingRouteDependencies {
243
250
  finalDiarize?: FinalDiarize
244
251
  /** Control's stop requests for live G2 meetings (lib/meeting-stop-requests.ts). */
245
252
  stopRequests?: StopRequestStore
253
+ /** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
254
+ transcriptCleanup?: TranscriptCleanupRunner
246
255
  }
247
256
 
248
257
  const activeFinalizationJobs = new Set<string>()
@@ -254,6 +263,21 @@ interface FinalizationRuntime {
254
263
  scheduleBackground: (task: Promise<void>) => void
255
264
  sessions?: MeetingSessionSource
256
265
  finalDiarize?: FinalDiarize
266
+ transcriptCleanup?: TranscriptCleanupRunner
267
+ }
268
+
269
+ /**
270
+ * The cleanup as finalization runs it: a meeting whose session is somehow live again is
271
+ * skipped, and nothing it does can fail the finalization (it never throws; this catch is
272
+ * the last line of defence should that ever change).
273
+ */
274
+ async function runCleanupStage(runner: TranscriptCleanupRunner | undefined, input: { sessionId: string; meetingPath: string; sidecarPath: string }, sessions?: MeetingSessionSource): Promise<void> {
275
+ const run = runner ?? (cleanupInput => runTranscriptCleanup(cleanupInput, { sessionLive: id => (sessions?.getState ?? defaultSessionState)(id) === 'active' }))
276
+ try {
277
+ await run(input)
278
+ } catch (error) {
279
+ console.warn(`[meeting/save] transcript cleanup threw; the saved transcript is unchanged: ${error instanceof Error ? error.name : 'error'}`)
280
+ }
257
281
  }
258
282
 
259
283
  function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
@@ -289,6 +313,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
289
313
  let current = runtime.finalizationJobs.get(job.sessionId) ?? job
290
314
  if (canonicalFinalizationIsComplete(current)) {
291
315
  runtime.finalizationJobs.remove(current.sessionId)
316
+ bootResumeGate().forget('meeting_finalization', current.sessionId)
292
317
  return
293
318
  }
294
319
  if (current.phase === 'capture_pending') {
@@ -372,6 +397,11 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
372
397
  markCanonicalFinalizationState(current.sidecarPath, 'ops_pending', current.claimPending)
373
398
  }
374
399
 
400
+ // 6.77.0: the transcript is final here (HQ text and the Nemotron relabel are written),
401
+ // and nothing downstream has read it yet: COS operations imports, and the standalone
402
+ // summary describes, the cleaned text. Off unless Miles turns it on.
403
+ await runCleanupStage(runtime.transcriptCleanup, { sessionId: current.sessionId, meetingPath: current.meetingPath, sidecarPath: current.sidecarPath }, runtime.sessions)
404
+
375
405
  if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId)) {
376
406
  await handoffMeetingToOperations(current.meetingPath)
377
407
  } else {
@@ -387,6 +417,8 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
387
417
  // capture, and never throws back at the finalization path.
388
418
  if (!MAC_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
389
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)
390
422
  finalizationRetryCounts.delete(key)
391
423
  }).catch(error => {
392
424
  const current = runtime.finalizationJobs.get(job.sessionId) ?? job
@@ -429,16 +461,28 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
429
461
  }
430
462
 
431
463
  /** Boot hook: replay post-save HQ/operations work whose response already
432
- * succeeded before a prior process exited. Safe to call more than once. */
433
- export function resumeMeetingFinalizationJobs(): void {
434
- 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()
435
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
+ }))
436
480
  for (const job of finalizationJobs.list()) {
437
- scheduleFinalizationJob(job, {
438
- finalizationJobs,
439
- runBatch: runMeetingBatchPipeline,
440
- scheduleBackground: task => { void task },
441
- })
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)
442
486
  }
443
487
  }
444
488
 
@@ -558,6 +602,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
558
602
  const emit = deps.emit ?? emitDisplay
559
603
  const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
560
604
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
605
+ const transcriptCleanup = deps.transcriptCleanup
561
606
  const router = Router()
562
607
  const savingSessions = new Set<string>()
563
608
  const stopRequests = deps.stopRequests ?? createStopRequestStore()
@@ -779,7 +824,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
779
824
  if (alreadySaved) {
780
825
  finalizationJobs.reconcileCanonicalSidecars()
781
826
  const pendingJob = finalizationJobs.get(sessionId)
782
- 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, {
829
+ transcriptCleanup,
783
830
  finalizationJobs,
784
831
  runBatch,
785
832
  scheduleBackground,
@@ -943,6 +990,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
943
990
  markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
944
991
  if (!readMacRecording(sessionId)) triggerMeetingMergeRun('g2_finalized')
945
992
  finalizationJobs.remove(sessionId)
993
+ bootResumeGate().forget('meeting_finalization', sessionId)
946
994
  finalizationJob = null
947
995
  }
948
996
 
@@ -956,6 +1004,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
956
1004
  scheduleBackground,
957
1005
  sessions,
958
1006
  finalDiarize,
1007
+ transcriptCleanup,
959
1008
  })
960
1009
  }
961
1010
  } catch (error) {
@@ -2438,6 +2487,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2438
2487
  },
2439
2488
  })
2440
2489
  } catch { /* display is best-effort */ }
2490
+ await runCleanupStage(transcriptCleanup, { sessionId, meetingPath: saved.filepath, sidecarPath: saved.sidecarPath }, sessions)
2441
2491
  if (cosOpsPipelineConfigured() && !macCapture) {
2442
2492
  await handoffMeetingToOperations(saved.filepath)
2443
2493
  } else {
@@ -2620,3 +2670,36 @@ async function finalizeBatch(options: {
2620
2670
  }
2621
2671
 
2622
2672
  export const meetingRouter = createMeetingRouter()
2673
+
2674
+ /** 6.77.0: what the transcript cleanup routes need from the meeting layer. */
2675
+ export function transcriptCleanupRouteDeps(store: MeetingStore = getMeetingStore()): TranscriptCleanupRouteDeps {
2676
+ return {
2677
+ sessionState: id => defaultSessionState(id),
2678
+ sessionStartedAt: id => getSessionStartTime(id),
2679
+ findMeeting: id => {
2680
+ const meeting = store.findBySessionId(id)
2681
+ return meeting ? { meetingPath: meeting.filepath, sidecarPath: meeting.sidecarPath } : null
2682
+ },
2683
+ }
2684
+ }
2685
+
2686
+ /**
2687
+ * Boot: delete cleanup data (screen context and ledgers) whose meeting is gone, and screen
2688
+ * text past its retention. Deferred and unref'd, so it never delays startup or a restart.
2689
+ */
2690
+ export function scheduleTranscriptCleanupSweep(delayMs = 90_000): void {
2691
+ const timer = setTimeout(() => {
2692
+ try {
2693
+ const store = getMeetingStore()
2694
+ const jobs = new MeetingFinalizationJobStore()
2695
+ const result = sweepTranscriptCleanupData({
2696
+ now: Date.now(),
2697
+ known: id => defaultSessionState(id) !== 'missing' || Boolean(jobs.get(id)) || store.findBySessionId(id) !== null,
2698
+ })
2699
+ if (result.screenDeleted || result.ledgersDeleted) {
2700
+ console.log(`[transcript-cleanup] swept ${result.screenDeleted} screen and ${result.ledgersDeleted} ledger files`)
2701
+ }
2702
+ } catch { /* the next boot sweeps again */ }
2703
+ }, delayMs)
2704
+ timer.unref()
2705
+ }
@@ -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)