@katyella/legio 0.1.2 → 0.2.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 +47 -3
- package/README.md +15 -8
- 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/bin/legio.mjs +13 -2
- package/package.json +3 -3
- 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 +78 -78
- package/src/commands/coordinator.ts +92 -47
- 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 +39 -13
- package/src/commands/gateway.ts +95 -7
- 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.ts +40 -16
- 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 +7 -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 +35 -10
- package/src/doctor/dependencies.ts +16 -92
- 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
package/src/commands/up.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. Check git repo
|
|
6
6
|
* 2. Initialize .legio/ if needed (legio init)
|
|
7
7
|
* 3. Start the server in daemon mode (legio server start --daemon)
|
|
8
|
-
* The server auto-starts the coordinator with
|
|
8
|
+
* The server auto-starts the coordinator with watchman.
|
|
9
9
|
* 4. Start the gateway (legio gateway start --no-attach)
|
|
10
10
|
* 5. Open the browser (unless --no-open)
|
|
11
11
|
*
|
|
@@ -16,7 +16,7 @@ import { spawn } from "node:child_process";
|
|
|
16
16
|
import { access, readFile } from "node:fs/promises";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { ServerError, ValidationError } from "../errors.ts";
|
|
19
|
-
import { isProcessRunning } from "../
|
|
19
|
+
import { isProcessRunning } from "../watchman/health.ts";
|
|
20
20
|
|
|
21
21
|
function getFlag(args: string[], flag: string): string | undefined {
|
|
22
22
|
const idx = args.indexOf(flag);
|
|
@@ -118,7 +118,7 @@ Options:
|
|
|
118
118
|
|
|
119
119
|
legio up initializes .legio/ if needed, starts the server in daemon mode,
|
|
120
120
|
starts the gateway, and opens the browser. The server auto-starts the
|
|
121
|
-
coordinator with
|
|
121
|
+
coordinator with watchman. Running legio up when already running is a no-op.`;
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
124
|
* Entry point for \`legio up [options]\`.
|
|
@@ -238,14 +238,18 @@ export async function upCommand(args: string[], deps: UpDeps = {}): Promise<void
|
|
|
238
238
|
// ignore status check errors, proceed to try starting
|
|
239
239
|
}
|
|
240
240
|
if (!gatewayRunning) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
241
|
+
try {
|
|
242
|
+
const gatewayResult = await run(["legio", "gateway", "start", "--no-attach", "--json"], {
|
|
243
|
+
cwd: projectRoot,
|
|
244
|
+
});
|
|
245
|
+
if (gatewayResult.exitCode === 0) {
|
|
246
|
+
gatewayStarted = true;
|
|
247
|
+
if (!json) process.stdout.write("Gateway started\n");
|
|
248
|
+
} else if (!json) {
|
|
249
|
+
process.stderr.write(`Warning: gateway start failed: ${gatewayResult.stderr.trim()}\n`);
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
if (!json) process.stderr.write("Warning: gateway start failed\n");
|
|
249
253
|
}
|
|
250
254
|
}
|
|
251
255
|
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: legio watchman start/stop/status
|
|
3
|
+
*
|
|
4
|
+
* Unified daemon combining:
|
|
5
|
+
* - Health monitoring (watchdog): session health checks, zombie detection, recovery
|
|
6
|
+
* - Mail delivery (mailman): poll for unread mail, nudge agents
|
|
7
|
+
* - Beacon safety net: detect stuck beacons and send follow-up Enter
|
|
8
|
+
*
|
|
9
|
+
* PID file: .legio/watchman.pid
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { loadConfig } from "../config.ts";
|
|
16
|
+
import { LegioError } from "../errors.ts";
|
|
17
|
+
import type { HealthCheck } from "../types.ts";
|
|
18
|
+
import { startDaemon } from "../watchman/daemon.ts";
|
|
19
|
+
import { isProcessRunning } from "../watchman/health.ts";
|
|
20
|
+
|
|
21
|
+
function getFlag(args: string[], flag: string): string | undefined {
|
|
22
|
+
const idx = args.indexOf(flag);
|
|
23
|
+
if (idx === -1 || idx + 1 >= args.length) return undefined;
|
|
24
|
+
return args[idx + 1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
28
|
+
return args.includes(flag);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Format a health check for display.
|
|
33
|
+
*/
|
|
34
|
+
function formatCheck(check: HealthCheck): string {
|
|
35
|
+
const actionIcon =
|
|
36
|
+
check.action === "terminate"
|
|
37
|
+
? "💀"
|
|
38
|
+
: check.action === "escalate"
|
|
39
|
+
? "⚠️"
|
|
40
|
+
: check.action === "investigate"
|
|
41
|
+
? "🔍"
|
|
42
|
+
: "✅";
|
|
43
|
+
const pidLabel = check.pidAlive === null ? "n/a" : check.pidAlive ? "up" : "down";
|
|
44
|
+
let line = `${actionIcon} ${check.agentName}: ${check.state} (tmux=${check.tmuxAlive ? "up" : "down"}, pid=${pidLabel})`;
|
|
45
|
+
if (check.reconciliationNote) {
|
|
46
|
+
line += ` [${check.reconciliationNote}]`;
|
|
47
|
+
}
|
|
48
|
+
return line;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the PID from the watchman PID file.
|
|
53
|
+
*/
|
|
54
|
+
async function readPidFile(pidFilePath: string): Promise<number | null> {
|
|
55
|
+
try {
|
|
56
|
+
const text = await readFile(pidFilePath, "utf-8");
|
|
57
|
+
const pid = Number.parseInt(text.trim(), 10);
|
|
58
|
+
if (Number.isNaN(pid) || pid <= 0) return null;
|
|
59
|
+
return pid;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Write a PID to the watchman PID file.
|
|
67
|
+
*/
|
|
68
|
+
async function writePidFile(pidFilePath: string, pid: number): Promise<void> {
|
|
69
|
+
await writeFile(pidFilePath, `${pid}\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove the watchman PID file.
|
|
74
|
+
*/
|
|
75
|
+
async function removePidFile(pidFilePath: string): Promise<void> {
|
|
76
|
+
try {
|
|
77
|
+
await unlink(pidFilePath);
|
|
78
|
+
} catch {
|
|
79
|
+
// File may already be gone
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the path to the legio binary for re-launching.
|
|
85
|
+
*/
|
|
86
|
+
async function resolveLegioBin(): Promise<string> {
|
|
87
|
+
try {
|
|
88
|
+
const result = await new Promise<{ exitCode: number; stdout: string }>((resolve) => {
|
|
89
|
+
const proc = spawn("which", ["legio"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
90
|
+
const stdoutChunks: Buffer[] = [];
|
|
91
|
+
proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
|
|
92
|
+
proc.on("close", (code: number | null) => {
|
|
93
|
+
resolve({ exitCode: code ?? 1, stdout: Buffer.concat(stdoutChunks).toString("utf-8") });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
if (result.exitCode === 0) {
|
|
97
|
+
const binPath = result.stdout.trim();
|
|
98
|
+
if (binPath.length > 0) return binPath;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// which not available
|
|
102
|
+
}
|
|
103
|
+
const scriptPath = process.argv[1];
|
|
104
|
+
if (scriptPath) return scriptPath;
|
|
105
|
+
throw new LegioError("Cannot resolve legio binary path for background launch", "WATCHMAN_ERROR");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Handle `legio watchman start` */
|
|
109
|
+
async function handleStart(args: string[]): Promise<void> {
|
|
110
|
+
const background = hasFlag(args, "--background");
|
|
111
|
+
const intervalStr = getFlag(args, "--interval");
|
|
112
|
+
|
|
113
|
+
const cwd = process.cwd();
|
|
114
|
+
const config = await loadConfig(cwd);
|
|
115
|
+
const root = config.project.root;
|
|
116
|
+
const intervalMs = intervalStr
|
|
117
|
+
? Number.parseInt(intervalStr, 10)
|
|
118
|
+
: config.watchman.tier0IntervalMs;
|
|
119
|
+
const pidFilePath = join(root, ".legio", "watchman.pid");
|
|
120
|
+
|
|
121
|
+
if (background) {
|
|
122
|
+
// Check if already running
|
|
123
|
+
const existingPid = await readPidFile(pidFilePath);
|
|
124
|
+
if (existingPid !== null && isProcessRunning(existingPid)) {
|
|
125
|
+
process.stderr.write(
|
|
126
|
+
`Error: Watchman already running (PID: ${existingPid}). ` +
|
|
127
|
+
`Kill it first or remove ${pidFilePath}\n`,
|
|
128
|
+
);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (existingPid !== null) {
|
|
134
|
+
await removePidFile(pidFilePath);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const childArgs: string[] = ["watchman", "start"];
|
|
138
|
+
if (intervalStr) {
|
|
139
|
+
childArgs.push("--interval", intervalStr);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const legioBin = await resolveLegioBin();
|
|
143
|
+
const child = spawn(process.execPath, ["--import", "tsx", legioBin, ...childArgs], {
|
|
144
|
+
cwd,
|
|
145
|
+
stdio: "ignore",
|
|
146
|
+
detached: true,
|
|
147
|
+
});
|
|
148
|
+
child.unref();
|
|
149
|
+
|
|
150
|
+
const childPid = child.pid ?? 0;
|
|
151
|
+
await writePidFile(pidFilePath, childPid);
|
|
152
|
+
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
`Watchman started in background (PID: ${childPid}, interval: ${intervalMs}ms)\n`,
|
|
155
|
+
);
|
|
156
|
+
process.stdout.write(`PID file: ${pidFilePath}\n`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Foreground mode
|
|
161
|
+
process.stdout.write(
|
|
162
|
+
`Watchman running (health: ${intervalMs}ms, mail: ${config.watchman.mailIntervalMs}ms)\n`,
|
|
163
|
+
);
|
|
164
|
+
process.stdout.write("Press Ctrl+C to stop.\n\n");
|
|
165
|
+
|
|
166
|
+
await writePidFile(pidFilePath, process.pid);
|
|
167
|
+
|
|
168
|
+
const { stop } = startDaemon({
|
|
169
|
+
root,
|
|
170
|
+
intervalMs,
|
|
171
|
+
zombieThresholdMs: config.watchman.zombieThresholdMs,
|
|
172
|
+
mailIntervalMs: config.watchman.mailIntervalMs,
|
|
173
|
+
reNudgeIntervalMs: config.watchman.reNudgeIntervalMs,
|
|
174
|
+
warnAfterMs: config.watchman.warnAfterMs,
|
|
175
|
+
beaconNudgeMs: config.watchman.beaconNudgeMs,
|
|
176
|
+
onHealthCheck(check) {
|
|
177
|
+
const timestamp = new Date().toISOString().slice(11, 19);
|
|
178
|
+
process.stdout.write(`[${timestamp}] ${formatCheck(check)}\n`);
|
|
179
|
+
},
|
|
180
|
+
onNudge(agentName, nudgeCount) {
|
|
181
|
+
const timestamp = new Date().toISOString().slice(11, 19);
|
|
182
|
+
process.stdout.write(`[${timestamp}] 📬 Nudged ${agentName} (attempt ${nudgeCount})\n`);
|
|
183
|
+
},
|
|
184
|
+
onWarn(agentName, unreadSinceMs) {
|
|
185
|
+
const timestamp = new Date().toISOString().slice(11, 19);
|
|
186
|
+
const seconds = Math.round(unreadSinceMs / 1000);
|
|
187
|
+
process.stdout.write(`[${timestamp}] ⚠️ ${agentName} has had unread mail for ${seconds}s\n`);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
process.on("SIGINT", () => {
|
|
192
|
+
stop();
|
|
193
|
+
removePidFile(pidFilePath).finally(() => {
|
|
194
|
+
process.stdout.write("\nWatchman stopped.\n");
|
|
195
|
+
process.exit(0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Block forever
|
|
200
|
+
await new Promise(() => {});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Handle `legio watchman stop` */
|
|
204
|
+
async function handleStop(args: string[]): Promise<void> {
|
|
205
|
+
const json = hasFlag(args, "--json");
|
|
206
|
+
const cwd = process.cwd();
|
|
207
|
+
const config = await loadConfig(cwd);
|
|
208
|
+
const pidFilePath = join(config.project.root, ".legio", "watchman.pid");
|
|
209
|
+
|
|
210
|
+
const pid = await readPidFile(pidFilePath);
|
|
211
|
+
|
|
212
|
+
if (pid === null) {
|
|
213
|
+
if (json) {
|
|
214
|
+
process.stdout.write(`${JSON.stringify({ stopped: false, reason: "not running" })}\n`);
|
|
215
|
+
} else {
|
|
216
|
+
process.stdout.write("Watchman is not running (no PID file)\n");
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!isProcessRunning(pid)) {
|
|
222
|
+
await removePidFile(pidFilePath);
|
|
223
|
+
if (json) {
|
|
224
|
+
process.stdout.write(
|
|
225
|
+
`${JSON.stringify({ stopped: false, reason: "stale PID file cleaned" })}\n`,
|
|
226
|
+
);
|
|
227
|
+
} else {
|
|
228
|
+
process.stdout.write("Watchman is not running (stale PID file cleaned)\n");
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
process.kill(pid, "SIGTERM");
|
|
235
|
+
} catch {
|
|
236
|
+
// Process may have just exited
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await removePidFile(pidFilePath);
|
|
240
|
+
|
|
241
|
+
if (json) {
|
|
242
|
+
process.stdout.write(`${JSON.stringify({ stopped: true, pid })}\n`);
|
|
243
|
+
} else {
|
|
244
|
+
process.stdout.write(`Watchman stopped (PID: ${pid})\n`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Handle `legio watchman status` */
|
|
249
|
+
async function handleStatus(args: string[]): Promise<void> {
|
|
250
|
+
const json = hasFlag(args, "--json");
|
|
251
|
+
const cwd = process.cwd();
|
|
252
|
+
const config = await loadConfig(cwd);
|
|
253
|
+
const pidFilePath = join(config.project.root, ".legio", "watchman.pid");
|
|
254
|
+
|
|
255
|
+
const pid = await readPidFile(pidFilePath);
|
|
256
|
+
|
|
257
|
+
if (pid === null) {
|
|
258
|
+
if (json) {
|
|
259
|
+
process.stdout.write(`${JSON.stringify({ running: false })}\n`);
|
|
260
|
+
} else {
|
|
261
|
+
process.stdout.write("Watchman: not running\n");
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const running = isProcessRunning(pid);
|
|
267
|
+
|
|
268
|
+
if (!running) {
|
|
269
|
+
await removePidFile(pidFilePath);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (json) {
|
|
273
|
+
process.stdout.write(`${JSON.stringify({ running, pid: running ? pid : null })}\n`);
|
|
274
|
+
} else if (running) {
|
|
275
|
+
process.stdout.write(`Watchman: running (PID: ${pid})\n`);
|
|
276
|
+
} else {
|
|
277
|
+
process.stdout.write("Watchman: not running (stale PID file cleaned)\n");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const WATCHMAN_HELP = `legio watchman — Unified daemon (health + mail + beacon)
|
|
282
|
+
|
|
283
|
+
Usage: legio watchman <subcommand> [options]
|
|
284
|
+
|
|
285
|
+
Subcommands:
|
|
286
|
+
start Start the watchman daemon
|
|
287
|
+
--background Daemonize (run in background)
|
|
288
|
+
--interval <ms> Health check interval in milliseconds (default: from config)
|
|
289
|
+
stop Stop the watchman daemon
|
|
290
|
+
--json JSON output
|
|
291
|
+
status Show watchman status
|
|
292
|
+
--json JSON output
|
|
293
|
+
|
|
294
|
+
Options:
|
|
295
|
+
--help, -h Show this help
|
|
296
|
+
|
|
297
|
+
The watchman daemon combines three capabilities:
|
|
298
|
+
1. Health monitoring: session health checks, zombie detection, auto-recovery
|
|
299
|
+
2. Mail delivery: polls for unread mail and nudges agents until they read it
|
|
300
|
+
3. Beacon safety net: detects stuck beacons and sends follow-up Enter`;
|
|
301
|
+
|
|
302
|
+
export async function watchmanCommand(args: string[]): Promise<void> {
|
|
303
|
+
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
|
304
|
+
process.stdout.write(`${WATCHMAN_HELP}\n`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const subcommand = args[0];
|
|
309
|
+
const subArgs = args.slice(1);
|
|
310
|
+
|
|
311
|
+
switch (subcommand) {
|
|
312
|
+
case "start":
|
|
313
|
+
await handleStart(subArgs);
|
|
314
|
+
break;
|
|
315
|
+
case "stop":
|
|
316
|
+
await handleStop(subArgs);
|
|
317
|
+
break;
|
|
318
|
+
case "status":
|
|
319
|
+
await handleStatus(subArgs);
|
|
320
|
+
break;
|
|
321
|
+
default:
|
|
322
|
+
process.stderr.write(
|
|
323
|
+
`Unknown watchman subcommand: ${subcommand ?? "(none)"}. Use: start, stop, status\n`,
|
|
324
|
+
);
|
|
325
|
+
process.exitCode = 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { existsSync, realpathSync } from "node:fs";
|
|
3
3
|
import { access, mkdir, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
6
|
import { createSessionStore } from "../sessions/store.ts";
|
|
7
7
|
import { cleanupTempDir, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
|
|
8
8
|
import type { AgentSession } from "../types.ts";
|
|
@@ -20,7 +20,6 @@ describe("worktreeCommand", () => {
|
|
|
20
20
|
let chunks: string[];
|
|
21
21
|
let originalWrite: typeof process.stdout.write;
|
|
22
22
|
let tempDir: string;
|
|
23
|
-
let originalCwd: string;
|
|
24
23
|
|
|
25
24
|
beforeEach(async () => {
|
|
26
25
|
// Spy on stdout
|
|
@@ -42,14 +41,11 @@ describe("worktreeCommand", () => {
|
|
|
42
41
|
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
43
42
|
);
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
originalCwd = process.cwd();
|
|
47
|
-
process.chdir(tempDir);
|
|
44
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
48
45
|
});
|
|
49
46
|
|
|
50
47
|
afterEach(async () => {
|
|
51
48
|
process.stdout.write = originalWrite;
|
|
52
|
-
process.chdir(originalCwd);
|
|
53
49
|
await cleanupTempDir(tempDir);
|
|
54
50
|
});
|
|
55
51
|
|
package/src/config.test.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { mkdir, mkdtemp,
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
5
|
-
import { DEFAULT_CONFIG, loadConfig
|
|
5
|
+
import { DEFAULT_CONFIG, loadConfig } from "./config.ts";
|
|
6
6
|
import { ValidationError } from "./errors.ts";
|
|
7
|
-
import { cleanupTempDir, createTempGitRepo, runGitInDir } from "./test-helpers.ts";
|
|
8
7
|
|
|
9
8
|
describe("loadConfig", () => {
|
|
10
9
|
let tempDir: string;
|
|
@@ -33,7 +32,8 @@ describe("loadConfig", () => {
|
|
|
33
32
|
expect(config.project.canonicalBranch).toBe("main");
|
|
34
33
|
expect(config.agents.maxConcurrent).toBe(25);
|
|
35
34
|
expect(config.agents.maxDepth).toBe(2);
|
|
36
|
-
expect(config.
|
|
35
|
+
expect(config.taskTracker.enabled).toBe(true);
|
|
36
|
+
expect(config.taskTracker.backend).toBe("auto");
|
|
37
37
|
expect(config.mulch.enabled).toBe(true);
|
|
38
38
|
expect(config.mulch.primeFormat).toBe("markdown");
|
|
39
39
|
expect(config.logging.verbose).toBe(false);
|
|
@@ -61,7 +61,7 @@ agents:
|
|
|
61
61
|
expect(config.agents.maxConcurrent).toBe(10);
|
|
62
62
|
// Non-overridden values keep defaults
|
|
63
63
|
expect(config.agents.maxDepth).toBe(2);
|
|
64
|
-
expect(config.
|
|
64
|
+
expect(config.taskTracker.enabled).toBe(true);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
test("always sets project.root to the actual projectRoot", async () => {
|
|
@@ -78,8 +78,9 @@ project:
|
|
|
78
78
|
test("parses boolean values correctly", async () => {
|
|
79
79
|
await ensureLegioDir();
|
|
80
80
|
await writeConfig(`
|
|
81
|
-
|
|
81
|
+
taskTracker:
|
|
82
82
|
enabled: false
|
|
83
|
+
backend: beads
|
|
83
84
|
mulch:
|
|
84
85
|
enabled: true
|
|
85
86
|
logging:
|
|
@@ -89,7 +90,8 @@ logging:
|
|
|
89
90
|
|
|
90
91
|
const config = await loadConfig(tempDir);
|
|
91
92
|
|
|
92
|
-
expect(config.
|
|
93
|
+
expect(config.taskTracker.enabled).toBe(false);
|
|
94
|
+
expect(config.taskTracker.backend).toBe("beads");
|
|
93
95
|
expect(config.mulch.enabled).toBe(true);
|
|
94
96
|
expect(config.logging.verbose).toBe(true);
|
|
95
97
|
expect(config.logging.redactSecrets).toBe(false);
|
|
@@ -118,7 +120,7 @@ watchdog:
|
|
|
118
120
|
|
|
119
121
|
const config = await loadConfig(tempDir);
|
|
120
122
|
expect(config.agents.staggerDelayMs).toBe(5000);
|
|
121
|
-
expect(config.
|
|
123
|
+
expect(config.watchman.tier0IntervalMs).toBe(60000);
|
|
122
124
|
});
|
|
123
125
|
|
|
124
126
|
test("handles quoted string values", async () => {
|
|
@@ -202,9 +204,9 @@ watchdog:
|
|
|
202
204
|
|
|
203
205
|
const config = await loadConfig(tempDir);
|
|
204
206
|
// Local override
|
|
205
|
-
expect(config.
|
|
207
|
+
expect(config.watchman.tier0Enabled).toBe(true);
|
|
206
208
|
// Non-overridden value from config.yaml preserved
|
|
207
|
-
expect(config.
|
|
209
|
+
expect(config.watchman.zombieThresholdMs).toBe(120000);
|
|
208
210
|
});
|
|
209
211
|
|
|
210
212
|
test("migrates deprecated watchdog tier1/tier2 keys to tier0/tier1", async () => {
|
|
@@ -218,10 +220,22 @@ watchdog:
|
|
|
218
220
|
|
|
219
221
|
const config = await loadConfig(tempDir);
|
|
220
222
|
// Old tier1 (mechanical daemon) → new tier0
|
|
221
|
-
expect(config.
|
|
222
|
-
expect(config.
|
|
223
|
+
expect(config.watchman.tier0Enabled).toBe(true);
|
|
224
|
+
expect(config.watchman.tier0IntervalMs).toBe(45000);
|
|
223
225
|
// Old tier2 (AI triage) → new tier1
|
|
224
|
-
expect(config.
|
|
226
|
+
expect(config.watchman.tier1Enabled).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("migrates deprecated 'beads' key to taskTracker", async () => {
|
|
230
|
+
await ensureLegioDir();
|
|
231
|
+
await writeConfig(`
|
|
232
|
+
beads:
|
|
233
|
+
enabled: false
|
|
234
|
+
`);
|
|
235
|
+
|
|
236
|
+
const config = await loadConfig(tempDir);
|
|
237
|
+
expect(config.taskTracker.enabled).toBe(false);
|
|
238
|
+
expect(config.taskTracker.backend).toBe("auto");
|
|
225
239
|
});
|
|
226
240
|
|
|
227
241
|
test("new-style tier keys take precedence over deprecated keys", async () => {
|
|
@@ -235,9 +249,9 @@ watchdog:
|
|
|
235
249
|
|
|
236
250
|
const config = await loadConfig(tempDir);
|
|
237
251
|
// New keys used directly — no migration needed
|
|
238
|
-
expect(config.
|
|
239
|
-
expect(config.
|
|
240
|
-
expect(config.
|
|
252
|
+
expect(config.watchman.tier0Enabled).toBe(false);
|
|
253
|
+
expect(config.watchman.tier0IntervalMs).toBe(20000);
|
|
254
|
+
expect(config.watchman.tier1Enabled).toBe(true);
|
|
241
255
|
});
|
|
242
256
|
});
|
|
243
257
|
|
|
@@ -336,99 +350,15 @@ models:
|
|
|
336
350
|
});
|
|
337
351
|
});
|
|
338
352
|
|
|
339
|
-
describe("resolveProjectRoot", () => {
|
|
340
|
-
let repoDir: string;
|
|
341
|
-
|
|
342
|
-
afterEach(async () => {
|
|
343
|
-
if (repoDir) {
|
|
344
|
-
// Remove worktrees before cleaning up
|
|
345
|
-
try {
|
|
346
|
-
await runGitInDir(repoDir, ["worktree", "prune"]);
|
|
347
|
-
} catch {
|
|
348
|
-
// Best effort
|
|
349
|
-
}
|
|
350
|
-
await cleanupTempDir(repoDir);
|
|
351
|
-
}
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
test("returns startDir when .legio/config.yaml exists there", async () => {
|
|
355
|
-
repoDir = await createTempGitRepo();
|
|
356
|
-
await mkdir(join(repoDir, ".legio"), { recursive: true });
|
|
357
|
-
await writeFile(join(repoDir, ".legio", "config.yaml"), "project:\n canonicalBranch: main\n");
|
|
358
|
-
|
|
359
|
-
const result = await resolveProjectRoot(repoDir);
|
|
360
|
-
expect(result).toBe(repoDir);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
test("resolves worktree to main project root", async () => {
|
|
364
|
-
repoDir = await createTempGitRepo();
|
|
365
|
-
// Resolve symlinks (macOS /var -> /private/var) to match git's output
|
|
366
|
-
repoDir = await realpath(repoDir);
|
|
367
|
-
await mkdir(join(repoDir, ".legio"), { recursive: true });
|
|
368
|
-
await writeFile(join(repoDir, ".legio", "config.yaml"), "project:\n canonicalBranch: main\n");
|
|
369
|
-
|
|
370
|
-
// Create a worktree like legio sling does
|
|
371
|
-
const worktreeDir = join(repoDir, ".legio", "worktrees", "test-agent");
|
|
372
|
-
await mkdir(join(repoDir, ".legio", "worktrees"), { recursive: true });
|
|
373
|
-
await runGitInDir(repoDir, ["worktree", "add", "-b", "legio/test-agent/task-1", worktreeDir]);
|
|
374
|
-
|
|
375
|
-
// resolveProjectRoot from the worktree should return the main repo
|
|
376
|
-
const result = await resolveProjectRoot(worktreeDir);
|
|
377
|
-
expect(result).toBe(repoDir);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
test("resolves worktree to main root even when config.yaml is committed (regression)", async () => {
|
|
381
|
-
repoDir = await createTempGitRepo();
|
|
382
|
-
repoDir = await realpath(repoDir);
|
|
383
|
-
|
|
384
|
-
// Commit .legio/config.yaml so the worktree gets a copy via git
|
|
385
|
-
// (this is what legio init does — the file is tracked)
|
|
386
|
-
await mkdir(join(repoDir, ".legio"), { recursive: true });
|
|
387
|
-
await writeFile(join(repoDir, ".legio", "config.yaml"), "project:\n canonicalBranch: main\n");
|
|
388
|
-
await runGitInDir(repoDir, ["add", ".legio/config.yaml"]);
|
|
389
|
-
await runGitInDir(repoDir, ["commit", "-m", "add legio config"]);
|
|
390
|
-
|
|
391
|
-
// Create a worktree — it will now have .legio/config.yaml from git
|
|
392
|
-
const worktreeDir = join(repoDir, ".legio", "worktrees", "mail-scout");
|
|
393
|
-
await mkdir(join(repoDir, ".legio", "worktrees"), { recursive: true });
|
|
394
|
-
await runGitInDir(repoDir, ["worktree", "add", "-b", "legio/mail-scout/task-1", worktreeDir]);
|
|
395
|
-
|
|
396
|
-
// Must resolve to main repo root, NOT the worktree
|
|
397
|
-
// (even though worktree has its own .legio/config.yaml)
|
|
398
|
-
const result = await resolveProjectRoot(worktreeDir);
|
|
399
|
-
expect(result).toBe(repoDir);
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
test("loadConfig resolves correct root from worktree", async () => {
|
|
403
|
-
repoDir = await createTempGitRepo();
|
|
404
|
-
// Resolve symlinks (macOS /var -> /private/var) to match git's output
|
|
405
|
-
repoDir = await realpath(repoDir);
|
|
406
|
-
await mkdir(join(repoDir, ".legio"), { recursive: true });
|
|
407
|
-
await writeFile(
|
|
408
|
-
join(repoDir, ".legio", "config.yaml"),
|
|
409
|
-
"project:\n canonicalBranch: develop\n",
|
|
410
|
-
);
|
|
411
|
-
|
|
412
|
-
const worktreeDir = join(repoDir, ".legio", "worktrees", "agent-2");
|
|
413
|
-
await mkdir(join(repoDir, ".legio", "worktrees"), { recursive: true });
|
|
414
|
-
await runGitInDir(repoDir, ["worktree", "add", "-b", "legio/agent-2/task-2", worktreeDir]);
|
|
415
|
-
|
|
416
|
-
// loadConfig from the worktree should resolve to the main project root
|
|
417
|
-
const config = await loadConfig(worktreeDir);
|
|
418
|
-
expect(config.project.root).toBe(repoDir);
|
|
419
|
-
expect(config.project.canonicalBranch).toBe("develop");
|
|
420
|
-
});
|
|
421
|
-
});
|
|
422
|
-
|
|
423
353
|
describe("DEFAULT_CONFIG", () => {
|
|
424
354
|
test("has all required top-level keys", () => {
|
|
425
355
|
expect(DEFAULT_CONFIG.project).toBeDefined();
|
|
426
356
|
expect(DEFAULT_CONFIG.agents).toBeDefined();
|
|
427
357
|
expect(DEFAULT_CONFIG.worktrees).toBeDefined();
|
|
428
|
-
expect(DEFAULT_CONFIG.
|
|
358
|
+
expect(DEFAULT_CONFIG.taskTracker).toBeDefined();
|
|
429
359
|
expect(DEFAULT_CONFIG.mulch).toBeDefined();
|
|
430
360
|
expect(DEFAULT_CONFIG.merge).toBeDefined();
|
|
431
|
-
expect(DEFAULT_CONFIG.
|
|
361
|
+
expect(DEFAULT_CONFIG.watchman).toBeDefined();
|
|
432
362
|
expect(DEFAULT_CONFIG.models).toBeDefined();
|
|
433
363
|
expect(DEFAULT_CONFIG.logging).toBeDefined();
|
|
434
364
|
});
|
|
@@ -438,8 +368,8 @@ describe("DEFAULT_CONFIG", () => {
|
|
|
438
368
|
expect(DEFAULT_CONFIG.agents.maxConcurrent).toBe(25);
|
|
439
369
|
expect(DEFAULT_CONFIG.agents.maxDepth).toBe(2);
|
|
440
370
|
expect(DEFAULT_CONFIG.agents.staggerDelayMs).toBe(2_000);
|
|
441
|
-
expect(DEFAULT_CONFIG.
|
|
442
|
-
expect(DEFAULT_CONFIG.
|
|
371
|
+
expect(DEFAULT_CONFIG.watchman.tier0IntervalMs).toBe(30_000);
|
|
372
|
+
expect(DEFAULT_CONFIG.watchman.zombieThresholdMs).toBe(600_000);
|
|
443
373
|
});
|
|
444
374
|
|
|
445
375
|
test("agents.maxAgentsPerLead defaults to 5", () => {
|