@gotcos/glasses-server 6.43.4 → 6.44.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.
@@ -0,0 +1,624 @@
1
+ import { join } from 'node:path'
2
+ import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
3
+ import { dataPath } from './data-dir.js'
4
+ import {
5
+ TASK_CATCH_UP_LIMITS,
6
+ ensurePrivateDir,
7
+ loadMorningBriefConfig,
8
+ morningBriefPaths,
9
+ type MorningBriefConfig,
10
+ } from './morning-brief-config.js'
11
+ import { localClock, shiftDay, taskInstant } from './morning-brief-schedule.js'
12
+ import { callPython, pythonBridgeAvailable } from './python-bridge.js'
13
+ import { isClientJobId } from './query-job-types.js'
14
+ import { DEFAULT_MODEL, isClaudeModel } from '../../shared/model-preference.js'
15
+
16
+ export const TASK_DISPATCH_LIMITS = Object.freeze({
17
+ runsPerDay: 3,
18
+ submitAttempts: 3,
19
+ submitSpacingMs: 2 * 60_000,
20
+ runSpacingMs: 10 * 60_000,
21
+ perTick: 2,
22
+ reconcilePerTick: 2,
23
+ capPerDay: 20,
24
+ retainedRuns: 200,
25
+ })
26
+ export const TASK_DISPATCH_WALL_MS = 25_000
27
+ export const TASK_LEASE_CEILING_MS = 2 * TASK_DISPATCH_WALL_MS
28
+ export const TASK_RECONCILE_WALL_MS = 40_000
29
+ export const TASK_BRIDGE_TIMEOUT_MS = 12_000
30
+ export const TASK_JOB_LOST_MS = 6 * 60 * 60_000
31
+ export const TASK_LOCK_RETRY = Object.freeze({ attempts: 3, spacingMs: 300 })
32
+ export const TASK_TODAY_PURGE_HORIZON_DAYS = 7
33
+ export const FULL_DOMAINS = ['quilt', 'personal', 'hermit_crabs', 'sprocket_rocket'] as const
34
+ export type TaskDomain = (typeof FULL_DOMAINS)[number]
35
+ export type TaskRunStatus = 'dispatching' | 'running' | 'done' | 'superseded' | 'failed' | 'orphaned'
36
+ export const TASK_RUN_TERMINAL = new Set<TaskRunStatus>(['done', 'superseded', 'failed', 'orphaned'])
37
+ export type TaskColumn = 'done' | 'running' | 'today' | 'carried' | 'scheduled' | 'inbox'
38
+
39
+ export interface TaskStorePaths {
40
+ runs: string
41
+ cap: string
42
+ capturesSeen: string
43
+ }
44
+
45
+ export interface TaskRun {
46
+ id: string
47
+ identity: string
48
+ taskId: string
49
+ ref: string
50
+ domain: TaskDomain
51
+ line: string
52
+ scheduledFor?: string
53
+ day: string
54
+ attempt: number
55
+ trigger: 'manual' | 'scheduled'
56
+ firedAt: string
57
+ clientJobId: string
58
+ generation: 1
59
+ sessionId: string
60
+ messageEra?: string
61
+ globalMsgNum?: number
62
+ status: TaskRunStatus
63
+ jobId?: string
64
+ submitAttempts: number
65
+ retryAfter?: number
66
+ lastKnownStatus?: string
67
+ completedAt?: string
68
+ error?: { code: string; message: string }
69
+ catchUp?: boolean
70
+ model: string
71
+ effort?: string
72
+ cursorExecutionMode?: string
73
+ activityToolMode: 'status'
74
+ }
75
+
76
+ export interface BridgeTaskRow {
77
+ ref: string
78
+ id: string
79
+ domain: string
80
+ description: string
81
+ priority: string
82
+ is_checked: boolean
83
+ archived: boolean
84
+ line_number: number
85
+ source: string | null
86
+ owner: string | null
87
+ delegated: boolean
88
+ needs_review: boolean
89
+ thread: null
90
+ run_at: string | null
91
+ agent_state: 'running' | 'done' | 'failed' | null
92
+ agent_no: number | null
93
+ section: string
94
+ section_day: string | null
95
+ }
96
+
97
+ export interface TaskFlags {
98
+ due: boolean
99
+ missed: boolean
100
+ failed: boolean
101
+ late: boolean
102
+ carriedOver: boolean
103
+ }
104
+
105
+ export interface TaskBoardRow {
106
+ id: string
107
+ ref: string
108
+ domain: string
109
+ title: string
110
+ column: TaskColumn
111
+ priority: string
112
+ runAt?: string
113
+ section: string
114
+ sectionDay?: string
115
+ due: boolean
116
+ missed: boolean
117
+ failed: boolean
118
+ late: boolean
119
+ carriedOver: boolean
120
+ }
121
+
122
+ export class TaskBridgeError extends Error {
123
+ constructor(readonly code: string, message: string) {
124
+ super(message)
125
+ this.name = 'TaskBridgeError'
126
+ }
127
+ }
128
+
129
+ export class TaskRunError extends Error {
130
+ constructor(readonly status: number, readonly code: string, message: string) {
131
+ super(message)
132
+ this.name = 'TaskRunError'
133
+ }
134
+ }
135
+
136
+ export function taskStorePaths(root?: string): TaskStorePaths {
137
+ const base = root ?? dataPath('tasks')
138
+ return {
139
+ runs: join(base, 'runs.json'),
140
+ cap: join(base, 'tasks-dispatch-cap.json'),
141
+ capturesSeen: join(base, 'captures-seen.json'),
142
+ }
143
+ }
144
+
145
+ export function tasksGate(): 'ready' | 'disabled' {
146
+ return pythonBridgeAvailable() ? 'ready' : 'disabled'
147
+ }
148
+
149
+ export function taskDispatchModel(config: MorningBriefConfig): string {
150
+ return config.model && isClaudeModel(config.model) ? config.model : DEFAULT_MODEL
151
+ }
152
+
153
+ function isTaskRunRecord(value: unknown): value is TaskRun {
154
+ if (!value || typeof value !== 'object') return false
155
+ const rec = value as Record<string, unknown>
156
+ return typeof rec.id === 'string' && typeof rec.taskId === 'string' && typeof rec.status === 'string'
157
+ }
158
+
159
+ export function loadTaskLedger(paths: TaskStorePaths): TaskRun[] {
160
+ const loaded = loadJsonOrQuarantine<unknown>(paths.runs)
161
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return []
162
+ const raw = loaded.data as { runs?: unknown }
163
+ if (!Array.isArray(raw.runs)) return []
164
+ return raw.runs.filter(isTaskRunRecord)
165
+ }
166
+
167
+ export function saveTaskLedger(paths: TaskStorePaths, runs: TaskRun[]): void {
168
+ const kept: TaskRun[] = []
169
+ const terminals: TaskRun[] = []
170
+ for (const run of runs) {
171
+ if (TASK_RUN_TERMINAL.has(run.status)) terminals.push(run)
172
+ else kept.push(run)
173
+ }
174
+ terminals.sort((a, b) => a.firedAt.localeCompare(b.firedAt))
175
+ const overflow = Math.max(0, terminals.length - TASK_DISPATCH_LIMITS.retainedRuns)
176
+ const retained = [...kept, ...terminals.slice(overflow)]
177
+ ensurePrivateDir(paths.runs)
178
+ durableAtomicWriteFileSync(paths.runs, `${JSON.stringify({ v: 1, runs: retained }, null, 2)}\n`, { mode: 0o600 })
179
+ }
180
+
181
+ let serializeChain: Promise<unknown> = Promise.resolve()
182
+ let serializeDepth = 0
183
+
184
+ export function serializeTaskWork<T>(fn: () => Promise<T> | T): Promise<T> {
185
+ if (serializeDepth > 0) {
186
+ return Promise.reject(new Error('nested serializeTaskWork'))
187
+ }
188
+ const run = serializeChain.then(async () => {
189
+ serializeDepth += 1
190
+ try {
191
+ return await fn()
192
+ } finally {
193
+ serializeDepth -= 1
194
+ }
195
+ })
196
+ serializeChain = run.then(() => undefined, () => undefined)
197
+ return run
198
+ }
199
+
200
+ export function parseRunAt(value: string | null | undefined): { day: string; minutes: number } | null {
201
+ if (!value) return null
202
+ const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}):(\d{2})$/.exec(value)
203
+ if (!match) return null
204
+ return { day: match[1], minutes: Number(match[2]) * 60 + Number(match[3]) }
205
+ }
206
+
207
+ export function hasAgentMarker(row: Pick<BridgeTaskRow, 'agent_state'>): boolean {
208
+ return row.agent_state === 'running' || row.agent_state === 'done' || row.agent_state === 'failed'
209
+ }
210
+
211
+ export function beyondCatchUp(
212
+ runAt: { day: string; minutes: number } | null,
213
+ nowMs: number,
214
+ tz: string,
215
+ taskCatchUpMinutes: number = TASK_CATCH_UP_LIMITS.defaultMinutes,
216
+ ): boolean {
217
+ if (!runAt) return false
218
+ return nowMs - taskInstant(runAt.day, runAt.minutes, tz) > taskCatchUpMinutes * 60_000
219
+ }
220
+
221
+ export function isCatchUpDue(
222
+ row: Pick<BridgeTaskRow, 'run_at' | 'agent_state'>,
223
+ nowMs: number,
224
+ tz: string,
225
+ taskCatchUpMinutes: number = TASK_CATCH_UP_LIMITS.defaultMinutes,
226
+ ): boolean {
227
+ const runAt = parseRunAt(row.run_at)
228
+ if (!runAt) return false
229
+ const runAtInstant = taskInstant(runAt.day, runAt.minutes, tz)
230
+ return runAtInstant <= nowMs && !beyondCatchUp(runAt, nowMs, tz, taskCatchUpMinutes) && !hasAgentMarker(row)
231
+ }
232
+
233
+ export function latestLedgerRow(runs: readonly TaskRun[], taskId: string, day: string): TaskRun | undefined {
234
+ const inDay = runs.filter(run => run.taskId === taskId && run.day === day)
235
+ const live = inDay.find(run => run.status === 'dispatching' || run.status === 'running')
236
+ if (live) return live
237
+ return [...inDay].sort((a, b) => b.firedAt.localeCompare(a.firedAt))[0]
238
+ }
239
+
240
+ export function column(
241
+ row: BridgeTaskRow,
242
+ ledgerRow: TaskRun | undefined,
243
+ day: string,
244
+ nowMs: number,
245
+ tz: string,
246
+ taskCatchUpMinutes: number = TASK_CATCH_UP_LIMITS.defaultMinutes,
247
+ ): TaskColumn | null {
248
+ if (row.archived) return null
249
+ if (row.delegated) return null
250
+ if (row.is_checked) return 'done'
251
+ if (row.agent_state === 'done') return 'done'
252
+ if (row.agent_state === 'running' || ledgerRow?.status === 'dispatching' || ledgerRow?.status === 'running') {
253
+ return 'running'
254
+ }
255
+ const runAt = parseRunAt(row.run_at)
256
+ if (runAt && runAt.day > day) return 'scheduled'
257
+ const skipSection = hasAgentMarker(row) || beyondCatchUp(runAt, nowMs, tz, taskCatchUpMinutes)
258
+ if (!skipSection && row.section === 'today' && row.section_day === day) return 'today'
259
+ if (!skipSection && row.section === 'today' && row.section_day && row.section_day < day) return 'carried'
260
+ if (!skipSection && row.section === 'today' && row.section_day && row.section_day > day) return 'carried'
261
+ if (isCatchUpDue(row, nowMs, tz, taskCatchUpMinutes)) return 'today'
262
+ return 'inbox'
263
+ }
264
+
265
+ export function flags(
266
+ row: BridgeTaskRow,
267
+ ledgerRow: TaskRun | undefined,
268
+ day: string,
269
+ nowMs: number,
270
+ tz: string,
271
+ taskCatchUpMinutes: number = TASK_CATCH_UP_LIMITS.defaultMinutes,
272
+ ): TaskFlags {
273
+ const runAt = parseRunAt(row.run_at)
274
+ const liveInDay = ledgerRow?.day === day && (ledgerRow.status === 'dispatching' || ledgerRow.status === 'running')
275
+ const missed = beyondCatchUp(runAt, nowMs, tz, taskCatchUpMinutes) && !hasAgentMarker(row) && !liveInDay
276
+ const failed = row.agent_state === 'failed'
277
+ const runAtInstant = runAt ? taskInstant(runAt.day, runAt.minutes, tz) : null
278
+ const due = !!runAt && runAtInstant! <= nowMs && !hasAgentMarker(row) && !missed && !beyondCatchUp(runAt, nowMs, tz, taskCatchUpMinutes)
279
+ return {
280
+ due,
281
+ missed,
282
+ failed,
283
+ late: ledgerRow?.catchUp === true,
284
+ carriedOver: row.section === 'today' && !!row.section_day && row.section_day !== day,
285
+ }
286
+ }
287
+
288
+ function briefContext(nowMs = Date.now()) {
289
+ const { config } = loadMorningBriefConfig(morningBriefPaths(), new Date(nowMs))
290
+ const clock = localClock(nowMs, config.timezone)
291
+ return { config, clock, nowMs }
292
+ }
293
+
294
+ function asBridgeError(payload: unknown): TaskBridgeError | null {
295
+ if (!payload || typeof payload !== 'object') return null
296
+ const error = (payload as { error?: unknown }).error
297
+ if (!error || typeof error !== 'object') return null
298
+ const rec = error as { code?: unknown; message?: unknown }
299
+ if (typeof rec.code !== 'string') return null
300
+ return new TaskBridgeError(rec.code, typeof rec.message === 'string' ? rec.message : rec.code)
301
+ }
302
+
303
+ async function bridge(args: string[], input?: string): Promise<unknown> {
304
+ const payload = await callPython(args, TASK_BRIDGE_TIMEOUT_MS, input)
305
+ const error = asBridgeError(payload)
306
+ if (error) throw error
307
+ return payload
308
+ }
309
+
310
+ async function withLockRetry<T>(fn: () => Promise<T>): Promise<T> {
311
+ let last: unknown
312
+ for (let attempt = 0; attempt < TASK_LOCK_RETRY.attempts; attempt++) {
313
+ try {
314
+ return await fn()
315
+ } catch (error) {
316
+ last = error
317
+ if (!(error instanceof TaskBridgeError) || error.code !== 'task_file_locked') throw error
318
+ if (attempt + 1 < TASK_LOCK_RETRY.attempts) {
319
+ await new Promise(resolve => setTimeout(resolve, TASK_LOCK_RETRY.spacingMs))
320
+ }
321
+ }
322
+ }
323
+ throw last
324
+ }
325
+
326
+ export async function loadDomainRows(domain: TaskDomain, day: string): Promise<BridgeTaskRow[]> {
327
+ const payload = await bridge(['task-rows', domain, '--day', day])
328
+ return Array.isArray(payload) ? payload as BridgeTaskRow[] : []
329
+ }
330
+
331
+ export async function loadAllRows(day: string): Promise<BridgeTaskRow[]> {
332
+ const groups = await Promise.all(FULL_DOMAINS.map(domain => loadDomainRows(domain, day)))
333
+ return groups.flat()
334
+ }
335
+
336
+ export function projectRow(
337
+ row: BridgeTaskRow,
338
+ ledger: readonly TaskRun[],
339
+ day: string,
340
+ nowMs: number,
341
+ tz: string,
342
+ taskCatchUpMinutes: number,
343
+ ): TaskBoardRow | null {
344
+ const ledgerRow = latestLedgerRow(ledger, row.id, day)
345
+ const col = column(row, ledgerRow, day, nowMs, tz, taskCatchUpMinutes)
346
+ if (!col) return null
347
+ const mark = flags(row, ledgerRow, day, nowMs, tz, taskCatchUpMinutes)
348
+ const runAt = parseRunAt(row.run_at)
349
+ return {
350
+ id: row.id,
351
+ ref: row.ref,
352
+ domain: row.domain,
353
+ title: row.description.slice(0, 44),
354
+ column: col,
355
+ priority: row.priority,
356
+ ...(runAt ? { runAt: new Date(taskInstant(runAt.day, runAt.minutes, tz)).toISOString() } : {}),
357
+ section: row.section,
358
+ ...(row.section_day ? { sectionDay: row.section_day } : {}),
359
+ ...mark,
360
+ }
361
+ }
362
+
363
+ export async function listBoard(columnFilter?: string, nowMs = Date.now()): Promise<TaskBoardRow[]> {
364
+ if (!pythonBridgeAvailable()) {
365
+ throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
366
+ }
367
+ const { config, clock } = briefContext(nowMs)
368
+ const [rows, ledger] = await Promise.all([
369
+ loadAllRows(clock.day),
370
+ Promise.resolve(loadTaskLedger(taskStorePaths())),
371
+ ])
372
+ return rows
373
+ .map(row => projectRow(row, ledger, clock.day, nowMs, config.timezone, config.taskCatchUpMinutes))
374
+ .filter((row): row is TaskBoardRow => !!row && (!columnFilter || row.column === columnFilter))
375
+ }
376
+
377
+ export function workBadgeCount(rows: readonly TaskBoardRow[]): number {
378
+ const ids = new Set<string>()
379
+ for (const row of rows) {
380
+ if (row.column === 'today' || row.column === 'carried' || row.missed || row.failed) ids.add(row.id)
381
+ }
382
+ return ids.size
383
+ }
384
+
385
+ export async function captureTask(body: {
386
+ domain: string
387
+ text: string
388
+ section: string
389
+ runAt?: string
390
+ captureId?: string
391
+ }, nowMs = Date.now()): Promise<{ ok: true; replayed?: boolean; fell_to_inbox?: boolean; section?: string }> {
392
+ if (!pythonBridgeAvailable()) throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
393
+ if (!body.captureId) throw new TaskRunError(400, 'capture_id_required', 'captureId is required.')
394
+ if (!isClientJobId(body.captureId)) throw new TaskRunError(422, 'invalid_capture_id', 'captureId must be a UUID v4.')
395
+ if (!FULL_DOMAINS.includes(body.domain as TaskDomain)) {
396
+ throw new TaskRunError(400, 'invalid_domain', 'full domain required')
397
+ }
398
+ const paths = taskStorePaths()
399
+ const seen = loadCapturesSeen(paths)
400
+ if (seen.ids.includes(body.captureId)) return { ok: true, replayed: true }
401
+ const { config, clock } = briefContext(nowMs)
402
+ const args = ['task-capture', body.domain, '--section', body.section]
403
+ if (body.runAt) args.push('--run-at', body.runAt)
404
+ if (body.section.startsWith('today:')) {
405
+ args.push('--purge-before', shiftDay(clock.day, -TASK_TODAY_PURGE_HORIZON_DAYS))
406
+ }
407
+ const payload = await withLockRetry(() => bridge(args, body.text)) as { ok?: boolean; fell_to_inbox?: boolean; section?: string }
408
+ rememberCapture(paths, body.captureId, nowMs)
409
+ return { ok: true, fell_to_inbox: payload.fell_to_inbox, section: payload.section }
410
+ }
411
+
412
+ export async function setTaskRunAt(domain: string, id: string, runAt: string | null, nowMs = Date.now()): Promise<void> {
413
+ const { clock } = briefContext(nowMs)
414
+ const row = await findTaskRow(domain, id, clock.day)
415
+ if (!row) throw new TaskBridgeError('task_not_found', `no task ${id} in ${domain}`)
416
+ if (row.agent_state === 'running' || liveLedgerFor(id, clock.day)) {
417
+ throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
418
+ }
419
+ const args = ['task-set-run-at', domain, id, runAt ?? '--clear']
420
+ await withLockRetry(() => bridge(args))
421
+ }
422
+
423
+ export async function moveTask(domain: string, id: string, section: string, nowMs = Date.now()): Promise<void> {
424
+ const { clock } = briefContext(nowMs)
425
+ const args = ['task-move', domain, id, '--section', section]
426
+ if (section.startsWith('today:')) {
427
+ args.push('--purge-before', shiftDay(clock.day, -TASK_TODAY_PURGE_HORIZON_DAYS))
428
+ }
429
+ await withLockRetry(() => bridge(args))
430
+ }
431
+
432
+ export async function checkTask(opts: {
433
+ domain: string
434
+ id?: string
435
+ text?: string
436
+ checked: boolean
437
+ }): Promise<void> {
438
+ const args = opts.id
439
+ ? ['task-check', opts.domain, opts.id]
440
+ : ['task-check', opts.domain, '--text', opts.text ?? '']
441
+ if (!opts.checked) args.push('--uncheck')
442
+ await withLockRetry(() => bridge(args))
443
+ }
444
+
445
+ interface CapturesSeen {
446
+ ids: string[]
447
+ at: Record<string, number>
448
+ }
449
+
450
+ function loadCapturesSeen(paths: TaskStorePaths): CapturesSeen {
451
+ const loaded = loadJsonOrQuarantine<unknown>(paths.capturesSeen)
452
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return { ids: [], at: {} }
453
+ const raw = loaded.data as CapturesSeen
454
+ return { ids: Array.isArray(raw.ids) ? raw.ids : [], at: raw.at && typeof raw.at === 'object' ? raw.at : {} }
455
+ }
456
+
457
+ function rememberCapture(paths: TaskStorePaths, id: string, nowMs: number): void {
458
+ const seen = loadCapturesSeen(paths)
459
+ const cutoff = nowMs - 24 * 60 * 60_000
460
+ const nextIds = [...seen.ids.filter(existing => (seen.at[existing] ?? 0) >= cutoff), id].slice(-500)
461
+ const at: Record<string, number> = {}
462
+ for (const existing of nextIds) at[existing] = existing === id ? nowMs : seen.at[existing] ?? nowMs
463
+ ensurePrivateDir(paths.capturesSeen)
464
+ durableAtomicWriteFileSync(paths.capturesSeen, `${JSON.stringify({ ids: nextIds, at }, null, 2)}\n`, { mode: 0o600 })
465
+ }
466
+
467
+ export function loadDispatchCap(paths = taskStorePaths()): number {
468
+ const loaded = loadJsonOrQuarantine<unknown>(paths.cap)
469
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return TASK_DISPATCH_LIMITS.capPerDay
470
+ const cap = Number((loaded.data as { capPerDay?: unknown }).capPerDay)
471
+ return Number.isSafeInteger(cap) && cap >= 0 ? cap : TASK_DISPATCH_LIMITS.capPerDay
472
+ }
473
+
474
+ export function saveDispatchCap(capPerDay: number, paths = taskStorePaths()): void {
475
+ if (!Number.isSafeInteger(capPerDay) || capPerDay < 0) {
476
+ throw new TaskRunError(400, 'invalid_cap', 'capPerDay must be a non-negative integer.')
477
+ }
478
+ ensurePrivateDir(paths.cap)
479
+ durableAtomicWriteFileSync(paths.cap, `${JSON.stringify({ capPerDay }, null, 2)}\n`, { mode: 0o600 })
480
+ }
481
+
482
+ export interface TaskRunView {
483
+ id: string
484
+ kind: 'task'
485
+ trigger: TaskRun['trigger']
486
+ status: string
487
+ firedAt: string
488
+ completedAt?: string
489
+ globalMsgNum?: number
490
+ title: string
491
+ taskId: string
492
+ jobId?: string
493
+ clientJobId: string
494
+ generation: 1
495
+ messageEra?: string
496
+ sessionId: string
497
+ error?: { code: string; message: string }
498
+ catchUp?: boolean
499
+ }
500
+
501
+ export function projectTaskRun(
502
+ run: TaskRun,
503
+ snapshot?: { status: string; completedAt?: string } | null,
504
+ ): TaskRunView {
505
+ const title = run.line.slice(0, 44)
506
+ const base = {
507
+ id: run.id,
508
+ kind: 'task' as const,
509
+ trigger: run.trigger,
510
+ firedAt: run.firedAt,
511
+ ...(run.completedAt ? { completedAt: run.completedAt } : {}),
512
+ ...(run.globalMsgNum != null ? { globalMsgNum: run.globalMsgNum } : {}),
513
+ title,
514
+ taskId: run.taskId,
515
+ ...(run.jobId ? { jobId: run.jobId } : {}),
516
+ clientJobId: run.clientJobId,
517
+ generation: 1 as const,
518
+ ...(run.messageEra ? { messageEra: run.messageEra } : {}),
519
+ sessionId: run.sessionId,
520
+ ...(run.catchUp ? { catchUp: true } : {}),
521
+ }
522
+ switch (run.status) {
523
+ case 'dispatching':
524
+ return { ...base, status: 'submitting' }
525
+ case 'running':
526
+ return {
527
+ ...base,
528
+ status: snapshot?.status ?? 'running',
529
+ ...(snapshot?.completedAt ? { completedAt: snapshot.completedAt } : {}),
530
+ }
531
+ case 'done':
532
+ return { ...base, status: 'completed' }
533
+ case 'superseded':
534
+ return {
535
+ ...base,
536
+ status: 'canceled',
537
+ error: { code: 'superseded', message: 'Rescheduled before it ran' },
538
+ }
539
+ case 'failed':
540
+ return {
541
+ ...base,
542
+ status: 'failed',
543
+ error: run.error ?? { code: 'failed', message: `Task ${run.status} on the Mac` },
544
+ }
545
+ case 'orphaned':
546
+ return {
547
+ ...base,
548
+ status: 'canceled',
549
+ error: { code: 'orphaned', message: run.error?.message ?? 'Task orphaned on the Mac' },
550
+ }
551
+ }
552
+ }
553
+
554
+ export function listTaskRuns(limit = 20, paths = taskStorePaths()): TaskRun[] {
555
+ return loadTaskLedger(paths).sort((a, b) => b.firedAt.localeCompare(a.firedAt)).slice(0, limit)
556
+ }
557
+
558
+ export async function listProjectedTaskRuns(
559
+ getSnapshot: (jobId: string) => Promise<{ status: string; completedAt?: string } | undefined>,
560
+ limit = 20,
561
+ paths = taskStorePaths(),
562
+ ): Promise<TaskRunView[]> {
563
+ const runs = listTaskRuns(limit, paths)
564
+ return Promise.all(runs.map(async run => {
565
+ const snapshot = run.status === 'running' && run.jobId
566
+ ? await getSnapshot(run.jobId).catch(() => undefined)
567
+ : undefined
568
+ return projectTaskRun(run, snapshot)
569
+ }))
570
+ }
571
+
572
+ export function composeTaskDigest(rows: readonly TaskBoardRow[]): string {
573
+ const today = rows.filter(row => row.column === 'today' || row.column === 'carried').length
574
+ const running = rows.filter(row => row.column === 'running').length
575
+ const scheduled = rows.filter(row => row.column === 'scheduled').length
576
+ const inbox = rows.filter(row => row.column === 'inbox').length
577
+ const missed = rows.filter(row => row.missed).length
578
+ const failed = rows.filter(row => row.failed).length
579
+ const clamp = (n: number) => (n > 99 ? '99+' : String(n))
580
+ const lines = [`TASKS Today ${clamp(today)} · Run ${clamp(running)} · Sched ${clamp(scheduled)} · Inbox ${clamp(inbox)}`]
581
+ if (missed > 0 || failed > 0) lines.push(`Missed ${clamp(missed)} · Failed ${clamp(failed)}`)
582
+ return lines.join('\n')
583
+ }
584
+
585
+ export function composeTaskDispatchPrompt(line: string, day: string, tz: string): string {
586
+ return [
587
+ `Scheduled task for ${day} (${tz}).`,
588
+ '',
589
+ 'Do this work from what is already on this Mac. Read-only: do not send messages, edit files, or change calendar events.',
590
+ '',
591
+ `Task: ${line}`,
592
+ '',
593
+ 'Reply with a short status the wearer can read on glasses.',
594
+ ].join('\n')
595
+ }
596
+
597
+ export function liveTaskReservations(era: string, nowMs = Date.now(), paths = taskStorePaths()) {
598
+ const floor = nowMs - 24 * 60 * 60_000
599
+ const out: Array<{ globalMsgNum: number; messageEra: string; owner: string }> = []
600
+ for (const run of loadTaskLedger(paths)) {
601
+ if (typeof run.globalMsgNum !== 'number') continue
602
+ if (era !== run.messageEra) continue
603
+ const fired = Date.parse(run.firedAt)
604
+ if (!Number.isFinite(fired) || fired < floor) continue
605
+ out.push({ globalMsgNum: run.globalMsgNum, messageEra: run.messageEra ?? era, owner: `task:${run.taskId}` })
606
+ }
607
+ return out
608
+ }
609
+
610
+ export async function setTaskMarker(domain: string, id: string, marker: string | null): Promise<void> {
611
+ const args = ['task-set-marker', domain, id, marker ?? '--clear']
612
+ await withLockRetry(() => bridge(args))
613
+ }
614
+
615
+ export async function findTaskRow(domain: string, id: string, day: string): Promise<BridgeTaskRow | undefined> {
616
+ const rows = await loadDomainRows(domain as TaskDomain, day)
617
+ return rows.find(row => row.id === id)
618
+ }
619
+
620
+ export function liveLedgerFor(taskId: string, day: string, paths = taskStorePaths()): TaskRun | undefined {
621
+ return loadTaskLedger(paths).find(run =>
622
+ run.taskId === taskId && run.day === day && (run.status === 'dispatching' || run.status === 'running'),
623
+ )
624
+ }
@@ -3,7 +3,7 @@ import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
3
3
  import { claudeSessionsEnabled } from './claude-sessions.js'
