@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
@@ -0,0 +1,269 @@
1
+ // 6.78.0 restart truth (plan v3.1 P2, the Pi Durable resume planner made real).
2
+ //
3
+ // WHAT A RESTART CUT OFF, TOLD FROM THE JOB'S OWN RECORD. When a restart ends an Ask (a crash
4
+ // then boot, or a graceful shutdown), the store builds one `interruption` record from the
5
+ // job's trail before it writes the terminal row: the step that was running, the side-effect
6
+ // steps that already finished, and whether that list may be missing steps. Pure and in
7
+ // memory, so the graceful path stays far inside its 8-second shutdown budget.
8
+ //
9
+ // NOTHING IS INFERRED AS SAFE. The trail can be absent (tool activity off), bounded (200
10
+ // entries, 32,000 characters), or hold a step whose outcome has no call id to match. Each of
11
+ // those sets `incomplete`, and a call-less outcome finishes nothing. A step's outcome is only
12
+ // ever the row that names its call.
13
+ //
14
+ // STEER ROWS ARE NOT STEPS. Only `tool` and `status` entries are read; anything else in the
15
+ // list (prose, and a steer or any kind a later build adds) is skipped.
16
+ //
17
+ // REPLAY SAFETY IS DECLARED, NEVER INFERRED (the prototype's rule). `planRestartResume` never
18
+ // auto-resumes in 6.78.0: dispatch auto-resume is deferred (V3 N11), so a declared read-only
19
+ // dispatch is handed to a person like every other job.
20
+ //
21
+ // Imports types only, so the Pi Durable canaries can load it without reaching a live module.
22
+ import {
23
+ DISPATCH_ALLOWED_TOOLS,
24
+ QUERY_JOB_INTERRUPTION_LIMITS,
25
+ QUERY_JOB_LIMITS,
26
+ QUERY_JOB_RESTART_MESSAGE,
27
+ interruptionLine,
28
+ type QueryJobActivityMode,
29
+ type QueryJobInterruption,
30
+ type QueryJobRequest,
31
+ type QueryJobRestartCause,
32
+ type QueryJobSnapshot,
33
+ } from './query-job-types.js'
34
+
35
+ export interface RestartStep {
36
+ verb: string
37
+ target: string
38
+ call?: string
39
+ finished: boolean
40
+ ok?: boolean
41
+ }
42
+
43
+ /** Steps that change nothing outside the run. They are never listed as already done. */
44
+ const SIDE_EFFECT_FREE_VERBS: ReadonlySet<string> = new Set(['read', 'search'])
45
+
46
+ const GERUND: Readonly<Record<string, string>> = {
47
+ read: 'reading', edit: 'editing', write: 'writing', bash: 'running', search: 'searching', task: 'running', other: 'using',
48
+ }
49
+ const PAST: Readonly<Record<string, string>> = {
50
+ read: 'read', edit: 'edited', write: 'wrote', bash: 'ran', search: 'searched', task: 'ran', other: 'used',
51
+ }
52
+
53
+ type TrailLike = {
54
+ kind?: unknown
55
+ verb?: unknown
56
+ target?: unknown
57
+ call?: unknown
58
+ detail?: unknown
59
+ trailSeq?: unknown
60
+ tool_outcome?: unknown
61
+ }
62
+
63
+ function text(value: unknown): string {
64
+ return typeof value === 'string' ? value : ''
65
+ }
66
+
67
+ /**
68
+ * Tool steps in trail order. A `status` row finishes the newest unfinished step whose call it
69
+ * names; an outcome with no call, or one that matches no step, finishes nothing and marks the
70
+ * list ambiguous, as does a step that carries no call id. `openDetail` is Claude Code's own
71
+ * step line (`status.detail`) when it came after the last step, the best words for "cut off
72
+ * during".
73
+ */
74
+ export function trailSteps(trail: readonly unknown[] | undefined): { steps: RestartStep[]; ambiguous: boolean; openDetail: string } {
75
+ const steps: RestartStep[] = []
76
+ let ambiguous = false
77
+ let openDetail = ''
78
+ for (const raw of trail ?? []) {
79
+ if (!raw || typeof raw !== 'object') continue
80
+ const entry = raw as TrailLike
81
+ if (entry.kind === 'tool') {
82
+ const verb = text(entry.verb), target = text(entry.target)
83
+ if (!verb || !target) continue
84
+ const call = text(entry.call)
85
+ steps.push({ verb, target, ...(call ? { call } : {}), finished: false })
86
+ if (!call) ambiguous = true
87
+ openDetail = ''
88
+ } else if (entry.kind === 'status') {
89
+ const outcome = entry.tool_outcome && typeof entry.tool_outcome === 'object'
90
+ ? entry.tool_outcome as { call?: unknown; ok?: unknown } : null
91
+ if (outcome) {
92
+ const call = text(outcome.call)
93
+ const step = call ? [...steps].reverse().find(s => s.call === call && !s.finished) : undefined
94
+ if (step) {
95
+ step.finished = true
96
+ step.ok = outcome.ok !== false
97
+ } else {
98
+ ambiguous = true
99
+ }
100
+ } else if (text(entry.detail)) {
101
+ openDetail = text(entry.detail)
102
+ }
103
+ }
104
+ // prose, steer and any later kind: not a step.
105
+ }
106
+ return { steps, ambiguous, openDetail }
107
+ }
108
+
109
+ function describeOpen(step: RestartStep): string {
110
+ return `${GERUND[step.verb] ?? 'using'} ${step.target}`
111
+ }
112
+
113
+ function describeDone(step: RestartStep): string {
114
+ return `${PAST[step.verb] ?? 'used'} ${step.target}${step.ok === false ? ' (failed)' : ''}`
115
+ }
116
+
117
+ /** "Running npm test." reads as "running npm test" after "Cut off during". */
118
+ function lowerLead(value: string): string {
119
+ const trimmed = value.trim().replace(/[.\s]+$/, '')
120
+ return trimmed ? trimmed[0]!.toLowerCase() + trimmed.slice(1) : ''
121
+ }
122
+
123
+ export interface InterruptionInput {
124
+ /** The job as the store holds it when the restart ends it: `trail`, ownership and status. */
125
+ job: Pick<QueryJobSnapshot, 'trail' | 'providerOwnershipConfirmedAt'>
126
+ activityToolMode: QueryJobActivityMode
127
+ cause: QueryJobRestartCause
128
+ at: string
129
+ }
130
+
131
+ /** The interruption record for a job a restart ended (see QueryJobInterruption). */
132
+ export function buildInterruption(input: InterruptionInput): QueryJobInterruption {
133
+ const limits = QUERY_JOB_INTERRUPTION_LIMITS
134
+ const trail = input.job.trail
135
+ const { steps, ambiguous, openDetail } = trailSteps(trail)
136
+ const unfinished = steps.filter(step => !step.finished)
137
+ const open = unfinished.at(-1)
138
+ const lastStep = steps.at(-1)
139
+ // Claude Code's own line describes the newest step only while that step is still open.
140
+ const openWords = open && open === lastStep && openDetail ? lowerLead(openDetail) : open ? describeOpen(open) : ''
141
+ const cutOffDuring = interruptionLine(openWords, limits.cutOffDuringChars) || null
142
+
143
+ const done: string[] = []
144
+ for (const step of steps) {
145
+ if (!step.finished || SIDE_EFFECT_FREE_VERBS.has(step.verb)) continue
146
+ const line = interruptionLine(describeDone(step), limits.alreadyDoneEntryChars)
147
+ if (line && !done.includes(line)) done.push(line)
148
+ }
149
+ const alreadyDone = done.slice(-limits.alreadyDoneMax)
150
+
151
+ const trailCapped = (trail?.length ?? 0) >= QUERY_JOB_LIMITS.trailEntries
152
+ || (typeof (trail?.[0] as TrailLike | undefined)?.trailSeq === 'number' && Number((trail![0] as TrailLike).trailSeq) > 1)
153
+ const providerStarted = Boolean(input.job.providerOwnershipConfirmedAt)
154
+ // A parked job's starts crashed the server: nothing on record can say what they did.
155
+ const incomplete = input.cause === 'crash_loop_parked'
156
+ || input.activityToolMode === 'off'
157
+ || trailCapped
158
+ || ambiguous
159
+ || unfinished.length > 1
160
+ || done.length > alreadyDone.length
161
+ || (providerStarted && !(trail?.length))
162
+
163
+ return {
164
+ cause: input.cause,
165
+ at: input.at,
166
+ cutOffDuring,
167
+ alreadyDone,
168
+ incomplete,
169
+ resumable: input.cause !== 'crash_loop_parked',
170
+ }
171
+ }
172
+
173
+ // ── Deferred: the restart-resume planner (QJ-6a) ─────────────────────────────────────────
174
+ // Everything from here to `restartError` ships UNCALLED in 6.78.0 (P7 S11). Nothing in the
175
+ // server reads a plan: the app decides Resume or Retry from `interruption.resumable`, and
176
+ // dispatch auto-resume (QJ-6a, V3 N11) is deferred to a later release. The exports stay
177
+ // because the Pi Durable canaries (QJ-6b, QJ-6d) drive this exact planner, so the release
178
+ // that wires it inherits a tested contract. Marked @internal: not an API.
179
+
180
+ /**
181
+ * @internal Deferred with the planner (QJ-6a): uncalled in 6.78.0.
182
+ * One sentence group for a person: what happened, the step cut off, what was already done.
183
+ */
184
+ export function restartNoticeText(interruption: QueryJobInterruption): string {
185
+ return [
186
+ interruption.cause === 'crash_loop_parked'
187
+ ? 'Stopped after repeated server crashes.'
188
+ : 'The server restarted mid-answer.',
189
+ interruption.cutOffDuring ? `Cut off during: ${interruption.cutOffDuring}.` : '',
190
+ interruption.alreadyDone.length ? `Already done: ${interruption.alreadyDone.join('; ')}.` : '',
191
+ interruption.incomplete ? 'Earlier steps may be missing from this list.' : '',
192
+ ].filter(Boolean).join(' ')
193
+ }
194
+
195
+ /** @internal (QJ-6a, uncalled in 6.78.0) The restart cause of a terminal job, or null. Reads 6.78.0 causes and the 6.77.0 messages. */
196
+ export function restartCauseOf(job: Pick<QueryJobSnapshot, 'status' | 'error' | 'interruption'>): QueryJobRestartCause | null {
197
+ if (job.status !== 'interrupted') return null
198
+ if (job.interruption) return job.interruption.cause
199
+ const error = job.error
200
+ if (!error) return null
201
+ if (error.cause === 'server_restarted' || error.cause === 'server_shutdown' || error.cause === 'crash_loop_parked') return error.cause
202
+ if (error.code !== 'interrupted') return null
203
+ // Journals written before 6.78.0: the boot path's message, and the raw graceful reason.
204
+ if (/^Server restarted\./.test(error.message)) return 'server_restarted'
205
+ if (error.message === 'server_shutdown') return 'server_shutdown'
206
+ return null
207
+ }
208
+
209
+ /** @internal (QJ-6a, uncalled in 6.78.0) Was this job cut off by a server restart (not cancelled, failed or stopped by a person)? */
210
+ export function interruptedByRestart(job: Pick<QueryJobSnapshot, 'status' | 'error' | 'interruption'>): boolean {
211
+ const cause = restartCauseOf(job)
212
+ return cause === 'server_restarted' || cause === 'server_shutdown'
213
+ }
214
+
215
+ /** @internal (QJ-6a) No lineage is auto-resumed in 6.78.0. */
216
+ export const MAX_AUTO_RESUMES_PER_LINEAGE = 0
217
+
218
+ /** @internal (QJ-6a) */
219
+ export interface ResumePlan {
220
+ action: 'auto_resume' | 'ask_user' | 'wait' | 'none'
221
+ reason: string
222
+ steps: RestartStep[]
223
+ notice: string
224
+ listMayBeIncomplete: boolean
225
+ }
226
+
227
+ /**
228
+ * @internal (QJ-6a, uncalled in 6.78.0: the canaries drive it; the server does not.)
229
+ * What to do with a job a restart ended. `wait` while a provider child from before the
230
+ * restart may still own the session (the 21-minute orphan fence), `ask_user` otherwise: the
231
+ * person picks Resume or Retry with the notice in front of them. Never `auto_resume` in
232
+ * 6.78.0 (dispatch auto-resume is deferred, V3 N11). Jobs that ended any other way: `none`.
233
+ */
234
+ export function planRestartResume(
235
+ job: QueryJobSnapshot,
236
+ request: Pick<QueryJobRequest, 'activityToolMode' | 'dispatch'>,
237
+ opts: { now: number; autoResumesSoFar: number },
238
+ ): ResumePlan {
239
+ const cause = restartCauseOf(job)
240
+ if (!cause) return { action: 'none', reason: 'not_a_restart', steps: [], notice: '', listMayBeIncomplete: false }
241
+ const steps = trailSteps(job.trail).steps
242
+ const interruption = job.interruption ?? buildInterruption({
243
+ job,
244
+ activityToolMode: request.activityToolMode,
245
+ cause,
246
+ at: job.completedAt ?? job.updatedAt,
247
+ })
248
+ const notice = restartNoticeText(interruption)
249
+ const listMayBeIncomplete = interruption.incomplete
250
+ if (cause === 'crash_loop_parked') {
251
+ return { action: 'ask_user', reason: 'crash_loop_parked', steps, notice, listMayBeIncomplete }
252
+ }
253
+ const fence = job.orphanFenceUntil ? Date.parse(job.orphanFenceUntil) : Number.NaN
254
+ if (Number.isFinite(fence) && fence > opts.now) return { action: 'wait', reason: 'orphan_fence', steps, notice, listMayBeIncomplete }
255
+ const tools = request.dispatch?.restricted === true ? request.dispatch.tools : []
256
+ const declaredReadOnly = tools.length > 0 && tools.every(t => (DISPATCH_ALLOWED_TOOLS as readonly string[]).includes(t))
257
+ return {
258
+ action: 'ask_user',
259
+ reason: declaredReadOnly ? 'auto_resume_deferred' : 'not_declared_replay_safe',
260
+ steps, notice, listMayBeIncomplete,
261
+ }
262
+ }
263
+
264
+ // ── End of the deferred planner. ─────────────────────────────────────────────────────────
265
+
266
+ /** The error a restart writes (both paths), with its cause set at construction. */
267
+ export function restartError(cause: Exclude<QueryJobRestartCause, 'crash_loop_parked'>): { code: 'interrupted'; cause: string; message: string; retryable: true } {
268
+ return { code: 'interrupted', cause, message: QUERY_JOB_RESTART_MESSAGE, retryable: true }
269
+ }
@@ -7,9 +7,11 @@ import { resolveQueryAttachments } from './query-attachments.js'
7
7
  import { getMediaStore } from './media-store.js'
