@gotcos/glasses-server 6.62.1 → 6.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/server/index.ts +6 -1
- package/server/lib/fork-job-ledger.ts +194 -0
- package/server/lib/fork-thread.ts +31 -5
- package/server/routes/agent-session-bindings.ts +125 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## 6.63.0 — 2026-10-05 — Background forks
|
|
2
|
+
|
|
3
|
+
- 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.
|
|
4
|
+
- 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)
|
|
5
|
+
|
|
1
6
|
## 6.62.1 — 2026-10-05 — Combined provider parity and handoff hardening
|
|
2
7
|
|
|
3
8
|
- 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.
|
|
3
|
+
"version": "6.63.0",
|
|
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'
|
|
@@ -989,6 +990,10 @@ app.use('/api', createAgentSessionBindingsRouter({
|
|
|
989
990
|
// 6.62.0 /qa (W9): the fixed note when the read misses its budget.
|
|
990
991
|
continueNoteFallback: (provider, options) => fallbackContinueNote(provider, { noteAck: options.noteAck, codexFallbackSandbox: continueFactsDeps.codexFallbackSandbox() }),
|
|
991
992
|
forkThread: forkThreadForRoute,
|
|
993
|
+
// 6.63.0: background forks record their outcome here, so a client that cannot hold a
|
|
994
|
+
// 21-minute request (COS Control, the phone) reads it from GET /api/agent-session-forks/:id.
|
|
995
|
+
// Durable: a restart turns a still-running row into the cautious orphan-possible answer.
|
|
996
|
+
forkJobs: new ForkJobLedger(randomUUID(), fileForkJobPersistence(dataPath('fork-jobs.json'))),
|
|
992
997
|
// The fork's real spawn directory. Separate from `resolveTarget` above, which
|
|
993
998
|
// deliberately yields only fingerprints because plan 3.3 keeps a filesystem path
|
|
994
999
|
// off anything client-visible. Null refuses: never fall back to the server's own
|
|
@@ -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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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))
|
|
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
|
-
|
|
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
|