@gotcos/glasses-server 6.61.2 → 6.61.5

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.
@@ -1,5 +1,6 @@
1
1
  import { getOrCreateSession } from './conversation.js'
2
2
  import {
3
+ QueryJobProviderOrphanFenceError,
3
4
  QueryJobStore,
4
5
  QueryJobStoreError,
5
6
  type QueryJobAdmissionResult,
@@ -81,8 +82,38 @@ export interface QueryJobCoordinatorOptions {
81
82
  /** Acquired synchronously before serialized admission and retained through
82
83
  * the provider terminal, so maintenance proof covers queued transitions. */
83
84
  acquireMaintenanceWork?: () => MaintenanceWorkLease
85
+ /** Longest single wait while a job is held (orphan fence, or a maintenance drain
86
+ * refusing its start lease) before the coordinator looks again. Bounded so a Mac
87
+ * that slept through the end of a fence does not wait out a stale timer. */
88
+ holdRecheckMs?: number
84
89
  }
85
90
 
91
+ /** Margin past the fence before the start is retried, so the retry lands after it. */
92
+ const FENCE_HOLD_SLACK_MS = 250
93
+
94
+ /** Clock time as a phone and the lens can read it: rounded UP to the minute (never a
95
+ * start time that has already passed), and ICU's narrow no-break space before AM/PM,
96
+ * which some Node builds emit, as a plain space. */
97
+ export function holdClock(ms: number): string {
98
+ return new Date(Math.ceil(ms / 60_000) * 60_000)
99
+ .toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
100
+ .replace(/[\u202f\u00a0]/g, ' ')
101
+ }
102
+
103
+ /**
104
+ * The two lines a job held by an orphan fence shows (6.61.3), journaled as tool-status
105
+ * activity: the reason, then the time. The glasses header shows the LATEST line cut to
106
+ * 18 characters (the client's normalizeHeaderActivityLabel), so the line that stays there
107
+ * must fit whole: `Starts 12:53 PM` is 15. The phone's job log shows both. A clock time,
108
+ * never a countdown: the lines are journaled once, and a countdown is wrong when read.
109
+ */
110
+ export function orphanFenceHoldStatus(startsAtMs: number): [string, string] {
111
+ return ['An earlier run may still be running', `Starts ${holdClock(startsAtMs)}`]
112
+ }
113
+
114
+ /** What a job shows while a maintenance drain (Update Server) refuses its start. 18 chars. */
115
+ export const DRAIN_HOLD_STATUS = 'Waiting for update'
116
+
86
117
  interface ActiveRun {
87
118
  jobId: string
88
119
  generation: number
@@ -107,6 +138,10 @@ interface ActiveRun {
107
138
 
108
139
  export interface QueryJobCoordinatorHealth {
109
140
  activeRuns: number
141
+ /** Admitted jobs this process has not started yet (6.61.3): waiting for an earlier run
142
+ * in their session, an orphan fence, or a drain refusing their start. Durable and
143
+ * `accepted`, so a restart re-queues them; not provider work. */
144
+ heldRuns: number
110
145
  shuttingDown: boolean
111
146
  callbackPersistenceFailures: number
112
147
  terminalProjectionFailures: number
@@ -129,6 +164,11 @@ export class QueryJobCoordinator {
129
164
  private readonly providerTimeoutMs: number
130
165
  private readonly active = new Map<string, ActiveRun>()
131
166
  private readonly admittedMaintenance = new Map<string, MaintenanceWorkLease>()
167
+ /** Held jobs, each with the function that ends its current wait early. */
168
+ private readonly holds = new Map<string, () => void>()
169
+ /** Admitted jobs inside execute that have not started (or given up) yet. */
170
+ private readonly waiting = new Set<string>()
171
+ private readonly holdRecheckMs: number
132
172
  private admissionTail: Promise<void> = Promise.resolve()
133
173
  private shuttingDown = false
134
174
  private callbackPersistenceFailures = 0
@@ -143,6 +183,7 @@ export class QueryJobCoordinator {
143
183
  this.partialFlushMs = Math.max(0, options.partialFlushMs ?? 50)
144
184
  this.partialFlushChars = Math.max(1, options.partialFlushChars ?? 1_024)
145
185
  this.providerTimeoutMs = Math.max(1_000, options.providerTimeoutMs ?? 21 * 60_000)
186
+ this.holdRecheckMs = Math.max(10, options.holdRecheckMs ?? 30_000)
146
187
  }
147
188
 
148
189
  async init(): Promise<QueryJobCoordinatorHealth> {
@@ -156,6 +197,12 @@ export class QueryJobCoordinator {
156
197
  }
157
198
  }
158
199
  }
200
+ // 6.61.3: work the last boot admitted but never started runs now, with no
201
+ // phone needed to send it again, in admission order. Each takes its own start
202
+ // lease in execute.
203
+ for (const { jobId, sessionId } of this.store.takeRequeuedOnBoot()) {
204
+ queueMicrotask(() => { void this.execute(jobId, sessionId) })
205
+ }
159
206
  return this.getHealth()
160
207
  }
161
208
 
@@ -178,7 +225,7 @@ export class QueryJobCoordinator {
178
225
  resolve(admission)
179
226
  if (admission.created) {
180
227
  if (maintenanceLease) this.admittedMaintenance.set(admission.job.jobId, maintenanceLease)
181
- queueMicrotask(() => { void this.execute(admission.job.jobId) })
228
+ queueMicrotask(() => { void this.execute(admission.job.jobId, admission.job.sessionId) })
182
229
  } else {
183
230
  maintenanceLease?.release()
184
231
  }
@@ -207,33 +254,157 @@ export class QueryJobCoordinator {
207
254
  return { ...input, sessionId }
208
255
  }
209
256
 
210
- private async execute(jobId: string): Promise<void> {
211
- const maintenanceLease = this.admittedMaintenance.get(jobId)
212
- this.admittedMaintenance.delete(jobId)
213
- const starting = await this.store.markStarting(jobId).catch(() => null)
214
- if (!starting?.applied) {
215
- maintenanceLease?.release()
216
- return
257
+ /**
258
+ * Mark a job starting, holding it first while its session is orphan-fenced (6.61.3).
259
+ *
260
+ * THE CONTRACT: a prompt the Mac accepted runs to an answer whether or not the
261
+ * phone stays connected. The job is durable before this runs, so the hold needs
262
+ * no subscriber, and a phone that drops reads the answer when it reconnects.
263
+ *
264
+ * The lease is RELEASED for the hold, so a fence (itself left by a restart) cannot
265
+ * block the next Update Server for 21 minutes. Nothing is lost if that restart lands
266
+ * mid-hold: the job is still `accepted`, and the next boot re-queues it. The start
267
+ * takes a fresh lease, which a drain refuses; the job then stays held and retries,
268
+ * rather than starting a provider under a closing server.
269
+ *
270
+ * Returns null when this process will not run the job: it went terminal (a cancel
271
+ * during the hold), the coordinator is shutting down, or the store refused.
272
+ */
273
+ private async startWhenClear(
274
+ jobId: string,
275
+ admitted: MaintenanceWorkLease | undefined,
276
+ ): Promise<{ lease?: MaintenanceWorkLease } | null> {
277
+ const id = jobId.slice(0, 8)
278
+ let lease = admitted
279
+ let heldSinceMs: number | null = null
280
+ let drainNoted = false
281
+ let fenceNoted = false
282
+ for (;;) {
283
+ if (this.shuttingDown) {
284
+ lease?.release()
285
+ if (heldSinceMs != null) console.info(`[query-jobs] ${id} held job left accepted for the next boot (shutdown)`)
286
+ return null
287
+ }
288
+ if (!lease && this.options.acquireMaintenanceWork) {
289
+ try {
290
+ lease = this.options.acquireMaintenanceWork()
291
+ } catch (error) {
292
+ if (!drainNoted) {
293
+ drainNoted = true
294
+ heldSinceMs ??= Date.now()
295
+ console.warn(`[query-jobs] ${id} held: start lease refused (${(error as { code?: unknown })?.code ?? 'error'}); retrying every ${Math.round(this.holdRecheckMs / 1000)}s`)
296
+ await this.appendStatusOnce(jobId, [DRAIN_HOLD_STATUS])
297
+ }
298
+ await this.holdFor(jobId, this.holdRecheckMs)
299
+ continue
300
+ }
301
+ }
302
+ try {
303
+ const starting = await this.store.markStarting(jobId, () => !this.shuttingDown)
304
+ if (starting.applied) {
305
+ if (heldSinceMs != null) console.info(`[query-jobs] ${id} starting after a ${Math.round((Date.now() - heldSinceMs) / 1000)}s hold`)
306
+ return { lease }
307
+ }
308
+ lease?.release()
309
+ if (heldSinceMs != null) console.info(`[query-jobs] ${id} held job ended without starting (${starting.job.status})`)
310
+ return null
311
+ } catch (error) {
312
+ if (!(error instanceof QueryJobProviderOrphanFenceError)) {
313
+ lease?.release()
314
+ console.error(`[query-jobs] ${id} could not start; left accepted for the next boot:`, error)
315
+ return null
316
+ }
317
+ if (!fenceNoted) {
318
+ fenceNoted = true
319
+ heldSinceMs ??= Date.now()
320
+ console.warn(`[query-jobs] ${id} held: a provider of its session may still be running; starts after ${new Date(Date.now() + error.retryAfterMs).toISOString()}`)
321
+ // Journaled while the lease still covers this job.
322
+ await this.appendStatusOnce(jobId, orphanFenceHoldStatus(Date.now() + error.retryAfterMs))
323
+ }
324
+ lease?.release()
325
+ lease = undefined
326
+ await this.holdFor(jobId, Math.min(error.retryAfterMs + FENCE_HOLD_SLACK_MS, this.holdRecheckMs))
327
+ }
217
328
  }
218
- maintenanceLease?.setPhase('active')
219
- let execution
329
+ }
330
+
331
+ /** Append status lines unless they are already the job's latest ones: a restart in the
332
+ * middle of a hold must not repeat them. A failed append never stops the hold. */
333
+ private async appendStatusOnce(jobId: string, lines: readonly string[]): Promise<void> {
220
334
  try {
221
- execution = await this.store.getExecution(jobId)
222
- } catch {
223
- maintenanceLease?.release()
224
- return
335
+ const statuses = (await this.store.getSnapshot(jobId)).activity
336
+ .filter(entry => entry.kind === 'status')
337
+ .map(entry => entry.text)
338
+ if (lines.every((line, index) => statuses[statuses.length - lines.length + index] === line)) return
339
+ for (const line of lines) await this.store.appendActivity(jobId, 'status', line)
340
+ } catch (error) {
341
+ console.warn(`[query-jobs] ${jobId.slice(0, 8)} hold status not journaled:`, error)
225
342
  }
343
+ }
344
+
345
+ /** One bounded wait for a held job, ended early by cancel or shutdown. */
346
+ private holdFor(jobId: string, ms: number): Promise<void> {
347
+ return new Promise(resolve => {
348
+ const end = () => {
349
+ clearTimeout(timer)
350
+ if (this.holds.get(jobId) === end) this.holds.delete(jobId)
351
+ resolve()
352
+ }
353
+ const timer = setTimeout(end, Math.max(0, ms))
354
+ timer.unref?.()
355
+ this.holds.set(jobId, end)
356
+ })
357
+ }
358
+
359
+ private async execute(jobId: string, sessionId: string): Promise<void> {
360
+ // The session lock is REQUESTED before anything awaits, so the jobs of one session
361
+ // line up for it in admission order (submit and the boot requeue call this in that
362
+ // order, and the lock is first-come-first-served). A job waiting for it is still
363
+ // `accepted`, so a restart re-queues it instead of interrupting it.
364
+ const lockRequest = this.options.acquireSessionLock?.(sessionId)
365
+ const admitted = this.admittedMaintenance.get(jobId)
366
+ this.admittedMaintenance.delete(jobId)
367
+ this.waiting.add(jobId)
226
368
  let release: (() => void) | undefined
369
+ let maintenanceLease: MaintenanceWorkLease | undefined
370
+ let execution
227
371
  try {
228
- release = await this.options.acquireSessionLock?.(execution.request.sessionId)
229
- } catch (error) {
372
+ if (lockRequest) {
373
+ // Released while it waits: a run queued behind a long one, or a held one, must not
374
+ // hold Update Server's drain open. startWhenClear takes a fresh lease to start.
375
+ admitted?.release()
376
+ try {
377
+ release = await lockRequest
378
+ } catch (error) {
379
+ await this.store.fail(jobId, error).catch(() => {})
380
+ return
381
+ }
382
+ }
230
383
  try {
231
- await this.store.fail(jobId, error)
232
- } finally {
384
+ execution = await this.store.getExecution(jobId)
385
+ } catch {
386
+ if (!lockRequest) admitted?.release()
387
+ release?.()
388
+ return
389
+ }
390
+ const started = await this.startWhenClear(jobId, lockRequest ? undefined : admitted)
391
+ if (!started) {
392
+ release?.()
393
+ return
394
+ }
395
+ maintenanceLease = started.lease
396
+ if (this.shuttingDown) {
397
+ // markStarting won its write just before shutdown began. Never spawn under a
398
+ // closing server: the job stays `starting`, the next boot interrupts it (no
399
+ // provider owned the session, so no fence) and the phone can retry it.
233
400
  maintenanceLease?.release()
401
+ release?.()
402
+ return
234
403
  }
235
- return
404
+ } finally {
405
+ this.waiting.delete(jobId)
236
406
  }
407
+ maintenanceLease?.setPhase('active')
237
408
 
238
409
  let resolveTerminal!: () => void
239
410
  const terminalPromise = new Promise<void>(resolve => { resolveTerminal = resolve })
@@ -526,6 +697,8 @@ export class QueryJobCoordinator {
526
697
  async cancel(jobId: string, generation: number): Promise<QueryJobMutationResult> {
527
698
  const result = await this.store.cancel(jobId, generation)
528
699
  if (result.applied) {
700
+ // A held job is not active: end its wait so it sees the cancel now.
701
+ this.holds.get(jobId)?.()
529
702
  const active = this.active.get(jobId)
530
703
  const request = active?.request ?? (await this.store.getExecution(jobId)).request
531
704
  await this.projectTerminal(result.job, request)
@@ -569,6 +742,8 @@ export class QueryJobCoordinator {
569
742
 
570
743
  async shutdown(reason = 'server_shutdown'): Promise<void> {
571
744
  this.shuttingDown = true
745
+ // Held jobs stay `accepted` in the journal; the next boot re-queues them.
746
+ for (const end of [...this.holds.values()]) end()
572
747
  await this.admissionTail.catch(() => {})
573
748
  for (const active of [...this.active.values()]) {
574
749
  try {
@@ -634,6 +809,7 @@ export class QueryJobCoordinator {
634
809
  getHealth(): QueryJobCoordinatorHealth {
635
810
  return {
636
811
  activeRuns: this.active.size,
812
+ heldRuns: this.waiting.size,
637
813
  shuttingDown: this.shuttingDown,
638
814
  callbackPersistenceFailures: this.callbackPersistenceFailures,
639
815
  terminalProjectionFailures: this.terminalProjectionFailures,
@@ -362,6 +362,9 @@ const queryJobRoot = configuredRoot ? resolve(configuredRoot) : dataPath('query-
362
362
  export const queryJobStore = new QueryJobStore({
363
363
  root: queryJobRoot,
364
364
  bootId: randomUUID(),
365
+ // 6.61.3: only when the coordinator will run them. With COS_DURABLE_QUERY_JOBS=0 no
366
+ // coordinator starts, so prior-boot never-started jobs are interrupted as before.
367
+ requeueNeverStartedOnBoot: durableQueryJobsEnabled(),
365
368
  })
366
369
 
367
370
  export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner, {
@@ -63,6 +63,15 @@ const PARTITION_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/
63
63
  const MAX_JOURNAL_RECORD_BYTES = 512 * 1024
64
64
  const MAX_CHUNK_DELTA_CHARS = 16_000
65
65
  export const QUERY_JOB_ORPHAN_FENCE_MS = 21 * 60_000
66
+ /**
67
+ * How old a never-started job may be and still run after a restart (6.61.3). Covers the
68
+ * 21-minute orphan fence plus an Update Server or a crash in the middle of it, with margin.
69
+ * Kept under media-store's STAGED_TTL_MS (4 h), so a re-queued photo prompt still finds its
70
+ * staged upload (a test pins that). An older `accepted` job is interrupted on boot exactly
71
+ * as before: a question asked that long ago may no longer be the question, and the phone may
72
+ * have recorded a cancel it could not send while the Mac was down.
73
+ */
74
+ export const QUERY_JOB_BOOT_REQUEUE_MAX_AGE_MS = 60 * 60_000
66
75
 
67
76
  interface QueryJobJournalRecord {
68
77
  schemaVersion: typeof QUERY_JOB_SCHEMA_VERSION
@@ -234,6 +243,14 @@ export interface QueryJobStoreOptions {
234
243
  * rollback contract on real bytes without shipping a second copy of this class.
235
244
  */
236
245
  journalEventTypes?: readonly QueryJobEventType[]
246
+ /**
247
+ * Keep a recent never-started (`accepted`) job from a prior boot `accepted` for the
248
+ * coordinator to run (6.61.3), instead of interrupting it. Off by default: only a store
249
+ * whose coordinator will actually take the list may turn it on. Production sets it from
250
+ * `durableQueryJobsEnabled()`, because with COS_DURABLE_QUERY_JOBS=0 no coordinator runs
251
+ * and a kept job would sit `accepted` for the life of the process.
252
+ */
253
+ requeueNeverStartedOnBoot?: boolean
237
254
  }
238
255
 
239
256
  export class QueryJobStoreError extends Error {
@@ -373,6 +390,8 @@ export class QueryJobStore {
373
390
  private readonly trailEmitter = new EventEmitter()
374
391
  private appendTail: Promise<void> = Promise.resolve()
375
392
  private initPromise: Promise<QueryJobStoreHealth> | null = null
393
+ /** Never-started jobs the last boot left `accepted`, in journal (admission) order. */
394
+ private requeuedJobs: Array<{ jobId: string; sessionId: string }> = []
376
395
  private partitions: string[] = []
377
396
  private subscriberCount = 0
378
397
  private readonly health: QueryJobStoreHealth
@@ -398,6 +417,7 @@ export class QueryJobStore {
398
417
  malformedRows: 0,
399
418
  journalFailures: 0,
400
419
  interruptedOnBoot: 0,
420
+ requeuedOnBoot: 0,
401
421
  evictedHydratedJobs: 0,
402
422
  originDropped: 0,
403
423
  originStripped: 0,
@@ -418,6 +438,27 @@ export class QueryJobStore {
418
438
  return this.initPromise
419
439
  }
420
440
 
441
+ /** The never-started jobs init kept `accepted`, handed over exactly once, in admission order. */
442
+ takeRequeuedOnBoot(): Array<{ jobId: string; sessionId: string }> {
443
+ return this.requeuedJobs.splice(0)
444
+ }
445
+
446
+ /**
447
+ * Epoch ms until which a provider child of `sessionId` may still be running, or 0.
448
+ * Read from the in-memory identities, so it never hydrates a journal partition.
449
+ * Provider sessions, not phone queue ids, are the concurrency boundary: a fresh
450
+ * clientJobId in the same session is fenced exactly like a retry.
451
+ */
452
+ sessionFenceUntil(sessionId: string): number {
453
+ let latest = 0
454
+ for (const item of this.identitiesByJobId.values()) {
455
+ if (item.sessionId !== sessionId || item.status !== 'interrupted' || !item.orphanFenceUntil) continue
456
+ const value = new Date(item.orphanFenceUntil).getTime()
457
+ if (Number.isFinite(value)) latest = Math.max(latest, value)
458
+ }
459
+ return latest
460
+ }
461
+
421
462
  private async initialize(): Promise<QueryJobStoreHealth> {
422
463
  try {
423
464
  await this.storage.prepare(this.options.root)
@@ -430,17 +471,33 @@ export class QueryJobStore {
430
471
  for (const partition of this.partitions) await this.hydratePartition(partition)
431
472
  this.health.state = 'ready'
432
473
 
433
- // A local child process cannot survive a server boot. Persist the
474
+ // No provider child can be reattached after a boot (a detached one may still be
475
+ // running, which is what the orphan fence is for). Persist the
434
476
  // classification once; never invoke a runner during hydration. An
435
477
  // answer_ready record is different: provider generation has already
436
478
  // crossed the durable commit point, so finish it from the journaled
437
479
  // answer instead of throwing away a reply merely because bridge
438
480
  // post-processing was interrupted by the restart.
481
+ //
482
+ // 6.61.3: an `accepted` job never passed markStarting, so no prompt byte reached
483
+ // any provider and running it now cannot repeat anything. With
484
+ // requeueNeverStartedOnBoot it stays `accepted` and the coordinator starts it
485
+ // after init. This is what keeps a held prompt alive across an Update Server in
486
+ // the middle of its wait, with no phone needed to send it again. `starting` and
487
+ // `running` keep the old path: their state machine has no way back to `accepted`.
439
488
  const priorBootJobs = [...this.jobs.values()].filter(job =>
440
489
  !isTerminalQueryJobStatus(job.snapshot.status) && job.lastBootId !== this.options.bootId)
490
+ const nowMs = this.now().getTime()
441
491
  for (const job of priorBootJobs) {
442
492
  const snapshot = job.snapshot
443
- if (snapshot.status === 'answer_ready') {
493
+ const acceptedMs = Date.parse(snapshot.acceptedAt)
494
+ if (this.options.requeueNeverStartedOnBoot === true
495
+ && snapshot.status === 'accepted'
496
+ && Number.isFinite(acceptedMs)
497
+ && nowMs - acceptedMs <= QUERY_JOB_BOOT_REQUEUE_MAX_AGE_MS) {
498
+ this.requeuedJobs.push({ jobId: snapshot.jobId, sessionId: job.request.sessionId })
499
+ this.health.requeuedOnBoot = (this.health.requeuedOnBoot ?? 0) + 1
500
+ } else if (snapshot.status === 'answer_ready') {
444
501
  await this.complete(snapshot.jobId, {
445
502
  text: snapshot.partialText,
446
503
  attachments: snapshot.attachments,
@@ -458,6 +515,9 @@ export class QueryJobStore {
458
515
  if (result.applied) this.health.interruptedOnBoot++
459
516
  }
460
517
  }
518
+ if (this.requeuedJobs.length > 0) {
519
+ console.warn(`[query-jobs] boot: ${this.requeuedJobs.length} never-started job(s) from the last boot kept for the coordinator to run`)
520
+ }
461
521
  this.trimHydratedJobs()
462
522
  this.refreshHealth()
463
523
  return this.getHealth()
@@ -801,17 +861,11 @@ export class QueryJobStore {
801
861
  if (lineage.some(item => !isTerminalQueryJobStatus(item.status))) {
802
862
  throw new QueryJobActiveGenerationError(request.clientJobId)
803
863
  }
804
- const nowMs = this.now().getTime()
805
- // Provider sessions, not phone queue ids, are the concurrency boundary.
806
- // A restarted client can allocate a fresh clientJobId; it must not bypass
807
- // an orphan fence and resume the same provider session too early.
808
- const sessionLineage = identities.filter(item => item.sessionId === request.sessionId)
809
- const fencedUntil = sessionLineage.reduce((latest, item) => {
810
- if (item.status !== 'interrupted' || !item.orphanFenceUntil) return latest
811
- const value = new Date(item.orphanFenceUntil).getTime()
812
- return Number.isFinite(value) ? Math.max(latest, value) : latest
813
- }, 0)
814
- if (fencedUntil > nowMs) throw new QueryJobProviderOrphanFenceError(fencedUntil - nowMs)
864
+ // 6.61.3: an orphan fence no longer refuses admission. Refusing handed the
865
+ // prompt back to the phone as "Provider closing · retry in 1192s", where a
866
+ // phone that then lost signal never sent it again. The fence is enforced at
867
+ // markStarting instead, the gate every durable job crosses before a provider,
868
+ // so the prompt is journaled now and starts on the Mac once the fence clears.
815
869
  const highestGeneration = lineage.reduce((max, item) => Math.max(max, item.generation), 0)
816
870
  if (request.generation <= highestGeneration) throw new QueryJobGenerationOrderError(request.clientJobId)
817
871
 
@@ -844,8 +898,35 @@ export class QueryJobStore {
844
898
  })
845
899
  }
846
900
 
847
- async markStarting(jobId: string): Promise<QueryJobMutationResult> {
848
- return this.transition(jobId, 'starting', 'starting', {}, {})
901
+ /**
902
+ * The durable-job provider-start gate. Every durable job crosses it before a provider
903
+ * (the legacy /api/query and /v1 routes are not durable jobs and never did). A job whose
904
+ * session may still hold an orphaned provider child cannot start: this throws
905
+ * `provider_orphan_fence` with the time left and the job stays `accepted`, for the
906
+ * coordinator to start once the fence clears (6.61.3; the check used to refuse admission
907
+ * instead). A job that is terminal, or already past `accepted`, is `applied: false`,
908
+ * checked first so a canceled job never reports a fence. So is one `canStart` refuses,
909
+ * which is evaluated INSIDE the serialized write: a shutdown that begins while this call
910
+ * waits its turn leaves the job `accepted` for the next boot, never `starting`.
911
+ * Awaits the WHOLE boot classification, not just readiness: a start must never slip in
912
+ * before the boot has stamped the fence of a run it is about to interrupt.
913
+ */
914
+ async markStarting(jobId: string, canStart: () => boolean = () => true): Promise<QueryJobMutationResult> {
915
+ await this.init()
916
+ await this.ensureHydrated(jobId)
917
+ return this.enqueue(async () => {
918
+ this.assertWritable()
919
+ const hydrated = this.jobs.get(jobId)
920
+ if (!hydrated) throw new QueryJobNotFoundError(jobId)
921
+ const status = hydrated.snapshot.status
922
+ if (isTerminalQueryJobStatus(status) || !ALLOWED_TRANSITIONS[status].has('starting')) {
923
+ return { applied: false, job: clone(hydrated.snapshot) }
924
+ }
925
+ const remainingMs = this.sessionFenceUntil(hydrated.request.sessionId) - this.now().getTime()
926
+ if (remainingMs > 0) throw new QueryJobProviderOrphanFenceError(remainingMs)
927
+ if (!canStart()) return { applied: false, job: clone(hydrated.snapshot) }
928
+ return this.persistMutation(hydrated, 'starting', 'starting', {}, {})
929
+ })
849
930
  }
850
931
 
851
932
  async markRunning(jobId: string, linkage: QueryJobProviderLinkage): Promise<QueryJobMutationResult> {
@@ -286,6 +286,10 @@ export interface QueryJobStoreHealth {
286
286
  malformedRows: number
287
287
  journalFailures: number
288
288
  interruptedOnBoot: number
289
+ /** Never-started jobs a prior boot left `accepted` that this boot runs (6.61.3).
290
+ * Optional only because the vendored 6.51.0 rollback fixture types its health
291
+ * against this interface and must stay byte-identical; this build always sets it. */
292
+ requeuedOnBoot?: number
289
293
  evictedHydratedJobs: number
290
294
  /** Requests whose `origin` was present but not one this build recognises
291
295
  * (a bare string, or an object of a later build's kind) and was dropped. */
@@ -10,7 +10,7 @@ import { dataPath } from './data-dir.js'
10
10
  import { taskDomainNames } from './domains.js'
11
11
  import { loadProfileObject } from './profile.js'
12
12
  const runtime = resolve(import.meta.dirname, '../../work-task-runtime')
13
- const commands = new Set(['task-rows','task-capture','task-set-text','task-set-run-at','task-set-marker','task-set-stage','task-set-done-when','task-move','task-check','task-work-capabilities','task-set-work-stage','task-link-meeting'])
13
+ const commands = new Set(['task-rows','task-capture','task-set-text','task-set-run-at','task-set-marker','task-set-stage','task-set-done-when','task-move','task-check','task-work-capabilities','task-set-work-stage','task-link-meeting','task-edit-work'])
14
14
  export function taskBridgeUnavailableMessage(): string {
15
15
  if (process.env.COS_SCRIPTS_DIR && !pythonBridgeAvailable()) return 'The configured COS bridge is unavailable. Repair COS_SCRIPTS_DIR so it contains cos_api_bridge.py and an executable venv/bin/python3. Work will not switch task storage automatically.'
16
16
  if (process.env.COS_PORTABLE_TASKS === '0') return 'Portable Work tasks are disabled. Enable COS_PORTABLE_TASKS or configure a compatible COS bridge.'
@@ -17,7 +17,6 @@ import {
17
17
  QueryJobIdentityConflictError,
18
18
  QueryJobNotFoundError,
19
19
  QueryJobPersistenceError,
20
- QueryJobProviderOrphanFenceError,
21
20
  type QueryJobAdmissionResult,
22
21
  type QueryJobMutationResult,
23
22
  } from './query-job-store.js'
@@ -141,10 +140,11 @@ function buildTaskRequest(run: TaskRun, tz: string): RestrictedRequest {
141
140
  }
142
141
  }
143
142
 
144
- function classifySubmitError(error: unknown): 'identity_conflict' | 'adopt' | 'fence' | 'drain' | 'transient' {
143
+ // No 'fence' kind since 6.61.3: an orphan fence holds a durable job at its start and never
144
+ // refuses its admission, so submit cannot throw it.
145
+ function classifySubmitError(error: unknown): 'identity_conflict' | 'adopt' | 'drain' | 'transient' {
145
146
  if (error instanceof QueryJobIdentityConflictError) return 'identity_conflict'
146
147
  if (error instanceof QueryJobActiveGenerationError || error instanceof QueryJobGenerationOrderError) return 'adopt'
147
- if (error instanceof QueryJobProviderOrphanFenceError) return 'fence'
148
148
  if (error instanceof MaintenanceLifecycleError) return 'drain'
149
149
  if (error instanceof QueryJobCoordinatorError && error.code === 'query_job_coordinator_shutting_down') return 'transient'
150
150
  return 'transient'
@@ -273,11 +273,10 @@ async function adoptOrSubmit(deps: TaskDispatcherDeps, run: TaskRun): Promise<Ta
273
273
  return accepted ?? run
274
274
  }
275
275
  }
276
- const fenceMs = error instanceof QueryJobProviderOrphanFenceError ? error.retryAfterMs : TASK_DISPATCH_LIMITS.submitSpacingMs
277
276
  const consume = kind !== 'drain'
278
277
  await persistRun(deps, run.id, {
279
278
  status: 'dispatching',
280
- retryAfter: nowMs(deps) + fenceMs,
279
+ retryAfter: nowMs(deps) + TASK_DISPATCH_LIMITS.submitSpacingMs,
281
280
  ...(consume ? { submitAttempts: run.submitAttempts + 1 } : {}),
282
281
  })
283
282
  throw new TaskRunError(503, kind === 'drain' ? 'admissions_closed' : 'submit_failed', error instanceof Error ? error.message : 'submit failed')
@@ -543,11 +543,11 @@ export async function setTaskText(domain: string, id: string, text: string): Pro
543
543
  export function rejectReservedWorkText(text: string): void {
544
544
  if (/cos-work\s*:|\*\*Source:\*\*/i.test(text)) throw new TaskRunError(422, 'reserved_work_metadata', 'Use the explicit meeting link and stage controls; Source metadata is reserved.')
545
545
  }
546
- export async function workBoardCapabilities(): Promise<{ version: number; writable: boolean }> {
546
+ export async function workBoardCapabilities(): Promise<{ version: number; writable: boolean; editTasks?: number }> {
547
547
  if (!taskBridgeAvailable()) return { version: 0, writable: false }
548
548
  try {
549
- const value = await bridge(['task-work-capabilities']) as { version?: unknown }
550
- return value?.version === 1 ? { version: 1, writable: true } : { version: 0, writable: false }
549
+ const value = await bridge(['task-work-capabilities']) as { version?: unknown; editTasks?: unknown }
550
+ return value?.version === 1 ? { version: 1, writable: true, ...(value.editTasks === 1 ? { editTasks: 1 } : {}) } : { version: 0, writable: false }
551
551
  } catch { return { version: 0, writable: false } }
552
552
  }
553
553
  function validateWorkTarget(domain: string, id: string, expectedText: string, expectedRevision: string): void {
@@ -560,6 +560,20 @@ export async function setTaskWorkStage(domain: string, id: string, workStage: Wo
560
560
  if (!WORK_STAGES.includes(workStage)) throw new TaskRunError(422, 'invalid_work_stage', 'Unknown Work stage')
561
561
  await withLockRetry(() => bridge(['task-set-work-stage', domain, id, workStage], JSON.stringify({ expectedText, expectedRevision })))
562
562
  }
563
+
564
+ export interface WorkTaskEditResult { ok: true; id: string; workIdentity: string; workRevision: string; text: string; doneWhen: string }
565
+ export async function editWorkTask(domain: string, id: string, text: string, doneWhen: string, expectedText: string, expectedRevision: string): Promise<WorkTaskEditResult> {
566
+ validateWorkTarget(domain, id, expectedText, expectedRevision)
567
+ rejectReservedWorkText(text); rejectReservedWorkText(doneWhen)
568
+ const clean = text.replace(/\s+/g, ' ').trim(), finish = doneWhen.replace(/\s+/g, ' ').trim()
569
+ if (!clean || clean.length > 2000) throw new TaskRunError(422, 'invalid_task_text', 'Task name must contain 1 to 2,000 characters')
570
+ if (finish.length > 500 || finish.includes('**')) throw new TaskRunError(422, 'invalid_done_when', 'Finish line must be at most 500 characters without ** markup')
571
+ const out = await withLockRetry(() => bridge(['task-edit-work', domain, id], JSON.stringify({ text: clean, doneWhen: finish, expectedText, expectedRevision }))) as WorkTaskEditResult
572
+ if (out?.ok !== true || !/^[a-f0-9]{12}$/.test(out.id) || !/^[a-f0-9]{12}$/.test(out.workIdentity) || !/^[a-f0-9]{64}$/.test(out.workRevision) || out.text !== clean || out.doneWhen !== finish) {
573
+ throw new TaskRunError(502, 'edit_unverified', 'The save response could not be verified. Refresh before trying again; do not repeat the save blindly.')
574
+ }
575
+ return out
576
+ }
563
577
  /**
564
578
  * Work intake needs two bridge powers: exact meeting links (version 1) and one-write card capture. A bridge that
565
579
  * failed or timed out is reported as `bridgeUnavailable`, so a hiccup never reads as "this server is too old".
@@ -1,3 +1,4 @@
1
+ import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
1
2
  import { taskBridgeAvailable } from '../lib/task-bridge.js'
2
3
  import { Router } from 'express'
3
4
  import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
@@ -138,6 +139,7 @@ function durableQueryJobStatus() {
138
139
  && !runtime.shuttingDown,
139
140
  protocolVersion: configured.protocolVersion,
140
141
  activeRuns: runtime.activeRuns,
142
+ heldRuns: runtime.heldRuns,
141
143
  shuttingDown: runtime.shuttingDown,
142
144
  callbackPersistenceFailures: runtime.callbackPersistenceFailures,
143
145
  terminalProjectionFailures: runtime.terminalProjectionFailures,
@@ -153,6 +155,7 @@ function durableQueryJobStatus() {
153
155
  evictedHydratedJobs: runtime.store.evictedHydratedJobs,
154
156
  journalFailures: runtime.store.journalFailures,
155
157
  interruptedOnBoot: runtime.store.interruptedOnBoot,
158
+ requeuedOnBoot: runtime.store.requeuedOnBoot,
156
159
  lastErrorCode: runtime.store.lastErrorCode,
157
160
  lastSuccessfulWriteAt: runtime.store.lastSuccessfulWriteAt,
158
161
  rootFingerprint: runtime.store.rootFingerprint,
@@ -444,6 +447,7 @@ healthRouter.get('/health', async (_req, res) => {
444
447
  review_audio: reviewAudio,
445
448
  ...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
446
449
  capabilities: {
450
+ dictationCleanup: { models: DICTATION_CLEAN_MODELS },
447
451
  // Advertised on the PUBLIC health route on purpose: a client deciding whether
448
452
  // to request a display ticket may not hold a usable token yet, and an old
449
453
  // server simply omits this key, which is how a new client detects it.
@@ -596,6 +600,7 @@ healthRouter.get('/models', async (req, res) => {
596
600
  // Changing that is a breaking change that needs a client version gate first.
597
601
  ...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
598
602
  capabilities: {
603
+ dictationCleanup: { models: DICTATION_CLEAN_MODELS },
599
604
  durableQueryJobs: {
600
605
  enabled: durableJobs.enabled,
601
606
  protocolVersion: durableJobs.protocolVersion,
@@ -112,6 +112,11 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
112
112
  serverInstanceId: getServerInstanceId(),
113
113
  bootId: serverMetrics.bootId,
114
114
  activeJobs: jobs.activeRuns,
115
+ // 6.61.3: admitted prompts not started yet (an orphan fence, an earlier run in their
116
+ // session, or this drain). They are journaled `accepted` and a restart re-queues
117
+ // them, so they never block safeToRestart; a rollback below 6.61.3 interrupts them.
118
+ heldJobs: jobs.heldRuns,
119
+ requeuedOnBoot: jobs.store.requeuedOnBoot ?? 0,
115
120
  activeTranscriptionSessions,
116
121
  // Split out so a blocked operator can SEE why, and so "0 orphans" can never
117
122
  // again coexist with a held lock: the orphans endpoint reads the quarantine