@gotcos/glasses-server 6.76.1 → 6.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)!
|