alvin-bot 4.8.8 → 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 +72 -0
- package/dist/handlers/message.js +5 -2
- package/dist/index.js +14 -10
- package/dist/paths.js +2 -0
- package/dist/platforms/whatsapp-auth-helpers.js +53 -0
- package/dist/platforms/whatsapp.js +6 -2
- package/dist/services/browser-manager.js +470 -95
- 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/skills/browse/SKILL.md +123 -98
- 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,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
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #5 — runSubAgent must preserve the full final text, even when the
|
|
3
|
+
* stream ends on a tool_use or is aborted mid-stream.
|
|
4
|
+
*
|
|
5
|
+
* Regressions this closes:
|
|
6
|
+
*
|
|
7
|
+
* (a) The SDK yields `text` chunks as accumulated strings, then tool
|
|
8
|
+
* calls, then more text, then finally a `done` chunk that ALSO
|
|
9
|
+
* carries the final accumulated text. The old runSubAgent read
|
|
10
|
+
* `text` from text-chunks only and ignored `done.text`. If the
|
|
11
|
+
* assistant's very last action was a tool call with no trailing
|
|
12
|
+
* text block, `finalText` kept the pre-tool text and the
|
|
13
|
+
* cron-jobs.json `lastResult` ended mid-sentence.
|
|
14
|
+
*
|
|
15
|
+
* (b) When queryWithFallback threw mid-stream (provider aborted,
|
|
16
|
+
* network error, etc.), the catch block set `output: ""` —
|
|
17
|
+
* throwing away whatever text had already streamed in before the
|
|
18
|
+
* failure. Users saw an empty "(empty output)" delivery.
|
|
19
|
+
*
|
|
20
|
+
* Contract:
|
|
21
|
+
* - Output = last non-empty value observed from (text.text | done.text)
|
|
22
|
+
* - On error / abort: output = whatever we'd buffered so far (never "")
|
|
23
|
+
*/
|
|
24
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
25
|
+
import fs from "fs";
|
|
26
|
+
import os from "os";
|
|
27
|
+
import { resolve } from "path";
|
|
28
|
+
import type { StreamChunk } from "../src/providers/types.js";
|
|
29
|
+
|
|
30
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-finaltext-${process.pid}-${Date.now()}`);
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
34
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
35
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
36
|
+
delete process.env.MAX_SUBAGENTS;
|
|
37
|
+
vi.resetModules();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function mockStream(chunks: StreamChunk[] | (() => AsyncIterable<StreamChunk>)) {
|
|
41
|
+
vi.doMock("../src/engine.js", () => ({
|
|
42
|
+
getRegistry: () => ({
|
|
43
|
+
queryWithFallback: typeof chunks === "function"
|
|
44
|
+
? chunks
|
|
45
|
+
: async function* () { for (const c of chunks) yield c; },
|
|
46
|
+
}),
|
|
47
|
+
}));
|
|
48
|
+
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
49
|
+
deliverSubAgentResult: async () => { /* no-op */ },
|
|
50
|
+
attachBotApi: () => {},
|
|
51
|
+
__setBotApiForTest: () => {},
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function runAndGetResult(prompt = "test") {
|
|
56
|
+
const mod = await import("../src/services/subagents.js");
|
|
57
|
+
return new Promise<{ output: string; status: string; tokensUsed: { input: number; output: number } }>((resolveResult) => {
|
|
58
|
+
mod.spawnSubAgent({
|
|
59
|
+
name: "test-agent",
|
|
60
|
+
prompt,
|
|
61
|
+
source: "cron",
|
|
62
|
+
parentChatId: 1,
|
|
63
|
+
onComplete: (r) => resolveResult({
|
|
64
|
+
output: r.output,
|
|
65
|
+
status: r.status,
|
|
66
|
+
tokensUsed: r.tokensUsed,
|
|
67
|
+
}),
|
|
68
|
+
}).catch(() => { /* spawn errors handled elsewhere */ });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("runSubAgent finalText (Fix #5)", () => {
|
|
73
|
+
it("uses done.text as the authoritative final output", async () => {
|
|
74
|
+
mockStream([
|
|
75
|
+
{ type: "text", text: "Working on it…" },
|
|
76
|
+
{ type: "tool_use", toolName: "Bash" },
|
|
77
|
+
{ type: "text", text: "Intermediate finding: 5 results." },
|
|
78
|
+
{ type: "tool_use", toolName: "Write" },
|
|
79
|
+
// No trailing text chunk — the assistant ended on a tool call,
|
|
80
|
+
// then the done chunk carries the authoritative final text.
|
|
81
|
+
{ type: "done", text: "Job complete. Report at /tmp/out.html", inputTokens: 100, outputTokens: 50 },
|
|
82
|
+
]);
|
|
83
|
+
const r = await runAndGetResult();
|
|
84
|
+
expect(r.status).toBe("completed");
|
|
85
|
+
expect(r.output).toBe("Job complete. Report at /tmp/out.html");
|
|
86
|
+
expect(r.tokensUsed).toEqual({ input: 100, output: 50 });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back to last text chunk when done has no text", async () => {
|
|
90
|
+
mockStream([
|
|
91
|
+
{ type: "text", text: "First sentence." },
|
|
92
|
+
{ type: "text", text: "Second sentence." },
|
|
93
|
+
{ type: "done", inputTokens: 10, outputTokens: 5 },
|
|
94
|
+
]);
|
|
95
|
+
const r = await runAndGetResult();
|
|
96
|
+
expect(r.output).toBe("Second sentence.");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("preserves buffered text when stream errors mid-way", async () => {
|
|
100
|
+
mockStream(async function* () {
|
|
101
|
+
yield { type: "text", text: "Partial progress so far…" };
|
|
102
|
+
yield { type: "tool_use", toolName: "Bash" };
|
|
103
|
+
throw new Error("network: socket hang up");
|
|
104
|
+
});
|
|
105
|
+
const r = await runAndGetResult();
|
|
106
|
+
// Status can legitimately be "error" or "cancelled" — but output
|
|
107
|
+
// must NOT be an empty string. That's the regression.
|
|
108
|
+
expect(r.output.length).toBeGreaterThan(0);
|
|
109
|
+
expect(r.output).toContain("Partial progress");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("preserves buffered text when the provider yields an error chunk", async () => {
|
|
113
|
+
mockStream([
|
|
114
|
+
{ type: "text", text: "Started the task." },
|
|
115
|
+
{ type: "text", text: "Started the task. More detail here." },
|
|
116
|
+
{ type: "error", error: "Provider 'claude-sdk' failed: Request aborted" },
|
|
117
|
+
]);
|
|
118
|
+
const r = await runAndGetResult();
|
|
119
|
+
expect(r.output).toContain("More detail");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns empty output gracefully when nothing was buffered", async () => {
|
|
123
|
+
mockStream(async function* () {
|
|
124
|
+
throw new Error("immediate failure");
|
|
125
|
+
});
|
|
126
|
+
const r = await runAndGetResult();
|
|
127
|
+
// No text at all → empty is acceptable (nothing to preserve), but
|
|
128
|
+
// status must reflect the failure.
|
|
129
|
+
expect(r.output).toBe("");
|
|
130
|
+
expect(["error", "cancelled", "timeout"]).toContain(r.status);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #12 — grammy error noise filter.
|
|
3
|
+
*
|
|
4
|
+
* Regression: chunks like
|
|
5
|
+
* Fehler: Call to 'editMessageText' failed! (400: Bad Request:
|
|
6
|
+
* message is not modified: specified new message content and reply
|
|
7
|
+
* markup are exactly the same as a current content and reply markup
|
|
8
|
+
* of the message)
|
|
9
|
+
* were being sent to end users 2-3 times per day whenever a live-stream
|
|
10
|
+
* edit raced against itself. The v4.8.8 `bot.catch()` fix swallowed
|
|
11
|
+
* these at the middleware layer, but `telegram.ts` finalize() and
|
|
12
|
+
* `handlers/message.ts` error paths bypass bot.catch completely —
|
|
13
|
+
* they surface the raw grammy error via `ctx.reply()`.
|
|
14
|
+
*
|
|
15
|
+
* Contract: `isHarmlessTelegramError(err)` returns true for:
|
|
16
|
+
* - "message is not modified" (any language, any prefix)
|
|
17
|
+
* - "Call to 'editMessageText' failed" combined with the above
|
|
18
|
+
* - "query is too old" (harmless callback-answer race)
|
|
19
|
+
* - "MESSAGE_ID_INVALID" (user deleted the message before we edited it)
|
|
20
|
+
*
|
|
21
|
+
* Returns false for all other errors — they still need surfacing.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect } from "vitest";
|
|
24
|
+
import { isHarmlessTelegramError } from "../src/util/telegram-error-filter.js";
|
|
25
|
+
|
|
26
|
+
describe("isHarmlessTelegramError (Fix #12)", () => {
|
|
27
|
+
it("matches the exact production message", () => {
|
|
28
|
+
const err = new Error(
|
|
29
|
+
"Call to 'editMessageText' failed! (400: Bad Request: message is not modified: " +
|
|
30
|
+
"specified new message content and reply markup are exactly the same as a current " +
|
|
31
|
+
"content and reply markup of the message)",
|
|
32
|
+
);
|
|
33
|
+
expect(isHarmlessTelegramError(err)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("matches just the 'message is not modified' substring", () => {
|
|
37
|
+
expect(isHarmlessTelegramError(new Error("400: message is not modified"))).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("matches 'specified new message content ... exactly the same'", () => {
|
|
41
|
+
expect(
|
|
42
|
+
isHarmlessTelegramError(
|
|
43
|
+
new Error("specified new message content and reply markup are exactly the same"),
|
|
44
|
+
),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("matches 'query is too old' (answerCallbackQuery race)", () => {
|
|
49
|
+
expect(
|
|
50
|
+
isHarmlessTelegramError(new Error("Bad Request: query is too old and response timeout expired")),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("matches 'message to edit not found' (user deleted)", () => {
|
|
55
|
+
expect(
|
|
56
|
+
isHarmlessTelegramError(new Error("Bad Request: message to edit not found")),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("matches MESSAGE_ID_INVALID", () => {
|
|
61
|
+
expect(isHarmlessTelegramError(new Error("Bad Request: MESSAGE_ID_INVALID"))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("accepts plain strings as well as Error objects", () => {
|
|
65
|
+
expect(isHarmlessTelegramError("message is not modified")).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("accepts undefined / null as not harmless (caller decides)", () => {
|
|
69
|
+
expect(isHarmlessTelegramError(undefined)).toBe(false);
|
|
70
|
+
expect(isHarmlessTelegramError(null)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does NOT swallow real errors", () => {
|
|
74
|
+
expect(isHarmlessTelegramError(new Error("Unauthorized"))).toBe(false);
|
|
75
|
+
expect(isHarmlessTelegramError(new Error("Too Many Requests: retry after 5"))).toBe(false);
|
|
76
|
+
expect(isHarmlessTelegramError(new Error("chat not found"))).toBe(false);
|
|
77
|
+
expect(isHarmlessTelegramError(new Error("stream error: provider timeout"))).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles nested err.description from grammy", () => {
|
|
81
|
+
const err = new Error("anything") as Error & { description?: string };
|
|
82
|
+
err.description = "Bad Request: message is not modified";
|
|
83
|
+
expect(isHarmlessTelegramError(err)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #4 — Watchdog brake must actually engage on chronic crashes.
|
|
3
|
+
*
|
|
4
|
+
* Regression: the previous logic reset crashCount after 5 min of clean
|
|
5
|
+
* uptime. Production logs showed the bot crashing ~5 times per hour, but
|
|
6
|
+
* each boot lived just long enough (>5 min, <10 min) to reset the counter.
|
|
7
|
+
* Result: `crashCount` never reached the brake threshold, the bot cycled
|
|
8
|
+
* for hours, and the daily job-alert silently lost its scheduled runs.
|
|
9
|
+
*
|
|
10
|
+
* New contract (pure function pair extracted to watchdog-brake.ts):
|
|
11
|
+
*
|
|
12
|
+
* decideBrakeAction(prevBeacon, now, opts)
|
|
13
|
+
* - returns `{ action: "proceed", crashCount, crashWindowStart }`
|
|
14
|
+
* on clean start or old previous beacon
|
|
15
|
+
* - returns `{ action: "proceed", crashCount: N }` when the last run
|
|
16
|
+
* exited recently but we're still under the brake threshold
|
|
17
|
+
* - returns `{ action: "brake", reason }` when either
|
|
18
|
+
* (a) N+1 crashes in a short window, or
|
|
19
|
+
* (b) the daily crash cap (default 20) is exceeded
|
|
20
|
+
*
|
|
21
|
+
* shouldResetCrashCounter(uptimeMs, opts) → boolean
|
|
22
|
+
* - default policy: only reset after 1 h of clean uptime (NOT 5 min)
|
|
23
|
+
*/
|
|
24
|
+
import { describe, it, expect } from "vitest";
|
|
25
|
+
import {
|
|
26
|
+
decideBrakeAction,
|
|
27
|
+
shouldResetCrashCounter,
|
|
28
|
+
DEFAULTS,
|
|
29
|
+
type BeaconData,
|
|
30
|
+
} from "../src/services/watchdog-brake.js";
|
|
31
|
+
|
|
32
|
+
const ONE_MIN = 60_000;
|
|
33
|
+
const ONE_HOUR = 60 * ONE_MIN;
|
|
34
|
+
|
|
35
|
+
function beacon(partial: Partial<BeaconData> = {}): BeaconData {
|
|
36
|
+
return {
|
|
37
|
+
lastBeat: 0,
|
|
38
|
+
pid: 1,
|
|
39
|
+
bootTime: 0,
|
|
40
|
+
crashCount: 0,
|
|
41
|
+
crashWindowStart: 0,
|
|
42
|
+
dailyCrashCount: 0,
|
|
43
|
+
dailyCrashWindowStart: 0,
|
|
44
|
+
version: "test",
|
|
45
|
+
...partial,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("decideBrakeAction (Fix #4)", () => {
|
|
50
|
+
it("proceeds on first boot (no previous beacon)", () => {
|
|
51
|
+
const now = 1_000_000;
|
|
52
|
+
const result = decideBrakeAction(null, now);
|
|
53
|
+
expect(result.action).toBe("proceed");
|
|
54
|
+
if (result.action === "proceed") {
|
|
55
|
+
expect(result.crashCount).toBe(0);
|
|
56
|
+
expect(result.crashWindowStart).toBe(now);
|
|
57
|
+
expect(result.dailyCrashCount).toBe(0);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("proceeds when previous beacon is old (>STALE_MS) — clean exit", () => {
|
|
62
|
+
const now = 1_000_000_000;
|
|
63
|
+
const prev = beacon({ lastBeat: now - 10 * ONE_MIN, crashCount: 3 });
|
|
64
|
+
const result = decideBrakeAction(prev, now);
|
|
65
|
+
expect(result.action).toBe("proceed");
|
|
66
|
+
if (result.action === "proceed") {
|
|
67
|
+
// Old beacon → treat as clean, reset window counter (but keep daily)
|
|
68
|
+
expect(result.crashCount).toBe(0);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("counts a restart after a fresh beacon as a crash", () => {
|
|
73
|
+
const now = 1_000_000_000;
|
|
74
|
+
const prev = beacon({
|
|
75
|
+
lastBeat: now - 15_000, // 15 s ago
|
|
76
|
+
crashCount: 2,
|
|
77
|
+
crashWindowStart: now - 5 * ONE_MIN,
|
|
78
|
+
dailyCrashCount: 2,
|
|
79
|
+
dailyCrashWindowStart: now - 2 * ONE_HOUR,
|
|
80
|
+
});
|
|
81
|
+
const result = decideBrakeAction(prev, now);
|
|
82
|
+
expect(result.action).toBe("proceed");
|
|
83
|
+
if (result.action === "proceed") {
|
|
84
|
+
expect(result.crashCount).toBe(3);
|
|
85
|
+
expect(result.dailyCrashCount).toBe(3);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("engages brake when short-window threshold is crossed", () => {
|
|
90
|
+
const now = 1_000_000_000;
|
|
91
|
+
const prev = beacon({
|
|
92
|
+
lastBeat: now - 10_000,
|
|
93
|
+
crashCount: DEFAULTS.SHORT_BRAKE_THRESHOLD - 1, // one more = brake
|
|
94
|
+
crashWindowStart: now - 2 * ONE_MIN,
|
|
95
|
+
dailyCrashCount: 5,
|
|
96
|
+
dailyCrashWindowStart: now - ONE_HOUR,
|
|
97
|
+
});
|
|
98
|
+
const result = decideBrakeAction(prev, now);
|
|
99
|
+
expect(result.action).toBe("brake");
|
|
100
|
+
if (result.action === "brake") {
|
|
101
|
+
expect(result.reason).toMatch(/short.*window|threshold|crashes/i);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("engages brake when daily cap is exceeded", () => {
|
|
106
|
+
const now = 1_000_000_000;
|
|
107
|
+
const prev = beacon({
|
|
108
|
+
lastBeat: now - 10_000,
|
|
109
|
+
crashCount: 1, // short window fine
|
|
110
|
+
crashWindowStart: now - 30 * ONE_MIN,
|
|
111
|
+
dailyCrashCount: DEFAULTS.DAILY_BRAKE_THRESHOLD - 1,
|
|
112
|
+
dailyCrashWindowStart: now - 12 * ONE_HOUR,
|
|
113
|
+
});
|
|
114
|
+
const result = decideBrakeAction(prev, now);
|
|
115
|
+
expect(result.action).toBe("brake");
|
|
116
|
+
if (result.action === "brake") {
|
|
117
|
+
expect(result.reason).toMatch(/daily|day/i);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("rolls over daily counter when 24h window expires", () => {
|
|
122
|
+
const now = 1_000_000_000;
|
|
123
|
+
const prev = beacon({
|
|
124
|
+
lastBeat: now - 10_000,
|
|
125
|
+
crashCount: 1,
|
|
126
|
+
crashWindowStart: now - 30 * ONE_MIN,
|
|
127
|
+
dailyCrashCount: 18, // high
|
|
128
|
+
dailyCrashWindowStart: now - 25 * ONE_HOUR, // but window rolled over
|
|
129
|
+
});
|
|
130
|
+
const result = decideBrakeAction(prev, now);
|
|
131
|
+
expect(result.action).toBe("proceed");
|
|
132
|
+
if (result.action === "proceed") {
|
|
133
|
+
expect(result.dailyCrashCount).toBe(1); // fresh window
|
|
134
|
+
expect(result.dailyCrashWindowStart).toBe(now);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("shouldResetCrashCounter (Fix #4)", () => {
|
|
140
|
+
it("does NOT reset after 5 min of uptime (old buggy behaviour)", () => {
|
|
141
|
+
expect(shouldResetCrashCounter(5 * ONE_MIN)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("does NOT reset after 30 min of uptime", () => {
|
|
145
|
+
expect(shouldResetCrashCounter(30 * ONE_MIN)).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("resets after 1 h of clean uptime", () => {
|
|
149
|
+
expect(shouldResetCrashCounter(ONE_HOUR)).toBe(true);
|
|
150
|
+
expect(shouldResetCrashCounter(ONE_HOUR + 1)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("can be overridden via opts.resetAfterMs", () => {
|
|
154
|
+
expect(shouldResetCrashCounter(10 * ONE_MIN, { resetAfterMs: 10 * ONE_MIN })).toBe(true);
|
|
155
|
+
expect(shouldResetCrashCounter(10 * ONE_MIN - 1, { resetAfterMs: 10 * ONE_MIN })).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|