clawspec 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -38
- package/README.zh-CN.md +18 -38
- package/package.json +2 -2
- package/src/acp/openclaw-config.ts +66 -0
- package/src/config.ts +4 -5
- package/src/index.ts +4 -2
- package/src/orchestrator/service.ts +71 -29
- package/src/utils/paths.ts +2 -0
- package/src/watchers/manager.ts +8 -6
- package/src/worker/io-helper.ts +126 -0
- package/src/worker/prompts.ts +11 -1
- package/test/config.test.ts +2 -2
- package/test/file-lock.test.ts +13 -3
- package/test/helpers/harness.ts +27 -3
- package/test/queue-work.test.ts +52 -0
- package/test/service-archive.test.ts +7 -2
- package/test/watcher-work.test.ts +3 -3
- package/test/worker-io-helper.test.ts +97 -0
package/README.md
CHANGED
|
@@ -236,7 +236,7 @@ Current OpenClaw builds do not accept raw GitHub URLs as ordinary plugin install
|
|
|
236
236
|
|
|
237
237
|
If you want an unreleased commit before npm publish, clone the repository and install from the local checkout or a downloaded `.tgz` archive instead.
|
|
238
238
|
|
|
239
|
-
### 2. Enable ACP
|
|
239
|
+
### 2. Enable ACP in OpenClaw
|
|
240
240
|
|
|
241
241
|
Example `~/.openclaw/openclaw.json`:
|
|
242
242
|
|
|
@@ -249,18 +249,10 @@ Example `~/.openclaw/openclaw.json`:
|
|
|
249
249
|
},
|
|
250
250
|
"plugins": {
|
|
251
251
|
"entries": {
|
|
252
|
-
"acpx": {
|
|
253
|
-
"enabled": true,
|
|
254
|
-
"config": {
|
|
255
|
-
"permissionMode": "approve-all",
|
|
256
|
-
"expectedVersion": "any"
|
|
257
|
-
}
|
|
258
|
-
},
|
|
259
252
|
"clawspec": {
|
|
260
253
|
"enabled": true,
|
|
261
254
|
"config": {
|
|
262
255
|
"defaultWorkspace": "~/clawspec/workspace",
|
|
263
|
-
"workerAgentId": "codex",
|
|
264
256
|
"openSpecTimeoutMs": 120000,
|
|
265
257
|
"watcherPollIntervalMs": 4000
|
|
266
258
|
}
|
|
@@ -273,21 +265,21 @@ Example `~/.openclaw/openclaw.json`:
|
|
|
273
265
|
Important notes:
|
|
274
266
|
|
|
275
267
|
- Recent OpenClaw builds often bundle `acpx` under the host install. ClawSpec checks that builtin copy before falling back to `acpx` on `PATH` or a plugin-local install.
|
|
276
|
-
- If your OpenClaw build does not bundle `acpx`,
|
|
277
|
-
- ClawSpec itself
|
|
278
|
-
-
|
|
279
|
-
-
|
|
280
|
-
-
|
|
281
|
-
- In other words, ClawSpec does not currently inherit `acp.defaultAgent` automatically. If you want both to use the same agent, set both values explicitly.
|
|
268
|
+
- If your OpenClaw build does not bundle `acpx`, ClawSpec can fall back to a plugin-local install automatically.
|
|
269
|
+
- ClawSpec manages the `acpx` command path itself. You do not need to hardcode `plugins.entries.acpx.config.command`.
|
|
270
|
+
- `acp.defaultAgent` is now the global default used by ClawSpec background workers.
|
|
271
|
+
- `/clawspec worker <agent-id>` is still available as an explicit channel/project override.
|
|
272
|
+
- When ACP setup is incomplete, ClawSpec now shows ready-to-run commands such as `openclaw config set acp.backend acpx` and `openclaw config set acp.defaultAgent codex`.
|
|
282
273
|
|
|
283
274
|
### 2.5. Choose the default worker agent
|
|
284
275
|
|
|
285
|
-
ClawSpec can run background work with different ACP agents such as `codex` or `claude
|
|
276
|
+
ClawSpec can run background work with different ACP agents such as `codex` or `claude`.
|
|
277
|
+
There is now one global default source plus an optional per-channel override:
|
|
286
278
|
|
|
287
|
-
- `acp.defaultAgent`: the OpenClaw ACP default used by
|
|
288
|
-
-
|
|
279
|
+
- `acp.defaultAgent`: the OpenClaw ACP default used by ClawSpec workers
|
|
280
|
+
- `/clawspec worker <agent-id>`: an explicit override persisted for the current channel/project context
|
|
289
281
|
|
|
290
|
-
Recommended
|
|
282
|
+
Recommended global setup:
|
|
291
283
|
|
|
292
284
|
```json
|
|
293
285
|
{
|
|
@@ -295,16 +287,6 @@ Recommended if you want both layers aligned:
|
|
|
295
287
|
"enabled": true,
|
|
296
288
|
"backend": "acpx",
|
|
297
289
|
"defaultAgent": "claude"
|
|
298
|
-
},
|
|
299
|
-
"plugins": {
|
|
300
|
-
"entries": {
|
|
301
|
-
"clawspec": {
|
|
302
|
-
"enabled": true,
|
|
303
|
-
"config": {
|
|
304
|
-
"workerAgentId": "claude"
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
290
|
}
|
|
309
291
|
}
|
|
310
292
|
```
|
|
@@ -320,7 +302,7 @@ Notes:
|
|
|
320
302
|
|
|
321
303
|
- `/clawspec worker <agent-id>` is persisted at the current channel/project scope
|
|
322
304
|
- the chosen agent must exist in your OpenClaw agent list or ACP allowlist
|
|
323
|
-
- if you want a global default, set `
|
|
305
|
+
- if you want a global default, set `acp.defaultAgent` in OpenClaw config
|
|
324
306
|
- if you want a one-off override for the active project conversation, use `/clawspec worker <agent-id>`
|
|
325
307
|
|
|
326
308
|
### 2.6. ClawSpec plugin config reference
|
|
@@ -330,7 +312,6 @@ Common `plugins.entries.clawspec.config` fields:
|
|
|
330
312
|
| Key | Purpose | Notes |
|
|
331
313
|
| --- | --- | --- |
|
|
332
314
|
| `defaultWorkspace` | Default base directory used by `/clawspec workspace` and `/clawspec use` | Channel-specific workspace selection overrides this after first use |
|
|
333
|
-
| `workerAgentId` | Default ACP agent used by background workers | Can be overridden per channel/project with `/clawspec worker <agent-id>` |
|
|
334
315
|
| `openSpecTimeoutMs` | Timeout for each OpenSpec CLI invocation | Increase this if your repo or host is slow |
|
|
335
316
|
| `watcherPollIntervalMs` | Background watcher recovery poll interval | Controls how quickly recovery scans and replay checks run |
|
|
336
317
|
| `archiveDirName` | Directory name under `.openclaw/clawspec/` for archived bundles | Keep the default unless you need a different archive layout |
|
|
@@ -340,6 +321,7 @@ Backward-compatibility keys still accepted but currently treated as no-ops:
|
|
|
340
321
|
|
|
341
322
|
- `maxAutoContinueTurns`
|
|
342
323
|
- `maxNoProgressTurns`
|
|
324
|
+
- `workerAgentId`
|
|
343
325
|
- `workerBackendId`
|
|
344
326
|
- `workerWaitTimeoutMs`
|
|
345
327
|
- `subagentLane`
|
|
@@ -823,16 +805,15 @@ Cause:
|
|
|
823
805
|
|
|
824
806
|
- `acp.enabled` is false
|
|
825
807
|
- `acp.backend` is not `acpx`
|
|
826
|
-
- `
|
|
827
|
-
- your OpenClaw build does not bundle `acpx` and
|
|
808
|
+
- `acp.defaultAgent` is missing
|
|
809
|
+
- your OpenClaw build does not bundle `acpx` and ClawSpec could not find or install a usable copy
|
|
828
810
|
|
|
829
811
|
What to do:
|
|
830
812
|
|
|
831
|
-
1.
|
|
832
|
-
2. set
|
|
833
|
-
3.
|
|
834
|
-
4.
|
|
835
|
-
5. rerun `cs-work` or `/clawspec continue`
|
|
813
|
+
1. run `openclaw config set acp.backend acpx`
|
|
814
|
+
2. run `openclaw config set acp.defaultAgent codex`
|
|
815
|
+
3. if your host does not bundle ACPX, let ClawSpec install a plugin-local copy or install `acpx` manually
|
|
816
|
+
4. rerun `cs-work` or `/clawspec continue`
|
|
836
817
|
|
|
837
818
|
### Ordinary chat is polluting the planning journal
|
|
838
819
|
|
package/README.zh-CN.md
CHANGED
|
@@ -241,7 +241,7 @@ openclaw plugins install clawspec@latest
|
|
|
241
241
|
|
|
242
242
|
如果你要安装一个还没发布到 npm 的提交,请改用本地 checkout 或下载好的 `.tgz` 包安装。
|
|
243
243
|
|
|
244
|
-
### 2. 在 OpenClaw 里启用 ACP
|
|
244
|
+
### 2. 在 OpenClaw 里启用 ACP
|
|
245
245
|
|
|
246
246
|
示例 `~/.openclaw/openclaw.json`:
|
|
247
247
|
|
|
@@ -254,18 +254,10 @@ openclaw plugins install clawspec@latest
|
|
|
254
254
|
},
|
|
255
255
|
"plugins": {
|
|
256
256
|
"entries": {
|
|
257
|
-
"acpx": {
|
|
258
|
-
"enabled": true,
|
|
259
|
-
"config": {
|
|
260
|
-
"permissionMode": "approve-all",
|
|
261
|
-
"expectedVersion": "any"
|
|
262
|
-
}
|
|
263
|
-
},
|
|
264
257
|
"clawspec": {
|
|
265
258
|
"enabled": true,
|
|
266
259
|
"config": {
|
|
267
260
|
"defaultWorkspace": "~/clawspec/workspace",
|
|
268
|
-
"workerAgentId": "codex",
|
|
269
261
|
"openSpecTimeoutMs": 120000,
|
|
270
262
|
"watcherPollIntervalMs": 4000
|
|
271
263
|
}
|
|
@@ -278,21 +270,20 @@ openclaw plugins install clawspec@latest
|
|
|
278
270
|
这里要注意:
|
|
279
271
|
|
|
280
272
|
- 新版 OpenClaw 往往已经自带 `acpx`,ClawSpec 现在会先检查这份 builtin ACPX,再回退到系统 `PATH` 或插件本地安装。
|
|
281
|
-
- 如果你的 OpenClaw 没有自带 `acpx
|
|
282
|
-
-
|
|
283
|
-
-
|
|
284
|
-
-
|
|
285
|
-
-
|
|
286
|
-
- 也就是说,ClawSpec 现在不会自动继承 `acp.defaultAgent`。如果你希望两边都用同一个 agent,需要把两处都显式配成一样。
|
|
273
|
+
- 如果你的 OpenClaw 没有自带 `acpx`,ClawSpec 会优先尝试自动落一份插件本地可用的 `acpx`。
|
|
274
|
+
- `acpx` 的命令路径现在由 ClawSpec 自己管理,不需要你再手工写 `plugins.entries.acpx.config.command`。
|
|
275
|
+
- `acp.defaultAgent` 现在就是 ClawSpec 后台 worker 的全局默认 agent。
|
|
276
|
+
- `/clawspec worker <agent-id>` 仍然可以作为当前 channel/project 的显式覆盖。
|
|
277
|
+
- 如果 ACP 配置不完整,ClawSpec 会直接提示可执行命令,比如 `openclaw config set acp.backend acpx` 和 `openclaw config set acp.defaultAgent codex`。
|
|
287
278
|
|
|
288
279
|
### 2.5. 选择默认 worker agent
|
|
289
280
|
|
|
290
|
-
ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或 `claude
|
|
281
|
+
ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或 `claude`。现在默认值来源只有一层,再加一个可选覆盖:
|
|
291
282
|
|
|
292
|
-
- `acp.defaultAgent`:
|
|
293
|
-
-
|
|
283
|
+
- `acp.defaultAgent`:ClawSpec 后台 worker 读取的全局默认 ACP agent
|
|
284
|
+
- `/clawspec worker <agent-id>`:当前 channel/project 的显式覆盖
|
|
294
285
|
|
|
295
|
-
|
|
286
|
+
推荐的全局配置:
|
|
296
287
|
|
|
297
288
|
```json
|
|
298
289
|
{
|
|
@@ -300,16 +291,6 @@ ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或
|
|
|
300
291
|
"enabled": true,
|
|
301
292
|
"backend": "acpx",
|
|
302
293
|
"defaultAgent": "claude"
|
|
303
|
-
},
|
|
304
|
-
"plugins": {
|
|
305
|
-
"entries": {
|
|
306
|
-
"clawspec": {
|
|
307
|
-
"enabled": true,
|
|
308
|
-
"config": {
|
|
309
|
-
"workerAgentId": "claude"
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
294
|
}
|
|
314
295
|
}
|
|
315
296
|
```
|
|
@@ -325,7 +306,7 @@ ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或
|
|
|
325
306
|
|
|
326
307
|
- `/clawspec worker <agent-id>` 的覆盖范围是当前 channel/project
|
|
327
308
|
- 你填写的 agent id 必须已经存在于 OpenClaw 的 agent 列表或 ACP allowlist 中
|
|
328
|
-
- 如果你想配置“全局默认”,改的是 `
|
|
309
|
+
- 如果你想配置“全局默认”,改的是 `acp.defaultAgent`
|
|
329
310
|
- 如果你想临时对当前项目改用另一个 agent,就直接执行 `/clawspec worker <agent-id>`
|
|
330
311
|
|
|
331
312
|
### 2.6. ClawSpec 插件配置项参考
|
|
@@ -335,7 +316,6 @@ ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或
|
|
|
335
316
|
| Key | 用途 | 说明 |
|
|
336
317
|
| --- | --- | --- |
|
|
337
318
|
| `defaultWorkspace` | `/clawspec workspace` 和 `/clawspec use` 的默认 workspace | 某个 channel 第一次选定 workspace 后,会优先使用该 channel 自己记住的值 |
|
|
338
|
-
| `workerAgentId` | 后台 worker 默认使用的 ACP agent | 可以被 `/clawspec worker <agent-id>` 在当前 channel/project 覆盖 |
|
|
339
319
|
| `openSpecTimeoutMs` | 每次 OpenSpec CLI 调用的超时时间 | repo 较大或主机较慢时可以调大 |
|
|
340
320
|
| `watcherPollIntervalMs` | watcher 的后台恢复扫描周期 | 影响恢复检查和进度补发的灵敏度 |
|
|
341
321
|
| `archiveDirName` | `.openclaw/clawspec/` 下归档目录名称 | 除非你要调整归档布局,否则保持默认即可 |
|
|
@@ -345,6 +325,7 @@ ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或
|
|
|
345
325
|
|
|
346
326
|
- `maxAutoContinueTurns`
|
|
347
327
|
- `maxNoProgressTurns`
|
|
328
|
+
- `workerAgentId`
|
|
348
329
|
- `workerBackendId`
|
|
349
330
|
- `workerWaitTimeoutMs`
|
|
350
331
|
- `subagentLane`
|
|
@@ -835,16 +816,15 @@ OpenSpec change 本身仍然放在标准目录:
|
|
|
835
816
|
|
|
836
817
|
- `acp.enabled` 没开
|
|
837
818
|
- `acp.backend` 不是 `acpx`
|
|
838
|
-
- `
|
|
839
|
-
- 你的 OpenClaw 没有自带 ACPX
|
|
819
|
+
- `acp.defaultAgent` 没配
|
|
820
|
+
- 你的 OpenClaw 没有自带 ACPX,且 ClawSpec 也没能找到或安装可用副本
|
|
840
821
|
|
|
841
822
|
处理方式:
|
|
842
823
|
|
|
843
|
-
1.
|
|
844
|
-
2.
|
|
845
|
-
3.
|
|
846
|
-
4.
|
|
847
|
-
5. 再运行 `cs-work` 或 `/clawspec continue`
|
|
824
|
+
1. 运行 `openclaw config set acp.backend acpx`
|
|
825
|
+
2. 运行 `openclaw config set acp.defaultAgent codex`
|
|
826
|
+
3. 如果宿主没自带 ACPX,就让 ClawSpec 自动安装插件本地副本,或手工安装 `acpx`
|
|
827
|
+
4. 再运行 `cs-work` 或 `/clawspec continue`
|
|
848
828
|
|
|
849
829
|
### 普通聊天污染了 planning journal
|
|
850
830
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawspec",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
|
|
6
6
|
"keywords": [
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"check": "node --experimental-strip-types -e \"import('./index.ts')\"",
|
|
45
45
|
"test": "node --experimental-strip-types --test --test-reporter spec test/*.test.ts",
|
|
46
|
-
"test:fast": "node --experimental-strip-types --test --test-reporter spec test/acp-client.test.ts test/acpx-dependency.test.ts test/assistant-journal.test.ts test/command-surface.test.ts test/config.test.ts test/detach-attach.test.ts test/file-lock.test.ts test/fs-utils.test.ts test/helpers.test.ts test/keywords.test.ts test/notifier.test.ts test/openspec-dependency.test.ts test/pause-cancel.test.ts test/planning-journal.test.ts test/plugin-registration.test.ts test/project-memory.test.ts test/proposal.test.ts test/queue-planning.test.ts test/queue-work.test.ts test/service-archive.test.ts test/shell-command.test.ts test/state-store.test.ts test/tasks-and-checkpoint.test.ts test/use-project.test.ts test/worker-command.test.ts test/worker-skills.test.ts",
|
|
46
|
+
"test:fast": "node --experimental-strip-types --test --test-reporter spec test/acp-client.test.ts test/acpx-dependency.test.ts test/assistant-journal.test.ts test/command-surface.test.ts test/config.test.ts test/detach-attach.test.ts test/file-lock.test.ts test/fs-utils.test.ts test/helpers.test.ts test/keywords.test.ts test/notifier.test.ts test/openspec-dependency.test.ts test/pause-cancel.test.ts test/planning-journal.test.ts test/plugin-registration.test.ts test/project-memory.test.ts test/proposal.test.ts test/queue-planning.test.ts test/queue-work.test.ts test/service-archive.test.ts test/shell-command.test.ts test/state-store.test.ts test/tasks-and-checkpoint.test.ts test/use-project.test.ts test/worker-command.test.ts test/worker-io-helper.test.ts test/worker-skills.test.ts",
|
|
47
47
|
"test:slow": "node --experimental-strip-types --test --test-reporter spec test/watcher-planning.test.ts test/watcher-work.test.ts test/recovery.test.ts"
|
|
48
48
|
},
|
|
49
49
|
"engines": {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
type ConfigLike = OpenClawConfig | Record<string, unknown> | undefined;
|
|
4
|
+
|
|
5
|
+
export function getConfiguredDefaultWorkerAgent(config: ConfigLike): string | undefined {
|
|
6
|
+
const acp = getAcpConfig(config);
|
|
7
|
+
return asOptionalString(acp?.defaultAgent);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function listConfiguredWorkerAgents(config: ConfigLike): string[] {
|
|
11
|
+
const acp = getAcpConfig(config);
|
|
12
|
+
const allowed = Array.isArray(acp?.allowedAgents)
|
|
13
|
+
? acp.allowedAgents.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
14
|
+
: [];
|
|
15
|
+
const agentsConfig = getAgentsConfig(config);
|
|
16
|
+
const listed = Array.isArray(agentsConfig?.list)
|
|
17
|
+
? agentsConfig.list
|
|
18
|
+
.map((entry) => (entry && typeof entry === "object" ? (entry as Record<string, unknown>).id : undefined))
|
|
19
|
+
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
20
|
+
: [];
|
|
21
|
+
const defaultAgent = getConfiguredDefaultWorkerAgent(config);
|
|
22
|
+
return Array.from(new Set([
|
|
23
|
+
...(defaultAgent ? [defaultAgent] : []),
|
|
24
|
+
...allowed,
|
|
25
|
+
...listed,
|
|
26
|
+
])).sort((left, right) => left.localeCompare(right));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildWorkerAgentSetupHint(action: "plan" | "work"): string {
|
|
30
|
+
const rerunCommand = action === "plan" ? "`cs-plan`" : "`cs-work`";
|
|
31
|
+
return `Run \`openclaw config set acp.defaultAgent codex\`, then rerun ${rerunCommand}.`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildWorkerAgentSetupMessage(action: "plan" | "work"): string {
|
|
35
|
+
const rerunCommand = action === "plan" ? "`cs-plan`" : "`cs-work`";
|
|
36
|
+
const scope = action === "plan" ? "planning" : "workers";
|
|
37
|
+
return [
|
|
38
|
+
`OpenClaw ACP is not configured for ClawSpec ${scope}.`,
|
|
39
|
+
"Run:",
|
|
40
|
+
"- `openclaw config set acp.backend acpx`",
|
|
41
|
+
"- `openclaw config set acp.defaultAgent codex`",
|
|
42
|
+
`Then rerun ${rerunCommand}. Replace \`codex\` with another ACP agent id if needed.`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getAcpConfig(config: ConfigLike): Record<string, unknown> | undefined {
|
|
47
|
+
const record = asRecord(config);
|
|
48
|
+
return record?.acp && typeof record.acp === "object"
|
|
49
|
+
? record.acp as Record<string, unknown>
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getAgentsConfig(config: ConfigLike): Record<string, unknown> | undefined {
|
|
54
|
+
const record = asRecord(config);
|
|
55
|
+
return record?.agents && typeof record.agents === "object"
|
|
56
|
+
? record.agents as Record<string, unknown>
|
|
57
|
+
: undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function asRecord(value: ConfigLike): Record<string, unknown> | undefined {
|
|
61
|
+
return value && typeof value === "object" ? value as Record<string, unknown> : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function asOptionalString(value: unknown): string | undefined {
|
|
65
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
66
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type ClawSpecPluginConfig = {
|
|
|
11
11
|
maxAutoContinueTurns: number;
|
|
12
12
|
maxNoProgressTurns: number;
|
|
13
13
|
workerWaitTimeoutMs: number;
|
|
14
|
-
workerAgentId
|
|
14
|
+
workerAgentId?: string;
|
|
15
15
|
workerBackendId?: string;
|
|
16
16
|
watcherPollIntervalMs: number;
|
|
17
17
|
subagentLane?: string;
|
|
@@ -105,8 +105,8 @@ export const clawspecPluginConfigSchema: OpenClawPluginConfigSchema = {
|
|
|
105
105
|
advanced: true,
|
|
106
106
|
},
|
|
107
107
|
workerAgentId: {
|
|
108
|
-
label: "Worker Agent",
|
|
109
|
-
help: "
|
|
108
|
+
label: "Deprecated Worker Agent",
|
|
109
|
+
help: "Deprecated no-op. Configure `acp.defaultAgent` in OpenClaw instead.",
|
|
110
110
|
advanced: true,
|
|
111
111
|
},
|
|
112
112
|
workerBackendId: {
|
|
@@ -142,7 +142,6 @@ const DEFAULT_CONFIG: ClawSpecPluginConfig = {
|
|
|
142
142
|
maxNoProgressTurns: 2,
|
|
143
143
|
openSpecTimeoutMs: 120_000,
|
|
144
144
|
workerWaitTimeoutMs: 300_000,
|
|
145
|
-
workerAgentId: "codex",
|
|
146
145
|
watcherPollIntervalMs: 4_000,
|
|
147
146
|
archiveDirName: "archives",
|
|
148
147
|
defaultWorkspace: getDefaultWorkspacePath(),
|
|
@@ -181,7 +180,7 @@ export function parsePluginConfig(
|
|
|
181
180
|
10_000,
|
|
182
181
|
3_600_000,
|
|
183
182
|
),
|
|
184
|
-
workerAgentId: asOptionalString(config.workerAgentId)
|
|
183
|
+
workerAgentId: asOptionalString(config.workerAgentId),
|
|
185
184
|
workerBackendId: asOptionalString(config.workerBackendId),
|
|
186
185
|
watcherPollIntervalMs: asInt(
|
|
187
186
|
config.watcherPollIntervalMs,
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { ClawSpecNotifier } from "./watchers/notifier.ts";
|
|
|
18
18
|
import { WatcherManager } from "./watchers/manager.ts";
|
|
19
19
|
import { ensureOpenSpecCli } from "./dependencies/openspec.ts";
|
|
20
20
|
import { ensureAcpxCli } from "./dependencies/acpx.ts";
|
|
21
|
+
import { getConfiguredDefaultWorkerAgent } from "./acp/openclaw-config.ts";
|
|
21
22
|
|
|
22
23
|
const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
24
|
const LOCAL_BIN_DIR = path.join(PLUGIN_ROOT, "node_modules", ".bin");
|
|
@@ -71,8 +72,9 @@ const plugin = {
|
|
|
71
72
|
pluginRoot: PLUGIN_ROOT,
|
|
72
73
|
logger: api.logger,
|
|
73
74
|
});
|
|
75
|
+
const configuredDefaultWorkerAgent = getConfiguredDefaultWorkerAgent(api.config) ?? "codex";
|
|
74
76
|
const acpClient = new AcpWorkerClient({
|
|
75
|
-
agentId:
|
|
77
|
+
agentId: configuredDefaultWorkerAgent,
|
|
76
78
|
logger: api.logger,
|
|
77
79
|
command: acpx.command,
|
|
78
80
|
env: acpx.env,
|
|
@@ -96,7 +98,7 @@ const plugin = {
|
|
|
96
98
|
archiveDirName: config.archiveDirName,
|
|
97
99
|
allowedChannels: config.allowedChannels,
|
|
98
100
|
defaultWorkspace: config.defaultWorkspace,
|
|
99
|
-
defaultWorkerAgentId:
|
|
101
|
+
defaultWorkerAgentId: undefined,
|
|
100
102
|
workspaceStore,
|
|
101
103
|
watcherManager,
|
|
102
104
|
});
|
|
@@ -83,6 +83,12 @@ import { slugToTitle } from "../utils/slug.ts";
|
|
|
83
83
|
import { WorkspaceStore } from "../workspace/store.ts";
|
|
84
84
|
import { isExecutionTriggerText, readExecutionResult } from "../execution/state.ts";
|
|
85
85
|
import { createWorkerSessionKey, matchesExecutionSession } from "../execution/session.ts";
|
|
86
|
+
import {
|
|
87
|
+
buildWorkerAgentSetupHint,
|
|
88
|
+
buildWorkerAgentSetupMessage,
|
|
89
|
+
getConfiguredDefaultWorkerAgent,
|
|
90
|
+
listConfiguredWorkerAgents,
|
|
91
|
+
} from "../acp/openclaw-config.ts";
|
|
86
92
|
import {
|
|
87
93
|
buildExecutionPrependContext,
|
|
88
94
|
buildExecutionSystemContext,
|
|
@@ -130,7 +136,7 @@ type ClawSpecServiceOptions = {
|
|
|
130
136
|
openSpec: OpenSpecClient;
|
|
131
137
|
archiveDirName: string;
|
|
132
138
|
defaultWorkspace: string;
|
|
133
|
-
defaultWorkerAgentId
|
|
139
|
+
defaultWorkerAgentId?: string;
|
|
134
140
|
workspaceStore: WorkspaceStore;
|
|
135
141
|
allowedChannels?: string[];
|
|
136
142
|
maxAutoContinueTurns?: number;
|
|
@@ -155,7 +161,7 @@ export class ClawSpecService {
|
|
|
155
161
|
readonly openSpec: OpenSpecClient;
|
|
156
162
|
readonly archiveDirName: string;
|
|
157
163
|
readonly defaultWorkspace: string;
|
|
158
|
-
readonly defaultWorkerAgentId
|
|
164
|
+
readonly defaultWorkerAgentId?: string;
|
|
159
165
|
readonly workspaceStore: WorkspaceStore;
|
|
160
166
|
readonly allowedChannels?: string[];
|
|
161
167
|
readonly watcherManager?: WatcherManager;
|
|
@@ -1062,7 +1068,7 @@ export class ClawSpecService {
|
|
|
1062
1068
|
...base,
|
|
1063
1069
|
contextMode: "attached",
|
|
1064
1070
|
workspacePath: project.workspacePath ?? workspacePath,
|
|
1065
|
-
workerAgentId: base.workerAgentId ?? current.workerAgentId
|
|
1071
|
+
workerAgentId: base.workerAgentId ?? current.workerAgentId,
|
|
1066
1072
|
repoPath,
|
|
1067
1073
|
projectName,
|
|
1068
1074
|
projectTitle: sameRepo ? base.projectTitle : projectName,
|
|
@@ -1325,7 +1331,8 @@ export class ClawSpecService {
|
|
|
1325
1331
|
const project = await this.ensureSessionProject(channelKey, workspacePath);
|
|
1326
1332
|
const requestedAgent = rawArgs.trim();
|
|
1327
1333
|
const availableAgents = this.listAvailableWorkerAgents();
|
|
1328
|
-
const currentAgent = project.workerAgentId ?? this.
|
|
1334
|
+
const currentAgent = project.workerAgentId ?? this.getDefaultWorkerAgentId();
|
|
1335
|
+
const defaultAgent = this.getDefaultWorkerAgentId();
|
|
1329
1336
|
|
|
1330
1337
|
if (requestedAgent.toLowerCase() === "status") {
|
|
1331
1338
|
return okReply(await this.buildWorkerStatusText(project, availableAgents));
|
|
@@ -1335,9 +1342,12 @@ export class ClawSpecService {
|
|
|
1335
1342
|
const lines = [
|
|
1336
1343
|
heading("Worker Agent"),
|
|
1337
1344
|
"",
|
|
1338
|
-
`Current worker agent:
|
|
1339
|
-
`Default worker agent:
|
|
1345
|
+
`Current worker agent: ${formatWorkerAgent(currentAgent)}`,
|
|
1346
|
+
`Default worker agent: ${formatWorkerAgent(defaultAgent)}`,
|
|
1340
1347
|
availableAgents.length > 0 ? `Available agents: ${availableAgents.map((agentId) => `\`${agentId}\``).join(", ")}` : "",
|
|
1348
|
+
!defaultAgent && !project.workerAgentId
|
|
1349
|
+
? `OpenClaw ACP default is missing. ${buildWorkerAgentSetupHint("work")}`
|
|
1350
|
+
: "",
|
|
1341
1351
|
"",
|
|
1342
1352
|
"Use `/clawspec worker <agent-id>` to change the ACP worker agent for this channel/project context.",
|
|
1343
1353
|
].filter(Boolean);
|
|
@@ -1364,7 +1374,7 @@ export class ClawSpecService {
|
|
|
1364
1374
|
[
|
|
1365
1375
|
heading("Worker Agent Updated"),
|
|
1366
1376
|
"",
|
|
1367
|
-
`Worker agent:
|
|
1377
|
+
`Worker agent: ${formatWorkerAgent(updated.workerAgentId)}`,
|
|
1368
1378
|
"Future background implementation turns will use this ACP agent.",
|
|
1369
1379
|
].join("\n"),
|
|
1370
1380
|
);
|
|
@@ -1415,6 +1425,10 @@ export class ClawSpecService {
|
|
|
1415
1425
|
if (hasBlockingExecution(project)) {
|
|
1416
1426
|
return errorReply(BLOCKING_EXECUTION_MSG);
|
|
1417
1427
|
}
|
|
1428
|
+
const workerConfig = this.validateWorkerAgentConfiguration(project, "work");
|
|
1429
|
+
if (!workerConfig.ok) {
|
|
1430
|
+
return workerConfig.result;
|
|
1431
|
+
}
|
|
1418
1432
|
|
|
1419
1433
|
const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
|
|
1420
1434
|
const outputs: OpenSpecCommandResult[] = [];
|
|
@@ -1483,6 +1497,7 @@ export class ClawSpecService {
|
|
|
1483
1497
|
}
|
|
1484
1498
|
|
|
1485
1499
|
const armedAt = new Date().toISOString();
|
|
1500
|
+
const workerAgentId = workerConfig.agentId;
|
|
1486
1501
|
|
|
1487
1502
|
await removeIfExists(repoStatePaths.executionResultFile);
|
|
1488
1503
|
const remainingTasks = apply.tasks.filter((task) => !task.done);
|
|
@@ -1503,12 +1518,12 @@ export class ClawSpecService {
|
|
|
1503
1518
|
mode,
|
|
1504
1519
|
action: "work",
|
|
1505
1520
|
state: "armed",
|
|
1506
|
-
workerAgentId
|
|
1521
|
+
workerAgentId,
|
|
1507
1522
|
workerSlot: "primary",
|
|
1508
1523
|
armedAt,
|
|
1509
1524
|
sessionKey: createWorkerSessionKey(current, {
|
|
1510
1525
|
workerSlot: "primary",
|
|
1511
|
-
workerAgentId
|
|
1526
|
+
workerAgentId,
|
|
1512
1527
|
attemptKey: armedAt,
|
|
1513
1528
|
}),
|
|
1514
1529
|
},
|
|
@@ -1885,14 +1900,13 @@ export class ClawSpecService {
|
|
|
1885
1900
|
private async ensureSessionProject(channelKey: string, workspacePath: string): Promise<ProjectState> {
|
|
1886
1901
|
const existing = await this.stateStore.getActiveProject(channelKey);
|
|
1887
1902
|
if (existing) {
|
|
1888
|
-
if (existing.workspacePath
|
|
1903
|
+
if (existing.workspacePath) {
|
|
1889
1904
|
return existing;
|
|
1890
1905
|
}
|
|
1891
1906
|
return await this.stateStore.updateProject(channelKey, (current) => ({
|
|
1892
1907
|
...current,
|
|
1893
1908
|
contextMode: current.contextMode ?? "attached",
|
|
1894
1909
|
workspacePath: current.workspacePath ?? workspacePath,
|
|
1895
|
-
workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
|
|
1896
1910
|
status: current.status || "idle",
|
|
1897
1911
|
phase: current.phase || "init",
|
|
1898
1912
|
}));
|
|
@@ -1904,29 +1918,22 @@ export class ClawSpecService {
|
|
|
1904
1918
|
...current,
|
|
1905
1919
|
contextMode: current.contextMode ?? "attached",
|
|
1906
1920
|
workspacePath,
|
|
1907
|
-
workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
|
|
1908
1921
|
status: "idle",
|
|
1909
1922
|
phase: "init",
|
|
1910
1923
|
}));
|
|
1911
1924
|
}
|
|
1912
1925
|
|
|
1913
1926
|
private listAvailableWorkerAgents(): string[] {
|
|
1914
|
-
const
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
const agentsConfig = config.agents as Record<string, unknown> | undefined;
|
|
1920
|
-
const listed = Array.isArray(agentsConfig?.list)
|
|
1921
|
-
? agentsConfig.list
|
|
1922
|
-
.map((entry) => (entry && typeof entry === "object" ? (entry as Record<string, unknown>).id : undefined))
|
|
1923
|
-
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
1924
|
-
: [];
|
|
1925
|
-
return Array.from(new Set([...allowed, ...listed])).sort((left, right) => left.localeCompare(right));
|
|
1927
|
+
const configured = listConfiguredWorkerAgents(this.config);
|
|
1928
|
+
if (configured.length > 0) {
|
|
1929
|
+
return configured;
|
|
1930
|
+
}
|
|
1931
|
+
return this.defaultWorkerAgentId ? [this.defaultWorkerAgentId] : [];
|
|
1926
1932
|
}
|
|
1927
1933
|
|
|
1928
1934
|
private async buildWorkerStatusText(project: ProjectState, availableAgents: string[]): Promise<string> {
|
|
1929
|
-
const configuredAgent =
|
|
1935
|
+
const configuredAgent = this.getEffectiveWorkerAgentId(project);
|
|
1936
|
+
const defaultAgent = this.getDefaultWorkerAgentId();
|
|
1930
1937
|
const execution = project.execution;
|
|
1931
1938
|
const taskCounts = project.taskCounts;
|
|
1932
1939
|
const runtimeStatus = this.watcherManager?.getWorkerRuntimeStatus
|
|
@@ -1961,9 +1968,10 @@ export class ClawSpecService {
|
|
|
1961
1968
|
`Context: \`${isProjectContextAttached(project) ? "attached" : "detached"}\``,
|
|
1962
1969
|
`Phase: \`${project.phase}\``,
|
|
1963
1970
|
`Lifecycle: \`${project.status}\``,
|
|
1964
|
-
`Configured worker agent:
|
|
1965
|
-
`Default worker agent:
|
|
1971
|
+
`Configured worker agent: ${formatWorkerAgent(configuredAgent)}`,
|
|
1972
|
+
`Default worker agent: ${formatWorkerAgent(defaultAgent)}`,
|
|
1966
1973
|
availableAgents.length > 0 ? `Available agents: ${availableAgents.map((agentId) => `\`${agentId}\``).join(", ")}` : "",
|
|
1974
|
+
!configuredAgent ? `Worker setup: ${buildWorkerAgentSetupHint("work")}` : "",
|
|
1967
1975
|
`Execution state: \`${execution?.state ?? "idle"}\``,
|
|
1968
1976
|
`Worker transport: \`${transportMode}\``,
|
|
1969
1977
|
`Action: \`${execution?.action ?? "none"}\``,
|
|
@@ -2667,7 +2675,7 @@ export class ClawSpecService {
|
|
|
2667
2675
|
: project.status === "planning"
|
|
2668
2676
|
? "visible-chat"
|
|
2669
2677
|
: "idle";
|
|
2670
|
-
const workerAgent =
|
|
2678
|
+
const workerAgent = this.getEffectiveWorkerAgentId(project);
|
|
2671
2679
|
const showLastExecution = !project.execution;
|
|
2672
2680
|
const latestExecutionSummary = showLastExecution && project.lastExecution
|
|
2673
2681
|
? formatExecutionSummary(project.lastExecution)
|
|
@@ -2696,12 +2704,13 @@ export class ClawSpecService {
|
|
|
2696
2704
|
`Repo path: ${project.repoPath ? `\`${project.repoPath}\`` : "_unset_"}`,
|
|
2697
2705
|
`Change: ${project.changeName ? `\`${project.changeName}\`` : "_none_"}`,
|
|
2698
2706
|
`Context: \`${isProjectContextAttached(project) ? "attached" : "detached"}\``,
|
|
2699
|
-
`Worker agent:
|
|
2707
|
+
`Worker agent: ${formatWorkerAgent(workerAgent)}`,
|
|
2700
2708
|
`Lifecycle: \`${project.status}\``,
|
|
2701
2709
|
`Phase: \`${project.phase}\``,
|
|
2702
2710
|
`Execution: \`${executionStatus}\``,
|
|
2703
2711
|
formatProjectTaskCounts(project, taskCounts),
|
|
2704
2712
|
`Planning journal: ${project.planningJournal?.dirty ? "dirty" : "clean"} (${project.planningJournal?.entryCount ?? 0} entries)`,
|
|
2713
|
+
!workerAgent ? `Worker setup: ${buildWorkerAgentSetupHint("work")}` : "",
|
|
2705
2714
|
nextStepHint ? `Next step: ${nextStepHint}` : "",
|
|
2706
2715
|
project.latestSummary ? `Latest summary: ${project.latestSummary}` : "Latest summary: _none_",
|
|
2707
2716
|
project.blockedReason ? `Blocked reason: ${project.blockedReason}` : "",
|
|
@@ -2717,6 +2726,39 @@ export class ClawSpecService {
|
|
|
2717
2726
|
|
|
2718
2727
|
return lines.filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n");
|
|
2719
2728
|
}
|
|
2729
|
+
|
|
2730
|
+
private getDefaultWorkerAgentId(): string | undefined {
|
|
2731
|
+
return getConfiguredDefaultWorkerAgent(this.config) ?? this.defaultWorkerAgentId;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
private getEffectiveWorkerAgentId(project: ProjectState): string | undefined {
|
|
2735
|
+
return project.execution?.workerAgentId ?? project.workerAgentId ?? this.getDefaultWorkerAgentId();
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
private validateWorkerAgentConfiguration(
|
|
2739
|
+
project: ProjectState,
|
|
2740
|
+
action: "plan" | "work",
|
|
2741
|
+
): { ok: true; agentId: string } | { ok: false; result: PluginCommandResult } {
|
|
2742
|
+
const workerAgentId = this.getEffectiveWorkerAgentId(project);
|
|
2743
|
+
if (workerAgentId) {
|
|
2744
|
+
return { ok: true, agentId: workerAgentId };
|
|
2745
|
+
}
|
|
2746
|
+
return {
|
|
2747
|
+
ok: false,
|
|
2748
|
+
result: errorReply(
|
|
2749
|
+
[
|
|
2750
|
+
heading("Worker Setup Required"),
|
|
2751
|
+
"",
|
|
2752
|
+
buildWorkerAgentSetupMessage(action),
|
|
2753
|
+
"ClawSpec manages the `acpx` command automatically; only the OpenClaw ACP agent selection is missing.",
|
|
2754
|
+
].join("\n"),
|
|
2755
|
+
),
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
function formatWorkerAgent(agentId: string | undefined): string {
|
|
2761
|
+
return agentId ? `\`${agentId}\`` : "_not configured_";
|
|
2720
2762
|
}
|
|
2721
2763
|
|
|
2722
2764
|
function describeWorkerTransportMode(project: ProjectState): string {
|
package/src/utils/paths.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type RepoStatePaths = {
|
|
|
7
7
|
executionControlFile: string;
|
|
8
8
|
executionResultFile: string;
|
|
9
9
|
workerProgressFile: string;
|
|
10
|
+
workerIoFile: string;
|
|
10
11
|
progressFile: string;
|
|
11
12
|
changedFilesFile: string;
|
|
12
13
|
decisionLogFile: string;
|
|
@@ -66,6 +67,7 @@ export function getRepoStatePaths(repoPath: string, archiveDirName: string): Rep
|
|
|
66
67
|
executionControlFile: path.join(root, "execution-control.json"),
|
|
67
68
|
executionResultFile: path.join(root, "execution-result.json"),
|
|
68
69
|
workerProgressFile: path.join(root, "worker-progress.jsonl"),
|
|
70
|
+
workerIoFile: path.join(root, "worker_io.mjs"),
|
|
69
71
|
progressFile: path.join(root, "progress.md"),
|
|
70
72
|
changedFilesFile: path.join(root, "changed-files.md"),
|
|
71
73
|
decisionLogFile: path.join(root, "decision-log.md"),
|
package/src/watchers/manager.ts
CHANGED
|
@@ -30,8 +30,10 @@ import {
|
|
|
30
30
|
} from "../utils/fs.ts";
|
|
31
31
|
import { getChangeDir, getRepoStatePaths, getTasksPath, resolveProjectScopedPath } from "../utils/paths.ts";
|
|
32
32
|
import { loadClawSpecSkillBundle } from "../worker/skills.ts";
|
|
33
|
+
import { ensureWorkerIoHelper } from "../worker/io-helper.ts";
|
|
33
34
|
import { buildAcpImplementationTurnPrompt, buildAcpPlanningTurnPrompt } from "../worker/prompts.ts";
|
|
34
35
|
import { AcpWorkerClient, type AcpWorkerEvent, type AcpWorkerStatus } from "../acp/client.ts";
|
|
36
|
+
import { buildWorkerAgentSetupHint } from "../acp/openclaw-config.ts";
|
|
35
37
|
import { ClawSpecNotifier } from "./notifier.ts";
|
|
36
38
|
|
|
37
39
|
type WatcherManagerOptions = {
|
|
@@ -228,7 +230,7 @@ export class WatcherManager {
|
|
|
228
230
|
|
|
229
231
|
const action: ProjectExecutionState["action"] =
|
|
230
232
|
project.phase === "planning_sync" || project.status === "planning" ? "plan" : "work";
|
|
231
|
-
const workerAgentId = project.workerAgentId ?? this.acpClient.agentId;
|
|
233
|
+
const workerAgentId = project.execution?.workerAgentId ?? project.workerAgentId ?? this.acpClient.agentId;
|
|
232
234
|
const armedAt = new Date().toISOString();
|
|
233
235
|
const sessionKey = createWorkerSessionKey(project, {
|
|
234
236
|
workerSlot: "primary",
|
|
@@ -2121,6 +2123,7 @@ function toRepoRelative(project: ProjectState, targetPath: string): string {
|
|
|
2121
2123
|
|
|
2122
2124
|
async function ensureSupportFiles(repoStatePaths: ReturnType<typeof getRepoStatePaths>): Promise<void> {
|
|
2123
2125
|
await ensureDir(repoStatePaths.root);
|
|
2126
|
+
await ensureWorkerIoHelper(repoStatePaths);
|
|
2124
2127
|
if (!(await pathExists(repoStatePaths.progressFile))) {
|
|
2125
2128
|
await writeUtf8(repoStatePaths.progressFile, "# Progress\n");
|
|
2126
2129
|
}
|
|
@@ -2177,6 +2180,7 @@ async function resetRunSupportFiles(
|
|
|
2177
2180
|
latestSummary: string,
|
|
2178
2181
|
): Promise<void> {
|
|
2179
2182
|
await ensureDir(repoStatePaths.root);
|
|
2183
|
+
await ensureWorkerIoHelper(repoStatePaths);
|
|
2180
2184
|
await writeUtf8(repoStatePaths.progressFile, "# Progress\n");
|
|
2181
2185
|
await writeUtf8(repoStatePaths.workerProgressFile, "");
|
|
2182
2186
|
await writeUtf8(repoStatePaths.changedFilesFile, "# Changed Files\n");
|
|
@@ -2329,9 +2333,7 @@ function isUnavailableAcpBackendFailure(message: string): boolean {
|
|
|
2329
2333
|
}
|
|
2330
2334
|
|
|
2331
2335
|
function buildAcpSetupHint(action: ProjectExecutionState["action"]): string {
|
|
2332
|
-
return action
|
|
2333
|
-
? "Enable `plugins.entries.acpx` + backend `acpx`; install/load `acpx` if needed; rerun `cs-plan`."
|
|
2334
|
-
: "Enable `plugins.entries.acpx` + backend `acpx`; install/load `acpx` if needed; rerun `cs-work`.";
|
|
2336
|
+
return buildWorkerAgentSetupHint(action);
|
|
2335
2337
|
}
|
|
2336
2338
|
|
|
2337
2339
|
function buildBlockedNextStep(project: ProjectState, blocker: string): string {
|
|
@@ -2362,7 +2364,7 @@ function buildWorkerRestartMessage(params: {
|
|
|
2362
2364
|
delayMs: number;
|
|
2363
2365
|
}): string {
|
|
2364
2366
|
if (isUnavailableAcpBackendFailure(params.failureMessage)) {
|
|
2365
|
-
return `Restarting ACP worker (attempt ${params.restartCount}) failed because
|
|
2367
|
+
return `Restarting ACP worker (attempt ${params.restartCount}) failed because OpenClaw ACP is unavailable. Next: ${buildAcpSetupHint(params.action)}`;
|
|
2366
2368
|
}
|
|
2367
2369
|
const retryDelaySeconds = Math.ceil(params.delayMs / 1000);
|
|
2368
2370
|
const retryTarget = formatWorkerRetryTarget(params.action, params.nextDetail);
|
|
@@ -2612,7 +2614,7 @@ const WORKER_STATUS_POLL_INTERVAL_MS = 1_000;
|
|
|
2612
2614
|
const DEAD_SESSION_GRACE_MS = 2_000;
|
|
2613
2615
|
const WORKER_STARTUP_GRACE_MS = 3_000;
|
|
2614
2616
|
const WORKER_STARTUP_WAIT_NOTIFY_DELAY_MS = 8_000;
|
|
2615
|
-
const WORKER_STARTUP_WAIT_NOTIFY_INTERVAL_MS =
|
|
2617
|
+
const WORKER_STARTUP_WAIT_NOTIFY_INTERVAL_MS = 60_000;
|
|
2616
2618
|
const QUEUE_OWNER_UNAVAILABLE_STARTUP_GRACE_MS = 4_500;
|
|
2617
2619
|
const RUN_TURN_SETTLE_GRACE_MS = 1_500;
|
|
2618
2620
|
const MAX_WORKER_RESTART_ATTEMPTS = 10;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { RepoStatePaths } from "../utils/paths.ts";
|
|
2
|
+
import { tryReadUtf8, writeUtf8 } from "../utils/fs.ts";
|
|
3
|
+
|
|
4
|
+
const WORKER_IO_HELPER_SOURCE = [
|
|
5
|
+
'import fs from "node:fs";',
|
|
6
|
+
'import path from "node:path";',
|
|
7
|
+
'import { fileURLToPath } from "node:url";',
|
|
8
|
+
"",
|
|
9
|
+
'const ROOT = path.dirname(fileURLToPath(import.meta.url));',
|
|
10
|
+
'const PROGRESS_FILE = path.join(ROOT, "worker-progress.jsonl");',
|
|
11
|
+
'const RESULT_FILE = path.join(ROOT, "execution-result.json");',
|
|
12
|
+
"",
|
|
13
|
+
"function nowIso() {",
|
|
14
|
+
" return new Date().toISOString();",
|
|
15
|
+
"}",
|
|
16
|
+
"",
|
|
17
|
+
"function usage(message) {",
|
|
18
|
+
" if (message) {",
|
|
19
|
+
" console.error(message);",
|
|
20
|
+
" }",
|
|
21
|
+
' console.error("usage:");',
|
|
22
|
+
' console.error(" worker_io.mjs event --kind <kind> --current <n> --total <n> --task-id <id> --message <text>");',
|
|
23
|
+
' console.error(" worker_io.mjs result --file <json-file>");',
|
|
24
|
+
" process.exit(1);",
|
|
25
|
+
"}",
|
|
26
|
+
"",
|
|
27
|
+
"function parseFlags(argv) {",
|
|
28
|
+
" const flags = {};",
|
|
29
|
+
" for (let index = 0; index < argv.length; index += 1) {",
|
|
30
|
+
" const token = argv[index];",
|
|
31
|
+
' if (!token || !token.startsWith("--")) {',
|
|
32
|
+
' usage(`unexpected argument: ${token ?? "<missing>"}`);',
|
|
33
|
+
" }",
|
|
34
|
+
" const key = token.slice(2);",
|
|
35
|
+
" const value = argv[index + 1];",
|
|
36
|
+
" if (!key) {",
|
|
37
|
+
' usage("flag name cannot be empty");',
|
|
38
|
+
" }",
|
|
39
|
+
" if (value === undefined) {",
|
|
40
|
+
' usage(`missing value for --${key}`);',
|
|
41
|
+
" }",
|
|
42
|
+
" flags[key] = value;",
|
|
43
|
+
" index += 1;",
|
|
44
|
+
" }",
|
|
45
|
+
" return flags;",
|
|
46
|
+
"}",
|
|
47
|
+
"",
|
|
48
|
+
"function requireFlag(flags, key) {",
|
|
49
|
+
" const value = flags[key];",
|
|
50
|
+
' if (typeof value !== "string" || value.trim() === "") {',
|
|
51
|
+
' usage(`missing --${key}`);',
|
|
52
|
+
" }",
|
|
53
|
+
" return value;",
|
|
54
|
+
"}",
|
|
55
|
+
"",
|
|
56
|
+
"function requireInteger(flags, key) {",
|
|
57
|
+
" const raw = requireFlag(flags, key);",
|
|
58
|
+
" const parsed = Number.parseInt(raw, 10);",
|
|
59
|
+
" if (!Number.isFinite(parsed) || parsed < 0) {",
|
|
60
|
+
' usage(`invalid integer for --${key}: ${raw}`);',
|
|
61
|
+
" }",
|
|
62
|
+
" return parsed;",
|
|
63
|
+
"}",
|
|
64
|
+
"",
|
|
65
|
+
"function appendEvent(flags) {",
|
|
66
|
+
" const payload = {",
|
|
67
|
+
" version: 1,",
|
|
68
|
+
" timestamp: nowIso(),",
|
|
69
|
+
' kind: requireFlag(flags, "kind"),',
|
|
70
|
+
' current: requireInteger(flags, "current"),',
|
|
71
|
+
' total: requireInteger(flags, "total"),',
|
|
72
|
+
' taskId: requireFlag(flags, "task-id"),',
|
|
73
|
+
' message: requireFlag(flags, "message"),',
|
|
74
|
+
" };",
|
|
75
|
+
" fs.mkdirSync(path.dirname(PROGRESS_FILE), { recursive: true });",
|
|
76
|
+
' fs.appendFileSync(PROGRESS_FILE, JSON.stringify(payload) + "\\n", "utf8");',
|
|
77
|
+
"}",
|
|
78
|
+
"",
|
|
79
|
+
"function writeResult(flags) {",
|
|
80
|
+
' const inputFile = requireFlag(flags, "file");',
|
|
81
|
+
' const payload = JSON.parse(fs.readFileSync(inputFile, "utf8"));',
|
|
82
|
+
' if (!payload || typeof payload !== "object" || Array.isArray(payload)) {',
|
|
83
|
+
' usage("result payload must be a JSON object");',
|
|
84
|
+
" }",
|
|
85
|
+
" if (!payload.version) {",
|
|
86
|
+
" payload.version = 1;",
|
|
87
|
+
" }",
|
|
88
|
+
" if (!payload.timestamp) {",
|
|
89
|
+
" payload.timestamp = nowIso();",
|
|
90
|
+
" }",
|
|
91
|
+
" fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });",
|
|
92
|
+
' fs.writeFileSync(RESULT_FILE, `${JSON.stringify(payload, null, 2)}\\n`, "utf8");',
|
|
93
|
+
"}",
|
|
94
|
+
"",
|
|
95
|
+
"function main(argv) {",
|
|
96
|
+
" const command = argv[2];",
|
|
97
|
+
' if (command === "event") {',
|
|
98
|
+
" appendEvent(parseFlags(argv.slice(3)));",
|
|
99
|
+
" return;",
|
|
100
|
+
" }",
|
|
101
|
+
' if (command === "result") {',
|
|
102
|
+
" writeResult(parseFlags(argv.slice(3)));",
|
|
103
|
+
" return;",
|
|
104
|
+
" }",
|
|
105
|
+
' usage(command ? `unknown command: ${command}` : "missing command");',
|
|
106
|
+
"}",
|
|
107
|
+
"",
|
|
108
|
+
"main(process.argv);",
|
|
109
|
+
"",
|
|
110
|
+
].join("\n");
|
|
111
|
+
|
|
112
|
+
export async function ensureWorkerIoHelper(repoStatePaths: RepoStatePaths): Promise<void> {
|
|
113
|
+
const existing = await tryReadUtf8(repoStatePaths.workerIoFile);
|
|
114
|
+
if (existing === WORKER_IO_HELPER_SOURCE) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await writeUtf8(repoStatePaths.workerIoFile, WORKER_IO_HELPER_SOURCE);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function buildWorkerIoEventCommandPrefix(repoStatePaths: RepoStatePaths): string {
|
|
121
|
+
return `node ${quoteForShell(repoStatePaths.workerIoFile)} event`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function quoteForShell(value: string): string {
|
|
125
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
126
|
+
}
|
package/src/worker/prompts.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from "../types.ts";
|
|
8
8
|
import type { RepoStatePaths } from "../utils/paths.ts";
|
|
9
9
|
import { resolveProjectScopedPath } from "../utils/paths.ts";
|
|
10
|
+
import { buildWorkerIoEventCommandPrefix } from "./io-helper.ts";
|
|
10
11
|
|
|
11
12
|
export function buildExecutionSystemContext(repoPath: string, importedSkills?: string): string {
|
|
12
13
|
return [
|
|
@@ -368,6 +369,11 @@ export function buildAcpImplementationTurnPrompt(params: {
|
|
|
368
369
|
const contextLabels = contextPaths.map((contextPath) => displayPath(contextPath));
|
|
369
370
|
const firstContextLabel = contextLabels[0] ?? displayPath(tasksPath);
|
|
370
371
|
const afterContextLabel = contextLabels[1] ?? `start task ${params.task.id}`;
|
|
372
|
+
const workerIoCommandPrefix = buildWorkerIoEventCommandPrefix(params.repoStatePaths);
|
|
373
|
+
const progressCommandTemplate =
|
|
374
|
+
`${workerIoCommandPrefix} --kind <status|task_start|task_done|blocked> --current <n> --total ${params.tasks.length} --task-id "<task-id>" --message "<message>"`;
|
|
375
|
+
const preparingCommandExample =
|
|
376
|
+
`${workerIoCommandPrefix} --kind status --current 1 --total ${params.tasks.length} --task-id "${params.task.id}" --message "Preparing ${params.task.id}: loading context. Next: read ${firstContextLabel}."`;
|
|
371
377
|
|
|
372
378
|
const taskList = params.tasks.map((task) => `- ${task.id} ${task.description}`).join("\n");
|
|
373
379
|
|
|
@@ -388,7 +394,8 @@ export function buildAcpImplementationTurnPrompt(params: {
|
|
|
388
394
|
"- Keep changes minimal and scoped to the active change.",
|
|
389
395
|
"- Do not inspect or modify sibling `openspec/changes/<other-change>` directories.",
|
|
390
396
|
`- Update ${tasksPath} by switching each task from \`- [ ]\` to \`- [x]\` as you complete it.`,
|
|
391
|
-
`-
|
|
397
|
+
`- Use the worker IO helper instead of editing ${params.repoStatePaths.workerProgressFile} directly.`,
|
|
398
|
+
`- Worker IO helper command template: ${progressCommandTemplate}`,
|
|
392
399
|
"- Progress events must be human-readable, one line each, and must match the actual work completed.",
|
|
393
400
|
`- Every progress event must include \`current\` (current task number) and \`total\` (total task count for this run).`,
|
|
394
401
|
`- Do not stay silent until \`task_start\`. Emit context-loading progress first.`,
|
|
@@ -407,6 +414,9 @@ export function buildAcpImplementationTurnPrompt(params: {
|
|
|
407
414
|
"Context files to read first:",
|
|
408
415
|
...contextPaths.map((contextPath) => `- ${contextPath}`),
|
|
409
416
|
"",
|
|
417
|
+
"Worker IO helper example:",
|
|
418
|
+
fence(preparingCommandExample, "bash"),
|
|
419
|
+
"",
|
|
410
420
|
"Worker progress JSONL event template:",
|
|
411
421
|
fence(JSON.stringify({
|
|
412
422
|
version: 1,
|
package/test/config.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { parsePluginConfig } from "../src/config.ts";
|
|
|
5
5
|
test("parsePluginConfig returns defaults for empty input", () => {
|
|
6
6
|
const config = parsePluginConfig(undefined);
|
|
7
7
|
assert.equal(config.enabled, true);
|
|
8
|
-
assert.equal(config.workerAgentId,
|
|
8
|
+
assert.equal(config.workerAgentId, undefined);
|
|
9
9
|
assert.equal(config.archiveDirName, "archives");
|
|
10
10
|
assert.equal(config.openSpecTimeoutMs, 120_000);
|
|
11
11
|
assert.equal(config.watcherPollIntervalMs, 4_000);
|
|
@@ -48,7 +48,7 @@ test("parsePluginConfig ignores invalid types", () => {
|
|
|
48
48
|
});
|
|
49
49
|
assert.equal(config.enabled, true); // falls back to default
|
|
50
50
|
assert.equal(config.maxAutoContinueTurns, 3); // falls back to default
|
|
51
|
-
assert.equal(config.workerAgentId,
|
|
51
|
+
assert.equal(config.workerAgentId, undefined); // deprecated and ignored unless explicitly set
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
test("parsePluginConfig filters allowedChannels", () => {
|
package/test/file-lock.test.ts
CHANGED
|
@@ -25,6 +25,7 @@ await withFileLock(lockPath, async () => {
|
|
|
25
25
|
let childOutput = "";
|
|
26
26
|
let childError = "";
|
|
27
27
|
let child: ReturnType<typeof spawn> | undefined;
|
|
28
|
+
let childExit: Promise<number | null> | undefined;
|
|
28
29
|
|
|
29
30
|
t.after(() => {
|
|
30
31
|
if (child && !child.killed) {
|
|
@@ -41,6 +42,7 @@ await withFileLock(lockPath, async () => {
|
|
|
41
42
|
cwd: process.cwd(),
|
|
42
43
|
stdio: ["ignore", "pipe", "pipe"],
|
|
43
44
|
});
|
|
45
|
+
childExit = waitForChildClose(child);
|
|
44
46
|
child.stdout?.setEncoding("utf8");
|
|
45
47
|
child.stderr?.setEncoding("utf8");
|
|
46
48
|
child.stdout?.on("data", (chunk) => {
|
|
@@ -55,9 +57,7 @@ await withFileLock(lockPath, async () => {
|
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
await waitFor(() => childOutput.includes("acquired"));
|
|
58
|
-
const exitCode = await
|
|
59
|
-
child?.once("close", (code) => resolve(code));
|
|
60
|
-
});
|
|
60
|
+
const exitCode = await childExit;
|
|
61
61
|
|
|
62
62
|
assert.equal(exitCode, 0, childError || undefined);
|
|
63
63
|
});
|
|
@@ -76,3 +76,13 @@ async function waitFor(check: () => boolean, timeoutMs = 2_000): Promise<void> {
|
|
|
76
76
|
}
|
|
77
77
|
throw new Error("timed out waiting for lock handoff");
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
function waitForChildClose(child: ReturnType<typeof spawn>): Promise<number | null> {
|
|
81
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
82
|
+
return Promise.resolve(child.exitCode);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return new Promise<number | null>((resolve) => {
|
|
86
|
+
child.once("close", (code) => resolve(code));
|
|
87
|
+
});
|
|
88
|
+
}
|
package/test/helpers/harness.ts
CHANGED
|
@@ -155,17 +155,41 @@ export async function createServiceHarness(prefix: string): Promise<{
|
|
|
155
155
|
const watcherManager = createFakeWatcherManager();
|
|
156
156
|
const service = new ClawSpecService({
|
|
157
157
|
api: {
|
|
158
|
-
config: {
|
|
158
|
+
config: {
|
|
159
|
+
acp: {
|
|
160
|
+
backend: "acpx",
|
|
161
|
+
defaultAgent: "codex",
|
|
162
|
+
allowedAgents: ["codex", "piper"],
|
|
163
|
+
},
|
|
164
|
+
agents: {
|
|
165
|
+
list: [
|
|
166
|
+
{ id: "codex" },
|
|
167
|
+
{ id: "piper" },
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
159
171
|
logger: createLogger(),
|
|
160
172
|
} as any,
|
|
161
|
-
config: {
|
|
173
|
+
config: {
|
|
174
|
+
acp: {
|
|
175
|
+
backend: "acpx",
|
|
176
|
+
defaultAgent: "codex",
|
|
177
|
+
allowedAgents: ["codex", "piper"],
|
|
178
|
+
},
|
|
179
|
+
agents: {
|
|
180
|
+
list: [
|
|
181
|
+
{ id: "codex" },
|
|
182
|
+
{ id: "piper" },
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
} as any,
|
|
162
186
|
logger: createLogger(),
|
|
163
187
|
stateStore,
|
|
164
188
|
memoryStore,
|
|
165
189
|
openSpec: openSpec as any,
|
|
166
190
|
archiveDirName: "archives",
|
|
167
191
|
defaultWorkspace: workspacePath,
|
|
168
|
-
defaultWorkerAgentId:
|
|
192
|
+
defaultWorkerAgentId: undefined,
|
|
169
193
|
workspaceStore,
|
|
170
194
|
watcherManager: watcherManager as any,
|
|
171
195
|
});
|
package/test/queue-work.test.ts
CHANGED
|
@@ -108,3 +108,55 @@ test("main chat agent end does not clear a background worker run", async () => {
|
|
|
108
108
|
assert.equal(project?.execution?.sessionKey, workerSessionKey);
|
|
109
109
|
assert.equal(project?.latestSummary, "Worker is running in the background.");
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
test("work requires OpenClaw ACP default agent when no project override is set", async () => {
|
|
113
|
+
const harness = await createServiceHarness("clawspec-work-missing-acp-");
|
|
114
|
+
const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
|
|
115
|
+
const channelKey = "discord:work-missing-acp:default:main";
|
|
116
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
117
|
+
await writeUtf8(tasksPath, "- [ ] 1.1 Build the demo endpoint\n");
|
|
118
|
+
|
|
119
|
+
(service as any).config = {
|
|
120
|
+
acp: {
|
|
121
|
+
backend: "acpx",
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
harness.openSpec.instructionsApply = async (cwd: string, changeName: string) => ({
|
|
126
|
+
command: `openspec instructions apply --change ${changeName} --json`,
|
|
127
|
+
cwd,
|
|
128
|
+
stdout: "{}",
|
|
129
|
+
stderr: "",
|
|
130
|
+
durationMs: 1,
|
|
131
|
+
parsed: {
|
|
132
|
+
changeName,
|
|
133
|
+
changeDir,
|
|
134
|
+
schemaName: "spec-driven",
|
|
135
|
+
contextFiles: { tasks: tasksPath },
|
|
136
|
+
progress: { total: 1, complete: 0, remaining: 1 },
|
|
137
|
+
tasks: [{ id: "1.1", description: "Build the demo endpoint", done: false }],
|
|
138
|
+
state: "ready",
|
|
139
|
+
instruction: "Implement the remaining task.",
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await seedPlanningProject(stateStore, channelKey, {
|
|
144
|
+
workspacePath,
|
|
145
|
+
repoPath,
|
|
146
|
+
projectName: "demo-app",
|
|
147
|
+
changeName: "queue-work",
|
|
148
|
+
changeDir,
|
|
149
|
+
phase: "tasks",
|
|
150
|
+
status: "ready",
|
|
151
|
+
planningDirty: false,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = await service.queueWorkProject(channelKey, "apply");
|
|
155
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
156
|
+
|
|
157
|
+
assert.match(result.text ?? "", /Worker Setup Required/);
|
|
158
|
+
assert.match(result.text ?? "", /openclaw config set acp\.backend acpx/);
|
|
159
|
+
assert.match(result.text ?? "", /openclaw config set acp\.defaultAgent codex/);
|
|
160
|
+
assert.equal(project?.status, "ready");
|
|
161
|
+
assert.deepEqual(watcherManager.wakeCalls, []);
|
|
162
|
+
});
|
|
@@ -57,14 +57,19 @@ test("service writes archive bundles from visible-execution state", async () =>
|
|
|
57
57
|
|
|
58
58
|
const service = new ClawSpecService({
|
|
59
59
|
api: fakeApi,
|
|
60
|
-
config: {
|
|
60
|
+
config: {
|
|
61
|
+
acp: {
|
|
62
|
+
backend: "acpx",
|
|
63
|
+
defaultAgent: "codex",
|
|
64
|
+
},
|
|
65
|
+
} as any,
|
|
61
66
|
logger: fakeApi.logger,
|
|
62
67
|
stateStore,
|
|
63
68
|
memoryStore,
|
|
64
69
|
openSpec: {} as any,
|
|
65
70
|
archiveDirName: "archives",
|
|
66
71
|
defaultWorkspace: workspacePath,
|
|
67
|
-
defaultWorkerAgentId:
|
|
72
|
+
defaultWorkerAgentId: undefined,
|
|
68
73
|
workspaceStore,
|
|
69
74
|
});
|
|
70
75
|
|
|
@@ -1462,7 +1462,7 @@ test("watcher blocked message includes ACPX setup guidance when backend stays un
|
|
|
1462
1462
|
assert.equal(project?.status, "blocked");
|
|
1463
1463
|
assert.equal(project?.blockedReason?.includes("Blocked after 10 ACP restart attempts"), true);
|
|
1464
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", "
|
|
1465
|
+
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "openclaw config set acp.defaultAgent codex"), true);
|
|
1466
1466
|
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "cs-work"), true);
|
|
1467
1467
|
});
|
|
1468
1468
|
|
|
@@ -1590,8 +1590,8 @@ test("watcher retries when ACP runtime backend is temporarily unavailable", asyn
|
|
|
1590
1590
|
assert.equal(project?.status, "done");
|
|
1591
1591
|
assert.equal(runCount, 2);
|
|
1592
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", "
|
|
1594
|
-
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "
|
|
1593
|
+
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "OpenClaw ACP is unavailable"), true);
|
|
1594
|
+
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "openclaw config set acp.defaultAgent codex"), true);
|
|
1595
1595
|
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "All tasks complete"), true);
|
|
1596
1596
|
});
|
|
1597
1597
|
|
|
@@ -0,0 +1,97 @@
|
|
|
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, writeFile } from "node:fs/promises";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { ensureWorkerIoHelper } from "../src/worker/io-helper.ts";
|
|
8
|
+
import { buildAcpImplementationTurnPrompt } from "../src/worker/prompts.ts";
|
|
9
|
+
import { getRepoStatePaths } from "../src/utils/paths.ts";
|
|
10
|
+
import { readUtf8 } from "../src/utils/fs.ts";
|
|
11
|
+
|
|
12
|
+
test("worker io helper appends progress events via node command", async () => {
|
|
13
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-worker-io-helper-"));
|
|
14
|
+
const repoPath = path.join(tempRoot, "demo-app");
|
|
15
|
+
await mkdir(repoPath, { recursive: true });
|
|
16
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
17
|
+
await ensureWorkerIoHelper(repoStatePaths);
|
|
18
|
+
|
|
19
|
+
const result = spawnSync(
|
|
20
|
+
process.execPath,
|
|
21
|
+
[
|
|
22
|
+
repoStatePaths.workerIoFile,
|
|
23
|
+
"event",
|
|
24
|
+
"--kind",
|
|
25
|
+
"status",
|
|
26
|
+
"--current",
|
|
27
|
+
"1",
|
|
28
|
+
"--total",
|
|
29
|
+
"3",
|
|
30
|
+
"--task-id",
|
|
31
|
+
"1.1",
|
|
32
|
+
"--message",
|
|
33
|
+
"Preparing 1.1: loading context. Next: read proposal.md.",
|
|
34
|
+
],
|
|
35
|
+
{ encoding: "utf8" },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
assert.equal(result.status, 0, result.stderr);
|
|
39
|
+
const raw = await readUtf8(repoStatePaths.workerProgressFile);
|
|
40
|
+
const lines = raw.trim().split(/\r?\n/);
|
|
41
|
+
assert.equal(lines.length, 1);
|
|
42
|
+
const payload = JSON.parse(lines[0]!);
|
|
43
|
+
assert.equal(payload.kind, "status");
|
|
44
|
+
assert.equal(payload.current, 1);
|
|
45
|
+
assert.equal(payload.total, 3);
|
|
46
|
+
assert.equal(payload.taskId, "1.1");
|
|
47
|
+
assert.equal(payload.message, "Preparing 1.1: loading context. Next: read proposal.md.");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("implementation prompt instructs the worker to use the helper", async () => {
|
|
51
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-worker-io-prompt-"));
|
|
52
|
+
const repoPath = path.join(tempRoot, "demo-app");
|
|
53
|
+
const changeDir = path.join(repoPath, "openspec", "changes", "demo-change");
|
|
54
|
+
await mkdir(changeDir, { recursive: true });
|
|
55
|
+
await writeFile(path.join(changeDir, "proposal.md"), "# Proposal\n", "utf8");
|
|
56
|
+
await writeFile(path.join(changeDir, "tasks.md"), "- [ ] 1.1 Demo task\n", "utf8");
|
|
57
|
+
|
|
58
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
59
|
+
const prompt = buildAcpImplementationTurnPrompt({
|
|
60
|
+
project: {
|
|
61
|
+
version: 1,
|
|
62
|
+
projectId: "project-1",
|
|
63
|
+
channelKey: "discord:test:default:main",
|
|
64
|
+
storagePath: path.join(repoStatePaths.root, "state.json"),
|
|
65
|
+
status: "running",
|
|
66
|
+
phase: "implementing",
|
|
67
|
+
createdAt: new Date().toISOString(),
|
|
68
|
+
updatedAt: new Date().toISOString(),
|
|
69
|
+
repoPath,
|
|
70
|
+
workspacePath: tempRoot,
|
|
71
|
+
projectName: "demo-app",
|
|
72
|
+
changeName: "demo-change",
|
|
73
|
+
changeDir,
|
|
74
|
+
pauseRequested: false,
|
|
75
|
+
},
|
|
76
|
+
repoStatePaths,
|
|
77
|
+
apply: {
|
|
78
|
+
changeName: "demo-change",
|
|
79
|
+
changeDir,
|
|
80
|
+
schemaName: "spec-driven",
|
|
81
|
+
contextFiles: {
|
|
82
|
+
proposal: path.join(changeDir, "proposal.md"),
|
|
83
|
+
tasks: path.join(changeDir, "tasks.md"),
|
|
84
|
+
},
|
|
85
|
+
progress: { total: 1, complete: 0, remaining: 1 },
|
|
86
|
+
tasks: [{ id: "1.1", description: "Demo task", done: false }],
|
|
87
|
+
state: "ready",
|
|
88
|
+
instruction: "Implement the remaining task.",
|
|
89
|
+
},
|
|
90
|
+
task: { id: "1.1", description: "Demo task" },
|
|
91
|
+
tasks: [{ id: "1.1", description: "Demo task" }],
|
|
92
|
+
mode: "apply",
|
|
93
|
+
});
|
|
94
|
+
|
|
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>/);
|
|
97
|
+
});
|