svharness 0.15.10 → 0.15.11
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/auto/auto-harness-detail-design.md +207 -33
- package/auto/templates/auto.config.example.yaml +1 -1
- package/dist/commands/auto.js +2 -1
- package/dist/dashboard/event-bus.js +3 -0
- package/dist/dashboard/index.js +1 -1
- package/dist/dashboard/sse-handler.js +3 -0
- package/dist/dashboard/static/js/app.js +5 -0
- package/dist/index.js +2 -1
- package/dist/lib/auto-orchestrator.js +29 -3
- package/dist/lib/auto-state.js +9 -2
- package/dist/lib/cost-tracker.js +30 -2
- package/dist/lib/phase-prompt-loader.js +4 -0
- package/dist/lib/phase-runner.js +221 -69
- package/package.json +1 -1
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
> **文档定位**:`svharness auto` 模式的完整设计书,面向本系统的开发者与维护者。
|
|
4
4
|
> **上级文档**:`svharnessbuild/README.md`(CLI 总体规范)、`svharnessbuild/tmp/svharnessbuild-detail-design.md`(SDD)。
|
|
5
5
|
> **参照工程**:[snarktank/ralph](https://github.com/snarktank/ralph)(外循环思路)。
|
|
6
|
-
> **版本**:v2.
|
|
7
|
-
> **最后更新**:2026-06-
|
|
6
|
+
> **版本**:v2.6(2026-06-26,任意错误不中断 + CostTracker 超限兜底优化)
|
|
7
|
+
> **最后更新**:2026-06-26
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -399,9 +399,10 @@ program
|
|
|
399
399
|
.option('-y, --yes', '跳过所有交互确认(推荐无人值守)')
|
|
400
400
|
.option('--verbose', '详细日志')
|
|
401
401
|
// === 成本控制 ===
|
|
402
|
-
.option('--max-total-calls <n>', '总 ACP 调用次数上限(含重试和优化),超出即冻结', '
|
|
402
|
+
.option('--max-total-calls <n>', '总 ACP 调用次数上限(含重试和优化),超出即冻结', '100')
|
|
403
403
|
.option('--budget-usd <amount>', '费用预算上限(USD),超出暂停等待确认')
|
|
404
404
|
.option('--cost-alert <amount>', '单次调用费用告警阈值(USD)', '5')
|
|
405
|
+
.option('--no-auto-repair', '禁用阶段失败自动修复(默认启用)')
|
|
405
406
|
.action(runAuto);
|
|
406
407
|
```
|
|
407
408
|
|
|
@@ -440,20 +441,46 @@ class AutoOrchestrator {
|
|
|
440
441
|
this.ensurePhaseRunnerReady();
|
|
441
442
|
|
|
442
443
|
// === AGENT-2: 构建 ===
|
|
443
|
-
|
|
444
|
+
try {
|
|
445
|
+
await this.runHarnessBuild(); // 驱动 S10→S80 全流程
|
|
446
|
+
} catch (err) {
|
|
447
|
+
// v2.6: 构建阶段失败不裸崩溃,先 emit build_failed 事件再终止
|
|
448
|
+
dashboardEventBus.emitBuildFailed(undefined);
|
|
449
|
+
throw new Error(`构建阶段失败:${err.message};auto 构建终止,请在修复后重新执行`);
|
|
450
|
+
}
|
|
444
451
|
|
|
445
452
|
// === AGENT-3 + AGENT-4: 审查 + 优化循环 ===
|
|
446
453
|
let round = 0;
|
|
447
454
|
while (round < this.ctx.maxOptimizeRounds) {
|
|
448
455
|
round++;
|
|
449
|
-
|
|
450
|
-
|
|
456
|
+
let review;
|
|
457
|
+
try {
|
|
458
|
+
review = await this.runHarnessReview(round); // 产出 第<n>评审报告.md
|
|
459
|
+
} catch (err) {
|
|
460
|
+
// v2.6: 资源限制(预算/调用次数超限)优雅终止,其他异常继续下一轮
|
|
461
|
+
if (err.message.includes('预算') || err.message.includes('调用次数') || err.message.includes('ACP 调用')) {
|
|
462
|
+
logger.error(`评审因资源限制终止:${err.message}`);
|
|
463
|
+
dashboardEventBus.emitBuildFailed(undefined);
|
|
464
|
+
throw err;
|
|
465
|
+
}
|
|
466
|
+
logger.warn(`评审异常:${err.message},继续下一轮`);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (review.composite_score >= this.ctx.targetScore && review.gate_pass) {
|
|
451
470
|
await this.runFinalize();
|
|
452
471
|
break;
|
|
453
472
|
}
|
|
454
473
|
// AGENT-4 两步:先写 第<n>轮修改-plan.md,再实施
|
|
455
|
-
|
|
456
|
-
|
|
474
|
+
try {
|
|
475
|
+
await this.runOptimizePlan(round, score);
|
|
476
|
+
await this.runOptimizeImplement(round, score);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
// v2.6: 资源限制 break,其他异常继续
|
|
479
|
+
if (err.message.includes('预算') || err.message.includes('次数')) {
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
logger.warn(`第 ${round} 轮优化失败:${err.message},继续下一轮`);
|
|
483
|
+
}
|
|
457
484
|
}
|
|
458
485
|
}
|
|
459
486
|
}
|
|
@@ -620,13 +647,19 @@ class CostTracker {
|
|
|
620
647
|
estimatedCostUsd: number;
|
|
621
648
|
phaseCosts: Map<string, number>;
|
|
622
649
|
|
|
650
|
+
// v2.6: 80%/95% 预警告警标志(单次触发,不重复)
|
|
651
|
+
private budget80Warned = false;
|
|
652
|
+
private budget95Warned = false;
|
|
653
|
+
private calls80Warned = false;
|
|
654
|
+
private calls95Warned = false;
|
|
655
|
+
|
|
623
656
|
constructor(
|
|
624
|
-
private maxTotalCalls: number, // 默认 30
|
|
657
|
+
private maxTotalCalls: number, // 默认 100(v2.6 从 30 提升)
|
|
625
658
|
private budgetUsd: number | null, // null = 不限制
|
|
626
659
|
private alertThreshold: number, // 默认 5 USD
|
|
627
660
|
) {}
|
|
628
661
|
|
|
629
|
-
|
|
662
|
+
recordCall(phase: string, promptTokens = 0, outputTokens = 0): void {
|
|
630
663
|
this.totalCalls++;
|
|
631
664
|
const cost = estimateCost(promptTokens, outputTokens);
|
|
632
665
|
this.estimatedCostUsd += cost;
|
|
@@ -635,14 +668,40 @@ class CostTracker {
|
|
|
635
668
|
if (cost > this.alertThreshold) {
|
|
636
669
|
logger.warn(`[成本] ${phase} 本次约 $${cost.toFixed(2)},累计 $${this.estimatedCostUsd.toFixed(2)}`);
|
|
637
670
|
}
|
|
671
|
+
|
|
672
|
+
// v2.6: 预算 80%/95% 预警告警
|
|
673
|
+
if (this.budgetUsd !== null) {
|
|
674
|
+
const usagePct = (this.estimatedCostUsd / this.budgetUsd) * 100;
|
|
675
|
+
if (usagePct >= 80 && usagePct < 95 && !this.budget80Warned) {
|
|
676
|
+
this.budget80Warned = true;
|
|
677
|
+
logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),接近上限`);
|
|
678
|
+
}
|
|
679
|
+
if (usagePct >= 95 && !this.budget95Warned) {
|
|
680
|
+
this.budget95Warned = true;
|
|
681
|
+
logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),即将达到上限`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// v2.6: 调用次数 80%/95% 预警告警
|
|
686
|
+
if (this.maxTotalCalls > 0) {
|
|
687
|
+
const callsPct = (this.totalCalls / this.maxTotalCalls) * 100;
|
|
688
|
+
if (callsPct >= 80 && callsPct < 95 && !this.calls80Warned) {
|
|
689
|
+
this.calls80Warned = true;
|
|
690
|
+
logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),接近上限`);
|
|
691
|
+
}
|
|
692
|
+
if (callsPct >= 95 && !this.calls95Warned) {
|
|
693
|
+
this.calls95Warned = true;
|
|
694
|
+
logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),即将达到上限`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
638
698
|
if (this.totalCalls >= this.maxTotalCalls) {
|
|
639
|
-
|
|
640
|
-
await enterCheckpoint('max-calls-exceeded');
|
|
699
|
+
throw new Error(`ACP 调用次数 ${this.totalCalls} 已超过上限 ${this.maxTotalCalls},累计费用 $${this.estimatedCostUsd.toFixed(2)}`);
|
|
641
700
|
}
|
|
642
701
|
if (this.budgetUsd !== null && this.estimatedCostUsd >= this.budgetUsd) {
|
|
643
|
-
|
|
644
|
-
await enterCheckpoint('budget-exceeded');
|
|
702
|
+
throw new Error(`ACP 估算费用 $${this.estimatedCostUsd.toFixed(2)} 已超过预算 $${this.budgetUsd.toFixed(2)}(${this.totalCalls} 次调用)`);
|
|
645
703
|
}
|
|
704
|
+
dashboardEventBus.emitCostUpdated(this.estimatedCostUsd, this.totalCalls);
|
|
646
705
|
}
|
|
647
706
|
|
|
648
707
|
checkBudget(): 'ok' | 'alert' | 'exceeded';
|
|
@@ -650,10 +709,10 @@ class CostTracker {
|
|
|
650
709
|
}
|
|
651
710
|
```
|
|
652
711
|
|
|
653
|
-
|
|
712
|
+
**默认建议**(v2.6 提升):
|
|
654
713
|
|
|
655
|
-
- `--max-total-calls
|
|
656
|
-
- `--max-optimize-rounds
|
|
714
|
+
- `--max-total-calls 100`(约 $30–$100 API 成本,覆盖完整构建+多轮优化)
|
|
715
|
+
- `--max-optimize-rounds 5`(默认值不变)
|
|
657
716
|
|
|
658
717
|
### 4.5 完成标记协议
|
|
659
718
|
|
|
@@ -1265,25 +1324,68 @@ async function rerunOutdatedPhases(
|
|
|
1265
1324
|
}
|
|
1266
1325
|
```
|
|
1267
1326
|
|
|
1268
|
-
### 6.4
|
|
1327
|
+
### 6.4 失败重试策略(v2.6 重写)
|
|
1328
|
+
|
|
1329
|
+
整个重试循环 + auto-repair + SKIPPED 跳过链,全部在 `PhaseRunner.runPhase()` 内实现。
|
|
1330
|
+
|
|
1331
|
+
**流程图**:
|
|
1332
|
+
|
|
1333
|
+
```
|
|
1334
|
+
for attempt = 0..maxRetriesPerPhase:
|
|
1335
|
+
cost.recordCall()
|
|
1336
|
+
┌─ try-catch 捕获 CostTracker 异常 ──────────────┐
|
|
1337
|
+
│ acpOk=false → tryResolveFromArtifacts(免费) │
|
|
1338
|
+
│ ├─ 命中 → DONE (「资源受限产物兜底」) ✅ │
|
|
1339
|
+
│ └─ 未命中 → break 退出循环 │
|
|
1340
|
+
└─────────────────────────────────────────────────┘
|
|
1341
|
+
┌─ acpOk=true → ACP 调用 → 状态检查 ─────────────┐
|
|
1342
|
+
│ ├─ 状态 DONE/SKIPPED → return ✅ │
|
|
1343
|
+
│ ├─ 信号兜底 <PHASE_DONE/SKIP> → return ✅ │
|
|
1344
|
+
│ └─ 产物兜底(检查磁盘产物)→ return ✅ │
|
|
1345
|
+
└─────────────────────────────────────────────────┘
|
|
1346
|
+
|
|
1347
|
+
重试循环结束后(尝试全部耗尽):
|
|
1348
|
+
1. emitPhaseFailed
|
|
1349
|
+
2. if (autoRepair !== false):
|
|
1350
|
+
tryAutoRepair() — 再次调 ACP 修复数据
|
|
1351
|
+
├─ ACP 修复成功 → DONE ✅
|
|
1352
|
+
└─ 修复失败 → 日志警告
|
|
1353
|
+
3. 标记 SKIPPED + writeBuildState → 继续下一阶段 ▶️
|
|
1354
|
+
(不再 throw Error,构建不会中断)
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
**关键差异**(对比 v2.5):
|
|
1358
|
+
|
|
1359
|
+
| 方面 | v2.5 | v2.6 |
|
|
1360
|
+
|------|------|------|
|
|
1361
|
+
| retry 耗尽后 | `throw new Error()` | `tryAutoRepair()` + 标记 `SKIPPED` |
|
|
1362
|
+
| CostTracker 超限 | 异常穿透 → 进程终止 | try-catch 捕获 → 免费产物兜底 + `break` |
|
|
1363
|
+
| 仪表盘事件 | 无 `build_failed` | 新增全链路 `build_failed` 事件 |
|
|
1364
|
+
| YAML 写入 | 直接 `writeFile` | `.tmp` + `rename` 原子写入 |
|
|
1365
|
+
| 默认调用次数 | 30 | 100 |
|
|
1366
|
+
|
|
1367
|
+
**`tryAutoRepair()` 实现**:
|
|
1269
1368
|
|
|
1270
1369
|
```typescript
|
|
1271
|
-
async
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const retryResult = await this.acp.call({
|
|
1279
|
-
workDir: this.harnessRoot,
|
|
1280
|
-
prompt: fixPrompt
|
|
1370
|
+
private async tryAutoRepair(def, harnessRoot, phaseStartTime): Promise<boolean> {
|
|
1371
|
+
try {
|
|
1372
|
+
this.cost.recordCall(def.key + '_repair');
|
|
1373
|
+
const result = await this.acp.call({
|
|
1374
|
+
workDir: harnessRoot,
|
|
1375
|
+
phase: def.key + '_repair',
|
|
1376
|
+
prompt: wrapAcpPrompt(repairPrompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`),
|
|
1281
1377
|
});
|
|
1282
|
-
|
|
1283
|
-
|
|
1378
|
+
// 三级兜底:状态检查 → 信号兜底 → 产物兜底
|
|
1379
|
+
const state = await readBuildState(harnessRoot);
|
|
1380
|
+
if (phaseStatus(state, def.key) === 'DONE') return true;
|
|
1381
|
+
const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
|
|
1382
|
+
if (resolved) return true;
|
|
1383
|
+
const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime);
|
|
1384
|
+
if (artifactResolved) return true;
|
|
1385
|
+
return false;
|
|
1386
|
+
} catch (err) {
|
|
1387
|
+
return false; // 任何异常不穿透,调用方标记 SKIPPED
|
|
1284
1388
|
}
|
|
1285
|
-
|
|
1286
|
-
throw new PhaseFailedError(phase, `超过最大重试次数 ${MAX_RETRIES_PER_PHASE}`);
|
|
1287
1389
|
}
|
|
1288
1390
|
```
|
|
1289
1391
|
|
|
@@ -2171,8 +2273,11 @@ auto:
|
|
|
2171
2273
|
dashboard: true # v2.5 新增:启动本地 Web 仪表盘(端口 7879)
|
|
2172
2274
|
dashboardPort: 7879 # 仪表盘 HTTP 端口
|
|
2173
2275
|
|
|
2276
|
+
# v2.6 新增:阶段失败自动修复(默认启用)
|
|
2277
|
+
autoRepair: true # false 则禁用 ACP 自动修复,阶段失败后直接 SKIPPED
|
|
2278
|
+
|
|
2174
2279
|
# 成本控制
|
|
2175
|
-
maxTotalCalls: 30
|
|
2280
|
+
maxTotalCalls: 100 # v2.6 从 30 提升(对应约 $30-$100 API 成本)
|
|
2176
2281
|
budgetUsd: null # null = 不限制
|
|
2177
2282
|
costAlertThreshold: 5 # USD,单次调用告警阈值
|
|
2178
2283
|
|
|
@@ -2180,6 +2285,7 @@ auto:
|
|
|
2180
2285
|
acpMode: auto # auto | sdk | cli-print
|
|
2181
2286
|
acpTimeoutMs: 1800000 # 30 分钟(v2.4 从 10 分钟提升)
|
|
2182
2287
|
maxRetriesPerPhase: 2 # 每阶段最大重试次数
|
|
2288
|
+
autoRepair: true # v2.6
|
|
2183
2289
|
|
|
2184
2290
|
# 构建参数(同 svharness.config.yaml build 节)
|
|
2185
2291
|
generateWiki: false # build 时是否直接生成完整 wiki(false = 由 auto 驱动 ACP 生成)
|
|
@@ -2627,3 +2733,71 @@ PhaseRunner/AutoOrchestrator/CostTracker
|
|
|
2627
2733
|
→ broadcast SSE event → 浏览器三面板自动更新
|
|
2628
2734
|
```
|
|
2629
2735
|
|
|
2736
|
+
---
|
|
2737
|
+
|
|
2738
|
+
## 附录:v2.6 实现修订记录(2026-06-26)
|
|
2739
|
+
|
|
2740
|
+
根据审查与验证反馈,新增任意错误不中断机制与 CostTracker 超限兜底优化:
|
|
2741
|
+
|
|
2742
|
+
| # | 文件 | 修订内容 | 类型 | 对应 § |
|
|
2743
|
+
|---|------|---------|------|--------|
|
|
2744
|
+
| 1 | `src/lib/auto-orchestrator.ts` | **runBuildPhases() try-catch**:初始构建失败时 `logger.error` + `emitBuildFailed` + 带上下文重新抛出 | Bug 修复 | §3.3 |
|
|
2745
|
+
| 2 | `src/lib/auto-orchestrator.ts` | **评审阶段 try-catch**:`runHarnessReview()` 单独 try-catch,资源限制优雅终止,其他异常继续下一轮 | 增强 | §3.3 |
|
|
2746
|
+
| 3 | `src/lib/auto-orchestrator.ts` | **优化循环 catch break**:CostTracker 超限时 `break` 而非 `continue`,避免下一轮再次触发崩溃 | Bug 修复 | §3.3 |
|
|
2747
|
+
| 4 | `src/lib/phase-runner.ts` | **重试耗尽 + auto-repair + SKIPPED**:ACP 重试(默认 3 次)全部耗尽后,先调 `tryAutoRepair()` 尝试 ACP 自动修复数据,修复失败则标记 SKIPPED 并继续下一阶段(不再 `throw Error`) | 新增 | §6.4 |
|
|
2748
|
+
| 5 | `src/lib/phase-runner.ts` | **CostTracker 超限兜底**:`cost.recordCall()` 包 try-catch,捕获后走 `else` 分支免费产物兜底 + `break` 退出重试循环;同时修复了 `if(acpOk)` 缺少闭合 brace 导致的代码结构 Bug | Bug 修复 | §6.4 |
|
|
2749
|
+
| 6 | `src/lib/phase-runner.ts` | **tryAutoRepair() 方法**:新增 `tryAutoRepair()` 私有方法,调用 ACP agent 诊断修复数据问题,叠加三级 fallback(状态/信号/产物) | 新增 | §6.4 |
|
|
2750
|
+
| 7 | `src/lib/auto-state.ts` | **YAML 原子写入**:`writeBuildState()` 改为先写 `.tmp` 再 `fs.rename` 原子替换;`readBuildState()` dedupe 修复路径也统一使用 `.tmp` + `rename` | 增强 | §6.5 |
|
|
2751
|
+
| 8 | `src/lib/cost-tracker.ts` | **80%/95% 预警告警**:新增 4 个一次性预警标志,预算/调用次数达到 80% 和 95% 时各触发一次 `logger.warn`;超限 `throw` 附带完整累计费用和调用次数信息 | 增强 | §4.4 |
|
|
2752
|
+
| 9 | `src/commands/auto.ts` | **默认值提升**:`maxTotalCalls` 默认 30→100;`autoRepair` 默认 `true` 传入 `AutoContext` | 配置 | §12.1 |
|
|
2753
|
+
| 10 | `src/index.ts` | **CLI 选项更新**:`--max-total-calls` 帮助文本更新为默认 100;新增 `--no-auto-repair` 选项 | 配置 | §3.1 |
|
|
2754
|
+
| 11 | `src/dashboard/event-bus.ts` | **build_failed 事件**:新增 `'build_failed'` 事件类型和 `emitBuildFailed()` 方法 | 新增 | §15.1 |
|
|
2755
|
+
| 12 | `src/dashboard/sse-handler.ts` | **build_failed 状态**:`build_failed` 事件将 `BuildSnapshot.status` 置为 `'failed'` | 增强 | §15.2 |
|
|
2756
|
+
| 13 | `src/dashboard/index.ts` | **事件路由**:`build_failed` 注册到事件订阅数组 | 增强 | §15.3 |
|
|
2757
|
+
| 14 | `src/dashboard/static/js/app.js` | **前端失败提示**:`build_failed` 处理器在日志面板显示 `❌ 构建失败` | 增强 | §15.5 |
|
|
2758
|
+
| 15 | `auto/templates/auto.config.example.yaml` | **示例值更新**:`maxTotalCalls: 100` | 配置 | §12.1 |
|
|
2759
|
+
|
|
2760
|
+
### 架构变更
|
|
2761
|
+
|
|
2762
|
+
```
|
|
2763
|
+
v2.5 → v2.6 关键变更:
|
|
2764
|
+
|
|
2765
|
+
PhaseRunner.runPhase() 重试循环
|
|
2766
|
+
├── 原:cost.recordCall() 裸调用 → 异常穿透 → 进程终止
|
|
2767
|
+
├── 新:try-catch 捕获 → acpOk=false → else 分支免费产物兜底 → break
|
|
2768
|
+
|
|
2769
|
+
├── 原:重试耗尽 → throw Error → 构建崩溃
|
|
2770
|
+
└── 新:重试耗尽 → tryAutoRepair() → 修复成功 DONE / 修复失败 SKIPPED ▶️
|
|
2771
|
+
|
|
2772
|
+
CostTracker.recordCall()
|
|
2773
|
+
├── 原:超限直接 throw
|
|
2774
|
+
└── 新:80% → warn | 95% → warn | 100% → throw(含完整累计信息)
|
|
2775
|
+
|
|
2776
|
+
auto-state.ts writeBuildState()
|
|
2777
|
+
├── 原:直接 writeFile
|
|
2778
|
+
└── 新:writeFile(.tmp) → rename(.yaml) 原子写入
|
|
2779
|
+
|
|
2780
|
+
仪表盘事件
|
|
2781
|
+
└── 新增 build_failed(event-bus → sse-handler → app.js 全链路)
|
|
2782
|
+
```
|
|
2783
|
+
|
|
2784
|
+
### 容器对比示意
|
|
2785
|
+
|
|
2786
|
+
```
|
|
2787
|
+
v2.6 PhaseRunner 重试循环容器边界:
|
|
2788
|
+
|
|
2789
|
+
for (attempt = 0; attempt <= maxRetries; attempt++) { ← 容器:重试循环
|
|
2790
|
+
try { cost.recordCall(); } catch { acpOk=false; }
|
|
2791
|
+
if (acpOk) {
|
|
2792
|
+
// ACP 调用 + 状态检查 + 信号兜底 + 产物兜底
|
|
2793
|
+
} else {
|
|
2794
|
+
// 资源耗尽:免费产物兜底 + break
|
|
2795
|
+
}
|
|
2796
|
+
if (attempt < maxRetries) { ... retry warning ... }
|
|
2797
|
+
} ← 容器闭合
|
|
2798
|
+
|
|
2799
|
+
emitPhaseFailed ← 在循环外
|
|
2800
|
+
if (autoRepair) { tryAutoRepair(); } ← 在循环外
|
|
2801
|
+
mark SKIPPED + writeBuildState + continue ← 在循环外
|
|
2802
|
+
```
|
|
2803
|
+
|
package/dist/commands/auto.js
CHANGED
|
@@ -73,7 +73,7 @@ async function resolveAutoContext(positionalWorkdir, opts) {
|
|
|
73
73
|
autoApprove: !!opts.autoApprove,
|
|
74
74
|
yes: !!opts.yes,
|
|
75
75
|
verbose: !!opts.verbose,
|
|
76
|
-
maxTotalCalls: normalizeInteger(opts.maxTotalCalls,
|
|
76
|
+
maxTotalCalls: normalizeInteger(opts.maxTotalCalls, 100, '--max-total-calls'),
|
|
77
77
|
budgetUsd: opts.budgetUsd ?? null,
|
|
78
78
|
costAlertThreshold: normalizeNumber(opts.costAlertThreshold, 5, '--cost-alert'),
|
|
79
79
|
acpMode: opts.acpMode === 'sdk' || opts.acpMode === 'cli-print' ? opts.acpMode : 'auto',
|
|
@@ -85,6 +85,7 @@ async function resolveAutoContext(positionalWorkdir, opts) {
|
|
|
85
85
|
maxPhase: opts.maxPhase,
|
|
86
86
|
goal: opts.goal,
|
|
87
87
|
buildOverrides: opts,
|
|
88
|
+
autoRepair: opts.autoRepair ?? true,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
function normalizeNumber(value, fallback, name) {
|
|
@@ -9,6 +9,9 @@ class DashboardEventBus extends node_events_1.EventEmitter {
|
|
|
9
9
|
emitBuildCompleted(finalScore) {
|
|
10
10
|
this.emitEvent('build_completed', { finalScore });
|
|
11
11
|
}
|
|
12
|
+
emitBuildFailed(finalScore) {
|
|
13
|
+
this.emitEvent('build_failed', { finalScore });
|
|
14
|
+
}
|
|
12
15
|
emitPhaseStarted(key, short, description, attempt) {
|
|
13
16
|
this.emitEvent('phase_started', { key, short, description, attempt });
|
|
14
17
|
}
|
package/dist/dashboard/index.js
CHANGED
|
@@ -24,7 +24,7 @@ function startDashboard(port = 7879) {
|
|
|
24
24
|
server = (0, server_1.createServer)(event_bus_1.dashboardEventBus, sseManager);
|
|
25
25
|
// Subscribe to all events from the bus and forward to SSE clients
|
|
26
26
|
const eventTypes = [
|
|
27
|
-
'build_started', 'build_completed',
|
|
27
|
+
'build_started', 'build_completed', 'build_failed',
|
|
28
28
|
'phase_started', 'phase_completed', 'phase_failed',
|
|
29
29
|
'acp_output',
|
|
30
30
|
'cost_updated', 'score_updated',
|
|
@@ -323,6 +323,11 @@ function connectSSE() {
|
|
|
323
323
|
addLog({ type: 'success', text: `✅ 构建完成!${data.finalScore != null ? `最终评分: ${data.finalScore}` : ''}`, timestamp: Date.now() });
|
|
324
324
|
updateStatus();
|
|
325
325
|
},
|
|
326
|
+
build_failed() {
|
|
327
|
+
state.status = 'failed';
|
|
328
|
+
addLog({ type: 'error', text: '❌ 构建失败!请检查终端日志', timestamp: Date.now() });
|
|
329
|
+
updateStatus();
|
|
330
|
+
},
|
|
326
331
|
phase_started(data) {
|
|
327
332
|
state.currentPhase = data.key;
|
|
328
333
|
state.phaseStatuses[data.key] = 'IN_PROGRESS';
|
package/dist/index.js
CHANGED
|
@@ -255,7 +255,7 @@ function main() {
|
|
|
255
255
|
.option('--auto-approve', '自动通过无人值守下的表单门禁')
|
|
256
256
|
.option('-y, --yes', '跳过交互确认')
|
|
257
257
|
.option('--verbose', '显示详细日志')
|
|
258
|
-
.option('--max-total-calls <n>', 'ACP 调用次数上限(默认
|
|
258
|
+
.option('--max-total-calls <n>', 'ACP 调用次数上限(默认 100)', (v) => Number(v))
|
|
259
259
|
.option('--budget-usd <amount>', '费用预算上限(USD)', (v) => Number(v))
|
|
260
260
|
.option('--cost-alert <amount>', '单次调用费用告警阈值(USD)', (v) => Number(v))
|
|
261
261
|
.option('--acp-mode <mode>', 'ACP 模式:auto | cli-print | sdk')
|
|
@@ -264,6 +264,7 @@ function main() {
|
|
|
264
264
|
.option('--prompt-dir <path>', '覆盖 auto phase prompt 模板目录')
|
|
265
265
|
.option('--baseline-auto-extract <mode>', 'explicit | conservative | disabled')
|
|
266
266
|
.option('--convert-endpoint <url>', 'markitdown_serve 基址')
|
|
267
|
+
.option('--no-auto-repair', '禁用阶段失败自动修复(默认启用)')
|
|
267
268
|
.action(async (workdir, opts, cmd) => {
|
|
268
269
|
try {
|
|
269
270
|
await (0, auto_1.runAuto)(workdir, {
|
|
@@ -67,7 +67,15 @@ class AutoOrchestrator {
|
|
|
67
67
|
this.phaseRunner = new phase_runner_1.PhaseRunner(this.ctx, this.acp, this.cost);
|
|
68
68
|
event_bus_1.dashboardEventBus.emitBuildStarted(phase_runner_1.PHASE_DEFS.length);
|
|
69
69
|
await this.ensureReviewRubric();
|
|
70
|
-
|
|
70
|
+
try {
|
|
71
|
+
await this.phaseRunner.runBuildPhases();
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
const msg = err.message;
|
|
75
|
+
logger_1.logger.error(`[auto] 构建阶段执行失败:${msg}`);
|
|
76
|
+
event_bus_1.dashboardEventBus.emitBuildFailed(undefined);
|
|
77
|
+
throw new Error(`构建阶段失败:${msg};auto 构建终止,请在修复后重新执行`);
|
|
78
|
+
}
|
|
71
79
|
if (this.ctx.maxPhase && phaseNumber(this.ctx.maxPhase) < 85) {
|
|
72
80
|
logger_1.logger.info(`已到达 --max-phase ${this.ctx.maxPhase},跳过评审优化`);
|
|
73
81
|
event_bus_1.dashboardEventBus.emitBuildCompleted(undefined);
|
|
@@ -77,7 +85,20 @@ class AutoOrchestrator {
|
|
|
77
85
|
let finalReview;
|
|
78
86
|
for (let round = 1; round <= this.ctx.maxOptimizeRounds; round++) {
|
|
79
87
|
event_bus_1.dashboardEventBus.emitReviewStarted(round);
|
|
80
|
-
|
|
88
|
+
let review;
|
|
89
|
+
try {
|
|
90
|
+
review = await this.runHarnessReview(round);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const msg = err.message;
|
|
94
|
+
if (msg.includes('预算') || msg.includes('调用次数') || msg.includes('ACP 调用')) {
|
|
95
|
+
logger_1.logger.error(`[auto] 第 ${round} 轮评审因资源限制终止:${msg}`);
|
|
96
|
+
event_bus_1.dashboardEventBus.emitBuildFailed(undefined);
|
|
97
|
+
throw err; // 资源限制是整个流程的硬终止
|
|
98
|
+
}
|
|
99
|
+
logger_1.logger.warn(`[auto] 第 ${round} 轮评审异常:${msg},继续下一轮`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
81
102
|
event_bus_1.dashboardEventBus.emitReviewCompleted(round, review.composite_score, review.critical_count);
|
|
82
103
|
event_bus_1.dashboardEventBus.emitScoreUpdated(review.composite_score, round);
|
|
83
104
|
if (review.composite_score >= this.ctx.targetScore &&
|
|
@@ -103,7 +124,12 @@ class AutoOrchestrator {
|
|
|
103
124
|
event_bus_1.dashboardEventBus.emitOptimizeCompleted(round);
|
|
104
125
|
}
|
|
105
126
|
catch (err) {
|
|
106
|
-
|
|
127
|
+
const msg = err.message;
|
|
128
|
+
if (msg.includes('预算') || msg.includes('次数') || msg.includes('ACP 调用')) {
|
|
129
|
+
logger_1.logger.warn(`[auto] 第 ${round} 轮优化因资源限制终止:${msg}`);
|
|
130
|
+
break; // 资源限制触发时终止优化循环,保留当前评分
|
|
131
|
+
}
|
|
132
|
+
logger_1.logger.warn(`第 ${round} 轮优化执行失败:${msg},状态重置已完成,下一轮可继续`);
|
|
107
133
|
}
|
|
108
134
|
}
|
|
109
135
|
event_bus_1.dashboardEventBus.emitCostUpdated(this.cost.report().estimatedCostUsd, this.cost.report().totalCalls);
|
package/dist/lib/auto-state.js
CHANGED
|
@@ -74,7 +74,9 @@ async function readBuildState(harnessRoot) {
|
|
|
74
74
|
if (err instanceof js_yaml_1.default.YAMLException && err.message.includes('duplicated mapping key')) {
|
|
75
75
|
const fixed = dedupePhases(raw);
|
|
76
76
|
// Persist the fixed content so subsequent reads also succeed
|
|
77
|
-
|
|
77
|
+
const tmpPath = statePath + '.tmp';
|
|
78
|
+
await fs_extra_1.default.writeFile(tmpPath, fixed, 'utf8');
|
|
79
|
+
await fs_extra_1.default.rename(tmpPath, statePath);
|
|
78
80
|
const fixedBody = fixed.replace(/^#.*\n/gm, '');
|
|
79
81
|
const state = (js_yaml_1.default.load(fixedBody) ?? {});
|
|
80
82
|
state.phases = state.phases ?? {};
|
|
@@ -85,7 +87,12 @@ async function readBuildState(harnessRoot) {
|
|
|
85
87
|
}
|
|
86
88
|
async function writeBuildState(harnessRoot, state) {
|
|
87
89
|
const statePath = node_path_1.default.join(harnessRoot, '.harness-build-state.yaml');
|
|
88
|
-
|
|
90
|
+
const tmpPath = statePath + '.tmp';
|
|
91
|
+
const content = STATE_HEADER + js_yaml_1.default.dump(state, { lineWidth: 100, noRefs: true, sortKeys: false });
|
|
92
|
+
// Write to temp file first, then atomic rename to prevent partial writes
|
|
93
|
+
// on process kill. On the same filesystem, rename is atomic.
|
|
94
|
+
await fs_extra_1.default.writeFile(tmpPath, content, 'utf8');
|
|
95
|
+
await fs_extra_1.default.rename(tmpPath, statePath);
|
|
89
96
|
}
|
|
90
97
|
function phaseStatus(state, phase) {
|
|
91
98
|
return state.phases?.[phase]?.status;
|
package/dist/lib/cost-tracker.js
CHANGED
|
@@ -11,6 +11,10 @@ class CostTracker {
|
|
|
11
11
|
totalCalls = 0;
|
|
12
12
|
estimatedCostUsd = 0;
|
|
13
13
|
phaseCosts = new Map();
|
|
14
|
+
budget80Warned = false;
|
|
15
|
+
budget95Warned = false;
|
|
16
|
+
calls80Warned = false;
|
|
17
|
+
calls95Warned = false;
|
|
14
18
|
constructor(maxTotalCalls, budgetUsd, alertThreshold) {
|
|
15
19
|
this.maxTotalCalls = maxTotalCalls;
|
|
16
20
|
this.budgetUsd = budgetUsd;
|
|
@@ -24,11 +28,35 @@ class CostTracker {
|
|
|
24
28
|
if (cost > this.alertThreshold) {
|
|
25
29
|
logger_1.logger.warn(`[成本] ${phase} 本次约 $${cost.toFixed(2)},累计 $${this.estimatedCostUsd.toFixed(2)}`);
|
|
26
30
|
}
|
|
31
|
+
// Budget pre-warnings (80% → 95% → hard limit)
|
|
32
|
+
if (this.budgetUsd !== null) {
|
|
33
|
+
const usagePct = (this.estimatedCostUsd / this.budgetUsd) * 100;
|
|
34
|
+
if (usagePct >= 80 && usagePct < 95 && !this.budget80Warned) {
|
|
35
|
+
this.budget80Warned = true;
|
|
36
|
+
logger_1.logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),接近上限`);
|
|
37
|
+
}
|
|
38
|
+
if (usagePct >= 95 && !this.budget95Warned) {
|
|
39
|
+
this.budget95Warned = true;
|
|
40
|
+
logger_1.logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),即将达到上限`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Call count pre-warnings (80% → 95% → hard limit)
|
|
44
|
+
if (this.maxTotalCalls > 0) {
|
|
45
|
+
const callsPct = (this.totalCalls / this.maxTotalCalls) * 100;
|
|
46
|
+
if (callsPct >= 80 && callsPct < 95 && !this.calls80Warned) {
|
|
47
|
+
this.calls80Warned = true;
|
|
48
|
+
logger_1.logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),接近上限`);
|
|
49
|
+
}
|
|
50
|
+
if (callsPct >= 95 && !this.calls95Warned) {
|
|
51
|
+
this.calls95Warned = true;
|
|
52
|
+
logger_1.logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),即将达到上限`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
27
55
|
if (this.totalCalls > this.maxTotalCalls) {
|
|
28
|
-
throw new Error(`ACP 调用次数 ${this.totalCalls} 已超过上限 ${this.maxTotalCalls}`);
|
|
56
|
+
throw new Error(`ACP 调用次数 ${this.totalCalls} 已超过上限 ${this.maxTotalCalls},累计费用 $${this.estimatedCostUsd.toFixed(2)}`);
|
|
29
57
|
}
|
|
30
58
|
if (this.budgetUsd !== null && this.estimatedCostUsd > this.budgetUsd) {
|
|
31
|
-
throw new Error(`ACP 估算费用 $${this.estimatedCostUsd.toFixed(2)} 已超过预算 $${this.budgetUsd.toFixed(2)}
|
|
59
|
+
throw new Error(`ACP 估算费用 $${this.estimatedCostUsd.toFixed(2)} 已超过预算 $${this.budgetUsd.toFixed(2)}(${this.totalCalls} 次调用)`);
|
|
32
60
|
}
|
|
33
61
|
event_bus_1.dashboardEventBus.emitCostUpdated(this.estimatedCostUsd, this.totalCalls);
|
|
34
62
|
}
|
|
@@ -8,6 +8,7 @@ exports.renderPrompt = renderPrompt;
|
|
|
8
8
|
exports.wrapAcpPrompt = wrapAcpPrompt;
|
|
9
9
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const logger_1 = require("../utils/logger");
|
|
11
12
|
const auto_assets_1 = require("./auto-assets");
|
|
12
13
|
async function loadPhasePrompt(templateName, vars, promptDirOverride) {
|
|
13
14
|
const assets = (0, auto_assets_1.resolveAutoAssetPaths)(promptDirOverride);
|
|
@@ -21,6 +22,9 @@ async function loadPhasePrompt(templateName, vars, promptDirOverride) {
|
|
|
21
22
|
function renderPrompt(template, vars) {
|
|
22
23
|
return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
|
|
23
24
|
const value = vars[key];
|
|
25
|
+
if (value === undefined || value === null) {
|
|
26
|
+
logger_1.logger.warn(`prompt 模板中占位符 {{${key}}} 未提供值,保留原样`);
|
|
27
|
+
}
|
|
24
28
|
return value === undefined || value === null ? match : String(value);
|
|
25
29
|
});
|
|
26
30
|
}
|
package/dist/lib/phase-runner.js
CHANGED
|
@@ -107,61 +107,108 @@ class PhaseRunner {
|
|
|
107
107
|
logger_1.logger.plain(`\n--- ${def.key} prompt ---\n${prompt}\n`);
|
|
108
108
|
return;
|
|
109
109
|
}
|
|
110
|
-
// Prepend a non-interactive preamble to every ACP prompt. This prevents
|
|
111
|
-
// Claude from asking questions or waiting for confirmation (it has no
|
|
112
|
-
// stdin backchannel in --print mode) and forces a completion signal as
|
|
113
|
-
// the very last line of output so the harness can detect phase completion
|
|
114
|
-
// reliably.
|
|
115
|
-
const boundedPrompt = (0, phase_prompt_loader_1.wrapAcpPrompt)(prompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`);
|
|
116
110
|
for (let attempt = 0; attempt <= this.ctx.maxRetriesPerPhase; attempt++) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
111
|
+
// Recalculate boundedPrompt inside the retry loop so each attempt gets:
|
|
112
|
+
// 1) A fresh wrap with the attempt number in context, and
|
|
113
|
+
// 2) Awareness of any partial artifacts left by previous attempts.
|
|
114
|
+
// Without this, every retry receives the same prompt and Claude would
|
|
115
|
+
// start from scratch rather than incrementally fixing.
|
|
116
|
+
const attemptPrompt = attempt > 0
|
|
117
|
+
? `[重试第 ${attempt} 次] 这是第 ${attempt} 次尝试。如果上一次尝试已经产生了部分文件,请检查并修复它们,不要从头开始。\n${prompt}`
|
|
118
|
+
: prompt;
|
|
119
|
+
const boundedPrompt = (0, phase_prompt_loader_1.wrapAcpPrompt)(attemptPrompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`);
|
|
120
|
+
// Try ACP call; on CostTracker budget/call-limit error, fall back to
|
|
121
|
+
// free recovery checks (signal + artifact detection) without crashing.
|
|
122
|
+
let acpOk = true;
|
|
123
|
+
try {
|
|
124
|
+
this.cost.recordCall(def.key);
|
|
128
125
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const elapsed = Date.now() - phaseStartTime;
|
|
133
|
-
event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, afterStatus, elapsed);
|
|
134
|
-
const label = afterStatus === 'DONE' ? '✅ 完成' : '⏭️ 跳过';
|
|
135
|
-
logger_1.logger.success(`${def.key} ${label}`);
|
|
136
|
-
return;
|
|
126
|
+
catch (err) {
|
|
127
|
+
acpOk = false;
|
|
128
|
+
logger_1.logger.warn(`${def.key} 资源限制:${err.message},跳过 ACP 调用并尝试产物兜底`);
|
|
137
129
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
130
|
+
if (acpOk) {
|
|
131
|
+
const result = await this.acp.call({
|
|
132
|
+
workDir: harnessRoot,
|
|
133
|
+
phase: def.key,
|
|
134
|
+
prompt: boundedPrompt,
|
|
135
|
+
timeoutMs: this.ctx.acpTimeoutMs,
|
|
136
|
+
onData: (text) => event_bus_1.dashboardEventBus.emitAcpOutput(def.key, text),
|
|
137
|
+
});
|
|
138
|
+
await this.writeAcpLog(def.short, result.output, result.error);
|
|
139
|
+
if (!result.success) {
|
|
140
|
+
logger_1.logger.warn(`${def.key} ACP 调用失败:${result.error ?? 'unknown error'}`);
|
|
141
|
+
}
|
|
142
|
+
const after = await (0, auto_state_1.readBuildState)(harnessRoot);
|
|
143
|
+
const afterStatus = (0, auto_state_1.phaseStatus)(after, def.key);
|
|
144
|
+
if (afterStatus === 'DONE' || afterStatus === 'SKIPPED') {
|
|
145
|
+
const elapsed = Date.now() - phaseStartTime;
|
|
146
|
+
event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, afterStatus, elapsed);
|
|
147
|
+
const label = afterStatus === 'DONE' ? '✅ 完成' : '⏭️ 跳过';
|
|
148
|
+
logger_1.logger.success(`${def.key} ${label}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Fallback: Claude may have emitted a completion signal in its text output
|
|
152
|
+
// but failed to edit the YAML state file. Rely on the signal instead.
|
|
153
|
+
const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
|
|
154
|
+
if (resolved) {
|
|
155
|
+
const elapsed = Date.now() - phaseStartTime;
|
|
156
|
+
event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, resolved, elapsed);
|
|
157
|
+
const label = resolved === 'DONE' ? '✅ 完成(信号兜底)' : '⏭️ 跳过(信号兜底)';
|
|
158
|
+
logger_1.logger.success(`${def.key} ${label}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Fallback: detect phase completion by checking for expected output artifacts.
|
|
162
|
+
// Some phases (e.g. S40_extract_requirements) may have produced all required
|
|
163
|
+
// artifacts even though Claude failed to emit the completion signal or update the
|
|
164
|
+
// YAML state file. Detect and resolve programmatically to avoid false retries.
|
|
165
|
+
const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime);
|
|
166
|
+
if (artifactResolved) {
|
|
167
|
+
const elapsed = Date.now() - phaseStartTime;
|
|
168
|
+
event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, artifactResolved, elapsed);
|
|
169
|
+
logger_1.logger.success(`${def.key} ✅ 完成(产物兜底检测)`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
147
172
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
173
|
+
else {
|
|
174
|
+
// Budget/call-limit exhausted: skip ACP but try free artifact fallback.
|
|
175
|
+
// The previous ACP attempt (which was already paid for) may have left valid
|
|
176
|
+
// artifacts on disk even though the state file wasn't updated to DONE.
|
|
177
|
+
logger_1.logger.warn(`${def.key} 资源已耗尽,尝试产物兜底恢复...`);
|
|
178
|
+
const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, { success: true }, phaseStartTime);
|
|
179
|
+
if (artifactResolved) {
|
|
180
|
+
const elapsed = Date.now() - phaseStartTime;
|
|
181
|
+
event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, artifactResolved, elapsed);
|
|
182
|
+
logger_1.logger.success(`${def.key} ✅ 完成(资源受限产物兜底)`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Break out of retry loop — no more ACP calls are possible.
|
|
186
|
+
break;
|
|
158
187
|
}
|
|
159
188
|
if (attempt < this.ctx.maxRetriesPerPhase) {
|
|
160
189
|
logger_1.logger.warn(`${def.key} 未更新为 DONE/SKIPPED,准备重试第 ${attempt + 1} 次`);
|
|
161
190
|
}
|
|
162
191
|
}
|
|
163
192
|
event_bus_1.dashboardEventBus.emitPhaseFailed(def.key, 'state 未更新为 DONE/SKIPPED', this.ctx.maxRetriesPerPhase);
|
|
164
|
-
|
|
193
|
+
// All retries exhausted — try ACP auto-repair before giving up
|
|
194
|
+
if (this.ctx.autoRepair !== false) {
|
|
195
|
+
logger_1.logger.warn(`${def.key} 重试耗尽,启动 ACP 自动修复...`);
|
|
196
|
+
const repaired = await this.tryAutoRepair(def, harnessRoot, phaseStartTime);
|
|
197
|
+
if (repaired) {
|
|
198
|
+
const elapsed = Date.now() - phaseStartTime;
|
|
199
|
+
event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, 'DONE', elapsed);
|
|
200
|
+
logger_1.logger.success(`${def.key} ✅ 完成(ACP 自动修复)`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
logger_1.logger.warn(`${def.key} ACP 自动修复未生效`);
|
|
204
|
+
}
|
|
205
|
+
// Don't throw — mark as SKIPPED so the build continues to subsequent phases
|
|
206
|
+
logger_1.logger.warn(`${def.key} 已耗尽所有重试次数,标记 SKIPPED 后继续执行后续阶段`);
|
|
207
|
+
const skipState = await (0, auto_state_1.readBuildState)(harnessRoot);
|
|
208
|
+
(0, auto_state_1.setPhaseStatus)(skipState, def.key, 'SKIPPED');
|
|
209
|
+
skipState.phases[def.key].note = '重试耗尽且自动修复未生效,已自动跳过';
|
|
210
|
+
await (0, auto_state_1.writeBuildState)(harnessRoot, skipState);
|
|
211
|
+
event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, 'SKIPPED', Date.now() - phaseStartTime);
|
|
165
212
|
}
|
|
166
213
|
async runMechanicalPre(def) {
|
|
167
214
|
if (!this.ctx.harnessRoot || this.ctx.dryRun)
|
|
@@ -270,8 +317,14 @@ class PhaseRunner {
|
|
|
270
317
|
await (0, auto_state_1.writeBuildState)(harnessRoot, state);
|
|
271
318
|
return targetStatus;
|
|
272
319
|
}
|
|
273
|
-
// <AUTO_SIGNAL>:
|
|
320
|
+
// <AUTO_SIGNAL>: resolves as DONE for any signal value.
|
|
321
|
+
// Note: this branch is only reachable for phase-runner phases (S10-S80),
|
|
322
|
+
// which all use <PHASE_DONE> signals via wrapAcpPrompt, not <AUTO_SIGNAL>.
|
|
323
|
+
// The <AUTO_SIGNAL> protocol is used by auto-optimize.ts and auto-review.ts
|
|
324
|
+
// which bypass PhaseRunner entirely. If this fires unexpectedly, the log
|
|
325
|
+
// below provides an audit trail.
|
|
274
326
|
if (autoSignal) {
|
|
327
|
+
logger_1.logger.warn(`${def.key} 收到意外 AUTO_SIGNAL「${autoSignal[1]}」(phase-runner 阶段应使用 PHASE_DONE)、自动标记 DONE`);
|
|
275
328
|
const state = await (0, auto_state_1.readBuildState)(harnessRoot);
|
|
276
329
|
(0, auto_state_1.setPhaseStatus)(state, def.key, 'DONE');
|
|
277
330
|
await (0, auto_state_1.writeBuildState)(harnessRoot, state);
|
|
@@ -281,27 +334,48 @@ class PhaseRunner {
|
|
|
281
334
|
}
|
|
282
335
|
/**
|
|
283
336
|
* Detect phase completion by checking for expected output artifacts on disk.
|
|
284
|
-
* Used
|
|
285
|
-
*
|
|
337
|
+
* Used when ACP succeeded but failed to emit a completion signal or update
|
|
338
|
+
* the YAML state file.
|
|
286
339
|
*
|
|
287
|
-
*
|
|
340
|
+
* Only runs when result.success === true — ACP failure artifacts are too
|
|
341
|
+
* unreliable to use as a completion signal.
|
|
288
342
|
*/
|
|
289
|
-
async tryResolveFromArtifacts(def, harnessRoot) {
|
|
343
|
+
async tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime) {
|
|
344
|
+
// Only use artifact fallback when ACP actually succeeded.
|
|
345
|
+
// Failed ACP calls may leave partial/incomplete artifacts that would
|
|
346
|
+
// cause false-positive phase resolution, bypassing retry logic.
|
|
347
|
+
if (!result.success)
|
|
348
|
+
return null;
|
|
290
349
|
try {
|
|
291
350
|
switch (def.key) {
|
|
292
351
|
case 'S40_extract_requirements': {
|
|
293
352
|
const yamlDir = node_path_1.default.join(harnessRoot, 'requirements', 'yaml');
|
|
294
353
|
if (await fs_extra_1.default.pathExists(yamlDir)) {
|
|
295
354
|
const entries = await fs_extra_1.default.readdir(yamlDir);
|
|
296
|
-
const yamlFiles = entries
|
|
355
|
+
const yamlFiles = entries
|
|
356
|
+
.filter((e) => e.endsWith('.yaml'));
|
|
297
357
|
if (yamlFiles.length > 0) {
|
|
298
|
-
|
|
358
|
+
// Verify at minimum that the yaml file has substantive content
|
|
359
|
+
// created during or after this phase started.
|
|
360
|
+
const validYamlFiles = (await Promise.all(yamlFiles.map(async (f) => {
|
|
361
|
+
const fp = node_path_1.default.join(yamlDir, f);
|
|
362
|
+
try {
|
|
363
|
+
const stat = await fs_extra_1.default.stat(fp);
|
|
364
|
+
return stat.size > 50 && stat.mtimeMs >= phaseStartTime - 5000;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}))).filter(Boolean);
|
|
370
|
+
if (validYamlFiles.length > 0)
|
|
371
|
+
break; // qualified
|
|
299
372
|
}
|
|
300
373
|
}
|
|
301
374
|
return null;
|
|
302
375
|
}
|
|
303
376
|
case 'S50_generate_specs': {
|
|
304
377
|
// specs/ must have at least one non-empty subdirectory
|
|
378
|
+
// with a non-trivial .md or .yaml file (>100 bytes) created during this phase.
|
|
305
379
|
const specsDir = node_path_1.default.join(harnessRoot, 'specs');
|
|
306
380
|
let found = false;
|
|
307
381
|
if (await fs_extra_1.default.pathExists(specsDir)) {
|
|
@@ -309,24 +383,43 @@ class PhaseRunner {
|
|
|
309
383
|
.filter((e) => e !== '_work');
|
|
310
384
|
for (const sub of subs) {
|
|
311
385
|
const subDir = node_path_1.default.join(specsDir, sub);
|
|
312
|
-
|
|
386
|
+
let stat;
|
|
387
|
+
try {
|
|
388
|
+
stat = await fs_extra_1.default.stat(subDir);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
313
393
|
if (stat.isDirectory()) {
|
|
314
394
|
const files = await fs_extra_1.default.readdir(subDir);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
395
|
+
for (const f of files) {
|
|
396
|
+
if (!f.endsWith('.md') && !f.endsWith('.yaml'))
|
|
397
|
+
continue;
|
|
398
|
+
const fp = node_path_1.default.join(subDir, f);
|
|
399
|
+
try {
|
|
400
|
+
const fstat = await fs_extra_1.default.stat(fp);
|
|
401
|
+
if (fstat.size > 100 && fstat.mtimeMs >= phaseStartTime - 5000) {
|
|
402
|
+
found = true;
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
318
409
|
}
|
|
410
|
+
if (found)
|
|
411
|
+
break;
|
|
319
412
|
}
|
|
320
413
|
}
|
|
321
414
|
}
|
|
322
415
|
if (found)
|
|
323
|
-
break;
|
|
416
|
+
break;
|
|
324
417
|
return null;
|
|
325
418
|
}
|
|
326
419
|
case 'S60_process_references': {
|
|
327
420
|
// references/yaml/index.yaml is the primary output artifact
|
|
328
421
|
const indexYaml = node_path_1.default.join(harnessRoot, 'references', 'yaml', 'index.yaml');
|
|
329
|
-
if (await fs_extra_1.default.pathExists(indexYaml)) {
|
|
422
|
+
if (await fs_extra_1.default.pathExists(indexYaml) && await isRecentFile(indexYaml, phaseStartTime)) {
|
|
330
423
|
break; // qualified
|
|
331
424
|
}
|
|
332
425
|
// Also accept SKIPPED state if references/md/ is empty (no references to process)
|
|
@@ -345,27 +438,25 @@ class PhaseRunner {
|
|
|
345
438
|
return null;
|
|
346
439
|
}
|
|
347
440
|
case 'S65_customize_agent_env': {
|
|
348
|
-
// agent-env/rules/ or agent-env/skills/ has content
|
|
441
|
+
// agent-env/rules/ or agent-env/skills/ has content from this phase
|
|
349
442
|
const rulesDir = node_path_1.default.join(harnessRoot, 'agent-env', 'rules');
|
|
350
443
|
const skillsDir = node_path_1.default.join(harnessRoot, 'agent-env', 'skills');
|
|
351
|
-
if (await
|
|
444
|
+
if (await hasNonGitkeepEntries(rulesDir) || await hasAnySubdirWithFiles(skillsDir)) {
|
|
352
445
|
break; // qualified
|
|
353
446
|
}
|
|
354
447
|
return null;
|
|
355
448
|
}
|
|
356
449
|
case 'S70_runtime_assets': {
|
|
357
|
-
// Check tasks/
|
|
450
|
+
// Check tasks/ for content; avoid skills/ overlap with S65 detection.
|
|
358
451
|
const s70TasksDir = node_path_1.default.join(harnessRoot, 'tasks');
|
|
359
|
-
|
|
360
|
-
if (await hasNonEmptyFiles(s70TasksDir) ||
|
|
361
|
-
await hasAnySubdirWithFiles(s70SkillsDir)) {
|
|
452
|
+
if (await hasNonGitkeepEntries(s70TasksDir)) {
|
|
362
453
|
break; // qualified
|
|
363
454
|
}
|
|
364
455
|
return null;
|
|
365
456
|
}
|
|
366
457
|
case 'S80_seed_memory': {
|
|
367
458
|
const memoryInbox = node_path_1.default.join(harnessRoot, 'agent-env', 'memory', 'inbox');
|
|
368
|
-
if (await
|
|
459
|
+
if (await hasNonGitkeepEntries(memoryInbox)) {
|
|
369
460
|
break; // qualified
|
|
370
461
|
}
|
|
371
462
|
return null;
|
|
@@ -381,8 +472,53 @@ class PhaseRunner {
|
|
|
381
472
|
await (0, auto_state_1.writeBuildState)(harnessRoot, state);
|
|
382
473
|
return 'DONE';
|
|
383
474
|
}
|
|
384
|
-
catch {
|
|
385
|
-
|
|
475
|
+
catch (err) {
|
|
476
|
+
// Log IO errors rather than swallowing them silently.
|
|
477
|
+
// Previously this was a bare catch {} which masked real filesystem
|
|
478
|
+
// issues (permissions, disk full, race conditions).
|
|
479
|
+
logger_1.logger.warn(`${def.key} 产物兜底检测异常:${err instanceof Error ? err.message : String(err)}`);
|
|
480
|
+
return null; // fall through to normal retry
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Try to auto-repair a failed phase by calling ACP with a diagnosis prompt.
|
|
485
|
+
* The ACP agent inspects the harness, fixes data issues, and updates the state.
|
|
486
|
+
*/
|
|
487
|
+
async tryAutoRepair(def, harnessRoot, phaseStartTime) {
|
|
488
|
+
try {
|
|
489
|
+
const desc = PHASE_DESC[def.key] ?? def.key;
|
|
490
|
+
const repairPrompt = `[ACP AUTO-REPAIR] 阶段 ${def.key}(${desc})执行失败。\n\n` +
|
|
491
|
+
`请检查 harness 目录 ${harnessRoot},识别并修复该阶段应产生的产物问题,\n` +
|
|
492
|
+
`然后更新 .harness-build-state.yaml 中 ${def.key} 的状态为 DONE。\n\n` +
|
|
493
|
+
`注意:\n` +
|
|
494
|
+
`1. 只关注 ${def.key} 阶段的问题,不要修改其他阶段的产物或状态\n` +
|
|
495
|
+
`2. 修复完成后务必在输出末尾输出 <PHASE_DONE>${def.short}</PHASE_DONE>\n`;
|
|
496
|
+
this.cost.recordCall(def.key + '_repair');
|
|
497
|
+
const result = await this.acp.call({
|
|
498
|
+
workDir: harnessRoot,
|
|
499
|
+
phase: def.key + '_repair',
|
|
500
|
+
prompt: (0, phase_prompt_loader_1.wrapAcpPrompt)(repairPrompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`),
|
|
501
|
+
timeoutMs: this.ctx.acpTimeoutMs,
|
|
502
|
+
});
|
|
503
|
+
await this.writeAcpLog(def.short + '-repair', result.output, result.error);
|
|
504
|
+
// Check state
|
|
505
|
+
const after = await (0, auto_state_1.readBuildState)(harnessRoot);
|
|
506
|
+
const afterStatus = (0, auto_state_1.phaseStatus)(after, def.key);
|
|
507
|
+
if (afterStatus === 'DONE' || afterStatus === 'SKIPPED')
|
|
508
|
+
return true;
|
|
509
|
+
// Check signal fallback
|
|
510
|
+
const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
|
|
511
|
+
if (resolved)
|
|
512
|
+
return true;
|
|
513
|
+
// Check artifact fallback
|
|
514
|
+
const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime);
|
|
515
|
+
if (artifactResolved)
|
|
516
|
+
return true;
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
logger_1.logger.warn(`${def.key} 自动修复异常:${err instanceof Error ? err.message : String(err)}`);
|
|
521
|
+
return false;
|
|
386
522
|
}
|
|
387
523
|
}
|
|
388
524
|
async writeAcpLog(phase, output, error) {
|
|
@@ -399,8 +535,8 @@ function phaseNumber(phase) {
|
|
|
399
535
|
const match = phase.match(/^S(\d+)/i);
|
|
400
536
|
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
|
|
401
537
|
}
|
|
402
|
-
/** Check if a directory exists and contains at least one
|
|
403
|
-
async function
|
|
538
|
+
/** Check if a directory exists and contains at least one entry (excluding .gitkeep). */
|
|
539
|
+
async function hasNonGitkeepEntries(dir) {
|
|
404
540
|
if (!(await fs_extra_1.default.pathExists(dir)))
|
|
405
541
|
return false;
|
|
406
542
|
const entries = await fs_extra_1.default.readdir(dir);
|
|
@@ -422,3 +558,19 @@ async function hasAnySubdirWithFiles(dir) {
|
|
|
422
558
|
}
|
|
423
559
|
return false;
|
|
424
560
|
}
|
|
561
|
+
/**
|
|
562
|
+
* Check if a file was last modified at or after a given timestamp.
|
|
563
|
+
* Used to distinguish artifacts produced by the current phase attempt
|
|
564
|
+
* from stale files left by a previous build, preventing false-positive
|
|
565
|
+
* artifact detection.
|
|
566
|
+
*/
|
|
567
|
+
async function isRecentFile(filePath, sinceMs) {
|
|
568
|
+
try {
|
|
569
|
+
const stat = await fs_extra_1.default.stat(filePath);
|
|
570
|
+
// Allow 5-second tolerance for filesystem timestamp granularity
|
|
571
|
+
return stat.mtimeMs >= sinceMs - 5000;
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
}
|