@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.
- package/CHANGELOG.md +43 -0
- package/README.md +24 -1
- package/package.json +2 -1
- package/server/index.ts +1 -1
- package/server/lib/cos-operations-meetings.ts +11 -17
- package/server/lib/dictation-clean-models.ts +7 -0
- package/server/lib/dictation-clean.ts +113 -56
- package/server/lib/message-era-reset.ts +14 -5
- package/server/lib/query-job-coordinator.ts +195 -19
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/query-job-store.ts +96 -15
- package/server/lib/query-job-types.ts +4 -0
- package/server/lib/task-bridge.ts +1 -1
- package/server/lib/task-dispatcher.ts +4 -5
- package/server/lib/task-store.ts +17 -3
- package/server/routes/health.ts +5 -0
- package/server/routes/maintenance.ts +5 -0
- package/server/routes/meeting.ts +48 -6
- package/server/routes/prompt-drafts.ts +13 -5
- package/server/routes/query-jobs.ts +2 -0
- package/server/routes/work-board.ts +13 -5
- package/work-task-runtime/manifest.json +6 -6
- package/work-task-runtime/task_bridge.py +3 -2
- package/work-task-runtime/task_commands.py +10 -6
- package/work-task-runtime/task_write.py +131 -1
|
@@ -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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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.
|
|
232
|
-
}
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
|
|
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
|
-
|
|
848
|
-
|
|
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
|
-
|
|
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) +
|
|
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')
|
package/server/lib/task-store.ts
CHANGED
|
@@ -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".
|
package/server/routes/health.ts
CHANGED
|
@@ -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
|