macroclaw 0.22.0 → 0.24.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/cron.test.ts +43 -0
- package/src/cron.ts +7 -2
- package/src/system-service.test.ts +17 -1
package/package.json
CHANGED
package/src/cron.test.ts
CHANGED
|
@@ -39,6 +39,12 @@ function minutesAgoCron(minutesAgo: number): string {
|
|
|
39
39
|
return `${past.getMinutes()} ${past.getHours()} ${past.getDate()} ${past.getMonth() + 1} *`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Build a cron expression that matched N days ago
|
|
43
|
+
function daysAgoCron(daysAgo: number): string {
|
|
44
|
+
const past = new Date(Date.now() - daysAgo * 24 * 60 * 60_000);
|
|
45
|
+
return `${past.getMinutes()} ${past.getHours()} ${past.getDate()} ${past.getMonth() + 1} *`;
|
|
46
|
+
}
|
|
47
|
+
|
|
42
48
|
beforeEach(() => {
|
|
43
49
|
mkdirSync(SCHEDULE_DIR, { recursive: true });
|
|
44
50
|
});
|
|
@@ -337,4 +343,41 @@ describe("missed non-recurring events", () => {
|
|
|
337
343
|
const updated = readScheduleConfig();
|
|
338
344
|
expect(updated.jobs).toHaveLength(0);
|
|
339
345
|
});
|
|
346
|
+
|
|
347
|
+
it("discards non-recurring job missed by more than a week without firing", () => {
|
|
348
|
+
writeScheduleConfig({
|
|
349
|
+
jobs: [
|
|
350
|
+
{ name: "stale", cron: daysAgoCron(10), prompt: "too old", recurring: false },
|
|
351
|
+
{ name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
|
|
352
|
+
],
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const onJob = makeOnJob();
|
|
356
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
357
|
+
cron.start();
|
|
358
|
+
cron.stop();
|
|
359
|
+
|
|
360
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
361
|
+
|
|
362
|
+
const updated = readScheduleConfig();
|
|
363
|
+
expect(updated.jobs).toHaveLength(1);
|
|
364
|
+
expect(updated.jobs[0].name).toBe("keeper");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("fires non-recurring job missed by 6 days (within week window)", () => {
|
|
368
|
+
writeScheduleConfig({
|
|
369
|
+
jobs: [{ name: "recent", cron: daysAgoCron(6), prompt: "still valid", recurring: false }],
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const onJob = makeOnJob();
|
|
373
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
374
|
+
cron.start();
|
|
375
|
+
cron.stop();
|
|
376
|
+
|
|
377
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
378
|
+
const call = onJob.mock.calls[0];
|
|
379
|
+
expect(call[0]).toBe("recent");
|
|
380
|
+
expect(call[1]).toContain("[missed event, should have fired");
|
|
381
|
+
expect(call[1]).toContain("still valid");
|
|
382
|
+
});
|
|
340
383
|
});
|
package/src/cron.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface CronSchedulerConfig {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const TICK_INTERVAL = 10_000; // 10 seconds
|
|
28
|
+
const MAX_MISSED_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
|
|
28
29
|
|
|
29
30
|
export class CronScheduler {
|
|
30
31
|
#lastMinute = -1;
|
|
@@ -87,14 +88,18 @@ export class CronScheduler {
|
|
|
87
88
|
if (job.recurring === false) {
|
|
88
89
|
firedNonRecurring.push(i);
|
|
89
90
|
}
|
|
90
|
-
} else if (job.recurring === false && diff >= 60_000) {
|
|
91
|
-
// Non-recurring job in the past — fire
|
|
91
|
+
} else if (job.recurring === false && diff >= 60_000 && diff <= MAX_MISSED_MS) {
|
|
92
|
+
// Non-recurring job in the past — fire if missed within the last week
|
|
92
93
|
const missedMinutes = Math.round(diff / 60_000);
|
|
93
94
|
const firedAt = prev.toISOString();
|
|
94
95
|
const missedPrompt = `[missed event, should have fired ${missedMinutes} min ago at ${firedAt}] ${job.prompt}`;
|
|
95
96
|
log.info({ name: job.name, missedMinutes, firedAt }, "Firing missed non-recurring job");
|
|
96
97
|
this.#config.onJob(job.name, missedPrompt, job.model);
|
|
97
98
|
firedNonRecurring.push(i);
|
|
99
|
+
} else if (job.recurring === false && diff > MAX_MISSED_MS) {
|
|
100
|
+
// Non-recurring job missed by more than a week — discard without firing
|
|
101
|
+
log.warn({ name: job.name, missedMinutes: Math.round(diff / 60_000) }, "Discarding stale non-recurring job");
|
|
102
|
+
firedNonRecurring.push(i);
|
|
98
103
|
}
|
|
99
104
|
} catch (err) {
|
|
100
105
|
log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
|
|
4
|
+
// Capture real fs functions before mocking
|
|
5
|
+
const realFs = await import("node:fs");
|
|
6
|
+
const { existsSync: realExistsSync, mkdirSync, readFileSync, rmSync, writeFileSync } = realFs;
|
|
7
|
+
const existsSync = realExistsSync;
|
|
8
|
+
|
|
5
9
|
// Mock child_process and os — safe since no other tests depend on real execSync or userInfo
|
|
6
10
|
const mockExecSync = mock((_cmd: string, _opts?: object) => "");
|
|
7
11
|
const mockUserInfo = mock(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
12
|
+
const mockExistsSync = mock((path: string) => realExistsSync(path));
|
|
8
13
|
|
|
9
14
|
mock.module("node:child_process", () => ({
|
|
10
15
|
execSync: (...args: unknown[]) => mockExecSync(args[0] as string, args[1] as object),
|
|
@@ -15,6 +20,14 @@ mock.module("node:os", () => ({
|
|
|
15
20
|
tmpdir: () => "/tmp",
|
|
16
21
|
}));
|
|
17
22
|
|
|
23
|
+
mock.module("node:fs", () => {
|
|
24
|
+
const result: Record<string, unknown> = {};
|
|
25
|
+
for (const [key, value] of Object.entries(realFs)) {
|
|
26
|
+
result[key] = key === "existsSync" ? (path: string) => mockExistsSync(path) : value;
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
});
|
|
30
|
+
|
|
18
31
|
const { SystemServiceManager } = await import("./system-service");
|
|
19
32
|
|
|
20
33
|
function createManager(opts?: { platform?: string; home?: string }): InstanceType<typeof SystemServiceManager> {
|
|
@@ -29,8 +42,10 @@ const SYSTEMD_INACTIVE = "inactive";
|
|
|
29
42
|
beforeEach(() => {
|
|
30
43
|
mockExecSync.mockClear();
|
|
31
44
|
mockUserInfo.mockClear();
|
|
45
|
+
mockExistsSync.mockClear();
|
|
32
46
|
mockExecSync.mockImplementation((_cmd: string, _opts?: object) => "");
|
|
33
47
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
48
|
+
mockExistsSync.mockImplementation((path: string) => realExistsSync(path));
|
|
34
49
|
});
|
|
35
50
|
|
|
36
51
|
describe("constructor", () => {
|
|
@@ -662,6 +677,7 @@ describe("status", () => {
|
|
|
662
677
|
if (cmd === "systemctl is-active macroclaw") throw new Error("not found");
|
|
663
678
|
return "";
|
|
664
679
|
});
|
|
680
|
+
mockExistsSync.mockReturnValue(false);
|
|
665
681
|
const mgr = createManager({ home: "/nonexistent" });
|
|
666
682
|
const s = mgr.status();
|
|
667
683
|
expect(s.installed).toBe(false);
|