macroclaw 0.23.0 → 0.25.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/package.json +1 -1
- package/src/app.ts +3 -3
- package/src/scheduler.test.ts +423 -0
- package/src/scheduler.ts +160 -0
- package/workspace-template/.claude/skills/schedule/SKILL.md +24 -17
- package/src/cron.test.ts +0 -340
- package/src/cron.ts +0 -116
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Bot } from "grammy";
|
|
2
|
-
import { CronScheduler } from "./cron";
|
|
3
2
|
import { createLogger } from "./logger";
|
|
4
3
|
import { type Claude, Orchestrator, type OrchestratorResponse } from "./orchestrator";
|
|
4
|
+
import { Scheduler } from "./scheduler";
|
|
5
5
|
import type { SpeechToText } from "./speech-to-text";
|
|
6
6
|
import { createBot, downloadFile, sendFile, sendResponse } from "./telegram";
|
|
7
7
|
|
|
@@ -42,10 +42,10 @@ export class App {
|
|
|
42
42
|
|
|
43
43
|
start() {
|
|
44
44
|
log.info("Starting macroclaw...");
|
|
45
|
-
const
|
|
45
|
+
const scheduler = new Scheduler(this.#config.workspace, {
|
|
46
46
|
onJob: (name, prompt, model) => this.#orchestrator.handleCron(name, prompt, model),
|
|
47
47
|
});
|
|
48
|
-
|
|
48
|
+
scheduler.start();
|
|
49
49
|
this.#bot.api.setMyCommands([
|
|
50
50
|
{ command: "chatid", description: "Show current chat ID" },
|
|
51
51
|
{ command: "session", description: "Show current session ID" },
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Scheduler } from "./scheduler";
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = join(import.meta.dir, "..", ".test-workspace-scheduler");
|
|
7
|
+
const SCHEDULE_DIR = join(TEST_DIR, "data");
|
|
8
|
+
const SCHEDULE_FILE = join(SCHEDULE_DIR, "schedule.json");
|
|
9
|
+
|
|
10
|
+
function writeScheduleConfig(config: any) {
|
|
11
|
+
mkdirSync(SCHEDULE_DIR, { recursive: true });
|
|
12
|
+
writeFileSync(SCHEDULE_FILE, JSON.stringify(config));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readScheduleConfig() {
|
|
16
|
+
return JSON.parse(readFileSync(SCHEDULE_FILE, "utf-8"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeOnJob() {
|
|
20
|
+
return mock((_name: string, _prompt: string, _model?: string) => {});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Build a cron expression that matches the current minute
|
|
24
|
+
function currentMinuteCron(): string {
|
|
25
|
+
const now = new Date();
|
|
26
|
+
return `${now.getMinutes()} ${now.getHours()} * * *`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Build a cron expression that never matches now
|
|
30
|
+
function nonMatchingCron(): string {
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const otherMinute = (now.getMinutes() + 30) % 60;
|
|
33
|
+
return `${otherMinute} ${(now.getHours() + 12) % 24} * * *`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build an ISO fireAt string N minutes in the past with timezone offset
|
|
37
|
+
function minutesAgoFireAt(minutesAgo: number): string {
|
|
38
|
+
return toIsoWithOffset(new Date(Date.now() - minutesAgo * 60_000));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Build an ISO fireAt string N days in the past with timezone offset
|
|
42
|
+
function daysAgoFireAt(daysAgo: number): string {
|
|
43
|
+
return toIsoWithOffset(new Date(Date.now() - daysAgo * 24 * 60 * 60_000));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Build an ISO fireAt string N minutes in the future with timezone offset
|
|
47
|
+
function minutesFromNowFireAt(minutes: number): string {
|
|
48
|
+
return toIsoWithOffset(new Date(Date.now() + minutes * 60_000));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Format a Date as ISO 8601 with +00:00 offset (not Z)
|
|
52
|
+
function toIsoWithOffset(date: Date): string {
|
|
53
|
+
return date.toISOString().replace("Z", "+00:00");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Build a fireAt string for ~30 seconds ago (within 60s window)
|
|
57
|
+
function justNowFireAt(): string {
|
|
58
|
+
return toIsoWithOffset(new Date(Date.now() - 30_000));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
mkdirSync(SCHEDULE_DIR, { recursive: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("Scheduler — cron jobs", () => {
|
|
70
|
+
it("calls onJob for matching cron job", () => {
|
|
71
|
+
writeScheduleConfig({
|
|
72
|
+
jobs: [{ name: "test-job", cron: currentMinuteCron(), prompt: "do something" }],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const onJob = makeOnJob();
|
|
76
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
77
|
+
s.start();
|
|
78
|
+
s.stop();
|
|
79
|
+
|
|
80
|
+
expect(onJob).toHaveBeenCalledWith("test-job", "do something", undefined);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not call onJob for non-matching jobs", () => {
|
|
84
|
+
writeScheduleConfig({
|
|
85
|
+
jobs: [{ name: "later", cron: nonMatchingCron(), prompt: "not now" }],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const onJob = makeOnJob();
|
|
89
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
90
|
+
s.start();
|
|
91
|
+
s.stop();
|
|
92
|
+
|
|
93
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("skips invalid cron expression and processes valid jobs", () => {
|
|
97
|
+
writeScheduleConfig({
|
|
98
|
+
jobs: [
|
|
99
|
+
{ name: "bad", cron: "invalid cron", prompt: "bad" },
|
|
100
|
+
{ name: "good", cron: currentMinuteCron(), prompt: "good" },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const onJob = makeOnJob();
|
|
105
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
106
|
+
s.start();
|
|
107
|
+
s.stop();
|
|
108
|
+
|
|
109
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(onJob).toHaveBeenCalledWith("good", "good", undefined);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("handles multiple matching jobs", () => {
|
|
114
|
+
writeScheduleConfig({
|
|
115
|
+
jobs: [
|
|
116
|
+
{ name: "first", cron: currentMinuteCron(), prompt: "first" },
|
|
117
|
+
{ name: "second", cron: currentMinuteCron(), prompt: "second" },
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const onJob = makeOnJob();
|
|
122
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
123
|
+
s.start();
|
|
124
|
+
s.stop();
|
|
125
|
+
|
|
126
|
+
expect(onJob).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(onJob).toHaveBeenCalledWith("first", "first", undefined);
|
|
128
|
+
expect(onJob).toHaveBeenCalledWith("second", "second", undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("passes model override to onJob", () => {
|
|
132
|
+
writeScheduleConfig({
|
|
133
|
+
jobs: [{ name: "smart", cron: currentMinuteCron(), prompt: "think hard", model: "opus" }],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const onJob = makeOnJob();
|
|
137
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
138
|
+
s.start();
|
|
139
|
+
s.stop();
|
|
140
|
+
|
|
141
|
+
expect(onJob).toHaveBeenCalledWith("smart", "think hard", "opus");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("never removes cron jobs after firing", () => {
|
|
145
|
+
writeScheduleConfig({
|
|
146
|
+
jobs: [{ name: "keeper", cron: currentMinuteCron(), prompt: "stay" }],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const onJob = makeOnJob();
|
|
150
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
151
|
+
s.start();
|
|
152
|
+
s.stop();
|
|
153
|
+
|
|
154
|
+
const updated = readScheduleConfig();
|
|
155
|
+
expect(updated.jobs).toHaveLength(1);
|
|
156
|
+
expect(updated.jobs[0].name).toBe("keeper");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("Scheduler — fireAt jobs", () => {
|
|
161
|
+
it("fires one-shot job when fireAt is within 60s of now", () => {
|
|
162
|
+
writeScheduleConfig({
|
|
163
|
+
jobs: [{ name: "now", fireAt: justNowFireAt(), prompt: "do it" }],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const onJob = makeOnJob();
|
|
167
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
168
|
+
s.start();
|
|
169
|
+
s.stop();
|
|
170
|
+
|
|
171
|
+
expect(onJob).toHaveBeenCalledWith("now", "do it", undefined);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("removes one-shot job after firing", () => {
|
|
175
|
+
writeScheduleConfig({
|
|
176
|
+
jobs: [
|
|
177
|
+
{ name: "once", fireAt: justNowFireAt(), prompt: "one-time" },
|
|
178
|
+
{ name: "recurring", cron: currentMinuteCron(), prompt: "forever" },
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const onJob = makeOnJob();
|
|
183
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
184
|
+
s.start();
|
|
185
|
+
s.stop();
|
|
186
|
+
|
|
187
|
+
expect(onJob).toHaveBeenCalledTimes(2);
|
|
188
|
+
const updated = readScheduleConfig();
|
|
189
|
+
expect(updated.jobs).toHaveLength(1);
|
|
190
|
+
expect(updated.jobs[0].name).toBe("recurring");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("skips upcoming fireAt job (in the future)", () => {
|
|
194
|
+
writeScheduleConfig({
|
|
195
|
+
jobs: [{ name: "later", fireAt: minutesFromNowFireAt(60), prompt: "not yet" }],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const onJob = makeOnJob();
|
|
199
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
200
|
+
s.start();
|
|
201
|
+
s.stop();
|
|
202
|
+
|
|
203
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
204
|
+
|
|
205
|
+
const updated = readScheduleConfig();
|
|
206
|
+
expect(updated.jobs).toHaveLength(1);
|
|
207
|
+
expect(updated.jobs[0].name).toBe("later");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("fires missed one-shot job with missed prefix", () => {
|
|
211
|
+
writeScheduleConfig({
|
|
212
|
+
jobs: [{ name: "reminder", fireAt: minutesAgoFireAt(3), prompt: "buy milk" }],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const onJob = makeOnJob();
|
|
216
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
217
|
+
s.start();
|
|
218
|
+
s.stop();
|
|
219
|
+
|
|
220
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
221
|
+
const call = onJob.mock.calls[0];
|
|
222
|
+
expect(call[0]).toBe("reminder");
|
|
223
|
+
expect(call[1]).toContain("[missed event, should have fired");
|
|
224
|
+
expect(call[1]).toContain("min ago at");
|
|
225
|
+
expect(call[1]).toContain("buy milk");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("removes missed one-shot job after firing", () => {
|
|
229
|
+
writeScheduleConfig({
|
|
230
|
+
jobs: [
|
|
231
|
+
{ name: "missed", fireAt: minutesAgoFireAt(3), prompt: "do it" },
|
|
232
|
+
{ name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const onJob = makeOnJob();
|
|
237
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
238
|
+
s.start();
|
|
239
|
+
s.stop();
|
|
240
|
+
|
|
241
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
242
|
+
const updated = readScheduleConfig();
|
|
243
|
+
expect(updated.jobs).toHaveLength(1);
|
|
244
|
+
expect(updated.jobs[0].name).toBe("keeper");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("fires one-shot job missed by 6 days (within week window)", () => {
|
|
248
|
+
writeScheduleConfig({
|
|
249
|
+
jobs: [{ name: "recent", fireAt: daysAgoFireAt(6), prompt: "still valid" }],
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const onJob = makeOnJob();
|
|
253
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
254
|
+
s.start();
|
|
255
|
+
s.stop();
|
|
256
|
+
|
|
257
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
258
|
+
const call = onJob.mock.calls[0];
|
|
259
|
+
expect(call[0]).toBe("recent");
|
|
260
|
+
expect(call[1]).toContain("[missed event, should have fired");
|
|
261
|
+
expect(call[1]).toContain("still valid");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("discards stale one-shot job (older than a week) without firing", () => {
|
|
265
|
+
writeScheduleConfig({
|
|
266
|
+
jobs: [
|
|
267
|
+
{ name: "stale", fireAt: daysAgoFireAt(10), prompt: "too old" },
|
|
268
|
+
{ name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const onJob = makeOnJob();
|
|
273
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
274
|
+
s.start();
|
|
275
|
+
s.stop();
|
|
276
|
+
|
|
277
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
278
|
+
|
|
279
|
+
const updated = readScheduleConfig();
|
|
280
|
+
expect(updated.jobs).toHaveLength(1);
|
|
281
|
+
expect(updated.jobs[0].name).toBe("keeper");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("passes model override for fireAt jobs", () => {
|
|
285
|
+
writeScheduleConfig({
|
|
286
|
+
jobs: [{ name: "smart", fireAt: justNowFireAt(), prompt: "think", model: "opus" }],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const onJob = makeOnJob();
|
|
290
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
291
|
+
s.start();
|
|
292
|
+
s.stop();
|
|
293
|
+
|
|
294
|
+
expect(onJob).toHaveBeenCalledWith("smart", "think", "opus");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("still fires when write-back of schedule.json fails", () => {
|
|
298
|
+
writeScheduleConfig({
|
|
299
|
+
jobs: [{ name: "once", fireAt: justNowFireAt(), prompt: "fire" }],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const { chmodSync } = require("node:fs");
|
|
303
|
+
chmodSync(SCHEDULE_FILE, 0o444);
|
|
304
|
+
|
|
305
|
+
const onJob = makeOnJob();
|
|
306
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
307
|
+
s.start();
|
|
308
|
+
s.stop();
|
|
309
|
+
|
|
310
|
+
chmodSync(SCHEDULE_FILE, 0o644);
|
|
311
|
+
|
|
312
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("Scheduler — validation and edge cases", () => {
|
|
317
|
+
it("silently skips when schedule.json does not exist", () => {
|
|
318
|
+
rmSync(SCHEDULE_FILE, { force: true });
|
|
319
|
+
|
|
320
|
+
const onJob = makeOnJob();
|
|
321
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
322
|
+
s.start();
|
|
323
|
+
s.stop();
|
|
324
|
+
|
|
325
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("does not call onJob on malformed JSON", () => {
|
|
329
|
+
writeFileSync(SCHEDULE_FILE, "not json{{{");
|
|
330
|
+
|
|
331
|
+
const onJob = makeOnJob();
|
|
332
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
333
|
+
s.start();
|
|
334
|
+
s.stop();
|
|
335
|
+
|
|
336
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("does not call onJob when jobs is not an array", () => {
|
|
340
|
+
writeScheduleConfig({ jobs: "not-array" });
|
|
341
|
+
|
|
342
|
+
const onJob = makeOnJob();
|
|
343
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
344
|
+
s.start();
|
|
345
|
+
s.stop();
|
|
346
|
+
|
|
347
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("skips job with both cron and fireAt", () => {
|
|
351
|
+
writeScheduleConfig({
|
|
352
|
+
jobs: [{ name: "bad", cron: currentMinuteCron(), fireAt: justNowFireAt(), prompt: "oops" }],
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const onJob = makeOnJob();
|
|
356
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
357
|
+
s.start();
|
|
358
|
+
s.stop();
|
|
359
|
+
|
|
360
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("skips fireAt with unparseable date", () => {
|
|
364
|
+
writeScheduleConfig({
|
|
365
|
+
jobs: [{ name: "bad", fireAt: "not-a-date", prompt: "invalid" }],
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const onJob = makeOnJob();
|
|
369
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
370
|
+
s.start();
|
|
371
|
+
s.stop();
|
|
372
|
+
|
|
373
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
374
|
+
|
|
375
|
+
// Job should remain (not removed, just skipped)
|
|
376
|
+
const updated = readScheduleConfig();
|
|
377
|
+
expect(updated.jobs).toHaveLength(1);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("stop clears the interval", () => {
|
|
381
|
+
writeScheduleConfig({ jobs: [] });
|
|
382
|
+
|
|
383
|
+
const onJob = makeOnJob();
|
|
384
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
385
|
+
s.start();
|
|
386
|
+
s.stop(); // should not throw
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("only evaluates once per minute", () => {
|
|
390
|
+
writeScheduleConfig({
|
|
391
|
+
jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "once" }],
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const onJob = makeOnJob();
|
|
395
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
396
|
+
s.start();
|
|
397
|
+
s.stop();
|
|
398
|
+
|
|
399
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
400
|
+
|
|
401
|
+
// Start again with a new instance — the lastMinute tracker is per-instance
|
|
402
|
+
const onJob2 = makeOnJob();
|
|
403
|
+
const s2 = new Scheduler(TEST_DIR, { onJob: onJob2 });
|
|
404
|
+
s2.start();
|
|
405
|
+
s2.stop();
|
|
406
|
+
|
|
407
|
+
expect(onJob2).toHaveBeenCalledTimes(1);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("does not write file when no one-shot jobs were processed", () => {
|
|
411
|
+
writeScheduleConfig({
|
|
412
|
+
jobs: [{ name: "recurring", cron: nonMatchingCron(), prompt: "nope" }],
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const onJob = makeOnJob();
|
|
416
|
+
const s = new Scheduler(TEST_DIR, { onJob });
|
|
417
|
+
s.start();
|
|
418
|
+
s.stop();
|
|
419
|
+
|
|
420
|
+
const updated = readScheduleConfig();
|
|
421
|
+
expect(updated.jobs).toHaveLength(1);
|
|
422
|
+
});
|
|
423
|
+
});
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CronExpressionParser } from "cron-parser";
|
|
4
|
+
import { z } from "zod/v4";
|
|
5
|
+
import { createLogger } from "./logger";
|
|
6
|
+
|
|
7
|
+
const log = createLogger("scheduler");
|
|
8
|
+
|
|
9
|
+
const jobSchema = z.object({
|
|
10
|
+
name: z.string(),
|
|
11
|
+
prompt: z.string(),
|
|
12
|
+
model: z.string().optional(),
|
|
13
|
+
cron: z.string().optional(),
|
|
14
|
+
fireAt: z.string().optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const scheduleConfigSchema = z.object({
|
|
18
|
+
jobs: z.array(jobSchema),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type ScheduleConfig = z.infer<typeof scheduleConfigSchema>;
|
|
22
|
+
type Job = z.infer<typeof jobSchema>;
|
|
23
|
+
|
|
24
|
+
export interface SchedulerConfig {
|
|
25
|
+
onJob: (name: string, prompt: string, model?: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TICK_INTERVAL = 10_000; // 10 seconds
|
|
29
|
+
const MAX_MISSED_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
|
|
30
|
+
|
|
31
|
+
export class Scheduler {
|
|
32
|
+
#lastMinute = -1;
|
|
33
|
+
#schedulePath: string;
|
|
34
|
+
#config: SchedulerConfig;
|
|
35
|
+
#timer: Timer | null = null;
|
|
36
|
+
|
|
37
|
+
constructor(workspace: string, config: SchedulerConfig) {
|
|
38
|
+
this.#schedulePath = join(workspace, "data", "schedule.json");
|
|
39
|
+
this.#config = config;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
start(): void {
|
|
43
|
+
this.#tick();
|
|
44
|
+
this.#timer = setInterval(() => this.#tick(), TICK_INTERVAL);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
stop(): void {
|
|
48
|
+
if (this.#timer) {
|
|
49
|
+
clearInterval(this.#timer);
|
|
50
|
+
this.#timer = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#tick(): void {
|
|
55
|
+
const now = new Date();
|
|
56
|
+
const currentMinute =
|
|
57
|
+
now.getMinutes() + now.getHours() * 60 + now.getDate() * 1440 + now.getMonth() * 43200;
|
|
58
|
+
|
|
59
|
+
// Only evaluate once per minute
|
|
60
|
+
if (currentMinute === this.#lastMinute) return;
|
|
61
|
+
this.#lastMinute = currentMinute;
|
|
62
|
+
|
|
63
|
+
let config: ScheduleConfig;
|
|
64
|
+
try {
|
|
65
|
+
const raw = readFileSync(this.#schedulePath, "utf-8");
|
|
66
|
+
const parsed = scheduleConfigSchema.safeParse(JSON.parse(raw));
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
log.warn("schedule.json validation failed");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
config = parsed.data;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") return;
|
|
74
|
+
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to read schedule.json");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const removedIndices: number[] = [];
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < config.jobs.length; i++) {
|
|
81
|
+
const job = config.jobs[i];
|
|
82
|
+
if (job.cron && job.fireAt) {
|
|
83
|
+
log.warn({ name: job.name }, "Job has both cron and fireAt, skipping");
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (job.cron) {
|
|
87
|
+
this.#evaluateCronJob(job as Job & { cron: string }, now);
|
|
88
|
+
} else if (job.fireAt) {
|
|
89
|
+
try {
|
|
90
|
+
const result = this.#evaluateFireAtJob(job as Job & { fireAt: string }, now);
|
|
91
|
+
if (result === "remove") removedIndices.push(i);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log.warn({ name: job.name, fireAt: job.fireAt, err: err instanceof Error ? err.message : err }, "Failed to evaluate fireAt job");
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
log.warn({ name: job.name }, "Job has neither cron nor fireAt, skipping");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (removedIndices.length > 0) {
|
|
101
|
+
for (let i = removedIndices.length - 1; i >= 0; i--) {
|
|
102
|
+
config.jobs.splice(removedIndices[i], 1);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
writeFileSync(this.#schedulePath, `${JSON.stringify(config, null, 2)}\n`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to write schedule.json");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#evaluateCronJob(job: { name: string; cron: string; prompt: string; model?: string }, now: Date): void {
|
|
113
|
+
try {
|
|
114
|
+
const interval = CronExpressionParser.parse(job.cron);
|
|
115
|
+
const prev = interval.prev();
|
|
116
|
+
const diff = Math.abs(now.getTime() - prev.getTime());
|
|
117
|
+
if (diff < 60_000) {
|
|
118
|
+
log.debug({ name: job.name, cron: job.cron }, "Cron job triggered");
|
|
119
|
+
this.#config.onJob(job.name, job.prompt, job.model);
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#evaluateFireAtJob(
|
|
127
|
+
job: { name: string; fireAt: string; prompt: string; model?: string },
|
|
128
|
+
now: Date,
|
|
129
|
+
): "remove" | "keep" {
|
|
130
|
+
const fireAt = new Date(job.fireAt);
|
|
131
|
+
if (Number.isNaN(fireAt.getTime())) {
|
|
132
|
+
log.warn({ name: job.name, fireAt: job.fireAt }, "Invalid fireAt date");
|
|
133
|
+
return "keep";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const diff = now.getTime() - fireAt.getTime();
|
|
137
|
+
|
|
138
|
+
if (diff < 0) {
|
|
139
|
+
// Upcoming — not yet due
|
|
140
|
+
return "keep";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (diff < 60_000) {
|
|
144
|
+
log.debug({ name: job.name, fireAt: job.fireAt }, "One-shot job triggered");
|
|
145
|
+
this.#config.onJob(job.name, job.prompt, job.model);
|
|
146
|
+
return "remove";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (diff <= MAX_MISSED_MS) {
|
|
150
|
+
const missedMinutes = Math.round(diff / 60_000);
|
|
151
|
+
const missedPrompt = `[missed event, should have fired ${missedMinutes} min ago at ${job.fireAt}] ${job.prompt}`;
|
|
152
|
+
log.info({ name: job.name, missedMinutes, fireAt: job.fireAt }, "Firing missed one-shot job");
|
|
153
|
+
this.#config.onJob(job.name, missedPrompt, job.model);
|
|
154
|
+
return "remove";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
log.warn({ name: job.name, missedMinutes: Math.round(diff / 60_000) }, "Discarding stale one-shot job");
|
|
158
|
+
return "remove";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -18,26 +18,32 @@ Schedule a new event by adding it to `data/schedule.json`.
|
|
|
18
18
|
|
|
19
19
|
1. Run `date` to get the current date and time
|
|
20
20
|
2. Read `data/schedule.json` (create with `{"jobs": []}` if missing)
|
|
21
|
-
3.
|
|
21
|
+
3. Determine job type:
|
|
22
|
+
- **Recurring** → convert to a cron expression (UTC). See reference below.
|
|
23
|
+
- **One-time** → compute an ISO 8601 timestamp with the user's timezone offset for `fireAt`
|
|
22
24
|
4. **Be proactive about timing**: if the user says "next week" or "tomorrow" without a specific time, pick the best time based on what you know (their routine, calendar, context)
|
|
23
25
|
5. Append the new job to the `jobs` array
|
|
24
26
|
6. Write the updated file
|
|
25
27
|
7. Confirm: what was scheduled, when it will fire, and offer to adjust
|
|
26
28
|
|
|
27
|
-
## Natural language →
|
|
29
|
+
## Natural language → job format
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
The user's timezone is in their profile (CLAUDE.md/USER.md).
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
- "in 5 minutes" → compute
|
|
33
|
-
- "tomorrow at 10am" → `
|
|
34
|
-
- "
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
- "every
|
|
33
|
+
**One-time events** use `fireAt` with the user's timezone offset:
|
|
34
|
+
- "in 5 minutes" → compute exact time, e.g. `"fireAt": "2026-03-16T10:05:00+01:00"`
|
|
35
|
+
- "tomorrow at 10am" → `"fireAt": "2026-03-14T10:00:00+01:00"`
|
|
36
|
+
- "next Monday" → `"fireAt": "2026-03-17T09:00:00+01:00"` (pick a sensible time)
|
|
37
|
+
|
|
38
|
+
**Recurring events** use `cron` in UTC:
|
|
39
|
+
- "every morning" → `"cron": "0 7 * * *"` (adjust for timezone)
|
|
40
|
+
- "every weekday at 9" → `"cron": "0 7 * * 1-5"` (UTC)
|
|
41
|
+
- "every 30 minutes" → `"cron": "*/30 * * * *"`
|
|
38
42
|
|
|
39
43
|
## schedule.json format
|
|
40
44
|
|
|
45
|
+
Two job types, discriminated by field:
|
|
46
|
+
|
|
41
47
|
```json
|
|
42
48
|
{
|
|
43
49
|
"jobs": [
|
|
@@ -54,9 +60,8 @@ Examples:
|
|
|
54
60
|
},
|
|
55
61
|
{
|
|
56
62
|
"name": "dentist-reminder",
|
|
57
|
-
"
|
|
58
|
-
"prompt": "Reminder: call the dentist to reschedule your appointment"
|
|
59
|
-
"recurring": false
|
|
63
|
+
"fireAt": "2026-03-15T08:00:00+01:00",
|
|
64
|
+
"prompt": "Reminder: call the dentist to reschedule your appointment"
|
|
60
65
|
}
|
|
61
66
|
]
|
|
62
67
|
}
|
|
@@ -67,11 +72,13 @@ Examples:
|
|
|
67
72
|
| Field | Required | Description |
|
|
68
73
|
|-------|----------|-------------|
|
|
69
74
|
| `name` | yes | Short kebab-case identifier (e.g. `dentist-reminder`). Appears in the `[Context: cron/<name>]` prefix when fired. |
|
|
70
|
-
| `cron` |
|
|
75
|
+
| `cron` | for recurring | Standard cron expression (UTC). See reference below. |
|
|
76
|
+
| `fireAt` | for one-time | ISO 8601 timestamp, preferably with timezone offset (e.g. `2026-03-15T08:00:00+01:00`). Any format parseable by JavaScript `Date` works. |
|
|
71
77
|
| `prompt` | yes | The message sent to the agent when the event fires. Write it as a natural instruction. |
|
|
72
|
-
| `recurring` | no | Defaults to `true`. Set to `false` for one-time events — they're automatically removed after firing. |
|
|
73
78
|
| `model` | no | Override the model. Use `haiku` for cheap checks, `opus` for complex reasoning. Omit for default. |
|
|
74
79
|
|
|
80
|
+
Each job must have exactly one of `cron` or `fireAt` (not both).
|
|
81
|
+
|
|
75
82
|
## Cron expression reference (UTC)
|
|
76
83
|
|
|
77
84
|
```
|
|
@@ -95,5 +102,5 @@ Common patterns:
|
|
|
95
102
|
|
|
96
103
|
- Changes are hot-reloaded — no restart needed
|
|
97
104
|
- File location: `<workspace>/data/schedule.json`
|
|
98
|
-
- One-shot events (`
|
|
99
|
-
- Missed one-shot events (e.g. service was down) are fired with a `[missed event]` prefix when the service restarts
|
|
105
|
+
- One-shot events (`fireAt`) are cleaned up automatically after firing
|
|
106
|
+
- Missed one-shot events (e.g. service was down) are fired with a `[missed event]` prefix when the service restarts (up to 7 days late)
|
package/src/cron.test.ts
DELETED
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { CronScheduler } from "./cron";
|
|
5
|
-
|
|
6
|
-
const TEST_DIR = join(import.meta.dir, "..", ".test-workspace-cron");
|
|
7
|
-
const SCHEDULE_DIR = join(TEST_DIR, "data");
|
|
8
|
-
const SCHEDULE_FILE = join(SCHEDULE_DIR, "schedule.json");
|
|
9
|
-
|
|
10
|
-
function writeScheduleConfig(config: any) {
|
|
11
|
-
mkdirSync(SCHEDULE_DIR, { recursive: true });
|
|
12
|
-
writeFileSync(SCHEDULE_FILE, JSON.stringify(config));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function readScheduleConfig() {
|
|
16
|
-
return JSON.parse(readFileSync(SCHEDULE_FILE, "utf-8"));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function makeOnJob() {
|
|
20
|
-
return mock((_name: string, _prompt: string, _model?: string) => {});
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Build a cron expression that matches the current minute
|
|
24
|
-
function currentMinuteCron(): string {
|
|
25
|
-
const now = new Date();
|
|
26
|
-
return `${now.getMinutes()} ${now.getHours()} * * *`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Build a cron expression that never matches now
|
|
30
|
-
function nonMatchingCron(): string {
|
|
31
|
-
const now = new Date();
|
|
32
|
-
const otherMinute = (now.getMinutes() + 30) % 60;
|
|
33
|
-
return `${otherMinute} ${(now.getHours() + 12) % 24} * * *`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Build a cron expression that matched N minutes ago
|
|
37
|
-
function minutesAgoCron(minutesAgo: number): string {
|
|
38
|
-
const past = new Date(Date.now() - minutesAgo * 60_000);
|
|
39
|
-
return `${past.getMinutes()} ${past.getHours()} ${past.getDate()} ${past.getMonth() + 1} *`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
beforeEach(() => {
|
|
43
|
-
mkdirSync(SCHEDULE_DIR, { recursive: true });
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("CronScheduler", () => {
|
|
51
|
-
it("calls onJob for matching cron job", () => {
|
|
52
|
-
writeScheduleConfig({
|
|
53
|
-
jobs: [{ name: "test-job", cron: currentMinuteCron(), prompt: "do something" }],
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const onJob = makeOnJob();
|
|
57
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
58
|
-
cron.start();
|
|
59
|
-
cron.stop();
|
|
60
|
-
|
|
61
|
-
expect(onJob).toHaveBeenCalledWith("test-job", "do something", undefined);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("does not call onJob for non-matching jobs", () => {
|
|
65
|
-
writeScheduleConfig({
|
|
66
|
-
jobs: [{ name: "later", cron: nonMatchingCron(), prompt: "not now" }],
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const onJob = makeOnJob();
|
|
70
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
71
|
-
cron.start();
|
|
72
|
-
cron.stop();
|
|
73
|
-
|
|
74
|
-
expect(onJob).not.toHaveBeenCalled();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("silently skips when schedule.json does not exist", () => {
|
|
78
|
-
rmSync(SCHEDULE_FILE, { force: true });
|
|
79
|
-
|
|
80
|
-
const onJob = makeOnJob();
|
|
81
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
82
|
-
cron.start();
|
|
83
|
-
cron.stop();
|
|
84
|
-
|
|
85
|
-
expect(onJob).not.toHaveBeenCalled();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("does not call onJob on malformed JSON", () => {
|
|
89
|
-
writeFileSync(SCHEDULE_FILE, "not json{{{");
|
|
90
|
-
|
|
91
|
-
const onJob = makeOnJob();
|
|
92
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
93
|
-
cron.start();
|
|
94
|
-
cron.stop();
|
|
95
|
-
|
|
96
|
-
expect(onJob).not.toHaveBeenCalled();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("does not call onJob when jobs is not an array", () => {
|
|
100
|
-
writeScheduleConfig({ jobs: "not-array" });
|
|
101
|
-
|
|
102
|
-
const onJob = makeOnJob();
|
|
103
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
104
|
-
cron.start();
|
|
105
|
-
cron.stop();
|
|
106
|
-
|
|
107
|
-
expect(onJob).not.toHaveBeenCalled();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("skips invalid cron expression and processes valid jobs", () => {
|
|
111
|
-
writeScheduleConfig({
|
|
112
|
-
jobs: [
|
|
113
|
-
{ name: "bad", cron: "invalid cron", prompt: "bad" },
|
|
114
|
-
{ name: "good", cron: currentMinuteCron(), prompt: "good" },
|
|
115
|
-
],
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const onJob = makeOnJob();
|
|
119
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
120
|
-
cron.start();
|
|
121
|
-
cron.stop();
|
|
122
|
-
|
|
123
|
-
expect(onJob).toHaveBeenCalledTimes(1);
|
|
124
|
-
expect(onJob).toHaveBeenCalledWith("good", "good", undefined);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("stop clears the interval", () => {
|
|
128
|
-
writeScheduleConfig({ jobs: [] });
|
|
129
|
-
|
|
130
|
-
const onJob = makeOnJob();
|
|
131
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
132
|
-
cron.start();
|
|
133
|
-
cron.stop(); // should not throw
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("only evaluates once per minute", () => {
|
|
137
|
-
writeScheduleConfig({
|
|
138
|
-
jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "once" }],
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
const onJob = makeOnJob();
|
|
142
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
143
|
-
cron.start();
|
|
144
|
-
cron.stop();
|
|
145
|
-
|
|
146
|
-
expect(onJob).toHaveBeenCalledTimes(1);
|
|
147
|
-
|
|
148
|
-
// Start again with a new instance — the lastMinute tracker is per-instance
|
|
149
|
-
const onJob2 = makeOnJob();
|
|
150
|
-
const cron2 = new CronScheduler(TEST_DIR, { onJob: onJob2 });
|
|
151
|
-
cron2.start();
|
|
152
|
-
cron2.stop();
|
|
153
|
-
|
|
154
|
-
expect(onJob2).toHaveBeenCalledTimes(1);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("handles multiple matching jobs", () => {
|
|
158
|
-
writeScheduleConfig({
|
|
159
|
-
jobs: [
|
|
160
|
-
{ name: "first", cron: currentMinuteCron(), prompt: "first" },
|
|
161
|
-
{ name: "second", cron: currentMinuteCron(), prompt: "second" },
|
|
162
|
-
],
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const onJob = makeOnJob();
|
|
166
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
167
|
-
cron.start();
|
|
168
|
-
cron.stop();
|
|
169
|
-
|
|
170
|
-
expect(onJob).toHaveBeenCalledTimes(2);
|
|
171
|
-
expect(onJob).toHaveBeenCalledWith("first", "first", undefined);
|
|
172
|
-
expect(onJob).toHaveBeenCalledWith("second", "second", undefined);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("passes model override to onJob", () => {
|
|
176
|
-
writeScheduleConfig({
|
|
177
|
-
jobs: [{ name: "smart", cron: currentMinuteCron(), prompt: "think hard", model: "opus" }],
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
const onJob = makeOnJob();
|
|
181
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
182
|
-
cron.start();
|
|
183
|
-
cron.stop();
|
|
184
|
-
|
|
185
|
-
expect(onJob).toHaveBeenCalledWith("smart", "think hard", "opus");
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("removes non-recurring job after it fires", () => {
|
|
189
|
-
writeScheduleConfig({
|
|
190
|
-
jobs: [
|
|
191
|
-
{ name: "once", cron: currentMinuteCron(), prompt: "one-time", recurring: false },
|
|
192
|
-
{ name: "always", cron: currentMinuteCron(), prompt: "forever" },
|
|
193
|
-
],
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
const onJob = makeOnJob();
|
|
197
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
198
|
-
cron.start();
|
|
199
|
-
cron.stop();
|
|
200
|
-
|
|
201
|
-
expect(onJob).toHaveBeenCalledTimes(2);
|
|
202
|
-
|
|
203
|
-
const updated = readScheduleConfig();
|
|
204
|
-
expect(updated.jobs).toHaveLength(1);
|
|
205
|
-
expect(updated.jobs[0].name).toBe("always");
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("keeps recurring jobs (default behavior)", () => {
|
|
209
|
-
writeScheduleConfig({
|
|
210
|
-
jobs: [{ name: "keeper", cron: currentMinuteCron(), prompt: "stay" }],
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
const onJob = makeOnJob();
|
|
214
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
215
|
-
cron.start();
|
|
216
|
-
cron.stop();
|
|
217
|
-
|
|
218
|
-
const updated = readScheduleConfig();
|
|
219
|
-
expect(updated.jobs).toHaveLength(1);
|
|
220
|
-
expect(updated.jobs[0].name).toBe("keeper");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("keeps jobs with recurring: true", () => {
|
|
224
|
-
writeScheduleConfig({
|
|
225
|
-
jobs: [{ name: "explicit", cron: currentMinuteCron(), prompt: "stay", recurring: true }],
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
const onJob = makeOnJob();
|
|
229
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
230
|
-
cron.start();
|
|
231
|
-
cron.stop();
|
|
232
|
-
|
|
233
|
-
const updated = readScheduleConfig();
|
|
234
|
-
expect(updated.jobs).toHaveLength(1);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("still fires job when write-back of schedule.json fails", () => {
|
|
238
|
-
writeScheduleConfig({
|
|
239
|
-
jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "fire", recurring: false }],
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
const { chmodSync } = require("node:fs");
|
|
243
|
-
chmodSync(SCHEDULE_FILE, 0o444);
|
|
244
|
-
|
|
245
|
-
const onJob = makeOnJob();
|
|
246
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
247
|
-
cron.start();
|
|
248
|
-
cron.stop();
|
|
249
|
-
|
|
250
|
-
chmodSync(SCHEDULE_FILE, 0o644);
|
|
251
|
-
|
|
252
|
-
expect(onJob).toHaveBeenCalledTimes(1);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("does not write file when no non-recurring jobs fired", () => {
|
|
256
|
-
writeScheduleConfig({
|
|
257
|
-
jobs: [{ name: "recurring", cron: nonMatchingCron(), prompt: "nope" }],
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
const onJob = makeOnJob();
|
|
261
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
262
|
-
cron.start();
|
|
263
|
-
cron.stop();
|
|
264
|
-
|
|
265
|
-
const updated = readScheduleConfig();
|
|
266
|
-
expect(updated.jobs).toHaveLength(1);
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
describe("missed non-recurring events", () => {
|
|
271
|
-
it("fires missed non-recurring job with missed prefix", () => {
|
|
272
|
-
writeScheduleConfig({
|
|
273
|
-
jobs: [{ name: "reminder", cron: minutesAgoCron(3), prompt: "buy milk", recurring: false }],
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
const onJob = makeOnJob();
|
|
277
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
278
|
-
cron.start();
|
|
279
|
-
cron.stop();
|
|
280
|
-
|
|
281
|
-
expect(onJob).toHaveBeenCalledTimes(1);
|
|
282
|
-
const call = onJob.mock.calls[0];
|
|
283
|
-
expect(call[0]).toBe("reminder");
|
|
284
|
-
expect(call[1]).toContain("[missed event, should have fired");
|
|
285
|
-
expect(call[1]).toContain("min ago at");
|
|
286
|
-
expect(call[1]).toContain("buy milk");
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("removes missed non-recurring job after firing", () => {
|
|
290
|
-
writeScheduleConfig({
|
|
291
|
-
jobs: [
|
|
292
|
-
{ name: "missed", cron: minutesAgoCron(3), prompt: "do it", recurring: false },
|
|
293
|
-
{ name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
|
|
294
|
-
],
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
const onJob = makeOnJob();
|
|
298
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
299
|
-
cron.start();
|
|
300
|
-
cron.stop();
|
|
301
|
-
|
|
302
|
-
expect(onJob).toHaveBeenCalledTimes(1);
|
|
303
|
-
const updated = readScheduleConfig();
|
|
304
|
-
expect(updated.jobs).toHaveLength(1);
|
|
305
|
-
expect(updated.jobs[0].name).toBe("keeper");
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it("does not fire missed recurring jobs", () => {
|
|
309
|
-
writeScheduleConfig({
|
|
310
|
-
jobs: [{ name: "recurring", cron: minutesAgoCron(3), prompt: "repeat" }],
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
const onJob = makeOnJob();
|
|
314
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
315
|
-
cron.start();
|
|
316
|
-
cron.stop();
|
|
317
|
-
|
|
318
|
-
expect(onJob).not.toHaveBeenCalled();
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it("fires non-recurring job missed by more than 5 minutes", () => {
|
|
322
|
-
writeScheduleConfig({
|
|
323
|
-
jobs: [{ name: "old", cron: minutesAgoCron(10), prompt: "still fires", recurring: false }],
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
const onJob = makeOnJob();
|
|
327
|
-
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
328
|
-
cron.start();
|
|
329
|
-
cron.stop();
|
|
330
|
-
|
|
331
|
-
expect(onJob).toHaveBeenCalledTimes(1);
|
|
332
|
-
const call = onJob.mock.calls[0];
|
|
333
|
-
expect(call[0]).toBe("old");
|
|
334
|
-
expect(call[1]).toContain("[missed event, should have fired");
|
|
335
|
-
expect(call[1]).toContain("still fires");
|
|
336
|
-
|
|
337
|
-
const updated = readScheduleConfig();
|
|
338
|
-
expect(updated.jobs).toHaveLength(0);
|
|
339
|
-
});
|
|
340
|
-
});
|
package/src/cron.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { CronExpressionParser } from "cron-parser";
|
|
4
|
-
import { z } from "zod/v4";
|
|
5
|
-
import { createLogger } from "./logger";
|
|
6
|
-
|
|
7
|
-
const log = createLogger("cron");
|
|
8
|
-
|
|
9
|
-
const cronJobSchema = z.object({
|
|
10
|
-
name: z.string(),
|
|
11
|
-
cron: z.string(),
|
|
12
|
-
prompt: z.string(),
|
|
13
|
-
recurring: z.boolean().optional(),
|
|
14
|
-
model: z.string().optional(),
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
const cronConfigSchema = z.object({
|
|
18
|
-
jobs: z.array(cronJobSchema),
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
type CronConfig = z.infer<typeof cronConfigSchema>;
|
|
22
|
-
|
|
23
|
-
export interface CronSchedulerConfig {
|
|
24
|
-
onJob: (name: string, prompt: string, model?: string) => void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const TICK_INTERVAL = 10_000; // 10 seconds
|
|
28
|
-
|
|
29
|
-
export class CronScheduler {
|
|
30
|
-
#lastMinute = -1;
|
|
31
|
-
#schedulePath: string;
|
|
32
|
-
#config: CronSchedulerConfig;
|
|
33
|
-
#timer: Timer | null = null;
|
|
34
|
-
|
|
35
|
-
constructor(workspace: string, config: CronSchedulerConfig) {
|
|
36
|
-
this.#schedulePath = join(workspace, "data", "schedule.json");
|
|
37
|
-
this.#config = config;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
start(): void {
|
|
41
|
-
this.#tick();
|
|
42
|
-
this.#timer = setInterval(() => this.#tick(), TICK_INTERVAL);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
stop(): void {
|
|
46
|
-
if (this.#timer) {
|
|
47
|
-
clearInterval(this.#timer);
|
|
48
|
-
this.#timer = null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
#tick(): void {
|
|
53
|
-
const now = new Date();
|
|
54
|
-
const currentMinute = now.getMinutes() + now.getHours() * 60 + now.getDate() * 1440 + now.getMonth() * 43200;
|
|
55
|
-
|
|
56
|
-
// Only evaluate once per minute
|
|
57
|
-
if (currentMinute === this.#lastMinute) return;
|
|
58
|
-
this.#lastMinute = currentMinute;
|
|
59
|
-
|
|
60
|
-
let config: CronConfig;
|
|
61
|
-
try {
|
|
62
|
-
const raw = readFileSync(this.#schedulePath, "utf-8");
|
|
63
|
-
const parsed = cronConfigSchema.safeParse(JSON.parse(raw));
|
|
64
|
-
if (!parsed.success) {
|
|
65
|
-
log.warn("schedule.json: 'jobs' is not an array");
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
config = parsed.data;
|
|
69
|
-
} catch (err) {
|
|
70
|
-
if (err instanceof Error && "code" in err && err.code === "ENOENT") return;
|
|
71
|
-
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to read schedule.json");
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const firedNonRecurring: number[] = [];
|
|
76
|
-
|
|
77
|
-
for (let i = 0; i < config.jobs.length; i++) {
|
|
78
|
-
const job = config.jobs[i];
|
|
79
|
-
try {
|
|
80
|
-
const interval = CronExpressionParser.parse(job.cron);
|
|
81
|
-
const prev = interval.prev();
|
|
82
|
-
const diff = Math.abs(now.getTime() - prev.getTime());
|
|
83
|
-
// Match if the previous occurrence is within the current minute
|
|
84
|
-
if (diff < 60_000) {
|
|
85
|
-
log.debug({ name: job.name, cron: job.cron }, "Cron job triggered");
|
|
86
|
-
this.#config.onJob(job.name, job.prompt, job.model);
|
|
87
|
-
if (job.recurring === false) {
|
|
88
|
-
firedNonRecurring.push(i);
|
|
89
|
-
}
|
|
90
|
-
} else if (job.recurring === false && diff >= 60_000) {
|
|
91
|
-
// Non-recurring job in the past — fire regardless of how late
|
|
92
|
-
const missedMinutes = Math.round(diff / 60_000);
|
|
93
|
-
const firedAt = prev.toISOString();
|
|
94
|
-
const missedPrompt = `[missed event, should have fired ${missedMinutes} min ago at ${firedAt}] ${job.prompt}`;
|
|
95
|
-
log.info({ name: job.name, missedMinutes, firedAt }, "Firing missed non-recurring job");
|
|
96
|
-
this.#config.onJob(job.name, missedPrompt, job.model);
|
|
97
|
-
firedNonRecurring.push(i);
|
|
98
|
-
}
|
|
99
|
-
} catch (err) {
|
|
100
|
-
log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Remove fired non-recurring jobs (iterate in reverse to preserve indices)
|
|
105
|
-
if (firedNonRecurring.length > 0) {
|
|
106
|
-
for (let i = firedNonRecurring.length - 1; i >= 0; i--) {
|
|
107
|
-
config.jobs.splice(firedNonRecurring[i], 1);
|
|
108
|
-
}
|
|
109
|
-
try {
|
|
110
|
-
writeFileSync(this.#schedulePath, `${JSON.stringify(config, null, 2)}\n`);
|
|
111
|
-
} catch (err) {
|
|
112
|
-
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to write schedule.json");
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|