clawspec 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +908 -0
  2. package/README.zh-CN.md +914 -0
  3. package/index.ts +3 -0
  4. package/openclaw.plugin.json +129 -0
  5. package/package.json +52 -0
  6. package/skills/openspec-apply-change.md +146 -0
  7. package/skills/openspec-explore.md +75 -0
  8. package/skills/openspec-propose.md +102 -0
  9. package/src/acp/client.ts +693 -0
  10. package/src/config.ts +220 -0
  11. package/src/control/keywords.ts +72 -0
  12. package/src/dependencies/acpx.ts +221 -0
  13. package/src/dependencies/openspec.ts +148 -0
  14. package/src/execution/session.ts +56 -0
  15. package/src/execution/state.ts +125 -0
  16. package/src/index.ts +179 -0
  17. package/src/memory/store.ts +118 -0
  18. package/src/openspec/cli.ts +279 -0
  19. package/src/openspec/tasks.ts +40 -0
  20. package/src/orchestrator/helpers.ts +312 -0
  21. package/src/orchestrator/service.ts +2971 -0
  22. package/src/planning/journal.ts +118 -0
  23. package/src/rollback/store.ts +173 -0
  24. package/src/state/locks.ts +133 -0
  25. package/src/state/store.ts +527 -0
  26. package/src/types.ts +301 -0
  27. package/src/utils/args.ts +88 -0
  28. package/src/utils/channel-key.ts +66 -0
  29. package/src/utils/env-path.ts +31 -0
  30. package/src/utils/fs.ts +218 -0
  31. package/src/utils/markdown.ts +136 -0
  32. package/src/utils/messages.ts +5 -0
  33. package/src/utils/paths.ts +127 -0
  34. package/src/utils/shell-command.ts +227 -0
  35. package/src/utils/slug.ts +50 -0
  36. package/src/watchers/manager.ts +3042 -0
  37. package/src/watchers/notifier.ts +69 -0
  38. package/src/worker/prompts.ts +484 -0
  39. package/src/worker/skills.ts +52 -0
  40. package/src/workspace/store.ts +140 -0
  41. package/test/acp-client.test.ts +234 -0
  42. package/test/acpx-dependency.test.ts +112 -0
  43. package/test/assistant-journal.test.ts +136 -0
  44. package/test/command-surface.test.ts +23 -0
  45. package/test/config.test.ts +77 -0
  46. package/test/detach-attach.test.ts +98 -0
  47. package/test/file-lock.test.ts +78 -0
  48. package/test/fs-utils.test.ts +22 -0
  49. package/test/helpers/harness.ts +241 -0
  50. package/test/helpers.test.ts +108 -0
  51. package/test/keywords.test.ts +80 -0
  52. package/test/notifier.test.ts +29 -0
  53. package/test/openspec-dependency.test.ts +67 -0
  54. package/test/pause-cancel.test.ts +55 -0
  55. package/test/planning-journal.test.ts +69 -0
  56. package/test/plugin-registration.test.ts +35 -0
  57. package/test/project-memory.test.ts +42 -0
  58. package/test/proposal.test.ts +24 -0
  59. package/test/queue-planning.test.ts +247 -0
  60. package/test/queue-work.test.ts +110 -0
  61. package/test/recovery.test.ts +576 -0
  62. package/test/service-archive.test.ts +82 -0
  63. package/test/shell-command.test.ts +48 -0
  64. package/test/state-store.test.ts +74 -0
  65. package/test/tasks-and-checkpoint.test.ts +60 -0
  66. package/test/use-project.test.ts +19 -0
  67. package/test/watcher-planning.test.ts +504 -0
  68. package/test/watcher-work.test.ts +1741 -0
  69. package/test/worker-command.test.ts +66 -0
  70. package/test/worker-skills.test.ts +12 -0
  71. package/tsconfig.json +25 -0
