@gotcos/glasses-server 6.43.2 → 6.43.4
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 +71 -11
- package/package.json +1 -1
- 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/conversation.ts +27 -0
- package/server/lib/morning-brief-scheduler.ts +62 -8
- package/server/lib/provider-proof.ts +119 -14
- package/server/lib/query-job-runtime.ts +13 -0
- package/server/lib/query-job-store.ts +28 -3
- package/server/lib/query-job-types.ts +117 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/message-ref.ts +2 -1
- package/server/routes/provider-proof.ts +3 -3
- package/server/routes/sessions.ts +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,76 @@
|
|
|
1
|
+
## 6.43.4
|
|
2
|
+
|
|
3
|
+
Every message now says who started it.
|
|
4
|
+
|
|
5
|
+
- **Server-started jobs carry an origin label.** A durable job request may
|
|
6
|
+
carry `origin: { kind: 'routine' | 'task', id }`; the scheduled morning
|
|
7
|
+
brief sends `{ kind: 'routine', id: 'morning-brief' }`. The label lands on
|
|
8
|
+
both halves of the projected exchange (`origin`, `originId`), on the display
|
|
9
|
+
bus (`start`, `done`, `error`, flattened to the same two keys), and on the
|
|
10
|
+
today/all-messages and day-archive views the phone and COS Control read.
|
|
11
|
+
A prompt typed on the phone carries nothing; the phone's own `G2` stamp
|
|
12
|
+
never reaches the server. Nothing is inferred from the label's absence.
|
|
13
|
+
- **The request fingerprint is pinned to the sixteen identity keys it always
|
|
14
|
+
had.** `origin` (and any future provenance or enforcement key) is outside
|
|
15
|
+
it, so a build that does not know the key still hydrates a journal written
|
|
16
|
+
by one that does, and a rollback from this version to 6.43.3 keeps every
|
|
17
|
+
retained job. Stored fingerprints are byte-identical before and after.
|
|
18
|
+
- **Admission is strict only about a known kind with a bad id (400
|
|
19
|
+
`invalid_origin`).** An unknown kind, and the phone's bare-string stamp, are
|
|
20
|
+
dropped and counted on both paths, so a newer client's label degrades to
|
|
21
|
+
unlabeled and never refuses the job; hydration is lenient throughout. Two new
|
|
22
|
+
store counters on `/api/health`: `originDropped` (once per job created, once
|
|
23
|
+
per journal record hydrated) and `fingerprintMismatches` — the second is a
|
|
24
|
+
job that did NOT hydrate, and the first mismatch logs at error level. No
|
|
25
|
+
client reads either yet; `/api/health` is where a human sees them.
|
|
26
|
+
- **The label is validated at every boundary it crosses.** The conversation
|
|
27
|
+
store drops an unknown kind (and an orphan id) when it loads from disk, and
|
|
28
|
+
the bus stamp is spread after provider metadata so no provider key can
|
|
29
|
+
overwrite it. A real 6.43.3-written journal is committed as a fixture and
|
|
30
|
+
hydrated by the suite, in addition to the in-process hash oracle.
|
|
31
|
+
- **The scheduler adopts an admitted brief on every scheduled fire**, not only
|
|
32
|
+
on the crash-resume branch, and replaces the last of the day's scheduled
|
|
33
|
+
ledger rows instead of pushing another; "Run now" and the scheduled tick
|
|
34
|
+
are serialised, so two overlapping admissions can no longer interleave. A
|
|
35
|
+
manual and a scheduled brief on the same day remain two briefs, by design.
|
|
36
|
+
- Briefs that ran on 6.43.0–6.43.3 stay unlabeled; badges start with runs
|
|
37
|
+
from this version. A routine's reply is unlabeled while it is still running
|
|
38
|
+
and gains the label when it lands.
|
|
39
|
+
|
|
40
|
+
## 6.43.3
|
|
41
|
+
|
|
42
|
+
Managed-server updates no longer hinge on one vendor's meter.
|
|
43
|
+
|
|
44
|
+
- **The readiness proof reports why it failed, as a code.** COS Control
|
|
45
|
+
verifies a candidate server with one real no-tool query per installed
|
|
46
|
+
provider. On 2026-09-01 Claude had hit its session limit, Codex proved in
|
|
47
|
+
seven seconds, and six 6.43.1 updates still rolled back because the only
|
|
48
|
+
answer was "provider process exited 1". `POST /api/diagnostics/provider-proof`
|
|
49
|
+
now returns `code`: `provider_quota` (vendor session or usage limit),
|
|
50
|
+
`provider_auth` (not signed in), `provider_context_overflow`,
|
|
51
|
+
`provider_missing`, `provider_timeout`, `provider_canceled`,
|
|
52
|
+
`provider_bad_answer`, or `provider_failed`. The `error` sentence is one
|
|
53
|
+
fixed phrase per code; vendor text never leaves the server. COS Control
|
|
54
|
+
0.5.184 uses the code to treat a quota as a skip when another provider
|
|
55
|
+
proves (on 6.43.2 and older it classifies the error sentence instead).
|
|
56
|
+
- **Cursor is a proof provider.** `{"provider":"cursor"}` runs the Cursor
|
|
57
|
+
`agent` in documented read-only ask mode with the prompt on stdin and reads
|
|
58
|
+
the result event, so a Mac with Cursor signed in can prove an update while
|
|
59
|
+
Claude and Codex are both at their limits.
|
|
60
|
+
|
|
1
61
|
## 6.43.2
|
|
2
62
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
63
|
+
What actually reached npm under this number (published 2026-09-02 07:27 CDT
|
|
64
|
+
from the commit before the proof codes landed):
|
|
65
|
+
|
|
66
|
+
- **The Claude proof states its empty MCP config explicitly**
|
|
67
|
+
(`--strict-mcp-config --mcp-config '{"mcpServers":{}}'`) instead of relying
|
|
68
|
+
on `CLAUDE_CODE_SAFE_MODE` alone. Without safe mode, `--tools ''` on Claude
|
|
69
|
+
Code 2.1.251 still loads the whole catalog, which on a large fleet is a
|
|
70
|
+
244K-token request Haiku refuses before any API call. Belt and braces; the
|
|
71
|
+
2026-09-01 rollbacks were the session limit, not this.
|
|
72
|
+
- The message-reservation rationale in code and changelog states the
|
|
73
|
+
observed double #74 accurately (one exchange shown twice by the companion).
|
|
14
74
|
|
|
15
75
|
## 6.43.1
|
|
16
76
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.43.
|
|
3
|
+
"version": "6.43.4",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
{"schemaVersion":1,"recordId":"02b09947-ed7d-47ef-99c5-4985af3ec924","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"43b397bd-dd67-4312-9a5c-4c5d4b246f1d","clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"turnId":"a456fd1d-1e82-4b2d-8a20-5d34681201fd","requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"query":"written by 6.43.3","sessionId":"session-6-43-3","model":"opus","effort":"high","cursorExecutionMode":"ask","messageEra":"era1","globalMsgNum":78,"reference":{"query":"earlier q","response":"earlier a"},"handoffCode":"ABCD","handoffLatest":true,"clientQueueItemId":"q1","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95"}}
|
|
2
|
+
{"schemaVersion":1,"recordId":"a1145a40-2ced-4da3-9cf7-76d84cffc946","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"64c5b502-13f2-4871-b6c4-007b91be2414","clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"turnId":"843e633f-0f92-4fe3-b2a1-9dc683715791","requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"query":"minimal by 6.43.3","sessionId":"session-6-43-3-min","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638"}}
|
package/server/lib/archive.ts
CHANGED
|
@@ -472,7 +472,8 @@ export function getArchiveChats(date: string): ArchiveChatSummary[] {
|
|
|
472
472
|
}
|
|
473
473
|
|
|
474
474
|
/** Get paired Q&A messages for a specific chat within a day. Request refs on
|
|
475
|
-
* the user turn and model-output refs on the assistant turn surface together.
|
|
475
|
+
* the user turn and model-output refs on the assistant turn surface together.
|
|
476
|
+
* Deliberately no origin label here (6.43.4): the day view carries it. */
|
|
476
477
|
export function getArchiveChatMessages(
|
|
477
478
|
date: string,
|
|
478
479
|
chatIndex: number,
|
|
@@ -507,11 +508,11 @@ export function getArchiveChatMessages(
|
|
|
507
508
|
* (sessionId, timestamp) instead of bare timestamp (collision-prone). */
|
|
508
509
|
export function getArchiveDayMessages(
|
|
509
510
|
date: string,
|
|
510
|
-
): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> {
|
|
511
|
+
): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; origin?: 'routine' | 'task'; originId?: string; attachments?: MediaAttachmentRef[] }> {
|
|
511
512
|
const archive = loadArchive(date)
|
|
512
513
|
if (!archive) return []
|
|
513
514
|
|
|
514
|
-
const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> = []
|
|
515
|
+
const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; origin?: 'routine' | 'task'; originId?: string; attachments?: MediaAttachmentRef[] }> = []
|
|
515
516
|
for (const chat of archive.chats) {
|
|
516
517
|
for (let i = 0; i < chat.exchanges.length; i++) {
|
|
517
518
|
const ex = chat.exchanges[i]
|
|
@@ -522,6 +523,8 @@ export function getArchiveDayMessages(
|
|
|
522
523
|
const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
|
|
523
524
|
const messageEra = ex.messageEra ?? next.messageEra
|
|
524
525
|
const modelPreference = resolveExchangePairModel(ex, next, null)
|
|
526
|
+
// Same one-half rule as the live view (routes/sessions.ts).
|
|
527
|
+
const stamped = ex.origin ? ex : next
|
|
525
528
|
messages.push({
|
|
526
529
|
query: ex.content,
|
|
527
530
|
text: next.content,
|
|
@@ -531,6 +534,7 @@ export function getArchiveDayMessages(
|
|
|
531
534
|
...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
|
|
532
535
|
...(messageEra ? { messageEra } : {}),
|
|
533
536
|
...(modelPreference ? { modelPreference } : {}),
|
|
537
|
+
...(stamped.origin ? { origin: stamped.origin, ...(stamped.originId ? { originId: stamped.originId } : {}) } : {}),
|
|
534
538
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
535
539
|
})
|
|
536
540
|
i++
|
|
@@ -37,6 +37,20 @@ export interface Exchange {
|
|
|
37
37
|
* photos on the user exchange, model-published images on the assistant
|
|
38
38
|
* exchange. Bytes, filesystem paths, and capabilities never persist here. */
|
|
39
39
|
attachments?: MediaAttachmentRef[]
|
|
40
|
+
/** Who started the job that produced this pair, when it was not the person
|
|
41
|
+
* on the phone or the glasses: `routine` (the scheduler) or `task` (a
|
|
42
|
+
* dispatched task). Absent on every human prompt. The phone's own `g2` stamp
|
|
43
|
+
* never reaches the server; this is the server's half of the label. */
|
|
44
|
+
origin?: 'routine' | 'task'
|
|
45
|
+
/** `morning-brief`, or a task's 12-hex id. */
|
|
46
|
+
originId?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Structural twin of `QueryJobOrigin` so the conversation store takes no
|
|
50
|
+
* import from the job module. */
|
|
51
|
+
export interface ExchangeOriginStamp {
|
|
52
|
+
kind: 'routine' | 'task'
|
|
53
|
+
id: string
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
export interface ExchangeJobProvenance {
|
|
@@ -159,6 +173,14 @@ function loadFromDisk(): void {
|
|
|
159
173
|
if (refs.length > 0) exchange.attachments = refs
|
|
160
174
|
else delete exchange.attachments
|
|
161
175
|
}
|
|
176
|
+
// Same boundary for the origin label (6.43.4): only the two kinds this
|
|
177
|
+
// build renders survive a load, and an id never survives without its kind.
|
|
178
|
+
if (exchange.origin !== 'routine' && exchange.origin !== 'task') {
|
|
179
|
+
delete exchange.origin
|
|
180
|
+
delete exchange.originId
|
|
181
|
+
} else if (typeof exchange.originId !== 'string' || !exchange.originId) {
|
|
182
|
+
delete exchange.originId
|
|
183
|
+
}
|
|
162
184
|
}
|
|
163
185
|
sessions.set(id, session)
|
|
164
186
|
loaded++
|
|
@@ -528,6 +550,7 @@ export function reconcileExchangeByJobIdentity(
|
|
|
528
550
|
attachments?: MediaAttachmentRef[],
|
|
529
551
|
messageEra?: string,
|
|
530
552
|
modelPreference?: ModelPreference | null,
|
|
553
|
+
origin?: ExchangeOriginStamp,
|
|
531
554
|
): ReconciledExchange {
|
|
532
555
|
if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
|
|
533
556
|
throw new Error('conversation: invalid durable job identity')
|
|
@@ -572,6 +595,7 @@ export function reconcileExchangeByJobIdentity(
|
|
|
572
595
|
generation: identity.generation,
|
|
573
596
|
...(stampedModel ? { modelPreference: stampedModel } : {}),
|
|
574
597
|
...(validatedAttachments.length > 0 ? { attachments: validatedAttachments } : {}),
|
|
598
|
+
...(origin ? { origin: origin.kind, originId: origin.id } : {}),
|
|
575
599
|
}
|
|
576
600
|
session.exchanges.push(exchange)
|
|
577
601
|
} else {
|
|
@@ -580,6 +604,9 @@ export function reconcileExchangeByJobIdentity(
|
|
|
580
604
|
exchange.globalMsgNum = globalMsgNum
|
|
581
605
|
exchange.messageEra = projectedMessageEra
|
|
582
606
|
if (stampedModel) exchange.modelPreference = stampedModel
|
|
607
|
+
// Guarded like the model: the cursor and ollama bridges reconcile the user
|
|
608
|
+
// half mid-run WITHOUT an origin, and must not erase the runtime's stamp.
|
|
609
|
+
if (origin) { exchange.origin = origin.kind; exchange.originId = origin.id }
|
|
583
610
|
if (validatedAttachments.length > 0) exchange.attachments = validatedAttachments
|
|
584
611
|
else delete exchange.attachments
|
|
585
612
|
|
|
@@ -108,6 +108,9 @@ export type TickResult =
|
|
|
108
108
|
|
|
109
109
|
/** Deterministic v4-shaped client id for one local day, so a retry after a
|
|
110
110
|
* crashed submission admits as the SAME job. The store dedupes on it. */
|
|
111
|
+
/** The routine id every surface renders as ROUTINE for the morning brief. */
|
|
112
|
+
export const MORNING_BRIEF_ROUTINE_ID = 'morning-brief'
|
|
113
|
+
|
|
111
114
|
export function scheduledClientJobId(day: string, timezone: string): string {
|
|
112
115
|
const digest = createHash('sha256').update(`morning-brief|${timezone}|${day}`).digest()
|
|
113
116
|
const bytes = Buffer.from(digest.subarray(0, 16))
|
|
@@ -128,6 +131,9 @@ export class MorningBriefScheduler {
|
|
|
128
131
|
private ledger: MorningBriefLedger
|
|
129
132
|
private timer: ReturnType<typeof setInterval> | null = null
|
|
130
133
|
private tickInFlight: Promise<TickResult> | null = null
|
|
134
|
+
/** One chain for tick() AND runNow(): the in-progress read in runNow and the
|
|
135
|
+
* ledger write in fire() must never interleave with a scheduled fire. */
|
|
136
|
+
private serial: Promise<unknown> = Promise.resolve()
|
|
131
137
|
private readonly now: () => number
|
|
132
138
|
private readonly log: (line: string) => void
|
|
133
139
|
readonly quarantinedConfig?: string
|
|
@@ -151,7 +157,11 @@ export class MorningBriefScheduler {
|
|
|
151
157
|
start(): void {
|
|
152
158
|
if (this.timer) return
|
|
153
159
|
const tickMs = Math.max(1_000, this.deps.tickMs ?? 30_000)
|
|
154
|
-
|
|
160
|
+
// A rejected tick must never become an unhandled rejection (Node exits on
|
|
161
|
+
// one by default): log it and let the next interval try again.
|
|
162
|
+
this.timer = setInterval(() => {
|
|
163
|
+
this.tick().catch(error => this.log(`tick failed: ${error instanceof Error ? error.message : String(error)}`))
|
|
164
|
+
}, tickMs)
|
|
155
165
|
this.timer.unref?.()
|
|
156
166
|
this.log(`scheduled ${this.config.enabled ? `daily at ${this.config.time} ${this.config.timezone}` : 'off'} · next ${nextScheduledFire(this.config, this.now()) ?? 'none'}`)
|
|
157
167
|
}
|
|
@@ -181,10 +191,16 @@ export class MorningBriefScheduler {
|
|
|
181
191
|
/** One scheduler pass. Serialised: a slow submission never overlaps the next tick. */
|
|
182
192
|
tick(): Promise<TickResult> {
|
|
183
193
|
if (this.tickInFlight) return this.tickInFlight
|
|
184
|
-
this.tickInFlight = this.runTick().finally(() => { this.tickInFlight = null })
|
|
194
|
+
this.tickInFlight = this.serialize(() => this.runTick()).finally(() => { this.tickInFlight = null })
|
|
185
195
|
return this.tickInFlight
|
|
186
196
|
}
|
|
187
197
|
|
|
198
|
+
private serialize<T>(fn: () => Promise<T>): Promise<T> {
|
|
199
|
+
const next = this.serial.then(fn, fn)
|
|
200
|
+
this.serial = next.then(() => undefined, () => undefined)
|
|
201
|
+
return next
|
|
202
|
+
}
|
|
203
|
+
|
|
188
204
|
private async runTick(): Promise<TickResult> {
|
|
189
205
|
if (!this.deps.durableJobsEnabled()) return { fired: false, reason: 'durable_jobs_off' }
|
|
190
206
|
if (!this.deps.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
|
|
@@ -195,7 +211,11 @@ export class MorningBriefScheduler {
|
|
|
195
211
|
}
|
|
196
212
|
|
|
197
213
|
/** "Run now" from a settings surface. Bounded per day; refused while a brief is live. */
|
|
198
|
-
|
|
214
|
+
runNow(): Promise<MorningBriefRun> {
|
|
215
|
+
return this.serialize(() => this.runNowInner())
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async runNowInner(): Promise<MorningBriefRun> {
|
|
199
219
|
if (!this.deps.durableJobsEnabled()) {
|
|
200
220
|
throw new MorningBriefRunError(409, 'durable_jobs_off', 'Turn on Background jobs in COS Control to run the brief.')
|
|
201
221
|
}
|
|
@@ -227,14 +247,45 @@ export class MorningBriefScheduler {
|
|
|
227
247
|
? scheduledClientJobId(day, this.config.timezone)
|
|
228
248
|
: randomUUID()
|
|
229
249
|
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
250
|
+
// Adopt before minting, on EVERY scheduled fire — not only the crash-resume
|
|
251
|
+
// branch. A submit that threw after admission, a ledger row lost to
|
|
252
|
+
// quarantine, and a plain resume all find the coordinator already holding
|
|
253
|
+
// this day's identity. A manual fire mints a fresh UUID and can never hit,
|
|
254
|
+
// so it skips the round trip. Reusing `existing.globalMsgNum` is safe even
|
|
255
|
+
// though liveReservations() skips submitError rows: the store's own
|
|
256
|
+
// reservation source (query-job-runtime registers the coordinator's live
|
|
257
|
+
// identities) holds an admitted job's number independently of the ledger.
|
|
258
|
+
if (trigger === 'scheduled') {
|
|
233
259
|
const existing = await this.deps.findByClientGeneration(clientJobId, 1).catch(() => undefined)
|
|
234
260
|
if (existing) {
|
|
235
|
-
const
|
|
261
|
+
const priorRow = resume ?? this.ledger.runs.filter(r => r.day === day && r.trigger === trigger).at(-1)
|
|
262
|
+
// Carry every current and future ledger field from the row being
|
|
263
|
+
// replaced; drop only the submit error (the job exists), then override
|
|
264
|
+
// the identity and provenance columns from what is actually admitted.
|
|
265
|
+
const { submitError: _dropped, ...carried } = priorRow ?? {}
|
|
266
|
+
const adopted: MorningBriefRun = {
|
|
267
|
+
...carried,
|
|
268
|
+
id: priorRow?.id ?? randomUUID(),
|
|
269
|
+
day,
|
|
270
|
+
trigger,
|
|
271
|
+
attempt,
|
|
272
|
+
firedAt: priorRow?.firedAt ?? existing.acceptedAt,
|
|
273
|
+
clientJobId,
|
|
274
|
+
generation: 1,
|
|
275
|
+
sessionId: priorRow?.sessionId ?? existing.sessionId,
|
|
276
|
+
messageEra: priorRow?.messageEra ?? existing.messageEra ?? this.deps.currentMessageEra(),
|
|
277
|
+
globalMsgNum: priorRow?.globalMsgNum ?? existing.globalMsgNum ?? this.deps.currentMessageMax() + 1,
|
|
278
|
+
sections: priorRow?.sections ?? briefSections(this.config, day),
|
|
279
|
+
jobId: existing.jobId,
|
|
280
|
+
lastKnownStatus: existing.status,
|
|
281
|
+
}
|
|
282
|
+
if (priorRow?.globalMsgNum == null && existing.globalMsgNum == null) {
|
|
283
|
+
this.log(`adopted job ${existing.jobId} carried no message number; minted #${adopted.globalMsgNum}`)
|
|
284
|
+
}
|
|
285
|
+
// Reusing the prior row's id makes replaceRun REPLACE the day's row
|
|
286
|
+
// rather than push a second one for the same job.
|
|
236
287
|
this.replaceRun(adopted)
|
|
237
|
-
this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId}`)
|
|
288
|
+
this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId} (${priorRow ? `replacing ledger row ${priorRow.id}` : 'no ledger row'})`)
|
|
238
289
|
return adopted
|
|
239
290
|
}
|
|
240
291
|
}
|
|
@@ -272,6 +323,9 @@ export class MorningBriefScheduler {
|
|
|
272
323
|
activityToolMode: 'status',
|
|
273
324
|
attachmentIds: [],
|
|
274
325
|
attachmentRefs: [],
|
|
326
|
+
// The label every surface renders as ROUTINE. Outside the fingerprint,
|
|
327
|
+
// so a run admitted on 6.43.3 still adopts here by identity.
|
|
328
|
+
origin: { kind: 'routine', id: MORNING_BRIEF_ROUTINE_ID },
|
|
275
329
|
})
|
|
276
330
|
const accepted: MorningBriefRun = { ...run, jobId: admission.job.jobId, lastKnownStatus: admission.job.status }
|
|
277
331
|
this.replaceRun(accepted)
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { cosBrainDir } from './launch-dir.js'
|
|
3
|
+
import { resolveAgentBinary } from './cursor-model-catalog.js'
|
|
3
4
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
4
5
|
|
|
5
|
-
export type ProofProvider = 'claude' | 'codex'
|
|
6
|
+
export type ProofProvider = 'claude' | 'codex' | 'cursor'
|
|
7
|
+
export const PROOF_PROVIDERS: readonly ProofProvider[] = ['claude', 'codex', 'cursor']
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Why a proof did not pass, as a stable code COS Control can branch on
|
|
11
|
+
* without reading logs. `provider_quota` is the one that matters: a vendor
|
|
12
|
+
* session or usage limit is a fact about the vendor's meter, not about the
|
|
13
|
+
* candidate server, and an update must not roll back on it when another
|
|
14
|
+
* provider proves. (2026-09-01: six 6.43.1 updates rolled back on Claude's
|
|
15
|
+
* session limit while Codex proved in 7 s.)
|
|
16
|
+
*/
|
|
17
|
+
export type ProviderProofCode =
|
|
18
|
+
| 'provider_quota'
|
|
19
|
+
| 'provider_auth'
|
|
20
|
+
| 'provider_context_overflow'
|
|
21
|
+
| 'provider_missing'
|
|
22
|
+
| 'provider_timeout'
|
|
23
|
+
| 'provider_canceled'
|
|
24
|
+
| 'provider_bad_answer'
|
|
25
|
+
| 'provider_failed'
|
|
6
26
|
|
|
7
27
|
export interface ProviderProofResult {
|
|
8
28
|
provider: ProofProvider
|
|
@@ -10,6 +30,7 @@ export interface ProviderProofResult {
|
|
|
10
30
|
durationMs: number
|
|
11
31
|
cached: boolean
|
|
12
32
|
error?: string
|
|
33
|
+
code?: ProviderProofCode
|
|
13
34
|
}
|
|
14
35
|
|
|
15
36
|
const PROOF_TOKEN = 'COS_CONTROL_OK'
|
|
@@ -110,13 +131,14 @@ export function runBounded(
|
|
|
110
131
|
export const CLAUDE_PROOF_MODEL = 'haiku'
|
|
111
132
|
export const CLAUDE_PROOF_TIMEOUT_MS = 45_000
|
|
112
133
|
|
|
113
|
-
/** No MCP servers for the proof
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
134
|
+
/** No MCP servers for the proof, stated explicitly rather than relied on
|
|
135
|
+
* from CLAUDE_CODE_SAFE_MODE alone. Without safe mode, Claude Code 2.1.251
|
|
136
|
+
* turns `--tools ''` into "load the whole catalog, expose none of it", and
|
|
137
|
+
* on a Mac with a large MCP fleet that catalog alone is ~244K tokens against
|
|
138
|
+
* Haiku's 200K window ("Prompt is too long", exit 1, zero API time). Safe
|
|
139
|
+
* mode currently prevents that; an explicit empty config with
|
|
140
|
+
* --strict-mcp-config keeps the proof under 20K tokens whatever a future
|
|
141
|
+
* CLI does with the safe-mode flag. */
|
|
120
142
|
export const CLAUDE_PROOF_MCP_CONFIG = '{"mcpServers":{}}'
|
|
121
143
|
|
|
122
144
|
export function claudeProofArgs(): string[] {
|
|
@@ -172,11 +194,85 @@ function safeProofError(result: ProcessResult): string {
|
|
|
172
194
|
return 'provider returned no valid proof response'
|
|
173
195
|
}
|
|
174
196
|
|
|
197
|
+
const QUOTA_RE = /usage limit|hit your (?:usage |session )?limit|session limit|rate.?limit|too many requests|\b429\b|\bquota\b|resets? (?:at|in) |over capacity|overloaded|\b529\b|plan limit/i
|
|
198
|
+
const AUTH_RE = /not logged in|please (?:log ?in|sign in)|invalid api key|authentication|unauthori[sz]ed|\b401\b|\b403\b|login required|token (?:has )?expired|no credentials/i
|
|
199
|
+
const OVERFLOW_RE = /prompt is too long|context window|too many tokens|exceeds the (?:model|context)/i
|
|
200
|
+
const MISSING_RE = /ENOENT|command not found|no such file/i
|
|
201
|
+
|
|
202
|
+
/** Vendor text the provider printed, so the code can be derived without ever
|
|
203
|
+
* returning that text to a caller. Claude's JSON `result` carries the error
|
|
204
|
+
* sentence on `is_error`; Codex and Cursor print it on stderr or as a
|
|
205
|
+
* result/error event. */
|
|
206
|
+
export function classifyProofFailure(result: ProcessResult, answer: string, expected = PROOF_TOKEN): ProviderProofCode {
|
|
207
|
+
if (result.aborted) return 'provider_canceled'
|
|
208
|
+
if (result.timedOut) return 'provider_timeout'
|
|
209
|
+
const haystack = `${result.stderr}\n${result.stdout}`.slice(0, 40_000)
|
|
210
|
+
if (result.code === null && MISSING_RE.test(haystack)) return 'provider_missing'
|
|
211
|
+
if (OVERFLOW_RE.test(haystack)) return 'provider_context_overflow'
|
|
212
|
+
if (QUOTA_RE.test(haystack)) return 'provider_quota'
|
|
213
|
+
if (AUTH_RE.test(haystack)) return 'provider_auth'
|
|
214
|
+
if (result.code === 0) return answer === expected ? 'provider_failed' : 'provider_bad_answer'
|
|
215
|
+
return 'provider_failed'
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One safe sentence per code; never the vendor's text. */
|
|
219
|
+
export function describeProofCode(code: ProviderProofCode, result: ProcessResult): string {
|
|
220
|
+
switch (code) {
|
|
221
|
+
case 'provider_quota': return 'provider session or usage limit reached'
|
|
222
|
+
case 'provider_auth': return 'provider is not signed in'
|
|
223
|
+
case 'provider_context_overflow': return 'provider refused the proof prompt as too long'
|
|
224
|
+
case 'provider_missing': return 'provider binary is not installed or not on PATH'
|
|
225
|
+
case 'provider_timeout': return 'provider proof timed out'
|
|
226
|
+
case 'provider_canceled': return 'provider proof canceled'
|
|
227
|
+
case 'provider_bad_answer': return 'provider answered but not with the proof token'
|
|
228
|
+
default: return safeProofError(result)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const CURSOR_PROOF_TIMEOUT_MS = 120_000
|
|
233
|
+
|
|
234
|
+
/** Cursor `agent` in documented read-only ask mode, one stream-json turn, the
|
|
235
|
+
* prompt on stdin like the bridge. No `--model`: the account default is the
|
|
236
|
+
* readiness question, not any particular model. */
|
|
237
|
+
export function cursorProofArgs(workspace = cosBrainDir() ?? process.cwd()): string[] {
|
|
238
|
+
return ['-p', '--mode', 'ask', '--output-format', 'stream-json', '--trust', '--workspace', workspace]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The final `result` event wins; assistant deltas with a timestamp are the
|
|
242
|
+
* fallback, mirroring cursor-bridge's extractCursorResponseText. */
|
|
243
|
+
export function cursorProofText(stdout: string): string {
|
|
244
|
+
let deltas = ''
|
|
245
|
+
for (const line of stdout.split('\n')) {
|
|
246
|
+
if (!line.trim()) continue
|
|
247
|
+
let event: any
|
|
248
|
+
try { event = JSON.parse(line) } catch { continue }
|
|
249
|
+
const type = String(event?.type ?? '').toLowerCase()
|
|
250
|
+
if (type === 'result') {
|
|
251
|
+
if (event?.subtype === 'success' && event?.is_error !== true && typeof event?.result === 'string') return event.result.trim()
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
if (type !== 'assistant' || typeof event?.timestamp_ms !== 'number') continue
|
|
255
|
+
if (event?.model_call_id != null && event.model_call_id !== '') continue
|
|
256
|
+
const content = event?.message?.content
|
|
257
|
+
if (Array.isArray(content)) {
|
|
258
|
+
for (const block of content) {
|
|
259
|
+
if (typeof block === 'string') deltas += block
|
|
260
|
+
else if (typeof block?.text === 'string') deltas += block.text
|
|
261
|
+
}
|
|
262
|
+
} else if (typeof event?.text === 'string') {
|
|
263
|
+
deltas += event.text
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return deltas.trim()
|
|
267
|
+
}
|
|
268
|
+
|
|
175
269
|
async function executeProof(provider: ProofProvider, signal?: AbortSignal): Promise<ProviderProofResult> {
|
|
176
270
|
const started = Date.now()
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
271
|
+
let result: ProcessResult
|
|
272
|
+
if (provider === 'claude') {
|
|
273
|
+
result = await runBounded('claude', claudeProofArgs(), '', CLAUDE_PROOF_TIMEOUT_MS, signal)
|
|
274
|
+
} else if (provider === 'codex') {
|
|
275
|
+
result = await runBounded('codex', [
|
|
180
276
|
'exec',
|
|
181
277
|
'--sandbox', 'read-only',
|
|
182
278
|
'--skip-git-repo-check',
|
|
@@ -185,16 +281,25 @@ async function executeProof(provider: ProofProvider, signal?: AbortSignal): Prom
|
|
|
185
281
|
'--ephemeral',
|
|
186
282
|
'-',
|
|
187
283
|
], PROOF_PROMPT, 120_000, signal)
|
|
284
|
+
} else {
|
|
285
|
+
const binary = resolveAgentBinary()
|
|
286
|
+
result = binary
|
|
287
|
+
? await runBounded(binary, cursorProofArgs(), PROOF_PROMPT, CURSOR_PROOF_TIMEOUT_MS, signal)
|
|
288
|
+
: { code: null, stdout: '', stderr: 'ENOENT: cursor agent binary not found', timedOut: false, aborted: false }
|
|
289
|
+
}
|
|
188
290
|
const text = provider === 'claude'
|
|
189
291
|
? claudeProofText(result.stdout)
|
|
190
|
-
: codexProofText(result.stdout)
|
|
292
|
+
: provider === 'codex' ? codexProofText(result.stdout) : cursorProofText(result.stdout)
|
|
191
293
|
const ok = result.code === 0 && text === PROOF_TOKEN
|
|
294
|
+
if (ok) return { provider, ok, durationMs: Date.now() - started, cached: false }
|
|
295
|
+
const code = classifyProofFailure(result, text)
|
|
192
296
|
return {
|
|
193
297
|
provider,
|
|
194
|
-
ok,
|
|
298
|
+
ok: false,
|
|
195
299
|
durationMs: Date.now() - started,
|
|
196
300
|
cached: false,
|
|
197
|
-
|
|
301
|
+
error: describeProofCode(code, result),
|
|
302
|
+
code,
|
|
198
303
|
}
|
|
199
304
|
}
|
|
200
305
|
|
|
@@ -112,6 +112,14 @@ function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' | 'o
|
|
|
112
112
|
return isCodexModel(model) ? 'codex' : 'claude'
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** The origin label as it travels on the display bus and the message views:
|
|
116
|
+
* FLATTENED (`origin: 'routine', originId: 'morning-brief'`) so every client
|
|
117
|
+
* parses one shape, and identical on `start`, `done` and `error`. Spread LAST
|
|
118
|
+
* into each event so no provider metadata key can overwrite it. */
|
|
119
|
+
function originStamp(request: QueryJobRequest): { origin?: NonNullable<QueryJobRequest['origin']>['kind']; originId?: string } {
|
|
120
|
+
return request.origin ? { origin: request.origin.kind, originId: request.origin.id } : {}
|
|
121
|
+
}
|
|
122
|
+
|
|
115
123
|
/** Project the authoritative terminal journal into the derived conversation
|
|
116
124
|
* cache. Journaled request/response text always wins over bridge-written
|
|
117
125
|
* partial rows; validated media refs may be merged because output media can
|
|
@@ -148,6 +156,7 @@ async function projectPublicConversationTerminal(
|
|
|
148
156
|
request.attachmentRefs,
|
|
149
157
|
request.messageEra,
|
|
150
158
|
normalizeModelPreference(request.model),
|
|
159
|
+
request.origin,
|
|
151
160
|
)
|
|
152
161
|
reconcileExchangeByJobIdentity(
|
|
153
162
|
request.sessionId,
|
|
@@ -158,6 +167,7 @@ async function projectPublicConversationTerminal(
|
|
|
158
167
|
mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
|
|
159
168
|
request.messageEra,
|
|
160
169
|
normalizeModelPreference(request.model),
|
|
170
|
+
request.origin,
|
|
161
171
|
)
|
|
162
172
|
flushConversationToDisk()
|
|
163
173
|
}
|
|
@@ -203,6 +213,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
203
213
|
sessionId,
|
|
204
214
|
cliSessionId,
|
|
205
215
|
...metadata,
|
|
216
|
+
...originStamp(request),
|
|
206
217
|
} })
|
|
207
218
|
},
|
|
208
219
|
onProviderProcess: metadata => callbacks.onProviderProcess({
|
|
@@ -281,6 +292,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
281
292
|
cliSessionId,
|
|
282
293
|
...runMetadata,
|
|
283
294
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
295
|
+
...originStamp(request),
|
|
284
296
|
} })
|
|
285
297
|
} finally {
|
|
286
298
|
attachmentLease?.release()
|
|
@@ -296,6 +308,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
296
308
|
turnId,
|
|
297
309
|
messageEra: request.messageEra,
|
|
298
310
|
globalMsgNum: request.globalMsgNum,
|
|
311
|
+
...originStamp(request),
|
|
299
312
|
error,
|
|
300
313
|
} })
|
|
301
314
|
},
|
|
@@ -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,8 @@ export class QueryJobStore {
|
|
|
317
318
|
journalFailures: 0,
|
|
318
319
|
interruptedOnBoot: 0,
|
|
319
320
|
evictedHydratedJobs: 0,
|
|
321
|
+
originDropped: 0,
|
|
322
|
+
fingerprintMismatches: 0,
|
|
320
323
|
lastErrorCode: null,
|
|
321
324
|
lastSuccessfulWriteAt: null,
|
|
322
325
|
rootFingerprint: createHash('sha256').update(options.root).digest('hex').slice(0, 16),
|
|
@@ -436,10 +439,25 @@ export class QueryJobStore {
|
|
|
436
439
|
if (!hydrated) {
|
|
437
440
|
if (record.type !== 'accepted' || !record.request || record.eventSeq !== 1) return undefined
|
|
438
441
|
let request: QueryJobRequest
|
|
442
|
+
// Lenient: a record written by another build hydrates with an unknown
|
|
443
|
+
// origin dropped (and counted) rather than being discarded.
|
|
439
444
|
try { request = parseQueryJobRequest(record.request) } catch { return undefined }
|
|
445
|
+
// Counted once per job: ensureHydrated replays this same record after an
|
|
446
|
+
// LRU eviction, and by then the identity is already registered below.
|
|
447
|
+
// (The mismatch counter needs no such guard: a mismatch returns before
|
|
448
|
+
// the identity is registered, so a replay can never reach it.)
|
|
449
|
+
if (!this.identitiesByJobId.has(record.jobId) && originWasDropped(record.request, request)) this.health.originDropped++
|
|
440
450
|
const fingerprint = requestFingerprint(request)
|
|
441
|
-
if (fingerprint !== record.requestFingerprint
|
|
442
|
-
|
|
451
|
+
if (fingerprint !== record.requestFingerprint) {
|
|
452
|
+
// A mismatch DROPS the job from hydration. Say so once, loudly: a
|
|
453
|
+
// counter in /api/health is read after the history has already gone.
|
|
454
|
+
if (this.health.fingerprintMismatches === 0) {
|
|
455
|
+
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)`)
|
|
456
|
+
}
|
|
457
|
+
this.health.fingerprintMismatches++
|
|
458
|
+
return undefined
|
|
459
|
+
}
|
|
460
|
+
if (request.clientJobId !== record.clientJobId
|
|
443
461
|
|| request.generation !== record.generation) return undefined
|
|
444
462
|
const acceptedAt = record.persistedAt
|
|
445
463
|
const retentionUntil = new Date(new Date(acceptedAt).getTime() + this.retentionDays * 86_400_000).toISOString()
|
|
@@ -626,7 +644,13 @@ export class QueryJobStore {
|
|
|
626
644
|
|
|
627
645
|
async admit(raw: unknown): Promise<QueryJobAdmissionResult> {
|
|
628
646
|
await this.ensureInitialized()
|
|
629
|
-
|
|
647
|
+
// Strict only where strictness is safe on the public route: a KNOWN kind
|
|
648
|
+
// with an id this server cannot accept is a bug in the scheduler or the
|
|
649
|
+
// dispatcher that stamped it and throws (→ 400). An unknown kind, or the
|
|
650
|
+
// phone's bare-string stamp, is dropped and counted below — once per job
|
|
651
|
+
// created, never per retry of the same identity.
|
|
652
|
+
const request = parseQueryJobRequest(raw, { strictOrigin: true })
|
|
653
|
+
const originDropped = originWasDropped(raw, request)
|
|
630
654
|
const fingerprint = requestFingerprint(request)
|
|
631
655
|
const existingIdentity = this.identitiesByKey.get(identityKey(request.clientJobId, request.generation))
|
|
632
656
|
if (existingIdentity) await this.ensureHydrated(existingIdentity.jobId)
|
|
@@ -663,6 +687,7 @@ export class QueryJobStore {
|
|
|
663
687
|
const highestGeneration = lineage.reduce((max, item) => Math.max(max, item.generation), 0)
|
|
664
688
|
if (request.generation <= highestGeneration) throw new QueryJobGenerationOrderError(request.clientJobId)
|
|
665
689
|
|
|
690
|
+
if (originDropped) this.health.originDropped++
|
|
666
691
|
const now = this.now().toISOString()
|
|
667
692
|
const jobId = randomUUID()
|
|
668
693
|
const turnId = randomUUID()
|
|
@@ -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,6 +102,10 @@ 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
|
|
75
109
|
}
|
|
76
110
|
|
|
77
111
|
export interface QueryJobProviderLinkage {
|
|
@@ -177,6 +211,13 @@ export interface QueryJobStoreHealth {
|
|
|
177
211
|
journalFailures: number
|
|
178
212
|
interruptedOnBoot: number
|
|
179
213
|
evictedHydratedJobs: number
|
|
214
|
+
/** Requests whose `origin` was present but not one this build recognises
|
|
215
|
+
* (a bare string, or an object of a later build's kind) and was dropped. */
|
|
216
|
+
originDropped: number
|
|
217
|
+
/** Journal `accepted` records whose stored fingerprint no longer matches the
|
|
218
|
+
* running build's `requestFingerprint` — each one is a job that did NOT
|
|
219
|
+
* hydrate. Zero is the only healthy value. */
|
|
220
|
+
fingerprintMismatches: number
|
|
180
221
|
lastErrorCode: string | null
|
|
181
222
|
lastSuccessfulWriteAt: string | null
|
|
182
223
|
rootFingerprint: string
|
|
@@ -226,7 +267,7 @@ function boundedContent(value: unknown, field: string, max: number, allowEmpty =
|
|
|
226
267
|
|
|
227
268
|
/** Parse untrusted admission input into the only request shape allowed in the
|
|
228
269
|
* private journal. Unknown keys are dropped before fingerprinting. */
|
|
229
|
-
export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
|
|
270
|
+
export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequestOptions = {}): QueryJobRequest {
|
|
230
271
|
if (!raw || typeof raw !== 'object') throw new QueryJobValidationError('invalid_request')
|
|
231
272
|
const input = raw as Record<string, unknown>
|
|
232
273
|
const clientJobId = requiredString(input.clientJobId, 'client_job_id', 36).toLowerCase()
|
|
@@ -272,6 +313,29 @@ export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
|
|
|
272
313
|
? input.activityToolMode
|
|
273
314
|
: 'status'
|
|
274
315
|
|
|
316
|
+
// `== null` covers both absent and JSON null, the way `reference` and
|
|
317
|
+
// `globalMsgNum` are read above. A non-object (the phone's local `'g2'`
|
|
318
|
+
// string) is silently absent on every path; the store counts the drop.
|
|
319
|
+
let origin: QueryJobOrigin | undefined
|
|
320
|
+
if (input.origin != null && typeof input.origin === 'object') {
|
|
321
|
+
const candidate = input.origin as Record<string, unknown>
|
|
322
|
+
const kind = candidate.kind
|
|
323
|
+
const id = candidate.id
|
|
324
|
+
if (kind === 'routine' || kind === 'task') {
|
|
325
|
+
if (typeof id === 'string' && QUERY_JOB_ORIGIN_ID_RE.test(id)) {
|
|
326
|
+
origin = { kind, id }
|
|
327
|
+
} else if (options.strictOrigin) {
|
|
328
|
+
// A kind this build knows with an id it cannot accept is a bug in the
|
|
329
|
+
// caller that stamped it (the scheduler, the dispatcher): loud.
|
|
330
|
+
throw new QueryJobValidationError('invalid_origin')
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// A kind this build does NOT know is a label it cannot render, never a
|
|
334
|
+
// reason to refuse the job: the public job route reaches this parser with
|
|
335
|
+
// strictOrigin, so an unknown kind must degrade to unlabeled (and be
|
|
336
|
+
// counted by the store), or a newer client would have every prompt 400'd.
|
|
337
|
+
}
|
|
338
|
+
|
|
275
339
|
const attachmentIds = parseMediaIdList(input.attachmentIds)
|
|
276
340
|
const attachmentRefs = parseMediaAttachmentRefs(input.attachmentRefs ?? input.attachments)
|
|
277
341
|
if (!query.trim() && attachmentIds.length === 0 && attachmentRefs.length === 0) {
|
|
@@ -295,9 +359,19 @@ export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
|
|
|
295
359
|
attachmentIds,
|
|
296
360
|
attachmentRefs,
|
|
297
361
|
activityToolMode,
|
|
362
|
+
...(origin ? { origin } : {}),
|
|
298
363
|
}
|
|
299
364
|
}
|
|
300
365
|
|
|
366
|
+
/** True when the raw admission/journal input carried an `origin` the parser
|
|
367
|
+
* did not keep — a bare string, or an object shape this build does not know.
|
|
368
|
+
* The store counts these; the parser stays pure. */
|
|
369
|
+
export function originWasDropped(raw: unknown, request: QueryJobRequest): boolean {
|
|
370
|
+
if (!raw || typeof raw !== 'object') return false
|
|
371
|
+
const rawOrigin = (raw as Record<string, unknown>).origin
|
|
372
|
+
return rawOrigin != null && request.origin == null
|
|
373
|
+
}
|
|
374
|
+
|
|
301
375
|
function canonical(value: unknown): unknown {
|
|
302
376
|
if (Array.isArray(value)) return value.map(canonical)
|
|
303
377
|
if (!value || typeof value !== 'object') return value
|
|
@@ -305,8 +379,49 @@ function canonical(value: unknown): unknown {
|
|
|
305
379
|
return Object.fromEntries(Object.keys(record).sort().map(key => [key, canonical(record[key])]))
|
|
306
380
|
}
|
|
307
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The request keys that make up a job's IDENTITY — frozen at the sixteen keys
|
|
384
|
+
* `QueryJobRequest` had before `origin` existed. Provenance and enforcement
|
|
385
|
+
* keys (`origin`, and any later `trustMode`/`toolAllowlist`) are deliberately
|
|
386
|
+
* outside it: a build that does not know a key must still hydrate the journal
|
|
387
|
+
* a newer build wrote, and a rollback must never discard a week of jobs.
|
|
388
|
+
*
|
|
389
|
+
* Consequence, stated: two submissions for the same (clientJobId, generation)
|
|
390
|
+
* that differ only in those excluded keys are the SAME job, and the second
|
|
391
|
+
* returns the first. The scheduled brief reuses one identity per DAY across
|
|
392
|
+
* its three attempts, and its excluded keys never differ between attempts; a
|
|
393
|
+
* genuine resubmit differs in `globalMsgNum`, which IS in the fingerprint, so
|
|
394
|
+
* it conflicts rather than silently adopting. Phone prompts never send an
|
|
395
|
+
* excluded key.
|
|
396
|
+
*
|
|
397
|
+
* Pick contract: an absent key stays absent. The parser never materialises an
|
|
398
|
+
* optional as null/''/0/[] (every optional is a conditional spread in its
|
|
399
|
+
* return literal), and `JSON.stringify` drops an undefined value, so the pick
|
|
400
|
+
* needs no guard of its own. A `?? null` pick WOULD re-hash every stored
|
|
401
|
+
* request and silently drop the journal; the test pins that property.
|
|
402
|
+
*/
|
|
403
|
+
export const FINGERPRINT_KEYS = [
|
|
404
|
+
'clientJobId', 'generation', 'query', 'sessionId', 'model', 'effort',
|
|
405
|
+
'cursorExecutionMode', 'messageEra', 'globalMsgNum', 'reference', 'handoffCode',
|
|
406
|
+
'handoffLatest', 'clientQueueItemId', 'attachmentIds', 'attachmentRefs',
|
|
407
|
+
'activityToolMode',
|
|
408
|
+
] as const satisfies readonly (keyof QueryJobRequest)[]
|
|
409
|
+
|
|
410
|
+
/** The request keys deliberately OUTSIDE the identity. Every key of
|
|
411
|
+
* `QueryJobRequest` must appear in exactly one of the two lists: adding a key
|
|
412
|
+
* without naming it here or above fails to compile, so the author picks a side. */
|
|
413
|
+
export const FINGERPRINT_EXCLUDED = ['origin'] as const satisfies readonly (keyof QueryJobRequest)[]
|
|
414
|
+
type FingerprintUncovered = Exclude<keyof QueryJobRequest, typeof FINGERPRINT_KEYS[number] | typeof FINGERPRINT_EXCLUDED[number]>
|
|
415
|
+
export const FINGERPRINT_KEYS_COVER_REQUEST: FingerprintUncovered extends never ? true : never = true
|
|
416
|
+
|
|
417
|
+
function pickFingerprintKeys(request: QueryJobRequest): Record<string, unknown> {
|
|
418
|
+
const picked: Record<string, unknown> = {}
|
|
419
|
+
for (const key of FINGERPRINT_KEYS) picked[key] = request[key]
|
|
420
|
+
return picked
|
|
421
|
+
}
|
|
422
|
+
|
|
308
423
|
export function requestFingerprint(request: QueryJobRequest): string {
|
|
309
|
-
return createHash('sha256').update(JSON.stringify(canonical(request))).digest('hex')
|
|
424
|
+
return createHash('sha256').update(JSON.stringify(canonical(pickFingerprintKeys(request)))).digest('hex')
|
|
310
425
|
}
|
|
311
426
|
|
|
312
427
|
export function isTerminalQueryJobStatus(status: QueryJobStatus): status is QueryJobTerminalStatus {
|
package/server/routes/health.ts
CHANGED
|
@@ -101,6 +101,9 @@ function durableQueryJobStatus() {
|
|
|
101
101
|
retainedIdentities: runtime.store.retainedIdentities,
|
|
102
102
|
subscribers: runtime.store.subscribers,
|
|
103
103
|
malformedRows: runtime.store.malformedRows,
|
|
104
|
+
originDropped: runtime.store.originDropped,
|
|
105
|
+
fingerprintMismatches: runtime.store.fingerprintMismatches,
|
|
106
|
+
evictedHydratedJobs: runtime.store.evictedHydratedJobs,
|
|
104
107
|
journalFailures: runtime.store.journalFailures,
|
|
105
108
|
interruptedOnBoot: runtime.store.interruptedOnBoot,
|
|
106
109
|
lastErrorCode: runtime.store.lastErrorCode,
|
|
@@ -126,7 +126,8 @@ export function resolveFromArchiveDir(
|
|
|
126
126
|
* global numbers (the archive-lib read path strips globalMsgNum; the browser
|
|
127
127
|
* needs it so "reference message N" is self-evident from the screen). Same
|
|
128
128
|
* user->next-assistant pairing as the lib; the pair's number is the user
|
|
129
|
-
* turn's stamp (falling back to the assistant's). Dir-param form for tests.
|
|
129
|
+
* turn's stamp (falling back to the assistant's). Dir-param form for tests.
|
|
130
|
+
* Deliberately no origin label here (6.43.4): the day view carries it. */
|
|
130
131
|
export function readArchiveChatNumbered(
|
|
131
132
|
dir: string,
|
|
132
133
|
date: string,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
|
-
import { runProviderProof, type ProofProvider } from '../lib/provider-proof.js'
|
|
2
|
+
import { PROOF_PROVIDERS, runProviderProof, type ProofProvider } from '../lib/provider-proof.js'
|
|
3
3
|
import {
|
|
4
4
|
acquireMaintenanceWork,
|
|
5
5
|
maintenanceErrorPayload,
|
|
@@ -17,8 +17,8 @@ providerProofRouter.post('/diagnostics/provider-proof', async (req, res) => {
|
|
|
17
17
|
|| address.startsWith('127.') || address.startsWith('::ffff:127.')
|
|
18
18
|
if (!loopback) return res.status(403).json({ error: 'loopback_required' })
|
|
19
19
|
const provider = req.body?.provider
|
|
20
|
-
if (provider
|
|
21
|
-
return res.status(400).json({ error: 'provider must be claude or
|
|
20
|
+
if (!PROOF_PROVIDERS.includes(provider)) {
|
|
21
|
+
return res.status(400).json({ error: 'provider must be claude, codex, or cursor', code: 'provider_unknown' })
|
|
22
22
|
}
|
|
23
23
|
const controllerProof = maintenanceOperationCredentialsValid({
|
|
24
24
|
leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
|
|
@@ -79,7 +79,9 @@ sessionsRouter.post('/sessions/:id/context-break', (req, res) => {
|
|
|
79
79
|
res.json({ ok: true })
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
// GET /api/sessions/:id/messages — client-compatible format (paired Q&A)
|
|
82
|
+
// GET /api/sessions/:id/messages — client-compatible format (paired Q&A).
|
|
83
|
+
// Deliberately carries no origin/originId (6.43.4): this is the legacy paired
|
|
84
|
+
// shape. The labelled views are today/all-messages and the day archive.
|
|
83
85
|
sessionsRouter.get('/sessions/:id/messages', (req, res) => {
|
|
84
86
|
const exchanges = getHistory(req.params.id)
|
|
85
87
|
if (exchanges.length === 0) {
|
|
@@ -315,6 +317,8 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
315
317
|
globalMsgNum?: number
|
|
316
318
|
messageEra?: string
|
|
317
319
|
modelPreference?: string
|
|
320
|
+
origin?: 'routine' | 'task'
|
|
321
|
+
originId?: string
|
|
318
322
|
attachments?: MediaAttachmentRef[]
|
|
319
323
|
}> = []
|
|
320
324
|
const liveSessions = getActiveSessions()
|
|
@@ -331,6 +335,10 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
331
335
|
const messageEra = ex.messageEra ?? next.messageEra
|
|
332
336
|
const attachments = turnAttachments(session.id, globalMsgNum, messageEra, activeEra, ex.attachments, next.attachments)
|
|
333
337
|
const modelPreference = resolveExchangePairModel(ex, next, session.modelPreference)
|
|
338
|
+
// Both halves of a pair are stamped together by the terminal
|
|
339
|
+
// projection; pick BOTH fields from ONE half so a mid-run insert
|
|
340
|
+
// (stamped later) can never pair one half's kind with the other's id.
|
|
341
|
+
const stamped = ex.origin ? ex : next
|
|
334
342
|
liveMessages.push({
|
|
335
343
|
query: ex.content,
|
|
336
344
|
text: next.content,
|
|
@@ -341,6 +349,7 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
341
349
|
...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
|
|
342
350
|
...(messageEra ? { messageEra } : {}),
|
|
343
351
|
...(modelPreference ? { modelPreference } : {}),
|
|
352
|
+
...(stamped.origin ? { origin: stamped.origin, ...(stamped.originId ? { originId: stamped.originId } : {}) } : {}),
|
|
344
353
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
345
354
|
})
|
|
346
355
|
i++
|