@katyella/legio 0.1.3 → 0.2.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/CHANGELOG.md +61 -3
- package/README.md +21 -10
- package/agents/builder.md +11 -10
- package/agents/coordinator.md +36 -27
- package/agents/cto.md +9 -8
- package/agents/gateway.md +28 -12
- package/agents/lead.md +45 -30
- package/agents/merger.md +4 -4
- package/agents/monitor.md +10 -9
- package/agents/reviewer.md +8 -8
- package/agents/scout.md +10 -10
- package/agents/supervisor.md +60 -45
- package/package.json +2 -2
- package/src/agents/hooks-deployer.test.ts +46 -41
- package/src/agents/hooks-deployer.ts +10 -9
- package/src/agents/manifest.test.ts +6 -2
- package/src/agents/overlay.test.ts +9 -7
- package/src/agents/overlay.ts +29 -7
- package/src/commands/agents.test.ts +1 -5
- package/src/commands/clean.test.ts +2 -5
- package/src/commands/clean.ts +25 -1
- package/src/commands/completions.test.ts +1 -1
- package/src/commands/completions.ts +26 -7
- package/src/commands/coordinator.test.ts +87 -82
- package/src/commands/coordinator.ts +94 -48
- package/src/commands/costs.test.ts +2 -6
- package/src/commands/dashboard.test.ts +2 -5
- package/src/commands/doctor.test.ts +2 -6
- package/src/commands/down.ts +3 -3
- package/src/commands/errors.test.ts +2 -6
- package/src/commands/feed.test.ts +2 -6
- package/src/commands/gateway.test.ts +43 -17
- package/src/commands/gateway.ts +101 -11
- package/src/commands/hooks.test.ts +2 -5
- package/src/commands/init.test.ts +4 -13
- package/src/commands/inspect.test.ts +2 -6
- package/src/commands/log.test.ts +2 -6
- package/src/commands/logs.test.ts +2 -9
- package/src/commands/mail.test.ts +76 -215
- package/src/commands/mail.ts +43 -187
- package/src/commands/metrics.test.ts +3 -10
- package/src/commands/nudge.ts +15 -0
- package/src/commands/prime.test.ts +4 -11
- package/src/commands/replay.test.ts +2 -6
- package/src/commands/server.test.ts +1 -5
- package/src/commands/server.ts +1 -1
- package/src/commands/sling.test.ts +6 -1
- package/src/commands/sling.ts +42 -17
- package/src/commands/spec.test.ts +2 -5
- package/src/commands/status.test.ts +2 -4
- package/src/commands/stop.test.ts +2 -5
- package/src/commands/supervisor.ts +6 -6
- package/src/commands/trace.test.ts +2 -6
- package/src/commands/up.test.ts +43 -9
- package/src/commands/up.ts +15 -11
- package/src/commands/watchman.ts +327 -0
- package/src/commands/worktree.test.ts +2 -6
- package/src/config.test.ts +34 -104
- package/src/config.ts +120 -32
- package/src/doctor/agents.test.ts +52 -2
- package/src/doctor/agents.ts +4 -2
- package/src/doctor/config-check.test.ts +7 -2
- package/src/doctor/consistency.test.ts +7 -2
- package/src/doctor/databases.test.ts +6 -2
- package/src/doctor/dependencies.test.ts +18 -13
- package/src/doctor/dependencies.ts +23 -94
- package/src/doctor/logs.test.ts +7 -2
- package/src/doctor/merge-queue.test.ts +6 -2
- package/src/doctor/structure.test.ts +7 -2
- package/src/doctor/version.test.ts +7 -2
- package/src/e2e/init-sling-lifecycle.test.ts +2 -5
- package/src/index.ts +7 -7
- package/src/mail/pending.ts +120 -0
- package/src/mail/store.test.ts +89 -0
- package/src/mail/store.ts +11 -0
- package/src/merge/resolver.test.ts +518 -489
- package/src/server/index.ts +33 -2
- package/src/server/public/app.js +3 -3
- package/src/server/public/components/message-bubble.js +11 -1
- package/src/server/public/components/terminal-panel.js +66 -74
- package/src/server/public/views/chat.js +18 -2
- package/src/server/public/views/costs.js +5 -5
- package/src/server/public/views/dashboard.js +80 -51
- package/src/server/public/views/gateway-chat.js +37 -131
- package/src/server/public/views/inspect.js +16 -4
- package/src/server/public/views/issues.js +16 -12
- package/src/server/routes.test.ts +55 -39
- package/src/server/routes.ts +38 -26
- package/src/test-helpers.ts +6 -3
- package/src/tracker/beads.ts +159 -0
- package/src/tracker/exec.ts +44 -0
- package/src/tracker/factory.test.ts +283 -0
- package/src/tracker/factory.ts +59 -0
- package/src/tracker/seeds.ts +156 -0
- package/src/tracker/types.ts +46 -0
- package/src/types.ts +11 -2
- package/src/{watchdog → watchman}/daemon.test.ts +421 -515
- package/src/watchman/daemon.ts +940 -0
- package/src/worktree/tmux.test.ts +2 -1
- package/src/worktree/tmux.ts +4 -4
- package/templates/hooks.json.tmpl +17 -17
- package/src/beads/client.test.ts +0 -210
- package/src/commands/merge.test.ts +0 -676
- package/src/commands/watch.test.ts +0 -152
- package/src/commands/watch.ts +0 -238
- package/src/test-helpers.test.ts +0 -97
- package/src/watchdog/daemon.ts +0 -533
- package/src/watchdog/health.test.ts +0 -371
- package/src/watchdog/triage.test.ts +0 -162
- package/src/worktree/manager.test.ts +0 -444
- /package/src/{watchdog → watchman}/health.ts +0 -0
- /package/src/{watchdog → watchman}/triage.ts +0 -0
|
@@ -1,371 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "vitest";
|
|
2
|
-
import type { AgentSession } from "../types.ts";
|
|
3
|
-
import { evaluateHealth, isProcessRunning, transitionState } from "./health.ts";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Tests for the ZFC-based health evaluation and state machine.
|
|
7
|
-
*
|
|
8
|
-
* evaluateHealth is a pure function that takes session state + tmux liveness +
|
|
9
|
-
* thresholds and returns a HealthCheck. No mocks needed for the core logic.
|
|
10
|
-
*
|
|
11
|
-
* isProcessRunning uses process.kill(pid, 0) which is safe to test with real PIDs:
|
|
12
|
-
* the current process PID (alive) and a known-dead PID (not alive).
|
|
13
|
-
*
|
|
14
|
-
* Note: evaluateHealth calls isProcessRunning internally. For tests that need
|
|
15
|
-
* to control pid liveness independently of the actual OS process table, we set
|
|
16
|
-
* session.pid to known-alive (current process) or known-dead PIDs.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const THRESHOLDS = { zombieMs: 120_000 };
|
|
20
|
-
|
|
21
|
-
/** PID that is guaranteed to be alive during tests: our own process. */
|
|
22
|
-
const ALIVE_PID = process.pid;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* PID that is very likely dead. PID 2147483647 (max 32-bit signed int) is
|
|
26
|
-
* almost never in use. If by some miracle it is, the test still works because
|
|
27
|
-
* we use it only for the "pid dead" path and the test validates behavior, not
|
|
28
|
-
* the exact PID value.
|
|
29
|
-
*/
|
|
30
|
-
const DEAD_PID = 2147483647;
|
|
31
|
-
|
|
32
|
-
function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
33
|
-
return {
|
|
34
|
-
id: "session-test",
|
|
35
|
-
agentName: "test-agent",
|
|
36
|
-
capability: "builder",
|
|
37
|
-
worktreePath: "/tmp/test",
|
|
38
|
-
branchName: "legio/test-agent/test-task",
|
|
39
|
-
beadId: "test-task",
|
|
40
|
-
tmuxSession: "legio-test-agent",
|
|
41
|
-
state: "booting",
|
|
42
|
-
pid: ALIVE_PID,
|
|
43
|
-
parentAgent: null,
|
|
44
|
-
depth: 0,
|
|
45
|
-
runId: null,
|
|
46
|
-
startedAt: new Date().toISOString(),
|
|
47
|
-
lastActivity: new Date().toISOString(),
|
|
48
|
-
escalationLevel: 0,
|
|
49
|
-
stalledSince: null,
|
|
50
|
-
...overrides,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// === isProcessRunning ===
|
|
55
|
-
|
|
56
|
-
describe("isProcessRunning", () => {
|
|
57
|
-
test("returns true for the current process PID", () => {
|
|
58
|
-
expect(isProcessRunning(process.pid)).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("returns false for a PID that does not exist", () => {
|
|
62
|
-
// PID 2147483647 is max 32-bit signed — extremely unlikely to be alive
|
|
63
|
-
expect(isProcessRunning(DEAD_PID)).toBe(false);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// === evaluateHealth ===
|
|
68
|
-
|
|
69
|
-
describe("evaluateHealth", () => {
|
|
70
|
-
// --- ZFC Rule 1: tmux dead → zombie (observable state wins) ---
|
|
71
|
-
|
|
72
|
-
test("ZFC: tmux dead + sessions.json says working → zombie with reconciliation note", () => {
|
|
73
|
-
const session = makeSession({ state: "working" });
|
|
74
|
-
const check = evaluateHealth(session, false, THRESHOLDS);
|
|
75
|
-
|
|
76
|
-
expect(check.state).toBe("zombie");
|
|
77
|
-
expect(check.action).toBe("terminate");
|
|
78
|
-
expect(check.tmuxAlive).toBe(false);
|
|
79
|
-
expect(check.processAlive).toBe(false);
|
|
80
|
-
expect(check.reconciliationNote).toContain("ZFC");
|
|
81
|
-
expect(check.reconciliationNote).toContain("tmux dead");
|
|
82
|
-
expect(check.reconciliationNote).toContain('"working"');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("ZFC: tmux dead + sessions.json says booting → zombie with reconciliation note", () => {
|
|
86
|
-
const session = makeSession({ state: "booting" });
|
|
87
|
-
const check = evaluateHealth(session, false, THRESHOLDS);
|
|
88
|
-
|
|
89
|
-
expect(check.state).toBe("zombie");
|
|
90
|
-
expect(check.action).toBe("terminate");
|
|
91
|
-
expect(check.reconciliationNote).toContain("ZFC");
|
|
92
|
-
expect(check.reconciliationNote).toContain('"booting"');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// --- ZFC Rule 2: tmux alive + sessions.json says zombie → investigate ---
|
|
96
|
-
|
|
97
|
-
test("ZFC: tmux alive + sessions.json says zombie → investigate (don't auto-kill)", () => {
|
|
98
|
-
const session = makeSession({ state: "zombie", pid: ALIVE_PID });
|
|
99
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
100
|
-
|
|
101
|
-
expect(check.state).toBe("zombie");
|
|
102
|
-
expect(check.action).toBe("investigate");
|
|
103
|
-
expect(check.processAlive).toBe(true);
|
|
104
|
-
expect(check.reconciliationNote).toContain("ZFC");
|
|
105
|
-
expect(check.reconciliationNote).toContain("investigation needed");
|
|
106
|
-
expect(check.reconciliationNote).toContain("don't auto-kill");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
// --- ZFC Rule 3: pid dead + tmux alive → zombie ---
|
|
110
|
-
|
|
111
|
-
test("ZFC: pid dead + tmux alive → zombie (agent process exited, shell survived)", () => {
|
|
112
|
-
const session = makeSession({ state: "working", pid: DEAD_PID });
|
|
113
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
114
|
-
|
|
115
|
-
expect(check.state).toBe("zombie");
|
|
116
|
-
expect(check.action).toBe("terminate");
|
|
117
|
-
expect(check.processAlive).toBe(false);
|
|
118
|
-
expect(check.pidAlive).toBe(false);
|
|
119
|
-
expect(check.tmuxAlive).toBe(true);
|
|
120
|
-
expect(check.reconciliationNote).toContain("ZFC");
|
|
121
|
-
expect(check.reconciliationNote).toContain("pid");
|
|
122
|
-
expect(check.reconciliationNote).toContain("shell survived");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// --- pid null (unavailable) ---
|
|
126
|
-
|
|
127
|
-
test("pid null does not trigger pid-based zombie detection", () => {
|
|
128
|
-
const session = makeSession({ state: "working", pid: null });
|
|
129
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
130
|
-
|
|
131
|
-
expect(check.state).toBe("working");
|
|
132
|
-
expect(check.action).toBe("none");
|
|
133
|
-
expect(check.pidAlive).toBeNull();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
// --- Time-based checks (both tmux and pid alive) ---
|
|
137
|
-
|
|
138
|
-
test("activity older than zombieMs → zombie", () => {
|
|
139
|
-
const oldActivity = new Date(Date.now() - 200_000).toISOString();
|
|
140
|
-
const session = makeSession({ state: "working", lastActivity: oldActivity });
|
|
141
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
142
|
-
|
|
143
|
-
expect(check.state).toBe("zombie");
|
|
144
|
-
expect(check.action).toBe("terminate");
|
|
145
|
-
expect(check.reconciliationNote).toBeNull();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// --- Normal state transitions ---
|
|
149
|
-
|
|
150
|
-
test("booting with recent activity → transitions to working", () => {
|
|
151
|
-
const recentActivity = new Date(Date.now() - 5_000).toISOString();
|
|
152
|
-
const session = makeSession({ state: "booting", lastActivity: recentActivity });
|
|
153
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
154
|
-
|
|
155
|
-
expect(check.state).toBe("working");
|
|
156
|
-
expect(check.action).toBe("none");
|
|
157
|
-
expect(check.reconciliationNote).toBeNull();
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("working with recent activity → stays working", () => {
|
|
161
|
-
const recentActivity = new Date(Date.now() - 5_000).toISOString();
|
|
162
|
-
const session = makeSession({ state: "working", lastActivity: recentActivity });
|
|
163
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
164
|
-
|
|
165
|
-
expect(check.state).toBe("working");
|
|
166
|
-
expect(check.action).toBe("none");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// --- Persistent capabilities (coordinator, monitor) ---
|
|
170
|
-
|
|
171
|
-
test("persistent capability: coordinator with stale activity → still working, no escalation", () => {
|
|
172
|
-
const staleActivity = new Date(Date.now() - 60_000).toISOString();
|
|
173
|
-
const session = makeSession({
|
|
174
|
-
capability: "coordinator",
|
|
175
|
-
state: "working",
|
|
176
|
-
lastActivity: staleActivity,
|
|
177
|
-
});
|
|
178
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
179
|
-
|
|
180
|
-
expect(check.state).toBe("working");
|
|
181
|
-
expect(check.action).toBe("none");
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("persistent capability: coordinator with zombie-level staleness → still working", () => {
|
|
185
|
-
const oldActivity = new Date(Date.now() - 200_000).toISOString();
|
|
186
|
-
const session = makeSession({
|
|
187
|
-
capability: "coordinator",
|
|
188
|
-
state: "working",
|
|
189
|
-
lastActivity: oldActivity,
|
|
190
|
-
});
|
|
191
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
192
|
-
|
|
193
|
-
expect(check.state).toBe("working");
|
|
194
|
-
expect(check.action).toBe("none");
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
test("persistent capability: monitor with stale activity → still working", () => {
|
|
198
|
-
const staleActivity = new Date(Date.now() - 60_000).toISOString();
|
|
199
|
-
const session = makeSession({
|
|
200
|
-
capability: "monitor",
|
|
201
|
-
state: "working",
|
|
202
|
-
lastActivity: staleActivity,
|
|
203
|
-
});
|
|
204
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
205
|
-
|
|
206
|
-
expect(check.state).toBe("working");
|
|
207
|
-
expect(check.action).toBe("none");
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test("persistent capability: gateway with stale activity → still working", () => {
|
|
211
|
-
const staleActivity = new Date(Date.now() - 60_000).toISOString();
|
|
212
|
-
const session = makeSession({
|
|
213
|
-
capability: "gateway",
|
|
214
|
-
state: "working",
|
|
215
|
-
lastActivity: staleActivity,
|
|
216
|
-
});
|
|
217
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
218
|
-
|
|
219
|
-
expect(check.state).toBe("working");
|
|
220
|
-
expect(check.action).toBe("none");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test("persistent capability: coordinator booting → transitions to working", () => {
|
|
224
|
-
const session = makeSession({
|
|
225
|
-
capability: "coordinator",
|
|
226
|
-
state: "booting",
|
|
227
|
-
});
|
|
228
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
229
|
-
|
|
230
|
-
expect(check.state).toBe("working");
|
|
231
|
-
expect(check.action).toBe("none");
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test("persistent capability: coordinator with tmux dead → still zombie (ZFC Rule 1 applies)", () => {
|
|
235
|
-
const session = makeSession({
|
|
236
|
-
capability: "coordinator",
|
|
237
|
-
state: "working",
|
|
238
|
-
});
|
|
239
|
-
const check = evaluateHealth(session, false, THRESHOLDS);
|
|
240
|
-
|
|
241
|
-
expect(check.state).toBe("zombie");
|
|
242
|
-
expect(check.action).toBe("terminate");
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("persistent capability: coordinator with pid dead → still zombie (ZFC Rule 3 applies)", () => {
|
|
246
|
-
const session = makeSession({
|
|
247
|
-
capability: "coordinator",
|
|
248
|
-
state: "working",
|
|
249
|
-
pid: DEAD_PID,
|
|
250
|
-
});
|
|
251
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
252
|
-
|
|
253
|
-
expect(check.state).toBe("zombie");
|
|
254
|
-
expect(check.action).toBe("terminate");
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// --- Completed agents ---
|
|
258
|
-
|
|
259
|
-
test("completed agents skip monitoring", () => {
|
|
260
|
-
const session = makeSession({ state: "completed" });
|
|
261
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
262
|
-
|
|
263
|
-
expect(check.state).toBe("completed");
|
|
264
|
-
expect(check.action).toBe("none");
|
|
265
|
-
expect(check.reconciliationNote).toBeNull();
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
// --- pidAlive field is populated ---
|
|
269
|
-
|
|
270
|
-
test("pidAlive reflects actual process state for alive PID", () => {
|
|
271
|
-
const session = makeSession({ pid: ALIVE_PID, state: "working" });
|
|
272
|
-
const check = evaluateHealth(session, true, THRESHOLDS);
|
|
273
|
-
|
|
274
|
-
expect(check.pidAlive).toBe(true);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test("pidAlive reflects actual process state for dead PID", () => {
|
|
278
|
-
// Use dead pid but also tmux dead to avoid pid-zombie path intercepting
|
|
279
|
-
const session = makeSession({ pid: DEAD_PID, state: "working" });
|
|
280
|
-
const check = evaluateHealth(session, false, THRESHOLDS);
|
|
281
|
-
|
|
282
|
-
// tmux dead takes priority, so state is zombie via ZFC Rule 1
|
|
283
|
-
expect(check.state).toBe("zombie");
|
|
284
|
-
expect(check.pidAlive).toBe(false);
|
|
285
|
-
});
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// === transitionState ===
|
|
289
|
-
|
|
290
|
-
describe("transitionState", () => {
|
|
291
|
-
test("advances from booting to working", () => {
|
|
292
|
-
const check = {
|
|
293
|
-
state: "working" as const,
|
|
294
|
-
agentName: "a",
|
|
295
|
-
timestamp: "",
|
|
296
|
-
tmuxAlive: true,
|
|
297
|
-
pidAlive: true as boolean | null,
|
|
298
|
-
lastActivity: "",
|
|
299
|
-
processAlive: true,
|
|
300
|
-
action: "none" as const,
|
|
301
|
-
reconciliationNote: null,
|
|
302
|
-
};
|
|
303
|
-
expect(transitionState("booting", check)).toBe("working");
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
test("never regresses from zombie to booting", () => {
|
|
307
|
-
const check = {
|
|
308
|
-
state: "booting" as const,
|
|
309
|
-
agentName: "a",
|
|
310
|
-
timestamp: "",
|
|
311
|
-
tmuxAlive: true,
|
|
312
|
-
pidAlive: true as boolean | null,
|
|
313
|
-
lastActivity: "",
|
|
314
|
-
processAlive: true,
|
|
315
|
-
action: "none" as const,
|
|
316
|
-
reconciliationNote: null,
|
|
317
|
-
};
|
|
318
|
-
expect(transitionState("zombie", check)).toBe("zombie");
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
test("same state stays the same", () => {
|
|
322
|
-
const check = {
|
|
323
|
-
state: "working" as const,
|
|
324
|
-
agentName: "a",
|
|
325
|
-
timestamp: "",
|
|
326
|
-
tmuxAlive: true,
|
|
327
|
-
pidAlive: true as boolean | null,
|
|
328
|
-
lastActivity: "",
|
|
329
|
-
processAlive: true,
|
|
330
|
-
action: "none" as const,
|
|
331
|
-
reconciliationNote: null,
|
|
332
|
-
};
|
|
333
|
-
expect(transitionState("working", check)).toBe("working");
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
// --- ZFC: investigate holds state ---
|
|
337
|
-
|
|
338
|
-
test("ZFC: investigate action holds current state (does not advance)", () => {
|
|
339
|
-
const check = {
|
|
340
|
-
state: "zombie" as const,
|
|
341
|
-
agentName: "a",
|
|
342
|
-
timestamp: "",
|
|
343
|
-
tmuxAlive: true,
|
|
344
|
-
pidAlive: true as boolean | null,
|
|
345
|
-
lastActivity: "",
|
|
346
|
-
processAlive: true,
|
|
347
|
-
action: "investigate" as const,
|
|
348
|
-
reconciliationNote: "ZFC: tmux alive but sessions.json says zombie",
|
|
349
|
-
};
|
|
350
|
-
// Even though check.state is zombie (order 4) and current is zombie (order 4),
|
|
351
|
-
// investigate should hold — not advance
|
|
352
|
-
expect(transitionState("zombie", check)).toBe("zombie");
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
test("ZFC: investigate prevents forward transition", () => {
|
|
356
|
-
const check = {
|
|
357
|
-
state: "zombie" as const,
|
|
358
|
-
agentName: "a",
|
|
359
|
-
timestamp: "",
|
|
360
|
-
tmuxAlive: true,
|
|
361
|
-
pidAlive: true as boolean | null,
|
|
362
|
-
lastActivity: "",
|
|
363
|
-
processAlive: true,
|
|
364
|
-
action: "investigate" as const,
|
|
365
|
-
reconciliationNote: "ZFC conflict",
|
|
366
|
-
};
|
|
367
|
-
// If something were at "working" and check says zombie with investigate,
|
|
368
|
-
// the state should NOT advance
|
|
369
|
-
expect(transitionState("working", check)).toBe("working");
|
|
370
|
-
});
|
|
371
|
-
});
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for Tier 1 AI-assisted triage.
|
|
3
|
-
* classifyResponse and buildTriagePrompt are pure functions — tested directly.
|
|
4
|
-
* triageAgent uses real filesystem (temp dirs). Claude spawn is expected to
|
|
5
|
-
* fail in test environments, exercising the fallback-to-extend path.
|
|
6
|
-
* spawnClaude is NOT mocked — we rely on it failing naturally in tests.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
-
import { buildTriagePrompt, classifyResponse, triageAgent } from "./triage.ts";
|
|
14
|
-
|
|
15
|
-
describe("classifyResponse", () => {
|
|
16
|
-
test("returns 'retry' when response contains 'retry'", () => {
|
|
17
|
-
const result = classifyResponse("The operation should retry.");
|
|
18
|
-
expect(result).toBe("retry");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("returns 'retry' when response contains 'recoverable'", () => {
|
|
22
|
-
const result = classifyResponse("This error is recoverable.");
|
|
23
|
-
expect(result).toBe("retry");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("returns 'terminate' when response contains 'terminate'", () => {
|
|
27
|
-
const result = classifyResponse("You should terminate the agent.");
|
|
28
|
-
expect(result).toBe("terminate");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("returns 'terminate' when response contains 'fatal'", () => {
|
|
32
|
-
const result = classifyResponse("This is a fatal error.");
|
|
33
|
-
expect(result).toBe("terminate");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("returns 'terminate' when response contains 'failed'", () => {
|
|
37
|
-
const result = classifyResponse("The operation has failed.");
|
|
38
|
-
expect(result).toBe("terminate");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("handles mixed case (e.g., 'RETRY', 'Fatal')", () => {
|
|
42
|
-
expect(classifyResponse("RETRY this operation")).toBe("retry");
|
|
43
|
-
expect(classifyResponse("Fatal error occurred")).toBe("terminate");
|
|
44
|
-
expect(classifyResponse("RecOverAble issue")).toBe("retry");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("returns 'extend' when response contains none of the keywords", () => {
|
|
48
|
-
const result = classifyResponse("The agent is processing data.");
|
|
49
|
-
expect(result).toBe("extend");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("returns 'extend' for empty string", () => {
|
|
53
|
-
const result = classifyResponse("");
|
|
54
|
-
expect(result).toBe("extend");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("first match wins when response has multiple keywords", () => {
|
|
58
|
-
// 'retry' is checked before 'terminate'
|
|
59
|
-
const result = classifyResponse("retry this but it may terminate later");
|
|
60
|
-
expect(result).toBe("retry");
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe("buildTriagePrompt", () => {
|
|
65
|
-
test("contains agent name in output", () => {
|
|
66
|
-
const prompt = buildTriagePrompt("test-agent", "2026-02-13T10:00:00Z", "log content");
|
|
67
|
-
expect(prompt).toContain("test-agent");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("contains lastActivity timestamp in output", () => {
|
|
71
|
-
const timestamp = "2026-02-13T10:00:00Z";
|
|
72
|
-
const prompt = buildTriagePrompt("test-agent", timestamp, "log content");
|
|
73
|
-
expect(prompt).toContain(timestamp);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("contains log content wrapped in code fences", () => {
|
|
77
|
-
const logContent = "Error: something went wrong\nat line 42";
|
|
78
|
-
const prompt = buildTriagePrompt("test-agent", "2026-02-13T10:00:00Z", logContent);
|
|
79
|
-
expect(prompt).toContain("```");
|
|
80
|
-
expect(prompt).toContain(logContent);
|
|
81
|
-
expect(prompt.split("```").length).toBeGreaterThanOrEqual(3); // Opening and closing fences
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("contains classification instructions (retry/terminate/extend)", () => {
|
|
85
|
-
const prompt = buildTriagePrompt("test-agent", "2026-02-13T10:00:00Z", "log content");
|
|
86
|
-
expect(prompt).toContain("retry");
|
|
87
|
-
expect(prompt).toContain("terminate");
|
|
88
|
-
expect(prompt).toContain("extend");
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe("triageAgent", () => {
|
|
93
|
-
let tempRoot: string;
|
|
94
|
-
|
|
95
|
-
beforeEach(async () => {
|
|
96
|
-
tempRoot = await mkdtemp(join(tmpdir(), "triage-test-"));
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
afterEach(async () => {
|
|
100
|
-
await rm(tempRoot, { recursive: true, force: true });
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("returns 'extend' when no logs directory exists", async () => {
|
|
104
|
-
const result = await triageAgent({
|
|
105
|
-
agentName: "test-agent",
|
|
106
|
-
root: tempRoot,
|
|
107
|
-
lastActivity: "2026-02-13T10:00:00Z",
|
|
108
|
-
});
|
|
109
|
-
expect(result).toBe("extend");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("returns 'extend' when logs directory exists but is empty", async () => {
|
|
113
|
-
const logsDir = join(tempRoot, ".legio", "logs", "test-agent");
|
|
114
|
-
await mkdir(logsDir, { recursive: true });
|
|
115
|
-
|
|
116
|
-
const result = await triageAgent({
|
|
117
|
-
agentName: "test-agent",
|
|
118
|
-
root: tempRoot,
|
|
119
|
-
lastActivity: "2026-02-13T10:00:00Z",
|
|
120
|
-
});
|
|
121
|
-
expect(result).toBe("extend");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test("returns 'extend' when logs directory has session dir but no session.log", async () => {
|
|
125
|
-
const logsDir = join(tempRoot, ".legio", "logs", "test-agent", "2026-02-13T10-00-00");
|
|
126
|
-
await mkdir(logsDir, { recursive: true });
|
|
127
|
-
await writeFile(join(logsDir, ".gitkeep"), "", "utf-8");
|
|
128
|
-
|
|
129
|
-
const result = await triageAgent({
|
|
130
|
-
agentName: "test-agent",
|
|
131
|
-
root: tempRoot,
|
|
132
|
-
lastActivity: "2026-02-13T10:00:00Z",
|
|
133
|
-
});
|
|
134
|
-
expect(result).toBe("extend");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("returns 'extend' when session.log exists but claude binary fails", async () => {
|
|
138
|
-
const timestamp = "2026-02-13T10-00-00";
|
|
139
|
-
const sessionLogDir = join(tempRoot, ".legio", "logs", "test-agent", timestamp);
|
|
140
|
-
const sessionLogPath = join(sessionLogDir, "session.log");
|
|
141
|
-
|
|
142
|
-
await mkdir(sessionLogDir, { recursive: true });
|
|
143
|
-
|
|
144
|
-
// Create session.log with some content
|
|
145
|
-
await writeFile(
|
|
146
|
-
sessionLogPath,
|
|
147
|
-
"Agent started\nProcessing data\nError: something went wrong\n",
|
|
148
|
-
"utf-8",
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
// triageAgent will try to spawn claude which should fail or be killed by timeout.
|
|
152
|
-
// Short timeout ensures the test doesn't hang even if the claude binary
|
|
153
|
-
// exists on the system (e.g., inside a Claude Code session).
|
|
154
|
-
const result = await triageAgent({
|
|
155
|
-
agentName: "test-agent",
|
|
156
|
-
root: tempRoot,
|
|
157
|
-
lastActivity: "2026-02-13T10:00:00Z",
|
|
158
|
-
timeoutMs: 500,
|
|
159
|
-
});
|
|
160
|
-
expect(result).toBe("extend");
|
|
161
|
-
});
|
|
162
|
-
});
|