@gotcos/glasses-server 6.42.1 → 6.43.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.
- package/CHANGELOG.md +103 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/server/index.ts +10 -0
- package/server/lib/message-reservations.ts +59 -0
- package/server/lib/morning-brief-config.ts +546 -0
- package/server/lib/morning-brief-coverage.ts +376 -0
- package/server/lib/morning-brief-prompt.ts +263 -0
- package/server/lib/morning-brief-runtime.ts +203 -0
- package/server/lib/morning-brief-schedule.ts +158 -0
- package/server/lib/morning-brief-scheduler.ts +412 -0
- package/server/lib/query-job-coordinator.ts +6 -0
- package/server/lib/query-job-runtime.ts +9 -0
- package/server/lib/query-job-store.ts +28 -0
- package/server/routes/health.ts +20 -0
- package/server/routes/message-ref.ts +6 -1
- package/server/routes/morning-brief.ts +94 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Morning brief — the one production scheduler, wired to the real coordinator.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors query-job-runtime.ts: the module owns the singleton so the router,
|
|
4
|
+
// health, and index.ts all see the same instance, and tests construct their
|
|
5
|
+
// own MorningBriefScheduler with fake deps instead of importing this file.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { basename, join, resolve } from 'node:path'
|
|
10
|
+
import { getActiveSessions, createSession } from './conversation.js'
|
|
11
|
+
import { currentMessageEra, exchangeBelongsToEra } from './message-era.js'
|
|
12
|
+
import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
|
|
13
|
+
import { dataPath } from './data-dir.js'
|
|
14
|
+
import { getOwnerName } from './profile.js'
|
|
15
|
+
import { durableQueryJobsEnabled } from './query-job-feature.js'
|
|
16
|
+
import { queryJobCoordinator } from './query-job-runtime.js'
|
|
17
|
+
import { maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
|
|
18
|
+
import { maxReservedGlobalMsgNum, registerMessageReservationSource } from './message-reservations.js'
|
|
19
|
+
import { morningBriefPaths } from './morning-brief-config.js'
|
|
20
|
+
import { MorningBriefScheduler } from './morning-brief-scheduler.js'
|
|
21
|
+
import {
|
|
22
|
+
MorningBriefCoverageService,
|
|
23
|
+
type CalendarProbe,
|
|
24
|
+
type ContextProbe,
|
|
25
|
+
type MeetingsProbe,
|
|
26
|
+
type ReflectionProbe,
|
|
27
|
+
type SkillProbe,
|
|
28
|
+
type TasksProbe,
|
|
29
|
+
} from './morning-brief-coverage.js'
|
|
30
|
+
import {
|
|
31
|
+
listCosOperationsMeetingDays,
|
|
32
|
+
listCosOperationsMeetingMonths,
|
|
33
|
+
listDirectLibraryMeetingDays,
|
|
34
|
+
listDirectLibraryMeetingMonths,
|
|
35
|
+
resolveMeetingLibrary,
|
|
36
|
+
} from './cos-operations-meetings.js'
|
|
37
|
+
import { getMeetingStore } from './meeting-store.js'
|
|
38
|
+
import { COS_SCRIPTS_DIR, callPython, contextSourceAvailable, pythonBridgeAvailable } from './python-bridge.js'
|
|
39
|
+
import { resolveProviderWorkDir } from './launch-dir.js'
|
|
40
|
+
|
|
41
|
+
/** Highest stamped message number in the active era: live sessions, the day
|
|
42
|
+
* archives, AND every number a not-yet-projected job or brief already holds.
|
|
43
|
+
* The same arithmetic /api/message-counter serves the phone. */
|
|
44
|
+
export function currentMessageMax(): number {
|
|
45
|
+
const era = currentMessageEra()
|
|
46
|
+
let liveMax = 0
|
|
47
|
+
for (const session of getActiveSessions()) {
|
|
48
|
+
for (const exchange of (session as { exchanges?: Array<{ globalMsgNum?: unknown; messageEra?: unknown }> }).exchanges ?? []) {
|
|
49
|
+
if (!exchangeBelongsToEra(exchange, era)) continue
|
|
50
|
+
if (typeof exchange?.globalMsgNum === 'number' && exchange.globalMsgNum > liveMax) liveMax = exchange.globalMsgNum
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era), maxReservedGlobalMsgNum(era))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Coverage probes (the same wells COS Control's Activity tiles read) ────────
|
|
57
|
+
|
|
58
|
+
function sumDayCounts(months: string[], days: (month: string) => Array<{ count: number }>): number {
|
|
59
|
+
let total = 0
|
|
60
|
+
for (const month of months) for (const day of days(month)) total += day.count
|
|
61
|
+
return total
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function probeMeetings(): MeetingsProbe | null {
|
|
65
|
+
const library = resolveMeetingLibrary()
|
|
66
|
+
if (library.layout === 'direct') {
|
|
67
|
+
const months = listDirectLibraryMeetingMonths()
|
|
68
|
+
return { count: sumDayCounts(months, listDirectLibraryMeetingDays), newestMonth: months[0] ?? null, layout: library.layout }
|
|
69
|
+
}
|
|
70
|
+
if (library.layout === 'multi_domain') {
|
|
71
|
+
const months = listCosOperationsMeetingMonths('all')
|
|
72
|
+
return { count: sumDayCounts(months, month => listCosOperationsMeetingDays(month, 'all')), newestMonth: months[0] ?? null, layout: library.layout }
|
|
73
|
+
}
|
|
74
|
+
if (library.layout === 'invalid_explicit_root') return null
|
|
75
|
+
const store = getMeetingStore()
|
|
76
|
+
const months = store.listMonths()
|
|
77
|
+
return { count: sumDayCounts(months, month => store.listDayCounts(month)), newestMonth: months[0] ?? null, layout: 'standalone' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function probeContext(): Promise<ContextProbe | null> {
|
|
81
|
+
if (contextSourceAvailable() === null) return null
|
|
82
|
+
const data = await callPython(['context-status'], 8_000) as {
|
|
83
|
+
memory?: { available?: unknown; total?: unknown; state?: unknown; reason?: unknown }
|
|
84
|
+
threads?: { available?: unknown; total?: unknown; active?: unknown; state?: unknown; reason?: unknown }
|
|
85
|
+
} | null
|
|
86
|
+
if (!data || typeof data !== 'object') return null
|
|
87
|
+
const num = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
88
|
+
const state = (part?: { state?: unknown; reason?: unknown }) =>
|
|
89
|
+
typeof part?.state === 'string' ? part.state : typeof part?.reason === 'string' ? part.reason : undefined
|
|
90
|
+
return {
|
|
91
|
+
memory: { available: data.memory?.available === true, total: num(data.memory?.total), state: state(data.memory) },
|
|
92
|
+
threads: { available: data.threads?.available === true, total: num(data.threads?.total), active: num(data.threads?.active), state: state(data.threads) },
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function probeCalendar(): Promise<CalendarProbe | null> {
|
|
97
|
+
if (!pythonBridgeAvailable()) return null
|
|
98
|
+
const data = await callPython(['calendar'], 8_000) as { meetings_today_count?: unknown; today_events?: unknown; data_source?: unknown } | null
|
|
99
|
+
if (!data || typeof data !== 'object') return null
|
|
100
|
+
const today = typeof data.meetings_today_count === 'number'
|
|
101
|
+
? data.meetings_today_count
|
|
102
|
+
: Array.isArray(data.today_events) ? data.today_events.length : 0
|
|
103
|
+
return { todayCount: Math.max(0, Math.trunc(today)), ...(typeof data.data_source === 'string' ? { source: data.data_source } : {}) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function probeTasks(): Promise<TasksProbe | null> {
|
|
107
|
+
if (!pythonBridgeAvailable()) return null
|
|
108
|
+
const data = await callPython(['tasks'], 8_000) as Record<string, unknown> | null
|
|
109
|
+
if (!data || typeof data !== 'object') return null
|
|
110
|
+
let open = 0
|
|
111
|
+
let files = 0
|
|
112
|
+
for (const rows of Object.values(data)) {
|
|
113
|
+
if (!Array.isArray(rows)) continue
|
|
114
|
+
files += 1
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
if (row && typeof row === 'object' && (row as { is_checked?: unknown }).is_checked !== true) open += 1
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { open, files }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const REFLECTION_LOG_MAX_BYTES = 8 * 1024 * 1024
|
|
123
|
+
|
|
124
|
+
export function probeReflection(): ReflectionProbe | null {
|
|
125
|
+
if (!COS_SCRIPTS_DIR) return null
|
|
126
|
+
const file = resolve(COS_SCRIPTS_DIR, '.cos_reflect_log.jsonl')
|
|
127
|
+
let stat
|
|
128
|
+
try { stat = statSync(file) } catch { return null }
|
|
129
|
+
if (!stat.isFile()) return null
|
|
130
|
+
const newestAt = stat.mtime.toISOString()
|
|
131
|
+
if (stat.size > REFLECTION_LOG_MAX_BYTES) return { entries: -1, newestAt }
|
|
132
|
+
let entries = 0
|
|
133
|
+
try {
|
|
134
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) if (line.trim()) entries += 1
|
|
135
|
+
} catch { return null }
|
|
136
|
+
return { entries, newestAt }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
|
|
140
|
+
|
|
141
|
+
/** Where a skill would be found, in the order the prompt tells the agent to
|
|
142
|
+
* look. Returns a folder LABEL, never an absolute path. */
|
|
143
|
+
export function probeSkill(rawName: string, workDir = resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }), home = homedir()): SkillProbe {
|
|
144
|
+
const name = rawName.trim().replace(/^\//, '')
|
|
145
|
+
if (!SKILL_NAME_RE.test(name) || basename(name) !== name) return { found: false }
|
|
146
|
+
const nested = name.replace(/:/g, '/')
|
|
147
|
+
const candidates: Array<[string, string]> = [
|
|
148
|
+
['.claude/skills', join(workDir, '.claude', 'skills', name, 'SKILL.md')],
|
|
149
|
+
['.agents/skills', join(workDir, '.agents', 'skills', name, 'SKILL.md')],
|
|
150
|
+
['.agents/skills', join(workDir, '.agents', 'skills', nested, 'SKILL.md')],
|
|
151
|
+
['.claude/commands', join(workDir, '.claude', 'commands', `${name}.md`)],
|
|
152
|
+
['.claude/commands', join(workDir, '.claude', 'commands', `${nested}.md`)],
|
|
153
|
+
['~/.claude/skills', join(home, '.claude', 'skills', name, 'SKILL.md')],
|
|
154
|
+
['~/.claude/commands', join(home, '.claude', 'commands', `${name}.md`)],
|
|
155
|
+
['~/.codex/prompts', join(home, '.codex', 'prompts', `${name}.md`)],
|
|
156
|
+
]
|
|
157
|
+
for (const [where, path] of candidates) {
|
|
158
|
+
try { if (existsSync(path)) return { found: true, where } } catch { /* unreadable: keep looking */ }
|
|
159
|
+
}
|
|
160
|
+
return { found: false }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let scheduler: MorningBriefScheduler | null = null
|
|
164
|
+
let unregisterReservations: (() => void) | null = null
|
|
165
|
+
|
|
166
|
+
export function getMorningBriefScheduler(): MorningBriefScheduler {
|
|
167
|
+
if (!scheduler) {
|
|
168
|
+
const instance = new MorningBriefScheduler({
|
|
169
|
+
paths: morningBriefPaths(),
|
|
170
|
+
submit: raw => queryJobCoordinator.submit(raw),
|
|
171
|
+
findByClientGeneration: (clientJobId, generation) => queryJobCoordinator.getByClientGeneration(clientJobId, generation),
|
|
172
|
+
getSnapshot: jobId => queryJobCoordinator.getSnapshot(jobId),
|
|
173
|
+
createSession,
|
|
174
|
+
currentMessageEra,
|
|
175
|
+
currentMessageMax,
|
|
176
|
+
ownerName: getOwnerName,
|
|
177
|
+
durableJobsEnabled: durableQueryJobsEnabled,
|
|
178
|
+
admissionsOpen: maintenanceAdmissionsOpen,
|
|
179
|
+
coverage: new MorningBriefCoverageService({
|
|
180
|
+
meetings: probeMeetings,
|
|
181
|
+
context: probeContext,
|
|
182
|
+
calendar: probeCalendar,
|
|
183
|
+
tasks: probeTasks,
|
|
184
|
+
reflection: probeReflection,
|
|
185
|
+
skill: name => probeSkill(name),
|
|
186
|
+
}),
|
|
187
|
+
})
|
|
188
|
+
// The ledger row exists before the job does; its number must count.
|
|
189
|
+
unregisterReservations = registerMessageReservationSource(() => instance.liveReservations(currentMessageEra()))
|
|
190
|
+
scheduler = instance
|
|
191
|
+
}
|
|
192
|
+
return scheduler
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function startMorningBriefScheduler(): void {
|
|
196
|
+
getMorningBriefScheduler().start()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function stopMorningBriefScheduler(): void {
|
|
200
|
+
scheduler?.stop()
|
|
201
|
+
unregisterReservations?.()
|
|
202
|
+
unregisterReservations = null
|
|
203
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Morning brief — pure schedule arithmetic in the user's zone.
|
|
2
|
+
//
|
|
3
|
+
// No dependency: Node's Intl is enough to read a wall clock in an IANA zone,
|
|
4
|
+
// and the two operations the scheduler needs — "what local day and minute is
|
|
5
|
+
// it now" and "when is HH:MM on day D as an instant" — are both derivable from
|
|
6
|
+
// it. Kept free of timers and I/O so the fire/no-fire decision is a table of
|
|
7
|
+
// inputs a test can enumerate: the Mac asleep through the slot, a weekend, a
|
|
8
|
+
// zone change, a DST morning, a second tick in the same minute.
|
|
9
|
+
|
|
10
|
+
import type { MorningBriefConfig, MorningBriefRun } from './morning-brief-config.js'
|
|
11
|
+
import { MORNING_BRIEF_LIMITS } from './morning-brief-config.js'
|
|
12
|
+
|
|
13
|
+
export interface LocalClock {
|
|
14
|
+
/** YYYY-MM-DD in the zone. */
|
|
15
|
+
day: string
|
|
16
|
+
/** Minutes since local midnight, 0..1439. */
|
|
17
|
+
minutes: number
|
|
18
|
+
/** 0 = Sunday … 6 = Saturday, in the zone. */
|
|
19
|
+
weekday: number
|
|
20
|
+
/** HH:MM in the zone. */
|
|
21
|
+
time: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const WEEKDAY_INDEX: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
|
|
25
|
+
const formatterCache = new Map<string, Intl.DateTimeFormat>()
|
|
26
|
+
|
|
27
|
+
function formatter(timezone: string): Intl.DateTimeFormat {
|
|
28
|
+
let cached = formatterCache.get(timezone)
|
|
29
|
+
if (!cached) {
|
|
30
|
+
cached = new Intl.DateTimeFormat('en-US', {
|
|
31
|
+
timeZone: timezone,
|
|
32
|
+
hourCycle: 'h23',
|
|
33
|
+
weekday: 'short',
|
|
34
|
+
year: 'numeric',
|
|
35
|
+
month: '2-digit',
|
|
36
|
+
day: '2-digit',
|
|
37
|
+
hour: '2-digit',
|
|
38
|
+
minute: '2-digit',
|
|
39
|
+
})
|
|
40
|
+
formatterCache.set(timezone, cached)
|
|
41
|
+
}
|
|
42
|
+
return cached
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read the wall clock at `nowMs` in `timezone`. */
|
|
46
|
+
export function localClock(nowMs: number, timezone: string): LocalClock {
|
|
47
|
+
const parts = formatter(timezone).formatToParts(new Date(nowMs))
|
|
48
|
+
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)?.value ?? ''
|
|
49
|
+
// `hourCycle: 'h23'` is honoured by modern ICU, but "24" has been observed
|
|
50
|
+
// from older builds at midnight. Normalise defensively.
|
|
51
|
+
const hour = Number(get('hour')) % 24
|
|
52
|
+
const minute = Number(get('minute'))
|
|
53
|
+
return {
|
|
54
|
+
day: `${get('year')}-${get('month')}-${get('day')}`,
|
|
55
|
+
minutes: hour * 60 + minute,
|
|
56
|
+
weekday: WEEKDAY_INDEX[get('weekday')] ?? new Date(nowMs).getUTCDay(),
|
|
57
|
+
time: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseTime(time: string): number {
|
|
62
|
+
const [h, m] = time.split(':').map(Number)
|
|
63
|
+
return h * 60 + m
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Shift a YYYY-MM-DD key by whole days (calendar arithmetic, zone-agnostic). */
|
|
67
|
+
export function shiftDay(day: string, delta: number): string {
|
|
68
|
+
const [y, m, d] = day.split('-').map(Number)
|
|
69
|
+
const date = new Date(Date.UTC(y, m - 1, d + delta))
|
|
70
|
+
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The instant at which `day` reaches `minutes` past midnight in `timezone`.
|
|
75
|
+
* Two-pass offset correction: read the zone's wall clock at a UTC guess, apply
|
|
76
|
+
* the difference, and re-check once so a DST transition between the guess and
|
|
77
|
+
* the target lands on the right side. On a non-existent local time (spring
|
|
78
|
+
* forward) this returns the first instant after the gap.
|
|
79
|
+
*/
|
|
80
|
+
export function zonedInstant(day: string, minutes: number, timezone: string): number {
|
|
81
|
+
const [y, m, d] = day.split('-').map(Number)
|
|
82
|
+
let guess = Date.UTC(y, m - 1, d, Math.floor(minutes / 60), minutes % 60)
|
|
83
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
84
|
+
const clock = localClock(guess, timezone)
|
|
85
|
+
const dayDelta = daysBetween(clock.day, day)
|
|
86
|
+
const diffMinutes = dayDelta * 1440 + (minutes - clock.minutes)
|
|
87
|
+
if (diffMinutes === 0) break
|
|
88
|
+
guess += diffMinutes * 60_000
|
|
89
|
+
}
|
|
90
|
+
return guess
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function daysBetween(fromDay: string, toDay: string): number {
|
|
94
|
+
const [fy, fm, fd] = fromDay.split('-').map(Number)
|
|
95
|
+
const [ty, tm, td] = toDay.split('-').map(Number)
|
|
96
|
+
return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** ISO instant of the next scheduled fire at or after `nowMs`, or null when
|
|
100
|
+
* the brief is disabled or no weekday is selected. */
|
|
101
|
+
export function nextScheduledFire(config: MorningBriefConfig, nowMs: number): string | null {
|
|
102
|
+
if (!config.enabled || config.days.length === 0) return null
|
|
103
|
+
const target = parseTime(config.time)
|
|
104
|
+
const clock = localClock(nowMs, config.timezone)
|
|
105
|
+
for (let offset = 0; offset <= 7; offset++) {
|
|
106
|
+
const day = shiftDay(clock.day, offset)
|
|
107
|
+
const instant = zonedInstant(day, target, config.timezone)
|
|
108
|
+
const weekday = localClock(instant, config.timezone).weekday
|
|
109
|
+
if (!config.days.includes(weekday)) continue
|
|
110
|
+
if (instant >= nowMs) return new Date(instant).toISOString()
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type FireDecision =
|
|
116
|
+
| { fire: true; day: string; attempt: number; resume?: MorningBriefRun }
|
|
117
|
+
| { fire: false; reason: 'disabled' | 'not_a_brief_day' | 'before_slot' | 'window_closed' | 'already_fired' | 'in_flight' | 'attempts_exhausted' | 'backoff' }
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Should the scheduled brief fire on this tick? The ledger is the memory: one
|
|
121
|
+
* completed submission per local day, bounded retries for a submission that
|
|
122
|
+
* failed (coordinator shutting down, store degraded), and a row that has no
|
|
123
|
+
* job id and no error is a crash between ledger write and admission — resumed
|
|
124
|
+
* by client identity rather than re-run.
|
|
125
|
+
*/
|
|
126
|
+
export function decideScheduledFire(
|
|
127
|
+
config: MorningBriefConfig,
|
|
128
|
+
runs: readonly MorningBriefRun[],
|
|
129
|
+
nowMs: number,
|
|
130
|
+
): FireDecision {
|
|
131
|
+
if (!config.enabled) return { fire: false, reason: 'disabled' }
|
|
132
|
+
const clock = localClock(nowMs, config.timezone)
|
|
133
|
+
if (!config.days.includes(clock.weekday)) return { fire: false, reason: 'not_a_brief_day' }
|
|
134
|
+
const target = parseTime(config.time)
|
|
135
|
+
if (clock.minutes < target) return { fire: false, reason: 'before_slot' }
|
|
136
|
+
if (clock.minutes - target > config.catchUpMinutes) return { fire: false, reason: 'window_closed' }
|
|
137
|
+
|
|
138
|
+
const today = runs.filter(run => run.day === clock.day && run.trigger === 'scheduled')
|
|
139
|
+
const accepted = today.find(run => run.jobId)
|
|
140
|
+
if (accepted) return { fire: false, reason: 'already_fired' }
|
|
141
|
+
const last = today.at(-1)
|
|
142
|
+
if (!last) return { fire: true, day: clock.day, attempt: 1 }
|
|
143
|
+
const lastAt = Date.parse(last.firedAt)
|
|
144
|
+
if (!last.submitError) {
|
|
145
|
+
// Ledger row exists, no job id, no error: submission never reported back.
|
|
146
|
+
// Give it a moment (the write happens just before submit), then resume by
|
|
147
|
+
// identity so a lost admission is adopted instead of duplicated.
|
|
148
|
+
if (Number.isFinite(lastAt) && nowMs - lastAt < MORNING_BRIEF_LIMITS.attemptSpacingMs) {
|
|
149
|
+
return { fire: false, reason: 'in_flight' }
|
|
150
|
+
}
|
|
151
|
+
return { fire: true, day: clock.day, attempt: last.attempt, resume: last }
|
|
152
|
+
}
|
|
153
|
+
if (last.attempt >= MORNING_BRIEF_LIMITS.scheduledAttemptsPerDay) return { fire: false, reason: 'attempts_exhausted' }
|
|
154
|
+
if (Number.isFinite(lastAt) && nowMs - lastAt < MORNING_BRIEF_LIMITS.attemptSpacingMs) {
|
|
155
|
+
return { fire: false, reason: 'backoff' }
|
|
156
|
+
}
|
|
157
|
+
return { fire: true, day: clock.day, attempt: last.attempt + 1 }
|
|
158
|
+
}
|