@gotcos/glasses-server 6.76.1 → 6.78.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 +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -18,12 +18,17 @@
|
|
|
18
18
|
import { Router, type Request, type Response } from 'express'
|
|
19
19
|
import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
|
|
20
20
|
import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
|
|
21
|
-
import { COS_SESSION_ID_RE, DEFAULT_MAX_PROMPT_CHARS } from './agent-session-bindings.js'
|
|
21
|
+
import { CLIENT_TURN_ID_RE, COS_SESSION_ID_RE, DEFAULT_MAX_PROMPT_CHARS } from './agent-session-bindings.js'
|
|
22
22
|
import {
|
|
23
23
|
admitToQueue, drainDecision, queueableRefusal, queuePosition,
|
|
24
24
|
MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
|
|
25
|
+
queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
|
|
26
|
+
type SessionSteerAvailability,
|
|
25
27
|
} from '../lib/thread-turn-queue.js'
|
|
26
28
|
import { readQueue, writeQueue, queuedThreadKeys } from '../lib/thread-turn-queue-store.js'
|
|
29
|
+
import type { DeliveryEvidence } from '../lib/queue-turn-evidence.js'
|
|
30
|
+
import { continueLiveEnabled } from '../lib/session-peer-inbox-deps.js'
|
|
31
|
+
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
27
32
|
|
|
28
33
|
export interface ThreadTurnQueueDeps {
|
|
29
34
|
/** Re-runs the FULL occupancy gate. The drainer never decides attachability itself. */
|
|
@@ -48,6 +53,10 @@ export interface ThreadTurnQueueDeps {
|
|
|
48
53
|
reason?: string
|
|
49
54
|
/** The turn route's OWN `retryable` judgement, when it sent one. Authoritative. */
|
|
50
55
|
serverRetryable?: boolean
|
|
56
|
+
/** 6.78.0: the turn route REPLAYED this key (`replayed: true`) rather than deciding afresh. */
|
|
57
|
+
replayed?: boolean
|
|
58
|
+
/** 6.78.0: the turn route's `deliveryState`; `unknown` on a replay is settled, never retried. */
|
|
59
|
+
deliveryState?: string
|
|
51
60
|
}>
|
|
52
61
|
nativeQueue?: NativeQueueControl
|
|
53
62
|
/**
|
|
@@ -58,11 +67,54 @@ export interface ThreadTurnQueueDeps {
|
|
|
58
67
|
*/
|
|
59
68
|
cursorCliChat?: (threadId: string) => boolean
|
|
60
69
|
now: () => number
|
|
70
|
+
|
|
71
|
+
// ------------------------------------------------------------- 6.78.0 (P3)
|
|
72
|
+
// Every member is optional. Absent, the queue behaves exactly as 6.77.0 for that member's
|
|
73
|
+
// part: no stamp boot id, no restart reconcile, no steer, no attempt journal.
|
|
74
|
+
|
|
75
|
+
/** This server's boot (`serverMetrics.bootId`). A `delivering` row stamped with another boot was cut off. */
|
|
76
|
+
bootId?: string
|
|
77
|
+
/**
|
|
78
|
+
* What happened to a `delivering` row a dead boot left (MVP-B, read by `drainThread`).
|
|
79
|
+
* Production: `makeDeliveryEvidence` over the registry, the transcript and the Codex queue.
|
|
80
|
+
* May be async. A throw is `unknown`.
|
|
81
|
+
*/
|
|
82
|
+
deliveryEvidence?: (turn: QueuedThreadTurn) => DeliveryEvidence | Promise<DeliveryEvidence>
|
|
83
|
+
/** Session steer's transport: `makeLiveTurnDeliverer`, the same one the turns route's live hop uses. */
|
|
84
|
+
deliverLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number; clientTurnId?: string }) => Promise<{ ok: boolean; reason: string }>
|
|
85
|
+
/** Can this Claude session take a steer: is a peer inbox open, and is it held on a prompt? */
|
|
86
|
+
steerProbe?: (provider: string, threadId: string) => { peerInbox: boolean; waitingOnPrompt: boolean }
|
|
87
|
+
/** `COS_CONTINUE_LIVE`. Defaults to `continueLiveEnabled()`, read per request. */
|
|
88
|
+
continueLive?: () => boolean
|
|
89
|
+
/** The attempt journal (the binding registry). The steer path writes `pending` here BEFORE any frame. */
|
|
90
|
+
attempts?: {
|
|
91
|
+
begin: (provider: string, threadId: string, clientTurnId: string, mode: 'turn' | 'steer', now: number) => boolean
|
|
92
|
+
settle: (provider: string, threadId: string, clientTurnId: string, exit: 'sent' | 'not_sent' | 'unknown', now: number) => boolean
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Steer availability for one thread, from the shared rule. A throwing probe is "no channel":
|
|
98
|
+
* the turn is queued, which is always safe.
|
|
99
|
+
*/
|
|
100
|
+
export function steerAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe' | 'deliverLiveTurn' | 'continueLive'>, provider: string, threadId: string, gateReason: string | null): SessionSteerAvailability {
|
|
101
|
+
let probe: { peerInbox: boolean; waitingOnPrompt: boolean } = { peerInbox: false, waitingOnPrompt: false }
|
|
102
|
+
if (provider === 'claude' && typeof deps.steerProbe === 'function' && typeof deps.deliverLiveTurn === 'function') {
|
|
103
|
+
try {
|
|
104
|
+
const read = deps.steerProbe(provider, threadId)
|
|
105
|
+
probe = { peerInbox: read?.peerInbox === true, waitingOnPrompt: read?.waitingOnPrompt === true }
|
|
106
|
+
} catch {
|
|
107
|
+
probe = { peerInbox: false, waitingOnPrompt: false }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let continueLive = false
|
|
111
|
+
try { continueLive = (deps.continueLive ?? (() => continueLiveEnabled()))() === true } catch { continueLive = false }
|
|
112
|
+
return sessionSteerAvailability({ provider, gateReason, continueLive, peerInbox: probe.peerInbox, waitingOnPrompt: probe.waitingOnPrompt })
|
|
61
113
|
}
|
|
62
114
|
|
|
63
115
|
/** Public shape of a queued turn. No prompt echo beyond a short preview. */
|
|
64
|
-
function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unknown> {
|
|
65
|
-
|
|
116
|
+
export function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unknown> {
|
|
117
|
+
const row: Record<string, unknown> = {
|
|
66
118
|
clientTurnId: turn.clientTurnId,
|
|
67
119
|
status: turn.status,
|
|
68
120
|
queuedAt: turn.queuedAt,
|
|
@@ -75,6 +127,21 @@ function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unk
|
|
|
75
127
|
...(turn.reason ? { reason: turn.reason } : {}),
|
|
76
128
|
...(turn.settledAt ? { settledAt: turn.settledAt } : {}),
|
|
77
129
|
}
|
|
130
|
+
// 6.78.0: additive, and only while a P3 flag is on, so the row is the 6.77 bytes otherwise.
|
|
131
|
+
// `resendable` is the per-row capability: present only when Send again can act on it.
|
|
132
|
+
const reconcile = queueReconcileEnabled()
|
|
133
|
+
const resend = queueResendEnabled()
|
|
134
|
+
if (!reconcile && !resend && !sessionSteerEnabled()) return row
|
|
135
|
+
const reasonCopy = reconcile ? queuedTurnReasonCopy(turn.reason) : null
|
|
136
|
+
return {
|
|
137
|
+
...row,
|
|
138
|
+
...(reasonCopy ? { reasonCopy } : {}),
|
|
139
|
+
deliveryState: turn.deliveryState ?? null,
|
|
140
|
+
mode: turn.mode ?? 'queue',
|
|
141
|
+
resendOf: turn.resendOf ?? null,
|
|
142
|
+
resentAs: turn.resentAs ?? null,
|
|
143
|
+
...(resend && (turn.status === 'refused' || turn.status === 'expired') ? { resendable: true } : {}),
|
|
144
|
+
}
|
|
78
145
|
}
|
|
79
146
|
|
|
80
147
|
/**
|
|
@@ -129,15 +196,19 @@ export async function drainThread(
|
|
|
129
196
|
threadId: string,
|
|
130
197
|
deps: ThreadTurnQueueDeps,
|
|
131
198
|
): Promise<{ delivered: number; held: number; retired: number }> {
|
|
199
|
+
// 6.78.0: settle what a dead boot left `delivering` BEFORE anything is drained, so a turn
|
|
200
|
+
// proved unsent is sent in this same pass and an unknown one is never sent at all.
|
|
201
|
+
const reconcile = queueReconcileEnabled()
|
|
202
|
+
if (reconcile && typeof deps.deliveryEvidence === 'function') await reconcileDeliveringTurns(provider, threadId, deps)
|
|
132
203
|
const now = deps.now()
|
|
133
204
|
const queue = readQueue(provider, threadId, now)
|
|
134
205
|
if (queue.length === 0) return { delivered: 0, held: 0, retired: 0 }
|
|
135
206
|
|
|
136
207
|
let delivered = 0, held = 0, retired = 0
|
|
137
|
-
const persist = (turn: QueuedThreadTurn) => {
|
|
208
|
+
const persist = (turn: QueuedThreadTurn, durable = false) => {
|
|
138
209
|
const latest = readQueue(provider, threadId, deps.now())
|
|
139
210
|
const index = latest.findIndex(t => t.clientTurnId === turn.clientTurnId)
|
|
140
|
-
if (index >= 0) { latest[index] = turn; writeQueue(provider, threadId, latest) }
|
|
211
|
+
if (index >= 0) { latest[index] = turn; writeQueue(provider, threadId, latest, durable ? { durable: true } : {}) }
|
|
141
212
|
}
|
|
142
213
|
let refusedGate: ReturnType<ThreadTurnQueueDeps['occupancy']> | null = null
|
|
143
214
|
|
|
@@ -196,9 +267,18 @@ export async function drainThread(
|
|
|
196
267
|
// forever; the ceiling only bounds anything if it survives the crash it is bounding.
|
|
197
268
|
turn.attempts += 1
|
|
198
269
|
turn.status = 'delivering'
|
|
199
|
-
|
|
270
|
+
// 6.78.0: the stamp the restart reconcile reads. `journal: 1` says this build keeps an
|
|
271
|
+
// attempt record for the delivery that follows; `deliveringBootId` says which boot owns
|
|
272
|
+
// it, so a sweep never touches a row this process is delivering right now. Durable:
|
|
273
|
+
// this is the intent a crash leaves behind.
|
|
274
|
+
if (reconcile) {
|
|
275
|
+
turn.journal = 1
|
|
276
|
+
if (typeof deps.bootId === 'string' && deps.bootId.length > 0) turn.deliveringBootId = deps.bootId
|
|
277
|
+
else delete turn.deliveringBootId
|
|
278
|
+
}
|
|
279
|
+
persist(turn, reconcile)
|
|
200
280
|
|
|
201
|
-
let outcome:
|
|
281
|
+
let outcome: Awaited<ReturnType<ThreadTurnQueueDeps['deliver']>>
|
|
202
282
|
refusedGate = null
|
|
203
283
|
try {
|
|
204
284
|
outcome = await deps.deliver(turn)
|
|
@@ -219,6 +299,15 @@ export async function drainThread(
|
|
|
219
299
|
turn.reason = 'turn_cancelled'
|
|
220
300
|
turn.settledAt = deps.now()
|
|
221
301
|
retired += 1
|
|
302
|
+
} else if (reconcile && outcome.replayed === true && outcome.deliveryState === 'unknown') {
|
|
303
|
+
// 6.78.0 (V1 W6): the turn route REPLAYED this key as unknown (a receipt, or an
|
|
304
|
+
// ambiguous ledger record). It will answer the same on every retry, by design, so it
|
|
305
|
+
// is settled now as what it is: may have been sent, never sent again.
|
|
306
|
+
turn.status = 'refused'
|
|
307
|
+
turn.reason = 'delivery_unknown_after_restart'
|
|
308
|
+
turn.deliveryState = 'unknown'
|
|
309
|
+
turn.settledAt = deps.now()
|
|
310
|
+
retired += 1
|
|
222
311
|
} else if (isRetryableDelivery(outcome)) {
|
|
223
312
|
// A GATE REFUSAL IS NOT A FAILED DELIVERY, so it must not spend the ceiling --
|
|
224
313
|
// measured cost of getting this wrong: three of the user's turns retired in about
|
|
@@ -250,6 +339,75 @@ export async function drainThread(
|
|
|
250
339
|
return { delivered, held, retired }
|
|
251
340
|
}
|
|
252
341
|
|
|
342
|
+
/**
|
|
343
|
+
* 6.78.0 (MVP-B): settle the `delivering` rows a dead boot left on one thread.
|
|
344
|
+
*
|
|
345
|
+
* Today a row the process died delivering holds forever: `drainDecision` only acts on
|
|
346
|
+
* `waiting`, `pruneQueue` keeps it, DELETE refuses it, and the busy-Codex hop stays off for
|
|
347
|
+
* the thread while it exists. Only rows stamped by ANOTHER boot (or by none) are touched, so
|
|
348
|
+
* a sweep cannot settle a row this process is delivering right now.
|
|
349
|
+
*
|
|
350
|
+
* The verdict is `deps.deliveryEvidence`; the one rule it cannot override is enforced here
|
|
351
|
+
* as well: only a row this build stamped (`journal: 1`) and that is not a steer can be put
|
|
352
|
+
* back to wait. Everything else needs positive evidence of delivery or is unknown.
|
|
353
|
+
* delivered `delivered`, reason `reconciled_after_restart`
|
|
354
|
+
* not_delivered back to `waiting`, reason `requeued_after_restart`, the attempt stays SPENT
|
|
355
|
+
* (the ceiling must outlive the crash it bounds)
|
|
356
|
+
* unknown `refused`, reason `delivery_unknown_after_restart`, `deliveryState:
|
|
357
|
+
* 'unknown'`, kept six hours and never resent
|
|
358
|
+
*/
|
|
359
|
+
export async function reconcileDeliveringTurns(
|
|
360
|
+
provider: string,
|
|
361
|
+
threadId: string,
|
|
362
|
+
deps: ThreadTurnQueueDeps,
|
|
363
|
+
): Promise<{ delivered: number; requeued: number; unknown: number }> {
|
|
364
|
+
const counts = { delivered: 0, requeued: 0, unknown: 0 }
|
|
365
|
+
if (typeof deps.deliveryEvidence !== 'function') return counts
|
|
366
|
+
// Without its own boot id this process cannot tell a row it is delivering right now (the
|
|
367
|
+
// drain and the steer path stamp only with one) from a row a dead boot left, so it
|
|
368
|
+
// touches nothing: a row in flight must never be judged.
|
|
369
|
+
const ownBoot = typeof deps.bootId === 'string' && deps.bootId.length > 0 ? deps.bootId : null
|
|
370
|
+
if (ownBoot === null) return counts
|
|
371
|
+
const stranded = readQueue(provider, threadId, deps.now())
|
|
372
|
+
.filter(t => t.status === 'delivering' && t.deliveringBootId !== ownBoot)
|
|
373
|
+
for (const row of stranded) {
|
|
374
|
+
let verdict: DeliveryEvidence
|
|
375
|
+
try {
|
|
376
|
+
verdict = await deps.deliveryEvidence(row)
|
|
377
|
+
} catch {
|
|
378
|
+
verdict = 'unknown'
|
|
379
|
+
}
|
|
380
|
+
if (verdict !== 'delivered' && verdict !== 'not_delivered') verdict = 'unknown'
|
|
381
|
+
if (verdict === 'not_delivered' && (row.journal !== 1 || row.mode === 'steer')) verdict = 'unknown'
|
|
382
|
+
// Re-read after the await, and only touch the row if it is still the one we judged.
|
|
383
|
+
const latest = readQueue(provider, threadId, deps.now())
|
|
384
|
+
const current = latest.find(t => t.clientTurnId === row.clientTurnId)
|
|
385
|
+
if (!current || current.status !== 'delivering' || current.deliveringBootId !== row.deliveringBootId) continue
|
|
386
|
+
const at = deps.now()
|
|
387
|
+
if (verdict === 'delivered') {
|
|
388
|
+
current.status = 'delivered'
|
|
389
|
+
current.reason = 'reconciled_after_restart'
|
|
390
|
+
current.settledAt = at
|
|
391
|
+
counts.delivered += 1
|
|
392
|
+
} else if (verdict === 'not_delivered') {
|
|
393
|
+
current.status = 'waiting'
|
|
394
|
+
current.reason = 'requeued_after_restart'
|
|
395
|
+
delete current.journal
|
|
396
|
+
delete current.deliveringBootId
|
|
397
|
+
counts.requeued += 1
|
|
398
|
+
} else {
|
|
399
|
+
current.status = 'refused'
|
|
400
|
+
current.reason = 'delivery_unknown_after_restart'
|
|
401
|
+
current.deliveryState = 'unknown'
|
|
402
|
+
current.settledAt = at
|
|
403
|
+
counts.unknown += 1
|
|
404
|
+
}
|
|
405
|
+
writeQueue(provider, threadId, latest, { durable: true })
|
|
406
|
+
console.log(`[thread-turn-queue] reconciled provider=${provider} clientTurnId=${row.clientTurnId} verdict=${verdict} stamped=${row.journal === 1} mode=${row.mode ?? 'queue'}`)
|
|
407
|
+
}
|
|
408
|
+
return counts
|
|
409
|
+
}
|
|
410
|
+
|
|
253
411
|
/** One pass over every thread holding a queue. */
|
|
254
412
|
export async function drainAllThreads(deps: ThreadTurnQueueDeps): Promise<number> {
|
|
255
413
|
let delivered = 0
|
|
@@ -264,11 +422,127 @@ export async function drainAllThreads(deps: ThreadTurnQueueDeps): Promise<number
|
|
|
264
422
|
return delivered
|
|
265
423
|
}
|
|
266
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Session steer (6.78.0, COS_SESSION_STEER): the turn goes INTO the running Claude turn
|
|
427
|
+
* through the session's peer inbox, and lands at its next tool boundary (live probe 3,
|
|
428
|
+
* Claude Code 2.1.302: a COS-format frame written mid-tool folded into the running turn).
|
|
429
|
+
*
|
|
430
|
+
* THE ORDER IS THE SAFETY:
|
|
431
|
+
* 1. the intent row, durably: `delivering`, `mode: 'steer'`, stamped
|
|
432
|
+
* 2. the attempt `pending`, durably, BEFORE any frame
|
|
433
|
+
* 3. `deliverLiveTurn`
|
|
434
|
+
* 4. the verdict, journal first, then the row
|
|
435
|
+
* A crash after 1 leaves a steer row with no positive evidence (the reconcile reads it as
|
|
436
|
+
* unknown, never resends it); after 2, an attempt that never reported back (unknown).
|
|
437
|
+
*
|
|
438
|
+
* ok 200 {state: delivered, reason: steered}
|
|
439
|
+
* unverified, write_failed, a throw 200 {state: refused, reason: steer_unverified,
|
|
440
|
+
* deliveryState: unknown}: may be in the session,
|
|
441
|
+
* never resent, kept six hours
|
|
442
|
+
* nothing reached the session the row waits like any parked turn: 202 with
|
|
443
|
+
* `steerFallback` (or, with fallback none, settled
|
|
444
|
+
* `refused` / `steer_not_sent`)
|
|
445
|
+
*/
|
|
446
|
+
async function steerTurn(
|
|
447
|
+
_req: Request,
|
|
448
|
+
res: Response,
|
|
449
|
+
deps: ThreadTurnQueueDeps,
|
|
450
|
+
turn: QueuedThreadTurn,
|
|
451
|
+
queue: QueuedThreadTurn[],
|
|
452
|
+
gateReason: string | null,
|
|
453
|
+
fallbackNone: boolean,
|
|
454
|
+
): Promise<unknown> {
|
|
455
|
+
const { provider, threadId, clientTurnId, prompt } = turn
|
|
456
|
+
const intent: QueuedThreadTurn = {
|
|
457
|
+
...turn,
|
|
458
|
+
status: 'delivering',
|
|
459
|
+
attempts: 1,
|
|
460
|
+
mode: 'steer',
|
|
461
|
+
journal: 1,
|
|
462
|
+
...(typeof deps.bootId === 'string' && deps.bootId.length > 0 ? { deliveringBootId: deps.bootId } : {}),
|
|
463
|
+
}
|
|
464
|
+
const admitted = admitToQueue(queue, intent)
|
|
465
|
+
if (!admitted.ok) return res.status(409).json({ error: admitted.reason })
|
|
466
|
+
writeQueue(provider, threadId, admitted.queue, { durable: true })
|
|
467
|
+
|
|
468
|
+
/** Rewrite this row on the CURRENT file: other requests may have changed others meanwhile. */
|
|
469
|
+
const settleRow = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn[] => {
|
|
470
|
+
const latest = readQueue(provider, threadId, deps.now())
|
|
471
|
+
const row = latest.find(t => t.clientTurnId === clientTurnId)
|
|
472
|
+
if (row) { apply(row); writeQueue(provider, threadId, latest, { durable: true }) }
|
|
473
|
+
return latest
|
|
474
|
+
}
|
|
475
|
+
const backToWaiting = (row: QueuedThreadTurn): void => {
|
|
476
|
+
row.status = 'waiting'
|
|
477
|
+
row.attempts = 0
|
|
478
|
+
delete row.mode
|
|
479
|
+
delete row.journal
|
|
480
|
+
delete row.deliveringBootId
|
|
481
|
+
}
|
|
482
|
+
const queued202 = (latest: QueuedThreadTurn[], steerFallback: string) => res.status(202).json({
|
|
483
|
+
queued: true,
|
|
484
|
+
clientTurnId,
|
|
485
|
+
position: queuePosition(latest, clientTurnId),
|
|
486
|
+
waitingOn: gateReason,
|
|
487
|
+
steerFallback,
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
let begun = false
|
|
491
|
+
try {
|
|
492
|
+
begun = deps.attempts?.begin(provider, threadId, clientTurnId, 'steer', deps.now()) === true
|
|
493
|
+
} catch {
|
|
494
|
+
begun = false
|
|
495
|
+
}
|
|
496
|
+
if (!begun) {
|
|
497
|
+
// Nothing was sent, and nothing may be until the attempt is on record: an ordinary park.
|
|
498
|
+
const latest = settleRow(backToWaiting)
|
|
499
|
+
console.warn(`[thread-turn-queue] steer not attempted provider=${provider} clientTurnId=${clientTurnId}: the attempt journal could not record it; queued`)
|
|
500
|
+
return queued202(latest, 'journal_unavailable')
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let live: { ok: boolean; reason: string } | null = null
|
|
504
|
+
try {
|
|
505
|
+
live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
|
|
506
|
+
} catch (error) {
|
|
507
|
+
console.error(`[thread-turn-queue] steer delivery threw: ${error instanceof Error ? error.message : error}`)
|
|
508
|
+
live = null
|
|
509
|
+
}
|
|
510
|
+
const at = deps.now()
|
|
511
|
+
const journal = (exit: 'sent' | 'not_sent' | 'unknown'): void => {
|
|
512
|
+
try { deps.attempts?.settle(provider, threadId, clientTurnId, exit, at) } catch { /* the reconcile reads `pending` as unknown */ }
|
|
513
|
+
}
|
|
514
|
+
console.log(`[thread-turn-queue] steer provider=${provider} clientTurnId=${clientTurnId} hop=${live?.reason ?? 'threw'}`)
|
|
515
|
+
|
|
516
|
+
if (live?.ok === true) {
|
|
517
|
+
journal('sent')
|
|
518
|
+
settleRow(row => { row.status = 'delivered'; row.reason = 'steered'; row.settledAt = at })
|
|
519
|
+
return res.status(200).json({ state: 'delivered', reason: 'steered', clientTurnId })
|
|
520
|
+
}
|
|
521
|
+
if (live === null || live.reason === 'unverified' || live.reason === 'write_failed') {
|
|
522
|
+
journal('unknown')
|
|
523
|
+
settleRow(row => { row.status = 'refused'; row.reason = 'steer_unverified'; row.deliveryState = 'unknown'; row.settledAt = at })
|
|
524
|
+
return res.status(200).json({
|
|
525
|
+
state: 'refused',
|
|
526
|
+
reason: 'steer_unverified',
|
|
527
|
+
deliveryState: 'unknown',
|
|
528
|
+
reasonCopy: queuedTurnReasonCopy('steer_unverified'),
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
// disabled, no_record, connect_failed, protocol_unsupported, suspect_expired, not_claude:
|
|
532
|
+
// the transport's own word that nothing reached a session.
|
|
533
|
+
journal('not_sent')
|
|
534
|
+
if (fallbackNone) {
|
|
535
|
+
settleRow(row => { row.status = 'refused'; row.reason = 'steer_not_sent'; row.settledAt = at })
|
|
536
|
+
return res.status(200).json({ state: 'refused', reason: 'steer_not_sent', reasonCopy: queuedTurnReasonCopy('steer_not_sent') })
|
|
537
|
+
}
|
|
538
|
+
return queued202(settleRow(backToWaiting), 'no_outside_channel')
|
|
539
|
+
}
|
|
540
|
+
|
|
267
541
|
export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
268
542
|
const router = Router()
|
|
269
543
|
|
|
270
544
|
// POST — park a turn for a thread that is busy right now.
|
|
271
|
-
router.post('/agent-sessions/:provider/:threadId/queued-turns', (req: Request, res: Response) => {
|
|
545
|
+
router.post('/agent-sessions/:provider/:threadId/queued-turns', async (req: Request, res: Response) => {
|
|
272
546
|
res.set('Cache-Control', 'private, no-store')
|
|
273
547
|
const provider = String(req.params.provider ?? '')
|
|
274
548
|
const threadId = String(req.params.threadId ?? '')
|
|
@@ -286,6 +560,30 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
286
560
|
return res.status(400).json({ error: 'invalid_request', reason: 'invalid_cos_session_id' })
|
|
287
561
|
}
|
|
288
562
|
|
|
563
|
+
// 6.78.0: `mode: 'steer'` asks for the running turn itself (COS_SESSION_STEER). Off, the
|
|
564
|
+
// field is ignored exactly as a 6.77 server ignores it.
|
|
565
|
+
const steerOn = sessionSteerEnabled()
|
|
566
|
+
const wantSteer = steerOn && body.mode === 'steer'
|
|
567
|
+
const fallbackNone = wantSteer && body.fallback === 'none'
|
|
568
|
+
|
|
569
|
+
// 6.78.0: REPLAY BEFORE THE GATE. A key this queue already holds answers with what that
|
|
570
|
+
// row is, whatever the thread is doing now: still 409 `duplicate_turn` (the 6.10.644 lens
|
|
571
|
+
// and Control read that code as "parked"), with the row's state, its delivery state and
|
|
572
|
+
// the reason line. Before 6.78 the gate ran first, so a retry of a turn that had since
|
|
573
|
+
// drained on a now-free thread read `thread_free` and was sent a second time.
|
|
574
|
+
if (queueReconcileEnabled() || steerOn) {
|
|
575
|
+
const held = readQueue(provider, threadId, deps.now()).find(t => t.clientTurnId === clientTurnId)
|
|
576
|
+
if (held) {
|
|
577
|
+
const heldCopy = queueReconcileEnabled() ? queuedTurnReasonCopy(held.reason) : null
|
|
578
|
+
return res.status(409).json({
|
|
579
|
+
error: 'duplicate_turn',
|
|
580
|
+
state: held.status,
|
|
581
|
+
...(held.deliveryState ? { deliveryState: held.deliveryState } : {}),
|
|
582
|
+
...(heldCopy ? { reasonCopy: heldCopy } : {}),
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
289
587
|
// THE GATE DECIDES WHETHER PARKING IS EVEN HONEST. A structural refusal can never
|
|
290
588
|
// clear, and telling someone their turn is queued when it can never run is worse
|
|
291
589
|
// than refusing it. An attachable thread is not queued either -- it is sent now,
|
|
@@ -305,12 +603,22 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
305
603
|
|
|
306
604
|
const now = deps.now()
|
|
307
605
|
const queue = readQueue(provider, threadId, now)
|
|
308
|
-
const
|
|
606
|
+
const turn: QueuedThreadTurn = {
|
|
309
607
|
clientTurnId, cosSessionId, provider, threadId, prompt,
|
|
310
608
|
queuedAt: now, status: 'waiting', attempts: 0,
|
|
311
609
|
// 6.62.0 /qa (Q3): the acknowledgement travels with the row to its drain.
|
|
312
610
|
...(continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]) ? { continueNoteAck: true as const } : {}),
|
|
313
|
-
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let steerFallback: string | null = null
|
|
614
|
+
if (wantSteer) {
|
|
615
|
+
const availability = steerAvailabilityFor(deps, provider, threadId, gate.reason)
|
|
616
|
+
if (availability.available) return steerTurn(req, res, deps, turn, queue, gate.reason, fallbackNone)
|
|
617
|
+
if (fallbackNone) return res.status(409).json({ error: 'steer_unavailable', reason: availability.reason })
|
|
618
|
+
steerFallback = availability.reason
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const admitted = admitToQueue(queue, turn)
|
|
314
622
|
if (!admitted.ok) return res.status(409).json({ error: admitted.reason })
|
|
315
623
|
|
|
316
624
|
writeQueue(provider, threadId, admitted.queue)
|
|
@@ -319,9 +627,62 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
319
627
|
clientTurnId,
|
|
320
628
|
position: queuePosition(admitted.queue, clientTurnId),
|
|
321
629
|
waitingOn: gate.reason,
|
|
630
|
+
...(steerFallback !== null ? { steerFallback } : {}),
|
|
322
631
|
})
|
|
323
632
|
})
|
|
324
633
|
|
|
634
|
+
// POST — Send again (6.78.0, COS_QUEUE_RESEND). A refused or expired row's words, copied
|
|
635
|
+
// ON THE SERVER under a new key. Once only, across every surface: the original records the
|
|
636
|
+
// copy it became (`resentAs`), and a repeat from anywhere answers that row.
|
|
637
|
+
//
|
|
638
|
+
// ONE SYNCHRONOUS READ-MODIFY-WRITE. There is no `await` between reading `resentAs` and
|
|
639
|
+
// writing it, so two taps (lens and phone) in the same tick cannot both pass the check.
|
|
640
|
+
// The new row is admitted even when the thread is free: the drain sends it through the
|
|
641
|
+
// ordinary front door, with every gate.
|
|
642
|
+
router.post('/agent-sessions/:provider/:threadId/queued-turns/:clientTurnId/resend', (req: Request, res: Response, next) => {
|
|
643
|
+
// Off: not a route at all, exactly as on 6.77 (a client reads the 404 as "update the server").
|
|
644
|
+
if (!queueResendEnabled()) return next()
|
|
645
|
+
res.set('Cache-Control', 'private, no-store')
|
|
646
|
+
const provider = String(req.params.provider ?? '')
|
|
647
|
+
const threadId = String(req.params.threadId ?? '')
|
|
648
|
+
const originalId = String(req.params.clientTurnId ?? '')
|
|
649
|
+
const body = (req.body ?? {}) as Record<string, unknown>
|
|
650
|
+
const newId = typeof body.clientTurnId === 'string' ? body.clientTurnId : ''
|
|
651
|
+
const cosSessionId = typeof body.cosSessionId === 'string' ? body.cosSessionId : ''
|
|
652
|
+
if (!CLIENT_TURN_ID_RE.test(newId) || !COS_SESSION_ID_RE.test(cosSessionId)) {
|
|
653
|
+
return res.status(400).json({ error: 'invalid_request' })
|
|
654
|
+
}
|
|
655
|
+
const now = deps.now()
|
|
656
|
+
const queue = readQueue(provider, threadId, now)
|
|
657
|
+
const original = queue.find(t => t.clientTurnId === originalId)
|
|
658
|
+
// Pruned (or never here): nothing to copy, and saying "sent again" would be invented.
|
|
659
|
+
if (!original) return res.status(404).json({ error: 'unknown_turn' })
|
|
660
|
+
if (typeof original.resentAs === 'string' && original.resentAs.length > 0) {
|
|
661
|
+
const again = queue.find(t => t.clientTurnId === original.resentAs)
|
|
662
|
+
return res.status(200).json(again
|
|
663
|
+
? { ...publicRow(again, queuePosition(queue, again.clientTurnId)), replayed: true }
|
|
664
|
+
: { clientTurnId: original.resentAs, resendOf: original.clientTurnId, replayed: true })
|
|
665
|
+
}
|
|
666
|
+
if (original.status !== 'refused' && original.status !== 'expired') return res.status(409).json({ error: 'not_resendable' })
|
|
667
|
+
// "May not have been sent": a second copy is possible, so the person has to have been told.
|
|
668
|
+
if (original.deliveryState === 'unknown' && body.acknowledge !== 'may_duplicate') {
|
|
669
|
+
return res.status(409).json({ error: 'acknowledge_required' })
|
|
670
|
+
}
|
|
671
|
+
const copy: QueuedThreadTurn = {
|
|
672
|
+
clientTurnId: newId, cosSessionId, provider, threadId,
|
|
673
|
+
prompt: original.prompt,
|
|
674
|
+
queuedAt: now, status: 'waiting', attempts: 0,
|
|
675
|
+
...(original.continueNoteAck === true ? { continueNoteAck: true as const } : {}),
|
|
676
|
+
resendOf: original.clientTurnId,
|
|
677
|
+
}
|
|
678
|
+
const admitted = admitToQueue(queue, copy)
|
|
679
|
+
if (!admitted.ok) return res.status(409).json({ error: admitted.reason })
|
|
680
|
+
original.resentAs = newId
|
|
681
|
+
writeQueue(provider, threadId, admitted.queue)
|
|
682
|
+
console.log(`[thread-turn-queue] resend provider=${provider} from=${original.clientTurnId} as=${newId} unknown=${original.deliveryState === 'unknown'}`)
|
|
683
|
+
return res.status(200).json(publicRow(copy, queuePosition(admitted.queue, newId)))
|
|
684
|
+
})
|
|
685
|
+
|
|
325
686
|
// GET — what is waiting, for the pending row on the lens.
|
|
326
687
|
router.get('/agent-sessions/:provider/:threadId/queued-turns', async (req: Request, res: Response) => {
|
|
327
688
|
res.set('Cache-Control', 'private, no-store')
|
|
@@ -37,6 +37,9 @@ transcribeRouter.get('/transcribe/capabilities', (_req, res) => {
|
|
|
37
37
|
localOnly: true,
|
|
38
38
|
dictionary: 'cos',
|
|
39
39
|
benchmark: 1,
|
|
40
|
+
// 6.77.0: post-save transcript cleanup. suggestions = /api/transcript-cleanup/suggestions,
|
|
41
|
+
// screenContext = POST /api/meeting/:sessionId/screen-context.
|
|
42
|
+
transcriptCleanup: { protocol: 1, suggestions: 1, screenContext: 1 },
|
|
40
43
|
})
|
|
41
44
|
})
|
|
42
45
|
|