@@ -0,0 +1,1741 @@
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 { pathExists, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
7
+ import { getRepoStatePaths } from "../src/utils/paths.ts";
8
+ import { RollbackStore } from "../src/rollback/store.ts";
9
+ import { ProjectStateStore } from "../src/state/store.ts";
10
+ import { WatcherManager, describeWorkerStartupTimeout, shouldAbortWorkerStartup } from "../src/watchers/manager.ts";
11
+ import { createLogger, waitFor } from "./helpers/harness.ts";
12
+
13
+ function hasMessage(messages: string[], ...parts: string[]): boolean {
14
+ return messages.some((message) => parts.every((part) => message.includes(part)));
15
+ }
16
+
17
+ test("queue owner unavailable is treated as a non-fatal startup state", () => {
18
+ const status = {
19
+ summary: "status=dead acpxRecordId=session-1",
20
+ details: {
21
+ status: "dead",
22
+ summary: "queue owner unavailable",
23
+ },
24
+ } as any;
25
+
26
+ assert.equal(shouldAbortWorkerStartup(status), false);
27
+ assert.equal(describeWorkerStartupTimeout(status), undefined);
28
+ });
29
+
30
+ test("watcher work flow completes", async (t) => {
31
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-"));
32
+ const workspacePath = path.join(tempRoot, "workspace");
33
+ const repoPath = path.join(workspacePath, "demo-app");
34
+ const changeName = "watch-work";
35
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
36
+ const tasksPath = path.join(changeDir, "tasks.md");
37
+ const outputPath = path.join(repoPath, "src", "demo.txt");
38
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
39
+ await mkdir(path.join(repoPath, "src"), { recursive: true });
40
+ await mkdir(changeDir, { recursive: true });
41
+ await writeUtf8(tasksPath, "- [ ] 1.1 Build the demo endpoint\n");
42
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
43
+
44
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
45
+ await rollbackStore.initializeBaseline();
46
+
47
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
48
+ await stateStore.initialize();
49
+ const notifierMessages: string[] = [];
50
+
51
+ const fakeOpenSpec = {
52
+ instructionsApply: async (cwd: string, cn: string) => {
53
+ const done = (await readUtf8(tasksPath)).includes("- [x] 1.1 Build the demo endpoint");
54
+ return {
55
+ command: `openspec instructions apply --change ${cn} --json`,
56
+ cwd,
57
+ stdout: "{}",
58
+ stderr: "",
59
+ durationMs: 1,
60
+ parsed: {
61
+ changeName: cn,
62
+ changeDir,
63
+ schemaName: "spec-driven",
64
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
65
+ progress: done ? { total: 1, complete: 1, remaining: 0 } : { total: 1, complete: 0, remaining: 1 },
66
+ tasks: [{ id: "1.1", description: "Build the demo endpoint", done }],
67
+ state: done ? "all_done" : "ready",
68
+ instruction: "Implement the remaining task.",
69
+ },
70
+ };
71
+ },
72
+ } as any;
73
+
74
+ const fakeAcpClient = {
75
+ agentId: "codex",
76
+ runTurn: async (params: {
77
+ onReady?: () => Promise<void> | void;
78
+ onEvent?: (event: { type: string; title?: string }) => Promise<void> | void;
79
+ }) => {
80
+ await params.onReady?.();
81
+ const startEvent = JSON.stringify({
82
+ version: 1,
83
+ timestamp: new Date().toISOString(),
84
+ kind: "task_start",
85
+ current: 1,
86
+ total: 1,
87
+ taskId: "1.1",
88
+ message: "Start 1.1: build the demo endpoint. Next: finish this task.",
89
+ });
90
+ await writeUtf8(repoStatePaths.workerProgressFile, `${startEvent}\n`);
91
+ await params.onEvent?.({ type: "tool_call", title: "worker-progress" });
92
+
93
+ await writeUtf8(tasksPath, "- [x] 1.1 Build the demo endpoint\n");
94
+ await writeUtf8(outputPath, "demo\n");
95
+
96
+ const doneEvent = JSON.stringify({
97
+ version: 1,
98
+ timestamp: new Date().toISOString(),
99
+ kind: "task_done",
100
+ current: 1,
101
+ total: 1,
102
+ taskId: "1.1",
103
+ message: "Done 1.1: built the demo endpoint. Changed 2 files: openspec/changes/watch-work/tasks.md, src/demo.txt. Next: done.",
104
+ });
105
+ await writeUtf8(repoStatePaths.workerProgressFile, `${startEvent}\n${doneEvent}\n`);
106
+ await writeJsonFile(repoStatePaths.executionResultFile, {
107
+ version: 1,
108
+ changeName,
109
+ mode: "apply",
110
+ status: "done",
111
+ timestamp: new Date().toISOString(),
112
+ summary: "Completed task 1.1.",
113
+ progressMade: true,
114
+ completedTask: "1.1 Build the demo endpoint",
115
+ changedFiles: ["openspec/changes/watch-work/tasks.md", "src/demo.txt"],
116
+ notes: ["Task completed"],
117
+ taskCounts: { total: 1, complete: 1, remaining: 0 },
118
+ remainingTasks: 0,
119
+ });
120
+ },
121
+ cancelSession: async () => undefined,
122
+ closeSession: async () => undefined,
123
+ };
124
+
125
+ const manager = new WatcherManager({
126
+ stateStore,
127
+ openSpec: fakeOpenSpec,
128
+ archiveDirName: "archives",
129
+ logger: createLogger(),
130
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
131
+ acpClient: fakeAcpClient as any,
132
+ pollIntervalMs: 25,
133
+ });
134
+ t.after(async () => {
135
+ await manager.stop();
136
+ });
137
+
138
+ const channelKey = "discord:watch-work:default:main";
139
+ await stateStore.createProject(channelKey);
140
+ await stateStore.updateProject(channelKey, (current) => ({
141
+ ...current,
142
+ workspacePath,
143
+ repoPath,
144
+ projectName: "demo-app",
145
+ projectTitle: "Demo App",
146
+ changeName,
147
+ changeDir,
148
+ status: "armed",
149
+ phase: "implementing",
150
+ currentTask: "1.1 Build the demo endpoint",
151
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
152
+ planningJournal: { dirty: false, entryCount: 0 },
153
+ rollback: {
154
+ baselineRoot: rollbackStore.baselineRoot,
155
+ manifestPath: rollbackStore.manifestPath,
156
+ snapshotReady: true,
157
+ touchedFileCount: 0,
158
+ },
159
+ execution: {
160
+ mode: "apply",
161
+ action: "work",
162
+ state: "armed",
163
+ armedAt: new Date().toISOString(),
164
+ },
165
+ }));
166
+
167
+ await manager.start();
168
+ await manager.wake(channelKey);
169
+ await waitFor(async () =>
170
+ (await stateStore.getActiveProject(channelKey))?.status === "done"
171
+ && hasMessage(notifierMessages, "demo-app-watch-work", "All tasks complete", "/clawspec archive"),
172
+ );
173
+
174
+ const project = await stateStore.getActiveProject(channelKey);
175
+ const manifest = await rollbackStore.readManifest();
176
+ assert.equal(project?.status, "done");
177
+ assert.equal(project?.phase, "validating");
178
+ assert.equal(await pathExists(outputPath), true);
179
+ assert.equal(manifest?.files.some((entry) => entry.path === "src/demo.txt"), true);
180
+ assert.equal(notifierMessages.some((message) => message.includes("Run node") || message.includes("Run shell command")), false);
181
+ assert.equal(hasMessage(notifierMessages, "Watcher active. Starting codex worker for task 1.1"), true);
182
+ assert.equal(hasMessage(notifierMessages, "ACP worker connected with codex"), true);
183
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work", "[######] 1/1", "Start 1.1: build the demo endpoint"), true);
184
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work", "[######] 1/1", "Done 1.1: built the demo endpoint"), true);
185
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work", "[######] 1/1", "All tasks complete", "/clawspec archive"), true);
186
+ assert.match(notifierMessages.find((message) => message.includes("Start 1.1: build the demo endpoint")) ?? "", /\*\*demo-app-watch-work\*\*/);
187
+ assert.match(notifierMessages.find((message) => message.includes("Start 1.1: build the demo endpoint")) ?? "", /\nNext:/);
188
+ assert.match(notifierMessages.find((message) => message.includes("Done 1.1: built the demo endpoint")) ?? "", /\n(Changed 2 files:|Files:)/);
189
+ const watcherStartIndex = notifierMessages.findIndex((message) => message.includes("Watcher active. Starting codex worker"));
190
+ const workerReadyIndex = notifierMessages.findIndex((message) => message.includes("ACP worker connected with codex"));
191
+ const taskStartIndex = notifierMessages.findIndex((message) => message.includes("Start 1.1: build the demo endpoint"));
192
+ assert.equal(watcherStartIndex >= 0, true);
193
+ assert.equal(workerReadyIndex >= 0, true);
194
+ assert.equal(taskStartIndex >= 0, true);
195
+ assert.equal(watcherStartIndex < workerReadyIndex, true);
196
+ assert.equal(workerReadyIndex < taskStartIndex, true);
197
+ });
198
+
199
+ test("worker progress events keep running state in sync before execution finishes", async (t) => {
200
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-progress-sync-"));
201
+ const workspacePath = path.join(tempRoot, "workspace");
202
+ const repoPath = path.join(workspacePath, "demo-app");
203
+ const changeName = "watch-work-progress-sync";
204
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
205
+ const tasksPath = path.join(changeDir, "tasks.md");
206
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
207
+ let releaseFinalStep!: () => void;
208
+ const finalStep = new Promise<void>((resolve) => {
209
+ releaseFinalStep = resolve;
210
+ });
211
+
212
+ await mkdir(changeDir, { recursive: true });
213
+ await writeUtf8(
214
+ tasksPath,
215
+ "- [ ] 1.1 Define upload contracts\n- [ ] 1.2 Add multipart parsing\n",
216
+ );
217
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
218
+
219
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
220
+ await rollbackStore.initializeBaseline();
221
+
222
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
223
+ await stateStore.initialize();
224
+ const notifierMessages: string[] = [];
225
+
226
+ const fakeOpenSpec = {
227
+ instructionsApply: async (cwd: string, cn: string) => {
228
+ const content = await readUtf8(tasksPath);
229
+ const task1Done = content.includes("- [x] 1.1");
230
+ const task2Done = content.includes("- [x] 1.2");
231
+ const complete = (task1Done ? 1 : 0) + (task2Done ? 1 : 0);
232
+ return {
233
+ command: `openspec instructions apply --change ${cn} --json`,
234
+ cwd,
235
+ stdout: "{}",
236
+ stderr: "",
237
+ durationMs: 1,
238
+ parsed: {
239
+ changeName: cn,
240
+ changeDir,
241
+ schemaName: "spec-driven",
242
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
243
+ progress: { total: 2, complete, remaining: 2 - complete },
244
+ tasks: [
245
+ { id: "1.1", description: "Define upload contracts", done: task1Done },
246
+ { id: "1.2", description: "Add multipart parsing", done: task2Done },
247
+ ],
248
+ state: complete === 2 ? "all_done" : "ready",
249
+ instruction: "Implement the remaining task.",
250
+ },
251
+ };
252
+ },
253
+ } as any;
254
+
255
+ const fakeAcpClient = {
256
+ agentId: "codex",
257
+ runTurn: async (params: { onEvent?: (event: { type: string; title?: string }) => Promise<void> | void }) => {
258
+ const startEvent1 = JSON.stringify({
259
+ version: 1,
260
+ timestamp: new Date().toISOString(),
261
+ kind: "task_start",
262
+ current: 1,
263
+ total: 2,
264
+ taskId: "1.1",
265
+ message: "Start 1.1: define upload contracts. Next: write shared metadata.",
266
+ });
267
+ await writeUtf8(repoStatePaths.workerProgressFile, `${startEvent1}\n`);
268
+ await params.onEvent?.({ type: "tool_call", title: "worker-progress" });
269
+
270
+ await writeUtf8(
271
+ tasksPath,
272
+ "- [x] 1.1 Define upload contracts\n- [ ] 1.2 Add multipart parsing\n",
273
+ );
274
+ const doneEvent1 = JSON.stringify({
275
+ version: 1,
276
+ timestamp: new Date().toISOString(),
277
+ kind: "task_done",
278
+ current: 1,
279
+ total: 2,
280
+ taskId: "1.1",
281
+ message: "Done 1.1: added shared upload contracts. Changed 1 files: openspec/changes/watch-work-progress-sync/tasks.md. Next: 1.2.",
282
+ });
283
+ const startEvent2 = JSON.stringify({
284
+ version: 1,
285
+ timestamp: new Date().toISOString(),
286
+ kind: "task_start",
287
+ current: 2,
288
+ total: 2,
289
+ taskId: "1.2",
290
+ message: "Start 1.2: add multipart parsing. Next: implement parser checks.",
291
+ });
292
+ await writeUtf8(repoStatePaths.workerProgressFile, `${startEvent1}\n${doneEvent1}\n${startEvent2}\n`);
293
+ await params.onEvent?.({ type: "tool_call", title: "worker-progress" });
294
+
295
+ await finalStep;
296
+
297
+ await writeUtf8(
298
+ tasksPath,
299
+ "- [x] 1.1 Define upload contracts\n- [x] 1.2 Add multipart parsing\n",
300
+ );
301
+ await writeJsonFile(repoStatePaths.executionResultFile, {
302
+ version: 1,
303
+ changeName,
304
+ mode: "apply",
305
+ status: "done",
306
+ timestamp: new Date().toISOString(),
307
+ summary: "Completed task 1.2.",
308
+ progressMade: true,
309
+ completedTask: "1.2 Add multipart parsing",
310
+ changedFiles: ["openspec/changes/watch-work-progress-sync/tasks.md"],
311
+ notes: ["Task completed"],
312
+ taskCounts: { total: 2, complete: 2, remaining: 0 },
313
+ remainingTasks: 0,
314
+ });
315
+ },
316
+ getSessionStatus: async () => ({ summary: "status=running", details: { status: "running" } }),
317
+ cancelSession: async () => undefined,
318
+ closeSession: async () => undefined,
319
+ };
320
+
321
+ const manager = new WatcherManager({
322
+ stateStore,
323
+ openSpec: fakeOpenSpec,
324
+ archiveDirName: "archives",
325
+ logger: createLogger(),
326
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
327
+ acpClient: fakeAcpClient as any,
328
+ pollIntervalMs: 25,
329
+ });
330
+ t.after(async () => {
331
+ await manager.stop();
332
+ });
333
+
334
+ const channelKey = "discord:watch-work-progress-sync:default:main";
335
+ await stateStore.createProject(channelKey);
336
+ await stateStore.updateProject(channelKey, (current) => ({
337
+ ...current,
338
+ workspacePath,
339
+ repoPath,
340
+ projectName: "demo-app",
341
+ projectTitle: "Demo App",
342
+ changeName,
343
+ changeDir,
344
+ status: "armed",
345
+ phase: "implementing",
346
+ currentTask: "1.1 Define upload contracts",
347
+ taskCounts: { total: 2, complete: 0, remaining: 2 },
348
+ planningJournal: { dirty: false, entryCount: 0 },
349
+ rollback: {
350
+ baselineRoot: rollbackStore.baselineRoot,
351
+ manifestPath: rollbackStore.manifestPath,
352
+ snapshotReady: true,
353
+ touchedFileCount: 0,
354
+ },
355
+ execution: {
356
+ mode: "apply",
357
+ action: "work",
358
+ state: "armed",
359
+ armedAt: new Date().toISOString(),
360
+ },
361
+ }));
362
+
363
+ await manager.start();
364
+ await manager.wake(channelKey);
365
+ await waitFor(async () => {
366
+ const project = await stateStore.getActiveProject(channelKey);
367
+ return project?.status === "running"
368
+ && project.taskCounts?.complete === 1
369
+ && project.taskCounts?.remaining === 1
370
+ && project.currentTask === "1.2 Add multipart parsing"
371
+ && project.latestSummary?.includes("Start 1.2") === true
372
+ && project.execution?.currentTaskId === "1.2";
373
+ });
374
+
375
+ releaseFinalStep();
376
+ await waitFor(async () =>
377
+ (await stateStore.getActiveProject(channelKey))?.status === "done",
378
+ );
379
+
380
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-progress-sync", "1/2", "Done 1.1"), true);
381
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-progress-sync", "2/2", "Start 1.2"), true);
382
+ });
383
+
384
+ test("watcher restarts implementation worker after ACP runtime exit", async (t) => {
385
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-restart-"));
386
+ const workspacePath = path.join(tempRoot, "workspace");
387
+ const repoPath = path.join(workspacePath, "demo-app");
388
+ const changeName = "watch-work-restart";
389
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
390
+ const tasksPath = path.join(changeDir, "tasks.md");
391
+ const outputPath = path.join(repoPath, "src", "demo.txt");
392
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
393
+ await mkdir(path.join(repoPath, "src"), { recursive: true });
394
+ await mkdir(changeDir, { recursive: true });
395
+ await writeUtf8(tasksPath, "- [ ] 1.1 Build the demo endpoint\n");
396
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
397
+
398
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
399
+ await rollbackStore.initializeBaseline();
400
+
401
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
402
+ await stateStore.initialize();
403
+ const notifierMessages: string[] = [];
404
+ let runCount = 0;
405
+
406
+ const fakeOpenSpec = {
407
+ instructionsApply: async (cwd: string, cn: string) => {
408
+ const done = (await readUtf8(tasksPath)).includes("- [x] 1.1 Build the demo endpoint");
409
+ return {
410
+ command: `openspec instructions apply --change ${cn} --json`,
411
+ cwd,
412
+ stdout: "{}",
413
+ stderr: "",
414
+ durationMs: 1,
415
+ parsed: {
416
+ changeName: cn,
417
+ changeDir,
418
+ schemaName: "spec-driven",
419
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
420
+ progress: done ? { total: 1, complete: 1, remaining: 0 } : { total: 1, complete: 0, remaining: 1 },
421
+ tasks: [{ id: "1.1", description: "Build the demo endpoint", done }],
422
+ state: done ? "all_done" : "ready",
423
+ instruction: "Implement the remaining task.",
424
+ },
425
+ };
426
+ },
427
+ } as any;
428
+
429
+ const fakeAcpClient = {
430
+ agentId: "codex",
431
+ runTurn: async () => {
432
+ runCount += 1;
433
+ if (runCount === 1) {
434
+ const startEvent = JSON.stringify({
435
+ version: 1,
436
+ timestamp: new Date().toISOString(),
437
+ kind: "task_start",
438
+ current: 1,
439
+ total: 1,
440
+ taskId: "1.1",
441
+ message: "Start 1.1: build the demo endpoint. Next: finish this task.",
442
+ });
443
+ const doneEvent = JSON.stringify({
444
+ version: 1,
445
+ timestamp: new Date().toISOString(),
446
+ kind: "task_done",
447
+ current: 1,
448
+ total: 1,
449
+ taskId: "1.1",
450
+ message: "Done 1.1: built the demo endpoint. Changed 2 files: openspec/changes/watch-work-restart/tasks.md, src/demo.txt. Next: done.",
451
+ });
452
+ await writeUtf8(repoStatePaths.workerProgressFile, `${startEvent}\n${doneEvent}\n`);
453
+ await writeUtf8(tasksPath, "- [x] 1.1 Build the demo endpoint\n");
454
+ await writeUtf8(outputPath, "demo\n");
455
+ throw new Error("acpx exited with code 1");
456
+ }
457
+
458
+ await writeUtf8(tasksPath, "- [x] 1.1 Build the demo endpoint\n");
459
+ await writeUtf8(outputPath, "demo\n");
460
+ await writeJsonFile(repoStatePaths.executionResultFile, {
461
+ version: 1,
462
+ changeName,
463
+ mode: "apply",
464
+ status: "done",
465
+ timestamp: new Date().toISOString(),
466
+ summary: "Completed task 1.1.",
467
+ progressMade: true,
468
+ completedTask: "1.1 Build the demo endpoint",
469
+ changedFiles: ["openspec/changes/watch-work-restart/tasks.md", "src/demo.txt"],
470
+ notes: ["Task completed"],
471
+ taskCounts: { total: 1, complete: 1, remaining: 0 },
472
+ remainingTasks: 0,
473
+ });
474
+ },
475
+ cancelSession: async () => undefined,
476
+ closeSession: async () => undefined,
477
+ };
478
+
479
+ const manager = new WatcherManager({
480
+ stateStore,
481
+ openSpec: fakeOpenSpec,
482
+ archiveDirName: "archives",
483
+ logger: createLogger(),
484
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
485
+ acpClient: fakeAcpClient as any,
486
+ pollIntervalMs: 25,
487
+ });
488
+ t.after(async () => {
489
+ await manager.stop();
490
+ });
491
+
492
+ const channelKey = "discord:watch-work-restart:default:main";
493
+ await stateStore.createProject(channelKey);
494
+ await stateStore.updateProject(channelKey, (current) => ({
495
+ ...current,
496
+ workspacePath,
497
+ repoPath,
498
+ projectName: "demo-app",
499
+ projectTitle: "Demo App",
500
+ changeName,
501
+ changeDir,
502
+ status: "armed",
503
+ phase: "implementing",
504
+ currentTask: "1.1 Build the demo endpoint",
505
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
506
+ planningJournal: { dirty: false, entryCount: 0 },
507
+ rollback: {
508
+ baselineRoot: rollbackStore.baselineRoot,
509
+ manifestPath: rollbackStore.manifestPath,
510
+ snapshotReady: true,
511
+ touchedFileCount: 0,
512
+ },
513
+ execution: {
514
+ mode: "apply",
515
+ action: "work",
516
+ state: "armed",
517
+ armedAt: new Date().toISOString(),
518
+ },
519
+ }));
520
+
521
+ await manager.start();
522
+ await manager.wake(channelKey);
523
+ await waitFor(async () =>
524
+ (await stateStore.getActiveProject(channelKey))?.status === "done"
525
+ && hasMessage(notifierMessages, "demo-app-watch-work-restart", "All tasks complete", "/clawspec archive"),
526
+ 8_000,
527
+ );
528
+
529
+ const project = await stateStore.getActiveProject(channelKey);
530
+ assert.equal(project?.status, "done");
531
+ assert.equal(project?.lastExecution?.status, "done");
532
+ assert.equal(runCount, 1);
533
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-restart", "Restarting ACP worker", "retry task 1.1"), true);
534
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-restart", "All tasks complete", "/clawspec archive"), true);
535
+ assert.equal(notifierMessages.some((message) => message.includes("Blocked:")), false);
536
+ const progress = await readUtf8(repoStatePaths.progressFile);
537
+ assert.match(progress, /- status: done/);
538
+ assert.doesNotMatch(progress, /- status: blocked/);
539
+ });
540
+
541
+ test("watcher restarts a dead ACP session after progress stalls", async (t) => {
542
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-dead-session-"));
543
+ const workspacePath = path.join(tempRoot, "workspace");
544
+ const repoPath = path.join(workspacePath, "demo-app");
545
+ const changeName = "watch-work-dead-session";
546
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
547
+ const tasksPath = path.join(changeDir, "tasks.md");
548
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
549
+ await mkdir(changeDir, { recursive: true });
550
+ await writeUtf8(tasksPath, "- [ ] 1.1 Recover dead session\n");
551
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
552
+
553
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
554
+ await rollbackStore.initializeBaseline();
555
+
556
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
557
+ await stateStore.initialize();
558
+ const notifierMessages: string[] = [];
559
+ let runCount = 0;
560
+
561
+ const fakeOpenSpec = {
562
+ instructionsApply: async (cwd: string, cn: string) => {
563
+ const done = (await readUtf8(tasksPath)).includes("- [x] 1.1 Recover dead session");
564
+ return {
565
+ command: `openspec instructions apply --change ${cn} --json`,
566
+ cwd,
567
+ stdout: "{}",
568
+ stderr: "",
569
+ durationMs: 1,
570
+ parsed: {
571
+ changeName: cn,
572
+ changeDir,
573
+ schemaName: "spec-driven",
574
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
575
+ progress: done ? { total: 1, complete: 1, remaining: 0 } : { total: 1, complete: 0, remaining: 1 },
576
+ tasks: [{ id: "1.1", description: "Recover dead session", done }],
577
+ state: done ? "all_done" : "ready",
578
+ instruction: "Implement the remaining task.",
579
+ },
580
+ };
581
+ },
582
+ } as any;
583
+
584
+ const fakeAcpClient = {
585
+ agentId: "codex",
586
+ runTurn: async (
587
+ params: {
588
+ signal?: AbortSignal;
589
+ onEvent?: (event: { type: string; title?: string }) => Promise<void> | void;
590
+ },
591
+ ) => {
592
+ runCount += 1;
593
+ if (runCount === 1) {
594
+ const startEvent = JSON.stringify({
595
+ version: 1,
596
+ timestamp: new Date().toISOString(),
597
+ kind: "task_start",
598
+ current: 1,
599
+ total: 1,
600
+ taskId: "1.1",
601
+ message: "Start 1.1: recover dead session. Next: wait for the worker restart.",
602
+ });
603
+ await writeUtf8(repoStatePaths.workerProgressFile, `${startEvent}\n`);
604
+ await params.onEvent?.({ type: "tool_call", title: "worker-progress" });
605
+ await new Promise<void>((_resolve, reject) => {
606
+ params.signal?.addEventListener("abort", () => {
607
+ reject(new Error("acpx exited with code 1"));
608
+ }, { once: true });
609
+ });
610
+ return;
611
+ }
612
+
613
+ await writeUtf8(tasksPath, "- [x] 1.1 Recover dead session\n");
614
+ await writeJsonFile(repoStatePaths.executionResultFile, {
615
+ version: 1,
616
+ changeName,
617
+ mode: "apply",
618
+ status: "done",
619
+ timestamp: new Date().toISOString(),
620
+ summary: "Completed task 1.1.",
621
+ progressMade: true,
622
+ completedTask: "1.1 Recover dead session",
623
+ changedFiles: ["openspec/changes/watch-work-dead-session/tasks.md"],
624
+ notes: ["Recovered after dead session restart."],
625
+ taskCounts: { total: 1, complete: 1, remaining: 0 },
626
+ remainingTasks: 0,
627
+ });
628
+ },
629
+ getSessionStatus: async () => runCount === 1
630
+ ? {
631
+ summary: "status=dead acpxRecordId=dead-session",
632
+ details: {
633
+ status: "dead",
634
+ summary: "queue owner unavailable",
635
+ },
636
+ }
637
+ : {
638
+ summary: "status=running",
639
+ details: {
640
+ status: "running",
641
+ },
642
+ },
643
+ cancelSession: async () => undefined,
644
+ closeSession: async () => undefined,
645
+ };
646
+
647
+ const manager = new WatcherManager({
648
+ stateStore,
649
+ openSpec: fakeOpenSpec,
650
+ archiveDirName: "archives",
651
+ logger: createLogger(),
652
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
653
+ acpClient: fakeAcpClient as any,
654
+ pollIntervalMs: 25,
655
+ });
656
+ t.after(async () => {
657
+ await manager.stop();
658
+ });
659
+
660
+ const channelKey = "discord:watch-work-dead-session:default:main";
661
+ await stateStore.createProject(channelKey);
662
+ await stateStore.updateProject(channelKey, (current) => ({
663
+ ...current,
664
+ workspacePath,
665
+ repoPath,
666
+ projectName: "demo-app",
667
+ projectTitle: "Demo App",
668
+ changeName,
669
+ changeDir,
670
+ status: "armed",
671
+ phase: "implementing",
672
+ currentTask: "1.1 Recover dead session",
673
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
674
+ planningJournal: { dirty: false, entryCount: 0 },
675
+ rollback: {
676
+ baselineRoot: rollbackStore.baselineRoot,
677
+ manifestPath: rollbackStore.manifestPath,
678
+ snapshotReady: true,
679
+ touchedFileCount: 0,
680
+ },
681
+ execution: {
682
+ mode: "apply",
683
+ action: "work",
684
+ state: "armed",
685
+ armedAt: new Date().toISOString(),
686
+ },
687
+ }));
688
+
689
+ await manager.start();
690
+ await manager.wake(channelKey);
691
+ await waitFor(async () =>
692
+ (await stateStore.getActiveProject(channelKey))?.status === "done",
693
+ 8_000,
694
+ );
695
+
696
+ const project = await stateStore.getActiveProject(channelKey);
697
+ assert.equal(project?.status, "done");
698
+ assert.equal(project?.lastExecution?.status, "done");
699
+ assert.equal(runCount, 2);
700
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-dead-session", "Restarting ACP worker", "retry task 1.1"), true);
701
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-dead-session", "All tasks complete", "/clawspec archive"), true);
702
+ });
703
+
704
+ test("watcher restarts a dead ACP session that dies before first progress", async (t) => {
705
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-dead-startup-"));
706
+ const workspacePath = path.join(tempRoot, "workspace");
707
+ const repoPath = path.join(workspacePath, "demo-app");
708
+ const changeName = "watch-work-dead-startup";
709
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
710
+ const tasksPath = path.join(changeDir, "tasks.md");
711
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
712
+ await mkdir(changeDir, { recursive: true });
713
+ await writeUtf8(tasksPath, "- [ ] 1.1 Recover startup failure\n");
714
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
715
+
716
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
717
+ await rollbackStore.initializeBaseline();
718
+
719
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
720
+ await stateStore.initialize();
721
+ const notifierMessages: string[] = [];
722
+ let runCount = 0;
723
+
724
+ const fakeOpenSpec = {
725
+ instructionsApply: async (cwd: string, cn: string) => {
726
+ const done = (await readUtf8(tasksPath)).includes("- [x] 1.1 Recover startup failure");
727
+ return {
728
+ command: `openspec instructions apply --change ${cn} --json`,
729
+ cwd,
730
+ stdout: "{}",
731
+ stderr: "",
732
+ durationMs: 1,
733
+ parsed: {
734
+ changeName: cn,
735
+ changeDir,
736
+ schemaName: "spec-driven",
737
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
738
+ progress: done ? { total: 1, complete: 1, remaining: 0 } : { total: 1, complete: 0, remaining: 1 },
739
+ tasks: [{ id: "1.1", description: "Recover startup failure", done }],
740
+ state: done ? "all_done" : "ready",
741
+ instruction: "Implement the remaining task.",
742
+ },
743
+ };
744
+ },
745
+ } as any;
746
+
747
+ const fakeAcpClient = {
748
+ agentId: "codex",
749
+ runTurn: async (params: { signal?: AbortSignal }) => {
750
+ runCount += 1;
751
+ if (runCount === 1) {
752
+ await new Promise<void>((_resolve, reject) => {
753
+ params.signal?.addEventListener("abort", () => {
754
+ reject(new Error("acpx exited with code 1"));
755
+ }, { once: true });
756
+ });
757
+ return;
758
+ }
759
+
760
+ await writeUtf8(tasksPath, "- [x] 1.1 Recover startup failure\n");
761
+ await writeJsonFile(repoStatePaths.executionResultFile, {
762
+ version: 1,
763
+ changeName,
764
+ mode: "apply",
765
+ status: "done",
766
+ timestamp: new Date().toISOString(),
767
+ summary: "Recovered after startup failure.",
768
+ progressMade: true,
769
+ completedTask: "1.1 Recover startup failure",
770
+ changedFiles: ["openspec/changes/watch-work-dead-startup/tasks.md"],
771
+ notes: ["Recovered after dead startup restart."],
772
+ taskCounts: { total: 1, complete: 1, remaining: 0 },
773
+ remainingTasks: 0,
774
+ });
775
+ },
776
+ getSessionStatus: async () => runCount === 1
777
+ ? {
778
+ summary: "status=dead acpxRecordId=dead-startup",
779
+ details: {
780
+ status: "dead",
781
+ summary: "queue owner unavailable",
782
+ },
783
+ }
784
+ : {
785
+ summary: "status=running",
786
+ details: {
787
+ status: "running",
788
+ },
789
+ },
790
+ cancelSession: async () => undefined,
791
+ closeSession: async () => undefined,
792
+ };
793
+
794
+ const manager = new WatcherManager({
795
+ stateStore,
796
+ openSpec: fakeOpenSpec,
797
+ archiveDirName: "archives",
798
+ logger: createLogger(),
799
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
800
+ acpClient: fakeAcpClient as any,
801
+ pollIntervalMs: 25,
802
+ });
803
+ t.after(async () => {
804
+ await manager.stop();
805
+ });
806
+
807
+ const channelKey = "discord:watch-work-dead-startup:default:main";
808
+ await stateStore.createProject(channelKey);
809
+ await stateStore.updateProject(channelKey, (current) => ({
810
+ ...current,
811
+ workspacePath,
812
+ repoPath,
813
+ projectName: "demo-app",
814
+ projectTitle: "Demo App",
815
+ changeName,
816
+ changeDir,
817
+ status: "armed",
818
+ phase: "implementing",
819
+ currentTask: "1.1 Recover startup failure",
820
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
821
+ planningJournal: { dirty: false, entryCount: 0 },
822
+ rollback: {
823
+ baselineRoot: rollbackStore.baselineRoot,
824
+ manifestPath: rollbackStore.manifestPath,
825
+ snapshotReady: true,
826
+ touchedFileCount: 0,
827
+ },
828
+ execution: {
829
+ mode: "apply",
830
+ action: "work",
831
+ state: "armed",
832
+ armedAt: new Date().toISOString(),
833
+ },
834
+ }));
835
+
836
+ await manager.start();
837
+ await manager.wake(channelKey);
838
+ await waitFor(async () =>
839
+ (await stateStore.getActiveProject(channelKey))?.status === "done",
840
+ 8_000,
841
+ );
842
+
843
+ const project = await stateStore.getActiveProject(channelKey);
844
+ assert.equal(project?.status, "done");
845
+ assert.equal(project?.lastExecution?.status, "done");
846
+ assert.equal(runCount, 2);
847
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-dead-startup", "Restarting ACP worker", "retry task 1.1"), true);
848
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-dead-startup", "All tasks complete", "/clawspec archive"), true);
849
+ });
850
+
851
+ test("status-only ACP heartbeats do not keep a dead session alive", async (t) => {
852
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-status-heartbeats-"));
853
+ const workspacePath = path.join(tempRoot, "workspace");
854
+ const repoPath = path.join(workspacePath, "demo-app");
855
+ const changeName = "watch-work-status-heartbeats";
856
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
857
+ const tasksPath = path.join(changeDir, "tasks.md");
858
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
859
+ await mkdir(changeDir, { recursive: true });
860
+ await writeUtf8(tasksPath, "- [ ] 1.1 Recover dead worker after empty heartbeats\n");
861
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
862
+
863
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
864
+ await rollbackStore.initializeBaseline();
865
+
866
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
867
+ await stateStore.initialize();
868
+ const notifierMessages: string[] = [];
869
+ let runCount = 0;
870
+
871
+ const fakeOpenSpec = {
872
+ instructionsApply: async (cwd: string, cn: string) => {
873
+ const done = (await readUtf8(tasksPath)).includes("- [x] 1.1 Recover dead worker after empty heartbeats");
874
+ return {
875
+ command: `openspec instructions apply --change ${cn} --json`,
876
+ cwd,
877
+ stdout: "{}",
878
+ stderr: "",
879
+ durationMs: 1,
880
+ parsed: {
881
+ changeName: cn,
882
+ changeDir,
883
+ schemaName: "spec-driven",
884
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
885
+ progress: done ? { total: 1, complete: 1, remaining: 0 } : { total: 1, complete: 0, remaining: 1 },
886
+ tasks: [{ id: "1.1", description: "Recover dead worker after empty heartbeats", done }],
887
+ state: done ? "all_done" : "ready",
888
+ instruction: "Implement the remaining task.",
889
+ },
890
+ };
891
+ },
892
+ } as any;
893
+
894
+ const fakeAcpClient = {
895
+ agentId: "codex",
896
+ runTurn: async (
897
+ params: {
898
+ signal?: AbortSignal;
899
+ onEvent?: (event: { type: string; text?: string; tag?: string }) => Promise<void> | void;
900
+ },
901
+ ) => {
902
+ runCount += 1;
903
+ if (runCount === 1) {
904
+ await new Promise<void>((_resolve, reject) => {
905
+ const timer = setInterval(() => {
906
+ void params.onEvent?.({
907
+ type: "status",
908
+ text: "session heartbeat",
909
+ tag: "session_info_update",
910
+ });
911
+ }, 250);
912
+ params.signal?.addEventListener("abort", () => {
913
+ clearInterval(timer);
914
+ reject(new Error("acpx exited with code 1"));
915
+ }, { once: true });
916
+ });
917
+ return;
918
+ }
919
+
920
+ await writeUtf8(tasksPath, "- [x] 1.1 Recover dead worker after empty heartbeats\n");
921
+ await writeJsonFile(repoStatePaths.executionResultFile, {
922
+ version: 1,
923
+ changeName,
924
+ mode: "apply",
925
+ status: "done",
926
+ timestamp: new Date().toISOString(),
927
+ summary: "Recovered after dead worker heartbeat loop.",
928
+ progressMade: true,
929
+ completedTask: "1.1 Recover dead worker after empty heartbeats",
930
+ changedFiles: ["openspec/changes/watch-work-status-heartbeats/tasks.md"],
931
+ notes: ["Recovered after watcher ignored empty ACP status heartbeats."],
932
+ taskCounts: { total: 1, complete: 1, remaining: 0 },
933
+ remainingTasks: 0,
934
+ });
935
+ },
936
+ getSessionStatus: async () => runCount === 1
937
+ ? {
938
+ summary: "status=dead acpxRecordId=dead-heartbeats",
939
+ details: {
940
+ status: "dead",
941
+ summary: "agent process exited",
942
+ },
943
+ }
944
+ : {
945
+ summary: "status=running",
946
+ details: {
947
+ status: "running",
948
+ },
949
+ },
950
+ cancelSession: async () => undefined,
951
+ closeSession: async () => undefined,
952
+ };
953
+
954
+ const manager = new WatcherManager({
955
+ stateStore,
956
+ openSpec: fakeOpenSpec,
957
+ archiveDirName: "archives",
958
+ logger: createLogger(),
959
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
960
+ acpClient: fakeAcpClient as any,
961
+ pollIntervalMs: 25,
962
+ });
963
+ t.after(async () => {
964
+ await manager.stop();
965
+ });
966
+
967
+ const channelKey = "discord:watch-work-status-heartbeats:default:main";
968
+ await stateStore.createProject(channelKey);
969
+ await stateStore.updateProject(channelKey, (current) => ({
970
+ ...current,
971
+ workspacePath,
972
+ repoPath,
973
+ projectName: "demo-app",
974
+ projectTitle: "Demo App",
975
+ changeName,
976
+ changeDir,
977
+ status: "armed",
978
+ phase: "implementing",
979
+ currentTask: "1.1 Recover dead worker after empty heartbeats",
980
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
981
+ planningJournal: { dirty: false, entryCount: 0 },
982
+ rollback: {
983
+ baselineRoot: rollbackStore.baselineRoot,
984
+ manifestPath: rollbackStore.manifestPath,
985
+ snapshotReady: true,
986
+ touchedFileCount: 0,
987
+ },
988
+ execution: {
989
+ mode: "apply",
990
+ action: "work",
991
+ state: "armed",
992
+ armedAt: new Date().toISOString(),
993
+ },
994
+ }));
995
+
996
+ await manager.start();
997
+ await manager.wake(channelKey);
998
+ await waitFor(async () =>
999
+ (await stateStore.getActiveProject(channelKey))?.status === "done",
1000
+ 8_000,
1001
+ );
1002
+
1003
+ const project = await stateStore.getActiveProject(channelKey);
1004
+ assert.equal(project?.status, "done");
1005
+ assert.equal(runCount, 2);
1006
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-status-heartbeats", "Restarting ACP worker", "retry task 1.1"), true);
1007
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-status-heartbeats", "All tasks complete"), true);
1008
+ });
1009
+
1010
+ test("dead ACP session that ignores abort is restarted without hanging the watcher", async (t) => {
1011
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-hung-dead-session-"));
1012
+ const workspacePath = path.join(tempRoot, "workspace");
1013
+ const repoPath = path.join(workspacePath, "demo-app");
1014
+ const changeName = "watch-work-hung-dead-session";
1015
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
1016
+ const tasksPath = path.join(changeDir, "tasks.md");
1017
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
1018
+ await mkdir(changeDir, { recursive: true });
1019
+ await writeUtf8(tasksPath, "- [ ] 1.1 Recover hung dead worker session\n");
1020
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
1021
+
1022
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
1023
+ await rollbackStore.initializeBaseline();
1024
+
1025
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
1026
+ await stateStore.initialize();
1027
+ const notifierMessages: string[] = [];
1028
+ let runCount = 0;
1029
+ let heartbeatTimer: NodeJS.Timeout | undefined;
1030
+
1031
+ const fakeOpenSpec = {
1032
+ instructionsApply: async (cwd: string, cn: string) => {
1033
+ const done = (await readUtf8(tasksPath)).includes("- [x] 1.1 Recover hung dead worker session");
1034
+ return {
1035
+ command: `openspec instructions apply --change ${cn} --json`,
1036
+ cwd,
1037
+ stdout: "{}",
1038
+ stderr: "",
1039
+ durationMs: 1,
1040
+ parsed: {
1041
+ changeName: cn,
1042
+ changeDir,
1043
+ schemaName: "spec-driven",
1044
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
1045
+ progress: done ? { total: 1, complete: 1, remaining: 0 } : { total: 1, complete: 0, remaining: 1 },
1046
+ tasks: [{ id: "1.1", description: "Recover hung dead worker session", done }],
1047
+ state: done ? "all_done" : "ready",
1048
+ instruction: "Implement the remaining task.",
1049
+ },
1050
+ };
1051
+ },
1052
+ } as any;
1053
+
1054
+ const fakeAcpClient = {
1055
+ agentId: "codex",
1056
+ runTurn: async (
1057
+ params: {
1058
+ signal?: AbortSignal;
1059
+ onEvent?: (event: { type: string; text?: string; tag?: string }) => Promise<void> | void;
1060
+ },
1061
+ ) => {
1062
+ runCount += 1;
1063
+ if (runCount === 1) {
1064
+ await params.onEvent?.({ type: "tool_call" });
1065
+ await new Promise<void>(() => {
1066
+ heartbeatTimer = setInterval(() => {
1067
+ void params.onEvent?.({
1068
+ type: "status",
1069
+ text: "session heartbeat",
1070
+ tag: "session_info_update",
1071
+ });
1072
+ }, 250);
1073
+ heartbeatTimer.unref?.();
1074
+ params.signal?.addEventListener("abort", () => {
1075
+ // Simulate a dead backend that never settles the stream after abort.
1076
+ }, { once: true });
1077
+ });
1078
+ return;
1079
+ }
1080
+
1081
+ if (heartbeatTimer) {
1082
+ clearInterval(heartbeatTimer);
1083
+ heartbeatTimer = undefined;
1084
+ }
1085
+
1086
+ await writeUtf8(tasksPath, "- [x] 1.1 Recover hung dead worker session\n");
1087
+ await writeJsonFile(repoStatePaths.executionResultFile, {
1088
+ version: 1,
1089
+ changeName,
1090
+ mode: "apply",
1091
+ status: "done",
1092
+ timestamp: new Date().toISOString(),
1093
+ summary: "Recovered after hung dead worker session.",
1094
+ progressMade: true,
1095
+ completedTask: "1.1 Recover hung dead worker session",
1096
+ changedFiles: ["openspec/changes/watch-work-hung-dead-session/tasks.md"],
1097
+ notes: ["Watcher restarted after the first ACP run stayed dead and ignored abort."],
1098
+ taskCounts: { total: 1, complete: 1, remaining: 0 },
1099
+ remainingTasks: 0,
1100
+ });
1101
+ },
1102
+ getSessionStatus: async () => runCount === 1
1103
+ ? {
1104
+ summary: "status=dead acpxRecordId=hung-dead-worker",
1105
+ details: {
1106
+ status: "dead",
1107
+ summary: "agent process exited",
1108
+ },
1109
+ }
1110
+ : {
1111
+ summary: "status=running",
1112
+ details: {
1113
+ status: "running",
1114
+ },
1115
+ },
1116
+ cancelSession: async () => undefined,
1117
+ closeSession: async () => undefined,
1118
+ };
1119
+
1120
+ const manager = new WatcherManager({
1121
+ stateStore,
1122
+ openSpec: fakeOpenSpec,
1123
+ archiveDirName: "archives",
1124
+ logger: createLogger(),
1125
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1126
+ acpClient: fakeAcpClient as any,
1127
+ pollIntervalMs: 25,
1128
+ });
1129
+ t.after(async () => {
1130
+ if (heartbeatTimer) {
1131
+ clearInterval(heartbeatTimer);
1132
+ heartbeatTimer = undefined;
1133
+ }
1134
+ await manager.stop();
1135
+ });
1136
+
1137
+ const channelKey = "discord:watch-work-hung-dead-session:default:main";
1138
+ await stateStore.createProject(channelKey);
1139
+ await stateStore.updateProject(channelKey, (current) => ({
1140
+ ...current,
1141
+ workspacePath,
1142
+ repoPath,
1143
+ projectName: "demo-app",
1144
+ projectTitle: "Demo App",
1145
+ changeName,
1146
+ changeDir,
1147
+ status: "armed",
1148
+ phase: "implementing",
1149
+ currentTask: "1.1 Recover hung dead worker session",
1150
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
1151
+ planningJournal: { dirty: false, entryCount: 0 },
1152
+ rollback: {
1153
+ baselineRoot: rollbackStore.baselineRoot,
1154
+ manifestPath: rollbackStore.manifestPath,
1155
+ snapshotReady: true,
1156
+ touchedFileCount: 0,
1157
+ },
1158
+ execution: {
1159
+ mode: "apply",
1160
+ action: "work",
1161
+ state: "armed",
1162
+ armedAt: new Date().toISOString(),
1163
+ },
1164
+ }));
1165
+
1166
+ await manager.start();
1167
+ await manager.wake(channelKey);
1168
+ await waitFor(async () =>
1169
+ (await stateStore.getActiveProject(channelKey))?.status === "done",
1170
+ 8_000,
1171
+ );
1172
+
1173
+ const project = await stateStore.getActiveProject(channelKey);
1174
+ assert.equal(project?.status, "done");
1175
+ assert.equal(project?.lastExecution?.status, "done");
1176
+ assert.equal(runCount, 2);
1177
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-hung-dead-session", "Restarting ACP worker", "retry task 1.1"), true);
1178
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-hung-dead-session", "All tasks complete"), true);
1179
+ });
1180
+
1181
+ test("manager stop closes active worker sessions and rearms project recovery state", async () => {
1182
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-stop-closes-workers-"));
1183
+ const workspacePath = path.join(tempRoot, "workspace");
1184
+ const repoPath = path.join(workspacePath, "demo-app");
1185
+ const changeName = "watch-stop";
1186
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
1187
+ const tasksPath = path.join(changeDir, "tasks.md");
1188
+ await mkdir(changeDir, { recursive: true });
1189
+ await writeUtf8(tasksPath, "- [x] 1.1 First task\n- [ ] 1.2 Remaining task\n");
1190
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
1191
+
1192
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
1193
+ await stateStore.initialize();
1194
+ const closedSessions: Array<{ sessionKey: string; reason?: string }> = [];
1195
+
1196
+ const manager = new WatcherManager({
1197
+ stateStore,
1198
+ openSpec: {} as any,
1199
+ archiveDirName: "archives",
1200
+ logger: createLogger(),
1201
+ notifier: { send: async () => undefined } as any,
1202
+ acpClient: {
1203
+ agentId: "codex",
1204
+ closeSession: async (sessionKey: string, reason?: string) => {
1205
+ closedSessions.push({ sessionKey, reason });
1206
+ },
1207
+ } as any,
1208
+ pollIntervalMs: 25,
1209
+ });
1210
+
1211
+ const channelKey = "discord:watch-stop:default:main";
1212
+ await stateStore.createProject(channelKey);
1213
+ await stateStore.updateProject(channelKey, (current) => ({
1214
+ ...current,
1215
+ workspacePath,
1216
+ repoPath,
1217
+ projectName: "demo-app",
1218
+ projectTitle: "Demo App",
1219
+ changeName,
1220
+ changeDir,
1221
+ status: "running",
1222
+ phase: "implementing",
1223
+ latestSummary: "Running task 2",
1224
+ currentTask: "1.2 Remaining task",
1225
+ taskCounts: { total: 2, complete: 1, remaining: 1 },
1226
+ execution: {
1227
+ mode: "apply",
1228
+ action: "work",
1229
+ state: "running",
1230
+ workerAgentId: "codex",
1231
+ workerSlot: "primary",
1232
+ armedAt: new Date().toISOString(),
1233
+ startedAt: new Date().toISOString(),
1234
+ sessionKey: "session-stop-1",
1235
+ lastHeartbeatAt: new Date().toISOString(),
1236
+ },
1237
+ }));
1238
+
1239
+ await manager.stop();
1240
+
1241
+ const project = await stateStore.getActiveProject(channelKey);
1242
+ assert.equal(project?.status, "armed");
1243
+ assert.equal(project?.phase, "implementing");
1244
+ assert.equal(project?.execution?.state, "armed");
1245
+ assert.equal(project?.execution?.startedAt, undefined);
1246
+ assert.equal(project?.execution?.lastHeartbeatAt, undefined);
1247
+ assert.equal(project?.taskCounts?.complete, 1);
1248
+ assert.equal(project?.taskCounts?.remaining, 1);
1249
+ assert.deepEqual(closedSessions, [{
1250
+ sessionKey: "session-stop-1",
1251
+ reason: "gateway service stopping",
1252
+ }]);
1253
+ });
1254
+
1255
+ test("watcher stops retrying after 10 ACP restart attempts", async (t) => {
1256
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-restart-cap-"));
1257
+ const workspacePath = path.join(tempRoot, "workspace");
1258
+ const repoPath = path.join(workspacePath, "demo-app");
1259
+ const changeName = "watch-work-restart-cap";
1260
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
1261
+ const tasksPath = path.join(changeDir, "tasks.md");
1262
+ await mkdir(changeDir, { recursive: true });
1263
+ await writeUtf8(tasksPath, "- [ ] 1.1 Recover repeated worker failure\n");
1264
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
1265
+
1266
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
1267
+ await rollbackStore.initializeBaseline();
1268
+
1269
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
1270
+ await stateStore.initialize();
1271
+ const notifierMessages: string[] = [];
1272
+ let runCount = 0;
1273
+
1274
+ const fakeOpenSpec = {
1275
+ instructionsApply: async (cwd: string, cn: string) => ({
1276
+ command: `openspec instructions apply --change ${cn} --json`,
1277
+ cwd,
1278
+ stdout: "{}",
1279
+ stderr: "",
1280
+ durationMs: 1,
1281
+ parsed: {
1282
+ changeName: cn,
1283
+ changeDir,
1284
+ schemaName: "spec-driven",
1285
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
1286
+ progress: { total: 1, complete: 0, remaining: 1 },
1287
+ tasks: [{ id: "1.1", description: "Recover repeated worker failure", done: false }],
1288
+ state: "ready",
1289
+ instruction: "Implement the remaining task.",
1290
+ },
1291
+ }),
1292
+ } as any;
1293
+
1294
+ const fakeAcpClient = {
1295
+ agentId: "codex",
1296
+ runTurn: async () => {
1297
+ runCount += 1;
1298
+ throw new Error("acpx exited with code 1");
1299
+ },
1300
+ getSessionStatus: async () => undefined,
1301
+ cancelSession: async () => undefined,
1302
+ closeSession: async () => undefined,
1303
+ };
1304
+
1305
+ const manager = new WatcherManager({
1306
+ stateStore,
1307
+ openSpec: fakeOpenSpec,
1308
+ archiveDirName: "archives",
1309
+ logger: createLogger(),
1310
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1311
+ acpClient: fakeAcpClient as any,
1312
+ pollIntervalMs: 25,
1313
+ });
1314
+ t.after(async () => {
1315
+ await manager.stop();
1316
+ });
1317
+
1318
+ const channelKey = "discord:watch-work-restart-cap:default:main";
1319
+ await stateStore.createProject(channelKey);
1320
+ await stateStore.updateProject(channelKey, (current) => ({
1321
+ ...current,
1322
+ workspacePath,
1323
+ repoPath,
1324
+ projectName: "demo-app",
1325
+ projectTitle: "Demo App",
1326
+ changeName,
1327
+ changeDir,
1328
+ status: "armed",
1329
+ phase: "implementing",
1330
+ currentTask: "1.1 Recover repeated worker failure",
1331
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
1332
+ planningJournal: { dirty: false, entryCount: 0 },
1333
+ rollback: {
1334
+ baselineRoot: rollbackStore.baselineRoot,
1335
+ manifestPath: rollbackStore.manifestPath,
1336
+ snapshotReady: true,
1337
+ touchedFileCount: 0,
1338
+ },
1339
+ execution: {
1340
+ mode: "apply",
1341
+ action: "work",
1342
+ state: "armed",
1343
+ armedAt: new Date().toISOString(),
1344
+ restartCount: 10,
1345
+ lastFailure: "previous failure",
1346
+ },
1347
+ }));
1348
+
1349
+ await manager.wake(channelKey);
1350
+ await waitFor(async () =>
1351
+ (await stateStore.getActiveProject(channelKey))?.status === "blocked",
1352
+ 8_000,
1353
+ );
1354
+
1355
+ const project = await stateStore.getActiveProject(channelKey);
1356
+ assert.equal(project?.status, "blocked");
1357
+ assert.equal(project?.execution, undefined);
1358
+ assert.equal(runCount, 1);
1359
+ assert.equal(project?.blockedReason?.includes("Blocked after 10 ACP restart attempts"), true);
1360
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-restart-cap", "Blocked after 10 ACP restart attempts"), true);
1361
+ });
1362
+
1363
+ test("watcher blocked message includes ACPX setup guidance when backend stays unavailable", async (t) => {
1364
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-backend-blocked-"));
1365
+ const workspacePath = path.join(tempRoot, "workspace");
1366
+ const repoPath = path.join(workspacePath, "demo-app");
1367
+ const changeName = "watch-work-backend-blocked";
1368
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
1369
+ const tasksPath = path.join(changeDir, "tasks.md");
1370
+ await mkdir(changeDir, { recursive: true });
1371
+ await writeUtf8(tasksPath, "- [ ] 1.1 Recover ACP backend setup\n");
1372
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
1373
+
1374
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
1375
+ await rollbackStore.initializeBaseline();
1376
+
1377
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
1378
+ await stateStore.initialize();
1379
+ const notifierMessages: string[] = [];
1380
+
1381
+ const fakeOpenSpec = {
1382
+ instructionsApply: async (cwd: string, cn: string) => ({
1383
+ command: `openspec instructions apply --change ${cn} --json`,
1384
+ cwd,
1385
+ stdout: "{}",
1386
+ stderr: "",
1387
+ durationMs: 1,
1388
+ parsed: {
1389
+ changeName: cn,
1390
+ changeDir,
1391
+ schemaName: "spec-driven",
1392
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
1393
+ progress: { total: 1, complete: 0, remaining: 1 },
1394
+ tasks: [{ id: "1.1", description: "Recover ACP backend setup", done: false }],
1395
+ state: "ready",
1396
+ instruction: "Implement the remaining task.",
1397
+ },
1398
+ }),
1399
+ } as any;
1400
+
1401
+ const fakeAcpClient = {
1402
+ agentId: "codex",
1403
+ runTurn: async () => {
1404
+ throw new Error("ACP runtime backend is currently unavailable. Try again in a moment.");
1405
+ },
1406
+ getSessionStatus: async () => undefined,
1407
+ cancelSession: async () => undefined,
1408
+ closeSession: async () => undefined,
1409
+ };
1410
+
1411
+ const manager = new WatcherManager({
1412
+ stateStore,
1413
+ openSpec: fakeOpenSpec,
1414
+ archiveDirName: "archives",
1415
+ logger: createLogger(),
1416
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1417
+ acpClient: fakeAcpClient as any,
1418
+ pollIntervalMs: 25,
1419
+ });
1420
+ t.after(async () => {
1421
+ await manager.stop();
1422
+ });
1423
+
1424
+ const channelKey = "discord:watch-work-backend-blocked:default:main";
1425
+ await stateStore.createProject(channelKey);
1426
+ await stateStore.updateProject(channelKey, (current) => ({
1427
+ ...current,
1428
+ workspacePath,
1429
+ repoPath,
1430
+ projectName: "demo-app",
1431
+ projectTitle: "Demo App",
1432
+ changeName,
1433
+ changeDir,
1434
+ status: "armed",
1435
+ phase: "implementing",
1436
+ currentTask: "1.1 Recover ACP backend setup",
1437
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
1438
+ planningJournal: { dirty: false, entryCount: 0 },
1439
+ rollback: {
1440
+ baselineRoot: rollbackStore.baselineRoot,
1441
+ manifestPath: rollbackStore.manifestPath,
1442
+ snapshotReady: true,
1443
+ touchedFileCount: 0,
1444
+ },
1445
+ execution: {
1446
+ mode: "apply",
1447
+ action: "work",
1448
+ state: "armed",
1449
+ armedAt: new Date().toISOString(),
1450
+ restartCount: 10,
1451
+ lastFailure: "previous backend unavailable",
1452
+ },
1453
+ }));
1454
+
1455
+ await manager.wake(channelKey);
1456
+ await waitFor(async () =>
1457
+ (await stateStore.getActiveProject(channelKey))?.status === "blocked",
1458
+ 8_000,
1459
+ );
1460
+
1461
+ const project = await stateStore.getActiveProject(channelKey);
1462
+ assert.equal(project?.status, "blocked");
1463
+ assert.equal(project?.blockedReason?.includes("Blocked after 10 ACP restart attempts"), true);
1464
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "Blocked: ACPX backend unavailable"), true);
1465
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "plugins.entries.acpx"), true);
1466
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "cs-work"), true);
1467
+ });
1468
+
1469
+ test("watcher retries when ACP runtime backend is temporarily unavailable", async (t) => {
1470
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-backend-unavailable-"));
1471
+ const workspacePath = path.join(tempRoot, "workspace");
1472
+ const repoPath = path.join(workspacePath, "demo-app");
1473
+ const changeName = "watch-work-backend-unavailable";
1474
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
1475
+ const tasksPath = path.join(changeDir, "tasks.md");
1476
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
1477
+ await mkdir(changeDir, { recursive: true });
1478
+ await writeUtf8(tasksPath, "- [ ] 1.1 Recover backend startup race\n");
1479
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
1480
+
1481
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
1482
+ await rollbackStore.initializeBaseline();
1483
+
1484
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
1485
+ await stateStore.initialize();
1486
+ const notifierMessages: string[] = [];
1487
+ let runCount = 0;
1488
+
1489
+ const fakeOpenSpec = {
1490
+ instructionsApply: async (cwd: string, cn: string) => {
1491
+ const done = (await readUtf8(tasksPath)).includes("- [x] 1.1 Recover backend startup race");
1492
+ return {
1493
+ command: `openspec instructions apply --change ${cn} --json`,
1494
+ cwd,
1495
+ stdout: "{}",
1496
+ stderr: "",
1497
+ durationMs: 1,
1498
+ parsed: {
1499
+ changeName: cn,
1500
+ changeDir,
1501
+ schemaName: "spec-driven",
1502
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
1503
+ progress: done ? { total: 1, complete: 1, remaining: 0 } : { total: 1, complete: 0, remaining: 1 },
1504
+ tasks: [{ id: "1.1", description: "Recover backend startup race", done }],
1505
+ state: done ? "all_done" : "ready",
1506
+ instruction: "Implement the remaining task.",
1507
+ },
1508
+ };
1509
+ },
1510
+ } as any;
1511
+
1512
+ const fakeAcpClient = {
1513
+ agentId: "codex",
1514
+ runTurn: async () => {
1515
+ runCount += 1;
1516
+ if (runCount === 1) {
1517
+ throw new Error("ACP runtime backend is currently unavailable. Try again in a moment.");
1518
+ }
1519
+
1520
+ await writeUtf8(tasksPath, "- [x] 1.1 Recover backend startup race\n");
1521
+ await writeJsonFile(repoStatePaths.executionResultFile, {
1522
+ version: 1,
1523
+ changeName,
1524
+ mode: "apply",
1525
+ status: "done",
1526
+ timestamp: new Date().toISOString(),
1527
+ summary: "Recovered after backend startup race.",
1528
+ progressMade: true,
1529
+ completedTask: "1.1 Recover backend startup race",
1530
+ changedFiles: ["openspec/changes/watch-work-backend-unavailable/tasks.md"],
1531
+ notes: ["Recovered after ACP runtime backend became ready."],
1532
+ taskCounts: { total: 1, complete: 1, remaining: 0 },
1533
+ remainingTasks: 0,
1534
+ });
1535
+ },
1536
+ getSessionStatus: async () => undefined,
1537
+ cancelSession: async () => undefined,
1538
+ closeSession: async () => undefined,
1539
+ };
1540
+
1541
+ const manager = new WatcherManager({
1542
+ stateStore,
1543
+ openSpec: fakeOpenSpec,
1544
+ archiveDirName: "archives",
1545
+ logger: createLogger(),
1546
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1547
+ acpClient: fakeAcpClient as any,
1548
+ pollIntervalMs: 25,
1549
+ });
1550
+ t.after(async () => {
1551
+ await manager.stop();
1552
+ });
1553
+
1554
+ const channelKey = "discord:watch-work-backend-unavailable:default:main";
1555
+ await stateStore.createProject(channelKey);
1556
+ await stateStore.updateProject(channelKey, (current) => ({
1557
+ ...current,
1558
+ workspacePath,
1559
+ repoPath,
1560
+ projectName: "demo-app",
1561
+ projectTitle: "Demo App",
1562
+ changeName,
1563
+ changeDir,
1564
+ status: "armed",
1565
+ phase: "implementing",
1566
+ currentTask: "1.1 Recover backend startup race",
1567
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
1568
+ planningJournal: { dirty: false, entryCount: 0 },
1569
+ rollback: {
1570
+ baselineRoot: rollbackStore.baselineRoot,
1571
+ manifestPath: rollbackStore.manifestPath,
1572
+ snapshotReady: true,
1573
+ touchedFileCount: 0,
1574
+ },
1575
+ execution: {
1576
+ mode: "apply",
1577
+ action: "work",
1578
+ state: "armed",
1579
+ armedAt: new Date().toISOString(),
1580
+ },
1581
+ }));
1582
+
1583
+ await manager.wake(channelKey);
1584
+ await waitFor(async () =>
1585
+ (await stateStore.getActiveProject(channelKey))?.status === "done",
1586
+ 8_000,
1587
+ );
1588
+
1589
+ const project = await stateStore.getActiveProject(channelKey);
1590
+ assert.equal(project?.status, "done");
1591
+ assert.equal(runCount, 2);
1592
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "Restarting ACP worker"), true);
1593
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "ACPX is unavailable"), true);
1594
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "plugins.entries.acpx"), true);
1595
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "All tasks complete"), true);
1596
+ });
1597
+
1598
+ test("watcher finalizes when terminal result exists before ACP turn exits", async (t) => {
1599
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-work-terminal-"));
1600
+ const workspacePath = path.join(tempRoot, "workspace");
1601
+ const repoPath = path.join(workspacePath, "demo-app");
1602
+ const changeName = "watch-work-terminal";
1603
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
1604
+ const tasksPath = path.join(changeDir, "tasks.md");
1605
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
1606
+ await mkdir(changeDir, { recursive: true });
1607
+ await writeUtf8(tasksPath, "- [ ] 1 Build the demo endpoint\n");
1608
+ await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
1609
+
1610
+ const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
1611
+ await rollbackStore.initializeBaseline();
1612
+
1613
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
1614
+ await stateStore.initialize();
1615
+ const notifierMessages: string[] = [];
1616
+ let cancelled = false;
1617
+
1618
+ const fakeOpenSpec = {
1619
+ instructionsApply: async (cwd: string, cn: string) => {
1620
+ const done = (await readUtf8(tasksPath)).includes("- [x] 1 Build the demo endpoint");
1621
+ return {
1622
+ command: `openspec instructions apply --change ${cn} --json`,
1623
+ cwd,
1624
+ stdout: "{}",
1625
+ stderr: "",
1626
+ durationMs: 1,
1627
+ parsed: {
1628
+ changeName: cn,
1629
+ changeDir,
1630
+ schemaName: "spec-driven",
1631
+ contextFiles: { proposal: path.join(changeDir, "proposal.md"), tasks: tasksPath },
1632
+ progress: done ? { total: 1, complete: 1, remaining: 0 } : { total: 1, complete: 0, remaining: 1 },
1633
+ tasks: [{ id: "1", description: "Build the demo endpoint", done }],
1634
+ state: done ? "all_done" : "ready",
1635
+ instruction: "Implement the remaining task.",
1636
+ },
1637
+ };
1638
+ },
1639
+ } as any;
1640
+
1641
+ const fakeAcpClient = {
1642
+ agentId: "codex",
1643
+ runTurn: async (params: { onEvent?: (event: { type: string; title?: string }) => Promise<void> | void }) => {
1644
+ const startEvent = JSON.stringify({
1645
+ version: 1,
1646
+ timestamp: new Date().toISOString(),
1647
+ kind: "task_start",
1648
+ current: 1,
1649
+ total: 1,
1650
+ taskId: "1",
1651
+ message: "Start 1: build the demo endpoint. Next: done.",
1652
+ });
1653
+ const doneEvent = JSON.stringify({
1654
+ version: 1,
1655
+ timestamp: new Date().toISOString(),
1656
+ kind: "task_done",
1657
+ current: 1,
1658
+ total: 1,
1659
+ taskId: "1",
1660
+ message: "Done 1: built the demo endpoint. Changed 1 files: openspec/changes/watch-work-terminal/tasks.md. Next: done.",
1661
+ });
1662
+ await writeUtf8(repoStatePaths.workerProgressFile, `${startEvent}\n${doneEvent}\n`);
1663
+ await writeUtf8(tasksPath, "- [x] 1 Build the demo endpoint\n");
1664
+ await writeJsonFile(repoStatePaths.executionResultFile, {
1665
+ version: 1,
1666
+ changeName,
1667
+ mode: "apply",
1668
+ status: "done",
1669
+ timestamp: new Date().toISOString(),
1670
+ summary: "Completed task 1.",
1671
+ progressMade: true,
1672
+ completedTask: "1 Build the demo endpoint",
1673
+ changedFiles: ["openspec/changes/watch-work-terminal/tasks.md"],
1674
+ notes: ["Task completed"],
1675
+ taskCounts: { total: 1, complete: 1, remaining: 0 },
1676
+ remainingTasks: 0,
1677
+ });
1678
+ await params.onEvent?.({ type: "tool_call", title: "worker-progress" });
1679
+ await waitFor(async () => cancelled, 4_000);
1680
+ },
1681
+ cancelSession: async () => { cancelled = true; },
1682
+ closeSession: async () => undefined,
1683
+ };
1684
+
1685
+ const manager = new WatcherManager({
1686
+ stateStore,
1687
+ openSpec: fakeOpenSpec,
1688
+ archiveDirName: "archives",
1689
+ logger: createLogger(),
1690
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1691
+ acpClient: fakeAcpClient as any,
1692
+ pollIntervalMs: 25,
1693
+ });
1694
+ t.after(async () => {
1695
+ await manager.stop();
1696
+ });
1697
+
1698
+ const channelKey = "discord:watch-work-terminal:default:main";
1699
+ await stateStore.createProject(channelKey);
1700
+ await stateStore.updateProject(channelKey, (current) => ({
1701
+ ...current,
1702
+ workspacePath,
1703
+ repoPath,
1704
+ projectName: "demo-app",
1705
+ projectTitle: "Demo App",
1706
+ changeName,
1707
+ changeDir,
1708
+ status: "armed",
1709
+ phase: "implementing",
1710
+ currentTask: "1 Build the demo endpoint",
1711
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
1712
+ planningJournal: { dirty: false, entryCount: 0 },
1713
+ rollback: {
1714
+ baselineRoot: rollbackStore.baselineRoot,
1715
+ manifestPath: rollbackStore.manifestPath,
1716
+ snapshotReady: true,
1717
+ touchedFileCount: 0,
1718
+ },
1719
+ execution: {
1720
+ mode: "apply",
1721
+ action: "work",
1722
+ state: "armed",
1723
+ armedAt: new Date().toISOString(),
1724
+ },
1725
+ }));
1726
+
1727
+ await manager.start();
1728
+ await manager.wake(channelKey);
1729
+ await waitFor(async () =>
1730
+ (await stateStore.getActiveProject(channelKey))?.status === "done"
1731
+ && hasMessage(notifierMessages, "demo-app-watch-work-terminal", "All tasks complete"),
1732
+ 8_000,
1733
+ );
1734
+
1735
+ const project = await stateStore.getActiveProject(channelKey);
1736
+ assert.equal(cancelled, true);
1737
+ assert.equal(project?.status, "done");
1738
+ assert.equal(project?.execution, undefined);
1739
+ assert.equal(project?.lastExecution?.status, "done");
1740
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-terminal", "[######] 1/1", "All tasks complete"), true);
1741
+ });