@katyella/legio 0.1.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 +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the watchdog daemon tick loop.
|
|
3
|
+
*
|
|
4
|
+
* Uses real filesystem (temp directories via mkdtemp) and real SessionStore
|
|
5
|
+
* (better-sqlite3) for session persistence, plus real health evaluation logic.
|
|
6
|
+
*
|
|
7
|
+
* Only tmux operations (isSessionAlive, killSession) are mocked via dependency
|
|
8
|
+
* injection (_tmux params) because real tmux interferes with developer sessions
|
|
9
|
+
* and is fragile in CI.
|
|
10
|
+
*
|
|
11
|
+
* Does NOT use mock.module() — it leaks across test files. See mulch record
|
|
12
|
+
* mx-56558b for background.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
19
|
+
import { createEventStore } from "../events/store.ts";
|
|
20
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
21
|
+
import type { AgentSession, HealthCheck, SessionCheckpoint, StoredEvent } from "../types.ts";
|
|
22
|
+
import { runDaemonTick } from "./daemon.ts";
|
|
23
|
+
|
|
24
|
+
// === Test constants ===
|
|
25
|
+
|
|
26
|
+
const THRESHOLDS = {
|
|
27
|
+
zombieThresholdMs: 120_000,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// === Helpers ===
|
|
31
|
+
|
|
32
|
+
/** Create a temp directory with .legio/ subdirectory, ready for sessions.db. */
|
|
33
|
+
async function createTempRoot(): Promise<string> {
|
|
34
|
+
const dir = await mkdtemp(join(tmpdir(), "legio-daemon-test-"));
|
|
35
|
+
await mkdir(join(dir, ".legio"), { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Write sessions to the SessionStore (sessions.db) at the given root. */
|
|
40
|
+
function writeSessionsToStore(root: string, sessions: AgentSession[]): void {
|
|
41
|
+
const dbPath = join(root, ".legio", "sessions.db");
|
|
42
|
+
const store = createSessionStore(dbPath);
|
|
43
|
+
for (const session of sessions) {
|
|
44
|
+
store.upsert(session);
|
|
45
|
+
}
|
|
46
|
+
store.close();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read sessions from the SessionStore (sessions.db) at the given root. */
|
|
50
|
+
function readSessionsFromStore(root: string): AgentSession[] {
|
|
51
|
+
const dbPath = join(root, ".legio", "sessions.db");
|
|
52
|
+
const store = createSessionStore(dbPath);
|
|
53
|
+
const sessions = store.getAll();
|
|
54
|
+
store.close();
|
|
55
|
+
return sessions;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build a test AgentSession with sensible defaults. */
|
|
59
|
+
function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
60
|
+
return {
|
|
61
|
+
id: "session-test",
|
|
62
|
+
agentName: "test-agent",
|
|
63
|
+
capability: "builder",
|
|
64
|
+
worktreePath: "/tmp/test",
|
|
65
|
+
branchName: "legio/test-agent/test-task",
|
|
66
|
+
beadId: "test-task",
|
|
67
|
+
tmuxSession: "legio-test-agent",
|
|
68
|
+
state: "working",
|
|
69
|
+
pid: process.pid, // Use our own PID so isProcessRunning returns true
|
|
70
|
+
parentAgent: null,
|
|
71
|
+
depth: 0,
|
|
72
|
+
runId: null,
|
|
73
|
+
escalationLevel: 0,
|
|
74
|
+
stalledSince: null,
|
|
75
|
+
startedAt: new Date().toISOString(),
|
|
76
|
+
lastActivity: new Date().toISOString(),
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Create a fake _tmux dependency where all sessions are alive. */
|
|
82
|
+
function tmuxAllAlive(): {
|
|
83
|
+
isSessionAlive: (name: string) => Promise<boolean>;
|
|
84
|
+
killSession: (name: string) => Promise<void>;
|
|
85
|
+
} {
|
|
86
|
+
return {
|
|
87
|
+
isSessionAlive: async () => true,
|
|
88
|
+
killSession: async () => {},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Create a fake _tmux dependency where all sessions are dead. */
|
|
93
|
+
function tmuxAllDead(): {
|
|
94
|
+
isSessionAlive: (name: string) => Promise<boolean>;
|
|
95
|
+
killSession: (name: string) => Promise<void>;
|
|
96
|
+
} {
|
|
97
|
+
return {
|
|
98
|
+
isSessionAlive: async () => false,
|
|
99
|
+
killSession: async () => {},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create a fake _tmux dependency with per-session liveness control.
|
|
105
|
+
* Also tracks killSession calls for assertions.
|
|
106
|
+
*/
|
|
107
|
+
function tmuxWithLiveness(aliveMap: Record<string, boolean>): {
|
|
108
|
+
isSessionAlive: (name: string) => Promise<boolean>;
|
|
109
|
+
killSession: (name: string) => Promise<void>;
|
|
110
|
+
killed: string[];
|
|
111
|
+
} {
|
|
112
|
+
const killed: string[] = [];
|
|
113
|
+
return {
|
|
114
|
+
isSessionAlive: async (name: string) => aliveMap[name] ?? false,
|
|
115
|
+
killSession: async (name: string) => {
|
|
116
|
+
killed.push(name);
|
|
117
|
+
},
|
|
118
|
+
killed,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// === Tests ===
|
|
123
|
+
|
|
124
|
+
let tempRoot: string;
|
|
125
|
+
|
|
126
|
+
beforeEach(async () => {
|
|
127
|
+
tempRoot = await createTempRoot();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterEach(async () => {
|
|
131
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("daemon tick", () => {
|
|
135
|
+
// --- Test 1: tick with no sessions file ---
|
|
136
|
+
|
|
137
|
+
test("tick with no sessions is a graceful no-op", async () => {
|
|
138
|
+
// No sessions in the store — daemon should not crash
|
|
139
|
+
const checks: HealthCheck[] = [];
|
|
140
|
+
|
|
141
|
+
await runDaemonTick({
|
|
142
|
+
root: tempRoot,
|
|
143
|
+
...THRESHOLDS,
|
|
144
|
+
onHealthCheck: (c) => checks.push(c),
|
|
145
|
+
_tmux: tmuxAllAlive(),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// No health checks should have been produced (no sessions to check)
|
|
149
|
+
expect(checks).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// --- Test 2: tick with healthy sessions ---
|
|
153
|
+
|
|
154
|
+
test("tick with healthy sessions produces no state changes", async () => {
|
|
155
|
+
const session = makeSession({
|
|
156
|
+
state: "working",
|
|
157
|
+
lastActivity: new Date().toISOString(),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
161
|
+
|
|
162
|
+
const checks: HealthCheck[] = [];
|
|
163
|
+
|
|
164
|
+
await runDaemonTick({
|
|
165
|
+
root: tempRoot,
|
|
166
|
+
...THRESHOLDS,
|
|
167
|
+
onHealthCheck: (c) => checks.push(c),
|
|
168
|
+
_tmux: tmuxAllAlive(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(checks).toHaveLength(1);
|
|
172
|
+
const check = checks[0];
|
|
173
|
+
expect(check).toBeDefined();
|
|
174
|
+
expect(check?.state).toBe("working");
|
|
175
|
+
expect(check?.action).toBe("none");
|
|
176
|
+
|
|
177
|
+
// Session state should be unchanged because state didn't change.
|
|
178
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
179
|
+
expect(reloaded).toHaveLength(1);
|
|
180
|
+
expect(reloaded[0]?.state).toBe("working");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// --- Test 3: tick with dead tmux -> zombie transition ---
|
|
184
|
+
|
|
185
|
+
test("tick with dead tmux transitions session to zombie and fires terminate", async () => {
|
|
186
|
+
const session = makeSession({
|
|
187
|
+
agentName: "dead-agent",
|
|
188
|
+
tmuxSession: "legio-dead-agent",
|
|
189
|
+
state: "working",
|
|
190
|
+
lastActivity: new Date().toISOString(),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
194
|
+
|
|
195
|
+
const tmuxMock = tmuxWithLiveness({ "legio-dead-agent": false });
|
|
196
|
+
const checks: HealthCheck[] = [];
|
|
197
|
+
|
|
198
|
+
await runDaemonTick({
|
|
199
|
+
root: tempRoot,
|
|
200
|
+
...THRESHOLDS,
|
|
201
|
+
onHealthCheck: (c) => checks.push(c),
|
|
202
|
+
_tmux: tmuxMock,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Health check should detect zombie with terminate action
|
|
206
|
+
expect(checks).toHaveLength(1);
|
|
207
|
+
expect(checks[0]?.state).toBe("zombie");
|
|
208
|
+
expect(checks[0]?.action).toBe("terminate");
|
|
209
|
+
|
|
210
|
+
// tmux is dead so killSession should NOT be called (only kills if tmuxAlive)
|
|
211
|
+
expect(tmuxMock.killed).toHaveLength(0);
|
|
212
|
+
|
|
213
|
+
// Session state should be persisted as zombie
|
|
214
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
215
|
+
expect(reloaded).toHaveLength(1);
|
|
216
|
+
expect(reloaded[0]?.state).toBe("zombie");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("tick with alive tmux but zombie-old activity calls killSession", async () => {
|
|
220
|
+
// tmux IS alive but time-based zombie threshold is exceeded,
|
|
221
|
+
// causing a terminate action — killSession SHOULD be called.
|
|
222
|
+
const oldActivity = new Date(Date.now() - 200_000).toISOString();
|
|
223
|
+
const session = makeSession({
|
|
224
|
+
agentName: "zombie-agent",
|
|
225
|
+
tmuxSession: "legio-zombie-agent",
|
|
226
|
+
state: "working",
|
|
227
|
+
lastActivity: oldActivity,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
231
|
+
|
|
232
|
+
const tmuxMock = tmuxWithLiveness({ "legio-zombie-agent": true });
|
|
233
|
+
const checks: HealthCheck[] = [];
|
|
234
|
+
|
|
235
|
+
await runDaemonTick({
|
|
236
|
+
root: tempRoot,
|
|
237
|
+
...THRESHOLDS,
|
|
238
|
+
onHealthCheck: (c) => checks.push(c),
|
|
239
|
+
_tmux: tmuxMock,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(checks).toHaveLength(1);
|
|
243
|
+
expect(checks[0]?.action).toBe("terminate");
|
|
244
|
+
|
|
245
|
+
// tmux was alive, so killSession SHOULD have been called
|
|
246
|
+
expect(tmuxMock.killed).toContain("legio-zombie-agent");
|
|
247
|
+
|
|
248
|
+
// Session persisted as zombie
|
|
249
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
250
|
+
expect(reloaded[0]?.state).toBe("zombie");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// --- Test 4: session persistence round-trip ---
|
|
254
|
+
|
|
255
|
+
test("session persistence round-trip: load, modify, save, reload", async () => {
|
|
256
|
+
const sessions: AgentSession[] = [
|
|
257
|
+
makeSession({
|
|
258
|
+
id: "session-1",
|
|
259
|
+
agentName: "agent-alpha",
|
|
260
|
+
tmuxSession: "legio-agent-alpha",
|
|
261
|
+
state: "working",
|
|
262
|
+
lastActivity: new Date().toISOString(),
|
|
263
|
+
}),
|
|
264
|
+
makeSession({
|
|
265
|
+
id: "session-2",
|
|
266
|
+
agentName: "agent-beta",
|
|
267
|
+
tmuxSession: "legio-agent-beta",
|
|
268
|
+
state: "working",
|
|
269
|
+
// Make beta's tmux dead so it transitions to zombie
|
|
270
|
+
lastActivity: new Date().toISOString(),
|
|
271
|
+
}),
|
|
272
|
+
makeSession({
|
|
273
|
+
id: "session-3",
|
|
274
|
+
agentName: "agent-gamma",
|
|
275
|
+
tmuxSession: "legio-agent-gamma",
|
|
276
|
+
state: "completed",
|
|
277
|
+
lastActivity: new Date().toISOString(),
|
|
278
|
+
}),
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
writeSessionsToStore(tempRoot, sessions);
|
|
282
|
+
|
|
283
|
+
const tmuxMock = tmuxWithLiveness({
|
|
284
|
+
"legio-agent-alpha": true,
|
|
285
|
+
"legio-agent-beta": false, // Dead — should become zombie
|
|
286
|
+
"legio-agent-gamma": true, // Doesn't matter — completed is skipped
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const checks: HealthCheck[] = [];
|
|
290
|
+
|
|
291
|
+
await runDaemonTick({
|
|
292
|
+
root: tempRoot,
|
|
293
|
+
...THRESHOLDS,
|
|
294
|
+
onHealthCheck: (c) => checks.push(c),
|
|
295
|
+
_tmux: tmuxMock,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Completed sessions are skipped — only 2 health checks
|
|
299
|
+
expect(checks).toHaveLength(2);
|
|
300
|
+
|
|
301
|
+
// Reload and verify persistence
|
|
302
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
303
|
+
expect(reloaded).toHaveLength(3);
|
|
304
|
+
|
|
305
|
+
const alpha = reloaded.find((s) => s.agentName === "agent-alpha");
|
|
306
|
+
const beta = reloaded.find((s) => s.agentName === "agent-beta");
|
|
307
|
+
const gamma = reloaded.find((s) => s.agentName === "agent-gamma");
|
|
308
|
+
|
|
309
|
+
expect(alpha).toBeDefined();
|
|
310
|
+
expect(beta).toBeDefined();
|
|
311
|
+
expect(gamma).toBeDefined();
|
|
312
|
+
|
|
313
|
+
// Alpha: tmux alive + recent activity — stays working
|
|
314
|
+
expect(alpha?.state).toBe("working");
|
|
315
|
+
|
|
316
|
+
// Beta: tmux dead — zombie (ZFC rule 1)
|
|
317
|
+
expect(beta?.state).toBe("zombie");
|
|
318
|
+
|
|
319
|
+
// Gamma: completed — unchanged (skipped by daemon)
|
|
320
|
+
expect(gamma?.state).toBe("completed");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("session persistence: state unchanged when nothing changes", async () => {
|
|
324
|
+
const session = makeSession({
|
|
325
|
+
state: "working",
|
|
326
|
+
lastActivity: new Date().toISOString(),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
330
|
+
|
|
331
|
+
await runDaemonTick({
|
|
332
|
+
root: tempRoot,
|
|
333
|
+
...THRESHOLDS,
|
|
334
|
+
_tmux: tmuxAllAlive(),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Session state should remain unchanged since nothing triggered a transition
|
|
338
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
339
|
+
expect(reloaded).toHaveLength(1);
|
|
340
|
+
expect(reloaded[0]?.state).toBe("working");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// --- Edge cases ---
|
|
344
|
+
|
|
345
|
+
test("completed sessions are skipped entirely", async () => {
|
|
346
|
+
const session = makeSession({ state: "completed" });
|
|
347
|
+
|
|
348
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
349
|
+
|
|
350
|
+
const checks: HealthCheck[] = [];
|
|
351
|
+
|
|
352
|
+
await runDaemonTick({
|
|
353
|
+
root: tempRoot,
|
|
354
|
+
...THRESHOLDS,
|
|
355
|
+
onHealthCheck: (c) => checks.push(c),
|
|
356
|
+
_tmux: tmuxAllDead(), // Would be zombie if not skipped
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// No health checks emitted for completed sessions
|
|
360
|
+
expect(checks).toHaveLength(0);
|
|
361
|
+
|
|
362
|
+
// State unchanged
|
|
363
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
364
|
+
expect(reloaded[0]?.state).toBe("completed");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("multiple sessions with mixed states are all processed", async () => {
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
const sessions: AgentSession[] = [
|
|
370
|
+
makeSession({
|
|
371
|
+
id: "s1",
|
|
372
|
+
agentName: "healthy",
|
|
373
|
+
tmuxSession: "legio-healthy",
|
|
374
|
+
state: "working",
|
|
375
|
+
lastActivity: new Date(now).toISOString(),
|
|
376
|
+
}),
|
|
377
|
+
makeSession({
|
|
378
|
+
id: "s2",
|
|
379
|
+
agentName: "dying",
|
|
380
|
+
tmuxSession: "legio-dying",
|
|
381
|
+
state: "working",
|
|
382
|
+
lastActivity: new Date(now).toISOString(),
|
|
383
|
+
}),
|
|
384
|
+
makeSession({
|
|
385
|
+
id: "s3",
|
|
386
|
+
agentName: "stale",
|
|
387
|
+
tmuxSession: "legio-stale",
|
|
388
|
+
state: "working",
|
|
389
|
+
lastActivity: new Date(now - 60_000).toISOString(),
|
|
390
|
+
}),
|
|
391
|
+
makeSession({
|
|
392
|
+
id: "s4",
|
|
393
|
+
agentName: "done",
|
|
394
|
+
tmuxSession: "legio-done",
|
|
395
|
+
state: "completed",
|
|
396
|
+
}),
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
writeSessionsToStore(tempRoot, sessions);
|
|
400
|
+
|
|
401
|
+
const tmuxMock = tmuxWithLiveness({
|
|
402
|
+
"legio-healthy": true,
|
|
403
|
+
"legio-dying": false,
|
|
404
|
+
"legio-stale": true,
|
|
405
|
+
"legio-done": false,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const checks: HealthCheck[] = [];
|
|
409
|
+
|
|
410
|
+
await runDaemonTick({
|
|
411
|
+
root: tempRoot,
|
|
412
|
+
...THRESHOLDS,
|
|
413
|
+
onHealthCheck: (c) => checks.push(c),
|
|
414
|
+
_tmux: tmuxMock,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// 3 non-completed sessions processed
|
|
418
|
+
expect(checks).toHaveLength(3);
|
|
419
|
+
|
|
420
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
421
|
+
|
|
422
|
+
const healthy = reloaded.find((s) => s.agentName === "healthy");
|
|
423
|
+
const dying = reloaded.find((s) => s.agentName === "dying");
|
|
424
|
+
const stale = reloaded.find((s) => s.agentName === "stale");
|
|
425
|
+
const done = reloaded.find((s) => s.agentName === "done");
|
|
426
|
+
|
|
427
|
+
expect(healthy?.state).toBe("working");
|
|
428
|
+
expect(dying?.state).toBe("zombie");
|
|
429
|
+
// 60s old activity is below zombieMs (120s) — session stays working
|
|
430
|
+
expect(stale?.state).toBe("working");
|
|
431
|
+
expect(done?.state).toBe("completed");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("empty sessions array is a no-op", async () => {
|
|
435
|
+
writeSessionsToStore(tempRoot, []);
|
|
436
|
+
|
|
437
|
+
const checks: HealthCheck[] = [];
|
|
438
|
+
|
|
439
|
+
await runDaemonTick({
|
|
440
|
+
root: tempRoot,
|
|
441
|
+
...THRESHOLDS,
|
|
442
|
+
onHealthCheck: (c) => checks.push(c),
|
|
443
|
+
_tmux: tmuxAllAlive(),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(checks).toHaveLength(0);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("booting session with recent activity transitions to working", async () => {
|
|
450
|
+
const session = makeSession({
|
|
451
|
+
state: "booting",
|
|
452
|
+
lastActivity: new Date().toISOString(),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
456
|
+
|
|
457
|
+
const checks: HealthCheck[] = [];
|
|
458
|
+
|
|
459
|
+
await runDaemonTick({
|
|
460
|
+
root: tempRoot,
|
|
461
|
+
...THRESHOLDS,
|
|
462
|
+
onHealthCheck: (c) => checks.push(c),
|
|
463
|
+
_tmux: tmuxAllAlive(),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
expect(checks).toHaveLength(1);
|
|
467
|
+
expect(checks[0]?.state).toBe("working");
|
|
468
|
+
|
|
469
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
470
|
+
expect(reloaded[0]?.state).toBe("working");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// --- Backward compatibility ---
|
|
474
|
+
|
|
475
|
+
test("sessions with default escalation fields are processed correctly", async () => {
|
|
476
|
+
// Write a session with default (zero) escalation fields
|
|
477
|
+
const session = makeSession({
|
|
478
|
+
id: "session-old",
|
|
479
|
+
agentName: "old-agent",
|
|
480
|
+
worktreePath: "/tmp/test",
|
|
481
|
+
branchName: "legio/old-agent/task",
|
|
482
|
+
beadId: "task",
|
|
483
|
+
tmuxSession: "legio-old-agent",
|
|
484
|
+
state: "working",
|
|
485
|
+
pid: process.pid,
|
|
486
|
+
escalationLevel: 0,
|
|
487
|
+
stalledSince: null,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
491
|
+
|
|
492
|
+
const checks: HealthCheck[] = [];
|
|
493
|
+
|
|
494
|
+
await runDaemonTick({
|
|
495
|
+
root: tempRoot,
|
|
496
|
+
...THRESHOLDS,
|
|
497
|
+
onHealthCheck: (c) => checks.push(c),
|
|
498
|
+
_tmux: tmuxAllAlive(),
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Should process without errors
|
|
502
|
+
expect(checks).toHaveLength(1);
|
|
503
|
+
expect(checks[0]?.state).toBe("working");
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// === Event recording tests ===
|
|
508
|
+
|
|
509
|
+
describe("daemon event recording", () => {
|
|
510
|
+
/** Open the events.db in the temp root and return all events. */
|
|
511
|
+
function readEvents(root: string): StoredEvent[] {
|
|
512
|
+
const dbPath = join(root, ".legio", "events.db");
|
|
513
|
+
const store = createEventStore(dbPath);
|
|
514
|
+
try {
|
|
515
|
+
// Get all events (no agent filter — use a broad timeline)
|
|
516
|
+
return store.getTimeline({ since: "2000-01-01T00:00:00Z" });
|
|
517
|
+
} finally {
|
|
518
|
+
store.close();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
test("run_id is included in events when current-run.txt exists", async () => {
|
|
523
|
+
// Use zombie-old activity to trigger terminate + recovery attempt events
|
|
524
|
+
const oldActivity = new Date(Date.now() - 200_000).toISOString();
|
|
525
|
+
const session = makeSession({
|
|
526
|
+
agentName: "zombie-agent",
|
|
527
|
+
tmuxSession: "legio-zombie-agent",
|
|
528
|
+
state: "working",
|
|
529
|
+
lastActivity: oldActivity,
|
|
530
|
+
parentAgent: "my-lead",
|
|
531
|
+
beadId: "task-abc",
|
|
532
|
+
capability: "builder",
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
536
|
+
|
|
537
|
+
// Write a current-run.txt
|
|
538
|
+
const runId = "run-2026-02-13T10-00-00-000Z";
|
|
539
|
+
await writeFile(join(tempRoot, ".legio", "current-run.txt"), runId, "utf-8");
|
|
540
|
+
|
|
541
|
+
const checkpoint: SessionCheckpoint = {
|
|
542
|
+
agentName: "zombie-agent",
|
|
543
|
+
beadId: "task-abc",
|
|
544
|
+
sessionId: "test-session",
|
|
545
|
+
timestamp: new Date().toISOString(),
|
|
546
|
+
progressSummary: "Test progress",
|
|
547
|
+
filesModified: [],
|
|
548
|
+
currentBranch: "legio/zombie-agent/task-abc",
|
|
549
|
+
pendingWork: "Finish implementation",
|
|
550
|
+
mulchDomains: [],
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const eventsDbPath = join(tempRoot, ".legio", "events.db");
|
|
554
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
555
|
+
|
|
556
|
+
try {
|
|
557
|
+
await runDaemonTick({
|
|
558
|
+
root: tempRoot,
|
|
559
|
+
...THRESHOLDS,
|
|
560
|
+
_tmux: tmuxWithLiveness({ "legio-zombie-agent": true }),
|
|
561
|
+
_loadCheckpoint: async () => checkpoint,
|
|
562
|
+
_sling: async () => ({ exitCode: 0, stderr: "" }),
|
|
563
|
+
_sendRecoveryMail: async () => {},
|
|
564
|
+
_recordFailure: async () => {},
|
|
565
|
+
_eventStore: eventStore,
|
|
566
|
+
});
|
|
567
|
+
} finally {
|
|
568
|
+
eventStore.close();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const events = readEvents(tempRoot);
|
|
572
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
573
|
+
const attemptEvent = events.find((e) => {
|
|
574
|
+
if (!e.data) return false;
|
|
575
|
+
const d = JSON.parse(e.data) as Record<string, unknown>;
|
|
576
|
+
return d.type === "recovery_attempt";
|
|
577
|
+
});
|
|
578
|
+
expect(attemptEvent).toBeDefined();
|
|
579
|
+
expect(attemptEvent?.runId).toBe(runId);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("daemon continues normally when _eventStore is null", async () => {
|
|
583
|
+
const session = makeSession({
|
|
584
|
+
agentName: "working-agent",
|
|
585
|
+
tmuxSession: "legio-working-agent",
|
|
586
|
+
state: "working",
|
|
587
|
+
lastActivity: new Date().toISOString(),
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
591
|
+
|
|
592
|
+
const checks: HealthCheck[] = [];
|
|
593
|
+
|
|
594
|
+
// Inject null EventStore — daemon should still work fine
|
|
595
|
+
await runDaemonTick({
|
|
596
|
+
root: tempRoot,
|
|
597
|
+
...THRESHOLDS,
|
|
598
|
+
onHealthCheck: (c) => checks.push(c),
|
|
599
|
+
_tmux: tmuxWithLiveness({ "legio-working-agent": true }),
|
|
600
|
+
_eventStore: null,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// Daemon should still produce health checks even without EventStore
|
|
604
|
+
expect(checks).toHaveLength(1);
|
|
605
|
+
expect(checks[0]?.action).toBe("none");
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// === Mulch failure recording tests ===
|
|
610
|
+
|
|
611
|
+
describe("daemon mulch failure recording", () => {
|
|
612
|
+
let tempRoot: string;
|
|
613
|
+
|
|
614
|
+
beforeEach(async () => {
|
|
615
|
+
tempRoot = await createTempRoot();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
afterEach(async () => {
|
|
619
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
/** Track calls to the recordFailure mock. */
|
|
623
|
+
interface FailureRecord {
|
|
624
|
+
root: string;
|
|
625
|
+
session: AgentSession;
|
|
626
|
+
reason: string;
|
|
627
|
+
tier: 0 | 1;
|
|
628
|
+
triageSuggestion?: string;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function failureTracker(): {
|
|
632
|
+
calls: FailureRecord[];
|
|
633
|
+
recordFailure: (
|
|
634
|
+
root: string,
|
|
635
|
+
session: AgentSession,
|
|
636
|
+
reason: string,
|
|
637
|
+
tier: 0 | 1,
|
|
638
|
+
triageSuggestion?: string,
|
|
639
|
+
) => Promise<void>;
|
|
640
|
+
} {
|
|
641
|
+
const calls: FailureRecord[] = [];
|
|
642
|
+
return {
|
|
643
|
+
calls,
|
|
644
|
+
async recordFailure(root, session, reason, tier, triageSuggestion) {
|
|
645
|
+
calls.push({ root, session, reason, tier, triageSuggestion });
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
test("Tier 0: recordFailure called when action=terminate (process death)", async () => {
|
|
651
|
+
const session = makeSession({
|
|
652
|
+
agentName: "dying-agent",
|
|
653
|
+
capability: "builder",
|
|
654
|
+
beadId: "task-123",
|
|
655
|
+
tmuxSession: "legio-dying-agent",
|
|
656
|
+
state: "working",
|
|
657
|
+
lastActivity: new Date().toISOString(),
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
661
|
+
|
|
662
|
+
const tmuxMock = tmuxWithLiveness({ "legio-dying-agent": false });
|
|
663
|
+
const failureMock = failureTracker();
|
|
664
|
+
|
|
665
|
+
await runDaemonTick({
|
|
666
|
+
root: tempRoot,
|
|
667
|
+
...THRESHOLDS,
|
|
668
|
+
_tmux: tmuxMock,
|
|
669
|
+
_recordFailure: failureMock.recordFailure,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// recordFailure should be called with Tier 0
|
|
673
|
+
expect(failureMock.calls).toHaveLength(1);
|
|
674
|
+
expect(failureMock.calls[0]?.tier).toBe(0);
|
|
675
|
+
expect(failureMock.calls[0]?.session.agentName).toBe("dying-agent");
|
|
676
|
+
expect(failureMock.calls[0]?.session.capability).toBe("builder");
|
|
677
|
+
expect(failureMock.calls[0]?.session.beadId).toBe("task-123");
|
|
678
|
+
// Reason should be either the reconciliationNote or default "Process terminated"
|
|
679
|
+
expect(failureMock.calls[0]?.reason).toBeDefined();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("recordFailure includes evidenceBead when beadId is present", async () => {
|
|
683
|
+
const session = makeSession({
|
|
684
|
+
agentName: "beaded-agent",
|
|
685
|
+
capability: "builder",
|
|
686
|
+
beadId: "task-789",
|
|
687
|
+
tmuxSession: "legio-beaded-agent",
|
|
688
|
+
state: "working",
|
|
689
|
+
lastActivity: new Date().toISOString(),
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
693
|
+
|
|
694
|
+
const tmuxMock = tmuxWithLiveness({ "legio-beaded-agent": false });
|
|
695
|
+
const failureMock = failureTracker();
|
|
696
|
+
|
|
697
|
+
await runDaemonTick({
|
|
698
|
+
root: tempRoot,
|
|
699
|
+
...THRESHOLDS,
|
|
700
|
+
_tmux: tmuxMock,
|
|
701
|
+
_recordFailure: failureMock.recordFailure,
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
expect(failureMock.calls).toHaveLength(1);
|
|
705
|
+
expect(failureMock.calls[0]?.session.beadId).toBe("task-789");
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// === Recovery tests ===
|
|
710
|
+
|
|
711
|
+
describe("daemon recovery", () => {
|
|
712
|
+
let tempRoot: string;
|
|
713
|
+
|
|
714
|
+
beforeEach(async () => {
|
|
715
|
+
tempRoot = await createTempRoot();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
afterEach(async () => {
|
|
719
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
/** Open the events.db and return all events. */
|
|
723
|
+
function readEvents(root: string): StoredEvent[] {
|
|
724
|
+
const dbPath = join(root, ".legio", "events.db");
|
|
725
|
+
const store = createEventStore(dbPath);
|
|
726
|
+
try {
|
|
727
|
+
return store.getTimeline({ since: "2000-01-01T00:00:00Z" });
|
|
728
|
+
} finally {
|
|
729
|
+
store.close();
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Build a minimal SessionCheckpoint for a session. */
|
|
734
|
+
function makeCheckpoint(agentName: string, beadId: string): SessionCheckpoint {
|
|
735
|
+
return {
|
|
736
|
+
agentName,
|
|
737
|
+
beadId,
|
|
738
|
+
sessionId: "test-session",
|
|
739
|
+
timestamp: new Date().toISOString(),
|
|
740
|
+
progressSummary: "Test progress",
|
|
741
|
+
filesModified: ["src/foo.ts"],
|
|
742
|
+
currentBranch: `legio/${agentName}/${beadId}`,
|
|
743
|
+
pendingWork: "Finish implementation",
|
|
744
|
+
mulchDomains: ["typescript"],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Create a fake _sling that tracks calls and returns a given exit code. */
|
|
749
|
+
function slingTracker(exitCode = 0): {
|
|
750
|
+
sling: (args: string[]) => Promise<{ exitCode: number; stderr: string }>;
|
|
751
|
+
calls: string[][];
|
|
752
|
+
} {
|
|
753
|
+
const calls: string[][] = [];
|
|
754
|
+
return {
|
|
755
|
+
sling: async (args: string[]) => {
|
|
756
|
+
calls.push(args);
|
|
757
|
+
return { exitCode, stderr: exitCode !== 0 ? "sling failed" : "" };
|
|
758
|
+
},
|
|
759
|
+
calls,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Create a fake _sendRecoveryMail that tracks calls. */
|
|
764
|
+
function mailTracker(): {
|
|
765
|
+
sendRecoveryMail: (args: string[]) => Promise<void>;
|
|
766
|
+
calls: string[][];
|
|
767
|
+
} {
|
|
768
|
+
const calls: string[][] = [];
|
|
769
|
+
return {
|
|
770
|
+
sendRecoveryMail: async (args: string[]) => {
|
|
771
|
+
calls.push(args);
|
|
772
|
+
},
|
|
773
|
+
calls,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Read recovery count from disk. */
|
|
778
|
+
async function readRecoveryCountFromDisk(root: string, agentName: string): Promise<number> {
|
|
779
|
+
try {
|
|
780
|
+
const text = await readFile(
|
|
781
|
+
join(root, ".legio", "agents", agentName, "recovery-count"),
|
|
782
|
+
"utf-8",
|
|
783
|
+
);
|
|
784
|
+
return parseInt(text.trim(), 10) || 0;
|
|
785
|
+
} catch {
|
|
786
|
+
return 0;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Write recovery count to disk to simulate prior attempts. */
|
|
791
|
+
async function writeRecoveryCountToDisk(
|
|
792
|
+
root: string,
|
|
793
|
+
agentName: string,
|
|
794
|
+
count: number,
|
|
795
|
+
): Promise<void> {
|
|
796
|
+
const dir = join(root, ".legio", "agents", agentName);
|
|
797
|
+
await mkdir(dir, { recursive: true });
|
|
798
|
+
await writeFile(join(dir, "recovery-count"), String(count), "utf-8");
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// --- Direct terminate path (tmux dead) ---
|
|
802
|
+
|
|
803
|
+
test("no checkpoint → no recovery, agent marked zombie", async () => {
|
|
804
|
+
const session = makeSession({
|
|
805
|
+
agentName: "dead-agent",
|
|
806
|
+
tmuxSession: "legio-dead-agent",
|
|
807
|
+
state: "working",
|
|
808
|
+
lastActivity: new Date().toISOString(),
|
|
809
|
+
parentAgent: "my-lead",
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
813
|
+
|
|
814
|
+
const slingMock = slingTracker(0);
|
|
815
|
+
const mailMock = mailTracker();
|
|
816
|
+
|
|
817
|
+
await runDaemonTick({
|
|
818
|
+
root: tempRoot,
|
|
819
|
+
...THRESHOLDS,
|
|
820
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
821
|
+
_loadCheckpoint: async () => null,
|
|
822
|
+
_sling: slingMock.sling,
|
|
823
|
+
_sendRecoveryMail: mailMock.sendRecoveryMail,
|
|
824
|
+
_recordFailure: async () => {},
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// No sling attempted (no checkpoint)
|
|
828
|
+
expect(slingMock.calls).toHaveLength(0);
|
|
829
|
+
// No mail sent
|
|
830
|
+
expect(mailMock.calls).toHaveLength(0);
|
|
831
|
+
// Agent is zombie (existing behavior)
|
|
832
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
833
|
+
expect(reloaded[0]?.state).toBe("zombie");
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("checkpoint exists, sling succeeds → sling called, recovery events recorded", async () => {
|
|
837
|
+
const session = makeSession({
|
|
838
|
+
agentName: "dead-agent",
|
|
839
|
+
tmuxSession: "legio-dead-agent",
|
|
840
|
+
state: "working",
|
|
841
|
+
lastActivity: new Date().toISOString(),
|
|
842
|
+
parentAgent: "my-lead",
|
|
843
|
+
beadId: "task-abc",
|
|
844
|
+
capability: "builder",
|
|
845
|
+
depth: 1,
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
849
|
+
|
|
850
|
+
const checkpoint = makeCheckpoint("dead-agent", "task-abc");
|
|
851
|
+
const slingMock = slingTracker(0);
|
|
852
|
+
const mailMock = mailTracker();
|
|
853
|
+
|
|
854
|
+
const eventsDbPath = join(tempRoot, ".legio", "events.db");
|
|
855
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
856
|
+
|
|
857
|
+
try {
|
|
858
|
+
await runDaemonTick({
|
|
859
|
+
root: tempRoot,
|
|
860
|
+
...THRESHOLDS,
|
|
861
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
862
|
+
_loadCheckpoint: async () => checkpoint,
|
|
863
|
+
_sling: slingMock.sling,
|
|
864
|
+
_sendRecoveryMail: mailMock.sendRecoveryMail,
|
|
865
|
+
_recordFailure: async () => {},
|
|
866
|
+
_eventStore: eventStore,
|
|
867
|
+
});
|
|
868
|
+
} finally {
|
|
869
|
+
eventStore.close();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Sling was called
|
|
873
|
+
expect(slingMock.calls).toHaveLength(1);
|
|
874
|
+
// Mail sent to parent
|
|
875
|
+
expect(mailMock.calls).toHaveLength(1);
|
|
876
|
+
expect(mailMock.calls[0]).toContain("my-lead");
|
|
877
|
+
|
|
878
|
+
// recovery_attempt and recovery_success events recorded
|
|
879
|
+
const events = readEvents(tempRoot);
|
|
880
|
+
const attemptEvent = events.find((e) => {
|
|
881
|
+
if (!e.data) return false;
|
|
882
|
+
const d = JSON.parse(e.data) as Record<string, unknown>;
|
|
883
|
+
return d.type === "recovery_attempt";
|
|
884
|
+
});
|
|
885
|
+
expect(attemptEvent).toBeDefined();
|
|
886
|
+
expect(attemptEvent?.level).toBe("info");
|
|
887
|
+
expect(attemptEvent?.agentName).toBe("dead-agent");
|
|
888
|
+
|
|
889
|
+
const successEvent = events.find((e) => {
|
|
890
|
+
if (!e.data) return false;
|
|
891
|
+
const d = JSON.parse(e.data) as Record<string, unknown>;
|
|
892
|
+
return d.type === "recovery_success";
|
|
893
|
+
});
|
|
894
|
+
expect(successEvent).toBeDefined();
|
|
895
|
+
expect(successEvent?.level).toBe("info");
|
|
896
|
+
|
|
897
|
+
// State must be "completed" after successful recovery, not "zombie"
|
|
898
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
899
|
+
expect(reloaded[0]?.state).not.toBe("zombie");
|
|
900
|
+
expect(reloaded[0]?.state).toBe("completed");
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test("checkpoint exists, sling fails → sling called, agent stays zombie, recovery_failed event", async () => {
|
|
904
|
+
const session = makeSession({
|
|
905
|
+
agentName: "dead-agent",
|
|
906
|
+
tmuxSession: "legio-dead-agent",
|
|
907
|
+
state: "working",
|
|
908
|
+
lastActivity: new Date().toISOString(),
|
|
909
|
+
parentAgent: "my-lead",
|
|
910
|
+
beadId: "task-abc",
|
|
911
|
+
capability: "builder",
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
915
|
+
|
|
916
|
+
const checkpoint = makeCheckpoint("dead-agent", "task-abc");
|
|
917
|
+
const slingMock = slingTracker(1); // Non-zero exit code
|
|
918
|
+
|
|
919
|
+
const eventsDbPath = join(tempRoot, ".legio", "events.db");
|
|
920
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
921
|
+
|
|
922
|
+
try {
|
|
923
|
+
await runDaemonTick({
|
|
924
|
+
root: tempRoot,
|
|
925
|
+
...THRESHOLDS,
|
|
926
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
927
|
+
_loadCheckpoint: async () => checkpoint,
|
|
928
|
+
_sling: slingMock.sling,
|
|
929
|
+
_sendRecoveryMail: async () => {},
|
|
930
|
+
_recordFailure: async () => {},
|
|
931
|
+
_eventStore: eventStore,
|
|
932
|
+
});
|
|
933
|
+
} finally {
|
|
934
|
+
eventStore.close();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Sling was called
|
|
938
|
+
expect(slingMock.calls).toHaveLength(1);
|
|
939
|
+
// Agent should be zombie (sling failed)
|
|
940
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
941
|
+
expect(reloaded[0]?.state).toBe("zombie");
|
|
942
|
+
|
|
943
|
+
// recovery_failed event recorded
|
|
944
|
+
const events = readEvents(tempRoot);
|
|
945
|
+
const failedEvent = events.find((e) => {
|
|
946
|
+
if (!e.data) return false;
|
|
947
|
+
const d = JSON.parse(e.data) as Record<string, unknown>;
|
|
948
|
+
return d.type === "recovery_failed";
|
|
949
|
+
});
|
|
950
|
+
expect(failedEvent).toBeDefined();
|
|
951
|
+
expect(failedEvent?.level).toBe("error");
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
test("sling args include capability, name, spec path, files, parent, depth", async () => {
|
|
955
|
+
const session = makeSession({
|
|
956
|
+
agentName: "dead-agent",
|
|
957
|
+
tmuxSession: "legio-dead-agent",
|
|
958
|
+
state: "working",
|
|
959
|
+
lastActivity: new Date().toISOString(),
|
|
960
|
+
parentAgent: "my-lead",
|
|
961
|
+
beadId: "task-abc",
|
|
962
|
+
capability: "builder",
|
|
963
|
+
depth: 2,
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
967
|
+
|
|
968
|
+
const checkpoint: SessionCheckpoint = {
|
|
969
|
+
...makeCheckpoint("dead-agent", "task-abc"),
|
|
970
|
+
filesModified: ["src/foo.ts", "src/bar.ts"],
|
|
971
|
+
};
|
|
972
|
+
const slingMock = slingTracker(0);
|
|
973
|
+
|
|
974
|
+
await runDaemonTick({
|
|
975
|
+
root: tempRoot,
|
|
976
|
+
...THRESHOLDS,
|
|
977
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
978
|
+
_loadCheckpoint: async () => checkpoint,
|
|
979
|
+
_sling: slingMock.sling,
|
|
980
|
+
_sendRecoveryMail: async () => {},
|
|
981
|
+
_recordFailure: async () => {},
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
expect(slingMock.calls).toHaveLength(1);
|
|
985
|
+
const args = slingMock.calls[0] ?? [];
|
|
986
|
+
expect(args).toContain("task-abc");
|
|
987
|
+
expect(args).toContain("--capability");
|
|
988
|
+
expect(args).toContain("builder");
|
|
989
|
+
expect(args).toContain("--name");
|
|
990
|
+
expect(args).toContain("dead-agent");
|
|
991
|
+
expect(args).toContain("--spec");
|
|
992
|
+
expect(args).toContain("--files");
|
|
993
|
+
expect(args).toContain("src/foo.ts,src/bar.ts");
|
|
994
|
+
expect(args).toContain("--parent");
|
|
995
|
+
expect(args).toContain("my-lead");
|
|
996
|
+
expect(args).toContain("--depth");
|
|
997
|
+
expect(args).toContain("2");
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test("no files modified → --files arg omitted from sling", async () => {
|
|
1001
|
+
const session = makeSession({
|
|
1002
|
+
agentName: "dead-agent",
|
|
1003
|
+
tmuxSession: "legio-dead-agent",
|
|
1004
|
+
state: "working",
|
|
1005
|
+
lastActivity: new Date().toISOString(),
|
|
1006
|
+
capability: "builder",
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
1010
|
+
|
|
1011
|
+
const checkpoint: SessionCheckpoint = {
|
|
1012
|
+
...makeCheckpoint("dead-agent", "task-abc"),
|
|
1013
|
+
filesModified: [], // No files
|
|
1014
|
+
};
|
|
1015
|
+
const slingMock = slingTracker(0);
|
|
1016
|
+
|
|
1017
|
+
await runDaemonTick({
|
|
1018
|
+
root: tempRoot,
|
|
1019
|
+
...THRESHOLDS,
|
|
1020
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
1021
|
+
_loadCheckpoint: async () => checkpoint,
|
|
1022
|
+
_sling: slingMock.sling,
|
|
1023
|
+
_sendRecoveryMail: async () => {},
|
|
1024
|
+
_recordFailure: async () => {},
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
expect(slingMock.calls).toHaveLength(1);
|
|
1028
|
+
const args = slingMock.calls[0] ?? [];
|
|
1029
|
+
expect(args).not.toContain("--files");
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test("recovery count increments after successful attempt", async () => {
|
|
1033
|
+
const session = makeSession({
|
|
1034
|
+
agentName: "dead-agent",
|
|
1035
|
+
tmuxSession: "legio-dead-agent",
|
|
1036
|
+
state: "working",
|
|
1037
|
+
lastActivity: new Date().toISOString(),
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
1041
|
+
|
|
1042
|
+
const checkpoint = makeCheckpoint("dead-agent", "task-abc");
|
|
1043
|
+
|
|
1044
|
+
await runDaemonTick({
|
|
1045
|
+
root: tempRoot,
|
|
1046
|
+
...THRESHOLDS,
|
|
1047
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
1048
|
+
_loadCheckpoint: async () => checkpoint,
|
|
1049
|
+
_sling: slingTracker(0).sling,
|
|
1050
|
+
_sendRecoveryMail: async () => {},
|
|
1051
|
+
_recordFailure: async () => {},
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
const count = await readRecoveryCountFromDisk(tempRoot, "dead-agent");
|
|
1055
|
+
expect(count).toBe(1);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test("recovery count exhausted → no sling, agent zombified, escalation mail sent to parent", async () => {
|
|
1059
|
+
const session = makeSession({
|
|
1060
|
+
agentName: "dead-agent",
|
|
1061
|
+
tmuxSession: "legio-dead-agent",
|
|
1062
|
+
state: "working",
|
|
1063
|
+
lastActivity: new Date().toISOString(),
|
|
1064
|
+
parentAgent: "my-lead",
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
1068
|
+
|
|
1069
|
+
// Pre-write recovery count = 1 (default maxRecoveryAttempts=1, so exhausted)
|
|
1070
|
+
await writeRecoveryCountToDisk(tempRoot, "dead-agent", 1);
|
|
1071
|
+
|
|
1072
|
+
const checkpoint = makeCheckpoint("dead-agent", "task-abc");
|
|
1073
|
+
const slingMock = slingTracker(0);
|
|
1074
|
+
const mailMock = mailTracker();
|
|
1075
|
+
|
|
1076
|
+
await runDaemonTick({
|
|
1077
|
+
root: tempRoot,
|
|
1078
|
+
...THRESHOLDS,
|
|
1079
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
1080
|
+
_loadCheckpoint: async () => checkpoint,
|
|
1081
|
+
_sling: slingMock.sling,
|
|
1082
|
+
_sendRecoveryMail: mailMock.sendRecoveryMail,
|
|
1083
|
+
_recordFailure: async () => {},
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
// No sling attempted (exhausted)
|
|
1087
|
+
expect(slingMock.calls).toHaveLength(0);
|
|
1088
|
+
// Exhaustion error mail sent to parent
|
|
1089
|
+
expect(mailMock.calls).toHaveLength(1);
|
|
1090
|
+
const mailArgs = mailMock.calls[0] ?? [];
|
|
1091
|
+
expect(mailArgs).toContain("my-lead");
|
|
1092
|
+
expect(mailArgs).toContain("error");
|
|
1093
|
+
// Agent marked zombie
|
|
1094
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
1095
|
+
expect(reloaded[0]?.state).toBe("zombie");
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test("maxRecoveryAttempts=2: second attempt allowed when count=1", async () => {
|
|
1099
|
+
const session = makeSession({
|
|
1100
|
+
agentName: "dead-agent",
|
|
1101
|
+
tmuxSession: "legio-dead-agent",
|
|
1102
|
+
state: "working",
|
|
1103
|
+
lastActivity: new Date().toISOString(),
|
|
1104
|
+
parentAgent: "my-lead",
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
1108
|
+
|
|
1109
|
+
// count=1 but max=2, so one more attempt is allowed
|
|
1110
|
+
await writeRecoveryCountToDisk(tempRoot, "dead-agent", 1);
|
|
1111
|
+
|
|
1112
|
+
const checkpoint = makeCheckpoint("dead-agent", "task-abc");
|
|
1113
|
+
const slingMock = slingTracker(0);
|
|
1114
|
+
|
|
1115
|
+
await runDaemonTick({
|
|
1116
|
+
root: tempRoot,
|
|
1117
|
+
...THRESHOLDS,
|
|
1118
|
+
maxRecoveryAttempts: 2,
|
|
1119
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
1120
|
+
_loadCheckpoint: async () => checkpoint,
|
|
1121
|
+
_sling: slingMock.sling,
|
|
1122
|
+
_sendRecoveryMail: async () => {},
|
|
1123
|
+
_recordFailure: async () => {},
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// Second attempt was made
|
|
1127
|
+
expect(slingMock.calls).toHaveLength(1);
|
|
1128
|
+
// Count now 2
|
|
1129
|
+
const count = await readRecoveryCountFromDisk(tempRoot, "dead-agent");
|
|
1130
|
+
expect(count).toBe(2);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
test("no parent agent → no mail, recovery still attempted", async () => {
|
|
1134
|
+
const session = makeSession({
|
|
1135
|
+
agentName: "dead-agent",
|
|
1136
|
+
tmuxSession: "legio-dead-agent",
|
|
1137
|
+
state: "working",
|
|
1138
|
+
lastActivity: new Date().toISOString(),
|
|
1139
|
+
parentAgent: null,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
1143
|
+
|
|
1144
|
+
const checkpoint = makeCheckpoint("dead-agent", "task-abc");
|
|
1145
|
+
const slingMock = slingTracker(0);
|
|
1146
|
+
const mailMock = mailTracker();
|
|
1147
|
+
|
|
1148
|
+
await runDaemonTick({
|
|
1149
|
+
root: tempRoot,
|
|
1150
|
+
...THRESHOLDS,
|
|
1151
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
1152
|
+
_loadCheckpoint: async () => checkpoint,
|
|
1153
|
+
_sling: slingMock.sling,
|
|
1154
|
+
_sendRecoveryMail: mailMock.sendRecoveryMail,
|
|
1155
|
+
_recordFailure: async () => {},
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// Sling still attempted
|
|
1159
|
+
expect(slingMock.calls).toHaveLength(1);
|
|
1160
|
+
// No mail (no parent)
|
|
1161
|
+
expect(mailMock.calls).toHaveLength(0);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
test("recovery_attempt event includes attempt number and maxAttempts", async () => {
|
|
1165
|
+
const session = makeSession({
|
|
1166
|
+
agentName: "dead-agent",
|
|
1167
|
+
tmuxSession: "legio-dead-agent",
|
|
1168
|
+
state: "working",
|
|
1169
|
+
lastActivity: new Date().toISOString(),
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
1173
|
+
|
|
1174
|
+
const checkpoint = makeCheckpoint("dead-agent", "task-abc");
|
|
1175
|
+
|
|
1176
|
+
const eventsDbPath = join(tempRoot, ".legio", "events.db");
|
|
1177
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1178
|
+
|
|
1179
|
+
try {
|
|
1180
|
+
await runDaemonTick({
|
|
1181
|
+
root: tempRoot,
|
|
1182
|
+
...THRESHOLDS,
|
|
1183
|
+
maxRecoveryAttempts: 3,
|
|
1184
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
1185
|
+
_loadCheckpoint: async () => checkpoint,
|
|
1186
|
+
_sling: slingTracker(0).sling,
|
|
1187
|
+
_sendRecoveryMail: async () => {},
|
|
1188
|
+
_recordFailure: async () => {},
|
|
1189
|
+
_eventStore: eventStore,
|
|
1190
|
+
});
|
|
1191
|
+
} finally {
|
|
1192
|
+
eventStore.close();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const events = readEvents(tempRoot);
|
|
1196
|
+
const attemptEvent = events.find((e) => {
|
|
1197
|
+
if (!e.data) return false;
|
|
1198
|
+
const d = JSON.parse(e.data) as Record<string, unknown>;
|
|
1199
|
+
return d.type === "recovery_attempt";
|
|
1200
|
+
});
|
|
1201
|
+
expect(attemptEvent).toBeDefined();
|
|
1202
|
+
const data = JSON.parse(attemptEvent?.data ?? "{}") as Record<string, unknown>;
|
|
1203
|
+
expect(data.attempt).toBe(1);
|
|
1204
|
+
expect(data.maxAttempts).toBe(3);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("existing tests unchanged: dead tmux without recovery DI still zombifies", async () => {
|
|
1208
|
+
// Verify that omitting recovery DI (no _loadCheckpoint) uses default behavior —
|
|
1209
|
+
// since the real loadCheckpoint would find no file, agent should still be zombified.
|
|
1210
|
+
const session = makeSession({
|
|
1211
|
+
agentName: "dead-agent",
|
|
1212
|
+
tmuxSession: "legio-dead-agent",
|
|
1213
|
+
state: "working",
|
|
1214
|
+
lastActivity: new Date().toISOString(),
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
1218
|
+
|
|
1219
|
+
// Use a _loadCheckpoint that returns null (as the real impl would for no file)
|
|
1220
|
+
await runDaemonTick({
|
|
1221
|
+
root: tempRoot,
|
|
1222
|
+
...THRESHOLDS,
|
|
1223
|
+
_tmux: tmuxWithLiveness({ "legio-dead-agent": false }),
|
|
1224
|
+
_loadCheckpoint: async () => null,
|
|
1225
|
+
_sling: async () => ({ exitCode: 0, stderr: "" }),
|
|
1226
|
+
_sendRecoveryMail: async () => {},
|
|
1227
|
+
_recordFailure: async () => {},
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
1231
|
+
expect(reloaded[0]?.state).toBe("zombie");
|
|
1232
|
+
});
|
|
1233
|
+
});
|