alvin-bot 4.9.2 → 4.9.4
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 +79 -0
- package/README.md +12 -1
- package/dist/handlers/commands.js +93 -11
- package/dist/handlers/cron-progress.js +52 -0
- package/dist/index.js +8 -3
- package/dist/services/subagent-delivery.js +34 -3
- package/dist/web/bind-strategy.js +42 -0
- package/dist/web/server.js +231 -101
- package/package.json +1 -1
- package/test/cron-progress-ticker.test.ts +76 -0
- package/test/stress-scenarios.test.ts +1 -1
- package/test/subagent-delivery-markdown-fallback.test.ts +147 -0
- package/test/web-server-integration.test.ts +189 -0
- package/test/web-server-resilience.test.ts +118 -0
- package/test/web-server-shutdown.test.ts +7 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #16 (integration) — end-to-end tests for the decoupled
|
|
3
|
+
* startWebServer + stopWebServer pair.
|
|
4
|
+
*
|
|
5
|
+
* These tests exercise the ACTUAL http.Server binding, not the pure
|
|
6
|
+
* decision helper. They rely on:
|
|
7
|
+
* - process.env.WEB_PORT to keep the test off the running bot's 3100
|
|
8
|
+
* - process.env.ALVIN_DATA_DIR to keep touch-points away from
|
|
9
|
+
* the maintainer's real ~/.alvin-bot/.env
|
|
10
|
+
*
|
|
11
|
+
* What's covered here:
|
|
12
|
+
* 1. startWebServer() returns synchronously (void) without throwing
|
|
13
|
+
* 2. stopWebServer() releases the port so another server can bind
|
|
14
|
+
* 3. Start → stop → start cycle doesn't leak sockets or timers
|
|
15
|
+
* 4. If the configured port is already busy, startWebServer still
|
|
16
|
+
* returns cleanly (no throw); the bot keeps running.
|
|
17
|
+
* 5. stopWebServer() is idempotent — safe to call twice in a row
|
|
18
|
+
* and safe to call before startWebServer ever succeeded.
|
|
19
|
+
*
|
|
20
|
+
* The deliberate EADDRINUSE scenario is tested HERE against a real
|
|
21
|
+
* running hog — no mocking.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
24
|
+
import http from "http";
|
|
25
|
+
import fs from "fs";
|
|
26
|
+
import os from "os";
|
|
27
|
+
import { resolve } from "path";
|
|
28
|
+
|
|
29
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-web-int-${process.pid}-${Date.now()}`);
|
|
30
|
+
|
|
31
|
+
function getFreePort(): Promise<number> {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const s = http.createServer();
|
|
34
|
+
s.listen(0, () => {
|
|
35
|
+
const addr = s.address();
|
|
36
|
+
if (typeof addr === "object" && addr) {
|
|
37
|
+
const p = addr.port;
|
|
38
|
+
s.close(() => resolve(p));
|
|
39
|
+
} else {
|
|
40
|
+
reject(new Error("no address"));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function waitForPortBound(port: number, timeoutMs = 3000): Promise<boolean> {
|
|
47
|
+
const deadline = Date.now() + timeoutMs;
|
|
48
|
+
while (Date.now() < deadline) {
|
|
49
|
+
try {
|
|
50
|
+
const code = await new Promise<number>((resolveCode, reject) => {
|
|
51
|
+
const req = http.get(`http://127.0.0.1:${port}/`, (res) => {
|
|
52
|
+
res.resume();
|
|
53
|
+
resolveCode(res.statusCode ?? 0);
|
|
54
|
+
});
|
|
55
|
+
req.on("error", (err) => reject(err));
|
|
56
|
+
req.setTimeout(500, () => {
|
|
57
|
+
req.destroy(new Error("timeout"));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
if (code > 0) return true;
|
|
61
|
+
} catch {
|
|
62
|
+
/* not yet */
|
|
63
|
+
}
|
|
64
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
71
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
72
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
73
|
+
// Write a minimal .env so config.ts loads cleanly
|
|
74
|
+
fs.writeFileSync(`${TEST_DATA_DIR}/.env`, "WEB_PASSWORD=\n", "utf-8");
|
|
75
|
+
process.env.WEB_PORT = String(await getFreePort());
|
|
76
|
+
// Reset module cache so each test imports server.js fresh and
|
|
77
|
+
// picks up the new WEB_PORT env var at module-load time.
|
|
78
|
+
vi.resetModules();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(async () => {
|
|
82
|
+
// Best-effort: stop whatever is running in the current module instance
|
|
83
|
+
try {
|
|
84
|
+
const { stopWebServer } = await import("../src/web/server.js");
|
|
85
|
+
await stopWebServer();
|
|
86
|
+
} catch {
|
|
87
|
+
/* ignore */
|
|
88
|
+
}
|
|
89
|
+
// Give the OS a moment to release ports before the next test
|
|
90
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("startWebServer / stopWebServer integration (Fix #16)", () => {
|
|
94
|
+
it("startWebServer returns void synchronously without throwing", async () => {
|
|
95
|
+
const { startWebServer } = await import("../src/web/server.js");
|
|
96
|
+
const result = startWebServer();
|
|
97
|
+
// Must return void (undefined). If it returned a Server instance
|
|
98
|
+
// the old API is still in place.
|
|
99
|
+
expect(result).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("actually binds the web server and serves HTTP", async () => {
|
|
103
|
+
const port = Number(process.env.WEB_PORT);
|
|
104
|
+
const { startWebServer } = await import("../src/web/server.js");
|
|
105
|
+
startWebServer();
|
|
106
|
+
const up = await waitForPortBound(port, 3000);
|
|
107
|
+
expect(up).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("stopWebServer releases the port", async () => {
|
|
111
|
+
const port = Number(process.env.WEB_PORT);
|
|
112
|
+
const { startWebServer, stopWebServer } = await import("../src/web/server.js");
|
|
113
|
+
startWebServer();
|
|
114
|
+
expect(await waitForPortBound(port, 3000)).toBe(true);
|
|
115
|
+
await stopWebServer();
|
|
116
|
+
|
|
117
|
+
// Port should now be free — a fresh bind must succeed
|
|
118
|
+
const reuse = http.createServer();
|
|
119
|
+
await new Promise<void>((resolve, reject) => {
|
|
120
|
+
reuse.once("error", reject);
|
|
121
|
+
reuse.listen(port, () => resolve());
|
|
122
|
+
});
|
|
123
|
+
await new Promise<void>((r) => reuse.close(() => r()));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("stopWebServer is idempotent — safe to call multiple times", async () => {
|
|
127
|
+
const { startWebServer, stopWebServer } = await import("../src/web/server.js");
|
|
128
|
+
startWebServer();
|
|
129
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
130
|
+
await stopWebServer();
|
|
131
|
+
// Second call must not throw
|
|
132
|
+
await expect(stopWebServer()).resolves.toBeUndefined();
|
|
133
|
+
// Third call must also not throw
|
|
134
|
+
await expect(stopWebServer()).resolves.toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("stopWebServer is safe to call before startWebServer ever bound", async () => {
|
|
138
|
+
const { stopWebServer } = await import("../src/web/server.js");
|
|
139
|
+
// Module just imported — nothing started yet
|
|
140
|
+
await expect(stopWebServer()).resolves.toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("when the primary port is taken, startWebServer still returns cleanly (climbs the ladder)", async () => {
|
|
144
|
+
const originalPort = Number(process.env.WEB_PORT);
|
|
145
|
+
// Plant a hog on the primary port BEFORE startWebServer
|
|
146
|
+
const hog = http.createServer();
|
|
147
|
+
await new Promise<void>((r) => hog.listen(originalPort, () => r()));
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const { startWebServer } = await import("../src/web/server.js");
|
|
151
|
+
// Must NOT throw even though the port is occupied
|
|
152
|
+
expect(() => startWebServer()).not.toThrow();
|
|
153
|
+
|
|
154
|
+
// The bot should have climbed the ladder — one port higher should
|
|
155
|
+
// now be serving HTTP.
|
|
156
|
+
const climbed = await waitForPortBound(originalPort + 1, 3000);
|
|
157
|
+
expect(climbed).toBe(true);
|
|
158
|
+
} finally {
|
|
159
|
+
await new Promise<void>((r) => hog.close(() => r()));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("closeHttpServerGracefully closes a server that's holding an open socket", async () => {
|
|
164
|
+
const { closeHttpServerGracefully } = await import("../src/web/server.js");
|
|
165
|
+
const port = await getFreePort();
|
|
166
|
+
const server = http.createServer((_req, res) => {
|
|
167
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
168
|
+
res.write("chunk");
|
|
169
|
+
// never res.end — client hangs forever
|
|
170
|
+
});
|
|
171
|
+
await new Promise<void>((r) => server.listen(port, () => r()));
|
|
172
|
+
|
|
173
|
+
const req = http.get(`http://127.0.0.1:${port}/hang`);
|
|
174
|
+
req.on("error", () => { /* expected */ });
|
|
175
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
176
|
+
|
|
177
|
+
const t0 = Date.now();
|
|
178
|
+
await closeHttpServerGracefully(server);
|
|
179
|
+
expect(Date.now() - t0).toBeLessThan(2000);
|
|
180
|
+
|
|
181
|
+
// Port is reusable
|
|
182
|
+
const reuse = http.createServer();
|
|
183
|
+
await new Promise<void>((resolve, reject) => {
|
|
184
|
+
reuse.once("error", reject);
|
|
185
|
+
reuse.listen(port, () => resolve());
|
|
186
|
+
});
|
|
187
|
+
await new Promise<void>((r) => reuse.close(() => r()));
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #16 — Web server must never crash the bot.
|
|
3
|
+
*
|
|
4
|
+
* Colleague feedback (WhatsApp voice note, 2026-04-13):
|
|
5
|
+
* > The gateway binds to port 3100 like OpenClaw. When the bot
|
|
6
|
+
* > restarts, the port is often still held → catastrophic crash.
|
|
7
|
+
* > I ended up decoupling the gateway process completely, because
|
|
8
|
+
* > the actual bot runs independently of the gateway — it can still
|
|
9
|
+
* > answer Telegram even if the web endpoint isn't reachable yet.
|
|
10
|
+
* > It's weird that the main routine crashes when the port is busy.
|
|
11
|
+
* > It should just run in the background, watch for the port to
|
|
12
|
+
* > become free, and connect then. Zero impact on the main routine.
|
|
13
|
+
*
|
|
14
|
+
* This file tests the pure decision helper that the new startWebServer
|
|
15
|
+
* uses to choose between "try the next port immediately" and "retry
|
|
16
|
+
* the default port in the background after a delay".
|
|
17
|
+
*
|
|
18
|
+
* Contract:
|
|
19
|
+
* decideNextBindAction(err, attempt, opts)
|
|
20
|
+
*
|
|
21
|
+
* err.code = "EADDRINUSE", attempt < maxPortTries
|
|
22
|
+
* → { type: "retry-port", port: opts.originalPort + attempt + 1, attempt: attempt + 1 }
|
|
23
|
+
*
|
|
24
|
+
* err.code = "EADDRINUSE", attempt >= maxPortTries
|
|
25
|
+
* → { type: "retry-background", delayMs: opts.backgroundRetryMs, port: opts.originalPort }
|
|
26
|
+
*
|
|
27
|
+
* err.code = anything else (EACCES, ECONNRESET, "Listen method called twice"…)
|
|
28
|
+
* → { type: "retry-background", delayMs: opts.backgroundRetryMs, port: opts.originalPort }
|
|
29
|
+
*
|
|
30
|
+
* Pure function, no side effects, no timers, no I/O.
|
|
31
|
+
*/
|
|
32
|
+
import { describe, it, expect } from "vitest";
|
|
33
|
+
import { decideNextBindAction } from "../src/web/bind-strategy.js";
|
|
34
|
+
|
|
35
|
+
const defaultOpts = {
|
|
36
|
+
originalPort: 3100,
|
|
37
|
+
maxPortTries: 20,
|
|
38
|
+
backgroundRetryMs: 30_000,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("decideNextBindAction (Fix #16)", () => {
|
|
42
|
+
it("retries on the next port when EADDRINUSE and attempts remain", () => {
|
|
43
|
+
const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
|
|
44
|
+
const result = decideNextBindAction(err, 0, defaultOpts);
|
|
45
|
+
expect(result).toEqual({ type: "retry-port", port: 3101, attempt: 1 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("walks the port ladder across multiple attempts", () => {
|
|
49
|
+
const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
|
|
50
|
+
expect(decideNextBindAction(err, 5, defaultOpts)).toEqual({
|
|
51
|
+
type: "retry-port",
|
|
52
|
+
port: 3106,
|
|
53
|
+
attempt: 6,
|
|
54
|
+
});
|
|
55
|
+
expect(decideNextBindAction(err, 18, defaultOpts)).toEqual({
|
|
56
|
+
type: "retry-port",
|
|
57
|
+
port: 3119,
|
|
58
|
+
attempt: 19,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("switches to background retry when all port attempts are exhausted", () => {
|
|
63
|
+
const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
|
|
64
|
+
const result = decideNextBindAction(err, 19, defaultOpts); // 20th failure
|
|
65
|
+
expect(result).toEqual({
|
|
66
|
+
type: "retry-background",
|
|
67
|
+
delayMs: 30_000,
|
|
68
|
+
port: 3100,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("goes straight to background retry on non-EADDRINUSE errors", () => {
|
|
73
|
+
const err = Object.assign(new Error("EACCES"), { code: "EACCES" });
|
|
74
|
+
const result = decideNextBindAction(err, 0, defaultOpts);
|
|
75
|
+
expect(result).toEqual({
|
|
76
|
+
type: "retry-background",
|
|
77
|
+
delayMs: 30_000,
|
|
78
|
+
port: 3100,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("handles errors without a .code field by doing background retry", () => {
|
|
83
|
+
const err = new Error("Listen method has been called more than once");
|
|
84
|
+
const result = decideNextBindAction(err, 3, defaultOpts);
|
|
85
|
+
expect(result.type).toBe("retry-background");
|
|
86
|
+
if (result.type === "retry-background") {
|
|
87
|
+
expect(result.port).toBe(3100);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("respects custom maxPortTries", () => {
|
|
92
|
+
const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
|
|
93
|
+
const opts = { ...defaultOpts, maxPortTries: 3 };
|
|
94
|
+
// attempts 0, 1 still retry; attempt 2 is the LAST retry; attempt 3 -> background
|
|
95
|
+
expect(decideNextBindAction(err, 0, opts).type).toBe("retry-port");
|
|
96
|
+
expect(decideNextBindAction(err, 1, opts).type).toBe("retry-port");
|
|
97
|
+
expect(decideNextBindAction(err, 2, opts).type).toBe("retry-background");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("respects custom backgroundRetryMs", () => {
|
|
101
|
+
const err = Object.assign(new Error("EACCES"), { code: "EACCES" });
|
|
102
|
+
const opts = { ...defaultOpts, backgroundRetryMs: 5_000 };
|
|
103
|
+
const result = decideNextBindAction(err, 0, opts);
|
|
104
|
+
expect(result).toEqual({
|
|
105
|
+
type: "retry-background",
|
|
106
|
+
delayMs: 5_000,
|
|
107
|
+
port: 3100,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("is pure — same input, same output, no mutation", () => {
|
|
112
|
+
const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
|
|
113
|
+
const snapshot = JSON.stringify({ ...defaultOpts });
|
|
114
|
+
decideNextBindAction(err, 5, defaultOpts);
|
|
115
|
+
decideNextBindAction(err, 5, defaultOpts);
|
|
116
|
+
expect(JSON.stringify({ ...defaultOpts })).toBe(snapshot);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
import { describe, it, expect } from "vitest";
|
|
18
18
|
import http from "http";
|
|
19
19
|
import { once } from "events";
|
|
20
|
-
|
|
20
|
+
// Fix #1 shipped as stopWebServer(server) — Fix #16 (v4.9.4) promoted
|
|
21
|
+
// that to `closeHttpServerGracefully(server)` and reserved the name
|
|
22
|
+
// `stopWebServer()` for the module-state-aware shutdown. The underlying
|
|
23
|
+
// contract (close an http.Server even when clients hold open sockets,
|
|
24
|
+
// release the port, idempotent, never throw) is unchanged — these
|
|
25
|
+
// tests now exercise the renamed helper.
|
|
26
|
+
import { closeHttpServerGracefully as stopWebServer } from "../src/web/server.js";
|
|
21
27
|
|
|
22
28
|
function getFreePort(): Promise<number> {
|
|
23
29
|
return new Promise((resolve, reject) => {
|