@gotcos/glasses-server 6.43.3 → 6.44.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.
@@ -70,6 +70,10 @@ export interface MorningBriefSchedulerDeps {
70
70
  now?: () => number
71
71
  tickMs?: number
72
72
  log?: (line: string) => void
73
+ dispatchDueTasks?: () => Promise<{ fired: number; reason?: string }>
74
+ reconcileDispatch?: () => Promise<{ fired: number; reason?: string }>
75
+ onDispatch?: (result: { fired: number; reason?: string }) => void
76
+ taskDigest?: (day: string) => string | Promise<string>
73
77
  }
74
78
 
75
79
  export class MorningBriefRunError extends Error {
@@ -108,6 +112,9 @@ export type TickResult =
108
112
 
109
113
  /** Deterministic v4-shaped client id for one local day, so a retry after a
110
114
  * crashed submission admits as the SAME job. The store dedupes on it. */
115
+ /** The routine id every surface renders as ROUTINE for the morning brief. */
116
+ export const MORNING_BRIEF_ROUTINE_ID = 'morning-brief'
117
+
111
118
  export function scheduledClientJobId(day: string, timezone: string): string {
112
119
  const digest = createHash('sha256').update(`morning-brief|${timezone}|${day}`).digest()
113
120
  const bytes = Buffer.from(digest.subarray(0, 16))
@@ -128,6 +135,13 @@ export class MorningBriefScheduler {
128
135
  private ledger: MorningBriefLedger
129
136
  private timer: ReturnType<typeof setInterval> | null = null
130
137
  private tickInFlight: Promise<TickResult> | null = null
138
+ /** One chain for tick() AND runNow(): the in-progress read in runNow and the
139
+ * ledger write in fire() must never interleave with a scheduled fire. */
140
+ private serial: Promise<unknown> = Promise.resolve()
141
+ private serialTasks: Promise<unknown> = Promise.resolve()
142
+ private serialTasksDepth = 0
143
+ private dispatchInFlight: Promise<unknown> | null = null
144
+ private reconcileInFlight: Promise<unknown> | null = null
131
145
  private readonly now: () => number
132
146
  private readonly log: (line: string) => void
133
147
  readonly quarantinedConfig?: string
@@ -151,7 +165,11 @@ export class MorningBriefScheduler {
151
165
  start(): void {
152
166
  if (this.timer) return
153
167
  const tickMs = Math.max(1_000, this.deps.tickMs ?? 30_000)
154
- this.timer = setInterval(() => { void this.tick() }, tickMs)
168
+ // A rejected tick must never become an unhandled rejection (Node exits on
169
+ // one by default): log it and let the next interval try again.
170
+ this.timer = setInterval(() => {
171
+ this.tick().catch(error => this.log(`tick failed: ${error instanceof Error ? error.message : String(error)}`))
172
+ }, tickMs)
155
173
  this.timer.unref?.()
156
174
  this.log(`scheduled ${this.config.enabled ? `daily at ${this.config.time} ${this.config.timezone}` : 'off'} · next ${nextScheduledFire(this.config, this.now()) ?? 'none'}`)
157
175
  }
@@ -180,11 +198,50 @@ export class MorningBriefScheduler {
180
198
 
181
199
  /** One scheduler pass. Serialised: a slow submission never overlaps the next tick. */
182
200
  tick(): Promise<TickResult> {
201
+ if (this.deps.dispatchDueTasks && !this.dispatchInFlight) {
202
+ const mine = this.serializeTaskWork(() => this.deps.dispatchDueTasks!())
203
+ this.dispatchInFlight = mine
204
+ void mine.then(
205
+ result => this.deps.onDispatch?.(result),
206
+ () => undefined,
207
+ ).finally(() => {
208
+ if (this.dispatchInFlight === mine) this.dispatchInFlight = null
209
+ })
210
+ }
211
+ if (this.deps.reconcileDispatch && !this.reconcileInFlight) {
212
+ const mine = this.serializeTaskWork(() => this.deps.reconcileDispatch!())
213
+ this.reconcileInFlight = mine
214
+ void mine.finally(() => {
215
+ if (this.reconcileInFlight === mine) this.reconcileInFlight = null
216
+ })
217
+ }
183
218
  if (this.tickInFlight) return this.tickInFlight
184
- this.tickInFlight = this.runTick().finally(() => { this.tickInFlight = null })
219
+ this.tickInFlight = this.serialize(() => this.runTick()).finally(() => { this.tickInFlight = null })
185
220
  return this.tickInFlight
186
221
  }
187
222
 
223
+ private serialize<T>(fn: () => Promise<T>): Promise<T> {
224
+ const next = this.serial.then(fn, fn)
225
+ this.serial = next.then(() => undefined, () => undefined)
226
+ return next
227
+ }
228
+
229
+ private serializeTaskWork<T>(fn: () => Promise<T> | T): Promise<T> {
230
+ if (this.serialTasksDepth > 0) {
231
+ return Promise.reject(new Error('nested serializeTaskWork'))
232
+ }
233
+ const run = this.serialTasks.then(async () => {
234
+ this.serialTasksDepth += 1
235
+ try {
236
+ return await fn()
237
+ } finally {
238
+ this.serialTasksDepth -= 1
239
+ }
240
+ })
241
+ this.serialTasks = run.then(() => undefined, () => undefined)
242
+ return run
243
+ }
244
+
188
245
  private async runTick(): Promise<TickResult> {
189
246
  if (!this.deps.durableJobsEnabled()) return { fired: false, reason: 'durable_jobs_off' }
190
247
  if (!this.deps.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
@@ -195,7 +252,11 @@ export class MorningBriefScheduler {
195
252
  }
196
253
 
197
254
  /** "Run now" from a settings surface. Bounded per day; refused while a brief is live. */
198
- async runNow(): Promise<MorningBriefRun> {
255
+ runNow(): Promise<MorningBriefRun> {
256
+ return this.serialize(() => this.runNowInner())
257
+ }
258
+
259
+ private async runNowInner(): Promise<MorningBriefRun> {
199
260
  if (!this.deps.durableJobsEnabled()) {
200
261
  throw new MorningBriefRunError(409, 'durable_jobs_off', 'Turn on Background jobs in COS Control to run the brief.')
201
262
  }
@@ -227,14 +288,45 @@ export class MorningBriefScheduler {
227
288
  ? scheduledClientJobId(day, this.config.timezone)
228
289
  : randomUUID()
229
290
 
230
- // Resume: a ledger row with no job id. Adopt an admission the coordinator
231
- // may already hold for this identity before considering a fresh submit.
232
- if (resume) {
291
+ // Adopt before minting, on EVERY scheduled fire not only the crash-resume
292
+ // branch. A submit that threw after admission, a ledger row lost to
293
+ // quarantine, and a plain resume all find the coordinator already holding
294
+ // this day's identity. A manual fire mints a fresh UUID and can never hit,
295
+ // so it skips the round trip. Reusing `existing.globalMsgNum` is safe even
296
+ // though liveReservations() skips submitError rows: the store's own
297
+ // reservation source (query-job-runtime registers the coordinator's live
298
+ // identities) holds an admitted job's number independently of the ledger.
299
+ if (trigger === 'scheduled') {
233
300
  const existing = await this.deps.findByClientGeneration(clientJobId, 1).catch(() => undefined)
234
301
  if (existing) {
235
- const adopted: MorningBriefRun = { ...resume, jobId: existing.jobId, lastKnownStatus: existing.status }
302
+ const priorRow = resume ?? this.ledger.runs.filter(r => r.day === day && r.trigger === trigger).at(-1)
303
+ // Carry every current and future ledger field from the row being
304
+ // replaced; drop only the submit error (the job exists), then override
305
+ // the identity and provenance columns from what is actually admitted.
306
+ const { submitError: _dropped, ...carried } = priorRow ?? {}
307
+ const adopted: MorningBriefRun = {
308
+ ...carried,
309
+ id: priorRow?.id ?? randomUUID(),
310
+ day,
311
+ trigger,
312
+ attempt,
313
+ firedAt: priorRow?.firedAt ?? existing.acceptedAt,
314
+ clientJobId,
315
+ generation: 1,
316
+ sessionId: priorRow?.sessionId ?? existing.sessionId,
317
+ messageEra: priorRow?.messageEra ?? existing.messageEra ?? this.deps.currentMessageEra(),
318
+ globalMsgNum: priorRow?.globalMsgNum ?? existing.globalMsgNum ?? this.deps.currentMessageMax() + 1,
319
+ sections: priorRow?.sections ?? briefSections(this.config, day),
320
+ jobId: existing.jobId,
321
+ lastKnownStatus: existing.status,
322
+ }
323
+ if (priorRow?.globalMsgNum == null && existing.globalMsgNum == null) {
324
+ this.log(`adopted job ${existing.jobId} carried no message number; minted #${adopted.globalMsgNum}`)
325
+ }
326
+ // Reusing the prior row's id makes replaceRun REPLACE the day's row
327
+ // rather than push a second one for the same job.
236
328
  this.replaceRun(adopted)
237
- this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId}`)
329
+ this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId} (${priorRow ? `replacing ledger row ${priorRow.id}` : 'no ledger row'})`)
238
330
  return adopted
239
331
  }
240
332
  }
@@ -258,7 +350,14 @@ export class MorningBriefScheduler {
258
350
  // Ledger first. A crash after this line is a resume, not a second brief.
259
351
  this.replaceRun(run)
260
352
 
261
- const prompt = composeMorningBriefPrompt({ config: this.config, day, ownerName: this.deps.ownerName(), trigger })
353
+ const digest = this.deps.taskDigest ? await this.deps.taskDigest(day) : undefined
354
+ const prompt = composeMorningBriefPrompt({
355
+ config: this.config,
356
+ day,
357
+ ownerName: this.deps.ownerName(),
358
+ trigger,
359
+ ...(digest ? { taskDigest: digest } : {}),
360
+ })
262
361
  try {
263
362
  const admission = await this.deps.submit({
264
363
  clientJobId,
@@ -272,6 +371,9 @@ export class MorningBriefScheduler {
272
371
  activityToolMode: 'status',
273
372
  attachmentIds: [],
274
373
  attachmentRefs: [],
374
+ // The label every surface renders as ROUTINE. Outside the fingerprint,
375
+ // so a run admitted on 6.43.3 still adopts here by identity.
376
+ origin: { kind: 'routine', id: MORNING_BRIEF_ROUTINE_ID },
275
377
  })
276
378
  const accepted: MorningBriefRun = { ...run, jobId: admission.job.jobId, lastKnownStatus: admission.job.status }
277
379
  this.replaceRun(accepted)
@@ -78,9 +78,9 @@ if (pythonAvailable) {
78
78
  * pipeline is configured; otherwise resolves to an empty/no-op result so the
79
79
  * context builder degrades gracefully on a standalone install.
80
80
  */
81
- export function callPython(args: string[], timeoutMs = 30_000): Promise<unknown> {
81
+ export function callPython(args: string[], timeoutMs = 30_000, input?: string): Promise<unknown> {
82
82
  if (pythonAvailable) {
83
- return callPythonDirect(args, timeoutMs)
83
+ return callPythonDirect(args, timeoutMs, input)
84
84
  }
85
85
  return Promise.resolve(standaloneNoop(args))
86
86
  }
@@ -186,6 +186,13 @@ function standaloneNoop(args: string[]): unknown {
186
186
  return { error: 'cos_pipeline_not_configured' }
187
187
  }
188
188
  case 'badges': return {}
189
+ case 'task-rows':
190
+ case 'task-capture':
191
+ case 'task-set-run-at':
192
+ case 'task-set-marker':
193
+ case 'task-move':
194
+ case 'task-check':
195
+ return { error: { code: 'cos_pipeline_not_configured' } }
189
196
  default: return {}
190
197
  }
191
198
  }
@@ -199,15 +206,18 @@ function argLimit(args: string[], fallback: number): number {
199
206
  }
200
207
 
201
208
  /** Full Python bridge — requires the user's venv + cos_api_bridge.py. */
202
- function callPythonDirect(args: string[], timeoutMs: number): Promise<unknown> {
209
+ function callPythonDirect(args: string[], timeoutMs: number, input?: string): Promise<unknown> {
203
210
  return new Promise((resolvePromise, reject) => {
204
- execFile(
211
+ const child = execFile(
205
212
  PYTHON_BIN!,
206
213
  [BRIDGE_SCRIPT!, ...args],
207
214
  { cwd: COS_SCRIPTS_DIR!, timeout: timeoutMs, maxBuffer: 1024 * 1024 },
208
215
  (err, stdout, stderr) => {
209
216
  if (err) {
210
217
  const msg = stderr?.trim() || err.message
218
+ if (typeof msg === 'string' && msg.includes('unknown command')) {
219
+ return resolvePromise({ error: { code: 'cos_pipeline_not_configured', message: msg } })
220
+ }
211
221
  return reject(new Error(`python-bridge: ${msg}`))
212
222
  }
213
223
  try {
@@ -217,5 +227,9 @@ function callPythonDirect(args: string[], timeoutMs: number): Promise<unknown> {
217
227
  }
218
228
  }
219
229
  )
230
+ if (input != null) {
231
+ child.stdin?.write(input)
232
+ child.stdin?.end()
233
+ }
220
234
  })
221
235
  }
@@ -3,6 +3,7 @@ import {
3
3
  QueryJobStore,
4
4
  QueryJobStoreError,
5
5
  type QueryJobAdmissionResult,
6
+ type QueryJobMutationResult,
6
7
  type QueryJobSubscription,
7
8
  } from './query-job-store.js'
8
9
  import {
@@ -502,7 +503,7 @@ export class QueryJobCoordinator {
502
503
  }
503
504
  }
504
505
 
505
- async cancel(jobId: string, generation: number): Promise<QueryJobSnapshot> {
506
+ async cancel(jobId: string, generation: number): Promise<QueryJobMutationResult> {
506
507
  const result = await this.store.cancel(jobId, generation)
507
508
  if (result.applied) {
508
509
  const active = this.active.get(jobId)
@@ -514,7 +515,12 @@ export class QueryJobCoordinator {
514
515
  this.finishActive(active)
515
516
  }
516
517
  }
517
- return result.job
518
+ return result
519
+ }
520
+
521
+ finishIfActive(jobId: string): void {
522
+ const active = this.active.get(jobId)
523
+ if (active) this.finishActive(active)
518
524
  }
519
525
 
520
526
  getSnapshot(jobId: string, generation?: number): Promise<QueryJobSnapshot> {
@@ -92,8 +92,14 @@ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<
92
92
  }
93
93
  try {
94
94
  const resolved = await resolveQueryAttachments(input)
95
+ const { dispatch: _strippedDispatch, origin: rawOrigin, ...rest } = input
96
+ const origin = (rawOrigin && typeof rawOrigin === 'object' && (rawOrigin as { kind?: string }).kind === 'task')
97
+ ? undefined
98
+ : rawOrigin
99
+ if (_strippedDispatch !== undefined || origin !== rawOrigin) queryJobStore.noteOriginStripped()
95
100
  return {
96
- ...input,
101
+ ...rest,
102
+ ...(origin !== undefined ? { origin } : {}),
97
103
  messageEra: activeEra,
98
104
  attachmentIds: resolved.ids,
99
105
  attachmentRefs: resolved.refs,
@@ -112,11 +118,19 @@ function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' | 'o
112
118
  return isCodexModel(model) ? 'codex' : 'claude'
113
119
  }
114
120
 
121
+ /** The origin label as it travels on the display bus and the message views:
122
+ * FLATTENED (`origin: 'routine', originId: 'morning-brief'`) so every client
123
+ * parses one shape, and identical on `start`, `done` and `error`. Spread LAST
124
+ * into each event so no provider metadata key can overwrite it. */
125
+ function originStamp(request: QueryJobRequest): { origin?: NonNullable<QueryJobRequest['origin']>['kind']; originId?: string } {
126
+ return request.origin ? { origin: request.origin.kind, originId: request.origin.id } : {}
127
+ }
128
+
115
129
  /** Project the authoritative terminal journal into the derived conversation
116
130
  * cache. Journaled request/response text always wins over bridge-written
117
131
  * partial rows; validated media refs may be merged because output media can
118
132
  * finish immediately before a crash. Exact provenance collapses duplicates. */
119
- async function projectPublicConversationTerminal(
133
+ export async function projectPublicConversationTerminal(
120
134
  job: QueryJobSnapshot,
121
135
  request: QueryJobRequest,
122
136
  ): Promise<void> {
@@ -148,6 +162,7 @@ async function projectPublicConversationTerminal(
148
162
  request.attachmentRefs,
149
163
  request.messageEra,
150
164
  normalizeModelPreference(request.model),
165
+ request.origin,
151
166
  )
152
167
  reconcileExchangeByJobIdentity(
153
168
  request.sessionId,
@@ -158,6 +173,7 @@ async function projectPublicConversationTerminal(
158
173
  mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
159
174
  request.messageEra,
160
175
  normalizeModelPreference(request.model),
176
+ request.origin,
161
177
  )
162
178
  flushConversationToDisk()
163
179
  }
@@ -203,6 +219,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
203
219
  sessionId,
204
220
  cliSessionId,
205
221
  ...metadata,
222
+ ...originStamp(request),
206
223
  } })
207
224
  },
208
225
  onProviderProcess: metadata => callbacks.onProviderProcess({
@@ -281,6 +298,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
281
298
  cliSessionId,
282
299
  ...runMetadata,
283
300
  ...(attachments.length > 0 ? { attachments } : {}),
301
+ ...originStamp(request),
284
302
  } })
285
303
  } finally {
286
304
  attachmentLease?.release()
@@ -296,6 +314,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
296
314
  turnId,
297
315
  messageEra: request.messageEra,
298
316
  globalMsgNum: request.globalMsgNum,
317
+ ...originStamp(request),
299
318
  error,
300
319
  } })
301
320
  },
@@ -321,6 +340,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
321
340
  requestAttachments: resolvedAttachments.refs,
322
341
  attachmentPromptBlock: resolvedAttachments.promptBlock,
323
342
  sessionLockHeld: true,
343
+ ...(request.dispatch ? { dispatch: request.dispatch } : {}),
324
344
  },
325
345
  )
326
346
  }
@@ -13,6 +13,7 @@ import {
13
13
  isTerminalQueryJobStatus,
14
14
  normalizeQueryJobError,
15
15
  parseQueryJobOutputImageStats,
16
+ originWasDropped,
16
17
  parseQueryJobRequest,
17
18
  requestFingerprint,
18
19
  sanitizeQueryJobActivity,
@@ -317,6 +318,9 @@ export class QueryJobStore {
317
318
  journalFailures: 0,
318
319
  interruptedOnBoot: 0,
319
320
  evictedHydratedJobs: 0,
321
+ originDropped: 0,
322
+ originStripped: 0,
323
+ fingerprintMismatches: 0,
320
324
  lastErrorCode: null,
321
325
  lastSuccessfulWriteAt: null,
322
326
  rootFingerprint: createHash('sha256').update(options.root).digest('hex').slice(0, 16),
@@ -436,10 +440,25 @@ export class QueryJobStore {
436
440
  if (!hydrated) {
437
441
  if (record.type !== 'accepted' || !record.request || record.eventSeq !== 1) return undefined
438
442
  let request: QueryJobRequest
443
+ // Lenient: a record written by another build hydrates with an unknown
444
+ // origin dropped (and counted) rather than being discarded.
439
445
  try { request = parseQueryJobRequest(record.request) } catch { return undefined }
446
+ // Counted once per job: ensureHydrated replays this same record after an
447
+ // LRU eviction, and by then the identity is already registered below.
448
+ // (The mismatch counter needs no such guard: a mismatch returns before
449
+ // the identity is registered, so a replay can never reach it.)
450
+ if (!this.identitiesByJobId.has(record.jobId) && originWasDropped(record.request, request)) this.health.originDropped++
440
451
  const fingerprint = requestFingerprint(request)
441
- if (fingerprint !== record.requestFingerprint
442
- || request.clientJobId !== record.clientJobId
452
+ if (fingerprint !== record.requestFingerprint) {
453
+ // A mismatch DROPS the job from hydration. Say so once, loudly: a
454
+ // counter in /api/health is read after the history has already gone.
455
+ if (this.health.fingerprintMismatches === 0) {
456
+ console.error(`[query-jobs] journal fingerprint mismatch for ${record.jobId}; the job is not hydrated (the running build picks different identity keys than the writer, or the record was edited)`)
457
+ }
458
+ this.health.fingerprintMismatches++
459
+ return undefined
460
+ }
461
+ if (request.clientJobId !== record.clientJobId
443
462
  || request.generation !== record.generation) return undefined
444
463
  const acceptedAt = record.persistedAt
445
464
  const retentionUntil = new Date(new Date(acceptedAt).getTime() + this.retentionDays * 86_400_000).toISOString()
@@ -626,7 +645,13 @@ export class QueryJobStore {
626
645
 
627
646
  async admit(raw: unknown): Promise<QueryJobAdmissionResult> {
628
647
  await this.ensureInitialized()
629
- const request = parseQueryJobRequest(raw)
648
+ // Strict only where strictness is safe on the public route: a KNOWN kind
649
+ // with an id this server cannot accept is a bug in the scheduler or the
650
+ // dispatcher that stamped it and throws (→ 400). An unknown kind, or the
651
+ // phone's bare-string stamp, is dropped and counted below — once per job
652
+ // created, never per retry of the same identity.
653
+ const request = parseQueryJobRequest(raw, { strictOrigin: true })
654
+ const originDropped = originWasDropped(raw, request)
630
655
  const fingerprint = requestFingerprint(request)
631
656
  const existingIdentity = this.identitiesByKey.get(identityKey(request.clientJobId, request.generation))
632
657
  if (existingIdentity) await this.ensureHydrated(existingIdentity.jobId)
@@ -663,6 +688,7 @@ export class QueryJobStore {
663
688
  const highestGeneration = lineage.reduce((max, item) => Math.max(max, item.generation), 0)
664
689
  if (request.generation <= highestGeneration) throw new QueryJobGenerationOrderError(request.clientJobId)
665
690
 
691
+ if (originDropped) this.health.originDropped++
666
692
  const now = this.now().toISOString()
667
693
  const jobId = randomUUID()
668
694
  const turnId = randomUUID()
@@ -1137,6 +1163,10 @@ export class QueryJobStore {
1137
1163
  this.health.counts = counts
1138
1164
  }
1139
1165
 
1166
+ noteOriginStripped(): void {
1167
+ this.health.originStripped++
1168
+ }
1169
+
1140
1170
  getHealth(): QueryJobStoreHealth {
1141
1171
  this.refreshHealth()
1142
1172
  return clone(this.health)
@@ -52,6 +52,36 @@ export interface QueryJobPromptReference {
52
52
  response: string
53
53
  }
54
54
 
55
+ /**
56
+ * Who STARTED a job, when it was not the person holding the phone or the
57
+ * glasses. A label, nothing more: the server never infers anything from its
58
+ * absence (Miles, 2026-09-02). `routine` is the scheduler (`morning-brief`),
59
+ * `task` is a dispatch of a captured task (the id is the task's 12-hex id).
60
+ * Human prompts typed on the phone carry no origin. The phone stamps `g2`
61
+ * locally for prompts spoken on the glasses, as a bare string; if that string
62
+ * ever reaches this route it is dropped and counted, never rejected.
63
+ */
64
+ export interface QueryJobOrigin {
65
+ kind: 'routine' | 'task'
66
+ id: string
67
+ }
68
+
69
+ /** One alphabet for both kinds: `morning-brief` and a 12-hex task id both fit.
70
+ * Up to 64 chars is this server's own bound (a routine slug, a 12-hex task id
71
+ * and a bare sha256 all fit); COS Control 0.5.185 mirrors it in its allowlist. */
72
+ export const QUERY_JOB_ORIGIN_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/
73
+
74
+ export interface ParseQueryJobRequestOptions {
75
+ /** Admission (the public job route reaches the parser with this on): a KNOWN
76
+ * kind whose id fails the alphabet is a bug in the server's own scheduler or
77
+ * dispatcher, and throws. An UNKNOWN kind never throws on any path — it is a
78
+ * label this build cannot render, dropped and counted, so a newer client is
79
+ * never refused. Hydration leaves this off: a record written by another
80
+ * build must still hydrate. A bare STRING origin is dropped on both paths —
81
+ * it is the phone's local `'g2'` shape, never an error. */
82
+ strictOrigin?: boolean
83
+ }
84
+
55
85
  /** Immutable, persistence-safe request. Provider-only objects (paths, image
56
86
  * bytes, AbortControllers, handoff runtime state) deliberately do not fit. */
57
87
  export interface QueryJobRequest {
@@ -72,8 +102,16 @@ export interface QueryJobRequest {
72
102
  attachmentIds: string[]
73
103
  attachmentRefs: MediaAttachmentRef[]
74
104
  activityToolMode: QueryJobActivityMode
105
+ /** Present only on server-started jobs. NOT part of the request fingerprint
106
+ * (see `FINGERPRINT_KEYS`), so a rollback to a build that drops it still
107
+ * hydrates the journal. */
108
+ origin?: QueryJobOrigin
109
+ /** Read-only dispatch constraint. Excluded from the fingerprint. */
110
+ dispatch?: { restricted: true; tools: readonly string[] }
75
111
  }
76
112
 
113
+ export const DISPATCH_ALLOWED_TOOLS = ['Read', 'Grep', 'Glob'] as const
114
+
77
115
  export interface QueryJobProviderLinkage {
78
116
  provider?: 'claude' | 'codex' | 'cursor' | 'ollama'
79
117
  resolvedModel?: string
@@ -177,6 +215,15 @@ export interface QueryJobStoreHealth {
177
215
  journalFailures: number
178
216
  interruptedOnBoot: number
179
217
  evictedHydratedJobs: number
218
+ /** Requests whose `origin` was present but not one this build recognises
219
+ * (a bare string, or an object of a later build's kind) and was dropped. */
220
+ originDropped: number
221
+ /** Public-route admissions that had `dispatch` or `origin.kind==='task'` stripped. */
222
+ originStripped: number
223
+ /** Journal `accepted` records whose stored fingerprint no longer matches the
224
+ * running build's `requestFingerprint` — each one is a job that did NOT
225
+ * hydrate. Zero is the only healthy value. */
226
+ fingerprintMismatches: number
180
227
  lastErrorCode: string | null
181
228
  lastSuccessfulWriteAt: string | null
182
229
  rootFingerprint: string
@@ -184,6 +231,10 @@ export interface QueryJobStoreHealth {
184
231
  }
185
232
 
186
233
  const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
234
+
235
+ export function isClientJobId(value: string): boolean {
236
+ return CLIENT_JOB_ID_RE.test(value.toLowerCase())
237
+ }
187
238
  const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/
188
239
  const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
189
240
  const SECRET_PATTERNS: RegExp[] = [
@@ -226,7 +277,7 @@ function boundedContent(value: unknown, field: string, max: number, allowEmpty =
226
277
 
227
278
  /** Parse untrusted admission input into the only request shape allowed in the
228
279
  * private journal. Unknown keys are dropped before fingerprinting. */
229
- export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
280
+ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequestOptions = {}): QueryJobRequest {
230
281
  if (!raw || typeof raw !== 'object') throw new QueryJobValidationError('invalid_request')
231
282
  const input = raw as Record<string, unknown>
232
283
  const clientJobId = requiredString(input.clientJobId, 'client_job_id', 36).toLowerCase()
@@ -272,6 +323,34 @@ export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
272
323
  ? input.activityToolMode
273
324
  : 'status'
274
325
 
326
+ // `== null` covers both absent and JSON null, the way `reference` and
327
+ // `globalMsgNum` are read above. A non-object (the phone's local `'g2'`
328
+ // string) is silently absent on every path; the store counts the drop.
329
+ let origin: QueryJobOrigin | undefined
330
+ if (input.origin != null && typeof input.origin === 'object') {
331
+ const candidate = input.origin as Record<string, unknown>
332
+ const kind = candidate.kind
333
+ const id = candidate.id
334
+ if (kind === 'routine' || kind === 'task') {
335
+ if (typeof id === 'string' && QUERY_JOB_ORIGIN_ID_RE.test(id)) {
336
+ origin = { kind, id }
337
+ } else if (options.strictOrigin) {
338
+ // A kind this build knows with an id it cannot accept is a bug in the
339
+ // caller that stamped it (the scheduler, the dispatcher): loud.
340
+ throw new QueryJobValidationError('invalid_origin')
341
+ }
342
+ }
343
+ // A kind this build does NOT know is a label it cannot render, never a
344
+ // reason to refuse the job: the public job route reaches this parser with
345
+ // strictOrigin, so an unknown kind must degrade to unlabeled (and be
346
+ // counted by the store), or a newer client would have every prompt 400'd.
347
+ }
348
+
349
+ const dispatch = parseDispatch(input.dispatch)
350
+ if (options.strictOrigin && origin?.kind === 'task' && !dispatch) {
351
+ throw new QueryJobValidationError('invalid_dispatch')
352
+ }
353
+
275
354
  const attachmentIds = parseMediaIdList(input.attachmentIds)
276
355
  const attachmentRefs = parseMediaAttachmentRefs(input.attachmentRefs ?? input.attachments)
277
356
  if (!query.trim() && attachmentIds.length === 0 && attachmentRefs.length === 0) {
@@ -295,7 +374,32 @@ export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
295
374
  attachmentIds,
296
375
  attachmentRefs,
297
376
  activityToolMode,
377
+ ...(origin ? { origin } : {}),
378
+ ...(dispatch ? { dispatch } : {}),
379
+ }
380
+ }
381
+
382
+ function parseDispatch(raw: unknown): QueryJobRequest['dispatch'] | undefined {
383
+ if (raw == null) return undefined
384
+ if (!raw || typeof raw !== 'object') throw new QueryJobValidationError('invalid_dispatch')
385
+ const rec = raw as Record<string, unknown>
386
+ if (rec.restricted !== true || !Array.isArray(rec.tools) || rec.tools.length === 0) {
387
+ throw new QueryJobValidationError('invalid_dispatch')
298
388
  }
389
+ const tools = rec.tools.map(String)
390
+ if (tools.some(tool => !(DISPATCH_ALLOWED_TOOLS as readonly string[]).includes(tool))) {
391
+ throw new QueryJobValidationError('invalid_dispatch')
392
+ }
393
+ return { restricted: true, tools }
394
+ }
395
+
396
+ /** True when the raw admission/journal input carried an `origin` the parser
397
+ * did not keep — a bare string, or an object shape this build does not know.
398
+ * The store counts these; the parser stays pure. */
399
+ export function originWasDropped(raw: unknown, request: QueryJobRequest): boolean {
400
+ if (!raw || typeof raw !== 'object') return false
401
+ const rawOrigin = (raw as Record<string, unknown>).origin
402
+ return rawOrigin != null && request.origin == null
299
403
  }
300
404
 
301
405
  function canonical(value: unknown): unknown {
@@ -305,8 +409,49 @@ function canonical(value: unknown): unknown {
305
409
  return Object.fromEntries(Object.keys(record).sort().map(key => [key, canonical(record[key])]))
306
410
  }
307
411
 
412
+ /**
413
+ * The request keys that make up a job's IDENTITY — frozen at the sixteen keys
414
+ * `QueryJobRequest` had before `origin` existed. Provenance and enforcement
415
+ * keys (`origin`, and any later `trustMode`/`toolAllowlist`) are deliberately
416
+ * outside it: a build that does not know a key must still hydrate the journal
417
+ * a newer build wrote, and a rollback must never discard a week of jobs.
418
+ *
419
+ * Consequence, stated: two submissions for the same (clientJobId, generation)
420
+ * that differ only in those excluded keys are the SAME job, and the second
421
+ * returns the first. The scheduled brief reuses one identity per DAY across
422
+ * its three attempts, and its excluded keys never differ between attempts; a
423
+ * genuine resubmit differs in `globalMsgNum`, which IS in the fingerprint, so
424
+ * it conflicts rather than silently adopting. Phone prompts never send an
425
+ * excluded key.
426
+ *
427
+ * Pick contract: an absent key stays absent. The parser never materialises an
428
+ * optional as null/''/0/[] (every optional is a conditional spread in its
429
+ * return literal), and `JSON.stringify` drops an undefined value, so the pick
430
+ * needs no guard of its own. A `?? null` pick WOULD re-hash every stored
431
+ * request and silently drop the journal; the test pins that property.
432
+ */
433
+ export const FINGERPRINT_KEYS = [
434
+ 'clientJobId', 'generation', 'query', 'sessionId', 'model', 'effort',
435
+ 'cursorExecutionMode', 'messageEra', 'globalMsgNum', 'reference', 'handoffCode',
436
+ 'handoffLatest', 'clientQueueItemId', 'attachmentIds', 'attachmentRefs',
437
+ 'activityToolMode',
438
+ ] as const satisfies readonly (keyof QueryJobRequest)[]
439
+
440
+ /** The request keys deliberately OUTSIDE the identity. Every key of
441
+ * `QueryJobRequest` must appear in exactly one of the two lists: adding a key
442
+ * without naming it here or above fails to compile, so the author picks a side. */
443
+ export const FINGERPRINT_EXCLUDED = ['origin', 'dispatch'] as const satisfies readonly (keyof QueryJobRequest)[]
444
+ type FingerprintUncovered = Exclude<keyof QueryJobRequest, typeof FINGERPRINT_KEYS[number] | typeof FINGERPRINT_EXCLUDED[number]>
445
+ export const FINGERPRINT_KEYS_COVER_REQUEST: FingerprintUncovered extends never ? true : never = true
446
+
447
+ function pickFingerprintKeys(request: QueryJobRequest): Record<string, unknown> {
448
+ const picked: Record<string, unknown> = {}
449
+ for (const key of FINGERPRINT_KEYS) picked[key] = request[key]
450
+ return picked
451
+ }
452
+
308
453
  export function requestFingerprint(request: QueryJobRequest): string {
309
- return createHash('sha256').update(JSON.stringify(canonical(request))).digest('hex')
454
+ return createHash('sha256').update(JSON.stringify(canonical(pickFingerprintKeys(request)))).digest('hex')
310
455
  }
311
456
 
312
457
  export function isTerminalQueryJobStatus(status: QueryJobStatus): status is QueryJobTerminalStatus {