alvin-bot 4.18.0 → 4.18.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/AEC-PLUGINS-SOURCES.md +53 -0
- package/CHANGELOG.md +37 -2
- package/DESIGN-SKILLS-SOURCES.md +81 -0
- package/bin/cli.js +1 -1
- package/dist/providers/claude-sdk-provider.js +24 -0
- package/package.json +3 -1
- package/test/allowed-users-gate.test.ts +0 -98
- package/test/alvin-dispatch.test.ts +0 -220
- package/test/async-agent-chunk-flow.test.ts +0 -244
- package/test/async-agent-parser-staleness.test.ts +0 -412
- package/test/async-agent-parser-streamjson.test.ts +0 -273
- package/test/async-agent-parser.test.ts +0 -322
- package/test/async-agent-watcher.test.ts +0 -229
- package/test/background-bypass-integration.test.ts +0 -443
- package/test/background-bypass-stress.test.ts +0 -417
- package/test/background-bypass.test.ts +0 -127
- package/test/browser-webfetch.test.ts +0 -121
- package/test/claude-sdk-provider.test.ts +0 -115
- package/test/claude-sdk-tool-use-id.test.ts +0 -180
- package/test/console-timestamps.test.ts +0 -98
- package/test/cron-progress-ticker.test.ts +0 -76
- package/test/cron-restart-resilience.test.ts +0 -191
- package/test/cron-run-resolver.test.ts +0 -133
- package/test/cron-runjobnow-throw.test.ts +0 -100
- package/test/debounce.test.ts +0 -60
- package/test/delivery-registry.test.ts +0 -71
- package/test/exec-guard-metachars.test.ts +0 -110
- package/test/file-permissions.test.ts +0 -130
- package/test/i18n.test.ts +0 -108
- package/test/list-subagents-merged.test.ts +0 -172
- package/test/memory-extractor.test.ts +0 -151
- package/test/memory-layers.test.ts +0 -169
- package/test/memory-sdk-injection.test.ts +0 -146
- package/test/memory-stress-restart.test.ts +0 -337
- package/test/multi-session-stress.test.ts +0 -255
- package/test/platform-session-key.test.ts +0 -69
- package/test/process-manager.test.ts +0 -186
- package/test/registry.test.ts +0 -201
- package/test/session-pending-background.test.ts +0 -59
- package/test/session-persistence.test.ts +0 -195
- package/test/slack-progress-ticker.test.ts +0 -123
- package/test/slack-slash-command.test.ts +0 -61
- package/test/slack-test-connection.test.ts +0 -176
- package/test/stress-scenarios.test.ts +0 -356
- package/test/stuck-timer.test.ts +0 -116
- package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
- package/test/subagent-delivery-platform-routing.test.ts +0 -232
- package/test/subagent-delivery.test.ts +0 -273
- package/test/subagent-final-text.test.ts +0 -132
- package/test/subagent-stats.test.ts +0 -119
- package/test/subagent-toolset-allowlist.test.ts +0 -146
- package/test/subagents-commands.test.ts +0 -64
- package/test/subagents-config.test.ts +0 -114
- package/test/subagents-depth.test.ts +0 -58
- package/test/subagents-inheritance.test.ts +0 -67
- package/test/subagents-name-resolver.test.ts +0 -122
- package/test/subagents-priority-reject.test.ts +0 -88
- package/test/subagents-queue.test.ts +0 -127
- package/test/subagents-shutdown.test.ts +0 -126
- package/test/subagents-toolset.test.ts +0 -71
- package/test/sync-task-timeout.test.ts +0 -153
- package/test/system-prompt-background-hint.test.ts +0 -65
- package/test/telegram-error-filter.test.ts +0 -85
- package/test/telegram-workspace-command.test.ts +0 -78
- package/test/timing-safe-bearer.test.ts +0 -65
- package/test/watchdog-brake.test.ts +0 -157
- package/test/watcher-pending-count.test.ts +0 -228
- package/test/watcher-zombie-fix.test.ts +0 -252
- package/test/web-server-integration.test.ts +0 -189
- package/test/web-server-resilience.test.ts +0 -118
- package/test/web-server-shutdown.test.ts +0 -117
- package/test/whatsapp-auth-resilience.test.ts +0 -96
- package/test/workspaces.test.ts +0 -196
- package/vitest.config.ts +0 -17
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v4.13.1 — `/api/platforms/test-connection` must accept `slack` as a
|
|
3
|
-
* platformId and validate the Bot Token via Slack's auth.test endpoint.
|
|
4
|
-
*
|
|
5
|
-
* Before v4.13.1, the handler only knew about telegram/discord/signal/
|
|
6
|
-
* whatsapp, so slack fell through to "Unknown platform" even when a
|
|
7
|
-
* valid xoxb- Bot Token was set.
|
|
8
|
-
*
|
|
9
|
-
* These tests hit the handler directly (no HTTP server spin-up) and stub
|
|
10
|
-
* global fetch so the Slack API is never actually contacted.
|
|
11
|
-
*/
|
|
12
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
13
|
-
import { EventEmitter } from "node:events";
|
|
14
|
-
import { Writable } from "node:stream";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Minimal request/response pair that the setup-api handler expects.
|
|
18
|
-
* We capture the body written via res.end(body) so the test can assert
|
|
19
|
-
* on the JSON payload.
|
|
20
|
-
*/
|
|
21
|
-
interface FakeIO {
|
|
22
|
-
req: EventEmitter & { method: string; url: string; headers: Record<string, string> };
|
|
23
|
-
res: Writable & { statusCode: number; headers: Record<string, string>; body: string };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function makeIO(method: string, url: string, body: string): FakeIO {
|
|
27
|
-
const req = new EventEmitter() as FakeIO["req"];
|
|
28
|
-
req.method = method;
|
|
29
|
-
req.url = url;
|
|
30
|
-
req.headers = {};
|
|
31
|
-
|
|
32
|
-
let captured = "";
|
|
33
|
-
const res = new Writable({
|
|
34
|
-
write(chunk, _enc, cb) {
|
|
35
|
-
captured += chunk.toString();
|
|
36
|
-
cb();
|
|
37
|
-
},
|
|
38
|
-
}) as FakeIO["res"];
|
|
39
|
-
res.statusCode = 200;
|
|
40
|
-
res.headers = {};
|
|
41
|
-
res.setHeader = (k: string, v: string) => {
|
|
42
|
-
res.headers[k.toLowerCase()] = v;
|
|
43
|
-
return res as any;
|
|
44
|
-
};
|
|
45
|
-
res.end = (b?: unknown) => {
|
|
46
|
-
if (b != null) captured += String(b);
|
|
47
|
-
res.body = captured;
|
|
48
|
-
return res as any;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
return { req, res };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
beforeEach(() => {
|
|
55
|
-
vi.resetModules();
|
|
56
|
-
// Prevent the setup-api module from crashing on BOT_ROOT etc.
|
|
57
|
-
process.env.BOT_TOKEN = "";
|
|
58
|
-
process.env.SLACK_BOT_TOKEN = "";
|
|
59
|
-
process.env.SLACK_APP_TOKEN = "";
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
afterEach(() => {
|
|
63
|
-
vi.unstubAllGlobals();
|
|
64
|
-
delete process.env.BOT_TOKEN;
|
|
65
|
-
delete process.env.SLACK_BOT_TOKEN;
|
|
66
|
-
delete process.env.SLACK_APP_TOKEN;
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe("POST /api/platforms/test-connection — slack (v4.13.1)", () => {
|
|
70
|
-
it("returns {ok:false, error: 'SLACK_BOT_TOKEN not set'} when no tokens configured", async () => {
|
|
71
|
-
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
72
|
-
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
73
|
-
const body = JSON.stringify({ platformId: "slack" });
|
|
74
|
-
|
|
75
|
-
const handled = await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
76
|
-
expect(handled).toBe(true);
|
|
77
|
-
const parsed = JSON.parse(res.body);
|
|
78
|
-
expect(parsed.ok).toBe(false);
|
|
79
|
-
expect(parsed.error).toMatch(/SLACK_BOT_TOKEN/);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("returns {ok:true, info: '...'} when Slack's auth.test accepts the token", async () => {
|
|
83
|
-
process.env.SLACK_BOT_TOKEN = "xoxb-fake-valid";
|
|
84
|
-
process.env.SLACK_APP_TOKEN = "xapp-fake-valid";
|
|
85
|
-
|
|
86
|
-
vi.stubGlobal(
|
|
87
|
-
"fetch",
|
|
88
|
-
vi.fn(async (url: string) => {
|
|
89
|
-
expect(url).toContain("slack.com/api/auth.test");
|
|
90
|
-
return {
|
|
91
|
-
ok: true,
|
|
92
|
-
json: async () => ({
|
|
93
|
-
ok: true,
|
|
94
|
-
url: "https://my-project.slack.com/",
|
|
95
|
-
team: "my-project Workspace",
|
|
96
|
-
user: "alvinbot",
|
|
97
|
-
team_id: "T123",
|
|
98
|
-
user_id: "U456",
|
|
99
|
-
bot_id: "B789",
|
|
100
|
-
}),
|
|
101
|
-
};
|
|
102
|
-
}),
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
106
|
-
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
107
|
-
const body = JSON.stringify({ platformId: "slack" });
|
|
108
|
-
await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
109
|
-
|
|
110
|
-
const parsed = JSON.parse(res.body);
|
|
111
|
-
expect(parsed.ok).toBe(true);
|
|
112
|
-
expect(parsed.info).toMatch(/alvinbot|my-project/i);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("returns {ok:false} when Slack's auth.test rejects the token", async () => {
|
|
116
|
-
process.env.SLACK_BOT_TOKEN = "xoxb-fake-invalid";
|
|
117
|
-
process.env.SLACK_APP_TOKEN = "xapp-fake-invalid";
|
|
118
|
-
|
|
119
|
-
vi.stubGlobal(
|
|
120
|
-
"fetch",
|
|
121
|
-
vi.fn(async () => ({
|
|
122
|
-
ok: true,
|
|
123
|
-
json: async () => ({ ok: false, error: "invalid_auth" }),
|
|
124
|
-
})),
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
128
|
-
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
129
|
-
const body = JSON.stringify({ platformId: "slack" });
|
|
130
|
-
await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
131
|
-
|
|
132
|
-
const parsed = JSON.parse(res.body);
|
|
133
|
-
expect(parsed.ok).toBe(false);
|
|
134
|
-
expect(parsed.error).toMatch(/invalid_auth/);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("warns about missing/invalid App Token format when Bot Token is OK", async () => {
|
|
138
|
-
process.env.SLACK_BOT_TOKEN = "xoxb-fake-valid";
|
|
139
|
-
process.env.SLACK_APP_TOKEN = "xoxb-not-an-app-token"; // wrong prefix
|
|
140
|
-
|
|
141
|
-
vi.stubGlobal(
|
|
142
|
-
"fetch",
|
|
143
|
-
vi.fn(async () => ({
|
|
144
|
-
ok: true,
|
|
145
|
-
json: async () => ({
|
|
146
|
-
ok: true,
|
|
147
|
-
user: "alvinbot",
|
|
148
|
-
team: "x",
|
|
149
|
-
team_id: "T1",
|
|
150
|
-
user_id: "U1",
|
|
151
|
-
bot_id: "B1",
|
|
152
|
-
}),
|
|
153
|
-
})),
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
157
|
-
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
158
|
-
const body = JSON.stringify({ platformId: "slack" });
|
|
159
|
-
await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
160
|
-
|
|
161
|
-
const parsed = JSON.parse(res.body);
|
|
162
|
-
// Bot Token was valid, but we should still note the App Token format issue
|
|
163
|
-
expect(parsed.ok).toBe(true);
|
|
164
|
-
expect(parsed.info).toMatch(/App.?Token|xapp-/i);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("still rejects 'slack-workspace' or other typos as unknown (regression guard)", async () => {
|
|
168
|
-
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
169
|
-
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
170
|
-
const body = JSON.stringify({ platformId: "slack-workspace" });
|
|
171
|
-
await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
172
|
-
const parsed = JSON.parse(res.body);
|
|
173
|
-
expect(parsed.ok).toBe(false);
|
|
174
|
-
expect(parsed.error).toMatch(/Unknown platform/);
|
|
175
|
-
});
|
|
176
|
-
});
|
|
@@ -1,356 +0,0 @@
|
|
|
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 { closeHttpServerGracefully as 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
|
-
});
|
package/test/stuck-timer.test.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v4.12.1 — Task-aware stuck timer state machine.
|
|
3
|
-
*
|
|
4
|
-
* Before v4.12.1, message.ts used a flat 10-min stuck timeout that
|
|
5
|
-
* aborted the session when no chunks arrived for 10 minutes. This
|
|
6
|
-
* was fatal for synchronous Task/Agent tool calls, which legitimately
|
|
7
|
-
* produce no parent-stream chunks for their entire duration.
|
|
8
|
-
*
|
|
9
|
-
* The new stuck timer is task-aware: it escalates to an extended
|
|
10
|
-
* timeout (default 120 min) as soon as a sync Task/Agent tool call
|
|
11
|
-
* is detected (tracked by toolUseId), then reverts to the normal
|
|
12
|
-
* timeout once all tracked sync tool calls have emitted their
|
|
13
|
-
* tool_result.
|
|
14
|
-
*
|
|
15
|
-
* This module is a pure state machine — no grammy, no session,
|
|
16
|
-
* no provider. Testable in isolation with vi.useFakeTimers().
|
|
17
|
-
*/
|
|
18
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
19
|
-
import { createStuckTimer } from "../src/handlers/stuck-timer.js";
|
|
20
|
-
|
|
21
|
-
describe("stuck timer — task-aware state machine (v4.12.1)", () => {
|
|
22
|
-
beforeEach(() => vi.useFakeTimers());
|
|
23
|
-
afterEach(() => vi.useRealTimers());
|
|
24
|
-
|
|
25
|
-
it("fires after normalMs when no pending sync tasks", () => {
|
|
26
|
-
const onTimeout = vi.fn();
|
|
27
|
-
const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
|
|
28
|
-
t.reset();
|
|
29
|
-
vi.advanceTimersByTime(999);
|
|
30
|
-
expect(onTimeout).not.toHaveBeenCalled();
|
|
31
|
-
vi.advanceTimersByTime(1);
|
|
32
|
-
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("enterSync extends the timer to extendedMs", () => {
|
|
36
|
-
const onTimeout = vi.fn();
|
|
37
|
-
const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
|
|
38
|
-
t.reset();
|
|
39
|
-
t.enterSync("tool_1");
|
|
40
|
-
// 5 seconds in — should still be alive because we're in extended mode
|
|
41
|
-
vi.advanceTimersByTime(5000);
|
|
42
|
-
expect(onTimeout).not.toHaveBeenCalled();
|
|
43
|
-
// 5 more seconds (10s total since enterSync) — extended timer should fire
|
|
44
|
-
vi.advanceTimersByTime(5000);
|
|
45
|
-
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("exitSync returns to normalMs and rearms from that point", () => {
|
|
49
|
-
const onTimeout = vi.fn();
|
|
50
|
-
const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
|
|
51
|
-
t.enterSync("tool_1");
|
|
52
|
-
vi.advanceTimersByTime(500);
|
|
53
|
-
t.exitSync("tool_1");
|
|
54
|
-
// New normal timer is armed from exitSync time; fires after another 1000ms.
|
|
55
|
-
vi.advanceTimersByTime(999);
|
|
56
|
-
expect(onTimeout).not.toHaveBeenCalled();
|
|
57
|
-
vi.advanceTimersByTime(1);
|
|
58
|
-
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("multiple pending syncs: exit one keeps extended timer", () => {
|
|
62
|
-
const onTimeout = vi.fn();
|
|
63
|
-
const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
|
|
64
|
-
t.enterSync("tool_1");
|
|
65
|
-
t.enterSync("tool_2");
|
|
66
|
-
expect(t._pendingCount()).toBe(2);
|
|
67
|
-
t.exitSync("tool_1");
|
|
68
|
-
expect(t._pendingCount()).toBe(1);
|
|
69
|
-
// Still in extended mode — 5s of silence must not fire
|
|
70
|
-
vi.advanceTimersByTime(5000);
|
|
71
|
-
expect(onTimeout).not.toHaveBeenCalled();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("exitSync on unknown id is a no-op and doesn't corrupt state", () => {
|
|
75
|
-
const onTimeout = vi.fn();
|
|
76
|
-
const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
|
|
77
|
-
t.exitSync("never-seen");
|
|
78
|
-
expect(t._pendingCount()).toBe(0);
|
|
79
|
-
// Normal timer should work as usual
|
|
80
|
-
t.reset();
|
|
81
|
-
vi.advanceTimersByTime(1000);
|
|
82
|
-
expect(onTimeout).toHaveBeenCalled();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("cancel stops the timer entirely", () => {
|
|
86
|
-
const onTimeout = vi.fn();
|
|
87
|
-
const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
|
|
88
|
-
t.reset();
|
|
89
|
-
t.cancel();
|
|
90
|
-
vi.advanceTimersByTime(2000);
|
|
91
|
-
expect(onTimeout).not.toHaveBeenCalled();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("reset while extended keeps the extended timer (not shortening)", () => {
|
|
95
|
-
const onTimeout = vi.fn();
|
|
96
|
-
const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
|
|
97
|
-
t.enterSync("tool_1");
|
|
98
|
-
vi.advanceTimersByTime(500);
|
|
99
|
-
// A chunk arrived — reset. We should STAY in extended mode.
|
|
100
|
-
t.reset();
|
|
101
|
-
vi.advanceTimersByTime(9000);
|
|
102
|
-
expect(onTimeout).not.toHaveBeenCalled();
|
|
103
|
-
vi.advanceTimersByTime(1000);
|
|
104
|
-
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("idempotent enterSync: same id twice stays at count 1", () => {
|
|
108
|
-
const onTimeout = vi.fn();
|
|
109
|
-
const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
|
|
110
|
-
t.enterSync("tool_1");
|
|
111
|
-
t.enterSync("tool_1");
|
|
112
|
-
expect(t._pendingCount()).toBe(1);
|
|
113
|
-
t.exitSync("tool_1");
|
|
114
|
-
expect(t._pendingCount()).toBe(0);
|
|
115
|
-
});
|
|
116
|
-
});
|