@os-eco/overstory-cli 0.8.7 → 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 +26 -8
- package/agents/coordinator.md +30 -6
- package/agents/lead.md +11 -1
- package/agents/ov-co-creation.md +90 -0
- 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 +31 -4
- package/src/canopy/client.test.ts +107 -0
- package/src/canopy/client.ts +179 -0
- 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 +304 -146
- package/src/commands/dashboard.ts +47 -10
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- 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.test.ts +2 -1
- 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 +23 -3
- package/src/commands/update.test.ts +1 -0
- 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 +13 -88
- 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 +4 -2
- 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 +9 -1
- package/src/mail/store.test.ts +110 -0
- package/src/mail/store.ts +2 -1
- 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/schema-consistency.test.ts +1 -0
- package/src/sessions/store.ts +25 -4
- package/src/types.ts +65 -1
- 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 +87 -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
- package/templates/overlay.md.tmpl +2 -0
package/src/commands/watch.ts
CHANGED
|
@@ -9,17 +9,19 @@
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { Command } from "commander";
|
|
11
11
|
import { loadConfig } from "../config.ts";
|
|
12
|
-
import { OverstoryError } from "../errors.ts";
|
|
13
12
|
import { jsonOutput } from "../json.ts";
|
|
14
13
|
import { printError, printHint, printSuccess } from "../logging/color.ts";
|
|
15
14
|
import type { HealthCheck } from "../types.ts";
|
|
15
|
+
import { resolveOverstoryBin } from "../utils/bin.ts";
|
|
16
|
+
import { readPidFile, removePidFile, writePidFile } from "../utils/pid.ts";
|
|
16
17
|
import { startDaemon } from "../watchdog/daemon.ts";
|
|
17
18
|
import { isProcessRunning } from "../watchdog/health.ts";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Format a health check for display.
|
|
22
|
+
* @internal Exported for testing.
|
|
21
23
|
*/
|
|
22
|
-
function formatCheck(check: HealthCheck): string {
|
|
24
|
+
export function formatCheck(check: HealthCheck): string {
|
|
23
25
|
const actionIcon =
|
|
24
26
|
check.action === "terminate"
|
|
25
27
|
? "x"
|
|
@@ -36,83 +38,6 @@ function formatCheck(check: HealthCheck): string {
|
|
|
36
38
|
return line;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
// isProcessRunning is imported from ../watchdog/health.ts (ZFC shared utility)
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Read the PID from the watchdog PID file.
|
|
43
|
-
* Returns null if the file doesn't exist or can't be parsed.
|
|
44
|
-
*/
|
|
45
|
-
async function readPidFile(pidFilePath: string): Promise<number | null> {
|
|
46
|
-
const file = Bun.file(pidFilePath);
|
|
47
|
-
const exists = await file.exists();
|
|
48
|
-
if (!exists) {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const text = await file.text();
|
|
54
|
-
const pid = Number.parseInt(text.trim(), 10);
|
|
55
|
-
if (Number.isNaN(pid) || pid <= 0) {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
return pid;
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Write a PID to the watchdog PID file.
|
|
66
|
-
*/
|
|
67
|
-
async function writePidFile(pidFilePath: string, pid: number): Promise<void> {
|
|
68
|
-
await Bun.write(pidFilePath, `${pid}\n`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Remove the watchdog PID file.
|
|
73
|
-
*/
|
|
74
|
-
async function removePidFile(pidFilePath: string): Promise<void> {
|
|
75
|
-
const { unlink } = await import("node:fs/promises");
|
|
76
|
-
try {
|
|
77
|
-
await unlink(pidFilePath);
|
|
78
|
-
} catch {
|
|
79
|
-
// File may already be gone — not an error
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Resolve the path to the overstory binary for re-launching.
|
|
85
|
-
* Uses `which overstory` first, then falls back to process.argv.
|
|
86
|
-
*/
|
|
87
|
-
async function resolveOverstoryBin(): Promise<string> {
|
|
88
|
-
try {
|
|
89
|
-
const proc = Bun.spawn(["which", "ov"], {
|
|
90
|
-
stdout: "pipe",
|
|
91
|
-
stderr: "pipe",
|
|
92
|
-
});
|
|
93
|
-
const exitCode = await proc.exited;
|
|
94
|
-
if (exitCode === 0) {
|
|
95
|
-
const binPath = (await new Response(proc.stdout).text()).trim();
|
|
96
|
-
if (binPath.length > 0) {
|
|
97
|
-
return binPath;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
} catch {
|
|
101
|
-
// which not available or overstory not on PATH
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Fallback: use the script that's currently running (process.argv[1])
|
|
105
|
-
const scriptPath = process.argv[1];
|
|
106
|
-
if (scriptPath) {
|
|
107
|
-
return scriptPath;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
throw new OverstoryError(
|
|
111
|
-
"Cannot resolve overstory binary path for background launch",
|
|
112
|
-
"WATCH_ERROR",
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
41
|
/**
|
|
117
42
|
* Core implementation for the watch command.
|
|
118
43
|
*/
|
|
@@ -213,17 +138,17 @@ async function runWatch(opts: {
|
|
|
213
138
|
});
|
|
214
139
|
|
|
215
140
|
// Keep running until interrupted
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
141
|
+
await new Promise<void>((resolve) => {
|
|
142
|
+
process.on("SIGINT", () => {
|
|
143
|
+
stop();
|
|
144
|
+
// Clean up PID file on graceful shutdown
|
|
145
|
+
removePidFile(pidFilePath).finally(() => {
|
|
146
|
+
printSuccess("Watchdog stopped.");
|
|
147
|
+
process.exitCode = 0;
|
|
148
|
+
resolve();
|
|
149
|
+
});
|
|
222
150
|
});
|
|
223
151
|
});
|
|
224
|
-
|
|
225
|
-
// Block forever
|
|
226
|
-
await new Promise(() => {});
|
|
227
152
|
}
|
|
228
153
|
|
|
229
154
|
export function createWatchCommand(): Command {
|
package/src/config.test.ts
CHANGED
|
@@ -371,6 +371,7 @@ watchdog:
|
|
|
371
371
|
tier0Enabled: false
|
|
372
372
|
tier0IntervalMs: 20000
|
|
373
373
|
tier1Enabled: true
|
|
374
|
+
triageTimeoutMs: 15000
|
|
374
375
|
`);
|
|
375
376
|
|
|
376
377
|
const config = await loadConfig(tempDir);
|
|
@@ -568,6 +569,151 @@ watchdog:
|
|
|
568
569
|
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
569
570
|
});
|
|
570
571
|
|
|
572
|
+
// rpcTimeoutMs tests
|
|
573
|
+
test("defaults rpcTimeoutMs to 5000", async () => {
|
|
574
|
+
const config = await loadConfig(tempDir);
|
|
575
|
+
expect(config.watchdog.rpcTimeoutMs).toBe(5000);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("accepts valid rpcTimeoutMs", async () => {
|
|
579
|
+
await writeConfig(`
|
|
580
|
+
watchdog:
|
|
581
|
+
rpcTimeoutMs: 10000
|
|
582
|
+
`);
|
|
583
|
+
const config = await loadConfig(tempDir);
|
|
584
|
+
expect(config.watchdog.rpcTimeoutMs).toBe(10000);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("rejects rpcTimeoutMs below 1000", async () => {
|
|
588
|
+
await writeConfig(`
|
|
589
|
+
watchdog:
|
|
590
|
+
rpcTimeoutMs: 999
|
|
591
|
+
`);
|
|
592
|
+
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("rejects rpcTimeoutMs above 30000", async () => {
|
|
596
|
+
await writeConfig(`
|
|
597
|
+
watchdog:
|
|
598
|
+
rpcTimeoutMs: 30001
|
|
599
|
+
`);
|
|
600
|
+
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// triageTimeoutMs tests
|
|
604
|
+
test("defaults triageTimeoutMs to 30000", async () => {
|
|
605
|
+
const config = await loadConfig(tempDir);
|
|
606
|
+
expect(config.watchdog.triageTimeoutMs).toBe(30000);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("accepts valid triageTimeoutMs", async () => {
|
|
610
|
+
await writeConfig(`
|
|
611
|
+
watchdog:
|
|
612
|
+
triageTimeoutMs: 60000
|
|
613
|
+
`);
|
|
614
|
+
const config = await loadConfig(tempDir);
|
|
615
|
+
expect(config.watchdog.triageTimeoutMs).toBe(60000);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("rejects triageTimeoutMs below 5000", async () => {
|
|
619
|
+
await writeConfig(`
|
|
620
|
+
watchdog:
|
|
621
|
+
triageTimeoutMs: 4999
|
|
622
|
+
`);
|
|
623
|
+
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("rejects triageTimeoutMs above 120000", async () => {
|
|
627
|
+
await writeConfig(`
|
|
628
|
+
watchdog:
|
|
629
|
+
triageTimeoutMs: 120001
|
|
630
|
+
`);
|
|
631
|
+
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("rejects triageTimeoutMs >= tier0IntervalMs when tier1 is enabled", async () => {
|
|
635
|
+
// Must include tier0Enabled to avoid deprecated-key migration that would remap tier1Enabled
|
|
636
|
+
await writeConfig(`
|
|
637
|
+
watchdog:
|
|
638
|
+
tier0Enabled: true
|
|
639
|
+
tier1Enabled: true
|
|
640
|
+
tier0IntervalMs: 30000
|
|
641
|
+
triageTimeoutMs: 30000
|
|
642
|
+
`);
|
|
643
|
+
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("accepts triageTimeoutMs < tier0IntervalMs when tier1 is enabled", async () => {
|
|
647
|
+
await writeConfig(`
|
|
648
|
+
watchdog:
|
|
649
|
+
tier0Enabled: true
|
|
650
|
+
tier1Enabled: true
|
|
651
|
+
tier0IntervalMs: 60000
|
|
652
|
+
triageTimeoutMs: 30000
|
|
653
|
+
`);
|
|
654
|
+
const config = await loadConfig(tempDir);
|
|
655
|
+
expect(config.watchdog.triageTimeoutMs).toBe(30000);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("allows triageTimeoutMs >= tier0IntervalMs when tier1 is disabled", async () => {
|
|
659
|
+
await writeConfig(`
|
|
660
|
+
watchdog:
|
|
661
|
+
tier0Enabled: true
|
|
662
|
+
tier1Enabled: false
|
|
663
|
+
tier0IntervalMs: 30000
|
|
664
|
+
triageTimeoutMs: 30000
|
|
665
|
+
`);
|
|
666
|
+
const config = await loadConfig(tempDir);
|
|
667
|
+
expect(config.watchdog.triageTimeoutMs).toBe(30000);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// maxEscalationLevel tests
|
|
671
|
+
test("defaults maxEscalationLevel to 3", async () => {
|
|
672
|
+
const config = await loadConfig(tempDir);
|
|
673
|
+
expect(config.watchdog.maxEscalationLevel).toBe(3);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("accepts valid maxEscalationLevel", async () => {
|
|
677
|
+
await writeConfig(`
|
|
678
|
+
watchdog:
|
|
679
|
+
maxEscalationLevel: 5
|
|
680
|
+
`);
|
|
681
|
+
const config = await loadConfig(tempDir);
|
|
682
|
+
expect(config.watchdog.maxEscalationLevel).toBe(5);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("rejects maxEscalationLevel below 1", async () => {
|
|
686
|
+
await writeConfig(`
|
|
687
|
+
watchdog:
|
|
688
|
+
maxEscalationLevel: 0
|
|
689
|
+
`);
|
|
690
|
+
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("rejects maxEscalationLevel above 5", async () => {
|
|
694
|
+
await writeConfig(`
|
|
695
|
+
watchdog:
|
|
696
|
+
maxEscalationLevel: 6
|
|
697
|
+
`);
|
|
698
|
+
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("accepts maxEscalationLevel boundary values 1 and 5", async () => {
|
|
702
|
+
await writeConfig(`
|
|
703
|
+
watchdog:
|
|
704
|
+
maxEscalationLevel: 1
|
|
705
|
+
`);
|
|
706
|
+
let config = await loadConfig(tempDir);
|
|
707
|
+
expect(config.watchdog.maxEscalationLevel).toBe(1);
|
|
708
|
+
|
|
709
|
+
await writeConfig(`
|
|
710
|
+
watchdog:
|
|
711
|
+
maxEscalationLevel: 5
|
|
712
|
+
`);
|
|
713
|
+
config = await loadConfig(tempDir);
|
|
714
|
+
expect(config.watchdog.maxEscalationLevel).toBe(5);
|
|
715
|
+
});
|
|
716
|
+
|
|
571
717
|
test("accepts empty models section", async () => {
|
|
572
718
|
await writeConfig(`
|
|
573
719
|
models:
|
|
@@ -1153,6 +1299,110 @@ coordinator:
|
|
|
1153
1299
|
});
|
|
1154
1300
|
});
|
|
1155
1301
|
|
|
1302
|
+
describe("YAML parser edge cases", () => {
|
|
1303
|
+
let tempDir: string;
|
|
1304
|
+
|
|
1305
|
+
beforeEach(async () => {
|
|
1306
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-test-"));
|
|
1307
|
+
await mkdir(join(tempDir, ".overstory"), { recursive: true });
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
afterEach(async () => {
|
|
1311
|
+
await cleanupTempDir(tempDir);
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
async function writeConfig(yaml: string): Promise<void> {
|
|
1315
|
+
await Bun.write(join(tempDir, ".overstory", "config.yaml"), yaml);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
test("inline comments are stripped from values", async () => {
|
|
1319
|
+
await writeConfig(`
|
|
1320
|
+
project:
|
|
1321
|
+
canonicalBranch: develop # this is a comment
|
|
1322
|
+
`);
|
|
1323
|
+
const config = await loadConfig(tempDir);
|
|
1324
|
+
expect(config.project.canonicalBranch).toBe("develop");
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
test("quoted strings containing # are preserved (not treated as comments)", async () => {
|
|
1328
|
+
await writeConfig(`
|
|
1329
|
+
project:
|
|
1330
|
+
canonicalBranch: "feature#branch"
|
|
1331
|
+
`);
|
|
1332
|
+
const config = await loadConfig(tempDir);
|
|
1333
|
+
expect(config.project.canonicalBranch).toBe("feature#branch");
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
test("single-quoted strings containing # are preserved", async () => {
|
|
1337
|
+
await writeConfig(`
|
|
1338
|
+
project:
|
|
1339
|
+
canonicalBranch: 'feature#branch'
|
|
1340
|
+
`);
|
|
1341
|
+
const config = await loadConfig(tempDir);
|
|
1342
|
+
expect(config.project.canonicalBranch).toBe("feature#branch");
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
test("boolean coercion: true/True/TRUE all parse as true", async () => {
|
|
1346
|
+
// Test with three separate configs since they all map to the same field
|
|
1347
|
+
for (const val of ["true", "True", "TRUE"]) {
|
|
1348
|
+
await writeConfig(`mulch:\n enabled: ${val}\n`);
|
|
1349
|
+
const config = await loadConfig(tempDir);
|
|
1350
|
+
expect(config.mulch.enabled).toBe(true);
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
test("boolean coercion: false/False/FALSE all parse as false", async () => {
|
|
1355
|
+
for (const val of ["false", "False", "FALSE"]) {
|
|
1356
|
+
await writeConfig(`mulch:\n enabled: ${val}\n`);
|
|
1357
|
+
const config = await loadConfig(tempDir);
|
|
1358
|
+
expect(config.mulch.enabled).toBe(false);
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
test("yes/no are treated as plain strings, not booleans", async () => {
|
|
1363
|
+
// The YAML parser does NOT treat yes/no as booleans (unlike YAML 1.1)
|
|
1364
|
+
await writeConfig(`
|
|
1365
|
+
project:
|
|
1366
|
+
canonicalBranch: yes
|
|
1367
|
+
`);
|
|
1368
|
+
const config = await loadConfig(tempDir);
|
|
1369
|
+
// "yes" is a plain string, not coerced to boolean
|
|
1370
|
+
expect(config.project.canonicalBranch).toBe("yes");
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
test("integer number coercion", async () => {
|
|
1374
|
+
await writeConfig(`
|
|
1375
|
+
agents:
|
|
1376
|
+
maxConcurrent: 42
|
|
1377
|
+
`);
|
|
1378
|
+
const config = await loadConfig(tempDir);
|
|
1379
|
+
expect(config.agents.maxConcurrent).toBe(42);
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
test("float number coercion", async () => {
|
|
1383
|
+
// maxSessionsPerRun doesn't accept floats, but the parser itself parses them.
|
|
1384
|
+
// Use a field that passes validation as a number.
|
|
1385
|
+
await writeConfig(`
|
|
1386
|
+
agents:
|
|
1387
|
+
maxSessionsPerRun: 5
|
|
1388
|
+
staggerDelayMs: 1500
|
|
1389
|
+
`);
|
|
1390
|
+
const config = await loadConfig(tempDir);
|
|
1391
|
+
expect(config.agents.staggerDelayMs).toBe(1500);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
test("underscore-separated numbers are coerced correctly", async () => {
|
|
1395
|
+
await writeConfig(`
|
|
1396
|
+
watchdog:
|
|
1397
|
+
staleThresholdMs: 300_000
|
|
1398
|
+
zombieThresholdMs: 600_000
|
|
1399
|
+
`);
|
|
1400
|
+
const config = await loadConfig(tempDir);
|
|
1401
|
+
expect(config.watchdog.staleThresholdMs).toBe(300_000);
|
|
1402
|
+
expect(config.watchdog.zombieThresholdMs).toBe(600_000);
|
|
1403
|
+
});
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1156
1406
|
describe("DEFAULT_CONFIG", () => {
|
|
1157
1407
|
test("has all required top-level keys", () => {
|
|
1158
1408
|
expect(DEFAULT_CONFIG.project).toBeDefined();
|
package/src/config.ts
CHANGED
|
@@ -87,6 +87,9 @@ export const DEFAULT_CONFIG: OverstoryConfig = {
|
|
|
87
87
|
staleThresholdMs: 300_000, // 5 minutes
|
|
88
88
|
zombieThresholdMs: 600_000, // 10 minutes
|
|
89
89
|
nudgeIntervalMs: 60_000, // 1 minute between progressive nudge stages
|
|
90
|
+
rpcTimeoutMs: 5_000, // 5 seconds for RPC getState() calls
|
|
91
|
+
triageTimeoutMs: 30_000, // 30 seconds for Tier 1 AI triage calls
|
|
92
|
+
maxEscalationLevel: 3, // Maximum escalation level before termination
|
|
90
93
|
},
|
|
91
94
|
coordinator: {
|
|
92
95
|
exitTriggers: {
|
|
@@ -590,6 +593,46 @@ function validateConfig(config: OverstoryConfig): void {
|
|
|
590
593
|
});
|
|
591
594
|
}
|
|
592
595
|
|
|
596
|
+
if (config.watchdog.rpcTimeoutMs !== undefined) {
|
|
597
|
+
if (config.watchdog.rpcTimeoutMs < 1000 || config.watchdog.rpcTimeoutMs > 30000) {
|
|
598
|
+
throw new ValidationError("watchdog.rpcTimeoutMs must be between 1000 and 30000", {
|
|
599
|
+
field: "watchdog.rpcTimeoutMs",
|
|
600
|
+
value: config.watchdog.rpcTimeoutMs,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (config.watchdog.triageTimeoutMs !== undefined) {
|
|
606
|
+
if (config.watchdog.triageTimeoutMs < 5000 || config.watchdog.triageTimeoutMs > 120000) {
|
|
607
|
+
throw new ValidationError("watchdog.triageTimeoutMs must be between 5000 and 120000", {
|
|
608
|
+
field: "watchdog.triageTimeoutMs",
|
|
609
|
+
value: config.watchdog.triageTimeoutMs,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (
|
|
614
|
+
config.watchdog.tier1Enabled &&
|
|
615
|
+
config.watchdog.triageTimeoutMs >= config.watchdog.tier0IntervalMs
|
|
616
|
+
) {
|
|
617
|
+
throw new ValidationError(
|
|
618
|
+
"watchdog.triageTimeoutMs must be less than tier0IntervalMs when tier1 is enabled",
|
|
619
|
+
{
|
|
620
|
+
field: "watchdog.triageTimeoutMs",
|
|
621
|
+
value: config.watchdog.triageTimeoutMs,
|
|
622
|
+
},
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (config.watchdog.maxEscalationLevel !== undefined) {
|
|
628
|
+
if (config.watchdog.maxEscalationLevel < 1 || config.watchdog.maxEscalationLevel > 5) {
|
|
629
|
+
throw new ValidationError("watchdog.maxEscalationLevel must be between 1 and 5", {
|
|
630
|
+
field: "watchdog.maxEscalationLevel",
|
|
631
|
+
value: config.watchdog.maxEscalationLevel,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
593
636
|
// mulch.primeFormat must be one of the valid options
|
|
594
637
|
const validFormats = ["markdown", "xml", "json"] as const;
|
|
595
638
|
if (!validFormats.includes(config.mulch.primeFormat as (typeof validFormats)[number])) {
|
|
@@ -124,13 +124,41 @@ describe("checkAgents", () => {
|
|
|
124
124
|
expect(parseCheck?.status).toBe("pass");
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
test("
|
|
127
|
+
test("passes when agent uses a non-empty model string", async () => {
|
|
128
128
|
const manifest = {
|
|
129
129
|
version: "1.0",
|
|
130
130
|
agents: {
|
|
131
131
|
scout: {
|
|
132
132
|
file: "scout.md",
|
|
133
|
-
model: "
|
|
133
|
+
model: "gpt-5-4",
|
|
134
|
+
tools: ["Read"],
|
|
135
|
+
capabilities: ["explore"],
|
|
136
|
+
canSpawn: false,
|
|
137
|
+
constraints: [],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
capabilityIndex: {
|
|
141
|
+
explore: ["scout"],
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
await mkdir(join(overstoryDir, "agent-defs"), { recursive: true });
|
|
146
|
+
await Bun.write(join(overstoryDir, "agent-manifest.json"), JSON.stringify(manifest, null, 2));
|
|
147
|
+
await Bun.write(join(overstoryDir, "agent-defs", "scout.md"), "# Scout");
|
|
148
|
+
|
|
149
|
+
const checks = await checkAgents(mockConfig, overstoryDir);
|
|
150
|
+
|
|
151
|
+
const parseCheck = checks.find((c) => c.name === "Manifest parsing");
|
|
152
|
+
expect(parseCheck?.status).toBe("pass");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("fails when agent model is empty", async () => {
|
|
156
|
+
const manifest = {
|
|
157
|
+
version: "1.0",
|
|
158
|
+
agents: {
|
|
159
|
+
scout: {
|
|
160
|
+
file: "scout.md",
|
|
161
|
+
model: "",
|
|
134
162
|
tools: ["Read"],
|
|
135
163
|
capabilities: ["explore"],
|
|
136
164
|
canSpawn: false,
|
|
@@ -148,7 +176,9 @@ describe("checkAgents", () => {
|
|
|
148
176
|
|
|
149
177
|
const parseCheck = checks.find((c) => c.name === "Manifest parsing");
|
|
150
178
|
expect(parseCheck?.status).toBe("fail");
|
|
151
|
-
expect(parseCheck?.details?.some((d) => d.includes("model"))).toBe(
|
|
179
|
+
expect(parseCheck?.details?.some((d) => d.includes('"model" must be a non-empty string'))).toBe(
|
|
180
|
+
true,
|
|
181
|
+
);
|
|
152
182
|
});
|
|
153
183
|
|
|
154
184
|
test("fails when agent has zero capabilities", async () => {
|
|
@@ -378,7 +408,44 @@ sessionsCompleted: -5
|
|
|
378
408
|
expect(identityCheck?.details?.some((d) => d.includes("sessionsCompleted"))).toBe(true);
|
|
379
409
|
});
|
|
380
410
|
|
|
381
|
-
test("
|
|
411
|
+
test("does not warn for runtime-named identities when the recorded role exists", async () => {
|
|
412
|
+
const manifest = {
|
|
413
|
+
version: "1.0",
|
|
414
|
+
agents: {
|
|
415
|
+
scout: {
|
|
416
|
+
file: "scout.md",
|
|
417
|
+
model: "haiku",
|
|
418
|
+
tools: ["Read"],
|
|
419
|
+
capabilities: ["explore"],
|
|
420
|
+
canSpawn: false,
|
|
421
|
+
constraints: [],
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
capabilityIndex: {
|
|
425
|
+
explore: ["scout"],
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
await mkdir(join(overstoryDir, "agent-defs"), { recursive: true });
|
|
430
|
+
await mkdir(join(overstoryDir, "agents", "scout-task-123"), { recursive: true });
|
|
431
|
+
await Bun.write(join(overstoryDir, "agent-manifest.json"), JSON.stringify(manifest, null, 2));
|
|
432
|
+
await Bun.write(join(overstoryDir, "agent-defs", "scout.md"), "# Scout");
|
|
433
|
+
|
|
434
|
+
const identity = `name: scout-task-123
|
|
435
|
+
capability: scout
|
|
436
|
+
created: "2024-01-01T00:00:00Z"
|
|
437
|
+
sessionsCompleted: 5
|
|
438
|
+
`;
|
|
439
|
+
|
|
440
|
+
await Bun.write(join(overstoryDir, "agents", "scout-task-123", "identity.yaml"), identity);
|
|
441
|
+
|
|
442
|
+
const checks = await checkAgents(mockConfig, overstoryDir);
|
|
443
|
+
|
|
444
|
+
const staleCheck = checks.find((c) => c.name === "Stale identities");
|
|
445
|
+
expect(staleCheck).toBeUndefined();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("warns about stale identity files when the recorded role is missing", async () => {
|
|
382
449
|
const manifest = {
|
|
383
450
|
version: "1.0",
|
|
384
451
|
agents: {
|
|
@@ -413,7 +480,7 @@ sessionsCompleted: 5
|
|
|
413
480
|
|
|
414
481
|
const staleCheck = checks.find((c) => c.name === "Stale identities");
|
|
415
482
|
expect(staleCheck?.status).toBe("warn");
|
|
416
|
-
expect(staleCheck?.details?.some((d) => d.includes("
|
|
483
|
+
expect(staleCheck?.details?.some((d) => d.includes("obsolete"))).toBe(true);
|
|
417
484
|
});
|
|
418
485
|
|
|
419
486
|
test("warns when identity name contains invalid characters", async () => {
|
package/src/doctor/agents.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { join } from "node:path";
|
|
|
3
3
|
import type { AgentManifest } from "../types.ts";
|
|
4
4
|
import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
|
|
5
5
|
|
|
6
|
-
const VALID_MODELS = new Set(["sonnet", "opus", "haiku"]);
|
|
7
6
|
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
8
7
|
|
|
9
8
|
/**
|
|
@@ -64,8 +63,8 @@ async function loadAndValidateManifest(
|
|
|
64
63
|
errors.push(`Agent "${name}": "file" must be a non-empty string`);
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
if (typeof agentDef.model !== "string" ||
|
|
68
|
-
errors.push(`Agent "${name}": "model" must be
|
|
66
|
+
if (typeof agentDef.model !== "string" || agentDef.model.length === 0) {
|
|
67
|
+
errors.push(`Agent "${name}": "model" must be a non-empty string`);
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
if (!Array.isArray(agentDef.tools)) {
|
|
@@ -313,12 +312,6 @@ export const checkAgents: DoctorCheckFn = async (_config, overstoryDir): Promise
|
|
|
313
312
|
|
|
314
313
|
identityFileCount++;
|
|
315
314
|
|
|
316
|
-
// Check if agent still exists in manifest
|
|
317
|
-
if (!manifest.agents[agentName]) {
|
|
318
|
-
staleIdentities.push(agentName);
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
315
|
// Parse and validate identity
|
|
323
316
|
try {
|
|
324
317
|
const content = await Bun.file(identityPath).text();
|
|
@@ -346,6 +339,13 @@ export const checkAgents: DoctorCheckFn = async (_config, overstoryDir): Promise
|
|
|
346
339
|
identityErrors.push(`${agentName}: "sessionsCompleted" must be a non-negative integer`);
|
|
347
340
|
}
|
|
348
341
|
|
|
342
|
+
// Identity directories are keyed by runtime agent names (for example
|
|
343
|
+
// lead-foo-1234), not by manifest role names. Validate the recorded
|
|
344
|
+
// role/capability against the manifest instead of the directory name.
|
|
345
|
+
if (identity.capability && !manifest.agents[identity.capability]) {
|
|
346
|
+
staleIdentities.push(`${agentName} (capability: ${identity.capability})`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
349
|
// Validate name is valid identifier
|
|
350
350
|
if (identity.name && !VALID_NAME_REGEX.test(identity.name)) {
|
|
351
351
|
identityErrors.push(
|
|
@@ -384,7 +384,7 @@ export const checkAgents: DoctorCheckFn = async (_config, overstoryDir): Promise
|
|
|
384
384
|
category: "agents",
|
|
385
385
|
status: "warn",
|
|
386
386
|
message: `Found ${staleIdentities.length} stale identity file(s)`,
|
|
387
|
-
details: staleIdentities.map((name) => `${name} (
|
|
387
|
+
details: staleIdentities.map((name) => `${name} (role not present in manifest)`),
|
|
388
388
|
fixable: true,
|
|
389
389
|
});
|
|
390
390
|
}
|
|
@@ -375,6 +375,41 @@ describe("checkConsistency", () => {
|
|
|
375
375
|
expect(warnOrFail.length).toBe(0);
|
|
376
376
|
});
|
|
377
377
|
|
|
378
|
+
test("ignores completed sessions whose tmux, pid, and worktree are gone", async () => {
|
|
379
|
+
const dbPath = join(overstoryDir, "sessions.db");
|
|
380
|
+
const store = createSessionStore(dbPath);
|
|
381
|
+
|
|
382
|
+
store.upsert({
|
|
383
|
+
id: "session-1",
|
|
384
|
+
agentName: "completed-agent",
|
|
385
|
+
capability: "builder",
|
|
386
|
+
worktreePath: join(overstoryDir, "worktrees", "completed-agent"),
|
|
387
|
+
branchName: "overstory/completed-agent/test-123",
|
|
388
|
+
taskId: "test-123",
|
|
389
|
+
tmuxSession: "overstory-testproject-completed-agent",
|
|
390
|
+
state: "completed",
|
|
391
|
+
pid: 99999,
|
|
392
|
+
parentAgent: null,
|
|
393
|
+
depth: 0,
|
|
394
|
+
runId: null,
|
|
395
|
+
startedAt: new Date().toISOString(),
|
|
396
|
+
lastActivity: new Date().toISOString(),
|
|
397
|
+
escalationLevel: 0,
|
|
398
|
+
stalledSince: null,
|
|
399
|
+
transcriptPath: null,
|
|
400
|
+
});
|
|
401
|
+
store.close();
|
|
402
|
+
|
|
403
|
+
mockIsProcessAlive.mockReturnValue(false);
|
|
404
|
+
mockListSessions.mockResolvedValue([]);
|
|
405
|
+
|
|
406
|
+
const checks = await checkConsistency(config, overstoryDir, mockDeps);
|
|
407
|
+
|
|
408
|
+
expect(checks.find((c) => c.name === "dead-pids")?.status).toBe("pass");
|
|
409
|
+
expect(checks.find((c) => c.name === "missing-worktrees")?.status).toBe("pass");
|
|
410
|
+
expect(checks.find((c) => c.name === "missing-tmux")?.status).toBe("pass");
|
|
411
|
+
});
|
|
412
|
+
|
|
378
413
|
test("handles tmux not installed gracefully", async () => {
|
|
379
414
|
// Mock tmux listing to throw an error
|
|
380
415
|
mockListSessions.mockRejectedValue(new Error("tmux: command not found"));
|
|
@@ -89,6 +89,10 @@ export async function checkConsistency(
|
|
|
89
89
|
return checks;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Completed/zombie sessions are retained for history and metrics. Their tmux
|
|
93
|
+
// sessions, PIDs, and worktrees may legitimately be gone.
|
|
94
|
+
const liveSessions = storeSessions.filter((s) => s.state !== "completed" && s.state !== "zombie");
|
|
95
|
+
|
|
92
96
|
// Now perform cross-validation checks
|
|
93
97
|
|
|
94
98
|
// 4. Check for orphaned worktrees (worktree exists but no SessionStore entry)
|
|
@@ -155,7 +159,7 @@ export async function checkConsistency(
|
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
// 6. Check for dead processes in SessionStore
|
|
158
|
-
const deadSessions =
|
|
162
|
+
const deadSessions = liveSessions.filter((s) => s.pid !== null && !isProcessAliveFn(s.pid));
|
|
159
163
|
|
|
160
164
|
if (deadSessions.length > 0) {
|
|
161
165
|
checks.push({
|
|
@@ -177,7 +181,7 @@ export async function checkConsistency(
|
|
|
177
181
|
|
|
178
182
|
// 7. Check for SessionStore entries with missing worktrees
|
|
179
183
|
const existingWorktreePaths = new Set(worktrees.map((wt) => wt.path));
|
|
180
|
-
const missingWorktrees =
|
|
184
|
+
const missingWorktrees = liveSessions.filter((s) => {
|
|
181
185
|
// Try to normalize the SessionStore path for comparison
|
|
182
186
|
try {
|
|
183
187
|
const normalizedPath = realpathSync(s.worktreePath);
|
|
@@ -208,7 +212,7 @@ export async function checkConsistency(
|
|
|
208
212
|
|
|
209
213
|
// 8. Check for SessionStore entries with missing tmux sessions
|
|
210
214
|
const existingTmuxNames = new Set(tmuxSessions.map((s) => s.name));
|
|
211
|
-
const missingTmux =
|
|
215
|
+
const missingTmux = liveSessions.filter((s) => !existingTmuxNames.has(s.tmuxSession));
|
|
212
216
|
|
|
213
217
|
if (missingTmux.length > 0) {
|
|
214
218
|
checks.push({
|