@gotcos/glasses-server 6.77.0 → 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.
Files changed (33) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +92 -9
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/env-switch.ts +17 -0
  13. package/server/lib/managed-restart.ts +8 -0
  14. package/server/lib/provider-actions.ts +18 -0
  15. package/server/lib/query-job-coordinator.ts +300 -0
  16. package/server/lib/query-job-feature.ts +39 -0
  17. package/server/lib/query-job-resume.ts +269 -0
  18. package/server/lib/query-job-runtime.ts +23 -3
  19. package/server/lib/query-job-store.ts +516 -7
  20. package/server/lib/query-job-types.ts +210 -1
  21. package/server/lib/queue-turn-evidence.ts +213 -0
  22. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  23. package/server/lib/thread-turn-queue-store.ts +13 -4
  24. package/server/lib/thread-turn-queue.ts +151 -1
  25. package/server/routes/agent-session-bindings.ts +169 -11
  26. package/server/routes/health.ts +26 -1
  27. package/server/routes/maintenance.ts +34 -0
  28. package/server/routes/meeting.ts +27 -9
  29. package/server/routes/query-jobs.ts +107 -13
  30. package/server/routes/recovery.ts +4 -0
  31. package/server/routes/thread-turn-queue.ts +371 -10
  32. package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +0 -170
  33. package/server/scripts/transcript-cleanup-mutation-gate.mjs +0 -219
@@ -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
- return {
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
- persist(turn)
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: { ok: boolean; reason?: string; serverRetryable?: boolean }
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 admitted = admitToQueue(queue, {
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')