feat(automation): add timezone-safe daily briefing dedupe

This commit is contained in:
William Valentin
2026-02-16 14:01:14 -08:00
parent 71af3b5a42
commit 56854f04bd
10 changed files with 118 additions and 4 deletions
+3
View File
@@ -532,6 +532,7 @@ automation:
peer: "123456789" # Chat ID to send to peer: "123456789" # Chat ID to send to
timezone: Europe/London # Optional timezone timezone: Europe/London # Optional timezone
enabled: true enabled: true
once_per_local_day: false # Optional dedupe: max one trigger/day in job timezone
- name: hourly-check - name: hourly-check
schedule: "0 * * * *" # Every hour schedule: "0 * * * *" # Every hour
@@ -549,6 +550,7 @@ automation:
name: daily-briefing name: daily-briefing
schedule: "0 8 * * *" schedule: "0 8 * * *"
timezone: America/New_York timezone: America/New_York
dedupe_per_local_day: true
output: output:
channel: telegram channel: telegram
peer: "123456789" peer: "123456789"
@@ -571,6 +573,7 @@ automation:
| `timezone` | no | IANA timezone (defaults to system timezone) | | `timezone` | no | IANA timezone (defaults to system timezone) |
| `enabled` | no | Whether the job is active (default: `true`) | | `enabled` | no | Whether the job is active (default: `true`) |
| `model_tier` | no | Model tier for this job: `fast`, `default`, `complex`, or `local` | | `model_tier` | no | Model tier for this job: `fast`, `default`, `complex`, or `local` |
| `once_per_local_day` | no | If true, suppress duplicate triggers within the same local day (job timezone) |
| `automation.daily_briefing.*` | no | Built-in daily briefing preset; generates an extra cron job when `enabled: true` and `output` is set | | `automation.daily_briefing.*` | no | Built-in daily briefing preset; generates an extra cron job when `enabled: true` and `output` is set |
## Backup Scheduling ## Backup Scheduling
+2
View File
@@ -244,6 +244,7 @@ hooks:
# output: # output:
# channel: telegram # channel: telegram
# peer: "123456789" # peer: "123456789"
# once_per_local_day: false
# #
# # Optional built-in morning briefing job (auto-registered as a cron job) # # Optional built-in morning briefing job (auto-registered as a cron job)
# daily_briefing: # daily_briefing:
@@ -251,6 +252,7 @@ hooks:
# name: daily-briefing # name: daily-briefing
# schedule: "0 8 * * *" # schedule: "0 8 * * *"
# timezone: America/New_York # timezone: America/New_York
# dedupe_per_local_day: true
# output: # output:
# channel: telegram # channel: telegram
# peer: "123456789" # peer: "123456789"
+6 -3
View File
@@ -7,13 +7,15 @@
"status": "completed", "status": "completed",
"date": "2026-02-16", "date": "2026-02-16",
"updated": "2026-02-16", "updated": "2026-02-16",
"summary": "Added first-class automation presets and scheduling upgrades: `automation.daily_briefing` now auto-registers an opinionated cron job for morning briefings, and backup scheduling now supports cron expressions via `backup.schedule` plus optional `backup.run_on_start` while preserving interval fallback. Added `BackupScheduler` with `backup.notify` channel alerts, configurable `backup.failure_threshold`, and recovery notifications (`backup.notify_recovery`) so backup failures/recoveries proactively notify operators. Extended heartbeat monitoring with `process_memory`, `backup`, and `provider_errors` checks (with thresholds) so high RSS usage, backup failure streaks, and model-provider error spikes proactively trigger health alerts.", "summary": "Added first-class automation presets and scheduling upgrades: `automation.daily_briefing` now auto-registers an opinionated cron job for morning briefings, and backup scheduling now supports cron expressions via `backup.schedule` plus optional `backup.run_on_start` while preserving interval fallback. Added `BackupScheduler` with `backup.notify` channel alerts, configurable `backup.failure_threshold`, and recovery notifications (`backup.notify_recovery`) so backup failures/recoveries proactively notify operators. Extended heartbeat monitoring with `process_memory`, `backup`, and `provider_errors` checks (with thresholds) so high RSS usage, backup failure streaks, and model-provider error spikes proactively trigger health alerts. Added timezone-safe daily briefing dedupe via `automation.daily_briefing.dedupe_per_local_day` and cron-level `once_per_local_day` so morning briefings do not send twice on the same local day.",
"files_modified": [ "files_modified": [
"src/config/schema.ts", "src/config/schema.ts",
"src/config/schema.test.ts", "src/config/schema.test.ts",
"src/automation/index.ts", "src/automation/index.ts",
"src/automation/presets.ts", "src/automation/presets.ts",
"src/automation/presets.test.ts", "src/automation/presets.test.ts",
"src/automation/cron.ts",
"src/automation/cron.test.ts",
"src/automation/heartbeat.ts", "src/automation/heartbeat.ts",
"src/automation/heartbeat.test.ts", "src/automation/heartbeat.test.ts",
"src/backup/index.ts", "src/backup/index.ts",
@@ -27,10 +29,11 @@
"src/daemon/services.ts", "src/daemon/services.ts",
"src/gateway/handlers/services.ts", "src/gateway/handlers/services.ts",
"src/gateway/handlers/services.test.ts", "src/gateway/handlers/services.test.ts",
"src/tools/builtin/cron.ts",
"config/default.yaml", "config/default.yaml",
"README.md" "README.md"
], ],
"test_status": "pnpm test:run src/automation/presets.test.ts src/automation/heartbeat.test.ts src/backup/scheduler.test.ts src/backup/status.test.ts src/config/schema.test.ts src/daemon/channels.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck passing" "test_status": "pnpm test:run src/automation/presets.test.ts src/automation/cron.test.ts src/automation/heartbeat.test.ts src/backup/scheduler.test.ts src/backup/status.test.ts src/config/schema.test.ts src/daemon/channels.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck passing"
}, },
"backup-session-summary-audit-trail": { "backup-session-summary-audit-trail": {
"status": "completed", "status": "completed",
@@ -3316,7 +3319,7 @@
} }
}, },
"overall_progress": { "overall_progress": {
"total_test_count": 1832, "total_test_count": 1844,
"all_tests_passing": true, "all_tests_passing": true,
"p0_completion": "3/3 (100%)", "p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)", "p1_completion": "4/4 (100%)",
+35
View File
@@ -14,6 +14,7 @@ function makeCronJob(overrides?: Partial<CronJobConfig>): CronJobConfig {
message: 'Hello from cron', message: 'Hello from cron',
output: { channel: 'telegram', peer: '123' }, output: { channel: 'telegram', peer: '123' },
enabled: true, enabled: true,
once_per_local_day: false,
...overrides, ...overrides,
}; };
} }
@@ -176,4 +177,38 @@ describe('CronScheduler', () => {
const names = scheduler.getJobNames(); const names = scheduler.getJobNames();
expect(names).toEqual(['job-a', 'job-b']); expect(names).toEqual(['job-a', 'job-b']);
}); });
it('dedupes once_per_local_day job within same local day', () => {
const jobs = [makeCronJob({ once_per_local_day: true, timezone: 'America/New_York' })];
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
const messages: InboundMessage[] = [];
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValueOnce(new Date('2026-02-16T13:00:00.000Z').getTime()); // 08:00 local
scheduler.triggerJob('test-job');
nowSpy.mockReturnValueOnce(new Date('2026-02-16T18:00:00.000Z').getTime()); // still same local day
scheduler.triggerJob('test-job');
nowSpy.mockRestore();
expect(messages).toHaveLength(1);
});
it('allows once_per_local_day job on next local day', () => {
const jobs = [makeCronJob({ once_per_local_day: true, timezone: 'America/New_York' })];
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
const messages: InboundMessage[] = [];
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValueOnce(new Date('2026-02-16T13:00:00.000Z').getTime()); // 08:00 local
scheduler.triggerJob('test-job');
nowSpy.mockReturnValueOnce(new Date('2026-02-17T13:00:00.000Z').getTime()); // next local day
scheduler.triggerJob('test-job');
nowSpy.mockRestore();
expect(messages).toHaveLength(2);
});
}); });
+37
View File
@@ -17,6 +17,7 @@ export class CronScheduler implements ChannelAdapter {
private messageHandler?: (msg: InboundMessage) => void; private messageHandler?: (msg: InboundMessage) => void;
private cronInstances: Map<string, Cron> = new Map(); private cronInstances: Map<string, Cron> = new Map();
private jobs: Map<string, CronJobConfig> = new Map(); private jobs: Map<string, CronJobConfig> = new Map();
private lastTriggeredLocalDateByJob: Map<string, string> = new Map();
constructor( constructor(
private readonly jobConfigs: CronJobConfig[], private readonly jobConfigs: CronJobConfig[],
@@ -89,6 +90,14 @@ export class CronScheduler implements ChannelAdapter {
triggerJob(jobName: string): void { triggerJob(jobName: string): void {
const job = this.jobs.get(jobName); const job = this.jobs.get(jobName);
if (!job) {return;} if (!job) {return;}
if (job.once_per_local_day) {
const dayKey = this.localDayKey(Date.now(), job.timezone);
const lastDayKey = this.lastTriggeredLocalDateByJob.get(jobName);
if (lastDayKey === dayKey) {
return;
}
this.lastTriggeredLocalDateByJob.set(jobName, dayKey);
}
const runId = `run-${randomUUID()}`; const runId = `run-${randomUUID()}`;
const senderId = this.deliveryMode === 'isolated_job' ? `${jobName}:${runId}` : jobName; const senderId = this.deliveryMode === 'isolated_job' ? `${jobName}:${runId}` : jobName;
@@ -174,9 +183,37 @@ export class CronScheduler implements ChannelAdapter {
} }
this.jobs.delete(name); this.jobs.delete(name);
this.lastTriggeredLocalDateByJob.delete(name);
auditLogger?.cronRemove(name); auditLogger?.cronRemove(name);
return true; return true;
} }
private localDayKey(timestamp: number, timezone?: string): string {
const date = new Date(timestamp);
const format = (timeZoneValue?: string): string => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timeZoneValue,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const year = parts.find((part) => part.type === 'year')?.value ?? '0000';
const month = parts.find((part) => part.type === 'month')?.value ?? '00';
const day = parts.find((part) => part.type === 'day')?.value ?? '00';
return `${year}-${month}-${day}`;
};
if (!timezone) {
return format();
}
try {
return format(timezone);
} catch {
console.warn(`CronScheduler: invalid timezone '${timezone}' for job local-day dedupe; falling back to system timezone`);
return format();
}
}
} }
+20
View File
@@ -12,6 +12,7 @@ describe('buildPresetCronJobs', () => {
enabled: true, enabled: true,
schedule: '0 7 * * *', schedule: '0 7 * * *',
timezone: 'America/New_York', timezone: 'America/New_York',
dedupe_per_local_day: true,
output: { channel: 'telegram', peer: '1' }, output: { channel: 'telegram', peer: '1' },
model_tier: 'fast', model_tier: 'fast',
prompt: 'Daily briefing prompt', prompt: 'Daily briefing prompt',
@@ -28,9 +29,28 @@ describe('buildPresetCronJobs', () => {
output: { channel: 'telegram', peer: '1' }, output: { channel: 'telegram', peer: '1' },
model_tier: 'fast', model_tier: 'fast',
message: 'Daily briefing prompt', message: 'Daily briefing prompt',
once_per_local_day: true,
}); });
}); });
it('can disable daily briefing per-local-day dedupe', () => {
const config = configSchema.parse({
telegram: { bot_token: 'token', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-sonnet' } },
automation: {
daily_briefing: {
enabled: true,
dedupe_per_local_day: false,
output: { channel: 'telegram', peer: '1' },
},
},
});
const jobs = buildPresetCronJobs(config);
expect(jobs).toHaveLength(1);
expect(jobs[0].once_per_local_day).toBe(false);
});
it('skips daily briefing job when output is missing', () => { it('skips daily briefing job when output is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config = configSchema.parse({ const config = configSchema.parse({
+1
View File
@@ -23,6 +23,7 @@ export function buildPresetCronJobs(config: Config): CronJobConfig[] {
enabled: true, enabled: true,
timezone: briefing.timezone, timezone: briefing.timezone,
model_tier: briefing.model_tier, model_tier: briefing.model_tier,
once_per_local_day: briefing.dedupe_per_local_day,
}); });
} }
} }
+5
View File
@@ -863,6 +863,7 @@ describe('configSchema automation', () => {
expect(result.automation.daily_briefing.enabled).toBe(false); expect(result.automation.daily_briefing.enabled).toBe(false);
expect(result.automation.daily_briefing.schedule).toBe('0 8 * * *'); expect(result.automation.daily_briefing.schedule).toBe('0 8 * * *');
expect(result.automation.daily_briefing.name).toBe('daily-briefing'); expect(result.automation.daily_briefing.name).toBe('daily-briefing');
expect(result.automation.daily_briefing.dedupe_per_local_day).toBe(true);
}); });
it('accepts isolated automation delivery mode', () => { it('accepts isolated automation delivery mode', () => {
@@ -931,11 +932,13 @@ describe('configSchema automation', () => {
output: { channel: 'telegram', peer: '123' }, output: { channel: 'telegram', peer: '123' },
enabled: false, enabled: false,
timezone: 'America/New_York', timezone: 'America/New_York',
once_per_local_day: true,
}], }],
}, },
}); });
expect(result.automation.cron[0].enabled).toBe(false); expect(result.automation.cron[0].enabled).toBe(false);
expect(result.automation.cron[0].timezone).toBe('America/New_York'); expect(result.automation.cron[0].timezone).toBe('America/New_York');
expect(result.automation.cron[0].once_per_local_day).toBe(true);
}); });
it('accepts daily briefing automation config', () => { it('accepts daily briefing automation config', () => {
@@ -947,6 +950,7 @@ describe('configSchema automation', () => {
name: 'weekday-briefing', name: 'weekday-briefing',
schedule: '0 7 * * 1-5', schedule: '0 7 * * 1-5',
timezone: 'America/New_York', timezone: 'America/New_York',
dedupe_per_local_day: false,
output: { channel: 'telegram', peer: '123' }, output: { channel: 'telegram', peer: '123' },
prompt: 'Custom briefing prompt', prompt: 'Custom briefing prompt',
model_tier: 'fast', model_tier: 'fast',
@@ -958,6 +962,7 @@ describe('configSchema automation', () => {
expect(result.automation.daily_briefing.name).toBe('weekday-briefing'); expect(result.automation.daily_briefing.name).toBe('weekday-briefing');
expect(result.automation.daily_briefing.schedule).toBe('0 7 * * 1-5'); expect(result.automation.daily_briefing.schedule).toBe('0 7 * * 1-5');
expect(result.automation.daily_briefing.timezone).toBe('America/New_York'); expect(result.automation.daily_briefing.timezone).toBe('America/New_York');
expect(result.automation.daily_briefing.dedupe_per_local_day).toBe(false);
expect(result.automation.daily_briefing.output).toEqual({ channel: 'telegram', peer: '123' }); expect(result.automation.daily_briefing.output).toEqual({ channel: 'telegram', peer: '123' });
expect(result.automation.daily_briefing.prompt).toBe('Custom briefing prompt'); expect(result.automation.daily_briefing.prompt).toBe('Custom briefing prompt');
expect(result.automation.daily_briefing.model_tier).toBe('fast'); expect(result.automation.daily_briefing.model_tier).toBe('fast');
+2
View File
@@ -251,6 +251,7 @@ const cronJobSchema = z.object({
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
timezone: z.string().optional(), timezone: z.string().optional(),
model_tier: modelTierEnum.optional(), model_tier: modelTierEnum.optional(),
once_per_local_day: z.boolean().default(false),
}); });
const webhookSchema = z.object({ const webhookSchema = z.object({
@@ -350,6 +351,7 @@ const dailyBriefingSchema = z.object({
name: z.string().min(1).default('daily-briefing'), name: z.string().min(1).default('daily-briefing'),
schedule: z.string().min(1).default('0 8 * * *'), schedule: z.string().min(1).default('0 8 * * *'),
timezone: z.string().optional(), timezone: z.string().optional(),
dedupe_per_local_day: z.boolean().default(true),
output: z.object({ output: z.object({
channel: z.string().min(1), channel: z.string().min(1),
peer: z.string().min(1), peer: z.string().min(1),
+7 -1
View File
@@ -24,7 +24,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
const lines = jobNames.map((name) => { const lines = jobNames.map((name) => {
const job = scheduler.getJob(name); const job = scheduler.getJob(name);
if (!job) {return `- ${name}`;} if (!job) {return `- ${name}`;}
return `- **${name}** — schedule: \`${job.schedule}\`, enabled: ${job.enabled}, output: ${job.output.channel}/${job.output.peer}\n message: "${job.message.length > 80 ? job.message.slice(0, 80) + '...' : job.message}"`; return `- **${name}** — schedule: \`${job.schedule}\`, enabled: ${job.enabled}, output: ${job.output.channel}/${job.output.peer}, once_per_local_day: ${job.once_per_local_day}\n message: "${job.message.length > 80 ? job.message.slice(0, 80) + '...' : job.message}"`;
}); });
return { return {
success: true, success: true,
@@ -113,6 +113,10 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
type: 'string', type: 'string',
description: 'IANA timezone (e.g. "America/Los_Angeles"). Defaults to system timezone.', description: 'IANA timezone (e.g. "America/Los_Angeles"). Defaults to system timezone.',
}, },
once_per_local_day: {
type: 'boolean',
description: 'If true, suppress duplicate triggers within the same local day for this job (uses job timezone).',
},
}, },
required: ['name', 'schedule', 'message'], required: ['name', 'schedule', 'message'],
}, },
@@ -124,6 +128,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
output_channel?: string; output_channel?: string;
output_peer?: string; output_peer?: string;
timezone?: string; timezone?: string;
once_per_local_day?: boolean;
}; };
try { try {
@@ -149,6 +154,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
}, },
enabled: true, enabled: true,
timezone: args.timezone, timezone: args.timezone,
once_per_local_day: args.once_per_local_day ?? false,
}); });
if (!created) { if (!created) {