@gotcos/glasses-server 6.81.0 → 6.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/docs/work-orchestrator.md +184 -0
  3. package/package.json +6 -1
  4. package/server/index.ts +19 -0
  5. package/server/lib/claude-bridge.ts +10 -1
  6. package/server/lib/codex-bridge.ts +1 -1
  7. package/server/lib/cursor-bridge.ts +1 -1
  8. package/server/lib/direct-bridge.ts +1 -1
  9. package/server/lib/morning-brief-schedule.ts +13 -7
  10. package/server/lib/query-job-coordinator.ts +5 -1
  11. package/server/lib/query-job-runtime.ts +3 -0
  12. package/server/lib/routines-config.ts +421 -0
  13. package/server/lib/routines-registry.ts +151 -0
  14. package/server/lib/routines-runtime.ts +64 -0
  15. package/server/lib/routines-scheduler.ts +509 -0
  16. package/server/lib/work-chat.ts +76 -9
  17. package/server/lib/work-lens-rounds.ts +445 -0
  18. package/server/lib/work-orchestrator/discovery.ts +99 -0
  19. package/server/lib/work-orchestrator/input.ts +109 -0
  20. package/server/lib/work-orchestrator/pickup.ts +85 -0
  21. package/server/lib/work-orchestrator/plan-log.ts +38 -0
  22. package/server/lib/work-orchestrator/redact.ts +41 -0
  23. package/server/lib/work-orchestrator/runtime.ts +74 -0
  24. package/server/lib/work-orchestrator/session-log.ts +114 -0
  25. package/server/lib/work-orchestrator/snapshot.ts +87 -0
  26. package/server/lib/work-orchestrator/tag-index.ts +272 -0
  27. package/server/lib/work-orchestrator/tags.ts +52 -0
  28. package/server/lib/work-orchestrator/usage-events.ts +30 -0
  29. package/server/lib/work-orchestrator/usage.ts +341 -0
  30. package/server/routes/routines.ts +101 -0
  31. package/server/routes/work-chat.ts +54 -11
  32. package/server/routes/work-lens-rounds.ts +179 -0
  33. package/server/routes/work-orchestrator.ts +71 -0
  34. package/server/scripts/work-lens-rounds-mutation-gate.mjs +151 -0
