alvin-bot 4.6.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/bin/cli.js +79 -26
- package/dist/handlers/commands.js +47 -3
- package/dist/i18n.js +8 -8
- package/dist/index.js +1 -0
- package/dist/services/subagent-delivery.js +155 -0
- package/dist/services/subagent-stats.js +123 -0
- package/dist/services/subagents.js +225 -72
- package/docs/HANDBOOK.md +39 -2
- package/package.json +1 -1
- package/test/subagent-delivery.test.ts +104 -0
- package/test/subagent-stats.test.ts +119 -0
- package/test/subagents-config.test.ts +7 -1
- package/test/subagents-priority-reject.test.ts +29 -1
- package/test/subagents-queue.test.ts +127 -0
- package/alvin-bot-4.5.1.tgz +0 -0
|
@@ -167,3 +167,107 @@ describe("subagent-delivery (I3)", () => {
|
|
|
167
167
|
expect(sentMessages).toHaveLength(0);
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
|
+
|
|
171
|
+
describe("subagent-delivery LiveStream (A4)", () => {
|
|
172
|
+
const edits: Array<{ chatId: number; messageId: number; text: string }> = [];
|
|
173
|
+
let messageCounter = 100;
|
|
174
|
+
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
edits.length = 0;
|
|
177
|
+
messageCounter = 100;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
async function wireLiveApi() {
|
|
181
|
+
const mod = await import("../src/services/subagent-delivery.js");
|
|
182
|
+
mod.__setBotApiForTest({
|
|
183
|
+
sendMessage: async (chatId: number, text: string) => {
|
|
184
|
+
sentMessages.push({ chatId, text });
|
|
185
|
+
return { message_id: messageCounter++ };
|
|
186
|
+
},
|
|
187
|
+
sendDocument: async (chatId: number) => {
|
|
188
|
+
sentDocuments.push({ chatId });
|
|
189
|
+
return {};
|
|
190
|
+
},
|
|
191
|
+
editMessageText: async (chatId: number, messageId: number, text: string) => {
|
|
192
|
+
edits.push({ chatId, messageId, text });
|
|
193
|
+
return {};
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
return mod;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
it("start posts an initial 'thinking…' message and records messageId", async () => {
|
|
200
|
+
const mod = await wireLiveApi();
|
|
201
|
+
const stream = mod.createLiveStream(555, "code-review");
|
|
202
|
+
expect(stream).not.toBeNull();
|
|
203
|
+
await stream!.start();
|
|
204
|
+
|
|
205
|
+
expect(sentMessages).toHaveLength(1);
|
|
206
|
+
expect(sentMessages[0].chatId).toBe(555);
|
|
207
|
+
expect(sentMessages[0].text).toContain("thinking");
|
|
208
|
+
expect(stream!.failed).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("update coalesces multiple rapid calls into a single throttled edit", async () => {
|
|
212
|
+
const mod = await wireLiveApi();
|
|
213
|
+
const stream = mod.createLiveStream(1, "fast");
|
|
214
|
+
await stream!.start();
|
|
215
|
+
|
|
216
|
+
stream!.update("hello");
|
|
217
|
+
stream!.update("hello world");
|
|
218
|
+
stream!.update("hello world and more");
|
|
219
|
+
|
|
220
|
+
// Wait for the throttle window to elapse
|
|
221
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
222
|
+
|
|
223
|
+
// Should have produced exactly one edit with the LAST text
|
|
224
|
+
expect(edits.length).toBe(1);
|
|
225
|
+
expect(edits[0].text).toContain("hello world and more");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("finalize posts a banner as a new message", async () => {
|
|
229
|
+
const mod = await wireLiveApi();
|
|
230
|
+
const stream = mod.createLiveStream(42, "done-agent");
|
|
231
|
+
await stream!.start();
|
|
232
|
+
stream!.update("final text");
|
|
233
|
+
await new Promise((r) => setTimeout(r, 900)); // let flush run
|
|
234
|
+
|
|
235
|
+
await stream!.finalize(
|
|
236
|
+
{
|
|
237
|
+
id: "x",
|
|
238
|
+
name: "done-agent",
|
|
239
|
+
status: "completed",
|
|
240
|
+
startedAt: Date.now() - 5000,
|
|
241
|
+
source: "user",
|
|
242
|
+
depth: 0,
|
|
243
|
+
parentChatId: 42,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "x",
|
|
247
|
+
name: "done-agent",
|
|
248
|
+
status: "completed",
|
|
249
|
+
output: "final text",
|
|
250
|
+
tokensUsed: { input: 100, output: 50 },
|
|
251
|
+
duration: 5000,
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Two sends total: initial "thinking…" + final banner
|
|
256
|
+
expect(sentMessages.length).toBe(2);
|
|
257
|
+
const banner = sentMessages[sentMessages.length - 1].text;
|
|
258
|
+
expect(banner).toContain("done-agent");
|
|
259
|
+
expect(banner).toContain("completed");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("createLiveStream returns null when bot api lacks editMessageText", async () => {
|
|
263
|
+
const mod = await import("../src/services/subagent-delivery.js");
|
|
264
|
+
// Set an api that intentionally has no editMessageText
|
|
265
|
+
mod.__setBotApiForTest({
|
|
266
|
+
sendMessage: async () => ({ message_id: 1 }),
|
|
267
|
+
sendDocument: async () => ({}),
|
|
268
|
+
// no editMessageText
|
|
269
|
+
});
|
|
270
|
+
const stream = mod.createLiveStream(1, "no-edit");
|
|
271
|
+
expect(stream).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
import type { SubAgentInfo, SubAgentResult } from "../src/services/subagents.js";
|
|
6
|
+
|
|
7
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-stats-${process.pid}-${Date.now()}`);
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
11
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
12
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function makeInfo(overrides: Partial<SubAgentInfo> = {}): SubAgentInfo {
|
|
17
|
+
return {
|
|
18
|
+
id: "x",
|
|
19
|
+
name: "test",
|
|
20
|
+
status: "completed",
|
|
21
|
+
startedAt: Date.now() - 1000,
|
|
22
|
+
source: "user",
|
|
23
|
+
depth: 0,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeResult(overrides: Partial<SubAgentResult> = {}): SubAgentResult {
|
|
29
|
+
return {
|
|
30
|
+
id: "x",
|
|
31
|
+
name: "test",
|
|
32
|
+
status: "completed",
|
|
33
|
+
output: "ok",
|
|
34
|
+
tokensUsed: { input: 100, output: 50 },
|
|
35
|
+
duration: 1000,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("subagent-stats (H3)", () => {
|
|
41
|
+
it("getSubAgentStats returns zeros on a fresh install", async () => {
|
|
42
|
+
const mod = await import("../src/services/subagent-stats.js");
|
|
43
|
+
const stats = mod.getSubAgentStats();
|
|
44
|
+
expect(stats.total.runs).toBe(0);
|
|
45
|
+
expect(stats.bySource.user.runs).toBe(0);
|
|
46
|
+
expect(stats.byStatus.completed).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("recordSubAgentRun appends and updates totals", async () => {
|
|
50
|
+
const mod = await import("../src/services/subagent-stats.js");
|
|
51
|
+
mod.recordSubAgentRun(makeInfo({ source: "user" }), makeResult({ tokensUsed: { input: 100, output: 50 } }));
|
|
52
|
+
mod.recordSubAgentRun(makeInfo({ source: "cron" }), makeResult({ tokensUsed: { input: 200, output: 75 } }));
|
|
53
|
+
mod.recordSubAgentRun(makeInfo({ source: "user" }), makeResult({ tokensUsed: { input: 50, output: 25 } }));
|
|
54
|
+
|
|
55
|
+
const stats = mod.getSubAgentStats();
|
|
56
|
+
expect(stats.total.runs).toBe(3);
|
|
57
|
+
expect(stats.total.inputTokens).toBe(350);
|
|
58
|
+
expect(stats.total.outputTokens).toBe(150);
|
|
59
|
+
expect(stats.bySource.user.runs).toBe(2);
|
|
60
|
+
expect(stats.bySource.user.inputTokens).toBe(150);
|
|
61
|
+
expect(stats.bySource.cron.runs).toBe(1);
|
|
62
|
+
expect(stats.bySource.cron.inputTokens).toBe(200);
|
|
63
|
+
expect(stats.byStatus.completed).toBe(3);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("persists to disk and round-trips through reload", async () => {
|
|
67
|
+
let mod = await import("../src/services/subagent-stats.js");
|
|
68
|
+
mod.recordSubAgentRun(makeInfo({ source: "cron" }), makeResult());
|
|
69
|
+
|
|
70
|
+
// Force a reload by resetting modules
|
|
71
|
+
vi.resetModules();
|
|
72
|
+
mod = await import("../src/services/subagent-stats.js");
|
|
73
|
+
|
|
74
|
+
const stats = mod.getSubAgentStats();
|
|
75
|
+
expect(stats.total.runs).toBe(1);
|
|
76
|
+
expect(stats.bySource.cron.runs).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("prunes entries older than 24h", async () => {
|
|
80
|
+
const mod = await import("../src/services/subagent-stats.js");
|
|
81
|
+
// Seed the file with an entry from 25 hours ago
|
|
82
|
+
const ancient = [
|
|
83
|
+
{
|
|
84
|
+
completedAt: Date.now() - 25 * 60 * 60 * 1000,
|
|
85
|
+
name: "ancient",
|
|
86
|
+
source: "user",
|
|
87
|
+
status: "completed",
|
|
88
|
+
durationMs: 100,
|
|
89
|
+
inputTokens: 999,
|
|
90
|
+
outputTokens: 999,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
resolve(TEST_DATA_DIR, "subagent-stats.json"),
|
|
95
|
+
JSON.stringify(ancient),
|
|
96
|
+
);
|
|
97
|
+
mod.__resetStatsCacheForTest();
|
|
98
|
+
|
|
99
|
+
// Fresh read should exclude the ancient entry
|
|
100
|
+
const stats = mod.getSubAgentStats();
|
|
101
|
+
expect(stats.total.runs).toBe(0);
|
|
102
|
+
expect(stats.total.inputTokens).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("tracks byStatus separately for cancelled/error/timeout", async () => {
|
|
106
|
+
const mod = await import("../src/services/subagent-stats.js");
|
|
107
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "completed" }));
|
|
108
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "cancelled" }));
|
|
109
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "error" }));
|
|
110
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "timeout" }));
|
|
111
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "completed" }));
|
|
112
|
+
|
|
113
|
+
const stats = mod.getSubAgentStats();
|
|
114
|
+
expect(stats.byStatus.completed).toBe(2);
|
|
115
|
+
expect(stats.byStatus.cancelled).toBe(1);
|
|
116
|
+
expect(stats.byStatus.error).toBe(1);
|
|
117
|
+
expect(stats.byStatus.timeout).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -96,7 +96,13 @@ describe("sub-agents visibility config (A4)", () => {
|
|
|
96
96
|
|
|
97
97
|
it("rejects invalid visibility values", async () => {
|
|
98
98
|
const mod = await import("../src/services/subagents.js");
|
|
99
|
-
expect(() => mod.setVisibility("
|
|
99
|
+
expect(() => mod.setVisibility("bogus" as "auto")).toThrow(/invalid/i);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("accepts 'live' as a valid visibility mode (A4 Stufe 2)", async () => {
|
|
103
|
+
const mod = await import("../src/services/subagents.js");
|
|
104
|
+
mod.setVisibility("live");
|
|
105
|
+
expect(mod.getVisibility()).toBe("live");
|
|
100
106
|
});
|
|
101
107
|
|
|
102
108
|
it("setVisibility('auto') round-trips through disk", async () => {
|
|
@@ -23,10 +23,13 @@ vi.mock("../src/engine.js", () => ({
|
|
|
23
23
|
}),
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
|
-
describe("priority-aware reject (D4)", () => {
|
|
26
|
+
describe("priority-aware reject (D4) — queue disabled", () => {
|
|
27
|
+
// With queueCap=0 the bounded queue is disabled, so hitting the max
|
|
28
|
+
// parallel limit triggers immediate rejection — the D4 messages.
|
|
27
29
|
it("user-spawn message points out cron/implicit jobs hold the slots", async () => {
|
|
28
30
|
const mod = await import("../src/services/subagents.js");
|
|
29
31
|
mod.setMaxParallelAgents(2);
|
|
32
|
+
mod.setQueueCap(0);
|
|
30
33
|
|
|
31
34
|
await mod.spawnSubAgent({ name: "bg-1", prompt: "x", source: "cron" });
|
|
32
35
|
await mod.spawnSubAgent({ name: "bg-2", prompt: "y", source: "implicit" });
|
|
@@ -39,6 +42,7 @@ describe("priority-aware reject (D4)", () => {
|
|
|
39
42
|
it("user-spawn message blames user agents when they hold all slots", async () => {
|
|
40
43
|
const mod = await import("../src/services/subagents.js");
|
|
41
44
|
mod.setMaxParallelAgents(2);
|
|
45
|
+
mod.setQueueCap(0);
|
|
42
46
|
|
|
43
47
|
await mod.spawnSubAgent({ name: "u1", prompt: "x", source: "user" });
|
|
44
48
|
await mod.spawnSubAgent({ name: "u2", prompt: "y", source: "user" });
|
|
@@ -51,6 +55,7 @@ describe("priority-aware reject (D4)", () => {
|
|
|
51
55
|
it("cron and implicit rejects use the generic message", async () => {
|
|
52
56
|
const mod = await import("../src/services/subagents.js");
|
|
53
57
|
mod.setMaxParallelAgents(1);
|
|
58
|
+
mod.setQueueCap(0);
|
|
54
59
|
|
|
55
60
|
await mod.spawnSubAgent({ name: "first", prompt: "x", source: "user" });
|
|
56
61
|
await expect(
|
|
@@ -58,3 +63,26 @@ describe("priority-aware reject (D4)", () => {
|
|
|
58
63
|
).rejects.toThrow(/limit reached/i);
|
|
59
64
|
});
|
|
60
65
|
});
|
|
66
|
+
|
|
67
|
+
describe("priority-aware reject (D4) — queue enabled", () => {
|
|
68
|
+
// With a queue, reject only fires when BOTH the running pool and the
|
|
69
|
+
// queue are full. Queue-enabled rejections still carry the priority-
|
|
70
|
+
// aware messages because the code path is shared.
|
|
71
|
+
it("user-spawn rejects with cron-in-queue message when pool + queue are full", async () => {
|
|
72
|
+
const mod = await import("../src/services/subagents.js");
|
|
73
|
+
mod.setMaxParallelAgents(2);
|
|
74
|
+
mod.setQueueCap(2);
|
|
75
|
+
|
|
76
|
+
// Fill the running pool with cron agents
|
|
77
|
+
await mod.spawnSubAgent({ name: "bg-1", prompt: "x", source: "cron" });
|
|
78
|
+
await mod.spawnSubAgent({ name: "bg-2", prompt: "y", source: "implicit" });
|
|
79
|
+
// Fill the queue with 2 more
|
|
80
|
+
await mod.spawnSubAgent({ name: "q-1", prompt: "z", source: "cron" });
|
|
81
|
+
await mod.spawnSubAgent({ name: "q-2", prompt: "z", source: "cron" });
|
|
82
|
+
|
|
83
|
+
// Now a user spawn has nowhere to go → reject
|
|
84
|
+
await expect(
|
|
85
|
+
mod.spawnSubAgent({ name: "user-new", prompt: "w", source: "user" }),
|
|
86
|
+
).rejects.toThrow(/Queue voll|Hintergrund/i);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for the D3 bounded priority queue.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-queue-${process.pid}-${Date.now()}`);
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
14
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
15
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
16
|
+
delete process.env.MAX_SUBAGENTS;
|
|
17
|
+
vi.resetModules();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Slow generator so the first 2 agents stay "running" while we test
|
|
21
|
+
// queueing of subsequent spawns.
|
|
22
|
+
vi.mock("../src/engine.js", () => ({
|
|
23
|
+
getRegistry: () => ({
|
|
24
|
+
queryWithFallback: async function* () {
|
|
25
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
26
|
+
yield { type: "done", text: "ok", inputTokens: 0, outputTokens: 0 };
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
describe("sub-agents bounded queue (D3)", () => {
|
|
32
|
+
it("getQueueCap defaults to 20", async () => {
|
|
33
|
+
const mod = await import("../src/services/subagents.js");
|
|
34
|
+
expect(mod.getQueueCap()).toBe(20);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("setQueueCap persists to disk and round-trips through reload", async () => {
|
|
38
|
+
const mod = await import("../src/services/subagents.js");
|
|
39
|
+
mod.setQueueCap(5);
|
|
40
|
+
expect(mod.getQueueCap()).toBe(5);
|
|
41
|
+
|
|
42
|
+
const configPath = resolve(TEST_DATA_DIR, "sub-agents.json");
|
|
43
|
+
const persisted = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
44
|
+
expect(persisted.queueCap).toBe(5);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("clamps setQueueCap to [0, ABSOLUTE_MAX_QUEUE]", async () => {
|
|
48
|
+
const mod = await import("../src/services/subagents.js");
|
|
49
|
+
expect(mod.setQueueCap(-5)).toBe(0);
|
|
50
|
+
expect(mod.setQueueCap(500)).toBe(200); // ABSOLUTE_MAX_QUEUE
|
|
51
|
+
expect(mod.setQueueCap(7.9)).toBe(7);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("third spawn at full pool lands in the queue as status=queued", async () => {
|
|
55
|
+
const mod = await import("../src/services/subagents.js");
|
|
56
|
+
mod.setMaxParallelAgents(2);
|
|
57
|
+
mod.setQueueCap(20);
|
|
58
|
+
|
|
59
|
+
await mod.spawnSubAgent({ name: "a", prompt: "x", source: "user" });
|
|
60
|
+
await mod.spawnSubAgent({ name: "b", prompt: "y", source: "user" });
|
|
61
|
+
const id = await mod.spawnSubAgent({ name: "c", prompt: "z", source: "user" });
|
|
62
|
+
|
|
63
|
+
const info = mod.listSubAgents().find((a) => a.id === id);
|
|
64
|
+
expect(info?.status).toBe("queued");
|
|
65
|
+
expect(info?.queuePosition).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("queue drains automatically when a running agent finishes", async () => {
|
|
69
|
+
const mod = await import("../src/services/subagents.js");
|
|
70
|
+
mod.setMaxParallelAgents(2);
|
|
71
|
+
mod.setQueueCap(20);
|
|
72
|
+
|
|
73
|
+
await mod.spawnSubAgent({ name: "a", prompt: "x", source: "user" });
|
|
74
|
+
await mod.spawnSubAgent({ name: "b", prompt: "y", source: "user" });
|
|
75
|
+
const cId = await mod.spawnSubAgent({ name: "c", prompt: "z", source: "user" });
|
|
76
|
+
|
|
77
|
+
// c is queued
|
|
78
|
+
expect(mod.listSubAgents().find((a) => a.id === cId)?.status).toBe("queued");
|
|
79
|
+
|
|
80
|
+
// Wait for the first two to finish (1s each) + drain cycle
|
|
81
|
+
await new Promise((r) => setTimeout(r, 1400));
|
|
82
|
+
|
|
83
|
+
// c should now be running or completed
|
|
84
|
+
const cInfo = mod.listSubAgents().find((a) => a.id === cId);
|
|
85
|
+
expect(["running", "completed"]).toContain(cInfo?.status);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("priority order: user spawns drain before cron at the same moment", async () => {
|
|
89
|
+
const mod = await import("../src/services/subagents.js");
|
|
90
|
+
mod.setMaxParallelAgents(1);
|
|
91
|
+
mod.setQueueCap(20);
|
|
92
|
+
|
|
93
|
+
// One running blocker
|
|
94
|
+
await mod.spawnSubAgent({ name: "blocker", prompt: "x", source: "user" });
|
|
95
|
+
// Queue order: cron first, then user — BUT the drain should pick user first
|
|
96
|
+
const cronId = await mod.spawnSubAgent({ name: "cron-q", prompt: "y", source: "cron" });
|
|
97
|
+
const userId = await mod.spawnSubAgent({ name: "user-q", prompt: "z", source: "user" });
|
|
98
|
+
|
|
99
|
+
// Wait for blocker to finish and drain
|
|
100
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
101
|
+
|
|
102
|
+
const cronInfo = mod.listSubAgents().find((a) => a.id === cronId);
|
|
103
|
+
const userInfo = mod.listSubAgents().find((a) => a.id === userId);
|
|
104
|
+
|
|
105
|
+
// user agent should be running or done; cron should still be queued
|
|
106
|
+
// (because user has higher priority when draining)
|
|
107
|
+
expect(["running", "completed"]).toContain(userInfo?.status);
|
|
108
|
+
expect(cronInfo?.status).toBe("queued");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("cancelSubAgent removes a queued entry from the queue", async () => {
|
|
112
|
+
const mod = await import("../src/services/subagents.js");
|
|
113
|
+
mod.setMaxParallelAgents(1);
|
|
114
|
+
mod.setQueueCap(20);
|
|
115
|
+
|
|
116
|
+
await mod.spawnSubAgent({ name: "blocker", prompt: "x", source: "user" });
|
|
117
|
+
const qId = await mod.spawnSubAgent({ name: "victim", prompt: "y", source: "user" });
|
|
118
|
+
|
|
119
|
+
expect(mod.listSubAgents().find((a) => a.id === qId)?.status).toBe("queued");
|
|
120
|
+
|
|
121
|
+
const ok = mod.cancelSubAgent(qId);
|
|
122
|
+
expect(ok).toBe(true);
|
|
123
|
+
|
|
124
|
+
const info = mod.listSubAgents().find((a) => a.id === qId);
|
|
125
|
+
expect(info?.status).toBe("cancelled");
|
|
126
|
+
});
|
|
127
|
+
});
|
package/alvin-bot-4.5.1.tgz
DELETED
|
Binary file
|