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,228 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v4.12.3 — async-agent watcher ↔ session.pendingBackgroundCount wiring.
|
|
3
|
-
*
|
|
4
|
-
* Contract:
|
|
5
|
-
* - registerPendingAgent takes an optional `sessionKey` so the watcher
|
|
6
|
-
* can locate the right UserSession later.
|
|
7
|
-
* - When the watcher delivers a result (completed/failed/timeout), the
|
|
8
|
-
* session's pendingBackgroundCount MUST be decremented so the main
|
|
9
|
-
* handler knows it's safe to resume SDK-session-based queries.
|
|
10
|
-
* - Decrement is clamped at 0 — the counter never goes negative even
|
|
11
|
-
* if decoupled operations drift.
|
|
12
|
-
* - The handler is responsible for INCREMENTING when it registers.
|
|
13
|
-
* The watcher only decrements.
|
|
14
|
-
*
|
|
15
|
-
* These tests use the shared in-memory session Map from session.ts so
|
|
16
|
-
* they exercise the actual wiring, not a mock.
|
|
17
|
-
*/
|
|
18
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
19
|
-
import fs from "fs";
|
|
20
|
-
import os from "os";
|
|
21
|
-
import { resolve } from "path";
|
|
22
|
-
|
|
23
|
-
const TEST_DATA_DIR = resolve(
|
|
24
|
-
os.tmpdir(),
|
|
25
|
-
`alvin-watcher-pending-${process.pid}-${Date.now()}`,
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
beforeEach(async () => {
|
|
29
|
-
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
30
|
-
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
31
|
-
}
|
|
32
|
-
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
33
|
-
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
34
|
-
vi.resetModules();
|
|
35
|
-
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
36
|
-
deliverSubAgentResult: async () => {},
|
|
37
|
-
attachBotApi: () => {},
|
|
38
|
-
__setBotApiForTest: () => {},
|
|
39
|
-
}));
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
afterEach(async () => {
|
|
43
|
-
try {
|
|
44
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
45
|
-
mod.stopWatcher();
|
|
46
|
-
mod.__resetForTest();
|
|
47
|
-
} catch {
|
|
48
|
-
/* ignore */
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
function writeCompletedJsonl(path: string, finalText: string): void {
|
|
53
|
-
const lines =
|
|
54
|
-
[
|
|
55
|
-
JSON.stringify({
|
|
56
|
-
type: "user",
|
|
57
|
-
isSidechain: true,
|
|
58
|
-
agentId: "x",
|
|
59
|
-
message: { role: "user", content: "do it" },
|
|
60
|
-
}),
|
|
61
|
-
JSON.stringify({
|
|
62
|
-
type: "assistant",
|
|
63
|
-
isSidechain: true,
|
|
64
|
-
agentId: "x",
|
|
65
|
-
message: {
|
|
66
|
-
role: "assistant",
|
|
67
|
-
content: [{ type: "text", text: finalText }],
|
|
68
|
-
stop_reason: "end_turn",
|
|
69
|
-
usage: { input_tokens: 10, output_tokens: 5 },
|
|
70
|
-
},
|
|
71
|
-
}),
|
|
72
|
-
].join("\n") + "\n";
|
|
73
|
-
fs.mkdirSync(resolve(path, ".."), { recursive: true });
|
|
74
|
-
fs.writeFileSync(path, lines, "utf-8");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
describe("watcher ↔ session.pendingBackgroundCount (v4.12.3)", () => {
|
|
78
|
-
it("completed delivery decrements pendingBackgroundCount on the right session", async () => {
|
|
79
|
-
const { getSession } = await import("../src/services/session.js");
|
|
80
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
81
|
-
|
|
82
|
-
const sessionKey = "v412-session-a";
|
|
83
|
-
const session = getSession(sessionKey);
|
|
84
|
-
session.pendingBackgroundCount = 1;
|
|
85
|
-
|
|
86
|
-
const outPath = `${TEST_DATA_DIR}/out-a.jsonl`;
|
|
87
|
-
watcher.registerPendingAgent({
|
|
88
|
-
agentId: "a",
|
|
89
|
-
outputFile: outPath,
|
|
90
|
-
description: "research",
|
|
91
|
-
prompt: "p",
|
|
92
|
-
chatId: 42,
|
|
93
|
-
userId: 42,
|
|
94
|
-
toolUseId: null,
|
|
95
|
-
sessionKey,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
writeCompletedJsonl(outPath, "result");
|
|
99
|
-
await watcher.pollOnce();
|
|
100
|
-
|
|
101
|
-
expect(session.pendingBackgroundCount).toBe(0);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("timeout delivery also decrements the counter", async () => {
|
|
105
|
-
const { getSession } = await import("../src/services/session.js");
|
|
106
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
107
|
-
|
|
108
|
-
const sessionKey = "v412-session-timeout";
|
|
109
|
-
const session = getSession(sessionKey);
|
|
110
|
-
session.pendingBackgroundCount = 2;
|
|
111
|
-
|
|
112
|
-
watcher.registerPendingAgent({
|
|
113
|
-
agentId: "timed-out",
|
|
114
|
-
outputFile: `${TEST_DATA_DIR}/never-written.jsonl`,
|
|
115
|
-
description: "slow task",
|
|
116
|
-
prompt: "p",
|
|
117
|
-
chatId: 42,
|
|
118
|
-
userId: 42,
|
|
119
|
-
toolUseId: null,
|
|
120
|
-
sessionKey,
|
|
121
|
-
giveUpAt: Date.now() - 1000,
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
await watcher.pollOnce();
|
|
125
|
-
|
|
126
|
-
expect(session.pendingBackgroundCount).toBe(1);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("failure delivery decrements the counter", async () => {
|
|
130
|
-
const { getSession } = await import("../src/services/session.js");
|
|
131
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
132
|
-
|
|
133
|
-
const sessionKey = "v412-session-fail";
|
|
134
|
-
const session = getSession(sessionKey);
|
|
135
|
-
session.pendingBackgroundCount = 3;
|
|
136
|
-
|
|
137
|
-
const outPath = `${TEST_DATA_DIR}/fail.jsonl`;
|
|
138
|
-
// Write a malformed "error" state — a single invalid line that will
|
|
139
|
-
// fall through the parser and stay in "running" state. Then mark
|
|
140
|
-
// the session as a timeout by moving giveUpAt into the past.
|
|
141
|
-
// Actually easier: use giveUpAt again as the trigger.
|
|
142
|
-
watcher.registerPendingAgent({
|
|
143
|
-
agentId: "fail-via-timeout",
|
|
144
|
-
outputFile: outPath,
|
|
145
|
-
description: "will fail",
|
|
146
|
-
prompt: "p",
|
|
147
|
-
chatId: 42,
|
|
148
|
-
userId: 42,
|
|
149
|
-
toolUseId: null,
|
|
150
|
-
sessionKey,
|
|
151
|
-
giveUpAt: Date.now() - 1000,
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
await watcher.pollOnce();
|
|
155
|
-
|
|
156
|
-
expect(session.pendingBackgroundCount).toBe(2);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("decrement is clamped at 0 — counter never goes negative", async () => {
|
|
160
|
-
const { getSession } = await import("../src/services/session.js");
|
|
161
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
162
|
-
|
|
163
|
-
const sessionKey = "v412-session-drift";
|
|
164
|
-
const session = getSession(sessionKey);
|
|
165
|
-
session.pendingBackgroundCount = 0; // drift scenario
|
|
166
|
-
|
|
167
|
-
const outPath = `${TEST_DATA_DIR}/drift.jsonl`;
|
|
168
|
-
watcher.registerPendingAgent({
|
|
169
|
-
agentId: "drift",
|
|
170
|
-
outputFile: outPath,
|
|
171
|
-
description: "drift",
|
|
172
|
-
prompt: "p",
|
|
173
|
-
chatId: 42,
|
|
174
|
-
userId: 42,
|
|
175
|
-
toolUseId: null,
|
|
176
|
-
sessionKey,
|
|
177
|
-
});
|
|
178
|
-
writeCompletedJsonl(outPath, "done");
|
|
179
|
-
await watcher.pollOnce();
|
|
180
|
-
|
|
181
|
-
expect(session.pendingBackgroundCount).toBe(0);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it("missing sessionKey is handled gracefully — no throw, no crash", async () => {
|
|
185
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
186
|
-
const outPath = `${TEST_DATA_DIR}/orphan.jsonl`;
|
|
187
|
-
watcher.registerPendingAgent({
|
|
188
|
-
agentId: "orphan",
|
|
189
|
-
outputFile: outPath,
|
|
190
|
-
description: "orphan",
|
|
191
|
-
prompt: "p",
|
|
192
|
-
chatId: 42,
|
|
193
|
-
userId: 42,
|
|
194
|
-
toolUseId: null,
|
|
195
|
-
// sessionKey intentionally omitted
|
|
196
|
-
});
|
|
197
|
-
writeCompletedJsonl(outPath, "done");
|
|
198
|
-
await expect(watcher.pollOnce()).resolves.not.toThrow();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("multiple agents for the same session all decrement", async () => {
|
|
202
|
-
const { getSession } = await import("../src/services/session.js");
|
|
203
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
204
|
-
|
|
205
|
-
const sessionKey = "v412-session-multi";
|
|
206
|
-
const session = getSession(sessionKey);
|
|
207
|
-
session.pendingBackgroundCount = 3;
|
|
208
|
-
|
|
209
|
-
for (const id of ["m1", "m2", "m3"]) {
|
|
210
|
-
const outPath = `${TEST_DATA_DIR}/${id}.jsonl`;
|
|
211
|
-
watcher.registerPendingAgent({
|
|
212
|
-
agentId: id,
|
|
213
|
-
outputFile: outPath,
|
|
214
|
-
description: `task ${id}`,
|
|
215
|
-
prompt: "p",
|
|
216
|
-
chatId: 42,
|
|
217
|
-
userId: 42,
|
|
218
|
-
toolUseId: null,
|
|
219
|
-
sessionKey,
|
|
220
|
-
});
|
|
221
|
-
writeCompletedJsonl(outPath, `result ${id}`);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
await watcher.pollOnce();
|
|
225
|
-
|
|
226
|
-
expect(session.pendingBackgroundCount).toBe(0);
|
|
227
|
-
});
|
|
228
|
-
});
|
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v4.14.2 — zombie-entry fix for async-agent-watcher.
|
|
3
|
-
*
|
|
4
|
-
* Problem: when the dispatched `claude -p` subprocess never produces
|
|
5
|
-
* its outputFile (crashed before the first write, spawn failed, file
|
|
6
|
-
* got deleted externally), `parseOutputFileStatus` returns "missing"
|
|
7
|
-
* on every poll. The watcher keeps polling forever until `giveUpAt`
|
|
8
|
-
* (12 hours) fires, then delivers a timeout banner. Meanwhile the
|
|
9
|
-
* entry hangs in `/subagents list` as a permanent "running" zombie.
|
|
10
|
-
*
|
|
11
|
-
* Fix: when status is "missing" for longer than
|
|
12
|
-
* `MISSING_FILE_FAILURE_MS` (default 10 min, env-configurable), the
|
|
13
|
-
* watcher declares the agent failed with a clear "output file never
|
|
14
|
-
* appeared" reason, delivers the failure banner, and removes the
|
|
15
|
-
* entry. 10 minutes is well above normal startup variance (seconds)
|
|
16
|
-
* and well below the 12h hard ceiling.
|
|
17
|
-
*
|
|
18
|
-
* Invariants preserved:
|
|
19
|
-
* - An agent whose output file DOES appear, even slowly, continues
|
|
20
|
-
* normally (missing on first poll, running on second, completed
|
|
21
|
-
* on third — same as v4.14.1).
|
|
22
|
-
* - The `completed` path (end_turn or stream-json result) is
|
|
23
|
-
* unchanged.
|
|
24
|
-
* - The `failed` path (existing "error" state from parser) is
|
|
25
|
-
* unchanged.
|
|
26
|
-
* - The 12h giveUpAt ceiling still applies — it's now just less
|
|
27
|
-
* likely to be hit because missing-file zombies resolve earlier.
|
|
28
|
-
*/
|
|
29
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
30
|
-
import fs from "fs";
|
|
31
|
-
import os from "os";
|
|
32
|
-
import { resolve } from "path";
|
|
33
|
-
|
|
34
|
-
const TEST_DATA_DIR = resolve(
|
|
35
|
-
os.tmpdir(),
|
|
36
|
-
`alvin-zombie-${process.pid}-${Date.now()}`,
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
interface Delivered {
|
|
40
|
-
info: { name: string; status: string };
|
|
41
|
-
result: { status: string; output: string; error?: string };
|
|
42
|
-
}
|
|
43
|
-
let delivered: Delivered[] = [];
|
|
44
|
-
|
|
45
|
-
beforeEach(async () => {
|
|
46
|
-
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
47
|
-
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
48
|
-
}
|
|
49
|
-
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
50
|
-
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
51
|
-
// Reset the env override between tests
|
|
52
|
-
delete process.env.ALVIN_MISSING_FILE_FAILURE_MS;
|
|
53
|
-
delivered = [];
|
|
54
|
-
vi.resetModules();
|
|
55
|
-
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
56
|
-
deliverSubAgentResult: async (info: unknown, result: unknown) => {
|
|
57
|
-
delivered.push({
|
|
58
|
-
info: info as Delivered["info"],
|
|
59
|
-
result: result as Delivered["result"],
|
|
60
|
-
});
|
|
61
|
-
},
|
|
62
|
-
attachBotApi: () => {},
|
|
63
|
-
__setBotApiForTest: () => {},
|
|
64
|
-
}));
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
afterEach(async () => {
|
|
68
|
-
try {
|
|
69
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
70
|
-
mod.stopWatcher();
|
|
71
|
-
mod.__resetForTest();
|
|
72
|
-
} catch {}
|
|
73
|
-
delete process.env.ALVIN_MISSING_FILE_FAILURE_MS;
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe("watcher zombie fix (v4.14.2)", () => {
|
|
77
|
-
it("missing file younger than threshold stays pending (no premature fail)", async () => {
|
|
78
|
-
// Threshold = 10 min. Backdate only 2 min. Expect: still pending.
|
|
79
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
80
|
-
mod.registerPendingAgent({
|
|
81
|
-
agentId: "young-zombie",
|
|
82
|
-
outputFile: `${TEST_DATA_DIR}/nonexistent.jsonl`,
|
|
83
|
-
description: "young",
|
|
84
|
-
prompt: "p",
|
|
85
|
-
chatId: 1,
|
|
86
|
-
userId: 1,
|
|
87
|
-
toolUseId: null,
|
|
88
|
-
});
|
|
89
|
-
// Forcibly set startedAt to 2 min ago
|
|
90
|
-
const pending = mod.listPendingAgents();
|
|
91
|
-
expect(pending).toHaveLength(1);
|
|
92
|
-
(pending[0] as { startedAt: number }).startedAt = Date.now() - 2 * 60_000;
|
|
93
|
-
|
|
94
|
-
await mod.pollOnce();
|
|
95
|
-
|
|
96
|
-
expect(delivered).toHaveLength(0);
|
|
97
|
-
expect(mod.listPendingAgents()).toHaveLength(1);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("missing file older than threshold delivers failed + removes from pending", async () => {
|
|
101
|
-
process.env.ALVIN_MISSING_FILE_FAILURE_MS = "120000"; // 2 min for test
|
|
102
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
103
|
-
mod.registerPendingAgent({
|
|
104
|
-
agentId: "old-zombie",
|
|
105
|
-
outputFile: `${TEST_DATA_DIR}/never-appears.jsonl`,
|
|
106
|
-
description: "stuck crash zombie",
|
|
107
|
-
prompt: "p",
|
|
108
|
-
chatId: 1,
|
|
109
|
-
userId: 1,
|
|
110
|
-
toolUseId: null,
|
|
111
|
-
});
|
|
112
|
-
// Backdate 5 min (> 2 min threshold)
|
|
113
|
-
const pending = mod.listPendingAgents();
|
|
114
|
-
(pending[0] as { startedAt: number }).startedAt = Date.now() - 5 * 60_000;
|
|
115
|
-
|
|
116
|
-
await mod.pollOnce();
|
|
117
|
-
|
|
118
|
-
expect(delivered).toHaveLength(1);
|
|
119
|
-
expect(delivered[0].result.status).toBe("error");
|
|
120
|
-
// Error message should be explicit so user understands
|
|
121
|
-
expect(delivered[0].result.error).toMatch(/output file|never appeared|never wrote/i);
|
|
122
|
-
expect(mod.listPendingAgents()).toHaveLength(0);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("default threshold is 10 min when env var is not set", async () => {
|
|
126
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
127
|
-
mod.registerPendingAgent({
|
|
128
|
-
agentId: "at-default",
|
|
129
|
-
outputFile: `${TEST_DATA_DIR}/z.jsonl`,
|
|
130
|
-
description: "default threshold",
|
|
131
|
-
prompt: "p",
|
|
132
|
-
chatId: 1,
|
|
133
|
-
userId: 1,
|
|
134
|
-
toolUseId: null,
|
|
135
|
-
});
|
|
136
|
-
// Backdate 9 min — still under the 10-min default, should stay pending
|
|
137
|
-
let p = mod.listPendingAgents();
|
|
138
|
-
(p[0] as { startedAt: number }).startedAt = Date.now() - 9 * 60_000;
|
|
139
|
-
await mod.pollOnce();
|
|
140
|
-
expect(delivered).toHaveLength(0);
|
|
141
|
-
expect(mod.listPendingAgents()).toHaveLength(1);
|
|
142
|
-
|
|
143
|
-
// Backdate to 11 min — over threshold, should fire
|
|
144
|
-
p = mod.listPendingAgents();
|
|
145
|
-
(p[0] as { startedAt: number }).startedAt = Date.now() - 11 * 60_000;
|
|
146
|
-
await mod.pollOnce();
|
|
147
|
-
expect(delivered).toHaveLength(1);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("running file (has content, no end_turn) is unaffected by zombie check", async () => {
|
|
151
|
-
// A file WITH content should never trigger the missing-file path
|
|
152
|
-
// regardless of age.
|
|
153
|
-
const outPath = `${TEST_DATA_DIR}/running.jsonl`;
|
|
154
|
-
fs.writeFileSync(
|
|
155
|
-
outPath,
|
|
156
|
-
JSON.stringify({
|
|
157
|
-
type: "assistant",
|
|
158
|
-
isSidechain: true,
|
|
159
|
-
agentId: "x",
|
|
160
|
-
message: {
|
|
161
|
-
role: "assistant",
|
|
162
|
-
content: [{ type: "tool_use", name: "Bash", input: {} }],
|
|
163
|
-
stop_reason: "tool_use",
|
|
164
|
-
},
|
|
165
|
-
}) + "\n",
|
|
166
|
-
"utf-8",
|
|
167
|
-
);
|
|
168
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
169
|
-
mod.registerPendingAgent({
|
|
170
|
-
agentId: "active-work",
|
|
171
|
-
outputFile: outPath,
|
|
172
|
-
description: "legitimately running",
|
|
173
|
-
prompt: "p",
|
|
174
|
-
chatId: 1,
|
|
175
|
-
userId: 1,
|
|
176
|
-
toolUseId: null,
|
|
177
|
-
});
|
|
178
|
-
const p = mod.listPendingAgents();
|
|
179
|
-
(p[0] as { startedAt: number }).startedAt = Date.now() - 30 * 60_000; // 30 min old
|
|
180
|
-
|
|
181
|
-
await mod.pollOnce();
|
|
182
|
-
|
|
183
|
-
// v4.12.4 staleness detection COULD fire here because the file has
|
|
184
|
-
// text content and is stale. That's a different (benign) path — the
|
|
185
|
-
// agent gets delivered as "completed with partial output". Either
|
|
186
|
-
// way, the zombie-fix error path must NOT fire.
|
|
187
|
-
const anyZombieError = delivered.some(
|
|
188
|
-
(d) => d.result.error && /output file never/i.test(d.result.error),
|
|
189
|
-
);
|
|
190
|
-
expect(anyZombieError).toBe(false);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("completed file delivers as completed (unchanged)", async () => {
|
|
194
|
-
const outPath = `${TEST_DATA_DIR}/done.jsonl`;
|
|
195
|
-
fs.writeFileSync(
|
|
196
|
-
outPath,
|
|
197
|
-
JSON.stringify({
|
|
198
|
-
type: "assistant",
|
|
199
|
-
agentId: "x",
|
|
200
|
-
message: {
|
|
201
|
-
content: [{ type: "text", text: "all good" }],
|
|
202
|
-
stop_reason: "end_turn",
|
|
203
|
-
},
|
|
204
|
-
}) + "\n",
|
|
205
|
-
"utf-8",
|
|
206
|
-
);
|
|
207
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
208
|
-
mod.registerPendingAgent({
|
|
209
|
-
agentId: "done-agent",
|
|
210
|
-
outputFile: outPath,
|
|
211
|
-
description: "clean completion",
|
|
212
|
-
prompt: "p",
|
|
213
|
-
chatId: 1,
|
|
214
|
-
userId: 1,
|
|
215
|
-
toolUseId: null,
|
|
216
|
-
});
|
|
217
|
-
// Backdate 1h — would trigger zombie if misapplied
|
|
218
|
-
const p = mod.listPendingAgents();
|
|
219
|
-
(p[0] as { startedAt: number }).startedAt = Date.now() - 60 * 60_000;
|
|
220
|
-
|
|
221
|
-
await mod.pollOnce();
|
|
222
|
-
|
|
223
|
-
expect(delivered).toHaveLength(1);
|
|
224
|
-
expect(delivered[0].result.status).toBe("completed");
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("decrements session counter on zombie failure delivery", async () => {
|
|
228
|
-
process.env.ALVIN_MISSING_FILE_FAILURE_MS = "1000"; // 1 sec for fast test
|
|
229
|
-
const sessionMod = await import("../src/services/session.js");
|
|
230
|
-
const session = sessionMod.getSession("zombie-session");
|
|
231
|
-
session.pendingBackgroundCount = 1;
|
|
232
|
-
|
|
233
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
234
|
-
mod.registerPendingAgent({
|
|
235
|
-
agentId: "session-zombie",
|
|
236
|
-
outputFile: `${TEST_DATA_DIR}/gone.jsonl`,
|
|
237
|
-
description: "zombie for counter",
|
|
238
|
-
prompt: "p",
|
|
239
|
-
chatId: 1,
|
|
240
|
-
userId: 1,
|
|
241
|
-
toolUseId: null,
|
|
242
|
-
sessionKey: "zombie-session",
|
|
243
|
-
});
|
|
244
|
-
const p = mod.listPendingAgents();
|
|
245
|
-
(p[0] as { startedAt: number }).startedAt = Date.now() - 5000; // 5 sec ago, > 1sec threshold
|
|
246
|
-
|
|
247
|
-
await mod.pollOnce();
|
|
248
|
-
|
|
249
|
-
expect(delivered).toHaveLength(1);
|
|
250
|
-
expect(session.pendingBackgroundCount).toBe(0);
|
|
251
|
-
});
|
|
252
|
-
});
|
|
@@ -1,189 +0,0 @@
|
|
|
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
|
-
});
|