@gotcos/glasses-server 6.62.1 → 6.63.1

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 CHANGED
@@ -1,3 +1,13 @@
1
+ ## 6.63.1 — 2026-10-06 — Weather survives a restart
2
+
3
+ - Home weather survives a server restart. The last good weather and place are kept in `<data>/welcome-weather.json`; when a forecast lookup fails (timeout, HTTP error, bad payload), the route answers with that saved weather if it is under 3 hours old, never older. A failed forecast or place lookup is now logged once per kind per 10 minutes, with the cause and whether a saved weather was served. Before, after a restart the first failed lookup sent the glasses no weather and nothing said why. Pairs with glasses 6.10.603, which also retries on Home and keeps its own last good weather.
4
+ - Security: `proxy-addr` 2.0.8 (through Express), for GHSA-jqcg-44mw-7w3h (IP spoofing through an IPv4-mapped IPv6 trust subnet). Lockfile only.
5
+
6
+ ## 6.63.0 — 2026-10-05 — Background forks
7
+
8
+ - Background forks. `POST /api/agent-sessions/:provider/:threadId/fork` accepts an optional `clientForkId`: once every gate passes it answers 202 and runs the fork in the background, and `GET /api/agent-session-forks/:clientForkId` returns `running` or the exact answer the synchronous route would have sent. A repeated id replays its job and never makes a second copy. Outcomes are kept in `<data>/fork-jobs.json` (7 days, 200 rows); a fork still running when the server stops reads back as orphan-possible ("look in Sessions before forking again"), never as failed. Without the id the route is unchanged. Why: on 2026-10-05 COS Control waited 5 minutes for a 6.5-minute fork that succeeded and told Miles the server had stopped. Pairs with COS Control 0.5.257; older clients keep the synchronous route.
9
+ - A fork that recovered from a transient retry (Claude `api_retry` "overloaded"/"rate_limit", Codex "Reconnecting… 429") and then failed is reported by the provider's final error, not the retry: "too long" no longer reads as "usage limit". (6.62.1 QA)
10
+
1
11
  ## 6.62.1 — 2026-10-05 — Combined provider parity and handoff hardening
2
12
 
3
13
  - Includes the 6.62.0 provider parity, cancellation, hook readiness, and safe Cursor continuation updates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.62.1",