@@ -0,0 +1,509 @@
1
+ // 6.82.0 Routines: the skill-routine scheduler (routine #2 onward; the morning brief keeps its own).
2
+ //
3
+ // One 30-second tick, unref'd. Each tick (1) reads back the outcome of every run still open and
4
+ // feeds the breaker, then (2) asks the brief's own pure decision table whether each routine's slot
5
+ // has arrived, and fires by submitting a durable query job to the same coordinator every phone
6
+ // prompt uses. From there a run is an ordinary job: it survives the phone sleeping, lands in the
7
+ // inbox with a message number, and carries `origin: { kind: 'routine', id }`.
8
+ //
9
+ // How it stops: see routines-config.ts. In short: switch, master switch, per-routine daily cap,
10
+ // server-wide ceiling, breaker with cooldown, one slot per day, bounded submit attempts.
11
+
12
+ import { randomUUID } from 'node:crypto'
13
+ import {
14
+ ROUTINE_LIMITS,
15
+ RoutineConfigError,
16
+ applyRoutinePatch,
17
+ composeSkillRoutinePrompt,
18
+ loadRoutinesFile,
19
+ loadRoutinesLedger,
20
+ newRoutineId,
21
+ routineScheduledClientJobId,
22
+ routinesEnabledByEnv,
23
+ saveRoutinesFile,
24
+ saveRoutinesLedger,
25
+ type RoutineBreakerState,
26
+ type RoutineRun,
27
+ type RoutineStorePaths,
28
+ type RoutineTrigger,
29
+ type RoutinesFile,
30
+ type RoutinesLedger,
31
+ type SkillRoutine,
32
+ } from './routines-config.js'
33
+ import { decideScheduledFire, localClock, nextScheduledFire } from './morning-brief-schedule.js'
34
+ import type { MessageReservation } from './message-reservations.js'
35
+ import type { QueryJobSnapshot } from './query-job-types.js'
36
+ import { isTerminalQueryJobStatus } from './query-job-types.js'
37
+
38
+ export interface RoutineSubmission {
39
+ job: Pick<QueryJobSnapshot, 'jobId' | 'clientJobId' | 'generation' | 'status' | 'sessionId'>
40
+ created: boolean
41
+ }
42
+
43
+ export interface RoutineSchedulerDeps {
44
+ paths: RoutineStorePaths
45
+ submit: (request: Record<string, unknown>) => Promise<RoutineSubmission>
46
+ findByClientGeneration: (clientJobId: string, generation: number) => Promise<QueryJobSnapshot | undefined>
47
+ getSnapshot: (jobId: string) => Promise<QueryJobSnapshot>
48
+ createSession: () => string
49
+ currentMessageEra: () => string
50
+ currentMessageMax: () => number
51
+ ownerName: () => string
52
+ durableJobsEnabled: () => boolean
53
+ admissionsOpen: () => boolean
54
+ /** True when the workspace skill catalog has this skill. */
55
+ skillExists: (name: string) => boolean
56
+ /** Master switch. Defaults to COS_ROUTINES (only an explicit 0/false/off/no turns it off). */
57
+ masterEnabled?: () => boolean
58
+ now?: () => number
59
+ tickMs?: number
60
+ log?: (line: string) => void
61
+ }
62
+
63
+ export class RoutineRunError extends Error {
64
+ constructor(readonly status: number, readonly code: string, message: string, readonly detail: Record<string, unknown> = {}) {
65
+ super(message)
66
+ this.name = 'RoutineRunError'
67
+ }
68
+ }
69
+
70
+ export type RoutineGate = 'ready' | 'routines_off' | 'durable_jobs_off' | 'admissions_closed' | 'disabled' | 'breaker_open' | 'skill_missing'
71
+
72
+ export interface RoutineRunView extends RoutineRun {
73
+ status: string
74
+ completedAt?: string
75
+ error?: { code: string; message: string }
76
+ preview?: string
77
+ }
78
+
79
+ export interface RoutineBreakerView {
80
+ state: 'closed' | 'open' | 'half_open'
81
+ consecutiveFailures: number
82
+ failures: number
83
+ cooldownMinutes: number
84
+ openUntil?: string
85
+ lastFailure?: { code: string; message: string; at: string }
86
+ }
87
+
88
+ export interface SkillRoutineView extends SkillRoutine {
89
+ nextRunAt: string | null
90
+ gate: RoutineGate
91
+ skillFound: boolean
92
+ breakerState: RoutineBreakerView
93
+ today: { day: string; runs: number; cap: number }
94
+ lastRun: RoutineRunView | null
95
+ }
96
+
97
+ export type RoutineTickResult = { fired: string[]; skipped: Record<string, string>; reason?: string }
98
+
99
+ const FAILED_STATUSES = new Set(['failed', 'interrupted'])
100
+
101
+ function errorParts(error: unknown): { code: string; message: string } {
102
+ const code = typeof (error as { code?: unknown })?.code === 'string' ? (error as { code: string }).code : 'submit_failed'
103
+ const message = error instanceof Error ? error.message : String(error)
104
+ return { code: code.slice(0, 64), message: message.slice(0, 300) }
105
+ }
106
+
107
+ export class RoutineScheduler {
108
+ private file: RoutinesFile
109
+ private ledger: RoutinesLedger
110
+ private timer: ReturnType<typeof setInterval> | null = null
111
+ private serial: Promise<unknown> = Promise.resolve()
112
+ private tickInFlight: Promise<RoutineTickResult> | null = null
113
+ private readonly now: () => number
114
+ private readonly log: (line: string) => void
115
+ private readonly masterEnabled: () => boolean
116
+ readonly quarantinedFile?: string
117
+
118
+ constructor(private readonly deps: RoutineSchedulerDeps) {
119
+ this.now = deps.now ?? Date.now
120
+ this.log = deps.log ?? (line => console.log(`[routines] ${line}`))
121
+ this.masterEnabled = deps.masterEnabled ?? (() => routinesEnabledByEnv())
122
+ const loaded = loadRoutinesFile(deps.paths)
123
+ this.file = loaded.file
124
+ this.quarantinedFile = loaded.quarantinedAs
125
+ this.ledger = loadRoutinesLedger(deps.paths)
126
+ }
127
+
128
+ start(): void {
129
+ if (this.timer) return
130
+ const tickMs = Math.max(1_000, this.deps.tickMs ?? 30_000)
131
+ this.timer = setInterval(() => {
132
+ this.tick().catch(error => this.log(`tick failed: ${error instanceof Error ? error.message : String(error)}`))
133
+ }, tickMs)
134
+ this.timer.unref?.()
135
+ this.log(`${this.file.routines.length} skill routine(s)${this.masterEnabled() ? '' : ' · off by COS_ROUTINES'}`)
136
+ }
137
+
138
+ stop(): void {
139
+ if (this.timer) clearInterval(this.timer)
140
+ this.timer = null
141
+ }
142
+
143
+ private serialize<T>(fn: () => Promise<T>): Promise<T> {
144
+ const next = this.serial.then(fn, fn)
145
+ this.serial = next.then(() => undefined, () => undefined)
146
+ return next
147
+ }
148
+
149
+ // ── Config ──────────────────────────────────────────────────────────────
150
+
151
+ routines(): SkillRoutine[] {
152
+ return structuredClone(this.file.routines)
153
+ }
154
+
155
+ private find(id: string): SkillRoutine {
156
+ const routine = this.file.routines.find(r => r.id === id)
157
+ if (!routine) throw new RoutineConfigError('routine_not_found', 'That routine does not exist.', 404)
158
+ return routine
159
+ }
160
+
161
+ private persist(): void {
162
+ saveRoutinesFile(this.deps.paths, this.file)
163
+ }
164
+
165
+ create(body: unknown): Promise<SkillRoutine> {
166
+ return this.serialize(async () => {
167
+ if (this.file.routines.length >= ROUTINE_LIMITS.maxRoutines) {
168
+ throw new RoutineConfigError('too_many_routines', `A server holds at most ${ROUTINE_LIMITS.maxRoutines} skill routines.`, 409)
169
+ }
170
+ const routine = applyRoutinePatch(null, body, { now: new Date(this.now()), skillExists: this.deps.skillExists })
171
+ while (this.file.routines.some(r => r.id === routine.id)) routine.id = newRoutineId()
172
+ this.file.routines.push(routine)
173
+ this.persist()
174
+ this.log(`created ${routine.id} (/${routine.skill}) · ${routine.enabled ? `${routine.time} ${routine.timezone}` : 'off'}`)
175
+ return structuredClone(routine)
176
+ })
177
+ }
178
+
179
+ update(id: string, body: unknown): Promise<SkillRoutine> {
180
+ return this.serialize(async () => {
181
+ const current = this.find(id)
182
+ const patch = body && typeof body === 'object' && !Array.isArray(body) ? { ...(body as Record<string, unknown>) } : body
183
+ const reset = patch && typeof patch === 'object' && (patch as Record<string, unknown>).resetBreaker === true
184
+ if (patch && typeof patch === 'object') delete (patch as Record<string, unknown>).resetBreaker
185
+ const next = applyRoutinePatch(current, patch, { now: new Date(this.now()), skillExists: this.deps.skillExists })
186
+ next.id = current.id
187
+ next.createdAt = current.createdAt
188
+ this.file.routines = this.file.routines.map(r => r.id === id ? next : r)
189
+ if (reset) {
190
+ delete this.file.breakers[id]
191
+ this.log(`breaker reset for ${id}`)
192
+ }
193
+ this.persist()
194
+ return structuredClone(next)
195
+ })
196
+ }
197
+
198
+ remove(id: string): Promise<void> {
199
+ return this.serialize(async () => {
200
+ this.find(id)
201
+ this.file.routines = this.file.routines.filter(r => r.id !== id)
202
+ delete this.file.breakers[id]
203
+ this.persist()
204
+ this.log(`deleted ${id}`)
205
+ })
206
+ }
207
+
208
+ // ── Breaker ─────────────────────────────────────────────────────────────
209
+
210
+ private breaker(id: string): RoutineBreakerState {
211
+ return this.file.breakers[id] ?? { consecutiveFailures: 0 }
212
+ }
213
+
214
+ breakerOpen(id: string): boolean {
215
+ const until = this.breaker(id).openUntil
216
+ return !!until && Date.parse(until) > this.now()
217
+ }
218
+
219
+ private breakerView(routine: SkillRoutine): RoutineBreakerView {
220
+ const state = this.breaker(routine.id)
221
+ const open = this.breakerOpen(routine.id)
222
+ return {
223
+ state: open ? 'open' : state.consecutiveFailures >= routine.breaker.failures ? 'half_open' : 'closed',
224
+ consecutiveFailures: state.consecutiveFailures,
225
+ failures: routine.breaker.failures,
226
+ cooldownMinutes: routine.breaker.cooldownMinutes,
227
+ ...(open && state.openUntil ? { openUntil: state.openUntil } : {}),
228
+ ...(state.lastFailure ? { lastFailure: state.lastFailure } : {}),
229
+ }
230
+ }
231
+
232
+ /** Count one finished run. Success closes the breaker; a failure counts toward opening it. */
233
+ private recordOutcome(routineId: string, ok: boolean, failure?: { code: string; message: string }): void {
234
+ const routine = this.file.routines.find(r => r.id === routineId)
235
+ if (!routine) return
236
+ const prior = this.breaker(routineId)
237
+ if (ok) {
238
+ this.file.breakers[routineId] = { consecutiveFailures: 0, ...(prior.lastFailure ? { lastFailure: prior.lastFailure } : {}) }
239
+ } else {
240
+ const at = new Date(this.now()).toISOString()
241
+ const next: RoutineBreakerState = {
242
+ consecutiveFailures: prior.consecutiveFailures + 1,
243
+ lastFailure: { code: failure?.code ?? 'failed', message: failure?.message ?? '', at },
244
+ }
245
+ if (next.consecutiveFailures >= routine.breaker.failures) {
246
+ next.openedAt = at
247
+ next.openUntil = new Date(this.now() + routine.breaker.cooldownMinutes * 60_000).toISOString()
248
+ this.log(`breaker OPEN for ${routineId} after ${next.consecutiveFailures} failed runs · until ${next.openUntil}`)
249
+ }
250
+ this.file.breakers[routineId] = next
251
+ }
252
+ try { this.persist() } catch (error) { this.log(`breaker write failed: ${(error as Error).message}`) }
253
+ }
254
+
255
+ // ── Ledger ──────────────────────────────────────────────────────────────
256
+
257
+ private replaceRun(run: RoutineRun): void {
258
+ const index = this.ledger.runs.findIndex(existing => existing.id === run.id)
259
+ if (index >= 0) this.ledger.runs[index] = run
260
+ else this.ledger.runs.push(run)
261
+ if (this.ledger.runs.length > ROUTINE_LIMITS.retainedRuns) this.ledger.runs = this.ledger.runs.slice(-ROUTINE_LIMITS.retainedRuns)
262
+ try { saveRoutinesLedger(this.deps.paths, this.ledger) } catch (error) { this.log(`ledger write failed: ${(error as Error).message}`) }
263
+ }
264
+
265
+ private runsFor(id: string): RoutineRun[] {
266
+ return this.ledger.runs.filter(run => run.routineId === id)
267
+ }
268
+
269
+ /** Admitted runs today in the routine's own zone, both triggers. A failed submit never ran and is not counted. */
270
+ private admittedToday(routine: SkillRoutine): number {
271
+ const day = localClock(this.now(), routine.timezone).day
272
+ return this.runsFor(routine.id).filter(run => run.day === day && run.jobId).length
273
+ }
274
+
275
+ /** Admitted runs across every skill routine in the trailing 24 hours (the server-wide ceiling). */
276
+ private admittedLastDay(): number {
277
+ const floor = this.now() - 24 * 60 * 60_000
278
+ return this.ledger.runs.filter(run => run.jobId && Date.parse(run.firedAt) >= floor).length
279
+ }
280
+
281
+ /** Read back every open run; a terminal one is counted by the breaker exactly once. */
282
+ private async reconcile(): Promise<void> {
283
+ for (const run of [...this.ledger.runs]) {
284
+ if (run.outcome || !run.jobId) continue
285
+ const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
286
+ if (!snapshot || !isTerminalQueryJobStatus(snapshot.status)) continue
287
+ const outcome: RoutineRun['outcome'] = snapshot.status === 'completed' ? 'completed' : snapshot.status === 'canceled' ? 'canceled' : 'failed'
288
+ this.replaceRun({ ...run, lastKnownStatus: snapshot.status, outcome })
289
+ if (outcome === 'canceled') continue
290
+ this.recordOutcome(run.routineId, outcome === 'completed', snapshot.error
291
+ ? { code: snapshot.error.code, message: snapshot.error.message }
292
+ : FAILED_STATUSES.has(snapshot.status) ? { code: snapshot.status, message: '' } : undefined)
293
+ }
294
+ }
295
+
296
+ // ── Scheduling ──────────────────────────────────────────────────────────
297
+
298
+ private gateFor(routine: SkillRoutine): RoutineGate {
299
+ if (!this.masterEnabled()) return 'routines_off'
300
+ if (!this.deps.durableJobsEnabled()) return 'durable_jobs_off'
301
+ if (!this.deps.admissionsOpen()) return 'admissions_closed'
302
+ if (!routine.enabled) return 'disabled'
303
+ if (this.breakerOpen(routine.id)) return 'breaker_open'
304
+ if (!this.deps.skillExists(routine.skill)) return 'skill_missing'
305
+ return 'ready'
306
+ }
307
+
308
+ tick(): Promise<RoutineTickResult> {
309
+ if (this.tickInFlight) return this.tickInFlight
310
+ this.tickInFlight = this.serialize(() => this.runTick()).finally(() => { this.tickInFlight = null })
311
+ return this.tickInFlight
312
+ }
313
+
314
+ private async runTick(): Promise<RoutineTickResult> {
315
+ await this.reconcile()
316
+ const result: RoutineTickResult = { fired: [], skipped: {} }
317
+ if (!this.masterEnabled()) return { ...result, reason: 'routines_off' }
318
+ if (!this.deps.durableJobsEnabled()) return { ...result, reason: 'durable_jobs_off' }
319
+ if (!this.deps.admissionsOpen()) return { ...result, reason: 'admissions_closed' }
320
+ for (const routine of this.file.routines) {
321
+ if (this.breakerOpen(routine.id)) { result.skipped[routine.id] = 'breaker_open'; continue }
322
+ // The decision table reads only this routine's scheduled rows itself (Run now never takes the slot).
323
+ const decision = decideScheduledFire(routine, this.runsFor(routine.id), this.now())
324
+ if (!decision.fire) { result.skipped[routine.id] = decision.reason; continue }
325
+ if (!decision.resume && this.admittedToday(routine) >= routine.dailyCap) { result.skipped[routine.id] = 'daily_cap_reached'; continue }
326
+ if (!decision.resume && this.admittedLastDay() >= ROUTINE_LIMITS.globalRunsPerDay) { result.skipped[routine.id] = 'global_cap_reached'; continue }
327
+ const run = await this.fire(routine, 'scheduled', decision.day, decision.attempt, decision.resume)
328
+ if (run.jobId) result.fired.push(routine.id)
329
+ else result.skipped[routine.id] = run.submitError?.code ?? 'submit_failed'
330
+ }
331
+ return result
332
+ }
333
+
334
+ runNow(id: string): Promise<RoutineRun> {
335
+ return this.serialize(async () => {
336
+ await this.reconcile()
337
+ const routine = this.find(id)
338
+ if (!this.masterEnabled()) throw new RoutineRunError(409, 'routines_off', 'Routines are off on this Mac (COS_ROUTINES=0).')
339
+ if (!this.deps.durableJobsEnabled()) throw new RoutineRunError(409, 'durable_jobs_off', 'Turn on Background jobs in COS Control to run routines.')
340
+ if (!this.deps.admissionsOpen()) throw new RoutineRunError(503, 'admissions_closed', 'The server is in maintenance. Try again in a moment.')
341
+ if (this.breakerOpen(id)) {
342
+ const until = this.breaker(id).openUntil
343
+ throw new RoutineRunError(423, 'breaker_open', `Paused after ${this.breaker(id).consecutiveFailures} failed runs. Reset it, or wait until ${until}.`, { openUntil: until })
344
+ }
345
+ if (!this.deps.skillExists(routine.skill)) throw new RoutineRunError(409, 'skill_missing', `/${routine.skill} is no longer in this COS workspace.`)
346
+ for (const run of this.runsFor(id)) {
347
+ if (!run.jobId || run.outcome) continue
348
+ const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
349
+ if (snapshot && !isTerminalQueryJobStatus(snapshot.status)) {
350
+ throw new RoutineRunError(409, 'routine_in_progress', 'This routine is already running. It lands in the inbox when it finishes.')
351
+ }
352
+ }
353
+ if (this.admittedToday(routine) >= routine.dailyCap) {
354
+ throw new RoutineRunError(429, 'daily_cap_reached', `This routine is capped at ${routine.dailyCap} run${routine.dailyCap === 1 ? '' : 's'} a day.`)
355
+ }
356
+ if (this.admittedLastDay() >= ROUTINE_LIMITS.globalRunsPerDay) {
357
+ throw new RoutineRunError(429, 'global_cap_reached', `Routines together are capped at ${ROUTINE_LIMITS.globalRunsPerDay} runs a day.`)
358
+ }
359
+ const day = localClock(this.now(), routine.timezone).day
360
+ const manual = this.runsFor(id).filter(run => run.day === day && run.trigger === 'manual').length
361
+ const run = await this.fire(routine, 'manual', day, manual + 1)
362
+ if (run.submitError) throw new RoutineRunError(503, run.submitError.code, run.submitError.message)
363
+ return run
364
+ })
365
+ }
366
+
367
+ private async fire(routine: SkillRoutine, trigger: RoutineTrigger, day: string, attempt: number, resume?: RoutineRun): Promise<RoutineRun> {
368
+ const clientJobId = trigger === 'scheduled' ? routineScheduledClientJobId(routine.id, day, routine.timezone) : randomUUID()
369
+ if (trigger === 'scheduled') {
370
+ const existing = await this.deps.findByClientGeneration(clientJobId, 1).catch(() => undefined)
371
+ if (existing) {
372
+ const prior = resume ?? this.runsFor(routine.id).filter(r => r.day === day && r.trigger === 'scheduled').at(-1)
373
+ const { submitError: _dropped, ...carried } = prior ?? {}
374
+ const adopted: RoutineRun = {
375
+ ...carried,
376
+ id: prior?.id ?? randomUUID(),
377
+ routineId: routine.id,
378
+ day,
379
+ trigger,
380
+ attempt,
381
+ firedAt: prior?.firedAt ?? existing.acceptedAt,
382
+ clientJobId,
383
+ generation: 1,
384
+ sessionId: prior?.sessionId ?? existing.sessionId,
385
+ messageEra: prior?.messageEra ?? existing.messageEra ?? this.deps.currentMessageEra(),
386
+ globalMsgNum: prior?.globalMsgNum ?? existing.globalMsgNum ?? this.deps.currentMessageMax() + 1,
387
+ jobId: existing.jobId,
388
+ lastKnownStatus: existing.status,
389
+ }
390
+ this.replaceRun(adopted)
391
+ this.log(`adopted ${routine.id} for ${day} as job ${existing.jobId}`)
392
+ return adopted
393
+ }
394
+ }
395
+ const sessionId = resume?.sessionId ?? this.deps.createSession()
396
+ const messageEra = this.deps.currentMessageEra()
397
+ const globalMsgNum = resume?.globalMsgNum ?? this.deps.currentMessageMax() + 1
398
+ const run: RoutineRun = {
399
+ id: resume?.id ?? randomUUID(),
400
+ routineId: routine.id,
401
+ day,
402
+ trigger,
403
+ attempt,
404
+ firedAt: new Date(this.now()).toISOString(),
405
+ clientJobId,
406
+ generation: 1,
407
+ sessionId,
408
+ messageEra,
409
+ globalMsgNum,
410
+ }
411
+ // Ledger first: a crash after this line is a resume, not a second run.
412
+ this.replaceRun(run)
413
+ try {
414
+ const admission = await this.deps.submit({
415
+ clientJobId,
416
+ generation: 1,
417
+ query: composeSkillRoutinePrompt(routine, day, this.deps.ownerName(), trigger),
418
+ sessionId,
419
+ messageEra,
420
+ globalMsgNum,
421
+ // Always explicit: a job with no model would run on the user's default, which can be Opus.
422
+ model: routine.model,
423
+ ...(routine.effort ? { effort: routine.effort } : {}),
424
+ activityToolMode: 'status',
425
+ attachmentIds: [],
426
+ attachmentRefs: [],
427
+ origin: { kind: 'routine', id: routine.id },
428
+ })
429
+ const accepted: RoutineRun = { ...run, jobId: admission.job.jobId, lastKnownStatus: admission.job.status }
430
+ this.replaceRun(accepted)
431
+ this.log(`${trigger} ${routine.id} (/${routine.skill}) for ${day} admitted as job ${admission.job.jobId} (#${globalMsgNum})`)
432
+ return accepted
433
+ } catch (error) {
434
+ const failed: RoutineRun = { ...run, submitError: errorParts(error), outcome: 'failed' }
435
+ this.replaceRun(failed)
436
+ this.recordOutcome(routine.id, false, failed.submitError)
437
+ this.log(`${trigger} ${routine.id} for ${day} attempt ${attempt} failed to submit: ${failed.submitError!.code}`)
438
+ return failed
439
+ }
440
+ }
441
+
442
+ // ── Views ───────────────────────────────────────────────────────────────
443
+
444
+ async listRuns(id?: string, limit = 14): Promise<RoutineRunView[]> {
445
+ const pool = id ? this.runsFor(id) : this.ledger.runs
446
+ const rows = pool.slice(-Math.max(1, Math.min(limit, ROUTINE_LIMITS.retainedRuns))).reverse()
447
+ const views: RoutineRunView[] = []
448
+ for (const run of rows) {
449
+ if (!run.jobId) {
450
+ views.push({ ...run, status: run.submitError ? 'submit_failed' : 'submitting', ...(run.submitError ? { error: run.submitError } : {}) })
451
+ continue
452
+ }
453
+ const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
454
+ if (!snapshot) { views.push({ ...run, status: run.lastKnownStatus ?? 'unknown' }); continue }
455
+ const answer = snapshot.response ?? snapshot.partialText ?? ''
456
+ views.push({
457
+ ...run,
458
+ status: snapshot.status,
459
+ ...(snapshot.completedAt ? { completedAt: snapshot.completedAt } : {}),
460
+ ...(snapshot.error ? { error: { code: snapshot.error.code, message: snapshot.error.message } } : {}),
461
+ ...(answer ? { preview: answer.replace(/\s+/g, ' ').trim().slice(0, 120) } : {}),
462
+ })
463
+ }
464
+ return views
465
+ }
466
+
467
+ async view(routine: SkillRoutine): Promise<SkillRoutineView> {
468
+ const [lastRun] = await this.listRuns(routine.id, 1)
469
+ const day = localClock(this.now(), routine.timezone).day
470
+ return {
471
+ ...structuredClone(routine),
472
+ nextRunAt: routine.enabled && !this.breakerOpen(routine.id) ? nextScheduledFire(routine, this.now()) : null,
473
+ gate: this.gateFor(routine),
474
+ skillFound: this.deps.skillExists(routine.skill),
475
+ breakerState: this.breakerView(routine),
476
+ today: { day, runs: this.admittedToday(routine), cap: routine.dailyCap },
477
+ lastRun: lastRun ?? null,
478
+ }
479
+ }
480
+
481
+ async views(): Promise<SkillRoutineView[]> {
482
+ const out: SkillRoutineView[] = []
483
+ for (const routine of this.file.routines) out.push(await this.view(routine))
484
+ return out
485
+ }
486
+
487
+ async get(id: string): Promise<SkillRoutineView> {
488
+ return this.view(this.find(id))
489
+ }
490
+
491
+ masterSwitchOn(): boolean {
492
+ return this.masterEnabled()
493
+ }
494
+
495
+ /** Numbers minted for runs not terminal yet (the brief's rule): the ledger row exists before the job. */
496
+ liveReservations(era: string): MessageReservation[] {
497
+ const floor = this.now() - 24 * 60 * 60_000
498
+ const out: MessageReservation[] = []
499
+ for (const run of this.ledger.runs) {
500
+ if (typeof run.globalMsgNum !== 'number' || run.submitError) continue
501
+ if (run.lastKnownStatus && isTerminalQueryJobStatus(run.lastKnownStatus as never)) continue
502
+ const fired = Date.parse(run.firedAt)
503
+ if (!Number.isFinite(fired) || fired < floor) continue
504
+ if (era !== run.messageEra) continue
505
+ out.push({ globalMsgNum: run.globalMsgNum, messageEra: run.messageEra, owner: `routine:${run.routineId}:${run.day}` })
506
+ }
507
+ return out
508
+ }
509
+ }