8
8
  import { dataPath } from './data-dir.js'
9
9
  import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
10
- import { durableQueryJobsEnabled, messagesTrailEnabled } from './query-job-feature.js'
10
+ import { durableQueryJobsEnabled, messagesSteerEnabled, messagesTrailEnabled } from './query-job-feature.js'
11
+ import { claudeSupportsSteer } from './claude-cli-support.js'
12
+ import { providerOfModel, resolveRunModel } from './claude-steer.js'
11
13
  import { QueryJobCoordinator, type QueryJobRunner } from './query-job-coordinator.js'
12
- import { QueryJobStore } from './query-job-store.js'
14
+ import { QueryJobStore, createQueryJobJournalStorage } from './query-job-store.js'
13
15
  import {
14
16
  isCodexModel,
15
17
  isCursorModel,
@@ -24,6 +26,7 @@ import { attachmentHistoryPrefix, defaultAttachmentRequest } from '../../shared/
24
26
  import {
25
27
  findExchangesByJobIdentity,
26
28
  flushConversationToDisk,
29
+ getSessionModel,
27
30
  reconcileExchangeByJobIdentity,
28
31
  removeExchangesByJobIdentity,
29
32
  } from './conversation.js'
@@ -36,6 +39,7 @@ import {
36
39
  import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
37
40
  import type { JobTrailDraft } from './job-trail.js'
38
41
  import { registerMessageReservationSource } from './message-reservations.js'
42
+ import { bootResumeGate } from './boot-resume.js'
39
43
 
40
44
  const TOOL_STATUS_MESSAGES: Record<string, string> = {
41
45
  WebSearch: 'Searching web...',
@@ -191,7 +195,7 @@ export function nativeSessionLinkage(linkage: { cliSessionId: string; provider?:
191
195
  : { provider, cliSessionId: linkage.cliSessionId }
192
196
  }
193
197
 
194
- const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callbacks }) => {
198
+ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callbacks, steer }) => {
195
199
  // Resolve ids again at execution time. This closes the admission/execution
196
200
  // TOCTOU window without ever putting paths or bytes in the journal.
197
201
  const resolvedAttachments = await resolveQueryAttachments({
@@ -367,6 +371,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
367
371
  sessionLockHeld: true,
368
372
  ...(request.dispatch ? { dispatch: request.dispatch } : {}),
369
373
  ...(request.sessionName ? { sessionName: request.sessionName } : {}),
374
+ // 6.78.0: only on a run the coordinator decided is steerable.
375
+ ...(steer ? { steer } : {}),
370
376
  },
371
377
  )
372
378
  }
@@ -377,6 +383,9 @@ const queryJobRoot = configuredRoot ? resolve(configuredRoot) : dataPath('query-
377
383
  export const queryJobStore = new QueryJobStore({
378
384
  root: queryJobRoot,
379
385
  bootId: randomUUID(),
386
+ // 6.78.0 (MVP-D release 1): reads delta-only chunk rows; writes them only with
387
+ // COS_QUERY_JOB_DELTA_JOURNAL=1 (default off: every append is the 6.77.0 line).
388
+ storage: createQueryJobJournalStorage(),
380
389
  // 6.61.3: only when the coordinator will run them. With COS_DURABLE_QUERY_JOBS=0 no
381
390
  // coordinator starts, so prior-boot never-started jobs are interrupted as before.
382
391
  requeueNeverStartedOnBoot: durableQueryJobsEnabled(),
@@ -386,6 +395,15 @@ export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner
386
395
  projectTerminal: projectPublicConversationTerminal,
387
396
  acquireSessionLock: acquireModelSessionRunLock,
388
397
  acquireMaintenanceWork: () => acquireMaintenanceWork('durable_query', { phase: 'queued' }),
398
+ // 6.78.0: the boot requeue counts crashed boots per lineage and parks one past the limit.
399
+ bootResumeGate,
400
+ // 6.78.0 Messages steer (COS_MESSAGES_STEER, default off). The provider is resolved as
401
+ // model-router resolves it, so a run is steerable only when it will run on Claude.
402
+ steer: {
403
+ enabled: () => messagesSteerEnabled(),
404
+ provider: request => providerOfModel(resolveRunModel(request.model, getSessionModel(request.sessionId), process.env.COS_G2_DEFAULT_MODEL)),
405
+ cliSupported: () => claudeSupportsSteer(),
406
+ },
389
407
  })
390
408
 
391
409
  // A job's number is a live reservation from admission until its terminal
@@ -398,6 +416,8 @@ registerMessageReservationSource(() => queryJobCoordinator.liveMessageReservatio
398
416
 
399
417
  export function initQueryJobRuntime() {
400
418
  if (!durableQueryJobsEnabled()) return Promise.resolve(queryJobCoordinator.getHealth())
419
+ // 6.78.0: warm the steer probe so /api/models can advertise steer on its first answers.
420
+ if (messagesSteerEnabled()) void claudeSupportsSteer()
401
421
  return queryJobCoordinator.init()
402
422
  }
403
423