alvin-bot 4.8.9 → 4.9.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/CHANGELOG.md +44 -0
- package/dist/handlers/message.js +5 -2
- package/dist/index.js +14 -10
- package/dist/platforms/whatsapp-auth-helpers.js +53 -0
- package/dist/platforms/whatsapp.js +6 -2
- package/dist/services/browser-manager.js +82 -10
- package/dist/services/browser-webfetch.js +93 -0
- package/dist/services/cron-scheduling.js +142 -0
- package/dist/services/cron.js +32 -6
- package/dist/services/skills.js +15 -11
- package/dist/services/subagent-delivery.js +8 -2
- package/dist/services/subagents.js +49 -8
- package/dist/services/telegram.js +12 -3
- package/dist/services/watchdog-brake.js +113 -0
- package/dist/services/watchdog.js +56 -42
- package/dist/util/console-formatter.js +109 -0
- package/dist/util/debounce.js +24 -0
- package/dist/util/telegram-error-filter.js +62 -0
- package/dist/web/server.js +56 -0
- package/package.json +1 -1
- package/test/browser-webfetch.test.ts +121 -0
- package/test/console-timestamps.test.ts +98 -0
- package/test/cron-restart-resilience.test.ts +191 -0
- package/test/debounce.test.ts +60 -0
- package/test/subagent-final-text.test.ts +132 -0
- package/test/telegram-error-filter.test.ts +85 -0
- package/test/watchdog-brake.test.ts +157 -0
- package/test/web-server-shutdown.test.ts +111 -0
- package/test/whatsapp-auth-resilience.test.ts +96 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trailing-edge debounce. Delays `fn` until `waitMs` has elapsed since
|
|
3
|
+
* the most recent call. Coalesces bursts into a single invocation with
|
|
4
|
+
* the most recent arguments.
|
|
5
|
+
*
|
|
6
|
+
* Used by fs.watch consumers (skills, plugins) where macOS FSEvents
|
|
7
|
+
* delivers many duplicate events for a single logical change.
|
|
8
|
+
*/
|
|
9
|
+
export function debounce(fn, waitMs) {
|
|
10
|
+
let timer = null;
|
|
11
|
+
let lastArgs = null;
|
|
12
|
+
return function debounced(...args) {
|
|
13
|
+
lastArgs = args;
|
|
14
|
+
if (timer)
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
timer = setTimeout(() => {
|
|
17
|
+
timer = null;
|
|
18
|
+
const call = lastArgs;
|
|
19
|
+
lastArgs = null;
|
|
20
|
+
if (call)
|
|
21
|
+
fn(...call);
|
|
22
|
+
}, waitMs);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram error filter — single source of truth for "which grammy
|
|
3
|
+
* errors are harmless and should never reach the end user as a
|
|
4
|
+
* 'Fehler: ...' reply."
|
|
5
|
+
*
|
|
6
|
+
* Context: grammy's Bot API wrapper surfaces these as plain Error
|
|
7
|
+
* objects with the description baked into `.message`. Some call sites
|
|
8
|
+
* (live-stream edit races, callback-answer races after a modal was
|
|
9
|
+
* already dismissed, message-to-edit-gone races when the user just
|
|
10
|
+
* deleted the message) produce errors that are 100% benign — they
|
|
11
|
+
* just mean the UI state we were about to write is already there.
|
|
12
|
+
*
|
|
13
|
+
* This file centralises the list so we can update one regex and have
|
|
14
|
+
* the filter apply everywhere. Used by bot.catch(), by the streaming
|
|
15
|
+
* `telegram.ts` finalize path, by handlers/message.ts, and by any
|
|
16
|
+
* future caller that needs to decide "report this to the user or
|
|
17
|
+
* drop it silently."
|
|
18
|
+
*/
|
|
19
|
+
const HARMLESS_PATTERNS = [
|
|
20
|
+
// The big one — live-stream edit races
|
|
21
|
+
/message is not modified/i,
|
|
22
|
+
/specified new message content and reply markup are exactly the same/i,
|
|
23
|
+
// Callback-answer race: the user tapped a stale inline button
|
|
24
|
+
/query is too old and response timeout expired/i,
|
|
25
|
+
/query ID is invalid/i,
|
|
26
|
+
// The user deleted the message we were about to edit
|
|
27
|
+
/message to edit not found/i,
|
|
28
|
+
/message to delete not found/i,
|
|
29
|
+
/MESSAGE_ID_INVALID/i,
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* True if the error is one of the known-harmless Telegram races.
|
|
33
|
+
* Accepts Error objects, grammy's GrammyError (which has an additional
|
|
34
|
+
* `description` field), and plain strings. `null` / `undefined` return
|
|
35
|
+
* false so callers can use this directly in catch blocks.
|
|
36
|
+
*/
|
|
37
|
+
export function isHarmlessTelegramError(err) {
|
|
38
|
+
if (err === null || err === undefined)
|
|
39
|
+
return false;
|
|
40
|
+
let haystack = "";
|
|
41
|
+
if (typeof err === "string") {
|
|
42
|
+
haystack = err;
|
|
43
|
+
}
|
|
44
|
+
else if (err instanceof Error) {
|
|
45
|
+
haystack = err.message || "";
|
|
46
|
+
// grammy's GrammyError carries the server's reason on .description
|
|
47
|
+
const desc = err.description;
|
|
48
|
+
if (typeof desc === "string")
|
|
49
|
+
haystack += " " + desc;
|
|
50
|
+
}
|
|
51
|
+
else if (typeof err === "object") {
|
|
52
|
+
// Plain object — look for message/description fields
|
|
53
|
+
const obj = err;
|
|
54
|
+
if (typeof obj.message === "string")
|
|
55
|
+
haystack += obj.message;
|
|
56
|
+
if (typeof obj.description === "string")
|
|
57
|
+
haystack += " " + obj.description;
|
|
58
|
+
}
|
|
59
|
+
if (!haystack)
|
|
60
|
+
return false;
|
|
61
|
+
return HARMLESS_PATTERNS.some((re) => re.test(haystack));
|
|
62
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -31,6 +31,9 @@ import { BOT_ROOT, ENV_FILE, PUBLIC_DIR, MEMORY_DIR, MEMORY_FILE, SOUL_FILE, DAT
|
|
|
31
31
|
import { broadcast } from "../services/broadcast.js";
|
|
32
32
|
import { BOT_VERSION } from "../version.js";
|
|
33
33
|
const WEB_PORT = parseInt(process.env.WEB_PORT || "3100");
|
|
34
|
+
/** Module-scope reference to the WebSocket server so stopWebServer() can
|
|
35
|
+
* tear it down together with the HTTP server. Set inside startWebServer(). */
|
|
36
|
+
let wsServerRef = null;
|
|
34
37
|
const WEB_PASSWORD = process.env.WEB_PASSWORD || "";
|
|
35
38
|
/** The actual port the Web UI is running on (may differ from WEB_PORT if busy). */
|
|
36
39
|
let actualWebPort = WEB_PORT;
|
|
@@ -1426,6 +1429,7 @@ export function startWebServer() {
|
|
|
1426
1429
|
});
|
|
1427
1430
|
});
|
|
1428
1431
|
const wss = new WebSocketServer({ server });
|
|
1432
|
+
wsServerRef = wss;
|
|
1429
1433
|
handleWebSocket(wss);
|
|
1430
1434
|
// Smart port: try WEB_PORT, increment if busy (up to +20)
|
|
1431
1435
|
const MAX_TRIES = 20;
|
|
@@ -1449,6 +1453,58 @@ export function startWebServer() {
|
|
|
1449
1453
|
tryListen(WEB_PORT);
|
|
1450
1454
|
return server;
|
|
1451
1455
|
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Gracefully stop the web server so the port is released.
|
|
1458
|
+
*
|
|
1459
|
+
* Why this exists: `shutdown()` in src/index.ts used to stop grammy and the
|
|
1460
|
+
* scheduler but leave the HTTP server listening. macOS then held the
|
|
1461
|
+
* listening socket in the socket table, so launchd's next boot of the bot
|
|
1462
|
+
* hit `EADDRINUSE :::3100`, threw an Uncaught exception and crash-looped.
|
|
1463
|
+
*
|
|
1464
|
+
* What this does:
|
|
1465
|
+
* 1. Force-close idle keep-alive sockets (otherwise close() hangs on them).
|
|
1466
|
+
* 2. Force-close active open requests (long-poll clients, WebSocket
|
|
1467
|
+
* upgrades that never completed).
|
|
1468
|
+
* 3. Tear down the WebSocket server so its own sockets don't linger.
|
|
1469
|
+
* 4. Await `server.close()` so the listening socket is truly released
|
|
1470
|
+
* before the caller's shutdown continues.
|
|
1471
|
+
*
|
|
1472
|
+
* Safe to call multiple times; no-op when the server is already closed or
|
|
1473
|
+
* never listened. Never throws.
|
|
1474
|
+
*/
|
|
1475
|
+
export async function stopWebServer(server) {
|
|
1476
|
+
try {
|
|
1477
|
+
if (wsServerRef) {
|
|
1478
|
+
for (const client of wsServerRef.clients) {
|
|
1479
|
+
try {
|
|
1480
|
+
client.terminate();
|
|
1481
|
+
}
|
|
1482
|
+
catch { /* ignore */ }
|
|
1483
|
+
}
|
|
1484
|
+
await new Promise((resolve) => wsServerRef.close(() => resolve()));
|
|
1485
|
+
wsServerRef = null;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
catch { /* ignore */ }
|
|
1489
|
+
if (!server.listening)
|
|
1490
|
+
return;
|
|
1491
|
+
try {
|
|
1492
|
+
// Node 18.2+ APIs — break any keep-alive / long-poll stalls so
|
|
1493
|
+
// server.close() can actually resolve.
|
|
1494
|
+
const s = server;
|
|
1495
|
+
if (typeof s.closeIdleConnections === "function")
|
|
1496
|
+
s.closeIdleConnections();
|
|
1497
|
+
if (typeof s.closeAllConnections === "function")
|
|
1498
|
+
s.closeAllConnections();
|
|
1499
|
+
}
|
|
1500
|
+
catch { /* ignore */ }
|
|
1501
|
+
await new Promise((resolve) => {
|
|
1502
|
+
// close() callback fires with an Error arg when the server wasn't
|
|
1503
|
+
// listening — we just resolve in either case. The caller only cares
|
|
1504
|
+
// that the port is free when this awaits.
|
|
1505
|
+
server.close(() => resolve());
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1452
1508
|
/** Get the actual port the Web UI is running on. */
|
|
1453
1509
|
export function getWebPort() {
|
|
1454
1510
|
return actualWebPort;
|
package/package.json
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #11 (minimal) — webfetch Tier 0 for browser-manager.
|
|
3
|
+
*
|
|
4
|
+
* Background: the current browser fallback chain is
|
|
5
|
+
* gateway → cdp → hub-stealth → cli
|
|
6
|
+
* Every tier spawns playwright (or talks to a CDP-controlled Chrome),
|
|
7
|
+
* which is slow and occasionally impossible under load. Many scraping
|
|
8
|
+
* tasks only need plain HTTP — an RSS feed, a JSON API, an OG meta-
|
|
9
|
+
* tag sniff. For those, Node's native `fetch` is 100× faster and
|
|
10
|
+
* doesn't need a browser at all.
|
|
11
|
+
*
|
|
12
|
+
* Contract: `webfetchNavigate(url)` returns `{ title, url }` for a
|
|
13
|
+
* successful GET, or throws a distinct `WebfetchFailed` error that the
|
|
14
|
+
* cascade can catch and fall through to the next tier. Title is the
|
|
15
|
+
* first `<title>` tag content; if none, the URL is returned.
|
|
16
|
+
*
|
|
17
|
+
* Keep it small — this is a Tier 0 helper, not a full scraper.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
20
|
+
import {
|
|
21
|
+
webfetchNavigate,
|
|
22
|
+
WebfetchFailed,
|
|
23
|
+
parseTitle,
|
|
24
|
+
} from "../src/services/browser-webfetch.js";
|
|
25
|
+
|
|
26
|
+
describe("parseTitle (Fix #11)", () => {
|
|
27
|
+
it("extracts a simple <title>", () => {
|
|
28
|
+
expect(parseTitle("<html><head><title>Hello World</title></head></html>")).toBe("Hello World");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles whitespace and newlines", () => {
|
|
32
|
+
expect(parseTitle("<title>\n Multi line \n</title>")).toBe("Multi line");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns empty string when there's no title", () => {
|
|
36
|
+
expect(parseTitle("<html><body>no title</body></html>")).toBe("");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("decodes basic HTML entities", () => {
|
|
40
|
+
expect(parseTitle("<title>A & B</title>")).toBe("A & B");
|
|
41
|
+
expect(parseTitle("<title>"quoted"</title>")).toBe('"quoted"');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is case-insensitive for the tag name", () => {
|
|
45
|
+
expect(parseTitle("<HEAD><TITLE>Foo</TITLE></HEAD>")).toBe("Foo");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("webfetchNavigate (Fix #11)", () => {
|
|
50
|
+
let originalFetch: typeof fetch;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
originalFetch = globalThis.fetch;
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
globalThis.fetch = originalFetch;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns title + url on a 200 response", async () => {
|
|
60
|
+
globalThis.fetch = vi.fn(async () =>
|
|
61
|
+
new Response(
|
|
62
|
+
"<html><head><title>GitHub · alvbln/alvin-bot</title></head></html>",
|
|
63
|
+
{ status: 200, headers: { "content-type": "text/html" } },
|
|
64
|
+
),
|
|
65
|
+
) as unknown as typeof fetch;
|
|
66
|
+
|
|
67
|
+
const result = await webfetchNavigate("https://github.com/alvbln/alvin-bot");
|
|
68
|
+
expect(result.title).toBe("GitHub · alvbln/alvin-bot");
|
|
69
|
+
expect(result.url).toBe("https://github.com/alvbln/alvin-bot");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("throws WebfetchFailed with the HTTP status on 4xx/5xx", async () => {
|
|
73
|
+
globalThis.fetch = vi.fn(async () =>
|
|
74
|
+
new Response("blocked", { status: 403 }),
|
|
75
|
+
) as unknown as typeof fetch;
|
|
76
|
+
|
|
77
|
+
await expect(webfetchNavigate("https://example.com")).rejects.toThrow(WebfetchFailed);
|
|
78
|
+
try {
|
|
79
|
+
await webfetchNavigate("https://example.com");
|
|
80
|
+
} catch (err) {
|
|
81
|
+
expect((err as WebfetchFailed).status).toBe(403);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("throws WebfetchFailed when the response is not HTML and forceHtml=true", async () => {
|
|
86
|
+
globalThis.fetch = vi.fn(async () =>
|
|
87
|
+
new Response('{"json":true}', {
|
|
88
|
+
status: 200,
|
|
89
|
+
headers: { "content-type": "application/json" },
|
|
90
|
+
}),
|
|
91
|
+
) as unknown as typeof fetch;
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
webfetchNavigate("https://api.example.com/data", { forceHtml: true }),
|
|
95
|
+
).rejects.toThrow(WebfetchFailed);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("accepts non-HTML responses when forceHtml is false (default)", async () => {
|
|
99
|
+
globalThis.fetch = vi.fn(async () =>
|
|
100
|
+
new Response("plain text", {
|
|
101
|
+
status: 200,
|
|
102
|
+
headers: { "content-type": "text/plain" },
|
|
103
|
+
}),
|
|
104
|
+
) as unknown as typeof fetch;
|
|
105
|
+
|
|
106
|
+
const result = await webfetchNavigate("https://example.com/raw");
|
|
107
|
+
// No <title> in plain text → falls back to URL as display title
|
|
108
|
+
expect(result.url).toBe("https://example.com/raw");
|
|
109
|
+
expect(result.title).toBe("https://example.com/raw");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("wraps network errors in WebfetchFailed so the cascade can catch a single type", async () => {
|
|
113
|
+
globalThis.fetch = vi.fn(async () => {
|
|
114
|
+
throw new Error("getaddrinfo ENOTFOUND nonexistent.invalid");
|
|
115
|
+
}) as unknown as typeof fetch;
|
|
116
|
+
|
|
117
|
+
await expect(
|
|
118
|
+
webfetchNavigate("https://nonexistent.invalid/"),
|
|
119
|
+
).rejects.toThrow(WebfetchFailed);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #10 — console output must carry ISO timestamps so out.log / err.log
|
|
3
|
+
* are actually debuggable. Also: silence libsignal's "Closing session"
|
|
4
|
+
* SessionEntry dumps which were pushing tens of KB per day into the logs
|
|
5
|
+
* and making forensic work painful.
|
|
6
|
+
*
|
|
7
|
+
* Contract: `installConsoleFormatter(console)` wraps console.log /
|
|
8
|
+
* console.warn / console.error so every line is prefixed with the
|
|
9
|
+
* current ISO timestamp (zero-padded, UTC), and certain noise patterns
|
|
10
|
+
* (libsignal session dumps) are dropped entirely.
|
|
11
|
+
*
|
|
12
|
+
* The wrapper is idempotent — calling it twice is a no-op.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
15
|
+
import {
|
|
16
|
+
installConsoleFormatter,
|
|
17
|
+
uninstallConsoleFormatter,
|
|
18
|
+
isNoisyLine,
|
|
19
|
+
} from "../src/util/console-formatter.js";
|
|
20
|
+
|
|
21
|
+
describe("installConsoleFormatter (Fix #10)", () => {
|
|
22
|
+
let stdoutWrites: string[];
|
|
23
|
+
let stderrWrites: string[];
|
|
24
|
+
let origStdout: typeof process.stdout.write;
|
|
25
|
+
let origStderr: typeof process.stderr.write;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
stdoutWrites = [];
|
|
29
|
+
stderrWrites = [];
|
|
30
|
+
origStdout = process.stdout.write.bind(process.stdout);
|
|
31
|
+
origStderr = process.stderr.write.bind(process.stderr);
|
|
32
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
33
|
+
stdoutWrites.push(String(chunk));
|
|
34
|
+
return true;
|
|
35
|
+
}) as typeof process.stdout.write;
|
|
36
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
37
|
+
stderrWrites.push(String(chunk));
|
|
38
|
+
return true;
|
|
39
|
+
}) as typeof process.stderr.write;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
uninstallConsoleFormatter();
|
|
44
|
+
process.stdout.write = origStdout;
|
|
45
|
+
process.stderr.write = origStderr;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("prefixes console.log output with an ISO timestamp", () => {
|
|
49
|
+
installConsoleFormatter();
|
|
50
|
+
console.log("hello world");
|
|
51
|
+
const line = stdoutWrites.join("");
|
|
52
|
+
// ISO format like 2026-04-11T14:00:00.000Z
|
|
53
|
+
expect(line).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s+hello world/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("prefixes console.error output with an ISO timestamp", () => {
|
|
57
|
+
installConsoleFormatter();
|
|
58
|
+
console.error("boom");
|
|
59
|
+
const line = stderrWrites.join("");
|
|
60
|
+
expect(line).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s+boom/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("is idempotent — second install call does not double-prefix", () => {
|
|
64
|
+
installConsoleFormatter();
|
|
65
|
+
installConsoleFormatter();
|
|
66
|
+
console.log("once");
|
|
67
|
+
const line = stdoutWrites.join("");
|
|
68
|
+
// Exactly one ISO timestamp, not two
|
|
69
|
+
const matches = line.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g);
|
|
70
|
+
expect(matches).not.toBeNull();
|
|
71
|
+
expect(matches!.length).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("isNoisyLine (Fix #10)", () => {
|
|
76
|
+
it("treats libsignal session dumps as noise", () => {
|
|
77
|
+
const dump = `Closing session: SessionEntry {
|
|
78
|
+
_chains: {
|
|
79
|
+
'BUQxzJlwgVTCxCL5C4rTbZP/7a0ciMPnyo47Pwr4flJt': { chainKey: [Object], chainType: 1, messageKeys: {} }
|
|
80
|
+
},
|
|
81
|
+
registrationId: 1446528770`;
|
|
82
|
+
expect(isNoisyLine(dump)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("treats the one-line 'Closing open session' as noise", () => {
|
|
86
|
+
expect(isNoisyLine("Closing open session in favor of incoming prekey bundle")).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("treats the repetitive claude native binary banner as noise", () => {
|
|
90
|
+
expect(isNoisyLine("[claude] Native binary: /Users/foo/.local/share/claude/versions/2.1.101")).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does NOT silence normal log output", () => {
|
|
94
|
+
expect(isNoisyLine("⏰ Cron scheduler started (30s interval)")).toBe(false);
|
|
95
|
+
expect(isNoisyLine("[watchdog] started — beacon every 30s")).toBe(false);
|
|
96
|
+
expect(isNoisyLine("Cron: Running job \"Daily Job Alert\"")).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #3 — Cron scheduler must survive a bot restart during job execution.
|
|
3
|
+
*
|
|
4
|
+
* Background: the old scheduler set `nextRunAt = null` immediately before
|
|
5
|
+
* `await executeJob(job)` and only re-calculated it after completion. A
|
|
6
|
+
* crash mid-execution (EADDRINUSE, unhandled rejection, launchd restart)
|
|
7
|
+
* left `nextRunAt = null`, so the next boot called `calculateNextRun()`
|
|
8
|
+
* from the current time — which for a cron expression always yields a
|
|
9
|
+
* FUTURE trigger (e.g. tomorrow 08:00). Today's run was lost forever.
|
|
10
|
+
*
|
|
11
|
+
* New contract (pure-function pair):
|
|
12
|
+
*
|
|
13
|
+
* prepareForExecution(job, now)
|
|
14
|
+
* - updates lastAttemptAt = now
|
|
15
|
+
* - updates nextRunAt = <next regular trigger from `now`>
|
|
16
|
+
* - returns the mutated job
|
|
17
|
+
*
|
|
18
|
+
* handleStartupCatchup(jobs, now, graceMs)
|
|
19
|
+
* - for every enabled job where `lastAttemptAt > lastRunAt`
|
|
20
|
+
* (i.e. the last attempt never completed) AND the attempt is
|
|
21
|
+
* within `graceMs`, rewinds `nextRunAt` to `now` so the next
|
|
22
|
+
* scheduler tick picks it up immediately
|
|
23
|
+
* - for every enabled job where `lastAttemptAt > lastRunAt` but
|
|
24
|
+
* the attempt is older than `graceMs`, gives up and recalculates
|
|
25
|
+
* `nextRunAt` normally
|
|
26
|
+
* - never touches disabled jobs
|
|
27
|
+
* - returns a NEW array of jobs (pure, no mutation of input)
|
|
28
|
+
*/
|
|
29
|
+
import { describe, it, expect } from "vitest";
|
|
30
|
+
import {
|
|
31
|
+
prepareForExecution,
|
|
32
|
+
handleStartupCatchup,
|
|
33
|
+
} from "../src/services/cron-scheduling.js";
|
|
34
|
+
import type { CronJob } from "../src/services/cron.js";
|
|
35
|
+
|
|
36
|
+
function makeJob(overrides: Partial<CronJob> = {}): CronJob {
|
|
37
|
+
return {
|
|
38
|
+
id: "job-1",
|
|
39
|
+
name: "Daily Job Alert",
|
|
40
|
+
type: "ai-query",
|
|
41
|
+
schedule: "00 08 * * *",
|
|
42
|
+
oneShot: false,
|
|
43
|
+
payload: { prompt: "x" },
|
|
44
|
+
target: { platform: "telegram", chatId: "1" },
|
|
45
|
+
enabled: true,
|
|
46
|
+
createdAt: 1_700_000_000_000,
|
|
47
|
+
lastRunAt: null,
|
|
48
|
+
lastResult: null,
|
|
49
|
+
lastError: null,
|
|
50
|
+
nextRunAt: null,
|
|
51
|
+
runCount: 0,
|
|
52
|
+
createdBy: "test",
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("prepareForExecution (Fix #3)", () => {
|
|
58
|
+
it("sets lastAttemptAt to now", () => {
|
|
59
|
+
const job = makeJob();
|
|
60
|
+
const now = 1_775_887_200_000; // 2026-04-11 08:00 Berlin
|
|
61
|
+
const updated = prepareForExecution(job, now);
|
|
62
|
+
expect(updated.lastAttemptAt).toBe(now);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("advances nextRunAt to the NEXT regular trigger, not null", () => {
|
|
66
|
+
const job = makeJob({ schedule: "00 08 * * *" });
|
|
67
|
+
const now = 1_775_887_200_000; // today 08:00
|
|
68
|
+
const updated = prepareForExecution(job, now);
|
|
69
|
+
// nextRunAt must be a future timestamp, not null, not zero
|
|
70
|
+
expect(updated.nextRunAt).not.toBeNull();
|
|
71
|
+
expect(updated.nextRunAt!).toBeGreaterThan(now);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("works with interval schedules — base = now, not lastRunAt", () => {
|
|
75
|
+
const job = makeJob({ schedule: "5m", lastRunAt: 1_000_000_000_000 });
|
|
76
|
+
const now = 1_775_887_200_000;
|
|
77
|
+
const updated = prepareForExecution(job, now);
|
|
78
|
+
expect(updated.nextRunAt).toBe(now + 5 * 60_000);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not touch lastRunAt", () => {
|
|
82
|
+
const job = makeJob({ lastRunAt: 123 });
|
|
83
|
+
const updated = prepareForExecution(job, 9999);
|
|
84
|
+
expect(updated.lastRunAt).toBe(123);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is pure — returns a new object, leaves the input alone", () => {
|
|
88
|
+
const job = makeJob();
|
|
89
|
+
const before = JSON.stringify(job);
|
|
90
|
+
prepareForExecution(job, 42);
|
|
91
|
+
expect(JSON.stringify(job)).toBe(before);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("handleStartupCatchup (Fix #3)", () => {
|
|
96
|
+
const GRACE = 6 * 60 * 60 * 1000; // 6 h
|
|
97
|
+
|
|
98
|
+
it("rewinds nextRunAt to now when a recent attempt never completed", () => {
|
|
99
|
+
// Scenario: 08:00 triggered, 08:05 bot crashed, 10:30 bot restarts.
|
|
100
|
+
const job = makeJob({
|
|
101
|
+
lastRunAt: null, // never completed
|
|
102
|
+
lastAttemptAt: 1_775_887_200_000, // 08:00
|
|
103
|
+
nextRunAt: 1_775_973_600_000, // tomorrow 08:00 (set pre-execution)
|
|
104
|
+
});
|
|
105
|
+
const now = 1_775_896_200_000; // 10:30
|
|
106
|
+
const [out] = handleStartupCatchup([job], now, GRACE);
|
|
107
|
+
expect(out.nextRunAt).toBe(now); // rewind → picked up on next tick
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not rewind when attempt completed (lastRunAt >= lastAttemptAt)", () => {
|
|
111
|
+
const tomorrow8am = 1_775_973_600_000;
|
|
112
|
+
const job = makeJob({
|
|
113
|
+
lastRunAt: 1_775_896_200_000, // completed at 10:30
|
|
114
|
+
lastAttemptAt: 1_775_887_200_000, // started at 08:00
|
|
115
|
+
nextRunAt: tomorrow8am,
|
|
116
|
+
});
|
|
117
|
+
const now = 1_775_900_000_000;
|
|
118
|
+
const [out] = handleStartupCatchup([job], now, GRACE);
|
|
119
|
+
expect(out.nextRunAt).toBe(tomorrow8am); // unchanged
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("gives up when the attempt is older than the grace window", () => {
|
|
123
|
+
// Scenario: attempt was 7h ago, never completed, bot only now back up.
|
|
124
|
+
const sevenHoursAgo = 1_775_887_200_000 - 60_000;
|
|
125
|
+
const now = sevenHoursAgo + 7 * 60 * 60 * 1000 + 60_000;
|
|
126
|
+
const job = makeJob({
|
|
127
|
+
lastRunAt: null,
|
|
128
|
+
lastAttemptAt: sevenHoursAgo,
|
|
129
|
+
nextRunAt: now + 86_400_000, // whatever — scheduler will replace
|
|
130
|
+
});
|
|
131
|
+
const [out] = handleStartupCatchup([job], now, GRACE);
|
|
132
|
+
// Must NOT rewind to `now`. Must either keep the future value or
|
|
133
|
+
// recompute — either way it has to stay strictly greater than now.
|
|
134
|
+
expect(out.nextRunAt).not.toBe(now);
|
|
135
|
+
expect(out.nextRunAt!).toBeGreaterThan(now);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("ignores disabled jobs", () => {
|
|
139
|
+
const job = makeJob({
|
|
140
|
+
enabled: false,
|
|
141
|
+
lastRunAt: null,
|
|
142
|
+
lastAttemptAt: 1_775_887_200_000,
|
|
143
|
+
nextRunAt: 1_775_973_600_000,
|
|
144
|
+
});
|
|
145
|
+
const now = 1_775_896_200_000;
|
|
146
|
+
const [out] = handleStartupCatchup([job], now, GRACE);
|
|
147
|
+
expect(out).toEqual(job); // untouched
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("handles jobs without any attempt history (no-op)", () => {
|
|
151
|
+
const job = makeJob({
|
|
152
|
+
lastRunAt: null,
|
|
153
|
+
lastAttemptAt: null,
|
|
154
|
+
nextRunAt: 1_775_973_600_000,
|
|
155
|
+
});
|
|
156
|
+
const now = 1_775_896_200_000;
|
|
157
|
+
const [out] = handleStartupCatchup([job], now, GRACE);
|
|
158
|
+
expect(out).toEqual(job);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("is pure — does not mutate the input array", () => {
|
|
162
|
+
const job = makeJob({
|
|
163
|
+
lastRunAt: null,
|
|
164
|
+
lastAttemptAt: 1_775_887_200_000,
|
|
165
|
+
nextRunAt: 1_775_973_600_000,
|
|
166
|
+
});
|
|
167
|
+
const input = [job];
|
|
168
|
+
const snapshot = JSON.stringify(input);
|
|
169
|
+
handleStartupCatchup(input, 1_775_896_200_000, GRACE);
|
|
170
|
+
expect(JSON.stringify(input)).toBe(snapshot);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("processes multiple jobs independently", () => {
|
|
174
|
+
const now = 1_775_896_200_000;
|
|
175
|
+
const recent = makeJob({
|
|
176
|
+
id: "a",
|
|
177
|
+
lastRunAt: null,
|
|
178
|
+
lastAttemptAt: 1_775_887_200_000, // within grace
|
|
179
|
+
nextRunAt: 1_775_973_600_000,
|
|
180
|
+
});
|
|
181
|
+
const completed = makeJob({
|
|
182
|
+
id: "b",
|
|
183
|
+
lastRunAt: 1_775_887_500_000,
|
|
184
|
+
lastAttemptAt: 1_775_887_200_000,
|
|
185
|
+
nextRunAt: 1_775_973_600_000,
|
|
186
|
+
});
|
|
187
|
+
const out = handleStartupCatchup([recent, completed], now, GRACE);
|
|
188
|
+
expect(out[0].nextRunAt).toBe(now); // caught up
|
|
189
|
+
expect(out[1].nextRunAt).toBe(1_775_973_600_000); // untouched
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #7 — fs.watch emits duplicates on macOS; we need a simple debounce.
|
|
3
|
+
*
|
|
4
|
+
* Contract: `debounce(fn, waitMs)` returns a wrapped function. Calling
|
|
5
|
+
* the wrapped function schedules `fn()` to run `waitMs` ms after the
|
|
6
|
+
* last call. Multiple calls inside the window coalesce into one
|
|
7
|
+
* invocation. Each "quiet period" starts a fresh cycle.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import { debounce } from "../src/util/debounce.js";
|
|
11
|
+
|
|
12
|
+
beforeEach(() => { vi.useFakeTimers(); });
|
|
13
|
+
afterEach(() => { vi.useRealTimers(); });
|
|
14
|
+
|
|
15
|
+
describe("debounce (Fix #7)", () => {
|
|
16
|
+
it("runs the function once after the wait period", () => {
|
|
17
|
+
const fn = vi.fn();
|
|
18
|
+
const d = debounce(fn, 200);
|
|
19
|
+
d();
|
|
20
|
+
expect(fn).not.toHaveBeenCalled();
|
|
21
|
+
vi.advanceTimersByTime(199);
|
|
22
|
+
expect(fn).not.toHaveBeenCalled();
|
|
23
|
+
vi.advanceTimersByTime(1);
|
|
24
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("coalesces many rapid calls into one invocation", () => {
|
|
28
|
+
const fn = vi.fn();
|
|
29
|
+
const d = debounce(fn, 300);
|
|
30
|
+
d(); d(); d(); d();
|
|
31
|
+
vi.advanceTimersByTime(299);
|
|
32
|
+
d(); // resets the timer
|
|
33
|
+
vi.advanceTimersByTime(299);
|
|
34
|
+
expect(fn).not.toHaveBeenCalled();
|
|
35
|
+
vi.advanceTimersByTime(1);
|
|
36
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("allows a second invocation after the wait elapses between calls", () => {
|
|
40
|
+
const fn = vi.fn();
|
|
41
|
+
const d = debounce(fn, 100);
|
|
42
|
+
d();
|
|
43
|
+
vi.advanceTimersByTime(100);
|
|
44
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
45
|
+
d();
|
|
46
|
+
vi.advanceTimersByTime(100);
|
|
47
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("passes through the latest arguments to the final call", () => {
|
|
51
|
+
const fn = vi.fn();
|
|
52
|
+
const d = debounce(fn, 50);
|
|
53
|
+
d("first");
|
|
54
|
+
d("second");
|
|
55
|
+
d("third");
|
|
56
|
+
vi.advanceTimersByTime(50);
|
|
57
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(fn).toHaveBeenCalledWith("third");
|
|
59
|
+
});
|
|
60
|
+
});
|