3
+ "version": "6.63.1",
4
4
  "description": "COS Glasses: 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": {
package/server/index.ts CHANGED
@@ -11,7 +11,7 @@ import { readFileSync, existsSync } from 'node:fs'
11
11
  import { networkInterfaces, homedir } from 'node:os'
12
12
  import { join } from 'node:path'
13
13
  import { execSync } from 'node:child_process'
14
- import { randomBytes } from 'node:crypto'
14
+ import { randomBytes, randomUUID } from 'node:crypto'
15
15
  import { healthRouter } from './routes/health.js'
16
16
  import { diagRouter } from './routes/diag.js'
17
17
  import { createClientInstanceRouter } from './routes/client-instance.js'
@@ -67,6 +67,7 @@ import { parseAgentSession } from './lib/agent-session-store.js'
67
67
  import { makeLiveTurnDeliverer } from './lib/session-peer-inbox-deps.js'
68
68
  import { makeCodexLiveDeliverer } from './lib/codex-live-queue-deps.js'
69
69
  import { forkThread, realForkDeps } from './lib/fork-thread.js'
70
+ import { ForkJobLedger, fileForkJobPersistence } from './lib/fork-job-ledger.js'
70
71
  import { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
71
72
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
72
73
  import { displayRouter } from './routes/display.js'
@@ -105,7 +106,7 @@ import { recoveryRouter } from './routes/recovery.js'
105
106
  import { promptEditRouter } from './routes/prompt-edit.js'
106
107
  import { lensGistRouter } from './routes/lens-gist.js'
107
108
  import { bookmarksRouter } from './routes/bookmarks.js'
108
- import { welcomeContextRouter } from './routes/welcome-context.js'
109
+ import { setWeatherPersistence, welcomeContextRouter } from './routes/welcome-context.js'
109
110
  import { liveCuesRouter } from './routes/live-cues.js'
110
111
  import { tasksRouter } from './routes/tasks.js'
111
112
  import { memoryRouter } from './routes/memory.js'
@@ -142,6 +143,7 @@ import { createWorkIntakeRouter } from './routes/work-intake.js'
142
143
  import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
143
144
  import { createJevRouter } from './routes/jev.js'
144
145
  import { dataPath } from './lib/data-dir.js'
146
+ import { loadJsonOrQuarantine, durableAtomicWriteFileSync } from './lib/atomic-fs.js'
145
147
  import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
146
148
  import { WorkHandoffRequestStore, createOptionalWorkHandoffRequestStore, handoffRequestCapabilities } from './lib/work-handoff-requests.js'
147
149
  import { createWorkBoardReader } from './lib/work-board-reader.js'
@@ -989,6 +991,10 @@ app.use('/api', createAgentSessionBindingsRouter({
989
991
  // 6.62.0 /qa (W9): the fixed note when the read misses its budget.
990
992
  continueNoteFallback: (provider, options) => fallbackContinueNote(provider, { noteAck: options.noteAck, codexFallbackSandbox: continueFactsDeps.codexFallbackSandbox() }),
991
993
  forkThread: forkThreadForRoute,
994
+ // 6.63.0: background forks record their outcome here, so a client that cannot hold a
995
+ // 21-minute request (COS Control, the phone) reads it from GET /api/agent-session-forks/:id.
996
+ // Durable: a restart turns a still-running row into the cautious orphan-possible answer.
997
+ forkJobs: new ForkJobLedger(randomUUID(), fileForkJobPersistence(dataPath('fork-jobs.json'))),
992
998
  // The fork's real spawn directory. Separate from `resolveTarget` above, which
993
999
  // deliberately yields only fingerprints because plan 3.3 keeps a filesystem path
994
1000
  // off anything client-visible. Null refuses: never fall back to the server's own
@@ -1060,6 +1066,11 @@ app.use('/api', recoveryRouter)
1060
1066
  app.use('/api', promptEditRouter)
1061
1067
  app.use('/api', lensGistRouter)
1062
1068
  app.use('/api', bookmarksRouter)
1069
+ // 6.63.1: the last good weather and place survive a restart (<data>/welcome-weather.json).
1070
+ setWeatherPersistence({
1071
+ load: () => { const r = loadJsonOrQuarantine<unknown>(dataPath('welcome-weather.json')); return r.status === 'ok' ? r.data : null },
1072
+ save: (value) => durableAtomicWriteFileSync(dataPath('welcome-weather.json'), `${JSON.stringify(value)}\n`),
1073
+ })
1063
1074
  app.use('/api', welcomeContextRouter)
1064
1075
  app.use('/api', liveCuesRouter)
1065
1076
  app.use('/api', tasksRouter)
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 6.63.0: the durable record of a background fork (`POST .../fork` with a `clientForkId`).
3
+ *
4
+ * WHY THIS EXISTS. A fork runs a real model turn in the copy before it can say what it
5
+ * made, and the attached adapter lets that turn run for 21 minutes. COS Control waited 5,
6
+ * so on 2026-10-05 a 6.5-minute fork that SUCCEEDED was reported to Miles as "Server
7
+ * stopped", the card kept pointing at the original thread, and the copy only surfaced in
8
+ * the server log. A caller that cannot hold a request open for 21 minutes (Control holds
9
+ * its Work journal lock while it waits; iOS suspends the phone's WebView) needs to start a
10
+ * fork, get an answer at once, and read the outcome later. That outcome lives here.
11
+ *
12
+ * WHAT IT HOLDS. One row per `clientForkId`: `running` until the fork settles, then the
13
+ * exact status and body the synchronous route would have answered. The FIRST settle wins
14
+ * and is never overwritten, so a replayed id always sees what its own fork did.
15
+ *
16
+ * RESTART. A `running` row written by an earlier server process cannot settle any more:
17
+ * its promise died with that process, and its child may or may not have made a copy.
18
+ * `load` turns such rows into the cautious `fork_orphan_possible` answer (not
19
+ * retryable), which tells the user to look in Sessions before forking again. It never
20
+ * reports them as failed: a copy may exist.
21
+ *
22
+ * PRIVACY. No prompt, no native thread id: the body is the one the route already puts on
23
+ * the wire (a `forkRef` digest, never an id), and the source is kept only as a digest.
24
+ */
25
+ import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
26
+
27
+ /** Same shape as a client turn id: what the client mints and can safely echo back. */
28
+ export const CLIENT_FORK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/
29
+
30
+ /** Rows kept. A fork is a user action, so this is weeks of history, and the file stays small. */
31
+ export const MAX_FORK_JOBS = 200
32
+ /** A settled row older than this is dropped on the next write. */
33
+ export const FORK_JOB_TTL_MS = 7 * 24 * 60 * 60_000
34
+
35
+ export type ForkJobState = 'running' | 'done'
36
+
37
+ export interface ForkJobRow {
38
+ clientForkId: string
39
+ /** `opaqueRevision(targetKey(provider, sourceThreadId))`: which source this job forks, never the id. */
40
+ source: string
41
+ provider: string
42
+ state: ForkJobState
43
+ acceptedAt: number
44
+ /** The process that accepted it. A `running` row from another process is interrupted. */
45
+ instance: string
46
+ settledAt?: number
47
+ /** The HTTP status the synchronous route would have answered. */
48
+ status?: number
49
+ /** The body the synchronous route would have answered. */
50
+ result?: Record<string, unknown>
51
+ }
52
+
53
+ export interface ForkJobPersistence {
54
+ load: () => unknown
55
+ save: (rows: ForkJobRow[]) => void
56
+ }
57
+
58
+ /** The answer an interrupted fork settles to. Copy matches the route's own orphan wording. */
59
+ export const INTERRUPTED_FORK_RESULT: Record<string, unknown> = {
60
+ forked: false,
61
+ forkRef: null,
62
+ sourceIntegrity: null,
63
+ orphanPossible: true,
64
+ retryable: false,
65
+ interrupted: true,
66
+ reason: 'fork_orphan_possible',
67
+ reasonCopy: 'The server restarted while COS was making the copy. A copy may exist: look for it in Sessions before forking again.',
68
+ }
69
+ export const INTERRUPTED_FORK_STATUS = 503
70
+
71
+ function isRow(value: unknown): value is ForkJobRow {
72
+ if (!value || typeof value !== 'object') return false
73
+ const r = value as Record<string, unknown>
74
+ return typeof r.clientForkId === 'string' && CLIENT_FORK_ID_RE.test(r.clientForkId)
75
+ && typeof r.source === 'string' && typeof r.provider === 'string'
76
+ && (r.state === 'running' || r.state === 'done')
77
+ && typeof r.acceptedAt === 'number' && Number.isFinite(r.acceptedAt)
78
+ && typeof r.instance === 'string'
79
+ && (r.state === 'running' || (typeof r.status === 'number' && !!r.result && typeof r.result === 'object'))
80
+ }
81
+
82
+ export type BeginResult =
83
+ | { kind: 'started'; row: ForkJobRow }
84
+ /** This id already has a job: the caller replays it and never spawns. */
85
+ | { kind: 'existing'; row: ForkJobRow }
86
+ /** The id is already used for a different source thread. */
87
+ | { kind: 'conflict' }
88
+ /** The ledger could not be written, so the fork must not start (its outcome would be unreadable). */
89
+ | { kind: 'unavailable' }
90
+
91
+ export class ForkJobLedger {
92
+ private rows = new Map<string, ForkJobRow>()
93
+
94
+ constructor(
95
+ private readonly instance: string,
96
+ private readonly persistence?: ForkJobPersistence,
97
+ now: number = Date.now(),
98
+ ) {
99
+ let raw: unknown = []
100
+ try { raw = persistence?.load() ?? [] } catch (error) {
101
+ console.error(`[fork-job-ledger] load failed: ${error instanceof Error ? error.message : error}`)
102
+ raw = []
103
+ }
104
+ let interrupted = 0
105
+ for (const value of Array.isArray(raw) ? raw : []) {
106
+ if (!isRow(value)) continue
107
+ let row = value
108
+ if (row.state === 'running' && row.instance !== instance) {
109
+ row = { ...row, state: 'done', settledAt: now, status: INTERRUPTED_FORK_STATUS, result: { ...INTERRUPTED_FORK_RESULT } }
110
+ interrupted += 1
111
+ }
112
+ this.rows.set(row.clientForkId, row)
113
+ }
114
+ if (interrupted > 0) {
115
+ console.warn(`[fork-job-ledger] ${interrupted} fork job(s) were running when the server last stopped; recorded as orphan-possible`)
116
+ this.save(now)
117
+ }
118
+ }
119
+
120
+ get(clientForkId: string): ForkJobRow | null {
121
+ return this.rows.get(clientForkId) ?? null
122
+ }
123
+
124
+ /** Records a running job. Persisted BEFORE the caller spawns anything. */
125
+ begin(input: { clientForkId: string; source: string; provider: string }, now: number): BeginResult {
126
+ const existing = this.rows.get(input.clientForkId)
127
+ if (existing) return existing.source === input.source ? { kind: 'existing', row: existing } : { kind: 'conflict' }
128
+ const row: ForkJobRow = {
129
+ clientForkId: input.clientForkId, source: input.source, provider: input.provider,
130
+ state: 'running', acceptedAt: now, instance: this.instance,
131
+ }
132
+ this.rows.set(row.clientForkId, row)
133
+ if (!this.save(now)) {
134
+ this.rows.delete(row.clientForkId)
135
+ return { kind: 'unavailable' }
136
+ }
137
+ return { kind: 'started', row }
138
+ }
139
+
140
+ /** The first settle wins. Returns false if the row is unknown or already settled. */
141
+ settle(clientForkId: string, status: number, result: Record<string, unknown>, now: number): boolean {
142
+ const row = this.rows.get(clientForkId)
143
+ if (!row || row.state !== 'running') return false
144
+ this.rows.set(clientForkId, { ...row, state: 'done', settledAt: now, status, result })
145
+ // A failed write keeps the settled row in memory, so this process still answers it.
146
+ this.save(now)
147
+ return true
148
+ }
149
+
150
+ /** Running rows in this process (for health). */
151
+ runningCount(): number {
152
+ let n = 0
153
+ for (const row of this.rows.values()) if (row.state === 'running') n += 1
154
+ return n
155
+ }
156
+
157
+ private save(now: number): boolean {
158
+ // Drop settled rows past the TTL, then the oldest settled rows past the cap. Running
159
+ // rows are never dropped: their outcome is still owed to a caller.
160
+ for (const [id, row] of this.rows) {
161
+ if (row.state === 'done' && now - (row.settledAt ?? row.acceptedAt) > FORK_JOB_TTL_MS) this.rows.delete(id)
162
+ }
163
+ if (this.rows.size > MAX_FORK_JOBS) {
164
+ const settled = [...this.rows.values()].filter(r => r.state === 'done').sort((a, b) => a.acceptedAt - b.acceptedAt)
165
+ for (const row of settled) {
166
+ if (this.rows.size <= MAX_FORK_JOBS) break
167
+ this.rows.delete(row.clientForkId)
168
+ }
169
+ }
170
+ if (!this.persistence) return true
171
+ try {
172
+ this.persistence.save([...this.rows.values()])
173
+ return true
174
+ } catch (error) {
175
+ console.error(`[fork-job-ledger] save failed: ${error instanceof Error ? error.message : error}`)
176
+ return false
177
+ }
178
+ }
179
+ }
180
+
181
+ /** File persistence: `<data>/fork-jobs.json`, written durably, quarantined if corrupt. */
182
+ export function fileForkJobPersistence(path: string): ForkJobPersistence {
183
+ return {
184
+ load: () => {
185
+ const loaded = loadJsonOrQuarantine<unknown>(path)
186
+ if (loaded.status === 'corrupt') {
187
+ console.warn(`[fork-job-ledger] fork job file was corrupt, quarantined as ${loaded.quarantinedAs}`)
188
+ return []
189
+ }
190
+ return loaded.status === 'ok' ? loaded.data : []
191
+ },
192
+ save: rows => durableAtomicWriteFileSync(path, `${JSON.stringify(rows)}\n`),
193
+ }
194
+ }
@@ -232,13 +232,32 @@ export function classifyProviderErrorText(text: string): ForkProviderFailure | n
232
232
  * merely mentions a limit is never read.
233
233
  */
234
234
  export function classifyProviderOutputLine(line: string): ForkProviderFailure | null {
235
+ return readProviderOutputLine(line)?.cause ?? null
236
+ }
237
+
238
+ /**
239
+ * The cause on one stdout line, and whether the line is the provider's FINAL verdict
240
+ * (Claude's `result` with `is_error`, Codex's `turn.failed`). A terminal verdict beats
241
+ * anything earlier. A retry notice is not a verdict at all: the Claude CLI writes
242
+ * `{type:"system",subtype:"api_retry",error:"overloaded"|"rate_limit"}` and Codex
243
+ * writes `{type:"error",message:"Reconnecting... 1/5 (… 429 …)"}` for retries that
244
+ * may recover, and the first-carrier rule turned a recovered 529 followed by
245
+ * "Prompt is too long" into "usage limit" (6.62.1 QA).
246
+ */
247
+ export function readProviderOutputLine(line: string): { cause: ForkProviderFailure | null; terminal: boolean } | null {
235
248
  if (!line || line.length > 2_000_000 || line[0] !== '{') return null
236
249
  let j: any
237
250
  try { j = JSON.parse(line) } catch { return null }
238
251
  if (!j || typeof j !== 'object') return null
239
- const carrier = (j.type === 'result' && j.is_error === true) || j.isApiErrorMessage === true
240
- || typeof j.error === 'string' || j.type === 'error' || j.type === 'turn.failed'
252
+ if (j.type === 'system' && j.subtype === 'api_retry') return null
253
+ if (j.type === 'error' && typeof j.message === 'string' && /\b(?:reconnecting|retrying)\b/i.test(j.message)) return null
254
+ const terminal = (j.type === 'result' && j.is_error === true) || j.type === 'turn.failed'
255
+ const carrier = terminal || j.isApiErrorMessage === true || typeof j.error === 'string' || j.type === 'error'
241
256
  if (!carrier) return null
257
+ return { cause: providerCauseOf(j), terminal }
258
+ }
259
+
260
+ function providerCauseOf(j: any): ForkProviderFailure | null {
242
261
  if (j.error === 'rate_limit' || j.apiErrorStatus === 429) return 'provider_limit'
243
262
  if (j.error === 'authentication_failed') return 'provider_auth'
244
263
  const parts: string[] = []
@@ -824,6 +843,13 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
824
843
  let forkState: ForkState = 'none'
825
844
  let exitCode: number | null = null
826
845
  let providerFailure: ForkProviderFailure | null = null
846
+ let terminalFailure: ForkProviderFailure | null = null
847
+ const noteProviderLine = (line: string) => {
848
+ const read = readProviderOutputLine(line)
849
+ if (!read?.cause) return
850
+ if (read.terminal) terminalFailure = read.cause // the last verdict wins
851
+ else providerFailure ??= read.cause
852
+ }
827
853
  let settled = false
828
854
  let timedOut = false
829
855
  let spawnErrored = false
@@ -873,7 +899,7 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
873
899
  for (const id of extractNativeIdsFromLine(line)) {
874
900
  if (!observedIds.includes(id)) observedIds.push(id)
875
901
  }
876
- providerFailure ??= classifyProviderOutputLine(line)
902
+ noteProviderLine(line)
877
903
  }
878
904
  // Deliberately nothing else: no text, no tool calls, no transcript. This
879
905
  // module cannot leak what it never held.
@@ -900,7 +926,7 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
900
926
  for (const id of extractNativeIdsFromLine(stdoutTail)) {
901
927
  if (!observedIds.includes(id)) observedIds.push(id)
902
928
  }
903
- providerFailure ??= classifyProviderOutputLine(stdoutTail)
929
+ noteProviderLine(stdoutTail)
904
930
  stdoutTail = ''
905
931
  }
906
932
 
@@ -912,7 +938,7 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
912
938
  const verdict = selectForkedId(observedIds, sourceNativeThreadId)
913
939
  return verdict.ok ? verdict.newNativeThreadId : null
914
940
  }
915
- const cause = (): string | null => providerFailure ?? classifyProviderErrorText(stderrSample)
941
+ const cause = (): string | null => terminalFailure ?? providerFailure ?? classifyProviderErrorText(stderrSample)
916
942
  if (timedOut) return settleFailure('timeout', { newNativeThreadId: copyId() })
917
943
  if (spawnErrored) return settleFailure('spawn_failed', { detail: 'child_error' })
918
944
  if (exitCode !== 0) return settleFailure('provider_exit_nonzero', { newNativeThreadId: copyId(), detail: cause() })
@@ -126,6 +126,7 @@ import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/ag
126
126
  import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
127
127
  import { isValidNativeThreadId } from '../lib/native-thread-id.js'
128
128
  import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
129
+ import { CLIENT_FORK_ID_RE, ForkJobLedger, type ForkJobRow } from '../lib/fork-job-ledger.js'
129
130
  import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
130
131
  import {
131
132
  CANCEL_QUEUE_HOLD_MS,
@@ -448,6 +449,13 @@ export interface AgentSessionBindingsDeps {
448
449
  */
449
450
  forkRefs?: ForkRefStore
450
451
 
452
+ /**
453
+ * 6.63.0: where a background fork (`clientForkId` in the body) records its outcome for
454
+ * `GET /agent-session-forks/:clientForkId`. Defaults to an in-memory ledger per router;
455
+ * the server wires the durable one (`<data>/fork-jobs.json`).
456
+ */
457
+ forkJobs?: ForkJobLedger
458
+
451
459
  /** Lease TTL for a new binding. */
452
460
  attachTtlMs?: number
453
461
  /** Upper bound on one prompt. A prompt is not a payload. */
@@ -1522,6 +1530,22 @@ export function classifyFork(result: unknown, sourceNativeThreadId: string): For
1522
1530
  }
1523
1531
 
1524
1532
  export const FORKED_COPY = 'Copied into a new thread. Your original is untouched.'
1533
+ /** 6.63.0: the 202 for a background fork. The outcome is read from the status route. */
1534
+ export const FORK_RUNNING_COPY = 'COS is making the copy and sending your instruction. This can take several minutes.'
1535
+ export const FORK_JOB_UNKNOWN_COPY = 'This server has no record of that fork. Look for the copy in Sessions before forking again.'
1536
+
1537
+ /** What a background fork's id answers right now: running, or the recorded outcome. */
1538
+ export function projectForkJob(row: ForkJobRow): { status: number; body: Record<string, unknown> } {
1539
+ if (row.state === 'running') {
1540
+ return { status: 200, body: { state: 'running', clientForkId: row.clientForkId, acceptedAt: row.acceptedAt, reasonCopy: FORK_RUNNING_COPY } }
1541
+ }
1542
+ // `status` is the ORIGINAL answer's code, surfaced under its own name: a refused fork
1543
+ // read back correctly is a successful read.
1544
+ return {
1545
+ status: 200,
1546
+ body: { ...(row.result ?? {}), state: 'done', clientForkId: row.clientForkId, recordedStatus: row.status ?? null, polled: true },
1547
+ }
1548
+ }
1525
1549
  /** 6.62.1: `unverified` must never be rendered as confirmed untouched (see the success body). */
1526
1550
  export const FORKED_COPY_UNVERIFIED = 'Copied into a new thread. COS could not re-read your original afterwards to confirm it is unchanged.'
1527
1551
 
@@ -1648,6 +1672,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1648
1672
  // One per router. Injectable so the follow-on (attach accepting a `forkRef`)
1649
1673
  // shares this instance rather than standing up a second, disconnected one.
1650
1674
  const forkRefs = deps?.forkRefs instanceof ForkRefStore ? deps.forkRefs : new ForkRefStore()
1675
+ const forkJobs = deps?.forkJobs instanceof ForkJobLedger ? deps.forkJobs : new ForkJobLedger(randomUUID())
1651
1676
  const attachTtlMs =
1652
1677
  Number.isFinite(deps?.attachTtlMs) && (deps.attachTtlMs as number) > 0
1653
1678
  ? (deps.attachTtlMs as number)
@@ -2443,10 +2468,23 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2443
2468
 
2444
2469
  /** The per-source serialisation claim, released in the finally. */
2445
2470
  let claimedForkKey: string | null = null
2471
+ /**
2472
+ * 6.63.0: set once a background fork has answered 202. From then on the ledger is the
2473
+ * ONLY channel for the outcome, exactly like a queued turn: `answer` settles it there.
2474
+ */
2475
+ let backgroundForkId: string | null = null
2446
2476
 
2447
- const refuseFork = (reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
2477
+ const answer = (status: number, payload: Record<string, unknown>): void => {
2478
+ if (backgroundForkId !== null) {
2479
+ forkJobs.settle(backgroundForkId, status, payload, readNow() ?? Date.now())
2480
+ return
2481
+ }
2448
2482
  if (res.headersSent) return
2449
- res.status(refusalStatus(reason)).json({
2483
+ res.status(status).json(payload)
2484
+ }
2485
+
2486
+ const refuseFork = (reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
2487
+ answer(refusalStatus(reason), {
2450
2488
  forked: false,
2451
2489
  forkRef: null,
2452
2490
  sourceIntegrity: null,
@@ -2460,6 +2498,16 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2460
2498
  })
2461
2499
  }
2462
2500
 
2501
+ /** Answers a known background fork id with its job, never a second spawn. */
2502
+ const replayForkJob = (row: ForkJobRow): void => {
2503
+ const projected = projectForkJob(row)
2504
+ res.status(row.state === 'running' ? 202 : projected.status).json({
2505
+ ...projected.body,
2506
+ accepted: row.state === 'running' ? true : undefined,
2507
+ replayed: true,
2508
+ })
2509
+ }
2510
+
2463
2511
  try {
2464
2512
  const fork = deps.forkThread
2465
2513
  if (typeof fork !== 'function') return refuseFork('fork_unwired')
@@ -2477,6 +2525,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2477
2525
  if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
2478
2526
  return refuseFork('invalid_request')
2479
2527
  }
2528
+ // 6.63.0: optional. With it, the fork answers 202 once every gate has passed and runs
2529
+ // in the background; without it, the route behaves exactly as before.
2530
+ const clientForkId = body.clientForkId
2531
+ if (clientForkId !== undefined && (typeof clientForkId !== 'string' || !CLIENT_FORK_ID_RE.test(clientForkId))) {
2532
+ return refuseFork('invalid_request')
2533
+ }
2480
2534
 
2481
2535
  const providerParam = String(req.params.provider ?? '')
2482
2536
  const threadIdParam = String(req.params.threadId ?? '')
@@ -2486,6 +2540,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2486
2540
  if (!isForkableProvider(providerParam)) return refuseFork('fork_unsupported_provider')
2487
2541
  if (!isValidNativeThreadId(threadIdParam)) return refuseFork('fork_invalid_thread_id')
2488
2542
 
2543
+ // A repeat of a known id replays its job and never spawns: a client that lost the
2544
+ // 202 (or restarted) must be able to resend without making a second copy.
2545
+ const forkSource = opaqueRevision(targetKey(providerParam, threadIdParam))
2546
+ if (typeof clientForkId === 'string') {
2547
+ const known = forkJobs.get(clientForkId)
2548
+ if (known) return known.source === forkSource ? replayForkJob(known) : refuseFork('invalid_request')
2549
+ }
2550
+
2489
2551
  const now = readNow()
2490
2552
  if (now === null) return refuseFork('fork_failed')
2491
2553
 
@@ -2514,9 +2576,36 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2514
2576
  // continuation or be blocked by one — `targetKey` is length-prefixed and
2515
2577
  // therefore unambiguous, and this prefix cannot collide with one.
2516
2578
  const forkKey = `fork:${targetKey(providerParam, threadIdParam)}`
2517
- if (!guard.tryClaim(forkKey, forkKey)) return refuseFork('fork_in_progress')
2579
+ if (!guard.tryClaim(forkKey, forkKey)) {
2580
+ // The same id arriving twice while the first is still resolving its workspace: the
2581
+ // first holds the claim and has begun the job by now, so replay it.
2582
+ const raced = typeof clientForkId === 'string' ? forkJobs.get(clientForkId) : null
2583
+ if (raced && raced.source === forkSource) return replayForkJob(raced)
2584
+ return refuseFork('fork_in_progress')
2585
+ }
2518
2586
  claimedForkKey = forkKey
2519
2587
 
2588
+ if (typeof clientForkId === 'string') {
2589
+ // Recorded BEFORE the spawn and before the 202: a job the ledger cannot hold would
2590
+ // have an outcome nobody can read, so it does not start.
2591
+ const begun = forkJobs.begin({ clientForkId, source: forkSource, provider: providerParam }, now)
2592
+ if (begun.kind === 'existing') return replayForkJob(begun.row)
2593
+ if (begun.kind !== 'started') return refuseFork(begun.kind === 'conflict' ? 'invalid_request' : 'fork_failed')
2594
+ res.status(202).json({
2595
+ accepted: true,
2596
+ state: 'running',
2597
+ clientForkId,
2598
+ forked: null,
2599
+ forkRef: null,
2600
+ orphanPossible: false,
2601
+ retryable: false,
2602
+ reason: null,
2603
+ reasonCopy: FORK_RUNNING_COPY,
2604
+ })
2605
+ backgroundForkId = clientForkId
2606
+ console.log(`[agent-session-bindings] fork accepted provider=${providerParam} background=true`)
2607
+ }
2608
+
2520
2609
  let raw: unknown
2521
2610
  try {
2522
2611
  raw = await fork({
@@ -2568,7 +2657,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2568
2657
  // a fresh fork exactly as for an existing thread.
2569
2658
  const forkRef = forkRefs.remember(providerParam, outcome.newNativeThreadId, now)
2570
2659
 
2571
- res.status(201).json({
2660
+ answer(201, {
2572
2661
  forked: true,
2573
2662
  reason: null,
2574
2663
  reasonCopy: outcome.integrity === 'verified_unchanged' ? FORKED_COPY : FORKED_COPY_UNVERIFIED,
@@ -2586,7 +2675,39 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2586
2675
  refuseFork('fork_orphan_possible', { orphanPossible: true })
2587
2676
  } finally {
2588
2677
  if (claimedForkKey !== null) guard.release(claimedForkKey, claimedForkKey)
2678
+ // A background job must never be left `running` by a path that forgot to answer:
2679
+ // its caller would poll a fork that can no longer finish. Cautious outcome only.
2680
+ if (backgroundForkId !== null && forkJobs.get(backgroundForkId)?.state === 'running') {
2681
+ console.error('[agent-session-bindings] background fork ended without an outcome; recorded as orphan-possible')
2682
+ forkJobs.settle(backgroundForkId, refusalStatus('fork_orphan_possible'), {
2683
+ forked: false, forkRef: null, sourceIntegrity: null, orphanPossible: true, retryable: false,
2684
+ reason: 'fork_orphan_possible', reasonCopy: writeReasonCopy('fork_orphan_possible'),
2685
+ }, readNow() ?? Date.now())
2686
+ }
2687
+ }
2688
+ })
2689
+
2690
+ // ----------------------------------------------------- background fork status
2691
+ //
2692
+ // 6.63.0: the outcome of a fork started with a `clientForkId`. Its own path segment
2693
+ // (`/agent-session-forks`), so it can never be read as `/agent-sessions/:provider/:threadId`.
2694
+ // UNGATED, like the fork route it reports on.
2695
+ router.get('/agent-session-forks/:clientForkId', (req, res) => {
2696
+ res.set('Cache-Control', 'private, no-store')
2697
+ const clientForkId = String(req.params.clientForkId ?? '')
2698
+ if (!CLIENT_FORK_ID_RE.test(clientForkId)) {
2699
+ res.status(400).json({ state: 'invalid_request', reasonCopy: FORK_JOB_UNKNOWN_COPY })
2700
+ return
2701
+ }
2702
+ const row = forkJobs.get(clientForkId)
2703
+ if (row === null) {
2704
+ // Not "failed": an older server, a dropped row or an id never admitted all read this way,
2705
+ // and any of them may have a copy.
2706
+ res.status(404).json({ state: 'unknown', clientForkId, reasonCopy: FORK_JOB_UNKNOWN_COPY })
2707
+ return
2589
2708
  }
2709
+ const projected = projectForkJob(row)
2710
+ res.status(projected.status).json(projected.body)
2590
2711
  })
2591
2712
 
2592
2713
  // ----------------------------------------------------------------- turns
@@ -25,6 +25,72 @@ let lastKnownCity = ''
25
25
  let cachedNextEvent: NextEventPayload | null = null
26
26
  let calendarFetchedAt = 0
27
27
 
28
+ // 6.63.1: after a server restart the first forecast lookup can fail and, with nothing cached, the
29
+ // glasses Home got no weather until the app was relaunched. The last good weather and
30
+ // place now survive a restart, and a failed lookup says why in the log (it was silent).
31
+ /** A saved weather older than this is never served after a restart. */
32
+ export const WEATHER_PERSIST_MAX_AGE_MS = 3 * 60 * 60_000
33
+ const WEATHER_FAILURE_LOG_MS = 10 * 60_000
34
+
35
+ export interface WeatherPersistence {
36
+ load(): unknown
37
+ save(value: unknown): void
38
+ }
39
+ let weatherPersistence: WeatherPersistence | null = null
40
+ let weatherRestored = false
41
+ const lastFailureLogAt = new Map<string, number>()
42
+
43
+ /** Wired by the server (index.ts, `<data>/welcome-weather.json`); off by default so tests touch nothing. */
44
+ export function setWeatherPersistence(p: WeatherPersistence | null): void {
45
+ weatherPersistence = p
46
+ weatherRestored = false
47
+ }
48
+
49
+ function restoreWeatherOnce(): void {
50
+ if (weatherRestored || !weatherPersistence) return
51
+ weatherRestored = true
52
+ try {
53
+ const v = weatherPersistence.load() as Record<string, unknown> | null
54
+ if (!v || typeof v !== 'object') return
55
+ const at = typeof v.fetchedAt === 'number' ? v.fetchedAt : 0
56
+ const w = v.weather as WeatherPayload | undefined
57
+ if (typeof v.lat === 'number' && typeof v.lon === 'number' && lastKnownLat == null) { lastKnownLat = v.lat; lastKnownLon = v.lon }
58
+ if (typeof v.city === 'string' && !lastKnownCity) lastKnownCity = v.city
59
+ const fresh = at > 0 && at <= Date.now() + 60_000 && Date.now() - at <= WEATHER_PERSIST_MAX_AGE_MS
60
+ if (fresh && w && typeof w.temp === 'string' && typeof w.desc === 'string' && !cachedWeather) {
61
+ cachedWeather = { temp: w.temp, desc: w.desc, location: typeof w.location === 'string' ? w.location : '' }
62
+ // Expired for the TTL check, so the next request still tries a fresh lookup; kept as the fallback.
63
+ weatherFetchedAt = 0
64
+ }
65
+ } catch (error) {
66
+ console.warn(`[welcome-context] saved weather unreadable: ${error instanceof Error ? error.message : error}`)
67
+ }
68
+ }
69
+
70
+ function persistWeather(): void {
71
+ if (!weatherPersistence) return
72
+ try {
73
+ weatherPersistence.save({ lat: lastKnownLat ?? null, lon: lastKnownLon ?? null, city: lastKnownCity, weather: cachedWeather, fetchedAt: weatherFetchedAt })
74
+ } catch (error) {
75
+ console.warn(`[welcome-context] saving weather failed: ${error instanceof Error ? error.message : error}`)
76
+ }
77
+ }
78
+
79
+ /** One line per failure kind per 10 minutes: the cause, never the coordinates. */
80
+ function noteWeatherFailure(kind: 'forecast' | 'geocode', reason: string): void {
81
+ const key = `${kind}:${reason}`
82
+ const now = Date.now()
83
+ if (now - (lastFailureLogAt.get(key) ?? 0) < WEATHER_FAILURE_LOG_MS) return
84
+ lastFailureLogAt.set(key, now)
85
+ console.warn(`[welcome-context] ${kind} lookup failed: ${reason}${cachedWeather ? ' (serving the last good weather)' : ' (no weather to serve)'}`)
86
+ }
87
+
88
+ function failureReason(error: unknown): string {
89
+ const name = error instanceof Error ? error.name : ''
90
+ if (name === 'TimeoutError' || name === 'AbortError') return 'timeout'
91
+ return error instanceof Error ? (error.message.slice(0, 80) || name || 'error') : 'error'
92
+ }
93
+
28
94
  /** Full WMO 4677 — discrete codes, exact match required. */
29
95
  export const WMO_CODES: Record<number, string> = {
30
96
  0: 'Clear',
@@ -108,7 +174,7 @@ async function reverseGeocode(lat: number, lon: number): Promise<string> {
108
174
  `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`,
109
175
  { signal: AbortSignal.timeout(GEOCODE_TIMEOUT_MS) },
110
176
  )
111
- if (!res.ok) return lastKnownCity
177
+ if (!res.ok) { noteWeatherFailure('geocode', `http ${res.status}`); return lastKnownCity }
112
178
  const data = await res.json() as {
113
179
  city?: string
114
180
  locality?: string
@@ -122,7 +188,8 @@ async function reverseGeocode(lat: number, lon: number): Promise<string> {
122
188
  lastKnownCity = region ? `${city}, ${region}` : city
123
189
  }
124
190
  return lastKnownCity
125
- } catch {
191
+ } catch (error) {
192
+ noteWeatherFailure('geocode', failureReason(error))
126
193
  return lastKnownCity
127
194
  }
128
195
  }
@@ -144,6 +211,7 @@ export async function fetchWeather(
144
211
  queryLat?: number,
145
212
  queryLon?: number,
146
213
  ): Promise<WeatherPayload | null> {
214
+ restoreWeatherOnce()
147
215
  const resolved = resolveWeatherCoords(queryLat, queryLon)
148
216
  if (!resolved) return null
149
217
 
@@ -183,14 +251,14 @@ export async function fetchWeather(
183
251
  `https://api.open-meteo.com/v1/forecast?latitude=${useLat}&longitude=${useLon}&current=temperature_2m,weather_code&temperature_unit=fahrenheit&timezone=auto`,
184
252
  { signal: AbortSignal.timeout(FORECAST_TIMEOUT_MS) },
185
253
  )
186
- if (!res.ok) return cachedWeather
254
+ if (!res.ok) { noteWeatherFailure('forecast', `http ${res.status}`); return cachedWeather }
187
255
 
188
256
  const data = await res.json() as {
189
257
  current?: { temperature_2m?: number; weather_code?: number }
190
258
  }
191
259
  const temp = data.current?.temperature_2m
192
260
  const code = data.current?.weather_code
193
- if (typeof temp !== 'number' || typeof code !== 'number') return cachedWeather
261
+ if (typeof temp !== 'number' || typeof code !== 'number') { noteWeatherFailure('forecast', 'bad payload'); return cachedWeather }
194
262
 
195
263
  await ensureCityLabel(useLat, useLon, source)
196
264
 
@@ -200,8 +268,10 @@ export async function fetchWeather(
200
268
  location: lastKnownCity || `${useLat.toFixed(2)}, ${useLon.toFixed(2)}`,
201
269
  }
202
270
  weatherFetchedAt = Date.now()
271
+ persistWeather()
203
272
  return cachedWeather
204
- } catch {
273
+ } catch (error) {
274
+ noteWeatherFailure('forecast', failureReason(error))
205
275
  return cachedWeather
206
276
  }
207
277
  }
@@ -272,6 +342,9 @@ export function _resetWelcomeContextCachesForTests(): void {
272
342
  lastKnownCity = ''
273
343
  cachedNextEvent = null
274
344
  calendarFetchedAt = 0
345
+ weatherPersistence = null
346
+ weatherRestored = false
347
+ lastFailureLogAt.clear()
275
348
  }
276
349
 
277
350
  welcomeContextRouter.get('/welcome-context', async (req, res) => {