@gotcos/glasses-server 6.43.4 → 6.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,652 @@
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
+ /** Board and lens titles come from a raw tasks.md line, which carries markdown
337
+ * emphasis and is usually far longer than a row. Slicing the raw line cut words
338
+ * and left unbalanced `**` on 119 of 201 live rows, so a row read
339
+ * "**Provide data migration process docs by ind". Strip the markup first (which
340
+ * buys back the four characters the asterisks were spending), then cut on a word
341
+ * boundary. The cap is exported so tests pin the real value rather than a copy. */
342
+ export const TASK_TITLE_MAX = 44
343
+
344
+ export function taskTitle(line: string): string {
345
+ const plain = line
346
+ .replace(/`([^`]*)`/g, '$1')
347
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
348
+ // Underscore emphasis must be anchored: a bare `_` between word characters is
349
+ // an identifier (cos_python, hermit_crabs), not markup, and stripping it
350
+ // corrupts the row. Asterisks need no such care, so one catch-all below clears
351
+ // them whether the pair is balanced or not — and after a slice, it often is not.
352
+ .replace(/(^|[\s(])_([^_]+)_(?=[\s).,;:!?]|$)/g, '$1$2')
353
+ .replace(/\*+/g, '')
354
+ .replace(/\s+/g, ' ')
355
+ .trim()
356
+ if (plain.length <= TASK_TITLE_MAX) return plain
357
+ const cut = plain.slice(0, TASK_TITLE_MAX - 1)
358
+ const space = cut.lastIndexOf(' ')
359
+ // Only honour a word boundary that is not so early it throws the title away.
360
+ const body = space > TASK_TITLE_MAX * 0.6 ? cut.slice(0, space) : cut
361
+ return `${body.replace(/[\s\u2014\u2013,;:.-]+$/, '')}\u2026`
362
+ }
363
+
364
+ export function projectRow(
365
+ row: BridgeTaskRow,
366
+ ledger: readonly TaskRun[],
367
+ day: string,
368
+ nowMs: number,
369
+ tz: string,
370
+ taskCatchUpMinutes: number,
371
+ ): TaskBoardRow | null {
372
+ const ledgerRow = latestLedgerRow(ledger, row.id, day)
373
+ const col = column(row, ledgerRow, day, nowMs, tz, taskCatchUpMinutes)
374
+ if (!col) return null
375
+ const mark = flags(row, ledgerRow, day, nowMs, tz, taskCatchUpMinutes)
376
+ const runAt = parseRunAt(row.run_at)
377
+ return {
378
+ id: row.id,
379
+ ref: row.ref,
380
+ domain: row.domain,
381
+ title: taskTitle(row.description),
382
+ column: col,
383
+ priority: row.priority,
384
+ ...(runAt ? { runAt: new Date(taskInstant(runAt.day, runAt.minutes, tz)).toISOString() } : {}),
385
+ section: row.section,
386
+ ...(row.section_day ? { sectionDay: row.section_day } : {}),
387
+ ...mark,
388
+ }
389
+ }
390
+
391
+ export async function listBoard(columnFilter?: string, nowMs = Date.now()): Promise<TaskBoardRow[]> {
392
+ if (!pythonBridgeAvailable()) {
393
+ throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
394
+ }
395
+ const { config, clock } = briefContext(nowMs)
396
+ const [rows, ledger] = await Promise.all([
397
+ loadAllRows(clock.day),
398
+ Promise.resolve(loadTaskLedger(taskStorePaths())),
399
+ ])
400
+ return rows
401
+ .map(row => projectRow(row, ledger, clock.day, nowMs, config.timezone, config.taskCatchUpMinutes))
402
+ .filter((row): row is TaskBoardRow => !!row && (!columnFilter || row.column === columnFilter))
403
+ }
404
+
405
+ export function workBadgeCount(rows: readonly TaskBoardRow[]): number {
406
+ const ids = new Set<string>()
407
+ for (const row of rows) {
408
+ if (row.column === 'today' || row.column === 'carried' || row.missed || row.failed) ids.add(row.id)
409
+ }
410
+ return ids.size
411
+ }
412
+
413
+ export async function captureTask(body: {
414
+ domain: string
415
+ text: string
416
+ section: string
417
+ runAt?: string
418
+ captureId?: string
419
+ }, nowMs = Date.now()): Promise<{ ok: true; replayed?: boolean; fell_to_inbox?: boolean; section?: string }> {
420
+ if (!pythonBridgeAvailable()) throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
421
+ if (!body.captureId) throw new TaskRunError(400, 'capture_id_required', 'captureId is required.')
422
+ if (!isClientJobId(body.captureId)) throw new TaskRunError(422, 'invalid_capture_id', 'captureId must be a UUID v4.')
423
+ if (!FULL_DOMAINS.includes(body.domain as TaskDomain)) {
424
+ throw new TaskRunError(400, 'invalid_domain', 'full domain required')
425
+ }
426
+ const paths = taskStorePaths()
427
+ const seen = loadCapturesSeen(paths)
428
+ if (seen.ids.includes(body.captureId)) return { ok: true, replayed: true }
429
+ const { config, clock } = briefContext(nowMs)
430
+ const args = ['task-capture', body.domain, '--section', body.section]
431
+ if (body.runAt) args.push('--run-at', body.runAt)
432
+ if (body.section.startsWith('today:')) {
433
+ args.push('--purge-before', shiftDay(clock.day, -TASK_TODAY_PURGE_HORIZON_DAYS))
434
+ }
435
+ const payload = await withLockRetry(() => bridge(args, body.text)) as { ok?: boolean; fell_to_inbox?: boolean; section?: string }
436
+ rememberCapture(paths, body.captureId, nowMs)
437
+ return { ok: true, fell_to_inbox: payload.fell_to_inbox, section: payload.section }
438
+ }
439
+
440
+ export async function setTaskRunAt(domain: string, id: string, runAt: string | null, nowMs = Date.now()): Promise<void> {
441
+ const { clock } = briefContext(nowMs)
442
+ const row = await findTaskRow(domain, id, clock.day)
443
+ if (!row) throw new TaskBridgeError('task_not_found', `no task ${id} in ${domain}`)
444
+ if (row.agent_state === 'running' || liveLedgerFor(id, clock.day)) {
445
+ throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
446
+ }
447
+ const args = ['task-set-run-at', domain, id, runAt ?? '--clear']
448
+ await withLockRetry(() => bridge(args))
449
+ }
450
+
451
+ export async function moveTask(domain: string, id: string, section: string, nowMs = Date.now()): Promise<void> {
452
+ const { clock } = briefContext(nowMs)
453
+ const args = ['task-move', domain, id, '--section', section]
454
+ if (section.startsWith('today:')) {
455
+ args.push('--purge-before', shiftDay(clock.day, -TASK_TODAY_PURGE_HORIZON_DAYS))
456
+ }
457
+ await withLockRetry(() => bridge(args))
458
+ }
459
+
460
+ export async function checkTask(opts: {
461
+ domain: string
462
+ id?: string
463
+ text?: string
464
+ checked: boolean
465
+ }): Promise<void> {
466
+ const args = opts.id
467
+ ? ['task-check', opts.domain, opts.id]
468
+ : ['task-check', opts.domain, '--text', opts.text ?? '']
469
+ if (!opts.checked) args.push('--uncheck')
470
+ await withLockRetry(() => bridge(args))
471
+ }
472
+
473
+ interface CapturesSeen {
474
+ ids: string[]
475
+ at: Record<string, number>
476
+ }
477
+
478
+ function loadCapturesSeen(paths: TaskStorePaths): CapturesSeen {
479
+ const loaded = loadJsonOrQuarantine<unknown>(paths.capturesSeen)
480
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return { ids: [], at: {} }
481
+ const raw = loaded.data as CapturesSeen
482
+ return { ids: Array.isArray(raw.ids) ? raw.ids : [], at: raw.at && typeof raw.at === 'object' ? raw.at : {} }
483
+ }
484
+
485
+ function rememberCapture(paths: TaskStorePaths, id: string, nowMs: number): void {
486
+ const seen = loadCapturesSeen(paths)
487
+ const cutoff = nowMs - 24 * 60 * 60_000
488
+ const nextIds = [...seen.ids.filter(existing => (seen.at[existing] ?? 0) >= cutoff), id].slice(-500)
489
+ const at: Record<string, number> = {}
490
+ for (const existing of nextIds) at[existing] = existing === id ? nowMs : seen.at[existing] ?? nowMs
491
+ ensurePrivateDir(paths.capturesSeen)
492
+ durableAtomicWriteFileSync(paths.capturesSeen, `${JSON.stringify({ ids: nextIds, at }, null, 2)}\n`, { mode: 0o600 })
493
+ }
494
+
495
+ export function loadDispatchCap(paths = taskStorePaths()): number {
496
+ const loaded = loadJsonOrQuarantine<unknown>(paths.cap)
497
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return TASK_DISPATCH_LIMITS.capPerDay
498
+ const cap = Number((loaded.data as { capPerDay?: unknown }).capPerDay)
499
+ return Number.isSafeInteger(cap) && cap >= 0 ? cap : TASK_DISPATCH_LIMITS.capPerDay
500
+ }
501
+
502
+ export function saveDispatchCap(capPerDay: number, paths = taskStorePaths()): void {
503
+ if (!Number.isSafeInteger(capPerDay) || capPerDay < 0) {
504
+ throw new TaskRunError(400, 'invalid_cap', 'capPerDay must be a non-negative integer.')
505
+ }
506
+ ensurePrivateDir(paths.cap)
507
+ durableAtomicWriteFileSync(paths.cap, `${JSON.stringify({ capPerDay }, null, 2)}\n`, { mode: 0o600 })
508
+ }
509
+
510
+ export interface TaskRunView {
511
+ id: string
512
+ kind: 'task'
513
+ trigger: TaskRun['trigger']
514
+ status: string
515
+ firedAt: string
516
+ completedAt?: string
517
+ globalMsgNum?: number
518
+ title: string
519
+ taskId: string
520
+ jobId?: string
521
+ clientJobId: string
522
+ generation: 1
523
+ messageEra?: string
524
+ sessionId: string
525
+ error?: { code: string; message: string }
526
+ catchUp?: boolean
527
+ }
528
+
529
+ export function projectTaskRun(
530
+ run: TaskRun,
531
+ snapshot?: { status: string; completedAt?: string } | null,
532
+ ): TaskRunView {
533
+ const title = taskTitle(run.line)
534
+ const base = {
535
+ id: run.id,
536
+ kind: 'task' as const,
537
+ trigger: run.trigger,
538
+ firedAt: run.firedAt,
539
+ ...(run.completedAt ? { completedAt: run.completedAt } : {}),
540
+ ...(run.globalMsgNum != null ? { globalMsgNum: run.globalMsgNum } : {}),
541
+ title,
542
+ taskId: run.taskId,
543
+ ...(run.jobId ? { jobId: run.jobId } : {}),
544
+ clientJobId: run.clientJobId,
545
+ generation: 1 as const,
546
+ ...(run.messageEra ? { messageEra: run.messageEra } : {}),
547
+ sessionId: run.sessionId,
548
+ ...(run.catchUp ? { catchUp: true } : {}),
549
+ }
550
+ switch (run.status) {
551
+ case 'dispatching':
552
+ return { ...base, status: 'submitting' }
553
+ case 'running':
554
+ return {
555
+ ...base,
556
+ status: snapshot?.status ?? 'running',
557
+ ...(snapshot?.completedAt ? { completedAt: snapshot.completedAt } : {}),
558
+ }
559
+ case 'done':
560
+ return { ...base, status: 'completed' }
561
+ case 'superseded':
562
+ return {
563
+ ...base,
564
+ status: 'canceled',
565
+ error: { code: 'superseded', message: 'Rescheduled before it ran' },
566
+ }
567
+ case 'failed':
568
+ return {
569
+ ...base,
570
+ status: 'failed',
571
+ error: run.error ?? { code: 'failed', message: `Task ${run.status} on the Mac` },
572
+ }
573
+ case 'orphaned':
574
+ return {
575
+ ...base,
576
+ status: 'canceled',
577
+ error: { code: 'orphaned', message: run.error?.message ?? 'Task orphaned on the Mac' },
578
+ }
579
+ }
580
+ }
581
+
582
+ export function listTaskRuns(limit = 20, paths = taskStorePaths()): TaskRun[] {
583
+ return loadTaskLedger(paths).sort((a, b) => b.firedAt.localeCompare(a.firedAt)).slice(0, limit)
584
+ }
585
+
586
+ export async function listProjectedTaskRuns(
587
+ getSnapshot: (jobId: string) => Promise<{ status: string; completedAt?: string } | undefined>,
588
+ limit = 20,
589
+ paths = taskStorePaths(),
590
+ ): Promise<TaskRunView[]> {
591
+ const runs = listTaskRuns(limit, paths)
592
+ return Promise.all(runs.map(async run => {
593
+ const snapshot = run.status === 'running' && run.jobId
594
+ ? await getSnapshot(run.jobId).catch(() => undefined)
595
+ : undefined
596
+ return projectTaskRun(run, snapshot)
597
+ }))
598
+ }
599
+
600
+ export function composeTaskDigest(rows: readonly TaskBoardRow[]): string {
601
+ const today = rows.filter(row => row.column === 'today' || row.column === 'carried').length
602
+ const running = rows.filter(row => row.column === 'running').length
603
+ const scheduled = rows.filter(row => row.column === 'scheduled').length
604
+ const inbox = rows.filter(row => row.column === 'inbox').length
605
+ const missed = rows.filter(row => row.missed).length
606
+ const failed = rows.filter(row => row.failed).length
607
+ const clamp = (n: number) => (n > 99 ? '99+' : String(n))
608
+ const lines = [`TASKS Today ${clamp(today)} · Run ${clamp(running)} · Sched ${clamp(scheduled)} · Inbox ${clamp(inbox)}`]
609
+ if (missed > 0 || failed > 0) lines.push(`Missed ${clamp(missed)} · Failed ${clamp(failed)}`)
610
+ return lines.join('\n')
611
+ }
612
+
613
+ export function composeTaskDispatchPrompt(line: string, day: string, tz: string): string {
614
+ return [
615
+ `Scheduled task for ${day} (${tz}).`,
616
+ '',
617
+ 'Do this work from what is already on this Mac. Read-only: do not send messages, edit files, or change calendar events.',
618
+ '',
619
+ `Task: ${line}`,
620
+ '',
621
+ 'Reply with a short status the wearer can read on glasses.',
622
+ ].join('\n')
623
+ }
624
+
625
+ export function liveTaskReservations(era: string, nowMs = Date.now(), paths = taskStorePaths()) {
626
+ const floor = nowMs - 24 * 60 * 60_000
627
+ const out: Array<{ globalMsgNum: number; messageEra: string; owner: string }> = []
628
+ for (const run of loadTaskLedger(paths)) {
629
+ if (typeof run.globalMsgNum !== 'number') continue
630
+ if (era !== run.messageEra) continue
631
+ const fired = Date.parse(run.firedAt)
632
+ if (!Number.isFinite(fired) || fired < floor) continue
633
+ out.push({ globalMsgNum: run.globalMsgNum, messageEra: run.messageEra ?? era, owner: `task:${run.taskId}` })
634
+ }
635
+ return out
636
+ }
637
+
638
+ export async function setTaskMarker(domain: string, id: string, marker: string | null): Promise<void> {
639
+ const args = ['task-set-marker', domain, id, marker ?? '--clear']
640
+ await withLockRetry(() => bridge(args))
641
+ }
642
+
643
+ export async function findTaskRow(domain: string, id: string, day: string): Promise<BridgeTaskRow | undefined> {
644
+ const rows = await loadDomainRows(domain as TaskDomain, day)
645
+ return rows.find(row => row.id === id)
646
+ }
647
+
648
+ export function liveLedgerFor(taskId: string, day: string, paths = taskStorePaths()): TaskRun | undefined {
649
+ return loadTaskLedger(paths).find(run =>
650
+ run.taskId === taskId && run.day === day && (run.status === 'dispatching' || run.status === 'running'),
651
+ )
652
+ }
@@ -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)