alvin-bot 4.6.0 → 4.8.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 +191 -0
- package/bin/cli.js +314 -27
- package/dist/handlers/commands.js +54 -4
- 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/dist/tui/index.js +8 -1
- package/dist/version.js +24 -0
- package/dist/web/server.js +2 -1
- 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
|
@@ -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
|