feat(automation): add timezone-safe daily briefing dedupe
This commit is contained in:
@@ -532,6 +532,7 @@ automation:
|
||||
peer: "123456789" # Chat ID to send to
|
||||
timezone: Europe/London # Optional timezone
|
||||
enabled: true
|
||||
once_per_local_day: false # Optional dedupe: max one trigger/day in job timezone
|
||||
|
||||
- name: hourly-check
|
||||
schedule: "0 * * * *" # Every hour
|
||||
@@ -549,6 +550,7 @@ automation:
|
||||
name: daily-briefing
|
||||
schedule: "0 8 * * *"
|
||||
timezone: America/New_York
|
||||
dedupe_per_local_day: true
|
||||
output:
|
||||
channel: telegram
|
||||
peer: "123456789"
|
||||
@@ -571,6 +573,7 @@ automation:
|
||||
| `timezone` | no | IANA timezone (defaults to system timezone) |
|
||||
| `enabled` | no | Whether the job is active (default: `true`) |
|
||||
| `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 |
|
||||
|
||||
## Backup Scheduling
|
||||
|
||||
@@ -244,6 +244,7 @@ hooks:
|
||||
# output:
|
||||
# channel: telegram
|
||||
# peer: "123456789"
|
||||
# once_per_local_day: false
|
||||
#
|
||||
# # Optional built-in morning briefing job (auto-registered as a cron job)
|
||||
# daily_briefing:
|
||||
@@ -251,6 +252,7 @@ hooks:
|
||||
# name: daily-briefing
|
||||
# schedule: "0 8 * * *"
|
||||
# timezone: America/New_York
|
||||
# dedupe_per_local_day: true
|
||||
# output:
|
||||
# channel: telegram
|
||||
# peer: "123456789"
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
"status": "completed",
|
||||
"date": "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": [
|
||||
"src/config/schema.ts",
|
||||
"src/config/schema.test.ts",
|
||||
"src/automation/index.ts",
|
||||
"src/automation/presets.ts",
|
||||
"src/automation/presets.test.ts",
|
||||
"src/automation/cron.ts",
|
||||
"src/automation/cron.test.ts",
|
||||
"src/automation/heartbeat.ts",
|
||||
"src/automation/heartbeat.test.ts",
|
||||
"src/backup/index.ts",
|
||||
@@ -27,10 +29,11 @@
|
||||
"src/daemon/services.ts",
|
||||
"src/gateway/handlers/services.ts",
|
||||
"src/gateway/handlers/services.test.ts",
|
||||
"src/tools/builtin/cron.ts",
|
||||
"config/default.yaml",
|
||||
"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": {
|
||||
"status": "completed",
|
||||
@@ -3316,7 +3319,7 @@
|
||||
}
|
||||
},
|
||||
"overall_progress": {
|
||||
"total_test_count": 1832,
|
||||
"total_test_count": 1844,
|
||||
"all_tests_passing": true,
|
||||
"p0_completion": "3/3 (100%)",
|
||||
"p1_completion": "4/4 (100%)",
|
||||
|
||||
@@ -14,6 +14,7 @@ function makeCronJob(overrides?: Partial<CronJobConfig>): CronJobConfig {
|
||||
message: 'Hello from cron',
|
||||
output: { channel: 'telegram', peer: '123' },
|
||||
enabled: true,
|
||||
once_per_local_day: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -176,4 +177,38 @@ describe('CronScheduler', () => {
|
||||
const names = scheduler.getJobNames();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export class CronScheduler implements ChannelAdapter {
|
||||
private messageHandler?: (msg: InboundMessage) => void;
|
||||
private cronInstances: Map<string, Cron> = new Map();
|
||||
private jobs: Map<string, CronJobConfig> = new Map();
|
||||
private lastTriggeredLocalDateByJob: Map<string, string> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly jobConfigs: CronJobConfig[],
|
||||
@@ -89,6 +90,14 @@ export class CronScheduler implements ChannelAdapter {
|
||||
triggerJob(jobName: string): void {
|
||||
const job = this.jobs.get(jobName);
|
||||
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 senderId = this.deliveryMode === 'isolated_job' ? `${jobName}:${runId}` : jobName;
|
||||
|
||||
@@ -174,9 +183,37 @@ export class CronScheduler implements ChannelAdapter {
|
||||
}
|
||||
|
||||
this.jobs.delete(name);
|
||||
this.lastTriggeredLocalDateByJob.delete(name);
|
||||
|
||||
auditLogger?.cronRemove(name);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('buildPresetCronJobs', () => {
|
||||
enabled: true,
|
||||
schedule: '0 7 * * *',
|
||||
timezone: 'America/New_York',
|
||||
dedupe_per_local_day: true,
|
||||
output: { channel: 'telegram', peer: '1' },
|
||||
model_tier: 'fast',
|
||||
prompt: 'Daily briefing prompt',
|
||||
@@ -28,9 +29,28 @@ describe('buildPresetCronJobs', () => {
|
||||
output: { channel: 'telegram', peer: '1' },
|
||||
model_tier: 'fast',
|
||||
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', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const config = configSchema.parse({
|
||||
|
||||
@@ -23,6 +23,7 @@ export function buildPresetCronJobs(config: Config): CronJobConfig[] {
|
||||
enabled: true,
|
||||
timezone: briefing.timezone,
|
||||
model_tier: briefing.model_tier,
|
||||
once_per_local_day: briefing.dedupe_per_local_day,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,6 +863,7 @@ describe('configSchema automation', () => {
|
||||
expect(result.automation.daily_briefing.enabled).toBe(false);
|
||||
expect(result.automation.daily_briefing.schedule).toBe('0 8 * * *');
|
||||
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', () => {
|
||||
@@ -931,11 +932,13 @@ describe('configSchema automation', () => {
|
||||
output: { channel: 'telegram', peer: '123' },
|
||||
enabled: false,
|
||||
timezone: 'America/New_York',
|
||||
once_per_local_day: true,
|
||||
}],
|
||||
},
|
||||
});
|
||||
expect(result.automation.cron[0].enabled).toBe(false);
|
||||
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', () => {
|
||||
@@ -947,6 +950,7 @@ describe('configSchema automation', () => {
|
||||
name: 'weekday-briefing',
|
||||
schedule: '0 7 * * 1-5',
|
||||
timezone: 'America/New_York',
|
||||
dedupe_per_local_day: false,
|
||||
output: { channel: 'telegram', peer: '123' },
|
||||
prompt: 'Custom briefing prompt',
|
||||
model_tier: 'fast',
|
||||
@@ -958,6 +962,7 @@ describe('configSchema automation', () => {
|
||||
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.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.prompt).toBe('Custom briefing prompt');
|
||||
expect(result.automation.daily_briefing.model_tier).toBe('fast');
|
||||
|
||||
@@ -251,6 +251,7 @@ const cronJobSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
timezone: z.string().optional(),
|
||||
model_tier: modelTierEnum.optional(),
|
||||
once_per_local_day: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const webhookSchema = z.object({
|
||||
@@ -350,6 +351,7 @@ const dailyBriefingSchema = z.object({
|
||||
name: z.string().min(1).default('daily-briefing'),
|
||||
schedule: z.string().min(1).default('0 8 * * *'),
|
||||
timezone: z.string().optional(),
|
||||
dedupe_per_local_day: z.boolean().default(true),
|
||||
output: z.object({
|
||||
channel: z.string().min(1),
|
||||
peer: z.string().min(1),
|
||||
|
||||
@@ -24,7 +24,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
|
||||
const lines = jobNames.map((name) => {
|
||||
const job = scheduler.getJob(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 {
|
||||
success: true,
|
||||
@@ -113,6 +113,10 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
|
||||
type: 'string',
|
||||
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'],
|
||||
},
|
||||
@@ -124,6 +128,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
|
||||
output_channel?: string;
|
||||
output_peer?: string;
|
||||
timezone?: string;
|
||||
once_per_local_day?: boolean;
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -149,6 +154,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
|
||||
},
|
||||
enabled: true,
|
||||
timezone: args.timezone,
|
||||
once_per_local_day: args.once_per_local_day ?? false,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
|
||||
Reference in New Issue
Block a user