alvin-bot 4.12.4 → 4.13.1
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 +121 -0
- package/dist/handlers/message.js +9 -0
- package/dist/paths.js +8 -0
- package/dist/providers/claude-sdk-provider.js +25 -5
- package/dist/services/alvin-dispatch.js +125 -0
- package/dist/services/alvin-mcp-tools.js +103 -0
- package/dist/services/async-agent-parser.js +50 -0
- package/dist/services/personality.js +36 -10
- package/dist/services/process-manager.js +291 -0
- package/dist/web/doctor-api.js +59 -67
- package/dist/web/setup-api.js +52 -0
- package/package.json +1 -1
- package/test/alvin-dispatch.test.ts +220 -0
- package/test/async-agent-parser-streamjson.test.ts +273 -0
- package/test/process-manager.test.ts +186 -0
- package/test/slack-test-connection.test.ts +176 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13 — alvin_dispatch custom-tool service.
|
|
3
|
+
*
|
|
4
|
+
* `dispatchDetachedAgent(input)` spawns a truly independent `claude -p`
|
|
5
|
+
* subprocess that survives the parent handler's abort. This is the
|
|
6
|
+
* architectural replacement for SDK's built-in Task(run_in_background)
|
|
7
|
+
* tool, which was tied to the parent SDK subprocess lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* Contract:
|
|
10
|
+
* - Input: { prompt, description, chatId, userId, sessionKey }
|
|
11
|
+
* - Output (synchronous): { agentId, outputFile, spawned: true }
|
|
12
|
+
* - Side effect: spawns detached subprocess writing stream-json
|
|
13
|
+
* output to outputFile, registers with async-agent-watcher.
|
|
14
|
+
*
|
|
15
|
+
* These tests stub child_process.spawn so they run fast and deterministic.
|
|
16
|
+
* The "real subprocess survives parent" property was verified empirically
|
|
17
|
+
* in Phase A (see plan doc).
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
20
|
+
import os from "os";
|
|
21
|
+
import fs from "fs";
|
|
22
|
+
import { resolve } from "path";
|
|
23
|
+
|
|
24
|
+
const TEST_DATA_DIR = resolve(
|
|
25
|
+
os.tmpdir(),
|
|
26
|
+
`alvin-dispatch-${process.pid}-${Date.now()}`,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
interface SpawnRecord {
|
|
30
|
+
cmd: string;
|
|
31
|
+
args: string[];
|
|
32
|
+
opts: {
|
|
33
|
+
detached?: boolean;
|
|
34
|
+
stdio?: unknown;
|
|
35
|
+
cwd?: string;
|
|
36
|
+
env?: Record<string, string | undefined>;
|
|
37
|
+
};
|
|
38
|
+
unreffed: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let spawned: SpawnRecord[] = [];
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
if (fs.existsSync(TEST_DATA_DIR))
|
|
45
|
+
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
46
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
47
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
48
|
+
spawned = [];
|
|
49
|
+
vi.resetModules();
|
|
50
|
+
|
|
51
|
+
vi.doMock("node:child_process", async () => {
|
|
52
|
+
const actual = await vi.importActual<typeof import("node:child_process")>(
|
|
53
|
+
"node:child_process",
|
|
54
|
+
);
|
|
55
|
+
return {
|
|
56
|
+
...actual,
|
|
57
|
+
spawn: (cmd: string, args: string[], opts: SpawnRecord["opts"]) => {
|
|
58
|
+
const record: SpawnRecord = {
|
|
59
|
+
cmd,
|
|
60
|
+
args,
|
|
61
|
+
opts,
|
|
62
|
+
unreffed: false,
|
|
63
|
+
};
|
|
64
|
+
spawned.push(record);
|
|
65
|
+
return {
|
|
66
|
+
pid: 12345,
|
|
67
|
+
unref() {
|
|
68
|
+
record.unreffed = true;
|
|
69
|
+
},
|
|
70
|
+
on() {},
|
|
71
|
+
kill() {},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
78
|
+
deliverSubAgentResult: async () => {},
|
|
79
|
+
attachBotApi: () => {},
|
|
80
|
+
__setBotApiForTest: () => {},
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
try {
|
|
86
|
+
const mod = await import("../src/services/async-agent-watcher.js");
|
|
87
|
+
mod.stopWatcher();
|
|
88
|
+
mod.__resetForTest();
|
|
89
|
+
} catch {
|
|
90
|
+
/* ignore */
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("dispatchDetachedAgent (v4.13)", () => {
|
|
95
|
+
it("spawns claude -p with detached: true and unrefs", async () => {
|
|
96
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
97
|
+
const result = mod.dispatchDetachedAgent({
|
|
98
|
+
prompt: "research X",
|
|
99
|
+
description: "X research",
|
|
100
|
+
chatId: 42,
|
|
101
|
+
userId: 42,
|
|
102
|
+
sessionKey: "s1",
|
|
103
|
+
});
|
|
104
|
+
expect(result.agentId).toMatch(/^alvin-[a-f0-9]{16,}$/);
|
|
105
|
+
expect(result.outputFile).toContain(TEST_DATA_DIR);
|
|
106
|
+
expect(result.spawned).toBe(true);
|
|
107
|
+
|
|
108
|
+
expect(spawned).toHaveLength(1);
|
|
109
|
+
const [s] = spawned;
|
|
110
|
+
expect(s.cmd).toMatch(/claude/);
|
|
111
|
+
expect(s.args).toContain("-p");
|
|
112
|
+
expect(s.args).toContain("research X");
|
|
113
|
+
expect(s.args).toContain("--output-format");
|
|
114
|
+
expect(s.args).toContain("stream-json");
|
|
115
|
+
expect(s.opts.detached).toBe(true);
|
|
116
|
+
expect(s.unreffed).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns unique agentIds for concurrent dispatches", async () => {
|
|
120
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
121
|
+
const r1 = mod.dispatchDetachedAgent({
|
|
122
|
+
prompt: "a",
|
|
123
|
+
description: "a",
|
|
124
|
+
chatId: 1,
|
|
125
|
+
userId: 1,
|
|
126
|
+
sessionKey: "s1",
|
|
127
|
+
});
|
|
128
|
+
const r2 = mod.dispatchDetachedAgent({
|
|
129
|
+
prompt: "b",
|
|
130
|
+
description: "b",
|
|
131
|
+
chatId: 1,
|
|
132
|
+
userId: 1,
|
|
133
|
+
sessionKey: "s1",
|
|
134
|
+
});
|
|
135
|
+
expect(r1.agentId).not.toBe(r2.agentId);
|
|
136
|
+
expect(r1.outputFile).not.toBe(r2.outputFile);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("registers the pending agent with the watcher", async () => {
|
|
140
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
141
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
142
|
+
|
|
143
|
+
mod.dispatchDetachedAgent({
|
|
144
|
+
prompt: "x",
|
|
145
|
+
description: "X audit",
|
|
146
|
+
chatId: 42,
|
|
147
|
+
userId: 42,
|
|
148
|
+
sessionKey: "s1",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const pending = watcher.listPendingAgents();
|
|
152
|
+
expect(pending).toHaveLength(1);
|
|
153
|
+
expect(pending[0].description).toBe("X audit");
|
|
154
|
+
expect(pending[0].sessionKey).toBe("s1");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("increments session.pendingBackgroundCount on dispatch", async () => {
|
|
158
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
159
|
+
const { getSession } = await import("../src/services/session.js");
|
|
160
|
+
|
|
161
|
+
const session = getSession("s-count");
|
|
162
|
+
session.pendingBackgroundCount = 0;
|
|
163
|
+
|
|
164
|
+
mod.dispatchDetachedAgent({
|
|
165
|
+
prompt: "p",
|
|
166
|
+
description: "d",
|
|
167
|
+
chatId: 1,
|
|
168
|
+
userId: 1,
|
|
169
|
+
sessionKey: "s-count",
|
|
170
|
+
});
|
|
171
|
+
expect(session.pendingBackgroundCount).toBe(1);
|
|
172
|
+
|
|
173
|
+
mod.dispatchDetachedAgent({
|
|
174
|
+
prompt: "p2",
|
|
175
|
+
description: "d2",
|
|
176
|
+
chatId: 1,
|
|
177
|
+
userId: 1,
|
|
178
|
+
sessionKey: "s-count",
|
|
179
|
+
});
|
|
180
|
+
expect(session.pendingBackgroundCount).toBe(2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("uses stdio redirect so child's stdout goes to outputFile", async () => {
|
|
184
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
185
|
+
mod.dispatchDetachedAgent({
|
|
186
|
+
prompt: "p",
|
|
187
|
+
description: "d",
|
|
188
|
+
chatId: 1,
|
|
189
|
+
userId: 1,
|
|
190
|
+
sessionKey: "s1",
|
|
191
|
+
});
|
|
192
|
+
const [s] = spawned;
|
|
193
|
+
// stdio should be an array with FD redirects (ignore, pipe-to-file, ignore)
|
|
194
|
+
// or similar. We verify it's NOT "inherit" (which would attach to parent).
|
|
195
|
+
expect(s.opts.stdio).not.toBe("inherit");
|
|
196
|
+
expect(s.opts.stdio).not.toBe(undefined);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("cleans env of CLAUDECODE/CLAUDE_CODE_ENTRYPOINT to prevent nested session errors", async () => {
|
|
200
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
201
|
+
process.env.CLAUDECODE = "1";
|
|
202
|
+
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
|
203
|
+
try {
|
|
204
|
+
mod.dispatchDetachedAgent({
|
|
205
|
+
prompt: "p",
|
|
206
|
+
description: "d",
|
|
207
|
+
chatId: 1,
|
|
208
|
+
userId: 1,
|
|
209
|
+
sessionKey: "s1",
|
|
210
|
+
});
|
|
211
|
+
const [s] = spawned;
|
|
212
|
+
expect(s.opts.env).toBeDefined();
|
|
213
|
+
expect(s.opts.env?.CLAUDECODE).toBeUndefined();
|
|
214
|
+
expect(s.opts.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
|
215
|
+
} finally {
|
|
216
|
+
delete process.env.CLAUDECODE;
|
|
217
|
+
delete process.env.CLAUDE_CODE_ENTRYPOINT;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13 — parseOutputFileStatus support for `claude -p --output-format stream-json`.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's built-in Task tool writes its sub-agent output in one JSONL
|
|
5
|
+
* format (events with `message.stop_reason: "end_turn"`). The new v4.13
|
|
6
|
+
* dispatch mechanism spawns `claude -p --output-format stream-json`
|
|
7
|
+
* which writes a DIFFERENT format:
|
|
8
|
+
*
|
|
9
|
+
* - Assistant messages have `message.stop_reason: null` (streaming shape)
|
|
10
|
+
* - A final `{"type":"result","subtype":"success","stop_reason":"end_turn",...}`
|
|
11
|
+
* event marks completion explicitly
|
|
12
|
+
* - `result.duration_ms`, `total_cost_usd`, `num_turns`, `usage`
|
|
13
|
+
* are the authoritative completion signals
|
|
14
|
+
*
|
|
15
|
+
* The parser must recognize BOTH formats. v4.13 adds detection for the
|
|
16
|
+
* result-event format while preserving backward compat with the existing
|
|
17
|
+
* SDK-internal format (tested in the sibling test files).
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
20
|
+
import fs from "fs";
|
|
21
|
+
import os from "os";
|
|
22
|
+
import { resolve } from "path";
|
|
23
|
+
import { parseOutputFileStatus } from "../src/services/async-agent-parser.js";
|
|
24
|
+
|
|
25
|
+
const TMP_BASE = resolve(
|
|
26
|
+
os.tmpdir(),
|
|
27
|
+
`alvin-parser-streamjson-${process.pid}`,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
fs.mkdirSync(TMP_BASE, { recursive: true });
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
try {
|
|
35
|
+
fs.rmSync(TMP_BASE, { recursive: true, force: true });
|
|
36
|
+
} catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("parseOutputFileStatus — stream-json format (v4.13)", () => {
|
|
42
|
+
it("returns 'completed' when final event is type:result + subtype:success", async () => {
|
|
43
|
+
const path = resolve(TMP_BASE, "stream-success.jsonl");
|
|
44
|
+
const lines = [
|
|
45
|
+
{ type: "system", subtype: "init", session_id: "s1" },
|
|
46
|
+
{
|
|
47
|
+
type: "assistant",
|
|
48
|
+
message: {
|
|
49
|
+
role: "assistant",
|
|
50
|
+
content: [{ type: "text", text: "The answer is 42." }],
|
|
51
|
+
stop_reason: null, // streaming shape — NOT end_turn yet
|
|
52
|
+
},
|
|
53
|
+
session_id: "s1",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: "result",
|
|
57
|
+
subtype: "success",
|
|
58
|
+
stop_reason: "end_turn",
|
|
59
|
+
session_id: "s1",
|
|
60
|
+
total_cost_usd: 0.01,
|
|
61
|
+
duration_ms: 500,
|
|
62
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
63
|
+
result: "The answer is 42.",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
path,
|
|
68
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
69
|
+
"utf-8",
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const status = await parseOutputFileStatus(path);
|
|
73
|
+
expect(status.state).toBe("completed");
|
|
74
|
+
if (status.state === "completed") {
|
|
75
|
+
expect(status.output).toContain("The answer is 42.");
|
|
76
|
+
expect(status.output).not.toMatch(/interrupted|partial/i);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("extracts tokens from result.usage when using stream-json format", async () => {
|
|
81
|
+
const path = resolve(TMP_BASE, "stream-tokens.jsonl");
|
|
82
|
+
const lines = [
|
|
83
|
+
{
|
|
84
|
+
type: "assistant",
|
|
85
|
+
message: {
|
|
86
|
+
content: [{ type: "text", text: "x" }],
|
|
87
|
+
stop_reason: null,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: "result",
|
|
92
|
+
subtype: "success",
|
|
93
|
+
stop_reason: "end_turn",
|
|
94
|
+
usage: { input_tokens: 1234, output_tokens: 567 },
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
fs.writeFileSync(
|
|
98
|
+
path,
|
|
99
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
100
|
+
"utf-8",
|
|
101
|
+
);
|
|
102
|
+
const status = await parseOutputFileStatus(path);
|
|
103
|
+
expect(status.state).toBe("completed");
|
|
104
|
+
if (status.state === "completed") {
|
|
105
|
+
expect(status.tokensUsed).toEqual({ input: 1234, output: 567 });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("recognises 'failed' state when result.is_error is true", async () => {
|
|
110
|
+
const path = resolve(TMP_BASE, "stream-failed.jsonl");
|
|
111
|
+
const lines = [
|
|
112
|
+
{
|
|
113
|
+
type: "assistant",
|
|
114
|
+
message: {
|
|
115
|
+
content: [{ type: "text", text: "I tried..." }],
|
|
116
|
+
stop_reason: null,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: "result",
|
|
121
|
+
subtype: "error_max_turns",
|
|
122
|
+
is_error: true,
|
|
123
|
+
stop_reason: "max_turns",
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
fs.writeFileSync(
|
|
127
|
+
path,
|
|
128
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
129
|
+
"utf-8",
|
|
130
|
+
);
|
|
131
|
+
const status = await parseOutputFileStatus(path);
|
|
132
|
+
// With an is_error result + text content, we still deliver the text
|
|
133
|
+
// as completed (better to give the user SOMETHING than nothing).
|
|
134
|
+
// The delivery layer can annotate differently if it chooses.
|
|
135
|
+
expect(status.state).toBe("completed");
|
|
136
|
+
if (status.state === "completed") {
|
|
137
|
+
expect(status.output).toContain("I tried...");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns 'running' when stream-json events are present but no result yet", async () => {
|
|
142
|
+
const path = resolve(TMP_BASE, "stream-running.jsonl");
|
|
143
|
+
const lines = [
|
|
144
|
+
{ type: "system", subtype: "init", session_id: "s1" },
|
|
145
|
+
{
|
|
146
|
+
type: "assistant",
|
|
147
|
+
message: {
|
|
148
|
+
content: [{ type: "text", text: "Thinking..." }],
|
|
149
|
+
stop_reason: null,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: "assistant",
|
|
154
|
+
message: {
|
|
155
|
+
content: [{ type: "tool_use", name: "Bash", input: {} }],
|
|
156
|
+
stop_reason: null,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
fs.writeFileSync(
|
|
161
|
+
path,
|
|
162
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
163
|
+
"utf-8",
|
|
164
|
+
);
|
|
165
|
+
const status = await parseOutputFileStatus(path);
|
|
166
|
+
expect(status.state).toBe("running");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("aggregates text from ALL assistant messages when result arrives", async () => {
|
|
170
|
+
const path = resolve(TMP_BASE, "stream-multi-text.jsonl");
|
|
171
|
+
const lines = [
|
|
172
|
+
{
|
|
173
|
+
type: "assistant",
|
|
174
|
+
message: {
|
|
175
|
+
content: [{ type: "text", text: "First thought." }],
|
|
176
|
+
stop_reason: null,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: "user",
|
|
181
|
+
message: { content: [{ type: "tool_result", content: "ok" }] },
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
type: "assistant",
|
|
185
|
+
message: {
|
|
186
|
+
content: [{ type: "text", text: "Continuing..." }],
|
|
187
|
+
stop_reason: null,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
type: "user",
|
|
192
|
+
message: { content: [{ type: "tool_result", content: "ok" }] },
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
type: "assistant",
|
|
196
|
+
message: {
|
|
197
|
+
content: [{ type: "text", text: "Final answer." }],
|
|
198
|
+
stop_reason: null,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{ type: "result", subtype: "success", stop_reason: "end_turn" },
|
|
202
|
+
];
|
|
203
|
+
fs.writeFileSync(
|
|
204
|
+
path,
|
|
205
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
206
|
+
"utf-8",
|
|
207
|
+
);
|
|
208
|
+
const status = await parseOutputFileStatus(path);
|
|
209
|
+
expect(status.state).toBe("completed");
|
|
210
|
+
if (status.state === "completed") {
|
|
211
|
+
// All three text blocks must be present
|
|
212
|
+
expect(status.output).toContain("First thought");
|
|
213
|
+
expect(status.output).toContain("Continuing");
|
|
214
|
+
expect(status.output).toContain("Final answer");
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("prefers result.result field as authoritative output when available", async () => {
|
|
219
|
+
// The stream-json's result event has a `result` field with the
|
|
220
|
+
// already-concatenated final answer. Use it directly when present
|
|
221
|
+
// (more accurate than re-aggregating from streaming chunks).
|
|
222
|
+
const path = resolve(TMP_BASE, "stream-result-field.jsonl");
|
|
223
|
+
const lines = [
|
|
224
|
+
{
|
|
225
|
+
type: "assistant",
|
|
226
|
+
message: {
|
|
227
|
+
content: [{ type: "text", text: "Intermediate chunk" }],
|
|
228
|
+
stop_reason: null,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
type: "result",
|
|
233
|
+
subtype: "success",
|
|
234
|
+
stop_reason: "end_turn",
|
|
235
|
+
result: "FINAL AUTHORITATIVE ANSWER",
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
fs.writeFileSync(
|
|
239
|
+
path,
|
|
240
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
241
|
+
"utf-8",
|
|
242
|
+
);
|
|
243
|
+
const status = await parseOutputFileStatus(path);
|
|
244
|
+
expect(status.state).toBe("completed");
|
|
245
|
+
if (status.state === "completed") {
|
|
246
|
+
expect(status.output).toContain("FINAL AUTHORITATIVE ANSWER");
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("handles result event with only partial fields (defensive)", async () => {
|
|
251
|
+
const path = resolve(TMP_BASE, "stream-result-minimal.jsonl");
|
|
252
|
+
const lines = [
|
|
253
|
+
{
|
|
254
|
+
type: "assistant",
|
|
255
|
+
message: {
|
|
256
|
+
content: [{ type: "text", text: "Some output" }],
|
|
257
|
+
stop_reason: null,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
{ type: "result" }, // no subtype, no result field, no usage
|
|
261
|
+
];
|
|
262
|
+
fs.writeFileSync(
|
|
263
|
+
path,
|
|
264
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
265
|
+
"utf-8",
|
|
266
|
+
);
|
|
267
|
+
const status = await parseOutputFileStatus(path);
|
|
268
|
+
expect(status.state).toBe("completed");
|
|
269
|
+
if (status.state === "completed") {
|
|
270
|
+
expect(status.output).toContain("Some output");
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13.1 — process-manager abstraction tests.
|
|
3
|
+
*
|
|
4
|
+
* The maintenance section in the Web UI used to hard-wire PM2 commands
|
|
5
|
+
* (`pm2 jlist`, `pm2 restart`, `pm2 stop`, `pm2 logs ...`). Since v4.8
|
|
6
|
+
* the Mac install uses launchd (`com.alvinbot.app.plist`) — PM2 isn't
|
|
7
|
+
* running, so those calls returned "PM2 not available" and the buttons
|
|
8
|
+
* did nothing.
|
|
9
|
+
*
|
|
10
|
+
* This module abstracts the process manager and auto-detects which one
|
|
11
|
+
* is actually managing the bot. Detection order:
|
|
12
|
+
*
|
|
13
|
+
* 1. launchd (macOS) — if `launchctl print gui/$UID/com.alvinbot.app`
|
|
14
|
+
* succeeds AND the bot's actual running pid matches
|
|
15
|
+
* 2. PM2 — if `pm2 jlist` returns our process
|
|
16
|
+
* 3. standalone — neither detected; only the in-process graceful
|
|
17
|
+
* restart works (scheduleGracefulRestart — since there's no
|
|
18
|
+
* supervisor to bring it back, "stop" is effectively "kill")
|
|
19
|
+
*
|
|
20
|
+
* Each manager implements: getStatus(), stop(), start(), getLogs().
|
|
21
|
+
* Restart is intentionally NOT on the manager — it always routes through
|
|
22
|
+
* scheduleGracefulRestart() (Grammy-safe) and the supervisor auto-brings-
|
|
23
|
+
* back behaviour.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
26
|
+
|
|
27
|
+
interface ExecCall {
|
|
28
|
+
cmd: string;
|
|
29
|
+
opts?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let execLog: ExecCall[] = [];
|
|
33
|
+
let execReturn: Record<string, string | Error> = {};
|
|
34
|
+
|
|
35
|
+
function stubExec() {
|
|
36
|
+
vi.doMock("node:child_process", () => ({
|
|
37
|
+
execSync: (cmd: string, opts?: unknown) => {
|
|
38
|
+
execLog.push({ cmd, opts });
|
|
39
|
+
// Find match by pattern — longest matching prefix wins
|
|
40
|
+
const matches = Object.keys(execReturn).filter((k) => cmd.includes(k));
|
|
41
|
+
matches.sort((a, b) => b.length - a.length);
|
|
42
|
+
const key = matches[0];
|
|
43
|
+
if (key) {
|
|
44
|
+
const v = execReturn[key];
|
|
45
|
+
if (v instanceof Error) throw v;
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`execSync: no stub for ${cmd}`);
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
execLog = [];
|
|
55
|
+
execReturn = {};
|
|
56
|
+
vi.resetModules();
|
|
57
|
+
stubExec();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.doUnmock("node:child_process");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("detectProcessManager (v4.13.1)", () => {
|
|
65
|
+
it("detects 'launchd' when launchctl print succeeds on darwin", async () => {
|
|
66
|
+
execReturn["launchctl print"] = `gui/502/com.alvinbot.app = {
|
|
67
|
+
state = running
|
|
68
|
+
program = /opt/homebrew/bin/node
|
|
69
|
+
}`;
|
|
70
|
+
const mod = await import("../src/services/process-manager.js");
|
|
71
|
+
const pm = mod.detectProcessManager({ platform: "darwin" });
|
|
72
|
+
expect(pm.kind).toBe("launchd");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("falls through to 'pm2' when launchd is not detected", async () => {
|
|
76
|
+
execReturn["launchctl print"] = new Error("Could not find service");
|
|
77
|
+
execReturn["pm2 jlist"] = JSON.stringify([
|
|
78
|
+
{ name: "alvin-bot", pid: 1234, pm2_env: { status: "online" } },
|
|
79
|
+
]);
|
|
80
|
+
const mod = await import("../src/services/process-manager.js");
|
|
81
|
+
const pm = mod.detectProcessManager({ platform: "linux" });
|
|
82
|
+
expect(pm.kind).toBe("pm2");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("falls through to 'standalone' when neither is detected", async () => {
|
|
86
|
+
execReturn["launchctl print"] = new Error("not found");
|
|
87
|
+
execReturn["pm2 jlist"] = new Error("command not found");
|
|
88
|
+
const mod = await import("../src/services/process-manager.js");
|
|
89
|
+
const pm = mod.detectProcessManager({ platform: "linux" });
|
|
90
|
+
expect(pm.kind).toBe("standalone");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("skips launchd detection on non-darwin platforms", async () => {
|
|
94
|
+
// No launchctl command should be issued on Linux
|
|
95
|
+
execReturn["pm2 jlist"] = JSON.stringify([
|
|
96
|
+
{ name: "alvin-bot", pid: 1234, pm2_env: { status: "online" } },
|
|
97
|
+
]);
|
|
98
|
+
const mod = await import("../src/services/process-manager.js");
|
|
99
|
+
const pm = mod.detectProcessManager({ platform: "linux" });
|
|
100
|
+
expect(pm.kind).toBe("pm2");
|
|
101
|
+
// Verify launchctl was NOT called
|
|
102
|
+
expect(execLog.some((e) => e.cmd.includes("launchctl"))).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("launchd process manager (v4.13.1)", () => {
|
|
107
|
+
it("getStatus parses launchctl print output for state + PID", async () => {
|
|
108
|
+
execReturn["launchctl print"] = `gui/502/com.alvinbot.app = {
|
|
109
|
+
active count = 1
|
|
110
|
+
state = running
|
|
111
|
+
program = /opt/homebrew/Cellar/node/25.9.0_1/bin/node
|
|
112
|
+
pid = 65432
|
|
113
|
+
program path = /usr/bin/node
|
|
114
|
+
working directory = /Users/alvin_de/Projects/alvin-bot
|
|
115
|
+
stdout path = /Users/alvin_de/.alvin-bot/logs/alvin-bot.out.log
|
|
116
|
+
}`;
|
|
117
|
+
const mod = await import("../src/services/process-manager.js");
|
|
118
|
+
const pm = mod.createLaunchdManager(502);
|
|
119
|
+
const status = await pm.getStatus();
|
|
120
|
+
expect(status.status).toBe("running");
|
|
121
|
+
expect(status.pid).toBe(65432);
|
|
122
|
+
expect(status.kind).toBe("launchd");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("getStatus returns 'not-loaded' when service is not registered", async () => {
|
|
126
|
+
execReturn["launchctl print"] = new Error("Could not find service");
|
|
127
|
+
const mod = await import("../src/services/process-manager.js");
|
|
128
|
+
const pm = mod.createLaunchdManager(502);
|
|
129
|
+
const status = await pm.getStatus();
|
|
130
|
+
expect(status.status).toBe("not-loaded");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("stop uses launchctl bootout", async () => {
|
|
134
|
+
execReturn["launchctl bootout"] = "";
|
|
135
|
+
const mod = await import("../src/services/process-manager.js");
|
|
136
|
+
const pm = mod.createLaunchdManager(502);
|
|
137
|
+
await pm.stop();
|
|
138
|
+
const stopCall = execLog.find((e) => e.cmd.includes("bootout"));
|
|
139
|
+
expect(stopCall).toBeDefined();
|
|
140
|
+
expect(stopCall!.cmd).toContain("gui/502/com.alvinbot.app");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("start uses launchctl bootstrap", async () => {
|
|
144
|
+
execReturn["launchctl bootstrap"] = "";
|
|
145
|
+
const mod = await import("../src/services/process-manager.js");
|
|
146
|
+
const pm = mod.createLaunchdManager(502);
|
|
147
|
+
await pm.start();
|
|
148
|
+
const startCall = execLog.find((e) => e.cmd.includes("bootstrap"));
|
|
149
|
+
expect(startCall).toBeDefined();
|
|
150
|
+
expect(startCall!.cmd).toMatch(/com\.alvinbot\.app\.plist/);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("pm2 process manager (v4.13.1)", () => {
|
|
155
|
+
it("getStatus parses pm2 jlist for our process", async () => {
|
|
156
|
+
execReturn["pm2 jlist"] = JSON.stringify([
|
|
157
|
+
{
|
|
158
|
+
name: "alvin-bot",
|
|
159
|
+
pid: 9999,
|
|
160
|
+
pm2_env: {
|
|
161
|
+
status: "online",
|
|
162
|
+
pm_uptime: Date.now() - 60_000,
|
|
163
|
+
restart_time: 2,
|
|
164
|
+
},
|
|
165
|
+
monit: { memory: 123456, cpu: 1.5 },
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
const mod = await import("../src/services/process-manager.js");
|
|
169
|
+
const pm = mod.createPm2Manager();
|
|
170
|
+
const status = await pm.getStatus();
|
|
171
|
+
expect(status.status).toBe("online");
|
|
172
|
+
expect(status.pid).toBe(9999);
|
|
173
|
+
expect(status.kind).toBe("pm2");
|
|
174
|
+
expect(status.restarts).toBe(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("getStatus returns 'unknown' if pm2 jlist does not include our process", async () => {
|
|
178
|
+
execReturn["pm2 jlist"] = JSON.stringify([
|
|
179
|
+
{ name: "other-service", pid: 1111, pm2_env: { status: "online" } },
|
|
180
|
+
]);
|
|
181
|
+
const mod = await import("../src/services/process-manager.js");
|
|
182
|
+
const pm = mod.createPm2Manager();
|
|
183
|
+
const status = await pm.getStatus();
|
|
184
|
+
expect(status.status).toBe("unknown");
|
|
185
|
+
});
|
|
186
|
+
});
|