@gotcos/glasses-server 6.77.0 → 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 (33) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +16 -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 +92 -9
  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/provider-actions.ts +18 -0
  15. package/server/lib/query-job-coordinator.ts +300 -0
  16. package/server/lib/query-job-feature.ts +39 -0
  17. package/server/lib/query-job-resume.ts +269 -0
  18. package/server/lib/query-job-runtime.ts +23 -3
  19. package/server/lib/query-job-store.ts +516 -7
  20. package/server/lib/query-job-types.ts +210 -1
  21. package/server/lib/queue-turn-evidence.ts +213 -0
  22. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  23. package/server/lib/thread-turn-queue-store.ts +13 -4
  24. package/server/lib/thread-turn-queue.ts +151 -1
  25. package/server/routes/agent-session-bindings.ts +169 -11
  26. package/server/routes/health.ts +26 -1
  27. package/server/routes/maintenance.ts +34 -0
  28. package/server/routes/meeting.ts +27 -9
  29. package/server/routes/query-jobs.ts +107 -13
  30. package/server/routes/recovery.ts +4 -0
  31. package/server/routes/thread-turn-queue.ts +371 -10
  32. package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +0 -170
  33. package/server/scripts/transcript-cleanup-mutation-gate.mjs +0 -219
@@ -9,10 +9,16 @@ import {
9
9
  import {
10
10
  QUERY_JOB_LIMITS,
11
11
  QUERY_JOB_SCHEMA_VERSION,
12
+ QUERY_JOB_STEER_LIMITS,
12
13
  boundedText,
13
14
  isTerminalQueryJobStatus,
14
15
  normalizeQueryJobError,
16
+ parseQueryJobInterruption,
15
17
  parseQueryJobOutputImageStats,
18
+ parseQueryJobSteerRow,
19
+ steerReadState,
20
+ steerStateRank,
21
+ steerTextSha256,
16
22
  originWasDropped,
17
23
  parseQueryJobRequest,
18
24
  requestFingerprint,
@@ -28,6 +34,9 @@ import {
28
34
  type QueryJobSnapshot,
29
35
  type QueryJobStatus,
30
36
  type QueryJobStoreHealth,
37
+ type QueryJobJournaledSteerState,
38
+ type QueryJobSteer,
39
+ type QueryJobSteerState,
31
40
  type QueryJobTrailEntry,
32
41
  } from './query-job-types.js'
33
42
  import {
@@ -36,6 +45,8 @@ import {
36
45
  sanitizeJobTrailDraft,
37
46
  type JobTrailDraft,
38
47
  } from './job-trail.js'
48
+ import { buildInterruption, restartError } from './query-job-resume.js'
49
+ import { queryJobDeltaJournalEnabled, restartNoticeEnabled } from './query-job-feature.js'
39
50
 
40
51
  const QUERY_JOB_STATUSES: readonly QueryJobStatus[] = [
41
52
  'accepted', 'starting', 'running', 'answer_ready', 'completed', 'failed', 'canceled', 'interrupted',
@@ -57,6 +68,8 @@ const QUERY_JOB_STATUSES: readonly QueryJobStatus[] = [
57
68
  */
58
69
  export const QUERY_JOB_JOURNAL_EVENT_TYPES: readonly QueryJobEventType[] = [
59
70
  ...QUERY_JOB_STATUSES, 'chunk', 'tool_status', 'activity_line', 'acknowledged', 'trail',
71
+ // 6.78.0: steer rows follow the trail's rules (a journal row, never a job event).
72
+ 'steer',
60
73
  ]
61
74
 
62
75
  const PARTITION_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/
@@ -100,6 +113,11 @@ interface HydratedQueryJob {
100
113
  /** The last `trailSeq` this job journaled. The next trail row is this plus one,
101
114
  * assigned inside the serialized append, so the persisted order is dense 1..n. */
102
115
  trailSeq: number
116
+ /** 6.78.0: the text hash of each steer, for the identity check (never sent to a client). */
117
+ steerHashes?: Map<string, string>
118
+ /** 6.78.0: a steered run's answer once a result first covered its prompt. Every terminal
119
+ * path but cancel completes with it (see fail and interrupt). Never sent to a client. */
120
+ checkpoint?: { text: string; truncated: boolean }
103
121
  }
104
122
 
105
123
  interface QueryJobIdentity {
@@ -138,6 +156,8 @@ export interface QueryJobSubscription {
138
156
  replay: QueryJobReplay
139
157
  /** Present only when the subscriber asked for the trail. */
140
158
  trailReplay?: QueryJobTrailReplay
159
+ /** 6.78.0: present only when the subscriber asked for steers: each one as it reads now. */
160
+ steerReplay?: QueryJobSteer[]
141
161
  unsubscribe: () => void
142
162
  }
143
163
 
@@ -166,6 +186,37 @@ export interface QueryJobTrailReplay {
166
186
  latestTrailSeq: number
167
187
  }
168
188
 
189
+ /** 6.78.0: one steer state change as a subscriber receives it. No eventSeq, no ring slot. */
190
+ export interface QueryJobSteerFrame {
191
+ type: 'steer'
192
+ jobId: string
193
+ generation: number
194
+ steerId: string
195
+ state: QueryJobSteerState
196
+ at: string
197
+ }
198
+
199
+ export interface QueryJobSteerSubscribe {
200
+ listener: (frame: QueryJobSteerFrame) => void
201
+ }
202
+
203
+ /** 6.78.0: a steer found by its lineage (clientJobId and steerId), at its newest generation. */
204
+ export interface QueryJobSteerLookup {
205
+ jobId: string
206
+ generation: number
207
+ textSha256: string
208
+ steer: QueryJobSteer
209
+ job: QueryJobSnapshot
210
+ }
211
+
212
+ export class QueryJobSteerConflictError extends Error {
213
+ readonly code = 'steer_identity_conflict'
214
+ constructor() {
215
+ super('This steer id was already used with different text.')
216
+ this.name = 'QueryJobSteerConflictError'
217
+ }
218
+ }
219
+
169
220
  export interface QueryJobTrailSubscribe {
170
221
  after: number
171
222
  listener: (frame: QueryJobTrailFrame) => void
@@ -226,6 +277,117 @@ export class NodeQueryJobJournalStorage implements QueryJobJournalStorage {
226
277
  }
227
278
  }
228
279
 
280
+ /**
281
+ * 6.78.0 (MVP-D release 1): the delta-only chunk READER, always on.
282
+ *
283
+ * A writer-on journal (`createQueryJobJournalStorage` with COS_QUERY_JOB_DELTA_JOURNAL) stores
284
+ * a chunk row whose partial is exactly the previous partial plus its delta as the delta alone:
285
+ * `eventData.deltaOnly: true`, `eventData.partialChars` (the partial's length once the delta is
286
+ * added), and no `partialText` in `patch` or `eventData`. This puts the partial back, in read
287
+ * order: an `accepted` row starts a job at '', every row that carries a `partialText` sets it,
288
+ * and a delta-only row appends its `eventData.text`. Any other row is returned untouched, so a
289
+ * journal with no delta rows reads byte for byte as before. `deltas` is one scan's state (a
290
+ * full boot scan, or one job's rehydration), keyed by jobId.
291
+ *
292
+ * SELF-CHECKING. A delta row whose rebuilt length is not its `partialChars` (a row before it
293
+ * was torn or lost) never applies: the job keeps the last partial it could prove, marked
294
+ * `partialTruncated`. Every later delta row then fails the same check, until a row carries a
295
+ * full partial again. A wrong answer is never assembled from a gap.
296
+ */
297
+ type DeltaState = Map<string, string>
298
+
299
+ function rebuildDeltaRow(parsed: unknown, deltas: DeltaState, onlyJobId?: string): unknown {
300
+ if (!parsed || typeof parsed !== 'object') return parsed
301
+ const row = parsed as { jobId?: unknown; type?: unknown; patch?: unknown; eventData?: unknown }
302
+ if (typeof row.jobId !== 'string' || (onlyJobId && row.jobId !== onlyJobId)) return parsed
303
+ const patch = row.patch && typeof row.patch === 'object' ? row.patch as Record<string, unknown> : null
304
+ const eventData = row.eventData && typeof row.eventData === 'object' ? row.eventData as Record<string, unknown> : null
305
+ if (row.type === 'accepted') deltas.set(row.jobId, '')
306
+ if (row.type === 'chunk' && eventData?.deltaOnly === true && patch && typeof eventData.text === 'string') {
307
+ const { deltaOnly: _deltaOnly, partialChars, text, ...data } = eventData
308
+ const base = deltas.get(row.jobId)
309
+ const partialText = base === undefined ? null : base + text
310
+ if (partialText === null || (typeof partialChars === 'number' && partialText.length !== partialChars)) {
311
+ const { partialText: _none, ...rest } = patch
312
+ return { ...row, patch: { ...rest, partialTruncated: true }, eventData: { text, ...data, partialTruncated: true } }
313
+ }
314
+ deltas.set(row.jobId, partialText)
315
+ return { ...row, patch: { partialText, ...patch }, eventData: { text, partialText, ...data } }
316
+ }
317
+ if (typeof patch?.partialText === 'string') deltas.set(row.jobId, patch.partialText)
318
+ return parsed
319
+ }
320
+
321
+ /**
322
+ * 6.78.0 (MVP-D release 1): the journal storage production uses. Reads are the store's own
323
+ * (the reader above is always on). Appends pass through UNCHANGED unless the delta WRITER is on
324
+ * (COS_QUERY_JOB_DELTA_JOURNAL, default off): then a chunk row is stored as its delta alone, but
325
+ * ONLY when its partial is exactly the last partial this writer stored for the job plus that
326
+ * delta. An authoritative rewrite (the coordinator's flush, which REPLACES the partial), a
327
+ * truncated delta or partial, and the first chunk of a job this writer never saw are written
328
+ * in full. The last stored partial moves only after its append succeeded.
329
+ *
330
+ * Rollback: a 6.51.0 to 6.77.x server reads every finished answer whole (completed rows carry
331
+ * the full response), but shows a job left mid-run by a writer-on build with an empty partial.
332
+ * That is why the reader ships a release before the writer.
333
+ */
334
+ export class DeltaQueryJobJournalStorage implements QueryJobJournalStorage {
335
+ readonly inner: QueryJobJournalStorage
336
+ /** Rows stored as their delta alone, and chunk rows kept whole (observability, tests). */
337
+ stripped = 0
338
+ keptFull = 0
339
+ private readonly writeDeltas: boolean
340
+ private readonly written = new Map<string, string>()
341
+
342
+ constructor(options: { writeDeltas?: boolean; inner?: QueryJobJournalStorage } = {}) {
343
+ this.inner = options.inner ?? new NodeQueryJobJournalStorage()
344
+ this.writeDeltas = options.writeDeltas === true
345
+ }
346
+
347
+ prepare(root: string): Promise<void> { return this.inner.prepare(root) }
348
+ listPartitions(root: string): Promise<string[]> { return this.inner.listPartitions(root) }
349
+ readPartition(root: string, partition: string): Promise<string> { return this.inner.readPartition(root, partition) }
350
+ removePartition(root: string, partition: string): Promise<void> { return this.inner.removePartition(root, partition) }
351
+
352
+ async append(root: string, partitionDay: string, line: string): Promise<void> {
353
+ if (!this.writeDeltas) return this.inner.append(root, partitionDay, line)
354
+ let row: { jobId?: unknown; type?: unknown; patch?: Record<string, unknown>; eventData?: Record<string, unknown> }
355
+ try { row = JSON.parse(line) } catch { return this.inner.append(root, partitionDay, line) }
356
+ const jobId = typeof row.jobId === 'string' ? row.jobId : null
357
+ if (!jobId) return this.inner.append(root, partitionDay, line)
358
+ const partial = row.patch?.partialText
359
+ const delta = row.eventData?.text
360
+ // The equality alone decides. A delta the store cut (deltaTruncated) or a partial at its cap
361
+ // can never equal the previous partial plus the stored delta, so neither is ever stripped.
362
+ if (row.type === 'chunk' && typeof partial === 'string' && typeof delta === 'string') {
363
+ const previous = this.written.get(jobId)
364
+ if (previous !== undefined && partial === previous + delta) {
365
+ const { partialText: _patchPartial, ...patch } = row.patch!
366
+ const { partialText: _dataPartial, ...data } = row.eventData!
367
+ await this.inner.append(root, partitionDay, JSON.stringify({
368
+ ...row, patch, eventData: { ...data, deltaOnly: true, partialChars: partial.length },
369
+ }))
370
+ this.written.set(jobId, partial)
371
+ this.stripped++
372
+ return
373
+ }
374
+ await this.inner.append(root, partitionDay, line)
375
+ this.written.set(jobId, partial)
376
+ this.keptFull++
377
+ return
378
+ }
379
+ await this.inner.append(root, partitionDay, line)
380
+ if (row.type === 'accepted') this.written.set(jobId, '')
381
+ else if (typeof partial === 'string') this.written.set(jobId, partial)
382
+ if (row.type === 'completed' || row.type === 'failed' || row.type === 'canceled' || row.type === 'interrupted') this.written.delete(jobId)
383
+ }
384
+ }
385
+
386
+ /** 6.78.0 (MVP-D release 1): the storage production journals through. The writer reads COS_QUERY_JOB_DELTA_JOURNAL once, here. */
387
+ export function createQueryJobJournalStorage(env: NodeJS.ProcessEnv = process.env): QueryJobJournalStorage {
388
+ return new DeltaQueryJobJournalStorage({ writeDeltas: queryJobDeltaJournalEnabled(env) })
389
+ }
390
+
229
391
  export interface QueryJobStoreOptions {
230
392
  root: string
231
393
  bootId: string
@@ -251,6 +413,11 @@ export interface QueryJobStoreOptions {
251
413
  * and a kept job would sit `accepted` for the life of the process.
252
414
  */
253
415
  requeueNeverStartedOnBoot?: boolean
416
+ /**
417
+ * 6.78.0: write `error.cause` and `interruption` when a restart ends a job. Defaults to
418
+ * COS_RESTART_NOTICE (on), read on every interrupt. Tests pass it instead of the env.
419
+ */
420
+ restartNotice?: () => boolean
254
421
  }
255
422
 
256
423
  export class QueryJobStoreError extends Error {
@@ -382,12 +549,18 @@ export class QueryJobStore {
382
549
  private readonly maxTrailEntries: number
383
550
  private readonly maxTrailChars: number
384
551
  private readonly journalEventTypes: readonly QueryJobEventType[]
552
+ private readonly restartNotice: () => boolean
385
553
  private readonly jobs = new Map<string, HydratedQueryJob>()
386
554
  private readonly identitiesByJobId = new Map<string, QueryJobIdentity>()
387
555
  private readonly identitiesByKey = new Map<string, QueryJobIdentity>()
388
556
  private readonly emitter = new EventEmitter()
389
557
  /** Trail frames, apart from job events: only a subscriber that asked for them listens. */
390
558
  private readonly trailEmitter = new EventEmitter()
559
+ /** 6.78.0: steer frames, apart from job events: only a subscriber that asked listens. */
560
+ private readonly steerEmitter = new EventEmitter()
561
+ /** 6.78.0: every journaled steer by lineage (`clientJobId:steerId`), at its newest generation.
562
+ * Built by the boot scan and kept when a job body is evicted, like the identities. */
563
+ private readonly steerIndex = new Map<string, { jobId: string; generation: number; textSha256: string }>()
391
564
  private appendTail: Promise<void> = Promise.resolve()
392
565
  private initPromise: Promise<QueryJobStoreHealth> | null = null
393
566
  /** Never-started jobs the last boot left `accepted`, in journal (admission) order. */
@@ -406,8 +579,10 @@ export class QueryJobStore {
406
579
  this.maxTrailEntries = Math.max(1, options.maxTrailEntries ?? QUERY_JOB_LIMITS.trailEntries)
407
580
  this.maxTrailChars = Math.max(1, options.maxTrailChars ?? QUERY_JOB_LIMITS.trailChars)
408
581
  this.journalEventTypes = options.journalEventTypes ?? QUERY_JOB_JOURNAL_EVENT_TYPES
582
+ this.restartNotice = options.restartNotice ?? (() => restartNoticeEnabled())
409
583
  this.emitter.setMaxListeners(1_000)
410
584
  this.trailEmitter.setMaxListeners(1_000)
585
+ this.steerEmitter.setMaxListeners(1_000)
411
586
  this.health = {
412
587
  state: 'new',
413
588
  bootId: options.bootId,
@@ -443,6 +618,11 @@ export class QueryJobStore {
443
618
  return this.requeuedJobs.splice(0)
444
619
  }
445
620
 
621
+ /** 6.78.0: the clientJobId (lineage) of a job, from the in-memory identities. Null when unknown. */
622
+ lineageOf(jobId: string): string | null {
623
+ return this.identitiesByJobId.get(jobId)?.clientJobId ?? null
624
+ }
625
+
446
626
  /**
447
627
  * Epoch ms until which a provider child of `sessionId` may still be running, or 0.
448
628
  * Read from the in-memory identities, so it never hydrates a journal partition.
@@ -468,7 +648,9 @@ export class QueryJobStore {
468
648
  if (partition.slice(0, 10) < cutoff) await this.storage.removePartition(this.options.root, partition)
469
649
  }
470
650
  this.partitions = await this.storage.listPartitions(this.options.root)
471
- for (const partition of this.partitions) await this.hydratePartition(partition)
651
+ // One delta-reader state per scan, carried across partitions in read order (MVP-D).
652
+ const deltas: DeltaState = new Map()
653
+ for (const partition of this.partitions) await this.hydratePartition(partition, undefined, deltas)
472
654
  this.health.state = 'ready'
473
655
 
474
656
  // No provider child can be reattached after a boot (a detached one may still be
@@ -512,7 +694,8 @@ export class QueryJobStore {
512
694
  })
513
695
  } else {
514
696
  const result = await this.interrupt(snapshot.jobId, 'server_restarted')
515
- if (result.applied) this.health.interruptedOnBoot++
697
+ // A steered run with a whole answer completes instead (6.78.0); only an interrupt counts.
698
+ if (result.applied && result.job.status === 'interrupted') this.health.interruptedOnBoot++
516
699
  }
517
700
  }
518
701
  if (this.requeuedJobs.length > 0) {
@@ -528,7 +711,7 @@ export class QueryJobStore {
528
711
  }
529
712
  }
530
713
 
531
- private async hydratePartition(partition: string, onlyJobId?: string): Promise<void> {
714
+ private async hydratePartition(partition: string, onlyJobId?: string, deltas: DeltaState = new Map()): Promise<void> {
532
715
  let text: string
533
716
  try {
534
717
  text = await this.storage.readPartition(this.options.root, partition)
@@ -540,7 +723,7 @@ export class QueryJobStore {
540
723
  if (!line.trim()) continue
541
724
  try {
542
725
  const parsed = JSON.parse(line) as unknown
543
- const record = this.parseJournalRecord(parsed)
726
+ const record = this.parseJournalRecord(rebuildDeltaRow(parsed, deltas, onlyJobId))
544
727
  if (!record || (onlyJobId && record.jobId !== onlyJobId)) {
545
728
  if (!record && !onlyJobId) this.health.malformedRows++
546
729
  continue
@@ -574,6 +757,7 @@ export class QueryJobStore {
574
757
  || !this.journalEventTypes.includes(r.type as QueryJobEventType)) return null
575
758
  // A trail row whose data is not a trail entry is malformed, not a hole in the trail.
576
759
  if (r.type === 'trail' && !parseJobTrailEventData(r.eventData)) return null
760
+ if (r.type === 'steer' && !parseQueryJobSteerRow(r.eventData)) return null
577
761
  return r as unknown as QueryJobJournalRecord
578
762
  }
579
763
 
@@ -642,6 +826,11 @@ export class QueryJobStore {
642
826
  this.applyTrail(hydrated, record, publish)
643
827
  return undefined
644
828
  }
829
+ // 6.78.0: neither is a steer row; it touches only the job's steers (or its checkpoint).
830
+ if (record.type === 'steer') {
831
+ this.applySteer(hydrated, record, publish)
832
+ return undefined
833
+ }
645
834
  if (record.eventSeq <= hydrated.snapshot.eventSeq) return undefined
646
835
  if (record.type !== 'accepted'
647
836
  && record.type !== 'acknowledged'
@@ -685,6 +874,10 @@ export class QueryJobStore {
685
874
  snapshot.completedAt = typeof patch.completedAt === 'string' ? patch.completedAt : record.persistedAt
686
875
  snapshot.error = normalizeQueryJobError(patch.error, record.type)
687
876
  if (typeof patch.orphanFenceUntil === 'string') snapshot.orphanFenceUntil = patch.orphanFenceUntil
877
+ // 6.78.0: what a restart cut off. Re-parsed on every read, so a record another build
878
+ // wrote is held to this build's shape and bounds.
879
+ const interruption = record.type === 'interrupted' ? parseQueryJobInterruption(patch.interruption) : undefined
880
+ if (interruption) snapshot.interruption = interruption
688
881
  } else if (record.type === 'acknowledged') {
689
882
  snapshot.acknowledgedAt = typeof patch.acknowledgedAt === 'string'
690
883
  ? patch.acknowledgedAt : record.persistedAt
@@ -719,10 +912,60 @@ export class QueryJobStore {
719
912
  this.identitiesByJobId.set(identity.jobId, identity)
720
913
  this.identitiesByKey.set(identityKey(identity.clientJobId, identity.generation), identity)
721
914
  this.refreshHealth()
915
+ if (publish && isTerminalQueryJobStatus(record.status)) this.publishUnappliedSteers(hydrated, record.persistedAt)
722
916
  if (publish) this.emitter.emit(record.jobId, clone(event))
723
917
  return event
724
918
  }
725
919
 
920
+ /**
921
+ * 6.78.0: one steer row into the job's steers (or its checkpoint). Idempotent by
922
+ * (steerId, state rank): a state never moves back, a final state never changes, and a row at
923
+ * or below the current rank is history. Applies to a terminal job too (a steer that arrives
924
+ * after the answer is journaled `too_late`), and touches NOTHING else of the job: not
925
+ * eventSeq, not the replay ring, not `updatedAt`, not the identity.
926
+ */
927
+ private applySteer(hydrated: HydratedQueryJob, record: QueryJobJournalRecord, publish: boolean): void {
928
+ const row = parseQueryJobSteerRow(record.eventData)
929
+ if (!row || record.generation !== hydrated.snapshot.generation) return
930
+ if (row.kind === 'checkpoint') {
931
+ hydrated.checkpoint = { text: row.text, truncated: row.truncated }
932
+ return
933
+ }
934
+ const steers = hydrated.snapshot.steers ?? (hydrated.snapshot.steers = [])
935
+ let entry = steers.find(item => item.steerId === row.steerId)
936
+ if (!entry) {
937
+ entry = {
938
+ steerId: row.steerId, state: row.state, at: record.persistedAt, text: row.text ?? '',
939
+ ...(row.carriedFrom !== undefined ? { carriedFrom: row.carriedFrom } : {}),
940
+ }
941
+ steers.push(entry)
942
+ ;(hydrated.steerHashes ??= new Map()).set(row.steerId, row.textSha256)
943
+ } else if (steerStateRank(row.state) > steerStateRank(entry.state)) {
944
+ entry.state = row.state
945
+ entry.at = record.persistedAt
946
+ } else {
947
+ return
948
+ }
949
+ const key = `${record.clientJobId}:${row.steerId}`
950
+ const indexed = this.steerIndex.get(key)
951
+ if (!indexed || indexed.generation <= record.generation) {
952
+ this.steerIndex.set(key, { jobId: record.jobId, generation: record.generation, textSha256: hydrated.steerHashes!.get(row.steerId)! })
953
+ }
954
+ if (publish) this.emitSteer(hydrated, entry.steerId, entry.state, entry.at)
955
+ }
956
+
957
+ private emitSteer(hydrated: HydratedQueryJob, steerId: string, state: QueryJobSteerState, at: string): void {
958
+ const frame: QueryJobSteerFrame = { type: 'steer', jobId: hydrated.snapshot.jobId, generation: hydrated.snapshot.generation, steerId, state, at }
959
+ this.steerEmitter.emit(hydrated.snapshot.jobId, frame)
960
+ }
961
+
962
+ /** At a job's terminal: a live `not_applied` frame for each steer that did not apply (never journaled). */
963
+ private publishUnappliedSteers(hydrated: HydratedQueryJob, at: string): void {
964
+ for (const steer of hydrated.snapshot.steers ?? []) {
965
+ if (steerReadState(steer.state, hydrated.snapshot.status) === 'not_applied') this.emitSteer(hydrated, steer.steerId, 'not_applied', at)
966
+ }
967
+ }
968
+
726
969
  private applyLinkage(snapshot: QueryJobSnapshot, raw: Record<string, unknown>): void {
727
970
  const provider = raw.provider === 'claude' || raw.provider === 'codex' || raw.provider === 'cursor' || raw.provider === 'ollama'
728
971
  ? raw.provider
@@ -1028,6 +1271,179 @@ export class QueryJobStore {
1028
1271
  })
1029
1272
  }
1030
1273
 
1274
+ /**
1275
+ * 6.78.0: one steer state into the journal (fsynced before the caller acts on it). With
1276
+ * `create`, this is the steer's first row and carries its text: a steer id the job already
1277
+ * has is a replay (the same text) or a conflict (different text). Without it, a state move:
1278
+ * applied only when it moves the steer forward. `accepted` on a job that is answering or
1279
+ * ended is written `too_late` instead, decided INSIDE the serialized write. Like a trail row,
1280
+ * a steer row carries the job's current eventSeq and never advances it.
1281
+ */
1282
+ async appendSteer(jobId: string, input: {
1283
+ steerId: string
1284
+ state: QueryJobJournaledSteerState
1285
+ text?: string
1286
+ carriedFrom?: number
1287
+ create?: boolean
1288
+ }): Promise<{ applied: boolean; steer: QueryJobSteer; job: QueryJobSnapshot }> {
1289
+ await this.ensureInitialized()
1290
+ await this.ensureHydrated(jobId)
1291
+ return this.enqueue(async () => {
1292
+ this.assertWritable()
1293
+ const hydrated = this.jobs.get(jobId)
1294
+ if (!hydrated) throw new QueryJobNotFoundError(jobId)
1295
+ const snapshot = hydrated.snapshot
1296
+ const existing = snapshot.steers?.find(item => item.steerId === input.steerId)
1297
+ const text = input.text === undefined ? undefined : boundedText(input.text, QUERY_JOB_STEER_LIMITS.steerChars).text
1298
+ if (input.create) {
1299
+ if (text === undefined) throw new QueryJobStoreError('steer_text_required')
1300
+ if (existing) {
1301
+ if (hydrated.steerHashes?.get(input.steerId) !== steerTextSha256(text)) throw new QueryJobSteerConflictError()
1302
+ return { applied: false, steer: clone(existing), job: clone(snapshot) }
1303
+ }
1304
+ } else if (!existing) {
1305
+ throw new QueryJobStoreError('steer_not_found')
1306
+ }
1307
+ let state = input.state
1308
+ if (state === 'accepted' && (isTerminalQueryJobStatus(snapshot.status) || snapshot.status === 'answer_ready')) state = 'too_late'
1309
+ if (existing && steerStateRank(state) <= steerStateRank(existing.state)) {
1310
+ return { applied: false, steer: clone(existing), job: clone(snapshot) }
1311
+ }
1312
+ const record: QueryJobJournalRecord = {
1313
+ schemaVersion: QUERY_JOB_SCHEMA_VERSION,
1314
+ recordId: randomUUID(),
1315
+ partitionDay: localPartitionDay(this.now()),
1316
+ persistedAt: this.now().toISOString(),
1317
+ bootId: this.options.bootId,
1318
+ jobId: snapshot.jobId,
1319
+ clientJobId: snapshot.clientJobId,
1320
+ generation: snapshot.generation,
1321
+ turnId: snapshot.turnId,
1322
+ requestFingerprint: snapshot.requestFingerprint,
1323
+ // The event this row follows. Never `+ 1`: a steer is not a job event.
1324
+ eventSeq: hydrated.snapshot.eventSeq,
1325
+ type: 'steer',
1326
+ status: snapshot.status,
1327
+ patch: {},
1328
+ eventData: {
1329
+ steerId: input.steerId,
1330
+ state,
1331
+ textSha256: existing ? hydrated.steerHashes!.get(input.steerId)! : steerTextSha256(text!),
1332
+ ...(!existing ? { text } : {}),
1333
+ ...(!existing && input.carriedFrom !== undefined ? { carriedFrom: input.carriedFrom } : {}),
1334
+ },
1335
+ }
1336
+ await this.appendRecord(record)
1337
+ this.applySteer(hydrated, record, true)
1338
+ const steer = snapshot.steers!.find(item => item.steerId === input.steerId)!
1339
+ return { applied: true, steer: clone(steer), job: clone(snapshot) }
1340
+ })
1341
+ }
1342
+
1343
+ /**
1344
+ * 6.78.0: a steered run's answer at the moment a result first covered its prompt while a
1345
+ * steer was still uncovered. Only on a running job. From here every terminal but cancel
1346
+ * completes the job with this text (fail and interrupt), so a deadline, a restart or a
1347
+ * second-turn error never loses an answer that was already whole.
1348
+ */
1349
+ async appendSteerCheckpoint(jobId: string, text: string): Promise<QueryJobMutationResult> {
1350
+ await this.ensureInitialized()
1351
+ await this.ensureHydrated(jobId)
1352
+ return this.enqueue(async () => {
1353
+ this.assertWritable()
1354
+ const hydrated = this.jobs.get(jobId)
1355
+ if (!hydrated) throw new QueryJobNotFoundError(jobId)
1356
+ const snapshot = hydrated.snapshot
1357
+ if (snapshot.status !== 'running') return { applied: false, job: clone(snapshot) }
1358
+ const bounded = boundedText(text, QUERY_JOB_LIMITS.partialChars)
1359
+ const record: QueryJobJournalRecord = {
1360
+ schemaVersion: QUERY_JOB_SCHEMA_VERSION,
1361
+ recordId: randomUUID(),
1362
+ partitionDay: localPartitionDay(this.now()),
1363
+ persistedAt: this.now().toISOString(),
1364
+ bootId: this.options.bootId,
1365
+ jobId: snapshot.jobId,
1366
+ clientJobId: snapshot.clientJobId,
1367
+ generation: snapshot.generation,
1368
+ turnId: snapshot.turnId,
1369
+ requestFingerprint: snapshot.requestFingerprint,
1370
+ eventSeq: hydrated.snapshot.eventSeq,
1371
+ type: 'steer',
1372
+ status: snapshot.status,
1373
+ patch: {},
1374
+ eventData: { checkpoint: { text: bounded.text, truncated: bounded.truncated } },
1375
+ }
1376
+ await this.appendRecord(record)
1377
+ this.applySteer(hydrated, record, false)
1378
+ return { applied: true, job: clone(snapshot) }
1379
+ })
1380
+ }
1381
+
1382
+ /** 6.78.0: a steer by lineage, at its newest generation (that job is hydrated first). */
1383
+ async findSteer(clientJobId: string, steerId: string): Promise<QueryJobSteerLookup | undefined> {
1384
+ await this.ensureInitialized()
1385
+ const indexed = this.steerIndex.get(`${clientJobId.toLowerCase()}:${steerId.toLowerCase()}`)
1386
+ if (!indexed) return undefined
1387
+ if (!await this.hydratedOrGone(indexed.jobId)) return undefined
1388
+ const hydrated = this.jobs.get(indexed.jobId)!
1389
+ const steer = hydrated.snapshot.steers?.find(item => item.steerId === steerId.toLowerCase())
1390
+ if (!steer) return undefined
1391
+ return { ...indexed, steer: clone(steer), job: clone(hydrated.snapshot) }
1392
+ }
1393
+
1394
+ /** 6.78.0: the job's steers, read INSIDE the serialized queue, so every steer row appended
1395
+ * before this call is in the answer (the coordinator seeds a new run's port from it). */
1396
+ async queuedSteers(jobId: string): Promise<QueryJobSteer[]> {
1397
+ await this.ensureInitialized()
1398
+ await this.ensureHydrated(jobId)
1399
+ return this.enqueue(async () => clone(this.jobs.get(jobId)?.snapshot.steers ?? []))
1400
+ }
1401
+
1402
+ /** 6.78.0: steers a client sent to this job (carried ones do not count toward the cap). */
1403
+ async steerCount(jobId: string): Promise<number> {
1404
+ const snapshot = await this.getSnapshot(jobId)
1405
+ return (snapshot.steers ?? []).filter(item => item.carriedFrom === undefined).length
1406
+ }
1407
+
1408
+ /**
1409
+ * 6.78.0: what generation `generation` of a lineage carries forward. Each steer once, from the
1410
+ * newest generation that holds it, when that generation ended `failed` or `interrupted` and
1411
+ * the steer was meant to apply there (never `too_late` or `unsupported`). A completed or
1412
+ * canceled generation carries nothing.
1413
+ */
1414
+ async listCarryableSteers(clientJobId: string, generation: number): Promise<Array<{ steerId: string; text: string; carriedFrom: number }>> {
1415
+ await this.ensureInitialized()
1416
+ const prefix = `${clientJobId.toLowerCase()}:`
1417
+ const found: Array<{ steerId: string; text: string; carriedFrom: number; at: string }> = []
1418
+ for (const [key, indexed] of this.steerIndex) {
1419
+ if (!key.startsWith(prefix) || indexed.generation >= generation) continue
1420
+ if (!await this.hydratedOrGone(indexed.jobId)) continue
1421
+ const snapshot = this.jobs.get(indexed.jobId)!.snapshot
1422
+ if (snapshot.status !== 'failed' && snapshot.status !== 'interrupted') continue
1423
+ const steer = snapshot.steers?.find(item => key === `${prefix}${item.steerId}`)
1424
+ if (!steer || steer.state === 'too_late' || steer.state === 'unsupported') continue
1425
+ found.push({ steerId: steer.steerId, text: steer.text, carriedFrom: steer.carriedFrom ?? indexed.generation, at: steer.at })
1426
+ }
1427
+ return found.sort((a, b) => a.carriedFrom - b.carriedFrom || a.at.localeCompare(b.at))
1428
+ .map(({ at: _at, ...item }) => item)
1429
+ }
1430
+
1431
+ /** 6.78.0: hydrate a job a steer points at; false when its journal is gone (never an error). */
1432
+ private async hydratedOrGone(jobId: string): Promise<boolean> {
1433
+ try {
1434
+ await this.ensureHydrated(jobId)
1435
+ return true
1436
+ } catch (error) {
1437
+ if (error instanceof QueryJobNotFoundError) return false
1438
+ throw error
1439
+ }
1440
+ }
1441
+
1442
+ /** 6.78.0: the checkpoint of a steered run, if one was journaled (internal; never on a snapshot). */
1443
+ private checkpointOf(jobId: string): { text: string; truncated: boolean } | undefined {
1444
+ return this.jobs.get(jobId)?.checkpoint
1445
+ }
1446
+
1031
1447
  async markAnswerReady(
1032
1448
  jobId: string,
1033
1449
  fullText: string,
@@ -1078,6 +1494,10 @@ export class QueryJobStore {
1078
1494
 
1079
1495
  async fail(jobId: string, error: unknown): Promise<QueryJobMutationResult> {
1080
1496
  const current = await this.getSnapshot(jobId)
1497
+ // 6.78.0 steer: a deadline, an inactivity kill or a second-turn error after the answer was
1498
+ // whole completes with that answer; the steers it did not cover read not_applied.
1499
+ const checkpoint = isTerminalQueryJobStatus(current.status) ? undefined : this.checkpointOf(jobId)
1500
+ if (checkpoint) return this.completeFromCheckpoint(current, checkpoint.text)
1081
1501
  if (current.status === 'answer_ready') {
1082
1502
  return this.complete(jobId, {
1083
1503
  text: current.partialText,
@@ -1098,6 +1518,21 @@ export class QueryJobStore {
1098
1518
  }, { error: normalized })
1099
1519
  }
1100
1520
 
1521
+ private completeFromCheckpoint(current: QueryJobSnapshot, text: string): Promise<QueryJobMutationResult> {
1522
+ return this.complete(current.jobId, {
1523
+ text,
1524
+ attachments: current.attachments,
1525
+ outputImageStats: current.outputImageStats,
1526
+ provider: current.provider,
1527
+ resolvedModel: current.resolvedModel,
1528
+ cliSessionId: current.cliSessionId,
1529
+ claudeRunId: current.claudeRunId,
1530
+ codexRunId: current.codexRunId,
1531
+ codexThreadId: current.codexThreadId,
1532
+ cursorRunId: current.cursorRunId,
1533
+ })
1534
+ }
1535
+
1101
1536
  async cancel(jobId: string, generation: number): Promise<QueryJobMutationResult> {
1102
1537
  const current = await this.getSnapshot(jobId, generation)
1103
1538
  if (isTerminalQueryJobStatus(current.status)) return { applied: false, job: current }
@@ -1115,6 +1550,9 @@ export class QueryJobStore {
1115
1550
  async interrupt(jobId: string, reason = 'server_interrupted'): Promise<QueryJobMutationResult> {
1116
1551
  const current = await this.getSnapshot(jobId)
1117
1552
  if (isTerminalQueryJobStatus(current.status)) return { applied: false, job: current }
1553
+ // 6.78.0 steer: a restart after the answer was whole completes with that answer.
1554
+ const checkpoint = this.checkpointOf(jobId)
1555
+ if (checkpoint) return this.completeFromCheckpoint(current, checkpoint.text)
1118
1556
  if (current.status === 'answer_ready') {
1119
1557
  return this.complete(jobId, {
1120
1558
  text: current.partialText,
@@ -1129,13 +1567,30 @@ export class QueryJobStore {
1129
1567
  cursorRunId: current.cursorRunId,
1130
1568
  })
1131
1569
  }
1132
- const error: QueryJobError = {
1570
+ // 6.78.0 restart truth: both restart paths (a crash then boot, and a graceful shutdown) get
1571
+ // one message, `error.cause` set HERE at construction (normalizeQueryJobError keeps only an
1572
+ // allowlisted cause), and an `interruption` record built from the job's own trail. The flag
1573
+ // is read once per interrupt, so a restart applies a change.
1574
+ const restartCause = (reason === 'server_restarted' || reason === 'server_shutdown') && this.restartNotice()
1575
+ ? reason : null
1576
+ const error: QueryJobError = restartCause ? restartError(restartCause) : {
1133
1577
  code: 'interrupted',
1134
1578
  message: reason === 'server_restarted'
1135
1579
  ? 'Server restarted. Prompt preserved; provider was not restarted.'
1136
1580
  : sanitizeQueryJobActivity(reason).text,
1137
1581
  retryable: true,
1138
1582
  }
1583
+ // Built INSIDE the serialized write (transition's `late`), from the snapshot as it stands
1584
+ // there, so a trail row the run journaled just before the interrupt is in the record. Pure
1585
+ // and in memory: the graceful path stays inside its 8-second shutdown budget.
1586
+ const late = restartCause ? (hydrated: HydratedQueryJob) => ({
1587
+ interruption: buildInterruption({
1588
+ job: hydrated.snapshot,
1589
+ activityToolMode: hydrated.request.activityToolMode,
1590
+ cause: restartCause,
1591
+ at: this.now().toISOString(),
1592
+ }),
1593
+ }) : undefined
1139
1594
  // Only a job with a persisted provider-process ledger id can own an
1140
1595
  // orphan after this server process dies. Accepted/starting/context-build
1141
1596
  // jobs are safe to retry immediately after restart.
@@ -1149,7 +1604,36 @@ export class QueryJobStore {
1149
1604
  return this.transition(jobId, 'interrupted', 'interrupted', {
1150
1605
  error, completedAt: this.now().toISOString(),
1151
1606
  ...(orphanFenceUntil ? { orphanFenceUntil } : {}),
1152
- }, { error, ...(orphanFenceUntil ? { orphanFenceUntil } : {}) })
1607
+ }, { error, ...(orphanFenceUntil ? { orphanFenceUntil } : {}) }, late)
1608
+ }
1609
+
1610
+ /**
1611
+ * 6.78.0 (crash-loop parking): end a job that never started, because handing it back on
1612
+ * boot kept killing the server. Only an `accepted` job (no prompt byte reached a provider,
1613
+ * so there is no orphan fence); any other status is `applied: false`. `interrupted` is
1614
+ * terminal and `resumable: false`, so the app's Retry (the next generation of the same
1615
+ * clientJobId) is admitted.
1616
+ */
1617
+ async interruptParked(jobId: string, error: QueryJobError): Promise<QueryJobMutationResult> {
1618
+ await this.ensureInitialized()
1619
+ await this.ensureHydrated(jobId)
1620
+ return this.enqueue(async () => {
1621
+ this.assertWritable()
1622
+ const hydrated = this.jobs.get(jobId)
1623
+ if (!hydrated) throw new QueryJobNotFoundError(jobId)
1624
+ // 6.78.0 restart truth: a parked job carries its interruption record too.
1625
+ const interruption = error.cause === 'crash_loop_parked' && this.restartNotice() ? buildInterruption({
1626
+ job: hydrated.snapshot,
1627
+ activityToolMode: hydrated.request.activityToolMode,
1628
+ cause: 'crash_loop_parked',
1629
+ at: this.now().toISOString(),
1630
+ }) : undefined
1631
+ if (hydrated.snapshot.status !== 'accepted') return { applied: false, job: clone(hydrated.snapshot) }
1632
+ return this.persistMutation(hydrated, 'interrupted', 'interrupted', {
1633
+ error, completedAt: this.now().toISOString(),
1634
+ ...(interruption ? { interruption } : {}),
1635
+ }, { error, ...(interruption ? { interruption } : {}) })
1636
+ })
1153
1637
  }
1154
1638
 
1155
1639
  async acknowledge(jobId: string, generation: number): Promise<QueryJobMutationResult> {
@@ -1193,12 +1677,17 @@ export class QueryJobStore {
1193
1677
  }
1194
1678
  }
1195
1679
 
1680
+ /**
1681
+ * `late` (6.78.0) adds fields computed from the job as it stands INSIDE the serialized write,
1682
+ * after every earlier append, to both the patch and the event data. Only interrupt passes it.
1683
+ */
1196
1684
  private async transition(
1197
1685
  jobId: string,
1198
1686
  status: QueryJobStatus,
1199
1687
  type: QueryJobEventType,
1200
1688
  patch: Record<string, unknown>,
1201
1689
  eventData: Record<string, unknown>,
1690
+ late?: (hydrated: HydratedQueryJob) => Record<string, unknown>,
1202
1691
  ): Promise<QueryJobMutationResult> {
1203
1692
  await this.ensureInitialized()
1204
1693
  await this.ensureHydrated(jobId)
@@ -1212,6 +1701,10 @@ export class QueryJobStore {
1212
1701
  if (hydrated.snapshot.status === status || !ALLOWED_TRANSITIONS[hydrated.snapshot.status].has(status)) {
1213
1702
  return { applied: false, job: clone(hydrated.snapshot) }
1214
1703
  }
1704
+ if (late) {
1705
+ const extra = late(hydrated)
1706
+ return this.persistMutation(hydrated, status, type, { ...patch, ...extra }, { ...eventData, ...extra })
1707
+ }
1215
1708
  return this.persistMutation(hydrated, status, type, patch, eventData)
1216
1709
  })
1217
1710
  }
@@ -1270,7 +1763,8 @@ export class QueryJobStore {
1270
1763
  private async ensureHydrated(jobId: string): Promise<void> {
1271
1764
  if (this.jobs.has(jobId)) return
1272
1765
  if (!this.identitiesByJobId.has(jobId)) throw new QueryJobNotFoundError(jobId)
1273
- for (const partition of this.partitions) await this.hydratePartition(partition, jobId)
1766
+ const deltas: DeltaState = new Map()
1767
+ for (const partition of this.partitions) await this.hydratePartition(partition, jobId, deltas)
1274
1768
  if (!this.jobs.has(jobId)) throw new QueryJobNotFoundError(jobId)
1275
1769
  this.trimHydratedJobs(jobId)
1276
1770
  }
@@ -1367,6 +1861,7 @@ export class QueryJobStore {
1367
1861
  after: number,
1368
1862
  listener: (event: QueryJobEvent) => void,
1369
1863
  trail?: QueryJobTrailSubscribe,
1864
+ steer?: QueryJobSteerSubscribe,
1370
1865
  ): Promise<QueryJobSubscription> {
1371
1866
  await this.getSnapshot(jobId, generation)
1372
1867
  let closed = false
@@ -1380,8 +1875,13 @@ export class QueryJobStore {
1380
1875
  if (!closed && frame.generation === generation && frame.trailSeq > trailCursor) trail.listener(clone(frame))
1381
1876
  }
1382
1877
  : null
1878
+ // 6.78.0: steer frames, only for a subscriber that asked (`?steer=1`).
1879
+ const steerWrapped = steer
1880
+ ? (frame: QueryJobSteerFrame) => { if (!closed && frame.generation === generation) steer.listener(clone(frame)) }
1881
+ : null
1383
1882
  this.emitter.on(jobId, wrapped)
1384
1883
  if (trailWrapped) this.trailEmitter.on(jobId, trailWrapped)
1884
+ if (steerWrapped) this.steerEmitter.on(jobId, steerWrapped)
1385
1885
  this.subscriberCount++
1386
1886
  this.refreshHealth()
1387
1887
  // Register first, then take the synchronous replay snapshot. An append can
@@ -1389,6 +1889,7 @@ export class QueryJobStore {
1389
1889
  // trailSeq), but can never fall into an await-sized gap between the two.
1390
1890
  const replay = this.buildReplay(jobId, after)
1391
1891
  const trailReplay = trail ? this.buildTrailReplay(jobId, trail.after) : undefined
1892
+ const steerReplay = steer ? this.readSteers(jobId) : undefined
1392
1893
  // A rebuild (`cursor_ahead` or `trail_gap`) resets the client's trail to the held entries
1393
1894
  // and continues from `latestTrailSeq`, so the live filter does too (6.52.0 app QA). A
1394
1895
  // retried Messages run keeps its queue row but starts a new job whose trail restarts at 1;
@@ -1398,17 +1899,25 @@ export class QueryJobStore {
1398
1899
  return {
1399
1900
  replay,
1400
1901
  ...(trailReplay ? { trailReplay } : {}),
1902
+ ...(steerReplay ? { steerReplay } : {}),
1401
1903
  unsubscribe: () => {
1402
1904
  if (closed) return
1403
1905
  closed = true
1404
1906
  this.emitter.off(jobId, wrapped)
1405
1907
  if (trailWrapped) this.trailEmitter.off(jobId, trailWrapped)
1908
+ if (steerWrapped) this.steerEmitter.off(jobId, steerWrapped)
1406
1909
  this.subscriberCount = Math.max(0, this.subscriberCount - 1)
1407
1910
  this.refreshHealth()
1408
1911
  },
1409
1912
  }
1410
1913
  }
1411
1914
 
1915
+ /** 6.78.0: the job's steers as a client reads them now (not_applied computed). */
1916
+ private readSteers(jobId: string): QueryJobSteer[] {
1917
+ const snapshot = this.jobs.get(jobId)!.snapshot
1918
+ return (snapshot.steers ?? []).map(item => ({ ...clone(item), state: steerReadState(item.state, snapshot.status) }))
1919
+ }
1920
+
1412
1921
  /** The trail entries after `after`, or the whole held trail when some are gone. */
1413
1922
  private buildTrailReplay(jobId: string, after: number): QueryJobTrailReplay {
1414
1923
  const hydrated = this.jobs.get(jobId)!