@gotcos/glasses-server 6.77.0 → 6.78.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +40 -0
- package/CHANGELOG.md +24 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -1
- package/server/index.ts +96 -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/env-switch.ts +17 -0
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/native-head.ts +65 -6
- 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/routes/agent-session-bindings.ts +169 -11
- package/server/routes/health.ts +26 -1
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +27 -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/scripts/live-cues-v2-notes-mutation-gate.mjs +0 -170
- package/server/scripts/transcript-cleanup-mutation-gate.mjs +0 -219
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
// event, and positive evidence of an OPEN turn outranks it. Same reasoning as the
|
|
64
64
|
// session-trail handoff, one layer down.
|
|
65
65
|
|
|
66
|
+
import { readSwitch } from './env-switch.js'
|
|
67
|
+
|
|
66
68
|
/** Terminal-ish states a queued turn can reach. `waiting` is the only live one. */
|
|
67
69
|
export type QueuedTurnStatus =
|
|
68
70
|
| 'waiting'
|
|
@@ -93,6 +95,107 @@ export interface QueuedThreadTurn {
|
|
|
93
95
|
* only for a client that said it would show it. Absent: the 6.61 Ask posture.
|
|
94
96
|
*/
|
|
95
97
|
continueNoteAck?: true
|
|
98
|
+
// ---------------------------------------------------------------- 6.78.0 (P3)
|
|
99
|
+
// Every field below is OPTIONAL and written only while its flag is on, so with
|
|
100
|
+
// COS_QUEUE_RECONCILE, COS_QUEUE_RESEND and COS_SESSION_STEER all off a queue file is
|
|
101
|
+
// byte-for-byte what 6.77.0 writes. A 6.77 server reading a 6.78 file ignores them.
|
|
102
|
+
/**
|
|
103
|
+
* Stamped (`1`) when this row entered `delivering` under the attempt journal (6.78.0).
|
|
104
|
+
* Its ABSENCE on a `delivering` row is the whole point: a row with no stamp was written by
|
|
105
|
+
* a build that kept no attempt record, so "no attempt on file" proves nothing about it and
|
|
106
|
+
* the reconcile reads it as unknown, never as unsent.
|
|
107
|
+
*/
|
|
108
|
+
journal?: 1
|
|
109
|
+
/** The server boot that moved this row to `delivering`. A different boot means it was cut off. */
|
|
110
|
+
deliveringBootId?: string
|
|
111
|
+
/** `unknown`: COS cannot establish whether the words reached the session. Never resent. */
|
|
112
|
+
deliveryState?: 'unknown'
|
|
113
|
+
/** `steer`: sent into the running turn through the session's peer inbox. Absent: `queue`. */
|
|
114
|
+
mode?: 'steer'
|
|
115
|
+
/** Send again: the row this one was copied from. */
|
|
116
|
+
resendOf?: string
|
|
117
|
+
/** Send again: the row that was copied from this one. Set once, and only once. */
|
|
118
|
+
resentAs?: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ------------------------------------------------------------------- 6.78.0 flags
|
|
122
|
+
//
|
|
123
|
+
// Read at CALL time through the one 6.78.0 switch reader (lib/env-switch.ts): `1`, `true`
|
|
124
|
+
// or `on` is on, `0`, `false` or `off` is off, anything else is the default (P7 S3: an exact
|
|
125
|
+
// `0`/`1` reader here made `COS_QUEUE_RECONCILE=false` read ON). One reader per flag, so
|
|
126
|
+
// every caller (routes, registry, drain, health) decides with the same answer in the same tick.
|
|
127
|
+
function triStateFlag(env: NodeJS.ProcessEnv, name: string, fallback: boolean): boolean {
|
|
128
|
+
return readSwitch(env[name], fallback)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `COS_QUEUE_RECONCILE`: the attempt journal, the restart reconcile, receipts, unknown-row retention and reason copy. Default ON. */
|
|
132
|
+
export function queueReconcileEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
133
|
+
return triStateFlag(env, 'COS_QUEUE_RECONCILE', true)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `COS_PIN_BOOT_REAP`: release a pin a dead boot left, once its orphan child must be gone (21 min). Default ON. */
|
|
137
|
+
export function pinBootReapEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
138
|
+
return triStateFlag(env, 'COS_PIN_BOOT_REAP', true)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `COS_QUEUE_RESEND`: Send again on a refused or expired row. Default ON. */
|
|
142
|
+
export function queueResendEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
143
|
+
return triStateFlag(env, 'COS_QUEUE_RESEND', true)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `COS_SESSION_STEER`: steer a busy Claude session through its peer inbox. Default OFF. */
|
|
147
|
+
export function sessionSteerEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
148
|
+
return triStateFlag(env, 'COS_SESSION_STEER', false)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reasons whose outcome COS cannot establish. Such a row is never resent and is kept for
|
|
153
|
+
* UNKNOWN_DELIVERY_RETENTION_MS rather than 30 minutes, because the phone has to be able to
|
|
154
|
+
* read "may not have been sent" for as long as the person might come back to it.
|
|
155
|
+
*/
|
|
156
|
+
export const UNKNOWN_DELIVERY_REASONS: ReadonlySet<string> = new Set([
|
|
157
|
+
'delivery_unknown_after_restart',
|
|
158
|
+
'steer_unverified',
|
|
159
|
+
'delivery_ambiguous',
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
/** How long a settled row with an unknown outcome stays readable. */
|
|
163
|
+
export const UNKNOWN_DELIVERY_RETENTION_MS = 6 * 60 * 60 * 1000
|
|
164
|
+
|
|
165
|
+
/** The lens body is 62 columns; every reason line fits one. */
|
|
166
|
+
export const QUEUED_TURN_REASON_COPY_MAX = 62
|
|
167
|
+
|
|
168
|
+
/** One line per reason, server-authored. Keys are the reasons a row can carry. */
|
|
169
|
+
const QUEUED_TURN_REASON_COPY: Readonly<Record<string, string>> = {
|
|
170
|
+
delivery_unknown_after_restart: 'May not have been sent. Read it, then say it again.',
|
|
171
|
+
steer_unverified: 'May not have been sent. Read it, then say it again.',
|
|
172
|
+
delivery_ambiguous: 'May not have been sent. Read it, then say it again.',
|
|
173
|
+
steered: 'Sent into the running turn.',
|
|
174
|
+
reconciled_after_restart: 'Sent before the server restarted.',
|
|
175
|
+
requeued_after_restart: 'Not sent before the restart. Waiting to send.',
|
|
176
|
+
queued_turn_expired: 'Not sent: the thread stayed busy too long.',
|
|
177
|
+
delivery_attempts_exhausted: 'Not sent: the session refused it five times.',
|
|
178
|
+
turn_cancelled: 'Cancelled before it was sent.',
|
|
179
|
+
steer_not_sent: 'Not sent: the session could not take it mid-turn.',
|
|
180
|
+
native_thread_working: 'Waiting for the current turn to end.',
|
|
181
|
+
live_desktop_process: 'Waiting for the current turn to end.',
|
|
182
|
+
native_target_busy: 'Waiting: another COS turn holds this thread.',
|
|
183
|
+
native_turn_in_progress: 'Waiting: a COS turn is running on this thread.',
|
|
184
|
+
native_target_fenced: 'Waiting: an earlier turn needs a check on your Mac.',
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The reason line for a queue row, or null when there is nothing to say (no reason, or one
|
|
189
|
+
* this build has no words for: a raw code is never shown as if it were a sentence).
|
|
190
|
+
*/
|
|
191
|
+
export function queuedTurnReasonCopy(reason: string | null | undefined): string | null {
|
|
192
|
+
if (typeof reason !== 'string' || reason.length === 0) return null
|
|
193
|
+
return Object.prototype.hasOwnProperty.call(QUEUED_TURN_REASON_COPY, reason) ? QUEUED_TURN_REASON_COPY[reason] : null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Every reason with copy, for the column test. */
|
|
197
|
+
export function queuedTurnReasonCopyKeys(): string[] {
|
|
198
|
+
return Object.keys(QUEUED_TURN_REASON_COPY)
|
|
96
199
|
}
|
|
97
200
|
|
|
98
201
|
/**
|
|
@@ -286,10 +389,14 @@ export function pruneQueue(
|
|
|
286
389
|
now: number,
|
|
287
390
|
settledRetentionMs: number = 30 * 60 * 1000,
|
|
288
391
|
): QueuedThreadTurn[] {
|
|
392
|
+
// 6.78.0: a row whose outcome is unknown stays for six hours (COS_QUEUE_RECONCILE). Read
|
|
393
|
+
// once per prune, so one pass cannot keep some rows by the new rule and drop others by the old.
|
|
394
|
+
const keepUnknown = queueReconcileEnabled()
|
|
289
395
|
return queue.filter(t => {
|
|
290
396
|
if (t.status === 'waiting' || t.status === 'delivering') return true
|
|
291
397
|
const settled = typeof t.settledAt === 'number' ? t.settledAt : t.queuedAt
|
|
292
|
-
|
|
398
|
+
const unknown = keepUnknown && (t.deliveryState === 'unknown' || (typeof t.reason === 'string' && UNKNOWN_DELIVERY_REASONS.has(t.reason)))
|
|
399
|
+
return now - settled < (unknown ? Math.max(settledRetentionMs, UNKNOWN_DELIVERY_RETENTION_MS) : settledRetentionMs)
|
|
293
400
|
})
|
|
294
401
|
}
|
|
295
402
|
|
|
@@ -297,3 +404,46 @@ export function pruneQueue(
|
|
|
297
404
|
export function queuePosition(queue: readonly QueuedThreadTurn[], clientTurnId: string): number {
|
|
298
405
|
return queue.filter(t => t.status === 'waiting').findIndex(t => t.clientTurnId === clientTurnId)
|
|
299
406
|
}
|
|
407
|
+
|
|
408
|
+
// ------------------------------------------------------------- 6.78.0 session steer
|
|
409
|
+
|
|
410
|
+
/** Why a busy session cannot take a steer right now (the wire enum, plan v3.1 section 5.3). */
|
|
411
|
+
export type SessionSteerUnavailable = 'no_outside_channel' | 'engine_limit' | 'permission_pending'
|
|
412
|
+
|
|
413
|
+
export type SessionSteerAvailability =
|
|
414
|
+
| { available: true; mode: 'peer_inbox' }
|
|
415
|
+
| { available: false; reason: SessionSteerUnavailable }
|
|
416
|
+
|
|
417
|
+
/** Occupancy reasons that mean "a session outside COS is running this thread". Only those can be steered. */
|
|
418
|
+
const STEERABLE_GATE_REASONS: ReadonlySet<string> = new Set(['native_thread_working', 'live_desktop_process'])
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Can a turn go INTO the running turn of this thread now (6.78.0, COS_SESSION_STEER)?
|
|
422
|
+
*
|
|
423
|
+
* PURE, one rule for the attach refusal's `steer` field and the queued-turns POST, so the
|
|
424
|
+
* row the lens offers and the answer to the tap agree.
|
|
425
|
+
* cursor engine_limit: the Stop hook is its only door
|
|
426
|
+
* codex no_outside_channel: `turn/steer` lives in an app-server COS does not hold
|
|
427
|
+
* claude, live hop off no_outside_channel (`COS_CONTINUE_LIVE=0`)
|
|
428
|
+
* claude, not a desk run no_outside_channel: COS's own binding or turn, a fence, or a
|
|
429
|
+
* thread nobody could measure; nothing outside COS to fold into
|
|
430
|
+
* claude, no peer inbox no_outside_channel
|
|
431
|
+
* claude, held on a prompt permission_pending: a session waiting on a dialog reaches no
|
|
432
|
+
* tool boundary, and the steer verify window (4 s) is far shorter
|
|
433
|
+
* than the hook's wait (630 s); it is queued for the turn's end
|
|
434
|
+
*/
|
|
435
|
+
export function sessionSteerAvailability(input: {
|
|
436
|
+
provider: string
|
|
437
|
+
gateReason: string | null
|
|
438
|
+
continueLive: boolean
|
|
439
|
+
peerInbox: boolean
|
|
440
|
+
waitingOnPrompt: boolean
|
|
441
|
+
}): SessionSteerAvailability {
|
|
442
|
+
if (input.provider === 'cursor') return { available: false, reason: 'engine_limit' }
|
|
443
|
+
if (input.provider !== 'claude') return { available: false, reason: 'no_outside_channel' }
|
|
444
|
+
if (!input.continueLive) return { available: false, reason: 'no_outside_channel' }
|
|
445
|
+
if (typeof input.gateReason !== 'string' || !STEERABLE_GATE_REASONS.has(input.gateReason)) return { available: false, reason: 'no_outside_channel' }
|
|
446
|
+
if (!input.peerInbox) return { available: false, reason: 'no_outside_channel' }
|
|
447
|
+
if (input.waitingOnPrompt) return { available: false, reason: 'permission_pending' }
|
|
448
|
+
return { available: true, mode: 'peer_inbox' }
|
|
449
|
+
}
|
|
@@ -122,7 +122,10 @@ import {
|
|
|
122
122
|
type NativeBinding,
|
|
123
123
|
} from '../lib/agent-session-binding-store.js'
|
|
124
124
|
import { boundedContinueNote, CONTINUE_NOTE_HEADER, continueNoteAcknowledged, fallbackContinueNote } from '../lib/continue-plan.js'
|
|
125
|
-
import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
|
|
125
|
+
import type { PinExtras, RegistryCheck, RegistryRejection, RegistryResult, UnpinExtras } from '../lib/agent-session-binding-registry.js'
|
|
126
|
+
import { ORPHAN_CHILD_RUNNING_COPY } from '../lib/agent-session-binding-registry.js'
|
|
127
|
+
import { pinBootReapEnabled, queueableRefusal, queueReconcileEnabled, sessionSteerEnabled, type SessionSteerAvailability } from '../lib/thread-turn-queue.js'
|
|
128
|
+
import type { TurnReceipt } from '../lib/queue-turn-evidence.js'
|
|
126
129
|
import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
|
|
127
130
|
import { isValidNativeThreadId } from '../lib/native-thread-id.js'
|
|
128
131
|
import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
|
|
@@ -212,9 +215,24 @@ export interface BindingRegistry {
|
|
|
212
215
|
claim: { bindingId: string; epoch: number; targetKey: string },
|
|
213
216
|
now: number,
|
|
214
217
|
) => RegistryCheck
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
/**
|
|
219
|
+
* A rejection here is FATAL to the turn, per the registry's own caller contract.
|
|
220
|
+
* 6.78.0: `extras` carries the pin's client turn and opens its attempt in the same commit;
|
|
221
|
+
* passed only while COS_PIN_BOOT_REAP or COS_QUEUE_RECONCILE is on.
|
|
222
|
+
*/
|
|
223
|
+
pin?: (bindingId: string, jobId: string, now: number, extras?: PinExtras) => RegistryResult
|
|
224
|
+
/** 6.78.0: `extras` records how the turn's delivery ended, in the same commit as the release. */
|
|
225
|
+
unpin?: (bindingId: string, jobId: string, now: number, extras?: UnpinExtras) => RegistryResult
|
|
226
|
+
/**
|
|
227
|
+
* 6.78.0: is this target held only by pins a dead boot left, and until when? Optional; the
|
|
228
|
+
* refusal copy says when the thread frees, or keeps its 6.77 words without it.
|
|
229
|
+
*/
|
|
230
|
+
orphanPinHold?: (provider: string, nativeThreadId: string, now: number) => { releasesAt: number; childRunning?: true } | null
|
|
231
|
+
/**
|
|
232
|
+
* 6.78.0 (P7 S6): record the resume child a pinned turn spawned (pid and measured start),
|
|
233
|
+
* so a crash's orphan pin waits for that process. Optional; a failure changes nothing.
|
|
234
|
+
*/
|
|
235
|
+
notePinChild?: (bindingId: string, jobId: string, child: { pid: number; startMs: number }) => RegistryResult
|
|
218
236
|
}
|
|
219
237
|
|
|
220
238
|
/** What attach resolves server-side because plan 4.2 forbids the client sending it. */
|
|
@@ -411,6 +429,21 @@ export interface AgentSessionBindingsDeps {
|
|
|
411
429
|
/** Overrides the env gate. Tests only; production reads threadAttachEnabled(). */
|
|
412
430
|
attachEnabled?: boolean
|
|
413
431
|
|
|
432
|
+
/**
|
|
433
|
+
* 6.78.0 (COS_QUEUE_RECONCILE): was this client turn already sent, or may it have been?
|
|
434
|
+
* `makeTurnReceiptLookup`: the queue's SETTLED rows (never waiting or delivering), the
|
|
435
|
+
* attempt journal, and the transcript for an unknown. Consulted after the ledger replay
|
|
436
|
+
* and before the claim; a hit is replayed, never sent again. Absent: 6.77 behaviour.
|
|
437
|
+
*/
|
|
438
|
+
turnReceipt?: (provider: string, nativeThreadId: string, clientTurnId: string, prompt: string) => TurnReceipt | null
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 6.78.0 (COS_SESSION_STEER): could a turn go INTO this thread's running turn? Added to an
|
|
442
|
+
* attach refusal as `steer`. The router also checks the flag itself, so with it off the
|
|
443
|
+
* field is absent whatever is wired.
|
|
444
|
+
*/
|
|
445
|
+
sessionSteer?: (provider: string, nativeThreadId: string, gateReason: string) => SessionSteerAvailability | null
|
|
446
|
+
|
|
414
447
|
ownership?: {
|
|
415
448
|
record: (pid: number, startMs: number) => string
|
|
416
449
|
release: (pid: number) => boolean
|
|
@@ -1607,6 +1640,17 @@ export const CONTINUE_NOTE_BUDGET_MS = 1_500
|
|
|
1607
1640
|
*/
|
|
1608
1641
|
export const TURN_QUEUED_COPY = 'Queued to the original thread. It keeps running if you put your phone away.'
|
|
1609
1642
|
|
|
1643
|
+
/**
|
|
1644
|
+
* 6.78.0: the refusal copy while a pin a dead boot left holds the thread. The time is the
|
|
1645
|
+
* Mac's local clock, rounded UP to the minute, so the thread is never shown free early.
|
|
1646
|
+
* 62 columns or fewer, no em dash, no arrow.
|
|
1647
|
+
*/
|
|
1648
|
+
export function orphanPinWaitCopy(releasesAt: number): string {
|
|
1649
|
+
const at = new Date(Math.ceil(releasesAt / 60_000) * 60_000)
|
|
1650
|
+
const hour = at.getHours() % 12 || 12
|
|
1651
|
+
return `An earlier run may still be running · frees ${hour}:${String(at.getMinutes()).padStart(2, '0')}`
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1610
1654
|
/** Status of a turn that is admitted and still running. */
|
|
1611
1655
|
export const TURN_PENDING_COPY = 'Still working on your Mac.'
|
|
1612
1656
|
|
|
@@ -1878,14 +1922,39 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1878
1922
|
const runOccupancy = (provider: string, threadId: string): AttachabilityBody =>
|
|
1879
1923
|
projectAttachability(routeOccupancy(provider, threadId))
|
|
1880
1924
|
|
|
1881
|
-
const refuseAttach = (res: Response, reason: WriteRefusal): void => {
|
|
1925
|
+
const refuseAttach = (res: Response, reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
|
|
1882
1926
|
res.status(refusalStatus(reason)).json({
|
|
1883
1927
|
attached: false,
|
|
1884
1928
|
reason,
|
|
1885
1929
|
reasonCopy: writeReasonCopy(reason),
|
|
1930
|
+
...extra,
|
|
1886
1931
|
})
|
|
1887
1932
|
}
|
|
1888
1933
|
|
|
1934
|
+
/**
|
|
1935
|
+
* 6.78.0: what an attach refusal on THIS thread adds. `steer` (COS_SESSION_STEER) on a
|
|
1936
|
+
* refusal a turn may queue behind, from the shared rule. And when the holder is a pin a
|
|
1937
|
+
* dead boot left (COS_PIN_BOOT_REAP), the copy says when it frees instead of telling the
|
|
1938
|
+
* person to detach a chat that no longer exists. Empty with the flags off.
|
|
1939
|
+
*/
|
|
1940
|
+
const attachRefusalExtras = (provider: string, threadId: string, reason: WriteRefusal, now: number | null): Record<string, unknown> => {
|
|
1941
|
+
const extra: Record<string, unknown> = {}
|
|
1942
|
+
if (reason === 'native_target_busy' && now !== null && typeof deps.bindings?.orphanPinHold === 'function') {
|
|
1943
|
+
let hold: { releasesAt: number; childRunning?: true } | null = null
|
|
1944
|
+
try { hold = deps.bindings.orphanPinHold(provider, threadId, now) } catch { hold = null }
|
|
1945
|
+
if (hold && Number.isFinite(hold.releasesAt)) extra.reasonCopy = orphanPinWaitCopy(hold.releasesAt)
|
|
1946
|
+
// P7 S6: past the window, the dead boot's child itself still runs: no time to promise
|
|
1947
|
+
// (such a hold carries no finite releasesAt, so the line above never wrote).
|
|
1948
|
+
if (hold?.childRunning === true) extra.reasonCopy = ORPHAN_CHILD_RUNNING_COPY
|
|
1949
|
+
}
|
|
1950
|
+
if (sessionSteerEnabled() && typeof deps.sessionSteer === 'function' && queueableRefusal(reason)) {
|
|
1951
|
+
let steer: SessionSteerAvailability | null = null
|
|
1952
|
+
try { steer = deps.sessionSteer(provider, threadId, reason) } catch { steer = null }
|
|
1953
|
+
if (steer) extra.steer = steer
|
|
1954
|
+
}
|
|
1955
|
+
return extra
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1889
1958
|
/** Read a finite clock, or null. A route that cannot tell the time refuses. */
|
|
1890
1959
|
const readNow = (): number | null => {
|
|
1891
1960
|
try {
|
|
@@ -2335,7 +2404,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2335
2404
|
// or any doubt about whether it has one — is Fork-only, and attach is the
|
|
2336
2405
|
// gate that makes that true.
|
|
2337
2406
|
const verdict = runOccupancy(providerParam, threadIdParam)
|
|
2338
|
-
if (!verdict.attachable)
|
|
2407
|
+
if (!verdict.attachable) {
|
|
2408
|
+
const refused = verdict.reason ?? 'probe_failed'
|
|
2409
|
+
return refuseAttach(res, refused, attachRefusalExtras(providerParam, threadIdParam, refused, now))
|
|
2410
|
+
}
|
|
2339
2411
|
|
|
2340
2412
|
// `runOccupancy` already proved both, provider first. These narrow the types
|
|
2341
2413
|
// rather than re-deciding the rule — a second copy of the id rule is how the
|
|
@@ -2352,7 +2424,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2352
2424
|
// a per-binding fence.
|
|
2353
2425
|
if (fenced !== null) {
|
|
2354
2426
|
console.warn(`[agent-session-bindings] fence hit route=attach provider=${providerParam} target=${opaqueRevision(key)}`)
|
|
2355
|
-
return refuseAttach(res, fenced)
|
|
2427
|
+
return refuseAttach(res, fenced, attachRefusalExtras(providerParam, threadIdParam, fenced, now))
|
|
2356
2428
|
}
|
|
2357
2429
|
|
|
2358
2430
|
const resolve = deps.resolveTarget
|
|
@@ -2391,7 +2463,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2391
2463
|
ttlMs: attachTtlMs,
|
|
2392
2464
|
now,
|
|
2393
2465
|
})
|
|
2394
|
-
if (!created?.binding)
|
|
2466
|
+
if (!created?.binding) {
|
|
2467
|
+
const refused = registryRefusal(created?.reason)
|
|
2468
|
+
return refuseAttach(res, refused, attachRefusalExtras(providerParam, threadIdParam, refused, now))
|
|
2469
|
+
}
|
|
2395
2470
|
|
|
2396
2471
|
const activated = deps.bindings.activate!(bindingId, now)
|
|
2397
2472
|
if (!activated?.binding) {
|
|
@@ -2775,6 +2850,17 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2775
2850
|
* alone is backgrounded once the last gate passes.
|
|
2776
2851
|
*/
|
|
2777
2852
|
let queued = false
|
|
2853
|
+
/**
|
|
2854
|
+
* 6.78.0 (COS_QUEUE_RECONCILE): the attempt journal. Opened with the pin (same commit),
|
|
2855
|
+
* settled with the unpin. Where the turn MAY have gone: `proven` once a hop showed it
|
|
2856
|
+
* arrived; `liveMay` / `spawnMay` once a hop that could have written was entered and did
|
|
2857
|
+
* not prove nothing reached. `not_sent` only when every hop entered said nothing did.
|
|
2858
|
+
*/
|
|
2859
|
+
let attemptOpened = false
|
|
2860
|
+
let proven = false
|
|
2861
|
+
let liveMay = false
|
|
2862
|
+
let spawnMay = false
|
|
2863
|
+
const attemptExit = (): 'sent' | 'not_sent' | 'unknown' => (proven ? 'sent' : liveMay || spawnMay ? 'unknown' : 'not_sent')
|
|
2778
2864
|
|
|
2779
2865
|
const respond = (status: number, payload: Record<string, unknown>): void => {
|
|
2780
2866
|
if (!res.headersSent) {
|
|
@@ -2919,6 +3005,46 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2919
3005
|
return
|
|
2920
3006
|
}
|
|
2921
3007
|
|
|
3008
|
+
// 6.78.0: RECEIPTS, before any gate, claim, pin or hop. The ledger above is dropped
|
|
3009
|
+
// with its binding and records nothing for a pre-202 `live_unverified`; a crash after
|
|
3010
|
+
// the pin leaves no record at all. The receipt covers those (target-scoped attempt
|
|
3011
|
+
// journal, the queue's settled rows), so a phone that retries a key after a reconnect
|
|
3012
|
+
// or a restart can never put the same words in twice. A hit is replayed, never sent:
|
|
3013
|
+
// `delivered` as a completed turn, `unknown` as the ambiguous outcome every client
|
|
3014
|
+
// already treats as "do not send again".
|
|
3015
|
+
if (queueReconcileEnabled() && typeof deps.turnReceipt === 'function') {
|
|
3016
|
+
const owner = deps.bindings.get?.(bindingId) ?? null
|
|
3017
|
+
if (owner && isBindableProvider(owner.provider) && isValidNativeThreadId(owner.nativeThreadId)) {
|
|
3018
|
+
let receipt: TurnReceipt | null
|
|
3019
|
+
try {
|
|
3020
|
+
receipt = deps.turnReceipt(owner.provider, owner.nativeThreadId, clientTurnId, prompt)
|
|
3021
|
+
} catch (error) {
|
|
3022
|
+
// A receipt we could not read is not "never sent". Fail closed: unknown.
|
|
3023
|
+
console.error(`[agent-session-bindings] turn receipt read failed: ${error instanceof Error ? error.message : error}`)
|
|
3024
|
+
receipt = { kind: 'unknown', source: 'attempt' }
|
|
3025
|
+
}
|
|
3026
|
+
if (receipt !== null) {
|
|
3027
|
+
const delivered = receipt.kind === 'delivered'
|
|
3028
|
+
if (!res.headersSent) {
|
|
3029
|
+
res.status(delivered ? 200 : refusalStatus('delivery_ambiguous')).json({
|
|
3030
|
+
turnId,
|
|
3031
|
+
outcome: delivered ? 'completed' : 'ambiguous',
|
|
3032
|
+
deliveryState: delivered ? 'delivered' : 'unknown',
|
|
3033
|
+
retryable: false,
|
|
3034
|
+
changed: false,
|
|
3035
|
+
revision: null,
|
|
3036
|
+
reason: delivered ? null : 'delivery_ambiguous',
|
|
3037
|
+
reasonCopy: delivered ? TURN_SENT_COPY : writeReasonCopy('delivery_ambiguous'),
|
|
3038
|
+
replayed: true,
|
|
3039
|
+
receipt: receipt.source,
|
|
3040
|
+
})
|
|
3041
|
+
console.log(turnTiming(`receipt/${receipt.kind}/${receipt.source}`))
|
|
3042
|
+
}
|
|
3043
|
+
return
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
|
|
2922
3048
|
// The client-queued-prompt gate, used rather than reimplemented: it is the
|
|
2923
3049
|
// one place that orders state before epoch before target, and a second
|
|
2924
3050
|
// opinion here is how the store's own header says the two drifted apart.
|
|
@@ -3004,10 +3130,19 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3004
3130
|
|
|
3005
3131
|
// A failed pin is FATAL, per the registry's own caller contract: an unpinned
|
|
3006
3132
|
// binding can expire or be detached mid-turn, which defeats the lease.
|
|
3007
|
-
|
|
3133
|
+
// 6.78.0: the pin names its client turn (COS_PIN_BOOT_REAP) and opens the attempt
|
|
3134
|
+
// (COS_QUEUE_RECONCILE) in its own commit: on disk before any frame or spawn below.
|
|
3135
|
+
const pinExtras: PinExtras = {
|
|
3136
|
+
...(pinBootReapEnabled() ? { pinMeta: { clientTurnId } } : {}),
|
|
3137
|
+
...(queueReconcileEnabled() ? { attempt: { clientTurnId, mode: 'turn' as const } } : {}),
|
|
3138
|
+
}
|
|
3139
|
+
const pinned = Object.keys(pinExtras).length > 0
|
|
3140
|
+
? deps.bindings.pin!(bindingId, turnId, now, pinExtras)
|
|
3141
|
+
: deps.bindings.pin!(bindingId, turnId, now)
|
|
3008
3142
|
stage('pin')
|
|
3009
3143
|
if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
|
|
3010
3144
|
pinnedBindingId = bindingId
|
|
3145
|
+
attemptOpened = pinExtras.attempt !== undefined
|
|
3011
3146
|
|
|
3012
3147
|
// 6.53.0: a cancel that landed during the gate or the head read. Before the live hop,
|
|
3013
3148
|
// which cannot be recalled once it writes into the open session. Nothing was sent;
|
|
@@ -3051,7 +3186,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3051
3186
|
live = null
|
|
3052
3187
|
}
|
|
3053
3188
|
stage(`live(${live?.reason ?? 'threw'})`)
|
|
3189
|
+
// 6.78.0: a throw is a transport bug, not proof the frame never left.
|
|
3190
|
+
if (live === null) liveMay = true
|
|
3054
3191
|
if (live?.ok) {
|
|
3192
|
+
proven = true
|
|
3055
3193
|
// 6.53.3: delivered, so the turn runs in the open session now, and what stops it
|
|
3056
3194
|
// there is the desk halt marker. The turn itself WAS delivered and says so.
|
|
3057
3195
|
const latched = latchedHere()
|
|
@@ -3071,6 +3209,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3071
3209
|
return
|
|
3072
3210
|
}
|
|
3073
3211
|
if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
|
|
3212
|
+
liveMay = true
|
|
3074
3213
|
// 6.53.3: something MAY be in the session and the person asked for it to stop: arm
|
|
3075
3214
|
// the marker as for a landed turn, and settle this turn as cancelled, never as a
|
|
3076
3215
|
// retryable hold that the queue would send again after the cancel's two minutes.
|
|
@@ -3123,7 +3262,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3123
3262
|
live = null
|
|
3124
3263
|
}
|
|
3125
3264
|
stage(`codex-live(${live?.reason ?? 'threw'})`)
|
|
3265
|
+
// 6.78.0: a row MAY be in the Codex app's queue after a throw or an unverified write.
|
|
3266
|
+
if (live === null || live.reason === 'unverified') liveMay = true
|
|
3126
3267
|
if (live?.ok) {
|
|
3268
|
+
proven = true
|
|
3127
3269
|
// 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
|
|
3128
3270
|
const latched = latchedHere()
|
|
3129
3271
|
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt, busy: busyCodexHop })
|
|
@@ -3221,6 +3363,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3221
3363
|
|
|
3222
3364
|
let delivery: Delivery
|
|
3223
3365
|
deliveryAttempted = true
|
|
3366
|
+
spawnMay = true
|
|
3224
3367
|
try {
|
|
3225
3368
|
const result = await deliver({
|
|
3226
3369
|
turnId,
|
|
@@ -3261,6 +3404,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3261
3404
|
// occupancy check.
|
|
3262
3405
|
if (outcome !== 'recorded') return false
|
|
3263
3406
|
recordedPids.push({ pid, startMs })
|
|
3407
|
+
// 6.78.0 (P7 S6): the pin names this child, so after a crash the thread waits for
|
|
3408
|
+
// THIS process to be gone, not only for the window. Never fails the turn.
|
|
3409
|
+
if (pinBootReapEnabled() && typeof deps.bindings.notePinChild === 'function') {
|
|
3410
|
+
try { deps.bindings.notePinChild(bindingId, turnId, { pid, startMs }) } catch { /* the window still bounds the pin */ }
|
|
3411
|
+
}
|
|
3264
3412
|
return true
|
|
3265
3413
|
},
|
|
3266
3414
|
})
|
|
@@ -3310,7 +3458,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3310
3458
|
return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'cancelled' })
|
|
3311
3459
|
}
|
|
3312
3460
|
|
|
3313
|
-
if (delivery.kind === 'aborted')
|
|
3461
|
+
if (delivery.kind === 'aborted') {
|
|
3462
|
+
// The adapter's own proof that the child never opened the session.
|
|
3463
|
+
spawnMay = false
|
|
3464
|
+
return refuseTurn('provider_never_opened')
|
|
3465
|
+
}
|
|
3314
3466
|
|
|
3315
3467
|
if (delivery.kind === 'ambiguous') {
|
|
3316
3468
|
// Plan 4.6: the reservation is HELD, not released. A hand-crafted next
|
|
@@ -3356,6 +3508,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3356
3508
|
after = reread === null ? null : reread.digest
|
|
3357
3509
|
}
|
|
3358
3510
|
if (after !== null) guard.acknowledgeHead(bindingId, after)
|
|
3511
|
+
proven = true
|
|
3359
3512
|
|
|
3360
3513
|
// Phase 4 owns Message persistence. Nothing about the prompt or the reply is
|
|
3361
3514
|
// written, logged or echoed here; the terminal outcome is the whole result.
|
|
@@ -3411,7 +3564,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3411
3564
|
}
|
|
3412
3565
|
if (pinnedBindingId !== null) {
|
|
3413
3566
|
try {
|
|
3414
|
-
|
|
3567
|
+
// 6.78.0: the attempt's exit rides the release, in one commit.
|
|
3568
|
+
if (attemptOpened && clientTurnId !== null) {
|
|
3569
|
+
deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow, { attemptExit: { clientTurnId, exit: attemptExit() } })
|
|
3570
|
+
} else {
|
|
3571
|
+
deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow)
|
|
3572
|
+
}
|
|
3415
3573
|
} catch (error) {
|
|
3416
3574
|
console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
|
|
3417
3575
|
}
|
package/server/routes/health.ts
CHANGED
|
@@ -57,7 +57,8 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
|
57
57
|
// The flag lives beside the endpoints it describes, so it cannot drift from
|
|
58
58
|
// whether they are actually registered.
|
|
59
59
|
import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
|
|
60
|
-
import { durableQueryJobsCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
|
|
60
|
+
import { durableQueryJobsCapability, messagesSteerCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
|
|
61
|
+
import { claudeSteerSupportKnown } from '../lib/claude-cli-support.js'
|
|
61
62
|
import { jobTrailStats } from '../lib/job-trail.js'
|
|
62
63
|
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
63
64
|
import { getMorningBriefScheduler } from '../lib/morning-brief-runtime.js'
|
|
@@ -100,6 +101,7 @@ import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib
|
|
|
100
101
|
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
101
102
|
import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
|
|
102
103
|
import { codexLiveQueueEnabled } from '../lib/codex-live-queue.js'
|
|
104
|
+
import { bootResumeGate } from '../lib/boot-resume.js'
|
|
103
105
|
|
|
104
106
|
/**
|
|
105
107
|
* 6.49.0: which last hop a Continue takes, and how often each fell back.
|
|
@@ -544,9 +546,23 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
544
546
|
enabled: durableJobs.enabled && messagesTrailEnabled(),
|
|
545
547
|
...jobTrailStats(),
|
|
546
548
|
},
|
|
549
|
+
// 6.78.0 (P7 S5): how much boot-resumed work is parked for crash-looping the server. A
|
|
550
|
+
// count only (health is public); the items and their Retry are behind the token at
|
|
551
|
+
// GET /api/maintenance/status.
|
|
552
|
+
crash_loop_parking: crashLoopParkingHealth(),
|
|
547
553
|
})
|
|
548
554
|
})
|
|
549
555
|
|
|
556
|
+
/** Never throws: health must not 500 because of the ledger. */
|
|
557
|
+
function crashLoopParkingHealth(): { enabled: boolean; parked: number } {
|
|
558
|
+
try {
|
|
559
|
+
const status = bootResumeGate().status()
|
|
560
|
+
return { enabled: status.enabled, parked: status.parked.length }
|
|
561
|
+
} catch {
|
|
562
|
+
return { enabled: false, parked: 0 }
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
550
566
|
// Stable app slots backed by Codex + Cursor live catalogs. Authenticated by
|
|
551
567
|
// global /api middleware; ?refresh=1 forces discovery.
|
|
552
568
|
healthRouter.get('/models', async (req, res) => {
|
|
@@ -647,6 +663,9 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
647
663
|
// THIS surface (not /api/health) and falls back to the activity log without it.
|
|
648
664
|
// False whenever jobs themselves are off or COS_MESSAGES_TRAIL=0.
|
|
649
665
|
trail: durableJobs.enabled && messagesTrailEnabled(),
|
|
666
|
+
// 6.78.0 Messages steer: present only with COS_MESSAGES_STEER on and a Claude CLI that
|
|
667
|
+
// takes stream-json input (probed once). Absent, the app shows today's UI.
|
|
668
|
+
...steerCapabilityField(durableJobs.enabled),
|
|
650
669
|
},
|
|
651
670
|
// 6.52.0: the session questions API and its client contract. A client polls
|
|
652
671
|
// `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
|
|
@@ -695,6 +714,12 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
695
714
|
},
|
|
696
715
|
})
|
|
697
716
|
})
|
|
717
|
+
/** 6.78.0: `{ steer }` for /api/models `durableQueryJobs`, or nothing at all. */
|
|
718
|
+
export function steerCapabilityField(jobsEnabled: boolean): { steer?: { protocolVersion: 1; providers: ['claude'] } } {
|
|
719
|
+
const steer = messagesSteerCapability({ jobsEnabled, cliSupported: claudeSteerSupportKnown() })
|
|
720
|
+
return steer ? { steer } : {}
|
|
721
|
+
}
|
|
722
|
+
|
|
698
723
|
// GET /api/cli-session — returns current CLI session ID for cross-device resume
|
|
699
724
|
healthRouter.get('/cli-session', (req, res) => {
|
|
700
725
|
const cosSessionId = req.query.sid as string | undefined
|
|
@@ -17,6 +17,7 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
17
17
|
import { getWhisperHealth } from '../lib/whisper-local.js'
|
|
18
18
|
import { getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './transcribe-stream.js'
|
|
19
19
|
import { getVideoUploadRegistry } from '../lib/video-upload-v2.js'
|
|
20
|
+
import { bootResumeGate, isBootResumeKind, resumeRetriedItem, type ParkedBootResume } from '../lib/boot-resume.js'
|
|
20
21
|
|
|
21
22
|
export const maintenanceRouter = Router()
|
|
22
23
|
|
|
@@ -69,6 +70,15 @@ function operationCredentials(req: Request): MaintenanceOperationCredentials {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** 6.78.0: boot-resumed work parked for crash-looping the server. Never throws. */
|
|
74
|
+
function parkedSnapshot(): ParkedBootResume[] {
|
|
75
|
+
try {
|
|
76
|
+
return bootResumeGate().status().parked
|
|
77
|
+
} catch {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
73
83
|
const jobs = getQueryJobRuntimeHealth()
|
|
74
84
|
const activeTranscriptionSessions = getActiveTranscriptionSessionCount()
|
|
@@ -133,6 +143,9 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
|
133
143
|
// block a binary downgrade, where an old server could erase V2 evidence.
|
|
134
144
|
safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown && !videoUploads.blocksRestart,
|
|
135
145
|
whisper: getWhisperHealth(),
|
|
146
|
+
// 6.78.0, additive (contractVersion unchanged): work a boot no longer resumes because
|
|
147
|
+
// it crashed the server on RESTART_LIMIT boots in a row. Retry with the route below.
|
|
148
|
+
parked: parkedSnapshot(),
|
|
136
149
|
}
|
|
137
150
|
}
|
|
138
151
|
|
|
@@ -155,6 +168,27 @@ maintenanceRouter.get('/maintenance/status', (req, res) => {
|
|
|
155
168
|
res.json(statusSnapshot(operationCredentials(req)))
|
|
156
169
|
})
|
|
157
170
|
|
|
171
|
+
// 6.78.0: the person's Retry of parked boot-resumed work (lib/boot-resume.ts). The API
|
|
172
|
+
// token, like GET /maintenance/status, and not loopback-only, so the phone and the lens can
|
|
173
|
+
// offer it; the /api mutation lease still refuses it during a drain. The count starts over;
|
|
174
|
+
// a meeting finalization is resumed again now, and a query job (id = its clientJobId) runs
|
|
175
|
+
// again when the app Retries it (a parked Ask is never resumable: it never started).
|
|
176
|
+
maintenanceRouter.post('/maintenance/parked/:kind/:id/retry', (req, res) => {
|
|
177
|
+
const { kind, id } = req.params
|
|
178
|
+
if (!isBootResumeKind(kind)) {
|
|
179
|
+
res.status(400).json({ error: 'unknown_parked_kind', retryable: false })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
const gate = bootResumeGate()
|
|
183
|
+
if (!gate.isParked(kind, id)) {
|
|
184
|
+
res.status(404).json({ error: 'not_parked', retryable: false })
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
const entry = gate.retry(kind, id)
|
|
188
|
+
const resumed = resumeRetriedItem(kind, id)
|
|
189
|
+
res.json({ retried: true, resumed, entry })
|
|
190
|
+
})
|
|
191
|
+
|
|
158
192
|
maintenanceRouter.post('/maintenance/drain', (req, res) => {
|
|
159
193
|
if (!requireLoopback(req, res)) return
|
|
160
194
|
try {
|