@os-eco/overstory-cli 0.9.1 → 0.9.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/README.md +20 -6
- package/agents/coordinator.md +30 -6
- package/agents/lead.md +11 -1
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +9 -1
- package/src/agents/hooks-deployer.ts +2 -1
- package/src/agents/overlay.test.ts +26 -0
- package/src/agents/overlay.ts +18 -4
- package/src/commands/agents.ts +1 -1
- package/src/commands/clean.test.ts +3 -0
- package/src/commands/clean.ts +1 -58
- package/src/commands/completions.test.ts +18 -6
- package/src/commands/completions.ts +40 -1
- package/src/commands/coordinator.test.ts +77 -4
- package/src/commands/coordinator.ts +226 -124
- package/src/commands/dashboard.ts +46 -9
- package/src/commands/doctor.ts +3 -1
- package/src/commands/ecosystem.test.ts +126 -1
- package/src/commands/ecosystem.ts +7 -53
- package/src/commands/feed.test.ts +117 -2
- package/src/commands/feed.ts +46 -30
- package/src/commands/group.test.ts +274 -155
- package/src/commands/group.ts +11 -5
- package/src/commands/init.ts +8 -0
- package/src/commands/log.test.ts +35 -0
- package/src/commands/log.ts +10 -6
- package/src/commands/logs.test.ts +423 -1
- package/src/commands/logs.ts +99 -104
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +177 -2
- package/src/commands/prime.ts +4 -2
- package/src/commands/sling.ts +3 -3
- package/src/commands/upgrade.test.ts +2 -0
- package/src/commands/upgrade.ts +1 -17
- package/src/commands/watch.test.ts +67 -1
- package/src/commands/watch.ts +4 -79
- package/src/config.test.ts +250 -0
- package/src/config.ts +43 -0
- package/src/doctor/agents.test.ts +72 -5
- package/src/doctor/agents.ts +10 -10
- package/src/doctor/consistency.test.ts +35 -0
- package/src/doctor/consistency.ts +7 -3
- package/src/doctor/dependencies.test.ts +58 -1
- package/src/doctor/dependencies.ts +4 -2
- package/src/doctor/providers.test.ts +41 -5
- package/src/doctor/types.ts +2 -1
- package/src/doctor/version.test.ts +106 -2
- package/src/doctor/version.ts +4 -2
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +158 -0
- package/src/e2e/init-sling-lifecycle.test.ts +2 -1
- package/src/errors.test.ts +350 -0
- package/src/events/tailer.test.ts +25 -0
- package/src/events/tailer.ts +8 -1
- package/src/index.ts +4 -1
- package/src/mail/store.test.ts +110 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +4 -2
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/pi-guards.ts +2 -1
- package/src/runtimes/pi.test.ts +9 -9
- package/src/runtimes/pi.ts +6 -7
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +13 -4
- package/src/runtimes/sapling.ts +2 -1
- package/src/runtimes/types.ts +2 -2
- package/src/types.ts +4 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +68 -0
- package/src/utils/pid.ts +45 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/watchdog/daemon.test.ts +255 -1
- package/src/watchdog/daemon.ts +46 -9
- package/src/watchdog/health.test.ts +15 -1
- package/src/watchdog/health.ts +1 -1
- package/src/watchdog/triage.test.ts +49 -9
- package/src/watchdog/triage.ts +21 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { OverstoryConfig } from "../types.ts";
|
|
3
|
-
import { checkDependencies } from "./dependencies.ts";
|
|
3
|
+
import { checkAlias, checkDependencies, checkTool } from "./dependencies.ts";
|
|
4
4
|
|
|
5
5
|
// Minimal config for testing
|
|
6
6
|
const mockConfig: OverstoryConfig = {
|
|
@@ -237,3 +237,60 @@ describe("checkDependencies", () => {
|
|
|
237
237
|
expect(ovCheck?.category).toBe("dependencies");
|
|
238
238
|
});
|
|
239
239
|
});
|
|
240
|
+
|
|
241
|
+
describe("checkTool", () => {
|
|
242
|
+
test("git with --version passes", async () => {
|
|
243
|
+
const check = await checkTool("git", "--version", true);
|
|
244
|
+
expect(check.name).toBe("git availability");
|
|
245
|
+
expect(check.category).toBe("dependencies");
|
|
246
|
+
expect(check.status).toBe("pass");
|
|
247
|
+
expect(check.message).toContain("git");
|
|
248
|
+
expect(check.details).toBeArray();
|
|
249
|
+
expect(check.details?.length).toBeGreaterThan(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("nonexistent tool with required: true returns fail", async () => {
|
|
253
|
+
const check = await checkTool("nonexistent-tool-xyz-999", "--version", true);
|
|
254
|
+
expect(check.status).toBe("fail");
|
|
255
|
+
expect(check.message).toContain("nonexistent-tool-xyz-999");
|
|
256
|
+
expect(check.fixable).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("nonexistent tool with required: false returns warn", async () => {
|
|
260
|
+
const check = await checkTool("nonexistent-tool-xyz-999", "--version", false);
|
|
261
|
+
expect(check.status).toBe("warn");
|
|
262
|
+
expect(check.fixable).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("installHint appears in details for missing tool", async () => {
|
|
266
|
+
const check = await checkTool("nonexistent-tool-xyz-999", "--version", true, "@test/fake-pkg");
|
|
267
|
+
expect(check.status).toBe("fail");
|
|
268
|
+
const detailsText = check.details?.join(" ") ?? "";
|
|
269
|
+
expect(detailsText).toContain("npm install -g @test/fake-pkg");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("checkAlias", () => {
|
|
274
|
+
test("real tool alias passes", async () => {
|
|
275
|
+
// git is universally available — use it as a "real alias"
|
|
276
|
+
const check = await checkAlias("git-tool", "git");
|
|
277
|
+
expect(check.name).toBe("git alias");
|
|
278
|
+
expect(check.category).toBe("dependencies");
|
|
279
|
+
expect(check.status).toBe("pass");
|
|
280
|
+
expect(check.message).toContain("git");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("nonexistent alias returns warn", async () => {
|
|
284
|
+
const check = await checkAlias("some-tool", "nonexistent-alias-xyz-999");
|
|
285
|
+
expect(check.status).toBe("warn");
|
|
286
|
+
expect(check.name).toBe("nonexistent-alias-xyz-999 alias");
|
|
287
|
+
expect(check.fixable).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("nonexistent alias with installHint includes hint in details", async () => {
|
|
291
|
+
const check = await checkAlias("some-tool", "nonexistent-alias-xyz-999", "@test/fake-pkg");
|
|
292
|
+
expect(check.status).toBe("warn");
|
|
293
|
+
const detailsText = check.details?.join(" ") ?? "";
|
|
294
|
+
expect(detailsText).toContain("@test/fake-pkg");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -157,8 +157,9 @@ async function checkBdCgoSupport(): Promise<DoctorCheck> {
|
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
159
|
* Check if a short alias for a CLI tool is available.
|
|
160
|
+
* @internal Exported for testing.
|
|
160
161
|
*/
|
|
161
|
-
async function checkAlias(
|
|
162
|
+
export async function checkAlias(
|
|
162
163
|
toolName: string,
|
|
163
164
|
alias: string,
|
|
164
165
|
installHint?: string,
|
|
@@ -208,8 +209,9 @@ async function checkAlias(
|
|
|
208
209
|
|
|
209
210
|
/**
|
|
210
211
|
* Check if a CLI tool is available by attempting to run it with a version flag.
|
|
212
|
+
* @internal Exported for testing.
|
|
211
213
|
*/
|
|
212
|
-
async function checkTool(
|
|
214
|
+
export async function checkTool(
|
|
213
215
|
name: string,
|
|
214
216
|
versionFlag: string,
|
|
215
217
|
required: boolean,
|
|
@@ -104,7 +104,7 @@ describe("checkProviders", () => {
|
|
|
104
104
|
const config = makeConfig({
|
|
105
105
|
providers: {
|
|
106
106
|
anthropic: { type: "native" },
|
|
107
|
-
openrouter: { type: "gateway", baseUrl: "
|
|
107
|
+
openrouter: { type: "gateway", baseUrl: "http://127.0.0.1:19873" },
|
|
108
108
|
},
|
|
109
109
|
});
|
|
110
110
|
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
@@ -241,11 +241,29 @@ describe("checkProviders", () => {
|
|
|
241
241
|
});
|
|
242
242
|
|
|
243
243
|
describe("tool-use-compat check", () => {
|
|
244
|
+
// Local HTTP server to avoid network calls to real openrouter.ai
|
|
245
|
+
let localServer: ReturnType<typeof Bun.serve>;
|
|
246
|
+
let localBaseUrl: string;
|
|
247
|
+
|
|
248
|
+
beforeAll(() => {
|
|
249
|
+
localServer = Bun.serve({
|
|
250
|
+
port: 0,
|
|
251
|
+
fetch() {
|
|
252
|
+
return new Response("ok");
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
localBaseUrl = `http://127.0.0.1:${localServer.port}`;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
afterAll(async () => {
|
|
259
|
+
await localServer.stop();
|
|
260
|
+
});
|
|
261
|
+
|
|
244
262
|
test("tool-heavy role with provider-prefixed model warns", async () => {
|
|
245
263
|
const config = makeConfig({
|
|
246
264
|
models: { builder: "openrouter/openai/gpt-4o" },
|
|
247
265
|
providers: {
|
|
248
|
-
openrouter: { type: "gateway", baseUrl:
|
|
266
|
+
openrouter: { type: "gateway", baseUrl: localBaseUrl },
|
|
249
267
|
},
|
|
250
268
|
});
|
|
251
269
|
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
@@ -260,7 +278,7 @@ describe("checkProviders", () => {
|
|
|
260
278
|
const config = makeConfig({
|
|
261
279
|
models: { lead: "openrouter/openai/gpt-4o" },
|
|
262
280
|
providers: {
|
|
263
|
-
openrouter: { type: "gateway", baseUrl:
|
|
281
|
+
openrouter: { type: "gateway", baseUrl: localBaseUrl },
|
|
264
282
|
},
|
|
265
283
|
});
|
|
266
284
|
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
@@ -287,7 +305,7 @@ describe("checkProviders", () => {
|
|
|
287
305
|
merger: "openrouter/openai/gpt-4o",
|
|
288
306
|
},
|
|
289
307
|
providers: {
|
|
290
|
-
openrouter: { type: "gateway", baseUrl:
|
|
308
|
+
openrouter: { type: "gateway", baseUrl: localBaseUrl },
|
|
291
309
|
},
|
|
292
310
|
});
|
|
293
311
|
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
@@ -298,6 +316,24 @@ describe("checkProviders", () => {
|
|
|
298
316
|
});
|
|
299
317
|
|
|
300
318
|
describe("model-provider-ref(s) check", () => {
|
|
319
|
+
// Local HTTP server to avoid network calls to real openrouter.ai
|
|
320
|
+
let localServer: ReturnType<typeof Bun.serve>;
|
|
321
|
+
let localBaseUrl: string;
|
|
322
|
+
|
|
323
|
+
beforeAll(() => {
|
|
324
|
+
localServer = Bun.serve({
|
|
325
|
+
port: 0,
|
|
326
|
+
fetch() {
|
|
327
|
+
return new Response("ok");
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
localBaseUrl = `http://127.0.0.1:${localServer.port}`;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
afterAll(async () => {
|
|
334
|
+
await localServer.stop();
|
|
335
|
+
});
|
|
336
|
+
|
|
301
337
|
test("model referencing unknown provider fails", async () => {
|
|
302
338
|
const config = makeConfig({
|
|
303
339
|
models: { builder: "unknownprovider/some-model" },
|
|
@@ -315,7 +351,7 @@ describe("checkProviders", () => {
|
|
|
315
351
|
const config = makeConfig({
|
|
316
352
|
models: { builder: "openrouter/openai/gpt-4o" },
|
|
317
353
|
providers: {
|
|
318
|
-
openrouter: { type: "gateway", baseUrl:
|
|
354
|
+
openrouter: { type: "gateway", baseUrl: localBaseUrl },
|
|
319
355
|
},
|
|
320
356
|
});
|
|
321
357
|
const checks = await checkProviders(config, OVERSTORY_DIR);
|
package/src/doctor/types.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
2
6
|
import type { OverstoryConfig } from "../types.ts";
|
|
3
|
-
import { checkVersion } from "./version.ts";
|
|
7
|
+
import { checkCurrentVersion, checkVersion, checkVersionSync } from "./version.ts";
|
|
4
8
|
|
|
5
9
|
// Minimal config for testing
|
|
6
10
|
const mockConfig: OverstoryConfig = {
|
|
@@ -135,3 +139,103 @@ describe("checkVersion", () => {
|
|
|
135
139
|
}
|
|
136
140
|
});
|
|
137
141
|
});
|
|
142
|
+
|
|
143
|
+
describe("checkCurrentVersion", () => {
|
|
144
|
+
test("passes against real repo root", async () => {
|
|
145
|
+
// Use the real overstory repo root (two levels up from src/doctor/)
|
|
146
|
+
const toolRoot = join(import.meta.dir, "..", "..");
|
|
147
|
+
const check = await checkCurrentVersion(toolRoot);
|
|
148
|
+
expect(check.name).toBe("version-current");
|
|
149
|
+
expect(check.category).toBe("version");
|
|
150
|
+
expect(check.status).toBe("pass");
|
|
151
|
+
expect(check.message).toMatch(/ov v\d+\.\d+\.\d+/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("fails for temp dir without version field", async () => {
|
|
155
|
+
const tempDir = await mkdtemp(join(tmpdir(), "version-test-"));
|
|
156
|
+
try {
|
|
157
|
+
// Write a package.json without a version field
|
|
158
|
+
await Bun.write(join(tempDir, "package.json"), JSON.stringify({ name: "test" }));
|
|
159
|
+
const check = await checkCurrentVersion(tempDir);
|
|
160
|
+
expect(check.name).toBe("version-current");
|
|
161
|
+
expect(check.status).toBe("fail");
|
|
162
|
+
expect(check.message).toContain("no version field");
|
|
163
|
+
} finally {
|
|
164
|
+
await cleanupTempDir(tempDir);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("fails for temp dir without package.json", async () => {
|
|
169
|
+
const tempDir = await mkdtemp(join(tmpdir(), "version-test-"));
|
|
170
|
+
try {
|
|
171
|
+
const check = await checkCurrentVersion(tempDir);
|
|
172
|
+
expect(check.name).toBe("version-current");
|
|
173
|
+
expect(check.status).toBe("fail");
|
|
174
|
+
} finally {
|
|
175
|
+
await cleanupTempDir(tempDir);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("checkVersionSync", () => {
|
|
181
|
+
let tempDir: string;
|
|
182
|
+
|
|
183
|
+
beforeEach(async () => {
|
|
184
|
+
tempDir = await mkdtemp(join(tmpdir(), "version-sync-test-"));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
afterEach(async () => {
|
|
188
|
+
await cleanupTempDir(tempDir);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("passes when versions match", async () => {
|
|
192
|
+
await Bun.write(
|
|
193
|
+
join(tempDir, "package.json"),
|
|
194
|
+
JSON.stringify({ name: "test", version: "1.2.3" }),
|
|
195
|
+
);
|
|
196
|
+
const { mkdir } = await import("node:fs/promises");
|
|
197
|
+
await mkdir(join(tempDir, "src"), { recursive: true });
|
|
198
|
+
await Bun.write(
|
|
199
|
+
join(tempDir, "src", "index.ts"),
|
|
200
|
+
'const VERSION = "1.2.3";\nexport { VERSION };\n',
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const check = await checkVersionSync(tempDir);
|
|
204
|
+
expect(check.name).toBe("package-json-sync");
|
|
205
|
+
expect(check.category).toBe("version");
|
|
206
|
+
expect(check.status).toBe("pass");
|
|
207
|
+
expect(check.message).toContain("synchronized");
|
|
208
|
+
expect(check.details?.join(" ")).toContain("1.2.3");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("warns when versions mismatch", async () => {
|
|
212
|
+
await Bun.write(
|
|
213
|
+
join(tempDir, "package.json"),
|
|
214
|
+
JSON.stringify({ name: "test", version: "1.0.0" }),
|
|
215
|
+
);
|
|
216
|
+
const { mkdir } = await import("node:fs/promises");
|
|
217
|
+
await mkdir(join(tempDir, "src"), { recursive: true });
|
|
218
|
+
await Bun.write(
|
|
219
|
+
join(tempDir, "src", "index.ts"),
|
|
220
|
+
'const VERSION = "2.0.0";\nexport { VERSION };\n',
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const check = await checkVersionSync(tempDir);
|
|
224
|
+
expect(check.name).toBe("package-json-sync");
|
|
225
|
+
expect(check.status).toBe("warn");
|
|
226
|
+
expect(check.message).toContain("mismatch");
|
|
227
|
+
expect(check.fixable).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("warns when src/index.ts is missing", async () => {
|
|
231
|
+
await Bun.write(
|
|
232
|
+
join(tempDir, "package.json"),
|
|
233
|
+
JSON.stringify({ name: "test", version: "1.0.0" }),
|
|
234
|
+
);
|
|
235
|
+
// No src/index.ts created
|
|
236
|
+
|
|
237
|
+
const check = await checkVersionSync(tempDir);
|
|
238
|
+
expect(check.name).toBe("package-json-sync");
|
|
239
|
+
expect(check.status).toBe("warn");
|
|
240
|
+
});
|
|
241
|
+
});
|
package/src/doctor/version.ts
CHANGED
|
@@ -28,8 +28,9 @@ export const checkVersion: DoctorCheckFn = async (
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Check that the current version can be determined from package.json.
|
|
31
|
+
* @internal Exported for testing.
|
|
31
32
|
*/
|
|
32
|
-
async function checkCurrentVersion(toolRoot: string): Promise<DoctorCheck> {
|
|
33
|
+
export async function checkCurrentVersion(toolRoot: string): Promise<DoctorCheck> {
|
|
33
34
|
try {
|
|
34
35
|
const packageJsonPath = join(toolRoot, "package.json");
|
|
35
36
|
const packageJson = (await Bun.file(packageJsonPath).json()) as { version?: string };
|
|
@@ -62,8 +63,9 @@ async function checkCurrentVersion(toolRoot: string): Promise<DoctorCheck> {
|
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
65
|
* Check that package.json version matches src/index.ts VERSION constant.
|
|
66
|
+
* @internal Exported for testing.
|
|
65
67
|
*/
|
|
66
|
-
async function checkVersionSync(toolRoot: string): Promise<DoctorCheck> {
|
|
68
|
+
export async function checkVersionSync(toolRoot: string): Promise<DoctorCheck> {
|
|
67
69
|
try {
|
|
68
70
|
// Read package.json version
|
|
69
71
|
const packageJsonPath = join(toolRoot, "package.json");
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { utimes } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { OverstoryConfig } from "../types.ts";
|
|
7
|
+
import { checkWatchdog } from "./watchdog.ts";
|
|
8
|
+
|
|
9
|
+
describe("checkWatchdog", () => {
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
let mockConfig: OverstoryConfig;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tempDir = mkdtempSync(join(tmpdir(), "overstory-watchdog-test-"));
|
|
15
|
+
mockConfig = {
|
|
16
|
+
project: { name: "test", root: tempDir, canonicalBranch: "main" },
|
|
17
|
+
agents: {
|
|
18
|
+
manifestPath: "",
|
|
19
|
+
baseDir: "",
|
|
20
|
+
maxConcurrent: 5,
|
|
21
|
+
staggerDelayMs: 100,
|
|
22
|
+
maxDepth: 2,
|
|
23
|
+
maxSessionsPerRun: 0,
|
|
24
|
+
maxAgentsPerLead: 5,
|
|
25
|
+
},
|
|
26
|
+
worktrees: { baseDir: "" },
|
|
27
|
+
taskTracker: { backend: "auto", enabled: true },
|
|
28
|
+
mulch: { enabled: true, domains: [], primeFormat: "markdown" },
|
|
29
|
+
merge: { aiResolveEnabled: false, reimagineEnabled: false },
|
|
30
|
+
providers: {
|
|
31
|
+
anthropic: { type: "native" },
|
|
32
|
+
},
|
|
33
|
+
watchdog: {
|
|
34
|
+
tier0Enabled: true,
|
|
35
|
+
tier0IntervalMs: 30000,
|
|
36
|
+
tier1Enabled: false,
|
|
37
|
+
tier2Enabled: false,
|
|
38
|
+
staleThresholdMs: 300000,
|
|
39
|
+
zombieThresholdMs: 600000,
|
|
40
|
+
nudgeIntervalMs: 60000,
|
|
41
|
+
},
|
|
42
|
+
models: {},
|
|
43
|
+
logging: { verbose: false, redactSecrets: true },
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("all checks skip when tier0Enabled is false — returns single pass check", async () => {
|
|
52
|
+
mockConfig.watchdog.tier0Enabled = false;
|
|
53
|
+
const checks = await checkWatchdog(mockConfig, tempDir);
|
|
54
|
+
|
|
55
|
+
expect(checks).toHaveLength(1);
|
|
56
|
+
expect(checks[0]?.status).toBe("pass");
|
|
57
|
+
expect(checks[0]?.message).toContain("disabled");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("PID file missing — returns warn about daemon not running", async () => {
|
|
61
|
+
const checks = await checkWatchdog(mockConfig, tempDir);
|
|
62
|
+
|
|
63
|
+
const pidCheck = checks.find((c) => c.name === "watchdog pid file");
|
|
64
|
+
expect(pidCheck).toBeDefined();
|
|
65
|
+
expect(pidCheck?.status).toBe("warn");
|
|
66
|
+
expect(pidCheck?.message).toContain("PID file not found");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("PID file corrupted — returns fail with fixable", async () => {
|
|
70
|
+
writeFileSync(join(tempDir, "watchdog.pid"), "not-a-pid");
|
|
71
|
+
const checks = await checkWatchdog(mockConfig, tempDir);
|
|
72
|
+
|
|
73
|
+
const integrityCheck = checks.find((c) => c.name === "watchdog pid integrity");
|
|
74
|
+
expect(integrityCheck).toBeDefined();
|
|
75
|
+
expect(integrityCheck?.status).toBe("fail");
|
|
76
|
+
expect(integrityCheck?.fixable).toBe(true);
|
|
77
|
+
expect(integrityCheck?.details?.some((d) => d.includes("not-a-pid"))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("PID file with valid PID but process not running — returns warn (stale PID)", async () => {
|
|
81
|
+
// PID 999999999 is extremely unlikely to exist
|
|
82
|
+
writeFileSync(join(tempDir, "watchdog.pid"), "999999999");
|
|
83
|
+
const checks = await checkWatchdog(mockConfig, tempDir);
|
|
84
|
+
|
|
85
|
+
const processCheck = checks.find((c) => c.name === "watchdog process");
|
|
86
|
+
expect(processCheck).toBeDefined();
|
|
87
|
+
expect(processCheck?.status).toBe("warn");
|
|
88
|
+
expect(processCheck?.message).toContain("stale PID file");
|
|
89
|
+
expect(processCheck?.fixable).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("PID file with current process PID — returns pass", async () => {
|
|
93
|
+
writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
|
|
94
|
+
const checks = await checkWatchdog(mockConfig, tempDir);
|
|
95
|
+
|
|
96
|
+
const processCheck = checks.find((c) => c.name === "watchdog process");
|
|
97
|
+
expect(processCheck).toBeDefined();
|
|
98
|
+
expect(processCheck?.status).toBe("pass");
|
|
99
|
+
expect(processCheck?.message).toContain("running");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("PID file older than 24 hours — returns staleness warn", async () => {
|
|
103
|
+
const pidFile = join(tempDir, "watchdog.pid");
|
|
104
|
+
writeFileSync(pidFile, String(process.pid));
|
|
105
|
+
|
|
106
|
+
// Set mtime 25 hours ago
|
|
107
|
+
const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000);
|
|
108
|
+
await utimes(pidFile, twentyFiveHoursAgo, twentyFiveHoursAgo);
|
|
109
|
+
|
|
110
|
+
const checks = await checkWatchdog(mockConfig, tempDir);
|
|
111
|
+
|
|
112
|
+
const stalenessCheck = checks.find((c) => c.name === "watchdog pid staleness");
|
|
113
|
+
expect(stalenessCheck).toBeDefined();
|
|
114
|
+
expect(stalenessCheck?.status).toBe("warn");
|
|
115
|
+
expect(stalenessCheck?.message).toContain("older than 24 hours");
|
|
116
|
+
expect(stalenessCheck?.details?.some((d) => d.includes("hours"))).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("Tier 2 monitor check skipped when tier2Enabled=false — no monitor check in results", async () => {
|
|
120
|
+
mockConfig.watchdog.tier2Enabled = false;
|
|
121
|
+
writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
|
|
122
|
+
const checks = await checkWatchdog(mockConfig, tempDir);
|
|
123
|
+
|
|
124
|
+
const monitorCheck = checks.find((c) => c.name === "tier2 monitor");
|
|
125
|
+
expect(monitorCheck).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("Tier 1 triage check skipped when tier1Enabled=false — no triage check in results", async () => {
|
|
129
|
+
mockConfig.watchdog.tier1Enabled = false;
|
|
130
|
+
writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
|
|
131
|
+
const checks = await checkWatchdog(mockConfig, tempDir);
|
|
132
|
+
|
|
133
|
+
const triageCheck = checks.find((c) => c.name === "tier1 triage");
|
|
134
|
+
expect(triageCheck).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("Tier 2 monitor check warns when no monitor session found", async () => {
|
|
138
|
+
mockConfig.watchdog.tier2Enabled = true;
|
|
139
|
+
writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
|
|
140
|
+
// No sessions.db or sessions.json — openSessionStore creates empty DB
|
|
141
|
+
const checks = await checkWatchdog(mockConfig, tempDir);
|
|
142
|
+
|
|
143
|
+
const monitorCheck = checks.find((c) => c.name === "tier2 monitor");
|
|
144
|
+
expect(monitorCheck).toBeDefined();
|
|
145
|
+
// Either warns about not running or store unavailable — both are acceptable
|
|
146
|
+
expect(monitorCheck?.status).toBe("warn");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("Tier 1 triage check does not crash when enabled", async () => {
|
|
150
|
+
mockConfig.watchdog.tier1Enabled = true;
|
|
151
|
+
writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
|
|
152
|
+
// getRuntime will succeed (defaults to "claude" which is always registered)
|
|
153
|
+
let checks: Awaited<ReturnType<typeof checkWatchdog>>;
|
|
154
|
+
try {
|
|
155
|
+
checks = await checkWatchdog(mockConfig, tempDir);
|
|
156
|
+
} catch {
|
|
157
|
+
// Should not throw
|
|
158
|
+
expect(false).toBe(true);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const triageCheck = checks.find((c) => c.name === "tier1 triage");
|
|
163
|
+
expect(triageCheck).toBeDefined();
|
|
164
|
+
// Either pass or warn — depending on environment; it should not throw
|
|
165
|
+
expect(triageCheck?.status === "pass" || triageCheck?.status === "warn").toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getRuntime } from "../runtimes/registry.ts";
|
|
5
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
6
|
+
import { isProcessRunning } from "../watchdog/health.ts";
|
|
7
|
+
import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Watchdog subsystem health checks.
|
|
11
|
+
* Validates PID file integrity, process liveness, and tier availability.
|
|
12
|
+
*/
|
|
13
|
+
export const checkWatchdog: DoctorCheckFn = async (
|
|
14
|
+
config,
|
|
15
|
+
overstoryDir,
|
|
16
|
+
): Promise<DoctorCheck[]> => {
|
|
17
|
+
const checks: DoctorCheck[] = [];
|
|
18
|
+
|
|
19
|
+
// If tier0 is disabled, skip all checks with a single pass result
|
|
20
|
+
if (!config.watchdog.tier0Enabled) {
|
|
21
|
+
checks.push({
|
|
22
|
+
name: "watchdog disabled",
|
|
23
|
+
category: "watchdog",
|
|
24
|
+
status: "pass",
|
|
25
|
+
message: "Watchdog daemon is disabled (tier0Enabled: false)",
|
|
26
|
+
});
|
|
27
|
+
return checks;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pidFilePath = join(overstoryDir, "watchdog.pid");
|
|
31
|
+
|
|
32
|
+
// Check 1: PID file exists and is readable
|
|
33
|
+
if (!existsSync(pidFilePath)) {
|
|
34
|
+
checks.push({
|
|
35
|
+
name: "watchdog pid file",
|
|
36
|
+
category: "watchdog",
|
|
37
|
+
status: "warn",
|
|
38
|
+
message: "Watchdog PID file not found — daemon may not be running",
|
|
39
|
+
});
|
|
40
|
+
} else {
|
|
41
|
+
// Check 2: PID file not corrupted
|
|
42
|
+
const pidText = await Bun.file(pidFilePath).text();
|
|
43
|
+
const pid = Number.parseInt(pidText.trim(), 10);
|
|
44
|
+
|
|
45
|
+
if (Number.isNaN(pid) || pid <= 0) {
|
|
46
|
+
checks.push({
|
|
47
|
+
name: "watchdog pid integrity",
|
|
48
|
+
category: "watchdog",
|
|
49
|
+
status: "fail",
|
|
50
|
+
message: "Watchdog PID file is corrupted",
|
|
51
|
+
details: [`Raw content: ${pidText.trim()}`],
|
|
52
|
+
fixable: true,
|
|
53
|
+
fix: async () => {
|
|
54
|
+
await unlink(pidFilePath);
|
|
55
|
+
return ["Removed corrupted watchdog PID file"];
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
} else {
|
|
59
|
+
// Check 3: PID alive via isProcessRunning()
|
|
60
|
+
const alive = isProcessRunning(pid);
|
|
61
|
+
if (!alive) {
|
|
62
|
+
checks.push({
|
|
63
|
+
name: "watchdog process",
|
|
64
|
+
category: "watchdog",
|
|
65
|
+
status: "warn",
|
|
66
|
+
message: "Watchdog process is not running (stale PID file)",
|
|
67
|
+
details: [`PID: ${pid}`],
|
|
68
|
+
fixable: true,
|
|
69
|
+
fix: async () => {
|
|
70
|
+
await unlink(pidFilePath);
|
|
71
|
+
return ["Removed stale watchdog PID file"];
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
checks.push({
|
|
76
|
+
name: "watchdog process",
|
|
77
|
+
category: "watchdog",
|
|
78
|
+
status: "pass",
|
|
79
|
+
message: "Watchdog daemon is running",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check 4: PID file staleness > 24h
|
|
84
|
+
const fileStat = await stat(pidFilePath);
|
|
85
|
+
const ageMs = Date.now() - fileStat.mtimeMs;
|
|
86
|
+
const twentyFourHoursMs = 24 * 60 * 60 * 1000;
|
|
87
|
+
if (ageMs > twentyFourHoursMs) {
|
|
88
|
+
const ageHours = Math.round(ageMs / (1000 * 60 * 60));
|
|
89
|
+
checks.push({
|
|
90
|
+
name: "watchdog pid staleness",
|
|
91
|
+
category: "watchdog",
|
|
92
|
+
status: "warn",
|
|
93
|
+
message: "Watchdog PID file is older than 24 hours",
|
|
94
|
+
details: [`File age: ${ageHours} hours`],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check 5: Tier 2 monitor running if tier2Enabled
|
|
101
|
+
if (config.watchdog.tier2Enabled) {
|
|
102
|
+
try {
|
|
103
|
+
const { store } = openSessionStore(overstoryDir);
|
|
104
|
+
try {
|
|
105
|
+
const sessions = store.getAll();
|
|
106
|
+
const monitorActive = sessions.some(
|
|
107
|
+
(s) => s.capability === "monitor" && s.state !== "completed" && s.state !== "zombie",
|
|
108
|
+
);
|
|
109
|
+
if (!monitorActive) {
|
|
110
|
+
checks.push({
|
|
111
|
+
name: "tier2 monitor",
|
|
112
|
+
category: "watchdog",
|
|
113
|
+
status: "warn",
|
|
114
|
+
message: "Tier 2 monitor is enabled but not running",
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
checks.push({
|
|
118
|
+
name: "tier2 monitor",
|
|
119
|
+
category: "watchdog",
|
|
120
|
+
status: "pass",
|
|
121
|
+
message: "Tier 2 monitor agent is active",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
store.close();
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
checks.push({
|
|
129
|
+
name: "tier2 monitor",
|
|
130
|
+
category: "watchdog",
|
|
131
|
+
status: "warn",
|
|
132
|
+
message: "Tier 2 monitor check skipped — session store unavailable",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check 6: Tier 1 triage available if tier1Enabled
|
|
138
|
+
if (config.watchdog.tier1Enabled) {
|
|
139
|
+
try {
|
|
140
|
+
getRuntime(config?.runtime?.printCommand ?? config?.runtime?.default, config);
|
|
141
|
+
checks.push({
|
|
142
|
+
name: "tier1 triage",
|
|
143
|
+
category: "watchdog",
|
|
144
|
+
status: "pass",
|
|
145
|
+
message: "Tier 1 triage runtime is available",
|
|
146
|
+
});
|
|
147
|
+
} catch {
|
|
148
|
+
checks.push({
|
|
149
|
+
name: "tier1 triage",
|
|
150
|
+
category: "watchdog",
|
|
151
|
+
status: "warn",
|
|
152
|
+
message: "Tier 1 triage is enabled but runtime is not available",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return checks;
|
|
158
|
+
};
|
|
@@ -124,7 +124,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
|
|
|
124
124
|
|
|
125
125
|
const manifest = await loader.load();
|
|
126
126
|
|
|
127
|
-
// All
|
|
127
|
+
// All 8 agents present (supervisor removed: deprecated, use lead instead)
|
|
128
128
|
const agentNames = Object.keys(manifest.agents).sort();
|
|
129
129
|
expect(agentNames).toEqual([
|
|
130
130
|
"builder",
|
|
@@ -132,6 +132,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
|
|
|
132
132
|
"lead",
|
|
133
133
|
"merger",
|
|
134
134
|
"monitor",
|
|
135
|
+
"orchestrator",
|
|
135
136
|
"reviewer",
|
|
136
137
|
"scout",
|
|
137
138
|
]);
|