@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.
- package/CHANGELOG.md +50 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +2 -0
- package/server/lib/archive.ts +7 -3
- package/server/lib/claude-bridge.ts +30 -12
- package/server/lib/conversation.ts +27 -0
- package/server/lib/maintenance-lifecycle.ts +6 -0
- package/server/lib/model-router.ts +4 -0
- package/server/lib/morning-brief-config.ts +22 -1
- package/server/lib/morning-brief-prompt.ts +16 -2
- package/server/lib/morning-brief-runtime.ts +32 -1
- package/server/lib/morning-brief-schedule.ts +19 -2
- package/server/lib/morning-brief-scheduler.ts +111 -9
- package/server/lib/python-bridge.ts +18 -4
- package/server/lib/query-job-coordinator.ts +8 -2
- package/server/lib/query-job-runtime.ts +22 -2
- package/server/lib/query-job-store.ts +33 -3
- package/server/lib/query-job-types.ts +147 -2
- package/server/lib/task-dispatcher.ts +642 -0
- package/server/lib/task-store.ts +624 -0
- package/server/routes/health.ts +11 -1
- package/server/routes/message-ref.ts +2 -1
- package/server/routes/query-jobs.ts +1 -1
- package/server/routes/sessions.ts +10 -1
- package/server/routes/tasks.ts +152 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
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
|
|
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
|
|
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<
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|