clawspec 1.0.15 → 1.0.19
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 +16 -0
- package/README.zh-CN.md +16 -0
- package/package.json +2 -2
- package/src/acp/client.ts +17 -1
- package/src/control/keywords.ts +18 -2
- package/src/orchestrator/helpers.ts +1 -0
- package/src/orchestrator/service.ts +143 -33
- package/src/watchers/manager.ts +20 -6
- package/src/watchers/notifier.ts +1 -0
- package/src/worker/io-helper.ts +6 -5
- package/test/command-surface.test.ts +1 -0
- package/test/doctor.test.ts +142 -0
- package/test/helpers/harness.ts +6 -2
- package/test/recovery.test.ts +52 -25
- package/test/watcher-planning.test.ts +47 -18
- package/test/watcher-work.test.ts +83 -53
- package/test/worker-io-helper.test.ts +1 -1
- package/src/utils/debug-log.ts +0 -14
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
6
|
+
import { runDoctorCommand } from "../src/orchestrator/service.ts";
|
|
7
|
+
import { writeJsonFile, readJsonFile } from "../src/utils/fs.ts";
|
|
8
|
+
|
|
9
|
+
test("doctor reports no issues when config file does not exist", async () => {
|
|
10
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
11
|
+
const configPath = path.join(tempRoot, ".acpx", "config.json");
|
|
12
|
+
|
|
13
|
+
const result = await runDoctorCommand(configPath);
|
|
14
|
+
|
|
15
|
+
assert.equal(result.isError, undefined);
|
|
16
|
+
assert.match(result.text ?? "", /No issues found/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("doctor reports no issues when agents is empty", async () => {
|
|
20
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
21
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
22
|
+
await mkdir(configDir, { recursive: true });
|
|
23
|
+
const configPath = path.join(configDir, "config.json");
|
|
24
|
+
|
|
25
|
+
await writeJsonFile(configPath, {
|
|
26
|
+
defaultAgent: "codex",
|
|
27
|
+
agents: {},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = await runDoctorCommand(configPath);
|
|
31
|
+
|
|
32
|
+
assert.equal(result.isError, undefined);
|
|
33
|
+
assert.match(result.text ?? "", /No issues found/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("doctor detects custom agent entries", async () => {
|
|
37
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
38
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
39
|
+
await mkdir(configDir, { recursive: true });
|
|
40
|
+
const configPath = path.join(configDir, "config.json");
|
|
41
|
+
|
|
42
|
+
await writeJsonFile(configPath, {
|
|
43
|
+
defaultAgent: "codex",
|
|
44
|
+
agents: {
|
|
45
|
+
codex: { command: "/usr/local/bin/codex" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const result = await runDoctorCommand(configPath);
|
|
50
|
+
|
|
51
|
+
assert.equal(result.isError, undefined);
|
|
52
|
+
assert.match(result.text ?? "", /custom agent entries/);
|
|
53
|
+
assert.match(result.text ?? "", /`codex`/);
|
|
54
|
+
assert.match(result.text ?? "", /doctor fix/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("doctor reports no issues when config file is empty", async () => {
|
|
58
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
59
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
60
|
+
await mkdir(configDir, { recursive: true });
|
|
61
|
+
const configPath = path.join(configDir, "config.json");
|
|
62
|
+
|
|
63
|
+
const { writeUtf8 } = await import("../src/utils/fs.ts");
|
|
64
|
+
await writeUtf8(configPath, "");
|
|
65
|
+
|
|
66
|
+
const result = await runDoctorCommand(configPath);
|
|
67
|
+
|
|
68
|
+
assert.equal(result.isError, undefined);
|
|
69
|
+
assert.match(result.text ?? "", /No issues found/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("doctor detects invalid JSON", async () => {
|
|
73
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
74
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
75
|
+
await mkdir(configDir, { recursive: true });
|
|
76
|
+
const configPath = path.join(configDir, "config.json");
|
|
77
|
+
|
|
78
|
+
const { writeUtf8 } = await import("../src/utils/fs.ts");
|
|
79
|
+
await writeUtf8(configPath, '{ "agents": { broken }');
|
|
80
|
+
|
|
81
|
+
const result = await runDoctorCommand(configPath);
|
|
82
|
+
|
|
83
|
+
assert.equal(result.isError, undefined);
|
|
84
|
+
assert.match(result.text ?? "", /invalid JSON/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("doctor fix clears custom agent entries", async () => {
|
|
88
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
89
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
90
|
+
await mkdir(configDir, { recursive: true });
|
|
91
|
+
const configPath = path.join(configDir, "config.json");
|
|
92
|
+
|
|
93
|
+
await writeJsonFile(configPath, {
|
|
94
|
+
defaultAgent: "codex",
|
|
95
|
+
authPolicy: "skip",
|
|
96
|
+
agents: {
|
|
97
|
+
codex: { command: "/usr/local/bin/codex" },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = await runDoctorCommand(configPath, "fix");
|
|
102
|
+
|
|
103
|
+
assert.equal(result.isError, undefined);
|
|
104
|
+
assert.match(result.text ?? "", /Doctor Fix Applied/);
|
|
105
|
+
|
|
106
|
+
const updated = await readJsonFile<Record<string, unknown>>(configPath, {});
|
|
107
|
+
assert.deepEqual(updated.agents, {});
|
|
108
|
+
assert.equal(updated.defaultAgent, "codex");
|
|
109
|
+
assert.equal(updated.authPolicy, "skip");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("doctor fix reports nothing to fix when agents is already empty", async () => {
|
|
113
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
114
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
115
|
+
await mkdir(configDir, { recursive: true });
|
|
116
|
+
const configPath = path.join(configDir, "config.json");
|
|
117
|
+
|
|
118
|
+
await writeJsonFile(configPath, {
|
|
119
|
+
defaultAgent: "codex",
|
|
120
|
+
agents: {},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const result = await runDoctorCommand(configPath, "fix");
|
|
124
|
+
|
|
125
|
+
assert.equal(result.isError, undefined);
|
|
126
|
+
assert.match(result.text ?? "", /Nothing to fix/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("doctor fix reports error when config has invalid JSON", async () => {
|
|
130
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
131
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
132
|
+
await mkdir(configDir, { recursive: true });
|
|
133
|
+
const configPath = path.join(configDir, "config.json");
|
|
134
|
+
|
|
135
|
+
const { writeUtf8 } = await import("../src/utils/fs.ts");
|
|
136
|
+
await writeUtf8(configPath, '{ broken json');
|
|
137
|
+
|
|
138
|
+
const result = await runDoctorCommand(configPath, "fix");
|
|
139
|
+
|
|
140
|
+
assert.equal(result.isError, true);
|
|
141
|
+
assert.match(result.text ?? "", /Cannot auto-fix/);
|
|
142
|
+
});
|
package/test/helpers/harness.ts
CHANGED
|
@@ -55,13 +55,17 @@ export function createLogger() {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export async function waitFor(
|
|
58
|
+
export async function waitFor(
|
|
59
|
+
check: () => Promise<boolean>,
|
|
60
|
+
timeoutMs = 4_000,
|
|
61
|
+
pollIntervalMs = 250,
|
|
62
|
+
): Promise<void> {
|
|
59
63
|
const startedAt = Date.now();
|
|
60
64
|
while (Date.now() - startedAt < timeoutMs) {
|
|
61
65
|
if (await check()) {
|
|
62
66
|
return;
|
|
63
67
|
}
|
|
64
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
65
69
|
}
|
|
66
70
|
throw new Error("Timed out waiting for test condition.");
|
|
67
71
|
}
|
package/test/recovery.test.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import test from "node:test";
|
|
1
|
+
import test, { type TestContext } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
6
|
-
import { pathExists, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
6
|
+
import { pathExists, readJsonFile, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
7
7
|
import { getRepoStatePaths } from "../src/utils/paths.ts";
|
|
8
8
|
import { RollbackStore } from "../src/rollback/store.ts";
|
|
9
9
|
import { ProjectStateStore } from "../src/state/store.ts";
|
|
10
10
|
import { WatcherManager } from "../src/watchers/manager.ts";
|
|
11
11
|
import { createLogger, waitFor } from "./helpers/harness.ts";
|
|
12
12
|
|
|
13
|
+
const TEST_WATCHER_POLL_INTERVAL_MS = 250;
|
|
14
|
+
const TEST_WAIT_TIMEOUT_MS = 15_000;
|
|
15
|
+
|
|
13
16
|
function createWorkOpenSpec(changeDir: string, tasksPath: string) {
|
|
14
17
|
return {
|
|
15
18
|
instructionsApply: async (cwd: string, cn: string) => {
|
|
@@ -39,7 +42,25 @@ function createWorkOpenSpec(changeDir: string, tasksPath: string) {
|
|
|
39
42
|
} as any;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
function registerManagerCleanup(t: TestContext, manager: WatcherManager): () => Promise<void> {
|
|
46
|
+
let stopped = false;
|
|
47
|
+
const stop = async () => {
|
|
48
|
+
if (stopped) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
stopped = true;
|
|
52
|
+
await manager.stop();
|
|
53
|
+
};
|
|
54
|
+
t.after(stop);
|
|
55
|
+
return stop;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function waitForProjectStatus(repoPath: string, status: "done", timeoutMs = TEST_WAIT_TIMEOUT_MS): Promise<void> {
|
|
59
|
+
const stateFile = getRepoStatePaths(repoPath, "archives").stateFile;
|
|
60
|
+
await waitFor(async () => (await readJsonFile<any>(stateFile, null))?.status === status, timeoutMs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("recovery from orphaned state (armed, no execution field)", async (t) => {
|
|
43
64
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-orphan-"));
|
|
44
65
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
45
66
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -105,12 +126,13 @@ test("recovery from orphaned state (armed, no execution field)", async () => {
|
|
|
105
126
|
logger: createLogger(),
|
|
106
127
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
107
128
|
acpClient: fakeAcpClient as any,
|
|
108
|
-
pollIntervalMs:
|
|
129
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
109
130
|
});
|
|
131
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
110
132
|
|
|
111
133
|
await manager.start();
|
|
112
|
-
await
|
|
113
|
-
await
|
|
134
|
+
await waitForProjectStatus(repoPath, "done");
|
|
135
|
+
await stopManager();
|
|
114
136
|
|
|
115
137
|
const project = await stateStore.getActiveProject(channelKey);
|
|
116
138
|
assert.equal(project?.status, "done");
|
|
@@ -120,7 +142,7 @@ test("recovery from orphaned state (armed, no execution field)", async () => {
|
|
|
120
142
|
assert.equal(await pathExists(path.join(clawspecDir, "state.json.12345.999999.tmp")), false);
|
|
121
143
|
});
|
|
122
144
|
|
|
123
|
-
test("recovery from mid-crash running state", async () => {
|
|
145
|
+
test("recovery from mid-crash running state", async (t) => {
|
|
124
146
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-midcrash-"));
|
|
125
147
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
126
148
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -178,19 +200,20 @@ test("recovery from mid-crash running state", async () => {
|
|
|
178
200
|
logger: createLogger(),
|
|
179
201
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
180
202
|
acpClient: fakeAcpClient as any,
|
|
181
|
-
pollIntervalMs:
|
|
203
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
182
204
|
});
|
|
205
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
183
206
|
|
|
184
207
|
await manager.start();
|
|
185
|
-
await
|
|
186
|
-
await
|
|
208
|
+
await waitForProjectStatus(repoPath, "done");
|
|
209
|
+
await stopManager();
|
|
187
210
|
|
|
188
211
|
const project = await stateStore.getActiveProject(channelKey);
|
|
189
212
|
assert.equal(project?.status, "done");
|
|
190
213
|
assert.equal(notifierMessages.some((m) => m.includes("Gateway restarted")), true);
|
|
191
214
|
});
|
|
192
215
|
|
|
193
|
-
test("startup recovery adopts a live implementation session instead of spawning a new worker", async () => {
|
|
216
|
+
test("startup recovery adopts a live implementation session instead of spawning a new worker", async (t) => {
|
|
194
217
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-adopt-live-"));
|
|
195
218
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
196
219
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -274,8 +297,9 @@ test("startup recovery adopts a live implementation session instead of spawning
|
|
|
274
297
|
logger: createLogger(),
|
|
275
298
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
276
299
|
acpClient: fakeAcpClient as any,
|
|
277
|
-
pollIntervalMs:
|
|
300
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
278
301
|
});
|
|
302
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
279
303
|
|
|
280
304
|
setTimeout(() => {
|
|
281
305
|
void (async () => {
|
|
@@ -308,8 +332,8 @@ test("startup recovery adopts a live implementation session instead of spawning
|
|
|
308
332
|
}, 120);
|
|
309
333
|
|
|
310
334
|
await manager.start();
|
|
311
|
-
await
|
|
312
|
-
await
|
|
335
|
+
await waitForProjectStatus(repoPath, "done");
|
|
336
|
+
await stopManager();
|
|
313
337
|
|
|
314
338
|
const project = await stateStore.getActiveProject(channelKey);
|
|
315
339
|
assert.equal(project?.status, "done");
|
|
@@ -328,7 +352,7 @@ test("startup recovery adopts a live implementation session instead of spawning
|
|
|
328
352
|
assert.equal(notifierMessages.some((m) => m.includes("Watcher active. Starting codex worker")), false);
|
|
329
353
|
});
|
|
330
354
|
|
|
331
|
-
test("recovery from recoverable blocked implementation state", async () => {
|
|
355
|
+
test("recovery from recoverable blocked implementation state", async (t) => {
|
|
332
356
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-blocked-"));
|
|
333
357
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
334
358
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -421,12 +445,13 @@ test("recovery from recoverable blocked implementation state", async () => {
|
|
|
421
445
|
logger: createLogger(),
|
|
422
446
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
423
447
|
acpClient: fakeAcpClient as any,
|
|
424
|
-
pollIntervalMs:
|
|
448
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
425
449
|
});
|
|
450
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
426
451
|
|
|
427
452
|
await manager.start();
|
|
428
|
-
await
|
|
429
|
-
await
|
|
453
|
+
await waitForProjectStatus(repoPath, "done");
|
|
454
|
+
await stopManager();
|
|
430
455
|
|
|
431
456
|
const project = await stateStore.getActiveProject(channelKey);
|
|
432
457
|
assert.equal(project?.status, "done");
|
|
@@ -435,7 +460,7 @@ test("recovery from recoverable blocked implementation state", async () => {
|
|
|
435
460
|
assert.equal(notifierMessages.some((m) => m.includes("All tasks complete")), true);
|
|
436
461
|
});
|
|
437
462
|
|
|
438
|
-
test("rearm never drops execution field (batch mode)", async () => {
|
|
463
|
+
test("rearm never drops execution field (batch mode)", async (t) => {
|
|
439
464
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-rearm-safe-"));
|
|
440
465
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
441
466
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -497,13 +522,14 @@ test("rearm never drops execution field (batch mode)", async () => {
|
|
|
497
522
|
logger: createLogger(),
|
|
498
523
|
notifier: { send: async () => undefined } as any,
|
|
499
524
|
acpClient: fakeAcpClient as any,
|
|
500
|
-
pollIntervalMs:
|
|
525
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
501
526
|
});
|
|
527
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
502
528
|
|
|
503
529
|
await manager.start();
|
|
504
530
|
await manager.wake(channelKey);
|
|
505
|
-
await
|
|
506
|
-
await
|
|
531
|
+
await waitForProjectStatus(repoPath, "done");
|
|
532
|
+
await stopManager();
|
|
507
533
|
|
|
508
534
|
const project = await stateStore.getActiveProject(channelKey);
|
|
509
535
|
assert.equal(project?.status, "done");
|
|
@@ -511,7 +537,7 @@ test("rearm never drops execution field (batch mode)", async () => {
|
|
|
511
537
|
assert.equal(turnCount, 1);
|
|
512
538
|
});
|
|
513
539
|
|
|
514
|
-
test("startup recovery does not spawn a worker for visible chat planning", async () => {
|
|
540
|
+
test("startup recovery does not spawn a worker for visible chat planning", async (t) => {
|
|
515
541
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-visible-plan-"));
|
|
516
542
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
517
543
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -561,12 +587,13 @@ test("startup recovery does not spawn a worker for visible chat planning", async
|
|
|
561
587
|
logger: createLogger(),
|
|
562
588
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
563
589
|
acpClient: fakeAcpClient as any,
|
|
564
|
-
pollIntervalMs:
|
|
590
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
565
591
|
});
|
|
592
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
566
593
|
|
|
567
594
|
await manager.start();
|
|
568
595
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
569
|
-
await
|
|
596
|
+
await stopManager();
|
|
570
597
|
|
|
571
598
|
const project = await stateStore.getActiveProject(channelKey);
|
|
572
599
|
assert.equal(runCount, 0);
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import test from "node:test";
|
|
1
|
+
import test, { type TestContext } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
6
|
-
import { pathExists, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
6
|
+
import { pathExists, readJsonFile, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
7
7
|
import { PlanningJournalStore } from "../src/planning/journal.ts";
|
|
8
8
|
import { getRepoStatePaths } from "../src/utils/paths.ts";
|
|
9
9
|
import { ProjectStateStore } from "../src/state/store.ts";
|
|
10
10
|
import { WatcherManager } from "../src/watchers/manager.ts";
|
|
11
11
|
import { createLogger, waitFor } from "./helpers/harness.ts";
|
|
12
12
|
|
|
13
|
+
const TEST_WATCHER_POLL_INTERVAL_MS = 1_000;
|
|
14
|
+
const TEST_WAIT_TIMEOUT_MS = 35_000;
|
|
15
|
+
|
|
13
16
|
function createPlanningOpenSpec(changeDir: string, proposalPath: string) {
|
|
14
17
|
return {
|
|
15
18
|
status: async (cwd: string, changeName: string) => ({
|
|
@@ -71,7 +74,29 @@ function createPlanningOpenSpec(changeDir: string, proposalPath: string) {
|
|
|
71
74
|
} as any;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
function registerManagerCleanup(t: TestContext, manager: WatcherManager): () => Promise<void> {
|
|
78
|
+
let stopped = false;
|
|
79
|
+
const stop = async () => {
|
|
80
|
+
if (stopped) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
stopped = true;
|
|
84
|
+
await manager.stop();
|
|
85
|
+
};
|
|
86
|
+
t.after(stop);
|
|
87
|
+
return stop;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function waitForProjectStatus(
|
|
91
|
+
repoPath: string,
|
|
92
|
+
status: "ready" | "done" | "blocked" | "planning" | "armed" | "running",
|
|
93
|
+
timeoutMs = TEST_WAIT_TIMEOUT_MS,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const stateFile = getRepoStatePaths(repoPath, "archives").stateFile;
|
|
96
|
+
await waitFor(async () => (await readJsonFile<any>(stateFile, null))?.status === status, timeoutMs);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test("watcher planning flow completes", async (t) => {
|
|
75
100
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-"));
|
|
76
101
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
77
102
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -113,8 +138,9 @@ test("watcher planning flow completes", async () => {
|
|
|
113
138
|
logger: createLogger(),
|
|
114
139
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
115
140
|
acpClient: fakeAcpClient as any,
|
|
116
|
-
pollIntervalMs:
|
|
141
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
117
142
|
});
|
|
143
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
118
144
|
|
|
119
145
|
const channelKey = "discord:watch-plan:default:main";
|
|
120
146
|
await stateStore.createProject(channelKey);
|
|
@@ -143,8 +169,8 @@ test("watcher planning flow completes", async () => {
|
|
|
143
169
|
|
|
144
170
|
await manager.start();
|
|
145
171
|
await manager.wake(channelKey);
|
|
146
|
-
await
|
|
147
|
-
await
|
|
172
|
+
await waitForProjectStatus(repoPath, "ready");
|
|
173
|
+
await stopManager();
|
|
148
174
|
|
|
149
175
|
const project = await stateStore.getActiveProject(channelKey);
|
|
150
176
|
assert.equal(project?.status, "ready");
|
|
@@ -155,7 +181,7 @@ test("watcher planning flow completes", async () => {
|
|
|
155
181
|
assert.equal(notifierMessages.some((m) => m.includes("Planning ready") && m.includes("Next: run `cs-work` to start implementation")), true);
|
|
156
182
|
});
|
|
157
183
|
|
|
158
|
-
test("planning fallback normalizes artifact path", async () => {
|
|
184
|
+
test("planning fallback normalizes artifact path", async (t) => {
|
|
159
185
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-fallback-"));
|
|
160
186
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
161
187
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -215,8 +241,9 @@ test("planning fallback normalizes artifact path", async () => {
|
|
|
215
241
|
cancelSession: async () => undefined,
|
|
216
242
|
closeSession: async () => undefined,
|
|
217
243
|
} as any,
|
|
218
|
-
pollIntervalMs:
|
|
244
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
219
245
|
});
|
|
246
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
220
247
|
|
|
221
248
|
const channelKey = "discord:watch-plan-fallback:default:main";
|
|
222
249
|
await stateStore.createProject(channelKey);
|
|
@@ -230,14 +257,14 @@ test("planning fallback normalizes artifact path", async () => {
|
|
|
230
257
|
|
|
231
258
|
await manager.start();
|
|
232
259
|
await manager.wake(channelKey);
|
|
233
|
-
await
|
|
234
|
-
await
|
|
260
|
+
await waitForProjectStatus(repoPath, "ready");
|
|
261
|
+
await stopManager();
|
|
235
262
|
|
|
236
263
|
const project = await stateStore.getActiveProject(channelKey);
|
|
237
264
|
assert.deepEqual(project?.lastExecution?.changedFiles, ["openspec/changes/watch-plan-fallback/proposal.md"]);
|
|
238
265
|
});
|
|
239
266
|
|
|
240
|
-
test("watcher force-refreshes planning artifacts when journal is dirty and OpenSpec reports all artifacts done", async () => {
|
|
267
|
+
test("watcher force-refreshes planning artifacts when journal is dirty and OpenSpec reports all artifacts done", async (t) => {
|
|
241
268
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-forced-"));
|
|
242
269
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
243
270
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -358,8 +385,9 @@ test("watcher force-refreshes planning artifacts when journal is dirty and OpenS
|
|
|
358
385
|
logger: createLogger(),
|
|
359
386
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
360
387
|
acpClient: fakeAcpClient as any,
|
|
361
|
-
pollIntervalMs:
|
|
388
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
362
389
|
});
|
|
390
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
363
391
|
|
|
364
392
|
const channelKey = "discord:watch-plan-forced:default:main";
|
|
365
393
|
await stateStore.createProject(channelKey);
|
|
@@ -398,8 +426,8 @@ test("watcher force-refreshes planning artifacts when journal is dirty and OpenS
|
|
|
398
426
|
|
|
399
427
|
await manager.start();
|
|
400
428
|
await manager.wake(channelKey);
|
|
401
|
-
await
|
|
402
|
-
await
|
|
429
|
+
await waitForProjectStatus(repoPath, "ready");
|
|
430
|
+
await stopManager();
|
|
403
431
|
|
|
404
432
|
const project = await stateStore.getActiveProject(channelKey);
|
|
405
433
|
assert.deepEqual(runOrder, ["proposal", "specs", "design", "tasks"]);
|
|
@@ -412,7 +440,7 @@ test("watcher force-refreshes planning artifacts when journal is dirty and OpenS
|
|
|
412
440
|
);
|
|
413
441
|
});
|
|
414
442
|
|
|
415
|
-
test("watcher restarts planning worker after ACP runtime exit", async () => {
|
|
443
|
+
test("watcher restarts planning worker after ACP runtime exit", async (t) => {
|
|
416
444
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-restart-"));
|
|
417
445
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
418
446
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -460,8 +488,9 @@ test("watcher restarts planning worker after ACP runtime exit", async () => {
|
|
|
460
488
|
logger: createLogger(),
|
|
461
489
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
462
490
|
acpClient: fakeAcpClient as any,
|
|
463
|
-
pollIntervalMs:
|
|
491
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
464
492
|
});
|
|
493
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
465
494
|
|
|
466
495
|
const channelKey = "discord:watch-plan-restart:default:main";
|
|
467
496
|
await stateStore.createProject(channelKey);
|
|
@@ -490,8 +519,8 @@ test("watcher restarts planning worker after ACP runtime exit", async () => {
|
|
|
490
519
|
|
|
491
520
|
await manager.start();
|
|
492
521
|
await manager.wake(channelKey);
|
|
493
|
-
await
|
|
494
|
-
await
|
|
522
|
+
await waitForProjectStatus(repoPath, "ready");
|
|
523
|
+
await stopManager();
|
|
495
524
|
|
|
496
525
|
const project = await stateStore.getActiveProject(channelKey);
|
|
497
526
|
assert.equal(project?.status, "ready");
|