4
4
  import { statSync } from 'node:fs'
5
5
  import { resolve } from 'node:path'
6
- import { COS_SCRIPTS_DIR, COS_MODE } from '../lib/python-bridge.js'
6
+ import { COS_SCRIPTS_DIR, COS_MODE, pythonBridgeAvailable } from '../lib/python-bridge.js'
7
7
  import { serverMetrics } from '../lib/server-metrics.js'
8
8
  import { getServerInstanceId } from '../lib/server-instance-id.js'
9
9
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
@@ -102,6 +102,7 @@ function durableQueryJobStatus() {
102
102
  subscribers: runtime.store.subscribers,
103
103
  malformedRows: runtime.store.malformedRows,
104
104
  originDropped: runtime.store.originDropped,
105
+ originStripped: runtime.store.originStripped,
105
106
  fingerprintMismatches: runtime.store.fingerprintMismatches,
106
107
  evictedHydratedJobs: runtime.store.evictedHydratedJobs,
107
108
  journalFailures: runtime.store.journalFailures,
@@ -423,6 +424,9 @@ healthRouter.get('/health', async (_req, res) => {
423
424
  },
424
425
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
425
426
  ...(morningBrief ? { morningBrief } : {}),
427
+ tasks: {
428
+ gate: pythonBridgeAvailable() ? 'ready' : 'disabled',
429
+ },
426
430
  },
427
431
  // /api/health is intentionally unauthenticated for setup diagnostics.
428
432
  // Publish capability only; job counts, retention identities, subscriber
@@ -555,6 +559,9 @@ healthRouter.get('/models', async (req, res) => {
555
559
  },
556
560
  },
557
561
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
562
+ tasks: {
563
+ gate: pythonBridgeAvailable() ? 'ready' : 'disabled',
564
+ },
558
565
  },
559
566
  })
560
567
  })
@@ -270,7 +270,7 @@ export function createQueryJobsRouter(
270
270
  try {
271
271
  const jobId = validJobId(req.params.jobId)
272
272
  const generation = requiredGeneration(req.body?.generation)
273
- const job = await coordinator.cancel(jobId, generation)
273
+ const { job } = await coordinator.cancel(jobId, generation)
274
274
  return res.json({ job })
275
275
  } catch (error) {
276
276
  const wire = wireError(error)