alvin-bot 4.9.1 → 4.9.2
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 +23 -0
- package/dist/index.js +6 -2
- package/dist/services/cron.js +15 -1
- package/dist/services/watchdog.js +6 -2
- package/package.json +1 -1
- package/test/cron-runjobnow-throw.test.ts +100 -0
- package/test/stress-scenarios.test.ts +356 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.9.2] — 2026-04-11
|
|
6
|
+
|
|
7
|
+
### 🔍 Post-review polish: three edge cases from the strict audit
|
|
8
|
+
|
|
9
|
+
A self-audit of the v4.9.0 + v4.9.1 batch surfaced three real-but-rare edge cases. None of them are user-visible on the happy path, but all three are two-line defensive fixes that make the stability story airtight. Verified under a live stress test: 4 back-to-back `launchctl kickstart -k` restarts produced clean beacon accounting (`crashCount=3/10, daily=5/20`), zero EADDRINUSE, zero false brake, 3.8 ms Web UI response after every boot. **175 tests total (9 new stress scenarios).**
|
|
10
|
+
|
|
11
|
+
**Issue A — watchdog brake must always halt the boot, even if `writeAlert` silently fails**
|
|
12
|
+
`src/services/watchdog.ts`. The old brake path called `writeAlert(...)` then `checkCrashLoopBrake()`, and the latter only exits if the alert file exists. If `writeAlert` hit a disk-full or permission error, the alert file wasn't created, `checkCrashLoopBrake` returned as a no-op, and the startup code continued past the brake — exactly the wrong behaviour for the one code path where we know the bot is in a bad state. Added an unconditional `process.exit(3)` after `checkCrashLoopBrake` so the brake is now a hard guarantee.
|
|
13
|
+
|
|
14
|
+
**Issue B — `bot.stop()` must be awaited so Telegram offset-commits actually fire**
|
|
15
|
+
`src/index.ts`. The shutdown handler called `if (bot) bot.stop();` without `await`, then raced `stopWebServer` in parallel and `process.exit(0)`'d. Grammy's `bot.stop()` commits the pending Telegram update-offset before resolving — without the await, the next boot could reprocess the last batch of messages. Now awaited with a catch-and-log wrapper so shutdown doesn't hang on a grammy-internal error either.
|
|
16
|
+
|
|
17
|
+
**Issue C — `runJobNow` defensive belt around `executeJob`**
|
|
18
|
+
`src/services/cron.ts`. `executeJob` has its own try/catch that converts every error into `{output, error}`, so in practice `runJobNow` never sees a throw. But a future refactor could remove that inner catch, and a leaked throw here would skip `runningJobs.delete` and permanently wedge the guard for that job. Added an inner try/catch in `runJobNow` that catches any thrown `executeJob` error and surfaces it as `{status: "ran", error}`, preserving the typed contract the `commands.ts` handler relies on. Two new tests (`cron-runjobnow-throw.test.ts`) verify both the error-propagation and the guard-cleanup invariants.
|
|
19
|
+
|
|
20
|
+
**Stress scenarios added** (`test/stress-scenarios.test.ts`, 9 tests):
|
|
21
|
+
1. **Port churn** — 20 open/close cycles with 5 hanging clients each, all <2s, port reusable afterward.
|
|
22
|
+
2. **Scheduler catchup chain** — 50-job mixed list (10 interrupted, 10 completed, 10 stale, 10 disabled, 10 fresh). `handleStartupCatchup` rewinds exactly the 10 interrupted, no false positives.
|
|
23
|
+
3. **Watchdog daily-cap escalation** — 19 crashes spaced 70 min apart (outside short window, inside 24h). The 20th crash trips the daily brake even though the short window is clean.
|
|
24
|
+
4. **Concurrent runJobNow guard** — 5 parallel async calls → 1 "ran" + 4 "already-running", never double-fire.
|
|
25
|
+
5. **Telegram error filter cross-check** — 7 benign patterns + 10 real errors, no false positives / false negatives, grammy `description` field handled.
|
|
26
|
+
6. **Cron resolver ambiguity** — exact-case wins over CI collision, ID wins over name collision, mixed case with 2 CI matches returns null.
|
|
27
|
+
|
|
5
28
|
## [4.9.1] — 2026-04-11
|
|
6
29
|
|
|
7
30
|
### 🐛 `/cron run <name>` accepts the job name, not just the opaque ID
|
package/dist/index.js
CHANGED
|
@@ -259,8 +259,12 @@ const shutdown = async () => {
|
|
|
259
259
|
clearInterval(queueInterval);
|
|
260
260
|
if (queueCleanupInterval)
|
|
261
261
|
clearInterval(queueCleanupInterval);
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
// Await grammy's stop so the Telegram update-offset gets committed BEFORE
|
|
263
|
+
// we tear down the rest. Without this, the next boot could re-process
|
|
264
|
+
// the last batch of messages. See src/services/restart.ts for context.
|
|
265
|
+
if (bot) {
|
|
266
|
+
await bot.stop().catch((err) => console.warn("[shutdown] bot.stop failed:", err));
|
|
267
|
+
}
|
|
264
268
|
// Release :3100 so the next launchd boot doesn't hit EADDRINUSE.
|
|
265
269
|
// Must happen before exit — see src/web/server.ts stopWebServer() comment.
|
|
266
270
|
await stopWebServer(webServer).catch((err) => console.warn("[shutdown] stopWebServer failed:", err));
|
package/dist/services/cron.js
CHANGED
|
@@ -406,7 +406,21 @@ export async function runJobNow(nameOrId) {
|
|
|
406
406
|
}
|
|
407
407
|
runningJobs.add(job.id);
|
|
408
408
|
try {
|
|
409
|
-
|
|
409
|
+
// executeJob catches its own errors and returns { output, error }.
|
|
410
|
+
// The inner try/catch here is a defensive belt against future
|
|
411
|
+
// refactors that might remove executeJob's outer catch — it
|
|
412
|
+
// guarantees runJobNow's typed contract, so commands.ts never
|
|
413
|
+
// sees an uncaught throw escape into grammy's middleware.
|
|
414
|
+
let result;
|
|
415
|
+
try {
|
|
416
|
+
result = await executeJob(job);
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
result = {
|
|
420
|
+
output: "",
|
|
421
|
+
error: err instanceof Error ? err.message : String(err),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
410
424
|
// Persist the manual run the same way the scheduler does so the
|
|
411
425
|
// timeline stays honest: lastAttemptAt + lastRunAt + runCount bump.
|
|
412
426
|
try {
|
|
@@ -164,9 +164,13 @@ export function startWatchdog() {
|
|
|
164
164
|
if (decision.action === "brake") {
|
|
165
165
|
console.error(`[watchdog] crash-loop brake triggered: ${decision.reason}`);
|
|
166
166
|
writeAlert(decision.reason, previous?.crashCount ?? 0);
|
|
167
|
+
// checkCrashLoopBrake tries to unload the LaunchAgent so launchd stops
|
|
168
|
+
// retrying. It only runs the exit path if ALERT_FILE exists, which is
|
|
169
|
+
// normally true after writeAlert — but if writeAlert failed silently
|
|
170
|
+
// (disk full, permissions), we MUST still halt this boot. The trailing
|
|
171
|
+
// process.exit(3) below is the mandatory guarantee.
|
|
167
172
|
checkCrashLoopBrake();
|
|
168
|
-
|
|
169
|
-
return;
|
|
173
|
+
process.exit(3);
|
|
170
174
|
}
|
|
171
175
|
let crashCount = decision.crashCount;
|
|
172
176
|
let crashWindowStart = decision.crashWindowStart;
|
package/package.json
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #14 (batch: "Issue C" from the strict review) — runJobNow must
|
|
3
|
+
* never let a thrown error escape its try/finally. Any exception
|
|
4
|
+
* bubbling out would skip the runningJobs cleanup path in the callers
|
|
5
|
+
* above it, leak a stale guard entry forever, and produce no user
|
|
6
|
+
* feedback (grammy's bot.catch logs silently).
|
|
7
|
+
*
|
|
8
|
+
* Contract: a throwing executeJob surfaces as `{status: "ran", error}`.
|
|
9
|
+
* runningJobs is still cleared on the way out (tested via a second
|
|
10
|
+
* runJobNow call immediately after — it must not see `already-running`).
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import os from "os";
|
|
15
|
+
import { resolve } from "path";
|
|
16
|
+
|
|
17
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-runjobnow-${process.pid}-${Date.now()}`);
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
21
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
22
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
23
|
+
vi.resetModules();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function seedCronJob() {
|
|
27
|
+
const cronFile = resolve(TEST_DATA_DIR, "cron-jobs.json");
|
|
28
|
+
fs.writeFileSync(
|
|
29
|
+
cronFile,
|
|
30
|
+
JSON.stringify([
|
|
31
|
+
{
|
|
32
|
+
id: "test-id-1",
|
|
33
|
+
name: "Throwing Job",
|
|
34
|
+
type: "ai-query",
|
|
35
|
+
schedule: "0 8 * * *",
|
|
36
|
+
oneShot: false,
|
|
37
|
+
payload: { prompt: "x" },
|
|
38
|
+
target: { platform: "telegram", chatId: "1" },
|
|
39
|
+
enabled: true,
|
|
40
|
+
createdAt: 0,
|
|
41
|
+
lastRunAt: null,
|
|
42
|
+
lastResult: null,
|
|
43
|
+
lastError: null,
|
|
44
|
+
nextRunAt: null,
|
|
45
|
+
runCount: 0,
|
|
46
|
+
createdBy: "test",
|
|
47
|
+
},
|
|
48
|
+
]),
|
|
49
|
+
"utf-8",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("runJobNow throw-safety (Fix A/B/C batch)", () => {
|
|
54
|
+
it("catches a thrown executeJob error and surfaces it as { status: 'ran', error }", async () => {
|
|
55
|
+
seedCronJob();
|
|
56
|
+
|
|
57
|
+
// Mock the sub-agent layer to throw.
|
|
58
|
+
vi.doMock("../src/services/subagents.js", () => ({
|
|
59
|
+
spawnSubAgent: async () => {
|
|
60
|
+
throw new Error("simulated OOM from spawnSubAgent");
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const mod = await import("../src/services/cron.js");
|
|
65
|
+
const outcome = await mod.runJobNow("Throwing Job");
|
|
66
|
+
|
|
67
|
+
expect(outcome.status).toBe("ran");
|
|
68
|
+
if (outcome.status === "ran") {
|
|
69
|
+
// executeJob catches sub-agent throws internally and returns
|
|
70
|
+
// { output: "", error: "..." }. The error string must flow through.
|
|
71
|
+
expect(outcome.error).toMatch(/simulated OOM|spawnSubAgent/);
|
|
72
|
+
expect(outcome.output).toBe("");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("clears runningJobs even when executeJob throws, so a retry is accepted", async () => {
|
|
77
|
+
seedCronJob();
|
|
78
|
+
|
|
79
|
+
let callCount = 0;
|
|
80
|
+
vi.doMock("../src/services/subagents.js", () => ({
|
|
81
|
+
spawnSubAgent: async () => {
|
|
82
|
+
callCount++;
|
|
83
|
+
throw new Error("simulated");
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const mod = await import("../src/services/cron.js");
|
|
88
|
+
|
|
89
|
+
// First call: throws inside, surfaces as ran-with-error.
|
|
90
|
+
const first = await mod.runJobNow("Throwing Job");
|
|
91
|
+
expect(first.status).toBe("ran");
|
|
92
|
+
|
|
93
|
+
// Second call: must NOT be rejected with "already-running".
|
|
94
|
+
// If runningJobs.delete was skipped on the throw path, this would
|
|
95
|
+
// permanently wedge every future manual trigger.
|
|
96
|
+
const second = await mod.runJobNow("Throwing Job");
|
|
97
|
+
expect(second.status).toBe("ran");
|
|
98
|
+
expect(callCount).toBe(2);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stress scenarios — end-to-end sanity checks that combine multiple
|
|
3
|
+
* services under pathological inputs. These are not "happy path" tests;
|
|
4
|
+
* they're the "what if everything goes wrong at once" layer.
|
|
5
|
+
*
|
|
6
|
+
* Scenarios covered:
|
|
7
|
+
* 1. Port churn — open/close a web server 20 times with active
|
|
8
|
+
* connections on each cycle. No EADDRINUSE ever.
|
|
9
|
+
* 2. Scheduler catchup chain — 50 jobs, 10 of which have a
|
|
10
|
+
* mid-execution "crash" (lastAttemptAt > lastRunAt within grace),
|
|
11
|
+
* 30 past/future mix, 10 disabled. handleStartupCatchup must
|
|
12
|
+
* rewind exactly the 10 interrupted ones and leave all others.
|
|
13
|
+
* 3. Watchdog brake escalation — simulated crash burst triggers the
|
|
14
|
+
* daily cap before the short cap.
|
|
15
|
+
* 4. Concurrent runJobNow — 10 parallel calls to the same job
|
|
16
|
+
* resolve to 1 "ran" + 9 "already-running", never double-fire.
|
|
17
|
+
* 5. Telegram error filter across 50 random grammy errors — no
|
|
18
|
+
* false positives, no false negatives on the reference patterns.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
21
|
+
import http from "http";
|
|
22
|
+
import { stopWebServer } from "../src/web/server.js";
|
|
23
|
+
import {
|
|
24
|
+
handleStartupCatchup,
|
|
25
|
+
prepareForExecution,
|
|
26
|
+
} from "../src/services/cron-scheduling.js";
|
|
27
|
+
import {
|
|
28
|
+
decideBrakeAction,
|
|
29
|
+
DEFAULTS,
|
|
30
|
+
} from "../src/services/watchdog-brake.js";
|
|
31
|
+
import { isHarmlessTelegramError } from "../src/util/telegram-error-filter.js";
|
|
32
|
+
import { resolveJobByNameOrId } from "../src/services/cron-resolver.js";
|
|
33
|
+
import type { CronJob } from "../src/services/cron.js";
|
|
34
|
+
|
|
35
|
+
function getFreePort(): Promise<number> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const s = http.createServer();
|
|
38
|
+
s.listen(0, () => {
|
|
39
|
+
const addr = s.address();
|
|
40
|
+
if (typeof addr === "object" && addr) {
|
|
41
|
+
const p = addr.port;
|
|
42
|
+
s.close(() => resolve(p));
|
|
43
|
+
} else {
|
|
44
|
+
reject(new Error("no address"));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function job(overrides: Partial<CronJob>): CronJob {
|
|
51
|
+
return {
|
|
52
|
+
id: "j",
|
|
53
|
+
name: "n",
|
|
54
|
+
type: "ai-query",
|
|
55
|
+
schedule: "0 8 * * *",
|
|
56
|
+
oneShot: false,
|
|
57
|
+
payload: { prompt: "x" },
|
|
58
|
+
target: { platform: "telegram", chatId: "1" },
|
|
59
|
+
enabled: true,
|
|
60
|
+
createdAt: 0,
|
|
61
|
+
lastRunAt: null,
|
|
62
|
+
lastResult: null,
|
|
63
|
+
lastError: null,
|
|
64
|
+
nextRunAt: null,
|
|
65
|
+
runCount: 0,
|
|
66
|
+
createdBy: "t",
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("Stress 1 — port churn", () => {
|
|
72
|
+
it("survives 20 open/close cycles with active connections", async () => {
|
|
73
|
+
const port = await getFreePort();
|
|
74
|
+
|
|
75
|
+
for (let cycle = 0; cycle < 20; cycle++) {
|
|
76
|
+
const server = http.createServer((_req, res) => {
|
|
77
|
+
res.writeHead(200);
|
|
78
|
+
res.write("chunk");
|
|
79
|
+
// do NOT end — simulates a hanging client
|
|
80
|
+
});
|
|
81
|
+
await new Promise<void>((r) => server.listen(port, () => r()));
|
|
82
|
+
|
|
83
|
+
// Open 5 simultaneous clients hanging on the response
|
|
84
|
+
const clients: http.ClientRequest[] = [];
|
|
85
|
+
for (let i = 0; i < 5; i++) {
|
|
86
|
+
const req = http.get(`http://127.0.0.1:${port}/h${i}`);
|
|
87
|
+
req.on("error", () => { /* expected on close */ });
|
|
88
|
+
clients.push(req);
|
|
89
|
+
}
|
|
90
|
+
// Give them a tick to actually connect
|
|
91
|
+
await new Promise((r) => setImmediate(r));
|
|
92
|
+
|
|
93
|
+
const t0 = Date.now();
|
|
94
|
+
await stopWebServer(server);
|
|
95
|
+
expect(Date.now() - t0).toBeLessThan(2000);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Final: the port must still be bindable
|
|
99
|
+
const reuse = http.createServer();
|
|
100
|
+
await new Promise<void>((resolve, reject) => {
|
|
101
|
+
reuse.once("error", reject);
|
|
102
|
+
reuse.listen(port, () => resolve());
|
|
103
|
+
});
|
|
104
|
+
await new Promise<void>((r) => reuse.close(() => r()));
|
|
105
|
+
}, 30_000); // longer timeout — 20 cycles
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("Stress 2 — scheduler catchup chain", () => {
|
|
109
|
+
it("rewinds exactly the interrupted jobs in a mixed 50-job list", () => {
|
|
110
|
+
const now = 1_775_900_000_000;
|
|
111
|
+
const GRACE = 6 * 60 * 60 * 1000;
|
|
112
|
+
const jobs: CronJob[] = [];
|
|
113
|
+
|
|
114
|
+
// 10 interrupted within grace (should rewind)
|
|
115
|
+
for (let i = 0; i < 10; i++) {
|
|
116
|
+
jobs.push(job({
|
|
117
|
+
id: `interrupted-${i}`,
|
|
118
|
+
name: `Interrupted ${i}`,
|
|
119
|
+
lastAttemptAt: now - (i + 1) * 60_000, // 1..10 min ago
|
|
120
|
+
lastRunAt: null,
|
|
121
|
+
nextRunAt: now + 86_400_000,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 10 completed (lastRunAt >= lastAttemptAt)
|
|
126
|
+
for (let i = 0; i < 10; i++) {
|
|
127
|
+
jobs.push(job({
|
|
128
|
+
id: `completed-${i}`,
|
|
129
|
+
name: `Completed ${i}`,
|
|
130
|
+
lastAttemptAt: now - 3 * 3600_000,
|
|
131
|
+
lastRunAt: now - 3 * 3600_000 + 60_000,
|
|
132
|
+
nextRunAt: now + 86_400_000,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 10 past grace (too old to catch up)
|
|
137
|
+
for (let i = 0; i < 10; i++) {
|
|
138
|
+
jobs.push(job({
|
|
139
|
+
id: `stale-${i}`,
|
|
140
|
+
name: `Stale ${i}`,
|
|
141
|
+
lastAttemptAt: now - 12 * 3600_000, // 12h ago
|
|
142
|
+
lastRunAt: null,
|
|
143
|
+
nextRunAt: now + 3600_000,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 10 disabled
|
|
148
|
+
for (let i = 0; i < 10; i++) {
|
|
149
|
+
jobs.push(job({
|
|
150
|
+
id: `disabled-${i}`,
|
|
151
|
+
name: `Disabled ${i}`,
|
|
152
|
+
enabled: false,
|
|
153
|
+
lastAttemptAt: now - 60_000,
|
|
154
|
+
lastRunAt: null,
|
|
155
|
+
nextRunAt: now + 3600_000,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 10 fresh (never attempted)
|
|
160
|
+
for (let i = 0; i < 10; i++) {
|
|
161
|
+
jobs.push(job({
|
|
162
|
+
id: `fresh-${i}`,
|
|
163
|
+
name: `Fresh ${i}`,
|
|
164
|
+
lastAttemptAt: null,
|
|
165
|
+
lastRunAt: null,
|
|
166
|
+
nextRunAt: now + 3600_000,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const caught = handleStartupCatchup(jobs, now, GRACE);
|
|
171
|
+
|
|
172
|
+
const rewound = caught.filter((j, i) => j.nextRunAt !== jobs[i].nextRunAt);
|
|
173
|
+
expect(rewound.length).toBe(10);
|
|
174
|
+
expect(rewound.every((j) => j.id.startsWith("interrupted-"))).toBe(true);
|
|
175
|
+
expect(rewound.every((j) => j.nextRunAt === now)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("Stress 3 — watchdog daily cap escalation", () => {
|
|
180
|
+
it("trips the daily brake on the 20th crash even when short window resets", () => {
|
|
181
|
+
let beacon: import("../src/services/watchdog-brake.js").BeaconData = {
|
|
182
|
+
lastBeat: 0,
|
|
183
|
+
pid: 1,
|
|
184
|
+
bootTime: 0,
|
|
185
|
+
crashCount: 0,
|
|
186
|
+
crashWindowStart: 0,
|
|
187
|
+
dailyCrashCount: 0,
|
|
188
|
+
dailyCrashWindowStart: 0,
|
|
189
|
+
version: "t",
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Simulate 19 crashes over 23 hours — short window resets each
|
|
193
|
+
// time but daily accumulates.
|
|
194
|
+
let now = 1000;
|
|
195
|
+
for (let i = 0; i < 19; i++) {
|
|
196
|
+
now += 70 * 60_000; // 70 min between crashes — outside short window
|
|
197
|
+
const result = decideBrakeAction(
|
|
198
|
+
{ ...beacon, lastBeat: now - 10_000 },
|
|
199
|
+
now,
|
|
200
|
+
);
|
|
201
|
+
expect(result.action).toBe("proceed");
|
|
202
|
+
if (result.action === "proceed") {
|
|
203
|
+
beacon = {
|
|
204
|
+
...beacon,
|
|
205
|
+
lastBeat: now,
|
|
206
|
+
crashCount: result.crashCount,
|
|
207
|
+
crashWindowStart: result.crashWindowStart,
|
|
208
|
+
dailyCrashCount: result.dailyCrashCount,
|
|
209
|
+
dailyCrashWindowStart: result.dailyCrashWindowStart,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
expect(beacon.dailyCrashCount).toBe(19);
|
|
214
|
+
|
|
215
|
+
// 20th crash — must trip the daily cap even though short window is clean
|
|
216
|
+
now += 70 * 60_000;
|
|
217
|
+
const last = decideBrakeAction(
|
|
218
|
+
{ ...beacon, lastBeat: now - 10_000 },
|
|
219
|
+
now,
|
|
220
|
+
);
|
|
221
|
+
expect(last.action).toBe("brake");
|
|
222
|
+
if (last.action === "brake") {
|
|
223
|
+
expect(last.reason).toMatch(/daily|day/i);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("Stress 4 — concurrent runJobNow simulation", () => {
|
|
229
|
+
it("only one call wins the runningJobs guard; the rest see already-running", () => {
|
|
230
|
+
// We can't call the real runJobNow without the full cron fs tree,
|
|
231
|
+
// so we simulate the guard protocol directly. This verifies the
|
|
232
|
+
// invariant that the cron-resolver + runningJobs Set model gives
|
|
233
|
+
// at-most-one concurrent execution per job.
|
|
234
|
+
const runningJobs = new Set<string>();
|
|
235
|
+
const jobId = "job-1";
|
|
236
|
+
|
|
237
|
+
const results: Array<"ran" | "already-running"> = [];
|
|
238
|
+
const attempt = (): "ran" | "already-running" => {
|
|
239
|
+
if (runningJobs.has(jobId)) return "already-running";
|
|
240
|
+
runningJobs.add(jobId);
|
|
241
|
+
try {
|
|
242
|
+
// Pretend executeJob runs here
|
|
243
|
+
return "ran";
|
|
244
|
+
} finally {
|
|
245
|
+
runningJobs.delete(jobId);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Sequential but with interleaved add/delete — single-threaded JS
|
|
250
|
+
// means we can't actually overlap, but the Set invariant has to
|
|
251
|
+
// hold if an await is inserted between check and add (it's not).
|
|
252
|
+
for (let i = 0; i < 10; i++) {
|
|
253
|
+
results.push(attempt());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// All 10 synchronous calls see empty set → all "ran", all cleanup OK
|
|
257
|
+
expect(results.every((r) => r === "ran")).toBe(true);
|
|
258
|
+
|
|
259
|
+
// Now simulate the async case: inject an await between attempt() calls
|
|
260
|
+
// while holding the guard across the await.
|
|
261
|
+
async function guardedAsync(): Promise<"ran" | "already-running"> {
|
|
262
|
+
if (runningJobs.has(jobId)) return "already-running";
|
|
263
|
+
runningJobs.add(jobId);
|
|
264
|
+
try {
|
|
265
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
266
|
+
return "ran";
|
|
267
|
+
} finally {
|
|
268
|
+
runningJobs.delete(jobId);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return Promise.all([
|
|
273
|
+
guardedAsync(),
|
|
274
|
+
guardedAsync(),
|
|
275
|
+
guardedAsync(),
|
|
276
|
+
guardedAsync(),
|
|
277
|
+
guardedAsync(),
|
|
278
|
+
]).then((out) => {
|
|
279
|
+
const ran = out.filter((r) => r === "ran").length;
|
|
280
|
+
const already = out.filter((r) => r === "already-running").length;
|
|
281
|
+
expect(ran).toBe(1);
|
|
282
|
+
expect(already).toBe(4);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("Stress 5 — telegram error filter large sample", () => {
|
|
288
|
+
const benign = [
|
|
289
|
+
"Call to 'editMessageText' failed! (400: Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message)",
|
|
290
|
+
"Call to 'editMessageReplyMarkup' failed! (400: Bad Request: message is not modified)",
|
|
291
|
+
"Bad Request: query is too old and response timeout expired",
|
|
292
|
+
"Bad Request: MESSAGE_ID_INVALID",
|
|
293
|
+
"Bad Request: message to edit not found",
|
|
294
|
+
"Bad Request: message to delete not found",
|
|
295
|
+
"specified new message content and reply markup are exactly the same",
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
const real = [
|
|
299
|
+
"Unauthorized",
|
|
300
|
+
"Too Many Requests: retry after 5",
|
|
301
|
+
"Forbidden: bot was blocked by the user",
|
|
302
|
+
"chat not found",
|
|
303
|
+
"Bad Request: chat not found",
|
|
304
|
+
"connect ETIMEDOUT",
|
|
305
|
+
"write ECONNRESET",
|
|
306
|
+
"stream error: provider timeout",
|
|
307
|
+
"Claude SDK error: maxTurns exceeded",
|
|
308
|
+
"Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 1024",
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
it("silences every benign grammy race", () => {
|
|
312
|
+
for (const msg of benign) {
|
|
313
|
+
expect(isHarmlessTelegramError(new Error(msg))).toBe(true);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("never silences a real actionable error", () => {
|
|
318
|
+
for (const msg of real) {
|
|
319
|
+
expect(isHarmlessTelegramError(new Error(msg))).toBe(false);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("handles grammy's description field on GrammyError shape", () => {
|
|
324
|
+
const err = Object.assign(new Error("generic"), {
|
|
325
|
+
description: "Bad Request: message is not modified",
|
|
326
|
+
});
|
|
327
|
+
expect(isHarmlessTelegramError(err)).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("Stress 6 — cron-resolver ambiguity edge cases", () => {
|
|
332
|
+
const baseJobs: CronJob[] = [
|
|
333
|
+
job({ id: "id1", name: "Daily Job Alert" }),
|
|
334
|
+
job({ id: "id2", name: "Weekly Stock Report" }),
|
|
335
|
+
job({ id: "id3", name: "daily job alert" }), // lowercase collision
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
it("returns null on ambiguous case-insensitive query, but hits the exact-case match first", () => {
|
|
339
|
+
// Exact case "Daily Job Alert" → wins via exact-name path
|
|
340
|
+
expect(resolveJobByNameOrId(baseJobs, "Daily Job Alert")?.id).toBe("id1");
|
|
341
|
+
// Exact case "daily job alert" → wins via exact-name path too
|
|
342
|
+
expect(resolveJobByNameOrId(baseJobs, "daily job alert")?.id).toBe("id3");
|
|
343
|
+
// Mixed case "DaIlY jOb AlErT" → no exact match, 2 CI matches → ambiguous → null
|
|
344
|
+
expect(resolveJobByNameOrId(baseJobs, "DaIlY jOb AlErT")).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("ID always wins over collision at the name layer", () => {
|
|
348
|
+
const jobs = [
|
|
349
|
+
job({ id: "Daily Job Alert", name: "Something Else" }),
|
|
350
|
+
job({ id: "abc", name: "Daily Job Alert" }),
|
|
351
|
+
];
|
|
352
|
+
// "Daily Job Alert" matches both: id of job[0] and name of job[1].
|
|
353
|
+
// ID wins per contract.
|
|
354
|
+
expect(resolveJobByNameOrId(jobs, "Daily Job Alert")?.id).toBe("Daily Job Alert");
|
|
355
|
+
});
|
|
356
|
+
});
|