@gotcos/glasses-server 6.80.0 → 6.82.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.
- package/CHANGELOG.md +24 -0
- package/package.json +4 -1
- package/server/index.ts +10 -1
- package/server/lib/claude-bridge.ts +6 -1
- package/server/lib/codex-bridge.ts +1 -1
- package/server/lib/cursor-bridge.ts +1 -1
- package/server/lib/direct-bridge.ts +1 -1
- package/server/lib/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- package/server/lib/morning-brief-schedule.ts +13 -7
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/routines-config.ts +421 -0
- package/server/lib/routines-registry.ts +151 -0
- package/server/lib/routines-runtime.ts +64 -0
- package/server/lib/routines-scheduler.ts +509 -0
- package/server/routes/meeting.ts +326 -47
- package/server/routes/routines.ts +101 -0
- package/server/routes/transcribe-stream.ts +48 -1
|
@@ -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
|
+
}
|