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
|
@@ -3,17 +3,32 @@ 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, describeWorkerStartupTimeout, shouldAbortWorkerStartup } 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 = 60_000;
|
|
15
|
+
|
|
13
16
|
function hasMessage(messages: string[], ...parts: string[]): boolean {
|
|
14
17
|
return messages.some((message) => parts.every((part) => message.includes(part)));
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
async function readProjectState(repoPath: string) {
|
|
21
|
+
return await readJsonFile<any>(getRepoStatePaths(repoPath, "archives").stateFile, null);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function waitForProjectState(
|
|
25
|
+
repoPath: string,
|
|
26
|
+
predicate: (project: any) => boolean,
|
|
27
|
+
timeoutMs = TEST_WAIT_TIMEOUT_MS,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await waitFor(async () => predicate(await readProjectState(repoPath)), timeoutMs);
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
test("queue owner unavailable is treated as a non-fatal startup state", () => {
|
|
18
33
|
const status = {
|
|
19
34
|
summary: "status=dead acpxRecordId=session-1",
|
|
@@ -129,7 +144,7 @@ test("watcher work flow completes", async (t) => {
|
|
|
129
144
|
logger: createLogger(),
|
|
130
145
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
131
146
|
acpClient: fakeAcpClient as any,
|
|
132
|
-
pollIntervalMs:
|
|
147
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
133
148
|
});
|
|
134
149
|
t.after(async () => {
|
|
135
150
|
await manager.stop();
|
|
@@ -166,9 +181,11 @@ test("watcher work flow completes", async (t) => {
|
|
|
166
181
|
|
|
167
182
|
await manager.start();
|
|
168
183
|
await manager.wake(channelKey);
|
|
169
|
-
await
|
|
170
|
-
|
|
171
|
-
|
|
184
|
+
await waitForProjectState(
|
|
185
|
+
repoPath,
|
|
186
|
+
(project) =>
|
|
187
|
+
project?.status === "done"
|
|
188
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work", "All tasks complete", "/clawspec archive"),
|
|
172
189
|
);
|
|
173
190
|
|
|
174
191
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -325,7 +342,7 @@ test("worker progress events keep running state in sync before execution finishe
|
|
|
325
342
|
logger: createLogger(),
|
|
326
343
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
327
344
|
acpClient: fakeAcpClient as any,
|
|
328
|
-
pollIntervalMs:
|
|
345
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
329
346
|
});
|
|
330
347
|
t.after(async () => {
|
|
331
348
|
await manager.stop();
|
|
@@ -362,20 +379,17 @@ test("worker progress events keep running state in sync before execution finishe
|
|
|
362
379
|
|
|
363
380
|
await manager.start();
|
|
364
381
|
await manager.wake(channelKey);
|
|
365
|
-
await
|
|
366
|
-
|
|
367
|
-
return project?.status === "running"
|
|
382
|
+
await waitForProjectState(repoPath, (project) =>
|
|
383
|
+
project?.status === "running"
|
|
368
384
|
&& project.taskCounts?.complete === 1
|
|
369
385
|
&& project.taskCounts?.remaining === 1
|
|
370
386
|
&& project.currentTask === "1.2 Add multipart parsing"
|
|
371
387
|
&& project.latestSummary?.includes("Start 1.2") === true
|
|
372
|
-
&& project.execution?.currentTaskId === "1.2"
|
|
373
|
-
|
|
388
|
+
&& project.execution?.currentTaskId === "1.2",
|
|
389
|
+
);
|
|
374
390
|
|
|
375
391
|
releaseFinalStep();
|
|
376
|
-
await
|
|
377
|
-
(await stateStore.getActiveProject(channelKey))?.status === "done",
|
|
378
|
-
);
|
|
392
|
+
await waitForProjectState(repoPath, (project) => project?.status === "done");
|
|
379
393
|
|
|
380
394
|
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-progress-sync", "1/2", "Done 1.1"), true);
|
|
381
395
|
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-progress-sync", "2/2", "Start 1.2"), true);
|
|
@@ -483,7 +497,7 @@ test("watcher restarts implementation worker after ACP runtime exit", async (t)
|
|
|
483
497
|
logger: createLogger(),
|
|
484
498
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
485
499
|
acpClient: fakeAcpClient as any,
|
|
486
|
-
pollIntervalMs:
|
|
500
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
487
501
|
});
|
|
488
502
|
t.after(async () => {
|
|
489
503
|
await manager.stop();
|
|
@@ -520,10 +534,11 @@ test("watcher restarts implementation worker after ACP runtime exit", async (t)
|
|
|
520
534
|
|
|
521
535
|
await manager.start();
|
|
522
536
|
await manager.wake(channelKey);
|
|
523
|
-
await
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
537
|
+
await waitForProjectState(
|
|
538
|
+
repoPath,
|
|
539
|
+
(project) =>
|
|
540
|
+
project?.status === "done"
|
|
541
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-restart", "All tasks complete", "/clawspec archive"),
|
|
527
542
|
);
|
|
528
543
|
|
|
529
544
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -651,7 +666,7 @@ test("watcher restarts a dead ACP session after progress stalls", async (t) => {
|
|
|
651
666
|
logger: createLogger(),
|
|
652
667
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
653
668
|
acpClient: fakeAcpClient as any,
|
|
654
|
-
pollIntervalMs:
|
|
669
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
655
670
|
});
|
|
656
671
|
t.after(async () => {
|
|
657
672
|
await manager.stop();
|
|
@@ -688,9 +703,11 @@ test("watcher restarts a dead ACP session after progress stalls", async (t) => {
|
|
|
688
703
|
|
|
689
704
|
await manager.start();
|
|
690
705
|
await manager.wake(channelKey);
|
|
691
|
-
await
|
|
692
|
-
|
|
693
|
-
|
|
706
|
+
await waitForProjectState(
|
|
707
|
+
repoPath,
|
|
708
|
+
(project) =>
|
|
709
|
+
project?.status === "done"
|
|
710
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-dead-session", "All tasks complete", "/clawspec archive"),
|
|
694
711
|
);
|
|
695
712
|
|
|
696
713
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -798,7 +815,7 @@ test("watcher restarts a dead ACP session that dies before first progress", asyn
|
|
|
798
815
|
logger: createLogger(),
|
|
799
816
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
800
817
|
acpClient: fakeAcpClient as any,
|
|
801
|
-
pollIntervalMs:
|
|
818
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
802
819
|
});
|
|
803
820
|
t.after(async () => {
|
|
804
821
|
await manager.stop();
|
|
@@ -835,9 +852,11 @@ test("watcher restarts a dead ACP session that dies before first progress", asyn
|
|
|
835
852
|
|
|
836
853
|
await manager.start();
|
|
837
854
|
await manager.wake(channelKey);
|
|
838
|
-
await
|
|
839
|
-
|
|
840
|
-
|
|
855
|
+
await waitForProjectState(
|
|
856
|
+
repoPath,
|
|
857
|
+
(project) =>
|
|
858
|
+
project?.status === "done"
|
|
859
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-dead-startup", "All tasks complete", "/clawspec archive"),
|
|
841
860
|
);
|
|
842
861
|
|
|
843
862
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -958,7 +977,7 @@ test("status-only ACP heartbeats do not keep a dead session alive", async (t) =>
|
|
|
958
977
|
logger: createLogger(),
|
|
959
978
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
960
979
|
acpClient: fakeAcpClient as any,
|
|
961
|
-
pollIntervalMs:
|
|
980
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
962
981
|
});
|
|
963
982
|
t.after(async () => {
|
|
964
983
|
await manager.stop();
|
|
@@ -995,9 +1014,11 @@ test("status-only ACP heartbeats do not keep a dead session alive", async (t) =>
|
|
|
995
1014
|
|
|
996
1015
|
await manager.start();
|
|
997
1016
|
await manager.wake(channelKey);
|
|
998
|
-
await
|
|
999
|
-
|
|
1000
|
-
|
|
1017
|
+
await waitForProjectState(
|
|
1018
|
+
repoPath,
|
|
1019
|
+
(project) =>
|
|
1020
|
+
project?.status === "done"
|
|
1021
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-status-heartbeats", "All tasks complete"),
|
|
1001
1022
|
);
|
|
1002
1023
|
|
|
1003
1024
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1124,7 +1145,7 @@ test("dead ACP session that ignores abort is restarted without hanging the watch
|
|
|
1124
1145
|
logger: createLogger(),
|
|
1125
1146
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1126
1147
|
acpClient: fakeAcpClient as any,
|
|
1127
|
-
pollIntervalMs:
|
|
1148
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1128
1149
|
});
|
|
1129
1150
|
t.after(async () => {
|
|
1130
1151
|
if (heartbeatTimer) {
|
|
@@ -1165,9 +1186,11 @@ test("dead ACP session that ignores abort is restarted without hanging the watch
|
|
|
1165
1186
|
|
|
1166
1187
|
await manager.start();
|
|
1167
1188
|
await manager.wake(channelKey);
|
|
1168
|
-
await
|
|
1169
|
-
|
|
1170
|
-
|
|
1189
|
+
await waitForProjectState(
|
|
1190
|
+
repoPath,
|
|
1191
|
+
(project) =>
|
|
1192
|
+
project?.status === "done"
|
|
1193
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-hung-dead-session", "All tasks complete"),
|
|
1171
1194
|
);
|
|
1172
1195
|
|
|
1173
1196
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1205,7 +1228,7 @@ test("manager stop closes active worker sessions and rearms project recovery sta
|
|
|
1205
1228
|
closedSessions.push({ sessionKey, reason });
|
|
1206
1229
|
},
|
|
1207
1230
|
} as any,
|
|
1208
|
-
pollIntervalMs:
|
|
1231
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1209
1232
|
});
|
|
1210
1233
|
|
|
1211
1234
|
const channelKey = "discord:watch-stop:default:main";
|
|
@@ -1309,7 +1332,7 @@ test("watcher stops retrying after 10 ACP restart attempts", async (t) => {
|
|
|
1309
1332
|
logger: createLogger(),
|
|
1310
1333
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1311
1334
|
acpClient: fakeAcpClient as any,
|
|
1312
|
-
pollIntervalMs:
|
|
1335
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1313
1336
|
});
|
|
1314
1337
|
t.after(async () => {
|
|
1315
1338
|
await manager.stop();
|
|
@@ -1347,9 +1370,11 @@ test("watcher stops retrying after 10 ACP restart attempts", async (t) => {
|
|
|
1347
1370
|
}));
|
|
1348
1371
|
|
|
1349
1372
|
await manager.wake(channelKey);
|
|
1350
|
-
await
|
|
1351
|
-
|
|
1352
|
-
|
|
1373
|
+
await waitForProjectState(
|
|
1374
|
+
repoPath,
|
|
1375
|
+
(project) =>
|
|
1376
|
+
project?.status === "blocked"
|
|
1377
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-restart-cap", "Blocked after 10 ACP restart attempts"),
|
|
1353
1378
|
);
|
|
1354
1379
|
|
|
1355
1380
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1415,7 +1440,7 @@ test("watcher blocked message includes ACPX setup guidance when backend stays un
|
|
|
1415
1440
|
logger: createLogger(),
|
|
1416
1441
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1417
1442
|
acpClient: fakeAcpClient as any,
|
|
1418
|
-
pollIntervalMs:
|
|
1443
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1419
1444
|
});
|
|
1420
1445
|
t.after(async () => {
|
|
1421
1446
|
await manager.stop();
|
|
@@ -1453,9 +1478,11 @@ test("watcher blocked message includes ACPX setup guidance when backend stays un
|
|
|
1453
1478
|
}));
|
|
1454
1479
|
|
|
1455
1480
|
await manager.wake(channelKey);
|
|
1456
|
-
await
|
|
1457
|
-
|
|
1458
|
-
|
|
1481
|
+
await waitForProjectState(
|
|
1482
|
+
repoPath,
|
|
1483
|
+
(project) =>
|
|
1484
|
+
project?.status === "blocked"
|
|
1485
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "Blocked: ACPX backend unavailable"),
|
|
1459
1486
|
);
|
|
1460
1487
|
|
|
1461
1488
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1545,7 +1572,7 @@ test("watcher retries when ACP runtime backend is temporarily unavailable", asyn
|
|
|
1545
1572
|
logger: createLogger(),
|
|
1546
1573
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1547
1574
|
acpClient: fakeAcpClient as any,
|
|
1548
|
-
pollIntervalMs:
|
|
1575
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1549
1576
|
});
|
|
1550
1577
|
t.after(async () => {
|
|
1551
1578
|
await manager.stop();
|
|
@@ -1581,9 +1608,11 @@ test("watcher retries when ACP runtime backend is temporarily unavailable", asyn
|
|
|
1581
1608
|
}));
|
|
1582
1609
|
|
|
1583
1610
|
await manager.wake(channelKey);
|
|
1584
|
-
await
|
|
1585
|
-
|
|
1586
|
-
|
|
1611
|
+
await waitForProjectState(
|
|
1612
|
+
repoPath,
|
|
1613
|
+
(project) =>
|
|
1614
|
+
project?.status === "done"
|
|
1615
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "All tasks complete"),
|
|
1587
1616
|
);
|
|
1588
1617
|
|
|
1589
1618
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1689,7 +1718,7 @@ test("watcher finalizes when terminal result exists before ACP turn exits", asyn
|
|
|
1689
1718
|
logger: createLogger(),
|
|
1690
1719
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1691
1720
|
acpClient: fakeAcpClient as any,
|
|
1692
|
-
pollIntervalMs:
|
|
1721
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1693
1722
|
});
|
|
1694
1723
|
t.after(async () => {
|
|
1695
1724
|
await manager.stop();
|
|
@@ -1726,10 +1755,11 @@ test("watcher finalizes when terminal result exists before ACP turn exits", asyn
|
|
|
1726
1755
|
|
|
1727
1756
|
await manager.start();
|
|
1728
1757
|
await manager.wake(channelKey);
|
|
1729
|
-
await
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1758
|
+
await waitForProjectState(
|
|
1759
|
+
repoPath,
|
|
1760
|
+
(project) =>
|
|
1761
|
+
project?.status === "done"
|
|
1762
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-terminal", "All tasks complete"),
|
|
1733
1763
|
);
|
|
1734
1764
|
|
|
1735
1765
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -93,5 +93,5 @@ test("implementation prompt instructs the worker to use the helper", async () =>
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
assert.match(prompt, /Use the worker IO helper instead of editing .*worker-progress\.jsonl directly\./);
|
|
96
|
-
assert.match(prompt, /worker_io\.mjs" event --kind <status\|task_start\|task_done\|blocked>/);
|
|
96
|
+
assert.match(prompt, /worker_io\.mjs['"] event --kind <status\|task_start\|task_done\|blocked>/);
|
|
97
97
|
});
|
package/src/utils/debug-log.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { appendFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
const LOG_FILE = path.join(homedir(), ".openclaw", "clawspec-debug.log");
|
|
6
|
-
|
|
7
|
-
export function debugLog(message: string): void {
|
|
8
|
-
try {
|
|
9
|
-
const timestamp = new Date().toISOString();
|
|
10
|
-
appendFileSync(LOG_FILE, `${timestamp} ${message}\n`);
|
|
11
|
-
} catch {
|
|
12
|
-
// ignore
|
|
13
|
-
}
|
|
14
|
-
}
|