clawspec 1.0.16 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/README.zh-CN.md +16 -0
- package/package.json +2 -2
- package/src/acp/client.ts +17 -1
- package/src/orchestrator/helpers.ts +1 -0
- package/src/orchestrator/service.ts +143 -13
- package/src/watchers/manager.ts +20 -6
- package/src/worker/io-helper.ts +6 -5
- package/test/command-surface.test.ts +1 -0
- package/test/doctor.test.ts +142 -0
- package/test/helpers/harness.ts +6 -2
- package/test/recovery.test.ts +52 -25
- package/test/watcher-planning.test.ts +47 -18
- package/test/watcher-work.test.ts +83 -53
- package/test/worker-io-helper.test.ts +1 -1
package/README.md
CHANGED
|
@@ -552,6 +552,8 @@ That loop is the normal operating model:
|
|
|
552
552
|
| `/clawspec status` | Any time you want a reconciled project snapshot | Renders current workspace, project, change, lifecycle, task counts, journal status, and next step |
|
|
553
553
|
| `/clawspec archive` | After all tasks are complete and you want to close the change | Validates and archives the finished OpenSpec change, then clears active change state |
|
|
554
554
|
| `/clawspec cancel` | When you want to abandon the active change | Restores tracked files from snapshots, removes the change, stops execution state, and clears the active change |
|
|
555
|
+
| `/clawspec doctor` | When something isn't working as expected | Runs automated diagnostics on your environment and configuration, reports detected issues with suggested fixes |
|
|
556
|
+
| `/clawspec doctor fix` | After `/clawspec doctor` reports a fixable issue | Automatically applies safe fixes for all detected issues that support auto-repair |
|
|
555
557
|
|
|
556
558
|
Auxiliary host CLI:
|
|
557
559
|
|
|
@@ -858,6 +860,20 @@ Use one of:
|
|
|
858
860
|
|
|
859
861
|
That is by design. Cancel restores tracked files from the ClawSpec snapshot baseline for the active change. It is intentionally narrower than a blanket Git restore.
|
|
860
862
|
|
|
863
|
+
### Worker fails with "stdin is not a terminal" or session creation returns no result
|
|
864
|
+
|
|
865
|
+
Cause:
|
|
866
|
+
|
|
867
|
+
- `~/.acpx/config.json` has a custom agent entry (e.g. `agents.codex` pointing to a local CLI binary)
|
|
868
|
+
- acpx cannot spawn the agent in a non-interactive environment when it is configured as a raw CLI command
|
|
869
|
+
|
|
870
|
+
What to do:
|
|
871
|
+
|
|
872
|
+
1. Run `/clawspec doctor` to check for known configuration issues.
|
|
873
|
+
2. If `/clawspec doctor` reports custom agent entries, run `/clawspec doctor fix` to clear them automatically.
|
|
874
|
+
3. Alternatively, edit `~/.acpx/config.json` manually and set `"agents": {}`.
|
|
875
|
+
4. Retry `cs-work` or `/clawspec continue`.
|
|
876
|
+
|
|
861
877
|
## Development And Validation
|
|
862
878
|
|
|
863
879
|
Useful checks while editing the plugin:
|
package/README.zh-CN.md
CHANGED
|
@@ -556,6 +556,8 @@ cs-work
|
|
|
556
556
|
| `/clawspec status` | 任何时候想看一次统一状态快照时 | 输出当前 workspace、project、change、生命周期、任务计数、journal 状态和下一步建议 |
|
|
557
557
|
| `/clawspec archive` | 所有任务完成,准备收尾时 | 校验并归档当前 OpenSpec change,同时清理活动 change 状态 |
|
|
558
558
|
| `/clawspec cancel` | 想放弃当前 change 时 | 按快照恢复已跟踪文件、删除 change、停止执行状态,并清掉活动 change |
|
|
559
|
+
| `/clawspec doctor` | 遇到异常行为时 | 对环境和配置进行自动化诊断,报告检测到的问题并给出修复建议 |
|
|
560
|
+
| `/clawspec doctor fix` | `/clawspec doctor` 检测到可修复问题后 | 自动应用所有支持自动修复的安全修复 |
|
|
559
561
|
|
|
560
562
|
辅助 host CLI:
|
|
561
563
|
|
|
@@ -869,6 +871,20 @@ cs-detach
|
|
|
869
871
|
|
|
870
872
|
这是设计如此。Cancel 只会恢复 ClawSpec 为当前 change 跟踪的文件快照,而不是做整仓库级别的 Git 还原。
|
|
871
873
|
|
|
874
|
+
### Worker 报错 "stdin is not a terminal" 或 session 创建无结果
|
|
875
|
+
|
|
876
|
+
原因:
|
|
877
|
+
|
|
878
|
+
- `~/.acpx/config.json` 中的 `agents` 字段存在自定义配置(例如 `agents.codex` 指向了本地 CLI 路径)
|
|
879
|
+
- acpx 在非交互式环境下无法通过 raw CLI 方式启动 agent
|
|
880
|
+
|
|
881
|
+
解决方法:
|
|
882
|
+
|
|
883
|
+
1. 运行 `/clawspec doctor` 检查配置问题。
|
|
884
|
+
2. 如果 doctor 报告了自定义 agent 配置,运行 `/clawspec doctor fix` 自动修复。
|
|
885
|
+
3. 也可以手动编辑 `~/.acpx/config.json`,将 `"agents"` 设置为 `{}`。
|
|
886
|
+
4. 然后重新运行 `cs-work` 或 `/clawspec continue`。
|
|
887
|
+
|
|
872
888
|
## 开发与验证
|
|
873
889
|
|
|
874
890
|
开发时常用检查:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawspec",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
|
|
6
6
|
"keywords": [
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"scripts": {
|
|
47
47
|
"check": "node --experimental-strip-types -e \"import('./index.ts')\"",
|
|
48
48
|
"test": "node --experimental-strip-types --test --test-reporter spec test/*.test.ts",
|
|
49
|
-
"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/paths-utils.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",
|
|
49
|
+
"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/doctor.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/paths-utils.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",
|
|
50
50
|
"test:slow": "node --experimental-strip-types --test --test-reporter spec test/watcher-planning.test.ts test/watcher-work.test.ts test/recovery.test.ts"
|
|
51
51
|
},
|
|
52
52
|
"engines": {
|
package/src/acp/client.ts
CHANGED
|
@@ -141,7 +141,7 @@ export class AcpWorkerClient {
|
|
|
141
141
|
allowErrorCodes: ["NO_SESSION"],
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
if (events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION")) {
|
|
144
|
+
if (events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION") || events.length === 0) {
|
|
145
145
|
events = await this.runControlCommand({
|
|
146
146
|
agentId: descriptor.agentId,
|
|
147
147
|
cwd: descriptor.cwd,
|
|
@@ -149,6 +149,14 @@ export class AcpWorkerClient {
|
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
if (events.length === 0 || !extractSessionIdentifiers(events).backendSessionId) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Failed to create acpx session for agent "${descriptor.agentId}".\n`
|
|
155
|
+
+ `The session command returned no result. This usually means the agent is misconfigured.\n\n`
|
|
156
|
+
+ `Run \`/clawspec doctor\` to diagnose and fix common acpx configuration issues.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
152
160
|
const identifiers = extractSessionIdentifiers(events);
|
|
153
161
|
const handle: AcpWorkerHandle = {
|
|
154
162
|
sessionKey: descriptor.sessionKey,
|
|
@@ -186,6 +194,13 @@ export class AcpWorkerClient {
|
|
|
186
194
|
cwd: descriptor.cwd,
|
|
187
195
|
env: this.env,
|
|
188
196
|
});
|
|
197
|
+
|
|
198
|
+
const args = this.buildPromptArgs({
|
|
199
|
+
agentId: descriptor.agentId,
|
|
200
|
+
cwd: descriptor.cwd,
|
|
201
|
+
sessionKey: descriptor.sessionKey,
|
|
202
|
+
});
|
|
203
|
+
|
|
189
204
|
child.stdout.setEncoding("utf8");
|
|
190
205
|
child.stderr.setEncoding("utf8");
|
|
191
206
|
child.stdin.end(params.text);
|
|
@@ -250,6 +265,7 @@ export class AcpWorkerClient {
|
|
|
250
265
|
|
|
251
266
|
const exit = await waitForExit(child);
|
|
252
267
|
this.recordSessionExit(params.sessionKey, descriptor, child.pid, exit.code, exit.signal, stderr);
|
|
268
|
+
|
|
253
269
|
if (exit.error) {
|
|
254
270
|
throw exit.error;
|
|
255
271
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import type {
|
|
3
4
|
OpenClawConfig,
|
|
@@ -236,6 +237,8 @@ export class ClawSpecService {
|
|
|
236
237
|
return await this.archiveProject(channelKey);
|
|
237
238
|
case "cancel":
|
|
238
239
|
return await this.cancelProject(channelKey);
|
|
240
|
+
case "doctor":
|
|
241
|
+
return await this.runDoctor(rest);
|
|
239
242
|
default:
|
|
240
243
|
return errorReply(`Unknown subcommand \`${subcommand}\`.\n\n${buildHelpText()}`);
|
|
241
244
|
}
|
|
@@ -675,13 +678,6 @@ export class ClawSpecService {
|
|
|
675
678
|
repoStatePaths.planningJournalSnapshotFile,
|
|
676
679
|
project.planningJournal?.lastSyncedAt,
|
|
677
680
|
);
|
|
678
|
-
const snapshot = await journalStore.readSnapshot(repoStatePaths.planningJournalSnapshotFile);
|
|
679
|
-
const digest = await journalStore.digest(project.changeName);
|
|
680
|
-
this.logger.info(`[clawspec] cs-work check for ${project.changeName}:`);
|
|
681
|
-
this.logger.info(` - hasUnsyncedChanges: ${hasUnsyncedChanges}`);
|
|
682
|
-
this.logger.info(` - Snapshot: entryCount=${snapshot?.entryCount}, lastEntryAt=${snapshot?.lastEntryAt}, hash=${snapshot?.contentHash?.slice(0, 8)}`);
|
|
683
|
-
this.logger.info(` - Current digest: entryCount=${digest.entryCount}, lastEntryAt=${digest.lastEntryAt}, hash=${digest.contentHash.slice(0, 8)}`);
|
|
684
|
-
this.logger.info(` - fallbackLastSyncedAt: ${project.planningJournal?.lastSyncedAt}`);
|
|
685
681
|
if (!hasUnsyncedChanges) {
|
|
686
682
|
const isDetached = !isProjectContextAttached(project);
|
|
687
683
|
if (isDetached) {
|
|
@@ -758,7 +754,6 @@ export class ClawSpecService {
|
|
|
758
754
|
userPrompt: string,
|
|
759
755
|
mode: ExecutionMode,
|
|
760
756
|
): Promise<{ prependContext?: string; prependSystemContext?: string } | PluginCommandResult> {
|
|
761
|
-
this.logger.info(`[clawspec] startVisiblePlanningSync called for ${project.changeName}`);
|
|
762
757
|
void project;
|
|
763
758
|
void mode;
|
|
764
759
|
|
|
@@ -781,7 +776,6 @@ export class ClawSpecService {
|
|
|
781
776
|
execution: undefined,
|
|
782
777
|
lastExecutionAt: startedAt,
|
|
783
778
|
}));
|
|
784
|
-
this.logger.info(`[clawspec] Project state updated: status=planning, phase=planning_sync, boundSessionKey=${runningProject.boundSessionKey}`);
|
|
785
779
|
|
|
786
780
|
return await this.buildPlanningSyncInjection(runningProject, userPrompt, prepared.instructionResults);
|
|
787
781
|
}
|
|
@@ -964,7 +958,6 @@ export class ClawSpecService {
|
|
|
964
958
|
const planningProject = await this.findPlanningProjectBySessionKey(ctx.sessionKey)
|
|
965
959
|
?? await this.findPlanningProjectByContext(ctx);
|
|
966
960
|
if (planningProject) {
|
|
967
|
-
this.logger.info(`[clawspec] agent_end: found planning project ${planningProject.changeName}, calling finalizePlanningTurn`);
|
|
968
961
|
await this.finalizePlanningTurn(planningProject, event);
|
|
969
962
|
return;
|
|
970
963
|
}
|
|
@@ -1914,6 +1907,11 @@ export class ClawSpecService {
|
|
|
1914
1907
|
);
|
|
1915
1908
|
}
|
|
1916
1909
|
|
|
1910
|
+
async runDoctor(rawArgs?: string): Promise<PluginCommandResult> {
|
|
1911
|
+
const acpxConfigPath = path.join(os.homedir(), ".acpx", "config.json");
|
|
1912
|
+
return await runDoctorCommand(acpxConfigPath, rawArgs);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1917
1915
|
private async captureIncomingMessage(channelKey: string, project: ProjectState, text: string): Promise<ProjectState> {
|
|
1918
1916
|
const trimmed = sanitizePlanningMessageText(text).trim();
|
|
1919
1917
|
if (!trimmed || trimmed.startsWith("/clawspec") || !project.repoPath || !project.changeName) {
|
|
@@ -2412,7 +2410,6 @@ export class ClawSpecService {
|
|
|
2412
2410
|
}
|
|
2413
2411
|
|
|
2414
2412
|
private async finalizePlanningTurn(project: ProjectState, event: AgentEndEvent): Promise<void> {
|
|
2415
|
-
this.logger.info(`[clawspec] finalizePlanningTurn called for ${project.changeName}, success=${event.success}`);
|
|
2416
2413
|
if (!project.repoPath || !project.changeName) {
|
|
2417
2414
|
this.logger.warn(`[clawspec] finalizePlanningTurn skipped: missing repoPath or changeName`);
|
|
2418
2415
|
return;
|
|
@@ -2474,8 +2471,6 @@ export class ClawSpecService {
|
|
|
2474
2471
|
}
|
|
2475
2472
|
|
|
2476
2473
|
const snapshot = await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, project.changeName, timestamp);
|
|
2477
|
-
this.logger.info(`[clawspec] Planning snapshot written for ${project.changeName}: entryCount=${snapshot.entryCount}, lastEntryAt=${snapshot.lastEntryAt}`);
|
|
2478
|
-
this.logger.info(`[clawspec] Updating project state: status=${status}, phase=${phase}, dirty=${journalDirty}`);
|
|
2479
2474
|
await this.writeLatestSummary(repoStatePaths, latestSummary);
|
|
2480
2475
|
|
|
2481
2476
|
const finalized = await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
@@ -3303,3 +3298,138 @@ function parseOptionalNumber(value: string): number | undefined {
|
|
|
3303
3298
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
3304
3299
|
return typeof value === "object" && value !== null;
|
|
3305
3300
|
}
|
|
3301
|
+
|
|
3302
|
+
type DoctorIssue = {
|
|
3303
|
+
id: string;
|
|
3304
|
+
message: string;
|
|
3305
|
+
fix: string;
|
|
3306
|
+
};
|
|
3307
|
+
|
|
3308
|
+
type DoctorContext = {
|
|
3309
|
+
acpxConfigPath: string;
|
|
3310
|
+
acpxConfig: Record<string, unknown> | null;
|
|
3311
|
+
acpxConfigError: boolean;
|
|
3312
|
+
};
|
|
3313
|
+
|
|
3314
|
+
type DoctorCheck = {
|
|
3315
|
+
id: string;
|
|
3316
|
+
check: (ctx: DoctorContext) => DoctorIssue | undefined;
|
|
3317
|
+
applyFix?: (ctx: DoctorContext) => Promise<void>;
|
|
3318
|
+
};
|
|
3319
|
+
|
|
3320
|
+
const DOCTOR_CHECKS: DoctorCheck[] = [
|
|
3321
|
+
{
|
|
3322
|
+
id: "acpx-config-json",
|
|
3323
|
+
check: (ctx) => {
|
|
3324
|
+
if (ctx.acpxConfigError) {
|
|
3325
|
+
return {
|
|
3326
|
+
id: "acpx-config-json",
|
|
3327
|
+
message: `\`${ctx.acpxConfigPath}\` contains invalid JSON. acpx may fail to start.`,
|
|
3328
|
+
fix: `Fix the JSON syntax in \`${ctx.acpxConfigPath}\`, or delete the file to use defaults.`,
|
|
3329
|
+
};
|
|
3330
|
+
}
|
|
3331
|
+
return undefined;
|
|
3332
|
+
},
|
|
3333
|
+
},
|
|
3334
|
+
{
|
|
3335
|
+
id: "acpx-custom-agents",
|
|
3336
|
+
check: (ctx) => {
|
|
3337
|
+
if (!ctx.acpxConfig) return undefined;
|
|
3338
|
+
const agents = ctx.acpxConfig.agents;
|
|
3339
|
+
if (agents && typeof agents === "object" && Object.keys(agents as object).length > 0) {
|
|
3340
|
+
const agentKeys = Object.keys(agents as object).map((k) => `\`${k}\``).join(", ");
|
|
3341
|
+
return {
|
|
3342
|
+
id: "acpx-custom-agents",
|
|
3343
|
+
message:
|
|
3344
|
+
`\`${ctx.acpxConfigPath}\` has custom agent entries: ${agentKeys}.\n`
|
|
3345
|
+
+ ` Custom agent configurations (e.g. pointing to a local CLI) can cause session creation to fail with "stdin is not a terminal".`,
|
|
3346
|
+
fix:
|
|
3347
|
+
`Set \`"agents": {}\` in \`${ctx.acpxConfigPath}\` to use the default agent launcher.\n`
|
|
3348
|
+
+ ` Run \`/clawspec doctor fix\` to apply this fix automatically.`,
|
|
3349
|
+
};
|
|
3350
|
+
}
|
|
3351
|
+
return undefined;
|
|
3352
|
+
},
|
|
3353
|
+
applyFix: async (ctx) => {
|
|
3354
|
+
if (ctx.acpxConfig) {
|
|
3355
|
+
ctx.acpxConfig.agents = {};
|
|
3356
|
+
await writeJsonFile(ctx.acpxConfigPath, ctx.acpxConfig);
|
|
3357
|
+
}
|
|
3358
|
+
},
|
|
3359
|
+
},
|
|
3360
|
+
];
|
|
3361
|
+
|
|
3362
|
+
export async function runDoctorCommand(acpxConfigPath: string, rawArgs?: string): Promise<PluginCommandResult> {
|
|
3363
|
+
const action = rawArgs?.trim().toLowerCase();
|
|
3364
|
+
|
|
3365
|
+
let acpxConfig: Record<string, unknown> | null = null;
|
|
3366
|
+
let acpxConfigError = false;
|
|
3367
|
+
const raw = await tryReadUtf8(acpxConfigPath);
|
|
3368
|
+
if (raw !== undefined && raw.trim() !== "") {
|
|
3369
|
+
try {
|
|
3370
|
+
acpxConfig = JSON.parse(raw) as Record<string, unknown>;
|
|
3371
|
+
} catch {
|
|
3372
|
+
acpxConfigError = true;
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
const ctx: DoctorContext = { acpxConfigPath, acpxConfig, acpxConfigError };
|
|
3377
|
+
const issues: DoctorIssue[] = [];
|
|
3378
|
+
for (const check of DOCTOR_CHECKS) {
|
|
3379
|
+
const issue = check.check(ctx);
|
|
3380
|
+
if (issue) {
|
|
3381
|
+
issues.push(issue);
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
if (action === "fix") {
|
|
3386
|
+
if (issues.length === 0) {
|
|
3387
|
+
return okReply("Nothing to fix. acpx configuration is already clean.");
|
|
3388
|
+
}
|
|
3389
|
+
const fixable = DOCTOR_CHECKS.filter((c) => c.applyFix && issues.some((i) => i.id === c.id));
|
|
3390
|
+
if (fixable.length === 0) {
|
|
3391
|
+
return errorReply(
|
|
3392
|
+
"Cannot auto-fix the detected issues. Please fix them manually:\n\n"
|
|
3393
|
+
+ issues.map((i) => `- ${i.fix}`).join("\n"),
|
|
3394
|
+
);
|
|
3395
|
+
}
|
|
3396
|
+
for (const check of fixable) {
|
|
3397
|
+
await check.applyFix!(ctx);
|
|
3398
|
+
}
|
|
3399
|
+
return okReply(
|
|
3400
|
+
[
|
|
3401
|
+
heading("Doctor Fix Applied"),
|
|
3402
|
+
"",
|
|
3403
|
+
...fixable.map((c) => {
|
|
3404
|
+
const issue = issues.find((i) => i.id === c.id);
|
|
3405
|
+
return `Fixed: ${issue?.message.split("\n")[0] ?? c.id}`;
|
|
3406
|
+
}),
|
|
3407
|
+
"",
|
|
3408
|
+
"Please retry your previous command.",
|
|
3409
|
+
].join("\n"),
|
|
3410
|
+
);
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
if (issues.length === 0) {
|
|
3414
|
+
return okReply(
|
|
3415
|
+
[
|
|
3416
|
+
heading("Doctor"),
|
|
3417
|
+
"",
|
|
3418
|
+
"No issues found. acpx configuration looks healthy.",
|
|
3419
|
+
].join("\n"),
|
|
3420
|
+
);
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
return okReply(
|
|
3424
|
+
[
|
|
3425
|
+
heading("Doctor"),
|
|
3426
|
+
"",
|
|
3427
|
+
`Found ${issues.length} issue${issues.length > 1 ? "s" : ""}:`,
|
|
3428
|
+
"",
|
|
3429
|
+
...issues.flatMap((issue, i) => [`**${i + 1}.** ${issue.message}`, ""]),
|
|
3430
|
+
"Suggested fixes:",
|
|
3431
|
+
"",
|
|
3432
|
+
...issues.flatMap((issue, i) => [`**${i + 1}.** ${issue.fix}`, ""]),
|
|
3433
|
+
].join("\n"),
|
|
3434
|
+
);
|
|
3435
|
+
}
|
package/src/watchers/manager.ts
CHANGED
|
@@ -823,7 +823,7 @@ class ExecutionWatcher {
|
|
|
823
823
|
},
|
|
824
824
|
};
|
|
825
825
|
});
|
|
826
|
-
await this.writeExecutionControl(
|
|
826
|
+
await this.writeExecutionControl(queued);
|
|
827
827
|
const nextPlanningStep = nextForcedArtifactId
|
|
828
828
|
? (nextPlanningArtifactId(forcedArtifactIds, selectedArtifactId) ?? await this.describeNextPlanningStep(queued))
|
|
829
829
|
: await this.describeNextPlanningStep(queued);
|
|
@@ -906,6 +906,7 @@ class ExecutionWatcher {
|
|
|
906
906
|
await writeUtf8(repoStatePaths.workerProgressFile, "");
|
|
907
907
|
|
|
908
908
|
const importedSkills = await loadClawSpecSkillBundle(["apply"]);
|
|
909
|
+
|
|
909
910
|
({ runError } = await this.runAcpTurnWithTracking(
|
|
910
911
|
runningProject,
|
|
911
912
|
repoStatePaths,
|
|
@@ -1023,7 +1024,7 @@ class ExecutionWatcher {
|
|
|
1023
1024
|
},
|
|
1024
1025
|
};
|
|
1025
1026
|
});
|
|
1026
|
-
await this.writeExecutionControl(
|
|
1027
|
+
await this.writeExecutionControl(queued);
|
|
1027
1028
|
return true;
|
|
1028
1029
|
}
|
|
1029
1030
|
|
|
@@ -1205,6 +1206,17 @@ class ExecutionWatcher {
|
|
|
1205
1206
|
await flushProgress();
|
|
1206
1207
|
return { runError };
|
|
1207
1208
|
}
|
|
1209
|
+
|
|
1210
|
+
this.logger.debug?.(
|
|
1211
|
+
`[clawspec] calling acpClient.runTurn: ${JSON.stringify({
|
|
1212
|
+
sessionKey,
|
|
1213
|
+
cwd: project.repoPath,
|
|
1214
|
+
agentId: workerAgentId,
|
|
1215
|
+
promptLength: prompt.length,
|
|
1216
|
+
acpxCommand: this.acpClient.command,
|
|
1217
|
+
})}`,
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1208
1220
|
const runTurnPromise = this.acpClient.runTurn({
|
|
1209
1221
|
sessionKey,
|
|
1210
1222
|
cwd: project.repoPath!,
|
|
@@ -1637,7 +1649,7 @@ class ExecutionWatcher {
|
|
|
1637
1649
|
},
|
|
1638
1650
|
}));
|
|
1639
1651
|
|
|
1640
|
-
await this.writeExecutionControl(
|
|
1652
|
+
await this.writeExecutionControl(recovered);
|
|
1641
1653
|
await this.notify(
|
|
1642
1654
|
recovered,
|
|
1643
1655
|
buildWatcherStatusMessage(
|
|
@@ -1698,7 +1710,7 @@ class ExecutionWatcher {
|
|
|
1698
1710
|
lastFailure: current.execution?.lastFailure,
|
|
1699
1711
|
},
|
|
1700
1712
|
}));
|
|
1701
|
-
await this.writeExecutionControl(
|
|
1713
|
+
await this.writeExecutionControl(updated);
|
|
1702
1714
|
return updated;
|
|
1703
1715
|
}
|
|
1704
1716
|
|
|
@@ -1942,8 +1954,10 @@ class ExecutionWatcher {
|
|
|
1942
1954
|
};
|
|
1943
1955
|
}
|
|
1944
1956
|
|
|
1945
|
-
private async writeExecutionControl(
|
|
1946
|
-
const project =
|
|
1957
|
+
private async writeExecutionControl(projectOrChannelKey: string | ProjectState): Promise<void> {
|
|
1958
|
+
const project = typeof projectOrChannelKey === "string"
|
|
1959
|
+
? await this.stateStore.getActiveProject(projectOrChannelKey)
|
|
1960
|
+
: projectOrChannelKey;
|
|
1947
1961
|
if (!project?.repoPath || !project.changeName || !project.execution) {
|
|
1948
1962
|
return;
|
|
1949
1963
|
}
|
package/src/worker/io-helper.ts
CHANGED
|
@@ -118,9 +118,10 @@ export async function ensureWorkerIoHelper(repoStatePaths: RepoStatePaths): Prom
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
export function buildWorkerIoEventCommandPrefix(repoStatePaths: RepoStatePaths): string {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
const ioPath = repoStatePaths.workerIoFile;
|
|
122
|
+
// Use single quotes on POSIX, double quotes on Windows for cross-platform compatibility
|
|
123
|
+
if (process.platform === "win32") {
|
|
124
|
+
return `node "${ioPath.replace(/"/g, '""')}" event`;
|
|
125
|
+
}
|
|
126
|
+
return `node '${ioPath.replace(/'/g, `'\\''`)}' event`;
|
|
126
127
|
}
|
|
@@ -8,6 +8,7 @@ test("help text only advertises the clawspec command surface", () => {
|
|
|
8
8
|
assert.match(help, /\/clawspec workspace/);
|
|
9
9
|
assert.match(help, /\/clawspec proposal <change-name> \[description\]/);
|
|
10
10
|
assert.match(help, /\/clawspec continue/);
|
|
11
|
+
assert.match(help, /\/clawspec doctor/);
|
|
11
12
|
assert.match(help, /\/clawspec detach/);
|
|
12
13
|
assert.match(help, /cs-detach/);
|
|
13
14
|
assert.match(help, /legacy aliases/);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
6
|
+
import { runDoctorCommand } from "../src/orchestrator/service.ts";
|
|
7
|
+
import { writeJsonFile, readJsonFile } from "../src/utils/fs.ts";
|
|
8
|
+
|
|
9
|
+
test("doctor reports no issues when config file does not exist", async () => {
|
|
10
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
11
|
+
const configPath = path.join(tempRoot, ".acpx", "config.json");
|
|
12
|
+
|
|
13
|
+
const result = await runDoctorCommand(configPath);
|
|
14
|
+
|
|
15
|
+
assert.equal(result.isError, undefined);
|
|
16
|
+
assert.match(result.text ?? "", /No issues found/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("doctor reports no issues when agents is empty", async () => {
|
|
20
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
21
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
22
|
+
await mkdir(configDir, { recursive: true });
|
|
23
|
+
const configPath = path.join(configDir, "config.json");
|
|
24
|
+
|
|
25
|
+
await writeJsonFile(configPath, {
|
|
26
|
+
defaultAgent: "codex",
|
|
27
|
+
agents: {},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = await runDoctorCommand(configPath);
|
|
31
|
+
|
|
32
|
+
assert.equal(result.isError, undefined);
|
|
33
|
+
assert.match(result.text ?? "", /No issues found/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("doctor detects custom agent entries", async () => {
|
|
37
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
38
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
39
|
+
await mkdir(configDir, { recursive: true });
|
|
40
|
+
const configPath = path.join(configDir, "config.json");
|
|
41
|
+
|
|
42
|
+
await writeJsonFile(configPath, {
|
|
43
|
+
defaultAgent: "codex",
|
|
44
|
+
agents: {
|
|
45
|
+
codex: { command: "/usr/local/bin/codex" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const result = await runDoctorCommand(configPath);
|
|
50
|
+
|
|
51
|
+
assert.equal(result.isError, undefined);
|
|
52
|
+
assert.match(result.text ?? "", /custom agent entries/);
|
|
53
|
+
assert.match(result.text ?? "", /`codex`/);
|
|
54
|
+
assert.match(result.text ?? "", /doctor fix/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("doctor reports no issues when config file is empty", async () => {
|
|
58
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
59
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
60
|
+
await mkdir(configDir, { recursive: true });
|
|
61
|
+
const configPath = path.join(configDir, "config.json");
|
|
62
|
+
|
|
63
|
+
const { writeUtf8 } = await import("../src/utils/fs.ts");
|
|
64
|
+
await writeUtf8(configPath, "");
|
|
65
|
+
|
|
66
|
+
const result = await runDoctorCommand(configPath);
|
|
67
|
+
|
|
68
|
+
assert.equal(result.isError, undefined);
|
|
69
|
+
assert.match(result.text ?? "", /No issues found/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("doctor detects invalid JSON", async () => {
|
|
73
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
74
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
75
|
+
await mkdir(configDir, { recursive: true });
|
|
76
|
+
const configPath = path.join(configDir, "config.json");
|
|
77
|
+
|
|
78
|
+
const { writeUtf8 } = await import("../src/utils/fs.ts");
|
|
79
|
+
await writeUtf8(configPath, '{ "agents": { broken }');
|
|
80
|
+
|
|
81
|
+
const result = await runDoctorCommand(configPath);
|
|
82
|
+
|
|
83
|
+
assert.equal(result.isError, undefined);
|
|
84
|
+
assert.match(result.text ?? "", /invalid JSON/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("doctor fix clears custom agent entries", async () => {
|
|
88
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
89
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
90
|
+
await mkdir(configDir, { recursive: true });
|
|
91
|
+
const configPath = path.join(configDir, "config.json");
|
|
92
|
+
|
|
93
|
+
await writeJsonFile(configPath, {
|
|
94
|
+
defaultAgent: "codex",
|
|
95
|
+
authPolicy: "skip",
|
|
96
|
+
agents: {
|
|
97
|
+
codex: { command: "/usr/local/bin/codex" },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = await runDoctorCommand(configPath, "fix");
|
|
102
|
+
|
|
103
|
+
assert.equal(result.isError, undefined);
|
|
104
|
+
assert.match(result.text ?? "", /Doctor Fix Applied/);
|
|
105
|
+
|
|
106
|
+
const updated = await readJsonFile<Record<string, unknown>>(configPath, {});
|
|
107
|
+
assert.deepEqual(updated.agents, {});
|
|
108
|
+
assert.equal(updated.defaultAgent, "codex");
|
|
109
|
+
assert.equal(updated.authPolicy, "skip");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("doctor fix reports nothing to fix when agents is already empty", async () => {
|
|
113
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
114
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
115
|
+
await mkdir(configDir, { recursive: true });
|
|
116
|
+
const configPath = path.join(configDir, "config.json");
|
|
117
|
+
|
|
118
|
+
await writeJsonFile(configPath, {
|
|
119
|
+
defaultAgent: "codex",
|
|
120
|
+
agents: {},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const result = await runDoctorCommand(configPath, "fix");
|
|
124
|
+
|
|
125
|
+
assert.equal(result.isError, undefined);
|
|
126
|
+
assert.match(result.text ?? "", /Nothing to fix/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("doctor fix reports error when config has invalid JSON", async () => {
|
|
130
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
|
|
131
|
+
const configDir = path.join(tempRoot, ".acpx");
|
|
132
|
+
await mkdir(configDir, { recursive: true });
|
|
133
|
+
const configPath = path.join(configDir, "config.json");
|
|
134
|
+
|
|
135
|
+
const { writeUtf8 } = await import("../src/utils/fs.ts");
|
|
136
|
+
await writeUtf8(configPath, '{ broken json');
|
|
137
|
+
|
|
138
|
+
const result = await runDoctorCommand(configPath, "fix");
|
|
139
|
+
|
|
140
|
+
assert.equal(result.isError, true);
|
|
141
|
+
assert.match(result.text ?? "", /Cannot auto-fix/);
|
|
142
|
+
});
|
package/test/helpers/harness.ts
CHANGED
|
@@ -55,13 +55,17 @@ export function createLogger() {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export async function waitFor(
|
|
58
|
+
export async function waitFor(
|
|
59
|
+
check: () => Promise<boolean>,
|
|
60
|
+
timeoutMs = 4_000,
|
|
61
|
+
pollIntervalMs = 250,
|
|
62
|
+
): Promise<void> {
|
|
59
63
|
const startedAt = Date.now();
|
|
60
64
|
while (Date.now() - startedAt < timeoutMs) {
|
|
61
65
|
if (await check()) {
|
|
62
66
|
return;
|
|
63
67
|
}
|
|
64
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
65
69
|
}
|
|
66
70
|
throw new Error("Timed out waiting for test condition.");
|
|
67
71
|
}
|
package/test/recovery.test.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import test from "node:test";
|
|
1
|
+
import test, { type TestContext } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
6
|
-
import { pathExists, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
6
|
+
import { pathExists, readJsonFile, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
7
7
|
import { getRepoStatePaths } from "../src/utils/paths.ts";
|
|
8
8
|
import { RollbackStore } from "../src/rollback/store.ts";
|
|
9
9
|
import { ProjectStateStore } from "../src/state/store.ts";
|
|
10
10
|
import { WatcherManager } from "../src/watchers/manager.ts";
|
|
11
11
|
import { createLogger, waitFor } from "./helpers/harness.ts";
|
|
12
12
|
|
|
13
|
+
const TEST_WATCHER_POLL_INTERVAL_MS = 250;
|
|
14
|
+
const TEST_WAIT_TIMEOUT_MS = 15_000;
|
|
15
|
+
|
|
13
16
|
function createWorkOpenSpec(changeDir: string, tasksPath: string) {
|
|
14
17
|
return {
|
|
15
18
|
instructionsApply: async (cwd: string, cn: string) => {
|
|
@@ -39,7 +42,25 @@ function createWorkOpenSpec(changeDir: string, tasksPath: string) {
|
|
|
39
42
|
} as any;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
function registerManagerCleanup(t: TestContext, manager: WatcherManager): () => Promise<void> {
|
|
46
|
+
let stopped = false;
|
|
47
|
+
const stop = async () => {
|
|
48
|
+
if (stopped) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
stopped = true;
|
|
52
|
+
await manager.stop();
|
|
53
|
+
};
|
|
54
|
+
t.after(stop);
|
|
55
|
+
return stop;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function waitForProjectStatus(repoPath: string, status: "done", timeoutMs = TEST_WAIT_TIMEOUT_MS): Promise<void> {
|
|
59
|
+
const stateFile = getRepoStatePaths(repoPath, "archives").stateFile;
|
|
60
|
+
await waitFor(async () => (await readJsonFile<any>(stateFile, null))?.status === status, timeoutMs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("recovery from orphaned state (armed, no execution field)", async (t) => {
|
|
43
64
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-orphan-"));
|
|
44
65
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
45
66
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -105,12 +126,13 @@ test("recovery from orphaned state (armed, no execution field)", async () => {
|
|
|
105
126
|
logger: createLogger(),
|
|
106
127
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
107
128
|
acpClient: fakeAcpClient as any,
|
|
108
|
-
pollIntervalMs:
|
|
129
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
109
130
|
});
|
|
131
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
110
132
|
|
|
111
133
|
await manager.start();
|
|
112
|
-
await
|
|
113
|
-
await
|
|
134
|
+
await waitForProjectStatus(repoPath, "done");
|
|
135
|
+
await stopManager();
|
|
114
136
|
|
|
115
137
|
const project = await stateStore.getActiveProject(channelKey);
|
|
116
138
|
assert.equal(project?.status, "done");
|
|
@@ -120,7 +142,7 @@ test("recovery from orphaned state (armed, no execution field)", async () => {
|
|
|
120
142
|
assert.equal(await pathExists(path.join(clawspecDir, "state.json.12345.999999.tmp")), false);
|
|
121
143
|
});
|
|
122
144
|
|
|
123
|
-
test("recovery from mid-crash running state", async () => {
|
|
145
|
+
test("recovery from mid-crash running state", async (t) => {
|
|
124
146
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-midcrash-"));
|
|
125
147
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
126
148
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -178,19 +200,20 @@ test("recovery from mid-crash running state", async () => {
|
|
|
178
200
|
logger: createLogger(),
|
|
179
201
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
180
202
|
acpClient: fakeAcpClient as any,
|
|
181
|
-
pollIntervalMs:
|
|
203
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
182
204
|
});
|
|
205
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
183
206
|
|
|
184
207
|
await manager.start();
|
|
185
|
-
await
|
|
186
|
-
await
|
|
208
|
+
await waitForProjectStatus(repoPath, "done");
|
|
209
|
+
await stopManager();
|
|
187
210
|
|
|
188
211
|
const project = await stateStore.getActiveProject(channelKey);
|
|
189
212
|
assert.equal(project?.status, "done");
|
|
190
213
|
assert.equal(notifierMessages.some((m) => m.includes("Gateway restarted")), true);
|
|
191
214
|
});
|
|
192
215
|
|
|
193
|
-
test("startup recovery adopts a live implementation session instead of spawning a new worker", async () => {
|
|
216
|
+
test("startup recovery adopts a live implementation session instead of spawning a new worker", async (t) => {
|
|
194
217
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-adopt-live-"));
|
|
195
218
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
196
219
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -274,8 +297,9 @@ test("startup recovery adopts a live implementation session instead of spawning
|
|
|
274
297
|
logger: createLogger(),
|
|
275
298
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
276
299
|
acpClient: fakeAcpClient as any,
|
|
277
|
-
pollIntervalMs:
|
|
300
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
278
301
|
});
|
|
302
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
279
303
|
|
|
280
304
|
setTimeout(() => {
|
|
281
305
|
void (async () => {
|
|
@@ -308,8 +332,8 @@ test("startup recovery adopts a live implementation session instead of spawning
|
|
|
308
332
|
}, 120);
|
|
309
333
|
|
|
310
334
|
await manager.start();
|
|
311
|
-
await
|
|
312
|
-
await
|
|
335
|
+
await waitForProjectStatus(repoPath, "done");
|
|
336
|
+
await stopManager();
|
|
313
337
|
|
|
314
338
|
const project = await stateStore.getActiveProject(channelKey);
|
|
315
339
|
assert.equal(project?.status, "done");
|
|
@@ -328,7 +352,7 @@ test("startup recovery adopts a live implementation session instead of spawning
|
|
|
328
352
|
assert.equal(notifierMessages.some((m) => m.includes("Watcher active. Starting codex worker")), false);
|
|
329
353
|
});
|
|
330
354
|
|
|
331
|
-
test("recovery from recoverable blocked implementation state", async () => {
|
|
355
|
+
test("recovery from recoverable blocked implementation state", async (t) => {
|
|
332
356
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-blocked-"));
|
|
333
357
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
334
358
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -421,12 +445,13 @@ test("recovery from recoverable blocked implementation state", async () => {
|
|
|
421
445
|
logger: createLogger(),
|
|
422
446
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
423
447
|
acpClient: fakeAcpClient as any,
|
|
424
|
-
pollIntervalMs:
|
|
448
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
425
449
|
});
|
|
450
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
426
451
|
|
|
427
452
|
await manager.start();
|
|
428
|
-
await
|
|
429
|
-
await
|
|
453
|
+
await waitForProjectStatus(repoPath, "done");
|
|
454
|
+
await stopManager();
|
|
430
455
|
|
|
431
456
|
const project = await stateStore.getActiveProject(channelKey);
|
|
432
457
|
assert.equal(project?.status, "done");
|
|
@@ -435,7 +460,7 @@ test("recovery from recoverable blocked implementation state", async () => {
|
|
|
435
460
|
assert.equal(notifierMessages.some((m) => m.includes("All tasks complete")), true);
|
|
436
461
|
});
|
|
437
462
|
|
|
438
|
-
test("rearm never drops execution field (batch mode)", async () => {
|
|
463
|
+
test("rearm never drops execution field (batch mode)", async (t) => {
|
|
439
464
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-rearm-safe-"));
|
|
440
465
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
441
466
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -497,13 +522,14 @@ test("rearm never drops execution field (batch mode)", async () => {
|
|
|
497
522
|
logger: createLogger(),
|
|
498
523
|
notifier: { send: async () => undefined } as any,
|
|
499
524
|
acpClient: fakeAcpClient as any,
|
|
500
|
-
pollIntervalMs:
|
|
525
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
501
526
|
});
|
|
527
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
502
528
|
|
|
503
529
|
await manager.start();
|
|
504
530
|
await manager.wake(channelKey);
|
|
505
|
-
await
|
|
506
|
-
await
|
|
531
|
+
await waitForProjectStatus(repoPath, "done");
|
|
532
|
+
await stopManager();
|
|
507
533
|
|
|
508
534
|
const project = await stateStore.getActiveProject(channelKey);
|
|
509
535
|
assert.equal(project?.status, "done");
|
|
@@ -511,7 +537,7 @@ test("rearm never drops execution field (batch mode)", async () => {
|
|
|
511
537
|
assert.equal(turnCount, 1);
|
|
512
538
|
});
|
|
513
539
|
|
|
514
|
-
test("startup recovery does not spawn a worker for visible chat planning", async () => {
|
|
540
|
+
test("startup recovery does not spawn a worker for visible chat planning", async (t) => {
|
|
515
541
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-visible-plan-"));
|
|
516
542
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
517
543
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -561,12 +587,13 @@ test("startup recovery does not spawn a worker for visible chat planning", async
|
|
|
561
587
|
logger: createLogger(),
|
|
562
588
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
563
589
|
acpClient: fakeAcpClient as any,
|
|
564
|
-
pollIntervalMs:
|
|
590
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
565
591
|
});
|
|
592
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
566
593
|
|
|
567
594
|
await manager.start();
|
|
568
595
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
569
|
-
await
|
|
596
|
+
await stopManager();
|
|
570
597
|
|
|
571
598
|
const project = await stateStore.getActiveProject(channelKey);
|
|
572
599
|
assert.equal(runCount, 0);
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import test from "node:test";
|
|
1
|
+
import test, { type TestContext } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
6
|
-
import { pathExists, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
6
|
+
import { pathExists, readJsonFile, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
7
7
|
import { PlanningJournalStore } from "../src/planning/journal.ts";
|
|
8
8
|
import { getRepoStatePaths } from "../src/utils/paths.ts";
|
|
9
9
|
import { ProjectStateStore } from "../src/state/store.ts";
|
|
10
10
|
import { WatcherManager } from "../src/watchers/manager.ts";
|
|
11
11
|
import { createLogger, waitFor } from "./helpers/harness.ts";
|
|
12
12
|
|
|
13
|
+
const TEST_WATCHER_POLL_INTERVAL_MS = 1_000;
|
|
14
|
+
const TEST_WAIT_TIMEOUT_MS = 35_000;
|
|
15
|
+
|
|
13
16
|
function createPlanningOpenSpec(changeDir: string, proposalPath: string) {
|
|
14
17
|
return {
|
|
15
18
|
status: async (cwd: string, changeName: string) => ({
|
|
@@ -71,7 +74,29 @@ function createPlanningOpenSpec(changeDir: string, proposalPath: string) {
|
|
|
71
74
|
} as any;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
function registerManagerCleanup(t: TestContext, manager: WatcherManager): () => Promise<void> {
|
|
78
|
+
let stopped = false;
|
|
79
|
+
const stop = async () => {
|
|
80
|
+
if (stopped) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
stopped = true;
|
|
84
|
+
await manager.stop();
|
|
85
|
+
};
|
|
86
|
+
t.after(stop);
|
|
87
|
+
return stop;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function waitForProjectStatus(
|
|
91
|
+
repoPath: string,
|
|
92
|
+
status: "ready" | "done" | "blocked" | "planning" | "armed" | "running",
|
|
93
|
+
timeoutMs = TEST_WAIT_TIMEOUT_MS,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const stateFile = getRepoStatePaths(repoPath, "archives").stateFile;
|
|
96
|
+
await waitFor(async () => (await readJsonFile<any>(stateFile, null))?.status === status, timeoutMs);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test("watcher planning flow completes", async (t) => {
|
|
75
100
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-"));
|
|
76
101
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
77
102
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -113,8 +138,9 @@ test("watcher planning flow completes", async () => {
|
|
|
113
138
|
logger: createLogger(),
|
|
114
139
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
115
140
|
acpClient: fakeAcpClient as any,
|
|
116
|
-
pollIntervalMs:
|
|
141
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
117
142
|
});
|
|
143
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
118
144
|
|
|
119
145
|
const channelKey = "discord:watch-plan:default:main";
|
|
120
146
|
await stateStore.createProject(channelKey);
|
|
@@ -143,8 +169,8 @@ test("watcher planning flow completes", async () => {
|
|
|
143
169
|
|
|
144
170
|
await manager.start();
|
|
145
171
|
await manager.wake(channelKey);
|
|
146
|
-
await
|
|
147
|
-
await
|
|
172
|
+
await waitForProjectStatus(repoPath, "ready");
|
|
173
|
+
await stopManager();
|
|
148
174
|
|
|
149
175
|
const project = await stateStore.getActiveProject(channelKey);
|
|
150
176
|
assert.equal(project?.status, "ready");
|
|
@@ -155,7 +181,7 @@ test("watcher planning flow completes", async () => {
|
|
|
155
181
|
assert.equal(notifierMessages.some((m) => m.includes("Planning ready") && m.includes("Next: run `cs-work` to start implementation")), true);
|
|
156
182
|
});
|
|
157
183
|
|
|
158
|
-
test("planning fallback normalizes artifact path", async () => {
|
|
184
|
+
test("planning fallback normalizes artifact path", async (t) => {
|
|
159
185
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-fallback-"));
|
|
160
186
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
161
187
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -215,8 +241,9 @@ test("planning fallback normalizes artifact path", async () => {
|
|
|
215
241
|
cancelSession: async () => undefined,
|
|
216
242
|
closeSession: async () => undefined,
|
|
217
243
|
} as any,
|
|
218
|
-
pollIntervalMs:
|
|
244
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
219
245
|
});
|
|
246
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
220
247
|
|
|
221
248
|
const channelKey = "discord:watch-plan-fallback:default:main";
|
|
222
249
|
await stateStore.createProject(channelKey);
|
|
@@ -230,14 +257,14 @@ test("planning fallback normalizes artifact path", async () => {
|
|
|
230
257
|
|
|
231
258
|
await manager.start();
|
|
232
259
|
await manager.wake(channelKey);
|
|
233
|
-
await
|
|
234
|
-
await
|
|
260
|
+
await waitForProjectStatus(repoPath, "ready");
|
|
261
|
+
await stopManager();
|
|
235
262
|
|
|
236
263
|
const project = await stateStore.getActiveProject(channelKey);
|
|
237
264
|
assert.deepEqual(project?.lastExecution?.changedFiles, ["openspec/changes/watch-plan-fallback/proposal.md"]);
|
|
238
265
|
});
|
|
239
266
|
|
|
240
|
-
test("watcher force-refreshes planning artifacts when journal is dirty and OpenSpec reports all artifacts done", async () => {
|
|
267
|
+
test("watcher force-refreshes planning artifacts when journal is dirty and OpenSpec reports all artifacts done", async (t) => {
|
|
241
268
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-forced-"));
|
|
242
269
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
243
270
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -358,8 +385,9 @@ test("watcher force-refreshes planning artifacts when journal is dirty and OpenS
|
|
|
358
385
|
logger: createLogger(),
|
|
359
386
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
360
387
|
acpClient: fakeAcpClient as any,
|
|
361
|
-
pollIntervalMs:
|
|
388
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
362
389
|
});
|
|
390
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
363
391
|
|
|
364
392
|
const channelKey = "discord:watch-plan-forced:default:main";
|
|
365
393
|
await stateStore.createProject(channelKey);
|
|
@@ -398,8 +426,8 @@ test("watcher force-refreshes planning artifacts when journal is dirty and OpenS
|
|
|
398
426
|
|
|
399
427
|
await manager.start();
|
|
400
428
|
await manager.wake(channelKey);
|
|
401
|
-
await
|
|
402
|
-
await
|
|
429
|
+
await waitForProjectStatus(repoPath, "ready");
|
|
430
|
+
await stopManager();
|
|
403
431
|
|
|
404
432
|
const project = await stateStore.getActiveProject(channelKey);
|
|
405
433
|
assert.deepEqual(runOrder, ["proposal", "specs", "design", "tasks"]);
|
|
@@ -412,7 +440,7 @@ test("watcher force-refreshes planning artifacts when journal is dirty and OpenS
|
|
|
412
440
|
);
|
|
413
441
|
});
|
|
414
442
|
|
|
415
|
-
test("watcher restarts planning worker after ACP runtime exit", async () => {
|
|
443
|
+
test("watcher restarts planning worker after ACP runtime exit", async (t) => {
|
|
416
444
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-restart-"));
|
|
417
445
|
const workspacePath = path.join(tempRoot, "workspace");
|
|
418
446
|
const repoPath = path.join(workspacePath, "demo-app");
|
|
@@ -460,8 +488,9 @@ test("watcher restarts planning worker after ACP runtime exit", async () => {
|
|
|
460
488
|
logger: createLogger(),
|
|
461
489
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
462
490
|
acpClient: fakeAcpClient as any,
|
|
463
|
-
pollIntervalMs:
|
|
491
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
464
492
|
});
|
|
493
|
+
const stopManager = registerManagerCleanup(t, manager);
|
|
465
494
|
|
|
466
495
|
const channelKey = "discord:watch-plan-restart:default:main";
|
|
467
496
|
await stateStore.createProject(channelKey);
|
|
@@ -490,8 +519,8 @@ test("watcher restarts planning worker after ACP runtime exit", async () => {
|
|
|
490
519
|
|
|
491
520
|
await manager.start();
|
|
492
521
|
await manager.wake(channelKey);
|
|
493
|
-
await
|
|
494
|
-
await
|
|
522
|
+
await waitForProjectStatus(repoPath, "ready");
|
|
523
|
+
await stopManager();
|
|
495
524
|
|
|
496
525
|
const project = await stateStore.getActiveProject(channelKey);
|
|
497
526
|
assert.equal(project?.status, "ready");
|
|
@@ -3,17 +3,32 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
6
|
-
import { pathExists, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
6
|
+
import { pathExists, readJsonFile, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
7
7
|
import { getRepoStatePaths } from "../src/utils/paths.ts";
|
|
8
8
|
import { RollbackStore } from "../src/rollback/store.ts";
|
|
9
9
|
import { ProjectStateStore } from "../src/state/store.ts";
|
|
10
10
|
import { WatcherManager, describeWorkerStartupTimeout, shouldAbortWorkerStartup } from "../src/watchers/manager.ts";
|
|
11
11
|
import { createLogger, waitFor } from "./helpers/harness.ts";
|
|
12
12
|
|
|
13
|
+
const TEST_WATCHER_POLL_INTERVAL_MS = 1_000;
|
|
14
|
+
const TEST_WAIT_TIMEOUT_MS = 60_000;
|
|
15
|
+
|
|
13
16
|
function hasMessage(messages: string[], ...parts: string[]): boolean {
|
|
14
17
|
return messages.some((message) => parts.every((part) => message.includes(part)));
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
async function readProjectState(repoPath: string) {
|
|
21
|
+
return await readJsonFile<any>(getRepoStatePaths(repoPath, "archives").stateFile, null);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function waitForProjectState(
|
|
25
|
+
repoPath: string,
|
|
26
|
+
predicate: (project: any) => boolean,
|
|
27
|
+
timeoutMs = TEST_WAIT_TIMEOUT_MS,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await waitFor(async () => predicate(await readProjectState(repoPath)), timeoutMs);
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
test("queue owner unavailable is treated as a non-fatal startup state", () => {
|
|
18
33
|
const status = {
|
|
19
34
|
summary: "status=dead acpxRecordId=session-1",
|
|
@@ -129,7 +144,7 @@ test("watcher work flow completes", async (t) => {
|
|
|
129
144
|
logger: createLogger(),
|
|
130
145
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
131
146
|
acpClient: fakeAcpClient as any,
|
|
132
|
-
pollIntervalMs:
|
|
147
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
133
148
|
});
|
|
134
149
|
t.after(async () => {
|
|
135
150
|
await manager.stop();
|
|
@@ -166,9 +181,11 @@ test("watcher work flow completes", async (t) => {
|
|
|
166
181
|
|
|
167
182
|
await manager.start();
|
|
168
183
|
await manager.wake(channelKey);
|
|
169
|
-
await
|
|
170
|
-
|
|
171
|
-
|
|
184
|
+
await waitForProjectState(
|
|
185
|
+
repoPath,
|
|
186
|
+
(project) =>
|
|
187
|
+
project?.status === "done"
|
|
188
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work", "All tasks complete", "/clawspec archive"),
|
|
172
189
|
);
|
|
173
190
|
|
|
174
191
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -325,7 +342,7 @@ test("worker progress events keep running state in sync before execution finishe
|
|
|
325
342
|
logger: createLogger(),
|
|
326
343
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
327
344
|
acpClient: fakeAcpClient as any,
|
|
328
|
-
pollIntervalMs:
|
|
345
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
329
346
|
});
|
|
330
347
|
t.after(async () => {
|
|
331
348
|
await manager.stop();
|
|
@@ -362,20 +379,17 @@ test("worker progress events keep running state in sync before execution finishe
|
|
|
362
379
|
|
|
363
380
|
await manager.start();
|
|
364
381
|
await manager.wake(channelKey);
|
|
365
|
-
await
|
|
366
|
-
|
|
367
|
-
return project?.status === "running"
|
|
382
|
+
await waitForProjectState(repoPath, (project) =>
|
|
383
|
+
project?.status === "running"
|
|
368
384
|
&& project.taskCounts?.complete === 1
|
|
369
385
|
&& project.taskCounts?.remaining === 1
|
|
370
386
|
&& project.currentTask === "1.2 Add multipart parsing"
|
|
371
387
|
&& project.latestSummary?.includes("Start 1.2") === true
|
|
372
|
-
&& project.execution?.currentTaskId === "1.2"
|
|
373
|
-
|
|
388
|
+
&& project.execution?.currentTaskId === "1.2",
|
|
389
|
+
);
|
|
374
390
|
|
|
375
391
|
releaseFinalStep();
|
|
376
|
-
await
|
|
377
|
-
(await stateStore.getActiveProject(channelKey))?.status === "done",
|
|
378
|
-
);
|
|
392
|
+
await waitForProjectState(repoPath, (project) => project?.status === "done");
|
|
379
393
|
|
|
380
394
|
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-progress-sync", "1/2", "Done 1.1"), true);
|
|
381
395
|
assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-progress-sync", "2/2", "Start 1.2"), true);
|
|
@@ -483,7 +497,7 @@ test("watcher restarts implementation worker after ACP runtime exit", async (t)
|
|
|
483
497
|
logger: createLogger(),
|
|
484
498
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
485
499
|
acpClient: fakeAcpClient as any,
|
|
486
|
-
pollIntervalMs:
|
|
500
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
487
501
|
});
|
|
488
502
|
t.after(async () => {
|
|
489
503
|
await manager.stop();
|
|
@@ -520,10 +534,11 @@ test("watcher restarts implementation worker after ACP runtime exit", async (t)
|
|
|
520
534
|
|
|
521
535
|
await manager.start();
|
|
522
536
|
await manager.wake(channelKey);
|
|
523
|
-
await
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
537
|
+
await waitForProjectState(
|
|
538
|
+
repoPath,
|
|
539
|
+
(project) =>
|
|
540
|
+
project?.status === "done"
|
|
541
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-restart", "All tasks complete", "/clawspec archive"),
|
|
527
542
|
);
|
|
528
543
|
|
|
529
544
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -651,7 +666,7 @@ test("watcher restarts a dead ACP session after progress stalls", async (t) => {
|
|
|
651
666
|
logger: createLogger(),
|
|
652
667
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
653
668
|
acpClient: fakeAcpClient as any,
|
|
654
|
-
pollIntervalMs:
|
|
669
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
655
670
|
});
|
|
656
671
|
t.after(async () => {
|
|
657
672
|
await manager.stop();
|
|
@@ -688,9 +703,11 @@ test("watcher restarts a dead ACP session after progress stalls", async (t) => {
|
|
|
688
703
|
|
|
689
704
|
await manager.start();
|
|
690
705
|
await manager.wake(channelKey);
|
|
691
|
-
await
|
|
692
|
-
|
|
693
|
-
|
|
706
|
+
await waitForProjectState(
|
|
707
|
+
repoPath,
|
|
708
|
+
(project) =>
|
|
709
|
+
project?.status === "done"
|
|
710
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-dead-session", "All tasks complete", "/clawspec archive"),
|
|
694
711
|
);
|
|
695
712
|
|
|
696
713
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -798,7 +815,7 @@ test("watcher restarts a dead ACP session that dies before first progress", asyn
|
|
|
798
815
|
logger: createLogger(),
|
|
799
816
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
800
817
|
acpClient: fakeAcpClient as any,
|
|
801
|
-
pollIntervalMs:
|
|
818
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
802
819
|
});
|
|
803
820
|
t.after(async () => {
|
|
804
821
|
await manager.stop();
|
|
@@ -835,9 +852,11 @@ test("watcher restarts a dead ACP session that dies before first progress", asyn
|
|
|
835
852
|
|
|
836
853
|
await manager.start();
|
|
837
854
|
await manager.wake(channelKey);
|
|
838
|
-
await
|
|
839
|
-
|
|
840
|
-
|
|
855
|
+
await waitForProjectState(
|
|
856
|
+
repoPath,
|
|
857
|
+
(project) =>
|
|
858
|
+
project?.status === "done"
|
|
859
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-dead-startup", "All tasks complete", "/clawspec archive"),
|
|
841
860
|
);
|
|
842
861
|
|
|
843
862
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -958,7 +977,7 @@ test("status-only ACP heartbeats do not keep a dead session alive", async (t) =>
|
|
|
958
977
|
logger: createLogger(),
|
|
959
978
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
960
979
|
acpClient: fakeAcpClient as any,
|
|
961
|
-
pollIntervalMs:
|
|
980
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
962
981
|
});
|
|
963
982
|
t.after(async () => {
|
|
964
983
|
await manager.stop();
|
|
@@ -995,9 +1014,11 @@ test("status-only ACP heartbeats do not keep a dead session alive", async (t) =>
|
|
|
995
1014
|
|
|
996
1015
|
await manager.start();
|
|
997
1016
|
await manager.wake(channelKey);
|
|
998
|
-
await
|
|
999
|
-
|
|
1000
|
-
|
|
1017
|
+
await waitForProjectState(
|
|
1018
|
+
repoPath,
|
|
1019
|
+
(project) =>
|
|
1020
|
+
project?.status === "done"
|
|
1021
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-status-heartbeats", "All tasks complete"),
|
|
1001
1022
|
);
|
|
1002
1023
|
|
|
1003
1024
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1124,7 +1145,7 @@ test("dead ACP session that ignores abort is restarted without hanging the watch
|
|
|
1124
1145
|
logger: createLogger(),
|
|
1125
1146
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1126
1147
|
acpClient: fakeAcpClient as any,
|
|
1127
|
-
pollIntervalMs:
|
|
1148
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1128
1149
|
});
|
|
1129
1150
|
t.after(async () => {
|
|
1130
1151
|
if (heartbeatTimer) {
|
|
@@ -1165,9 +1186,11 @@ test("dead ACP session that ignores abort is restarted without hanging the watch
|
|
|
1165
1186
|
|
|
1166
1187
|
await manager.start();
|
|
1167
1188
|
await manager.wake(channelKey);
|
|
1168
|
-
await
|
|
1169
|
-
|
|
1170
|
-
|
|
1189
|
+
await waitForProjectState(
|
|
1190
|
+
repoPath,
|
|
1191
|
+
(project) =>
|
|
1192
|
+
project?.status === "done"
|
|
1193
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-hung-dead-session", "All tasks complete"),
|
|
1171
1194
|
);
|
|
1172
1195
|
|
|
1173
1196
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1205,7 +1228,7 @@ test("manager stop closes active worker sessions and rearms project recovery sta
|
|
|
1205
1228
|
closedSessions.push({ sessionKey, reason });
|
|
1206
1229
|
},
|
|
1207
1230
|
} as any,
|
|
1208
|
-
pollIntervalMs:
|
|
1231
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1209
1232
|
});
|
|
1210
1233
|
|
|
1211
1234
|
const channelKey = "discord:watch-stop:default:main";
|
|
@@ -1309,7 +1332,7 @@ test("watcher stops retrying after 10 ACP restart attempts", async (t) => {
|
|
|
1309
1332
|
logger: createLogger(),
|
|
1310
1333
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1311
1334
|
acpClient: fakeAcpClient as any,
|
|
1312
|
-
pollIntervalMs:
|
|
1335
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1313
1336
|
});
|
|
1314
1337
|
t.after(async () => {
|
|
1315
1338
|
await manager.stop();
|
|
@@ -1347,9 +1370,11 @@ test("watcher stops retrying after 10 ACP restart attempts", async (t) => {
|
|
|
1347
1370
|
}));
|
|
1348
1371
|
|
|
1349
1372
|
await manager.wake(channelKey);
|
|
1350
|
-
await
|
|
1351
|
-
|
|
1352
|
-
|
|
1373
|
+
await waitForProjectState(
|
|
1374
|
+
repoPath,
|
|
1375
|
+
(project) =>
|
|
1376
|
+
project?.status === "blocked"
|
|
1377
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-restart-cap", "Blocked after 10 ACP restart attempts"),
|
|
1353
1378
|
);
|
|
1354
1379
|
|
|
1355
1380
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1415,7 +1440,7 @@ test("watcher blocked message includes ACPX setup guidance when backend stays un
|
|
|
1415
1440
|
logger: createLogger(),
|
|
1416
1441
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1417
1442
|
acpClient: fakeAcpClient as any,
|
|
1418
|
-
pollIntervalMs:
|
|
1443
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1419
1444
|
});
|
|
1420
1445
|
t.after(async () => {
|
|
1421
1446
|
await manager.stop();
|
|
@@ -1453,9 +1478,11 @@ test("watcher blocked message includes ACPX setup guidance when backend stays un
|
|
|
1453
1478
|
}));
|
|
1454
1479
|
|
|
1455
1480
|
await manager.wake(channelKey);
|
|
1456
|
-
await
|
|
1457
|
-
|
|
1458
|
-
|
|
1481
|
+
await waitForProjectState(
|
|
1482
|
+
repoPath,
|
|
1483
|
+
(project) =>
|
|
1484
|
+
project?.status === "blocked"
|
|
1485
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "Blocked: ACPX backend unavailable"),
|
|
1459
1486
|
);
|
|
1460
1487
|
|
|
1461
1488
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1545,7 +1572,7 @@ test("watcher retries when ACP runtime backend is temporarily unavailable", asyn
|
|
|
1545
1572
|
logger: createLogger(),
|
|
1546
1573
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1547
1574
|
acpClient: fakeAcpClient as any,
|
|
1548
|
-
pollIntervalMs:
|
|
1575
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1549
1576
|
});
|
|
1550
1577
|
t.after(async () => {
|
|
1551
1578
|
await manager.stop();
|
|
@@ -1581,9 +1608,11 @@ test("watcher retries when ACP runtime backend is temporarily unavailable", asyn
|
|
|
1581
1608
|
}));
|
|
1582
1609
|
|
|
1583
1610
|
await manager.wake(channelKey);
|
|
1584
|
-
await
|
|
1585
|
-
|
|
1586
|
-
|
|
1611
|
+
await waitForProjectState(
|
|
1612
|
+
repoPath,
|
|
1613
|
+
(project) =>
|
|
1614
|
+
project?.status === "done"
|
|
1615
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "All tasks complete"),
|
|
1587
1616
|
);
|
|
1588
1617
|
|
|
1589
1618
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -1689,7 +1718,7 @@ test("watcher finalizes when terminal result exists before ACP turn exits", asyn
|
|
|
1689
1718
|
logger: createLogger(),
|
|
1690
1719
|
notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
|
|
1691
1720
|
acpClient: fakeAcpClient as any,
|
|
1692
|
-
pollIntervalMs:
|
|
1721
|
+
pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
|
|
1693
1722
|
});
|
|
1694
1723
|
t.after(async () => {
|
|
1695
1724
|
await manager.stop();
|
|
@@ -1726,10 +1755,11 @@ test("watcher finalizes when terminal result exists before ACP turn exits", asyn
|
|
|
1726
1755
|
|
|
1727
1756
|
await manager.start();
|
|
1728
1757
|
await manager.wake(channelKey);
|
|
1729
|
-
await
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1758
|
+
await waitForProjectState(
|
|
1759
|
+
repoPath,
|
|
1760
|
+
(project) =>
|
|
1761
|
+
project?.status === "done"
|
|
1762
|
+
&& hasMessage(notifierMessages, "demo-app-watch-work-terminal", "All tasks complete"),
|
|
1733
1763
|
);
|
|
1734
1764
|
|
|
1735
1765
|
const project = await stateStore.getActiveProject(channelKey);
|
|
@@ -93,5 +93,5 @@ test("implementation prompt instructs the worker to use the helper", async () =>
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
assert.match(prompt, /Use the worker IO helper instead of editing .*worker-progress\.jsonl directly\./);
|
|
96
|
-
assert.match(prompt, /worker_io\.mjs" event --kind <status\|task_start\|task_done\|blocked>/);
|
|
96
|
+
assert.match(prompt, /worker_io\.mjs['"] event --kind <status\|task_start\|task_done\|blocked>/);
|
|
97
97
|
});
|