@gotcos/glasses-server 6.50.4 → 6.52.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/.env.example +16 -0
- package/CHANGELOG.md +78 -0
- package/README.md +56 -3
- package/bin/hooks/cos-session-hook +24 -1
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +83 -5
- package/server/lib/activity-preview.ts +45 -2
- package/server/lib/agent-session-search.ts +3 -1
- package/server/lib/agent-session-store.ts +35 -5
- package/server/lib/agent-session-turns.ts +4 -1
- package/server/lib/claude-bridge.ts +9 -0
- package/server/lib/client-liveness.ts +50 -0
- package/server/lib/codex-bridge.ts +6 -0
- package/server/lib/codex-live-queue-deps.ts +135 -0
- package/server/lib/codex-live-queue.ts +232 -0
- package/server/lib/codex-turn-clock.ts +36 -0
- package/server/lib/cursor-bridge.ts +6 -0
- package/server/lib/cursor-stop-followup.ts +134 -0
- package/server/lib/job-trail.ts +758 -0
- package/server/lib/occupancy-probes.ts +52 -0
- package/server/lib/ollama-bridge.ts +17 -0
- package/server/lib/permission-broker.ts +1156 -0
- package/server/lib/query-job-coordinator.ts +17 -1
- package/server/lib/query-job-feature.ts +10 -0
- package/server/lib/query-job-runtime.ts +9 -1
- package/server/lib/query-job-store.ts +214 -5
- package/server/lib/query-job-types.ts +36 -0
- package/server/lib/session-signal-store.ts +42 -10
- package/server/lib/session-state-derive.ts +14 -4
- package/server/lib/session-stream-events.ts +600 -15
- package/server/lib/session-stream-producer.ts +25 -6
- package/server/lib/session-transcript-watcher.ts +120 -3
- package/server/lib/thread-occupancy.ts +30 -7
- package/server/routes/agent-session-bindings.ts +98 -6
- package/server/routes/agent-sessions.ts +24 -2
- package/server/routes/cursor-stop-followup.ts +74 -0
- package/server/routes/health.ts +34 -1
- package/server/routes/permission-broker.ts +176 -0
- package/server/routes/query-jobs.ts +84 -13
- package/server/routes/thread-turn-queue.ts +15 -5
package/server/routes/health.ts
CHANGED
|
@@ -49,7 +49,8 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
|
49
49
|
// The flag lives beside the endpoints it describes, so it cannot drift from
|
|
50
50
|
// whether they are actually registered.
|
|
51
51
|
import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
|
|
52
|
-
import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
52
|
+
import { durableQueryJobsCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
|
|
53
|
+
import { jobTrailStats } from '../lib/job-trail.js'
|
|
53
54
|
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
54
55
|
import { getMorningBriefScheduler } from '../lib/morning-brief-runtime.js'
|
|
55
56
|
import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
|
|
@@ -80,7 +81,10 @@ import {
|
|
|
80
81
|
threadAttachHealthFields,
|
|
81
82
|
} from '../lib/thread-attach-capability.js'
|
|
82
83
|
import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
|
|
84
|
+
import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
|
|
83
85
|
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
86
|
+
import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
|
|
87
|
+
import { codexLiveQueueEnabled } from '../lib/codex-live-queue.js'
|
|
84
88
|
|
|
85
89
|
/**
|
|
86
90
|
* 6.49.0: which last hop a Continue takes, and how often each fell back.
|
|
@@ -101,6 +105,18 @@ export function continueLiveHealthFields(): Record<string, unknown> {
|
|
|
101
105
|
lastReason: stats.lastReason,
|
|
102
106
|
lastAt: stats.lastAt,
|
|
103
107
|
},
|
|
108
|
+
// 6.51.0: Codex's last hop, counted apart so the Claude row above keeps its meaning.
|
|
109
|
+
codexLiveQueue: (() => {
|
|
110
|
+
const codex = codexLiveStats()
|
|
111
|
+
return {
|
|
112
|
+
enabled: codexLiveQueueEnabled(),
|
|
113
|
+
attempts: codex.attempts,
|
|
114
|
+
delivered: codex.delivered,
|
|
115
|
+
fallbacks: codex.fallbacks,
|
|
116
|
+
lastReason: codex.lastReason,
|
|
117
|
+
lastAt: codex.lastAt,
|
|
118
|
+
}
|
|
119
|
+
})(),
|
|
104
120
|
}
|
|
105
121
|
}
|
|
106
122
|
|
|
@@ -374,6 +390,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
374
390
|
server_version: managedServerVersion(),
|
|
375
391
|
...threadAttachHealthFields(threadAttach),
|
|
376
392
|
...sessionHooksHealthFields(),
|
|
393
|
+
// 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
|
|
394
|
+
// how many are held (that is on the authenticated questions route).
|
|
395
|
+
...permissionBrokerHealthFields(),
|
|
377
396
|
...continueLiveHealthFields(),
|
|
378
397
|
server_instance_id: getServerInstanceId(),
|
|
379
398
|
boot_id: serverMetrics.bootId,
|
|
@@ -465,6 +484,12 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
465
484
|
protocolVersion: durableJobs.protocolVersion,
|
|
466
485
|
state: durableJobs.store.state,
|
|
467
486
|
},
|
|
487
|
+
// 6.52.0: the Messages trail's readers, process-wide since boot. Counts and a
|
|
488
|
+
// timestamp only: stdout lines read, entries emitted, failures swallowed.
|
|
489
|
+
messages_trail: {
|
|
490
|
+
enabled: durableJobs.enabled && messagesTrailEnabled(),
|
|
491
|
+
...jobTrailStats(),
|
|
492
|
+
},
|
|
468
493
|
})
|
|
469
494
|
})
|
|
470
495
|
|
|
@@ -554,7 +579,15 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
554
579
|
durableQueryJobs: {
|
|
555
580
|
enabled: durableJobs.enabled,
|
|
556
581
|
protocolVersion: durableJobs.protocolVersion,
|
|
582
|
+
// 6.52.0: jobs journal `trail` events and carry `snapshot.trail`. The app reads
|
|
583
|
+
// THIS surface (not /api/health) and falls back to the activity log without it.
|
|
584
|
+
// False whenever jobs themselves are off or COS_MESSAGES_TRAIL=0.
|
|
585
|
+
trail: durableJobs.enabled && messagesTrailEnabled(),
|
|
557
586
|
},
|
|
587
|
+
// 6.52.0: the session questions API and its client contract. A client polls
|
|
588
|
+
// `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
|
|
589
|
+
// quiet for `liveWindowMs` hands every held request back to the Mac.
|
|
590
|
+
sessionQuestions: sessionQuestionsCapability(),
|
|
558
591
|
transcription: {
|
|
559
592
|
...transcription,
|
|
560
593
|
live: transcriptionLive,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The permission broker's two doors (6.52.0). See `lib/permission-broker.ts` for the why.
|
|
2
|
+
//
|
|
3
|
+
// POST /api/permission-requests/ask -- the COS session hook, on a PermissionRequest at a
|
|
4
|
+
// desk idle for 90 s. THIS IS THE URL THE 6.51.0 SCRIPT ALREADY CALLS, so no Mac needs
|
|
5
|
+
// `--hooks install` again (QA round 1: a new URL meant a new script, and every Mac that did
|
|
6
|
+
// not reinstall would have shown Control's "hooks outdated" banner and kept the old door).
|
|
7
|
+
// The hook holds the per-install HOOK token (`~/.cos-glasses/hook-token`, 0600), never the
|
|
8
|
+
// pairing token the /api gate checks, so this one route is registered BEFORE that gate and
|
|
9
|
+
// before the global body parser, as a deliberate exemption for exactly this method and
|
|
10
|
+
// path: it checks the hook token itself, in constant time, BEFORE it reads a byte of the
|
|
11
|
+
// body, and only then parses up to 2 MB. Any other method on the path falls through to the
|
|
12
|
+
// /api gate like every other /api route. It answers only `{}` or a
|
|
13
|
+
// `{"hookSpecificOutput": ...}` the hook prints verbatim, and is mounted unconditionally
|
|
14
|
+
// (not behind COS_THREAD_ATTACH_ENABLED): the switch is `COS_PERMISSION_BROKER`, read per
|
|
15
|
+
// request. It holds no maintenance lease; the broker refuses to park during a drain itself.
|
|
16
|
+
//
|
|
17
|
+
// Mixed versions fail safe. Against a 6.51 server the same script gets 401 from the /api
|
|
18
|
+
// gate, prints nothing, and the native dialog shows, as it always did there.
|
|
19
|
+
//
|
|
20
|
+
// GET /api/session-questions and POST /api/session-questions/:id/answer -- the lens and the
|
|
21
|
+
// phone, behind the API token like every other /api route. Question and command text only
|
|
22
|
+
// ever travels here, never on the display bus. The GET is ALSO the broker's liveness
|
|
23
|
+
// signal (lib/client-liveness.ts), and only when it names the client: `?client=glasses` or
|
|
24
|
+
// `?client=phone`. Anything else (missing, `control`, a typo) is answered but never counts,
|
|
25
|
+
// so a surface that only shows a count can poll without making the Mac hold questions it
|
|
26
|
+
// cannot answer. It checks X-Cos-Token itself as well, in constant time, and a request
|
|
27
|
+
// without it is refused and never counted, whatever sits in front of this router.
|
|
28
|
+
|
|
29
|
+
import express, { Router, type NextFunction, type Request, type Response } from 'express'
|
|
30
|
+
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
31
|
+
import {
|
|
32
|
+
ANSWERING_CLIENTS,
|
|
33
|
+
CLIENT_LIVE_WINDOW_MS,
|
|
34
|
+
QUESTIONS_POLL_INTERVAL_MS,
|
|
35
|
+
SESSION_QUESTIONS_PROTOCOL_VERSION,
|
|
36
|
+
type BrokerAdmission,
|
|
37
|
+
type HookReplyChannel,
|
|
38
|
+
type PermissionBroker,
|
|
39
|
+
} from '../lib/permission-broker.js'
|
|
40
|
+
import { noteQuestionsPoll } from '../lib/client-liveness.js'
|
|
41
|
+
|
|
42
|
+
export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
|
|
43
|
+
/** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
|
|
44
|
+
export const PERMISSION_BROKER_BODY_LIMIT = '2mb'
|
|
45
|
+
|
|
46
|
+
export interface PermissionBrokerHookRouterDeps {
|
|
47
|
+
/** The per-install hook token, or null when none has been minted (then nothing is answered). */
|
|
48
|
+
hookToken: () => string | null
|
|
49
|
+
broker: PermissionBroker
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The hook-token check, as middleware that runs BEFORE any body parser: a request without
|
|
54
|
+
* the token is answered 401 without a byte of its body being read or parsed.
|
|
55
|
+
*/
|
|
56
|
+
export function requireHookToken(hookToken: () => string | null) {
|
|
57
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
58
|
+
res.set('Cache-Control', 'private, no-store')
|
|
59
|
+
let expected: string | null = null
|
|
60
|
+
try { expected = hookToken() } catch { expected = null }
|
|
61
|
+
if (!expected || !timingSafeTokenEqual(req.headers['x-cos-hook-token'], expected)) {
|
|
62
|
+
res.status(401).json({})
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
next()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The held response, as the broker sees it. */
|
|
70
|
+
export function replyChannelFor(res: Response): HookReplyChannel {
|
|
71
|
+
const writable = (): boolean => {
|
|
72
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return false
|
|
73
|
+
const socket = res.socket
|
|
74
|
+
return !!socket && !socket.destroyed && socket.writable !== false
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
writable,
|
|
78
|
+
reply(body) {
|
|
79
|
+
if (!writable()) return false
|
|
80
|
+
try {
|
|
81
|
+
res.status(200).json(body)
|
|
82
|
+
return true
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createPermissionBrokerHookRouter(deps: PermissionBrokerHookRouterDeps): Router {
|
|
91
|
+
const router = Router()
|
|
92
|
+
router.post(
|
|
93
|
+
PERMISSION_BROKER_HOOK_PATH,
|
|
94
|
+
requireHookToken(deps.hookToken),
|
|
95
|
+
express.json({ limit: PERMISSION_BROKER_BODY_LIMIT }),
|
|
96
|
+
async (req: Request, res: Response) => {
|
|
97
|
+
let admission: BrokerAdmission
|
|
98
|
+
try {
|
|
99
|
+
admission = await deps.broker.admit(req.body)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error(`[permission-broker] admit failed: ${error instanceof Error ? error.message : error}`)
|
|
102
|
+
res.json({})
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (!admission.ok) {
|
|
106
|
+
res.json({})
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const channel = replyChannelFor(res)
|
|
110
|
+
// The hook gave up during the desk read: nothing to hold.
|
|
111
|
+
if (!channel.writable()) {
|
|
112
|
+
deps.broker.noteFastPath('hook_gone_early')
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const id = deps.broker.park(admission.request, channel)
|
|
116
|
+
// `res` close, not `req` close: since Node 16 the request emits `close` once its body
|
|
117
|
+
// is read, which is not the hook leaving. A close after the reply is a no-op.
|
|
118
|
+
res.on('close', () => { deps.broker.hookClosed(id) })
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
// A body the parser refused (too large, not JSON): `{}` and nothing else, never a stack.
|
|
122
|
+
router.use(PERMISSION_BROKER_HOOK_PATH, (error: unknown, _req: Request, res: Response, next: NextFunction) => {
|
|
123
|
+
if (res.headersSent) { next(error); return }
|
|
124
|
+
deps.broker.noteFastPath('malformed')
|
|
125
|
+
const status = Number((error as { status?: unknown })?.status)
|
|
126
|
+
res.status(Number.isInteger(status) && status >= 400 && status < 500 ? status : 400).json({})
|
|
127
|
+
})
|
|
128
|
+
return router
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface SessionQuestionsRouterDeps {
|
|
132
|
+
broker: PermissionBroker
|
|
133
|
+
/** The pairing token (X-Cos-Token). A poll counts as a live client only with it. */
|
|
134
|
+
apiToken: () => string
|
|
135
|
+
/** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
|
|
136
|
+
notePoll?: (at: number) => void
|
|
137
|
+
now?: () => number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
|
|
141
|
+
const router = Router()
|
|
142
|
+
const notePoll = deps.notePoll ?? noteQuestionsPoll
|
|
143
|
+
const now = deps.now ?? Date.now
|
|
144
|
+
router.get('/session-questions', (req: Request, res: Response) => {
|
|
145
|
+
res.set('Cache-Control', 'private, no-store')
|
|
146
|
+
let expected = ''
|
|
147
|
+
try { expected = deps.apiToken() } catch { expected = '' }
|
|
148
|
+
// A hook token, or nothing: refused, and never a live client.
|
|
149
|
+
if (!timingSafeTokenEqual(req.headers['x-cos-token'], expected)) {
|
|
150
|
+
res.status(401).json({ error: 'unauthorized' })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
// Only a client that says it can answer (and shows the cards) counts as live.
|
|
154
|
+
const client = typeof req.query.client === 'string' ? req.query.client : ''
|
|
155
|
+
if (ANSWERING_CLIENTS.has(client)) notePoll(now())
|
|
156
|
+
const health = deps.broker.health()
|
|
157
|
+
// Listed first: a deadline the timer has not caught yet is settled by the listing.
|
|
158
|
+
const items = deps.broker.list()
|
|
159
|
+
res.json({
|
|
160
|
+
enabled: health.enabled,
|
|
161
|
+
mode: health.mode,
|
|
162
|
+
protocolVersion: SESSION_QUESTIONS_PROTOCOL_VERSION,
|
|
163
|
+
pollIntervalMs: QUESTIONS_POLL_INTERVAL_MS,
|
|
164
|
+
liveWindowMs: CLIENT_LIVE_WINDOW_MS,
|
|
165
|
+
pending: deps.broker.pendingCount(),
|
|
166
|
+
stats: deps.broker.stats(),
|
|
167
|
+
items,
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
|
|
171
|
+
res.set('Cache-Control', 'private, no-store')
|
|
172
|
+
const result = deps.broker.answer(String(req.params.id ?? ''), req.body)
|
|
173
|
+
res.status(result.status).json(result.body)
|
|
174
|
+
})
|
|
175
|
+
return router
|
|
176
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Router, type Response } from 'express'
|
|
1
|
+
import { Router, type Request, type Response } from 'express'
|
|
2
2
|
import { durableQueryJobsEnabled } from '../lib/query-job-feature.js'
|
|
3
3
|
import {
|
|
4
4
|
QueryJobCoordinator,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
QueryJobPersistenceError,
|
|
17
17
|
QueryJobProviderOrphanFenceError,
|
|
18
18
|
QueryJobStoreError,
|
|
19
|
+
type QueryJobTrailFrame,
|
|
19
20
|
} from '../lib/query-job-store.js'
|
|
20
21
|
import {
|
|
21
22
|
isTerminalQueryJobStatus,
|
|
@@ -89,7 +90,25 @@ function writeSse(res: Response, type: string, event: Record<string, unknown>):
|
|
|
89
90
|
res.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`)
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
/**
|
|
94
|
+
* THE TRAIL IS OPT-IN (6.52.0 QA round 1). A client that does not send `?trail=1` gets
|
|
95
|
+
* exactly the 6.51.0 bytes: no `trail` frames on the stream, and no `trail` field on any
|
|
96
|
+
* snapshot, in any response. A client that does gets `trail` frames (their own dense
|
|
97
|
+
* `trailSeq`, no `id:` line, so Last-Event-ID stays the job eventSeq) and, on subscribe or
|
|
98
|
+
* reconnect, every entry after its `trailAfter` from `snapshot.trail`, or one
|
|
99
|
+
* `trail_snapshot` frame to rebuild from when entries it missed are no longer held.
|
|
100
|
+
*/
|
|
101
|
+
function wantsTrail(req: Request): boolean {
|
|
102
|
+
return req.query.trail === '1'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function forClient(job: QueryJobSnapshot, trail: boolean): QueryJobSnapshot {
|
|
106
|
+
if (trail || !('trail' in job)) return job
|
|
107
|
+
const { trail: _trail, ...rest } = job
|
|
108
|
+
return rest as QueryJobSnapshot
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function snapshotEvent(job: QueryJobSnapshot, reason: string, trail: boolean): Record<string, unknown> {
|
|
93
112
|
return {
|
|
94
113
|
type: 'snapshot',
|
|
95
114
|
eventSeq: job.eventSeq,
|
|
@@ -98,7 +117,7 @@ function snapshotEvent(job: QueryJobSnapshot, reason: string): Record<string, un
|
|
|
98
117
|
generation: job.generation,
|
|
99
118
|
status: job.status,
|
|
100
119
|
at: job.updatedAt,
|
|
101
|
-
data: { reason, job },
|
|
120
|
+
data: { reason, job: forClient(job, trail) },
|
|
102
121
|
}
|
|
103
122
|
}
|
|
104
123
|
|
|
@@ -122,7 +141,7 @@ export function createQueryJobsRouter(
|
|
|
122
141
|
try {
|
|
123
142
|
const prepared = options.prepareAdmission ? await options.prepareAdmission(req.body) : req.body
|
|
124
143
|
const admission = await coordinator.submit(prepared)
|
|
125
|
-
return res.status(202).json({ job: admission.job })
|
|
144
|
+
return res.status(202).json({ job: forClient(admission.job, wantsTrail(req)) })
|
|
126
145
|
} catch (error) {
|
|
127
146
|
const wire = wireError(error)
|
|
128
147
|
return res.status(wire.status).json(wire.body)
|
|
@@ -138,7 +157,7 @@ export function createQueryJobsRouter(
|
|
|
138
157
|
const generation = requiredGeneration(req.query.generation)
|
|
139
158
|
const job = await coordinator.getByClientGeneration(clientJobId, generation)
|
|
140
159
|
if (!job) throw new QueryJobNotFoundError(`${clientJobId}:${generation}`)
|
|
141
|
-
return res.json({ job })
|
|
160
|
+
return res.json({ job: forClient(job, wantsTrail(req)) })
|
|
142
161
|
} catch (error) {
|
|
143
162
|
const wire = wireError(error)
|
|
144
163
|
return res.status(wire.status).json(wire.body)
|
|
@@ -150,7 +169,7 @@ export function createQueryJobsRouter(
|
|
|
150
169
|
const jobId = validJobId(req.params.jobId)
|
|
151
170
|
const generation = requiredGeneration(req.query.generation)
|
|
152
171
|
const job = await coordinator.getSnapshot(jobId, generation)
|
|
153
|
-
return res.json({ job })
|
|
172
|
+
return res.json({ job: forClient(job, wantsTrail(req)) })
|
|
154
173
|
} catch (error) {
|
|
155
174
|
const wire = wireError(error)
|
|
156
175
|
return res.status(wire.status).json(wire.body)
|
|
@@ -163,7 +182,15 @@ export function createQueryJobsRouter(
|
|
|
163
182
|
let closed = false
|
|
164
183
|
let streamReady = false
|
|
165
184
|
let sentSeq = -1
|
|
185
|
+
let sentTrailSeq = 0
|
|
166
186
|
const pendingLive: QueryJobEvent[] = []
|
|
187
|
+
const pendingTrail: QueryJobTrailFrame[] = []
|
|
188
|
+
const trail = wantsTrail(req)
|
|
189
|
+
const writeTrail = (frame: QueryJobTrailFrame) => {
|
|
190
|
+
if (closed || frame.trailSeq <= sentTrailSeq) return
|
|
191
|
+
sentTrailSeq = frame.trailSeq
|
|
192
|
+
writeSse(res, 'trail', frame as unknown as Record<string, unknown>)
|
|
193
|
+
}
|
|
167
194
|
const cleanup = () => {
|
|
168
195
|
if (closed) return
|
|
169
196
|
closed = true
|
|
@@ -184,8 +211,22 @@ export function createQueryJobsRouter(
|
|
|
184
211
|
const generation = requiredGeneration(req.query.generation)
|
|
185
212
|
const after = cursor(req.query.after)
|
|
186
213
|
sentSeq = after
|
|
214
|
+
const trailAfter = trail ? cursor(req.query.trailAfter) : 0
|
|
215
|
+
sentTrailSeq = trailAfter
|
|
216
|
+
const trailSubscription = trail
|
|
217
|
+
? {
|
|
218
|
+
after: trailAfter,
|
|
219
|
+
listener: (frame: QueryJobTrailFrame) => {
|
|
220
|
+
if (!streamReady) {
|
|
221
|
+
pendingTrail.push(frame)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
writeTrail(frame)
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
: undefined
|
|
187
228
|
|
|
188
|
-
const
|
|
229
|
+
const onEvent = (event: QueryJobEvent) => {
|
|
189
230
|
// subscribe() registers its live listener before returning the replay
|
|
190
231
|
// snapshot so no append can fall into a gap. A very fast provider may
|
|
191
232
|
// therefore publish while this route is still installing SSE headers.
|
|
@@ -199,7 +240,11 @@ export function createQueryJobsRouter(
|
|
|
199
240
|
sentSeq = event.eventSeq
|
|
200
241
|
writeSse(res, event.type, event as unknown as Record<string, unknown>)
|
|
201
242
|
if (isTerminalQueryJobStatus(event.status)) finish()
|
|
202
|
-
}
|
|
243
|
+
}
|
|
244
|
+
// Without `?trail=1` the call is exactly the 6.51.0 one.
|
|
245
|
+
const subscription = trailSubscription
|
|
246
|
+
? await coordinator.subscribe(jobId, generation, after, onEvent, trailSubscription)
|
|
247
|
+
: await coordinator.subscribe(jobId, generation, after, onEvent)
|
|
203
248
|
unsubscribe = subscription.unsubscribe
|
|
204
249
|
if (closed) {
|
|
205
250
|
unsubscribe()
|
|
@@ -216,9 +261,33 @@ export function createQueryJobsRouter(
|
|
|
216
261
|
res.flushHeaders()
|
|
217
262
|
res.write(': keepalive\n\n')
|
|
218
263
|
|
|
219
|
-
const { replay } = subscription
|
|
264
|
+
const { replay, trailReplay } = subscription
|
|
265
|
+
// The trail first: every entry precedes the terminal event that may end this stream.
|
|
266
|
+
if (trail && trailReplay) {
|
|
267
|
+
if (trailReplay.gap) {
|
|
268
|
+
writeSse(res, 'trail_snapshot', {
|
|
269
|
+
type: 'trail_snapshot',
|
|
270
|
+
reason: trailReplay.reason ?? 'trail_gap',
|
|
271
|
+
jobId: replay.snapshot.jobId,
|
|
272
|
+
clientJobId: replay.snapshot.clientJobId,
|
|
273
|
+
generation: replay.snapshot.generation,
|
|
274
|
+
oldestTrailSeq: trailReplay.oldestTrailSeq,
|
|
275
|
+
latestTrailSeq: trailReplay.latestTrailSeq,
|
|
276
|
+
trail: trailReplay.entries,
|
|
277
|
+
})
|
|
278
|
+
sentTrailSeq = trailReplay.latestTrailSeq
|
|
279
|
+
} else {
|
|
280
|
+
for (const entry of trailReplay.entries) {
|
|
281
|
+
const { trailSeq, at, ...data } = entry
|
|
282
|
+
writeTrail({
|
|
283
|
+
type: 'trail', trailSeq, at, data,
|
|
284
|
+
jobId: replay.snapshot.jobId, clientJobId: replay.snapshot.clientJobId, generation: replay.snapshot.generation,
|
|
285
|
+
} as QueryJobTrailFrame)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
220
289
|
if (replay.gap) {
|
|
221
|
-
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap'))
|
|
290
|
+
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap', trail))
|
|
222
291
|
sentSeq = replay.snapshot.eventSeq
|
|
223
292
|
} else {
|
|
224
293
|
for (const event of replay.events) {
|
|
@@ -230,7 +299,7 @@ export function createQueryJobsRouter(
|
|
|
230
299
|
|
|
231
300
|
const replayTerminal = replay.events.some(event => isTerminalQueryJobStatus(event.status))
|
|
232
301
|
if (isTerminalQueryJobStatus(replay.snapshot.status) && !replayTerminal && !replay.gap) {
|
|
233
|
-
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot'))
|
|
302
|
+
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot', trail))
|
|
234
303
|
sentSeq = replay.snapshot.eventSeq
|
|
235
304
|
}
|
|
236
305
|
if (isTerminalQueryJobStatus(replay.snapshot.status)) {
|
|
@@ -239,6 +308,8 @@ export function createQueryJobsRouter(
|
|
|
239
308
|
}
|
|
240
309
|
|
|
241
310
|
streamReady = true
|
|
311
|
+
for (const frame of pendingTrail) writeTrail(frame)
|
|
312
|
+
pendingTrail.length = 0
|
|
242
313
|
for (const event of pendingLive) {
|
|
243
314
|
if (closed || event.eventSeq <= sentSeq) continue
|
|
244
315
|
sentSeq = event.eventSeq
|
|
@@ -271,7 +342,7 @@ export function createQueryJobsRouter(
|
|
|
271
342
|
const jobId = validJobId(req.params.jobId)
|
|
272
343
|
const generation = requiredGeneration(req.body?.generation)
|
|
273
344
|
const { job } = await coordinator.cancel(jobId, generation)
|
|
274
|
-
return res.json({ job })
|
|
345
|
+
return res.json({ job: forClient(job, wantsTrail(req)) })
|
|
275
346
|
} catch (error) {
|
|
276
347
|
const wire = wireError(error)
|
|
277
348
|
return res.status(wire.status).json(wire.body)
|
|
@@ -283,7 +354,7 @@ export function createQueryJobsRouter(
|
|
|
283
354
|
const jobId = validJobId(req.params.jobId)
|
|
284
355
|
const generation = requiredGeneration(req.body?.generation)
|
|
285
356
|
const job = await coordinator.acknowledge(jobId, generation)
|
|
286
|
-
return res.json({ job })
|
|
357
|
+
return res.json({ job: forClient(job, wantsTrail(req)) })
|
|
287
358
|
} catch (error) {
|
|
288
359
|
const wire = wireError(error)
|
|
289
360
|
return res.status(wire.status).json(wire.body)
|
|
@@ -119,7 +119,14 @@ export async function drainThread(
|
|
|
119
119
|
// for every queued row repeats a whole-process descriptor scan. A refusal
|
|
120
120
|
// can delay delivery until the next sweep, but can never authorize a write.
|
|
121
121
|
// Positive verdicts and all checks after a delivery remain fresh.
|
|
122
|
-
|
|
122
|
+
//
|
|
123
|
+
// 6.51.0: a Cursor turn is NEVER delivered from here. It leaves through the
|
|
124
|
+
// composer's own Stop hook (`routes/cursor-stop-followup.ts`), which claims it the
|
|
125
|
+
// moment the turn ends; a second deliverer could hand the same sentence over twice.
|
|
126
|
+
// The pass still runs for it so the TTL retires an unclaimed turn honestly.
|
|
127
|
+
const gate: ReturnType<ThreadTurnQueueDeps['occupancy']> = provider === 'cursor'
|
|
128
|
+
? { attachable: false, reason: 'cursor_stop_hook_delivery' }
|
|
129
|
+
: refusedGate ?? deps.occupancy(provider, threadId)
|
|
123
130
|
if (!gate.attachable) refusedGate = gate
|
|
124
131
|
const seen: DrainObservation = {
|
|
125
132
|
attachable: gate.attachable,
|
|
@@ -228,15 +235,18 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
228
235
|
return res.status(400).json({ error: 'invalid_request', reason: 'invalid_cos_session_id' })
|
|
229
236
|
}
|
|
230
237
|
|
|
231
|
-
if (provider === 'cursor') {
|
|
232
|
-
return res.status(423).json({ error: 'unsupported_provider', queueable: false })
|
|
233
|
-
}
|
|
234
|
-
|
|
235
238
|
// THE GATE DECIDES WHETHER PARKING IS EVEN HONEST. A structural refusal can never
|
|
236
239
|
// clear, and telling someone their turn is queued when it can never run is worse
|
|
237
240
|
// than refusing it. An attachable thread is not queued either -- it is sent now,
|
|
238
241
|
// through the ordinary route, which the client does on this 409.
|
|
239
242
|
const gate = deps.occupancy(provider, threadId)
|
|
243
|
+
|
|
244
|
+
// 6.51.0: Cursor queues ONLY a composer its hooks show mid-turn, because the only way
|
|
245
|
+
// in is that turn's Stop hook. An idle composer fires no Stop, so a turn parked there
|
|
246
|
+
// could never run; that stays the 423 it always was, as does every Agent CLI chat.
|
|
247
|
+
if (provider === 'cursor' && gate.reason !== 'native_thread_working') {
|
|
248
|
+
return res.status(423).json({ error: 'unsupported_provider', queueable: false })
|
|
249
|
+
}
|
|
240
250
|
if (gate.attachable) return res.status(409).json({ error: 'thread_free', hint: 'send_now' })
|
|
241
251
|
if (!queueableRefusal(gate.reason)) {
|
|
242
252
|
return res.status(423).json({ error: gate.reason ?? 'probe_failed', queueable: false })
|