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.
@@ -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.5(2026-06-16,新增 Web 仪表盘、默认标志优化、优化容错)
7
- > **最后更新**:2026-06-16
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 调用次数上限(含重试和优化),超出即冻结', '30')
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
- await this.runHarnessBuild(); // 驱动 S10→S80 全流程
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
- const score = await this.runHarnessReview(round); // 产出 第<n>评审报告.md
450
- if (score.composite_score >= this.ctx.targetScore && score.gate_pass) {
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
- await this.runOptimizePlan(round, score);
456
- await this.runOptimizeImplement(round, score);
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
- async recordCall(phase: string, promptTokens: number, outputTokens: number): Promise<void> {
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
- logger.warn(`[成本] 总调用次数 ${this.totalCalls} 已达上限 ${this.maxTotalCalls},暂停等待确认`);
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
- logger.warn(`[成本] 费用 $${this.estimatedCostUsd.toFixed(2)} 已达预算上限,暂停等待确认`);
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 30`(约 $10–$30 API 成本)
656
- - `--max-optimize-rounds 2`(大多数场景 2 轮内要么通过要么卡住)
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 retryPhase(phase: string, lastResult: AcpResult) {
1272
- // 分析失败原因
1273
- const doctorOutput = await runDoctor(this.harnessRoot, phase);
1274
- const fixPrompt = buildFixPrompt(phase, lastResult.output, doctorOutput);
1275
-
1276
- for (let attempt = 1; attempt <= MAX_RETRIES_PER_PHASE; attempt++) {
1277
- logger.warn(`${phase} 门禁未通过,第 ${attempt} 次修复尝试...`);
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
- const state = await readBuildState(this.harnessRoot);
1283
- if (state.phases[phase]?.status === 'DONE') return;
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
+
@@ -33,7 +33,7 @@ auto:
33
33
  acpMode: auto
34
34
  acpTimeoutMs: 600000
35
35
  maxRetriesPerPhase: 2
36
- maxTotalCalls: 30
36
+ maxTotalCalls: 100
37
37
  budgetUsd: null
38
38
  costAlertThreshold: 5
39
39
 
@@ -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, 30, '--max-total-calls'),
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
  }
@@ -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',
@@ -101,6 +101,9 @@ class SseManager {
101
101
  this.buildSnapshot.score = event.data.finalScore;
102
102
  }
103
103
  break;
104
+ case 'build_failed':
105
+ this.buildSnapshot.status = 'failed';
106
+ break;
104
107
  }
105
108
  }
106
109
  getSnapshot() {
@@ -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 调用次数上限(默认 30)', (v) => Number(v))
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
- await this.phaseRunner.runBuildPhases();
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
- const review = await this.runHarnessReview(round);
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
- logger_1.logger.warn(`第 ${round} 轮优化执行失败:${err.message},状态重置已完成,下一轮可继续`);
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);
@@ -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
- await fs_extra_1.default.writeFile(statePath, fixed, 'utf8');
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
- await fs_extra_1.default.writeFile(statePath, STATE_HEADER + js_yaml_1.default.dump(state, { lineWidth: 100, noRefs: true, sortKeys: false }), 'utf8');
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;
@@ -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
  }
@@ -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
- this.cost.recordCall(def.key);
118
- const result = await this.acp.call({
119
- workDir: harnessRoot,
120
- phase: def.key,
121
- prompt: boundedPrompt,
122
- timeoutMs: this.ctx.acpTimeoutMs,
123
- onData: (text) => event_bus_1.dashboardEventBus.emitAcpOutput(def.key, text),
124
- });
125
- await this.writeAcpLog(def.short, result.output, result.error);
126
- if (!result.success) {
127
- logger_1.logger.warn(`${def.key} ACP 调用失败:${result.error ?? 'unknown error'}`);
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
- const after = await (0, auto_state_1.readBuildState)(harnessRoot);
130
- const afterStatus = (0, auto_state_1.phaseStatus)(after, def.key);
131
- if (afterStatus === 'DONE' || afterStatus === 'SKIPPED') {
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
- // Fallback: Claude may have emitted a completion signal in its text output
139
- // but failed to edit the YAML state file. Rely on the signal instead.
140
- const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
141
- if (resolved) {
142
- const elapsed = Date.now() - phaseStartTime;
143
- event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, resolved, elapsed);
144
- const label = resolved === 'DONE' ? '✅ 完成(信号兜底)' : '⏭️ 跳过(信号兜底)';
145
- logger_1.logger.success(`${def.key} ${label}`);
146
- return;
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
- // Fallback: detect phase completion by checking for expected output artifacts.
149
- // Some phases (e.g. S40_extract_requirements) may have produced all required
150
- // artifacts even though Claude failed to emit the completion signal or update the
151
- // YAML state file. Detect and resolve programmatically to avoid false retries.
152
- const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot);
153
- if (artifactResolved) {
154
- const elapsed = Date.now() - phaseStartTime;
155
- event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, artifactResolved, elapsed);
156
- logger_1.logger.success(`${def.key} 完成(产物兜底检测)`);
157
- return;
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
- throw new Error(`${def.key} 未完成:state 未更新为 DONE/SKIPPED`);
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>: any signal value resolves as DONE (used by S85/optimize phases)
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 as a last-resort fallback when Claude completed the work but failed to
285
- * emit <PHASE_DONE> or update the YAML state file.
337
+ * Used when ACP succeeded but failed to emit a completion signal or update
338
+ * the YAML state file.
286
339
  *
287
- * Each phase defines its own detection criteria.
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.filter((e) => e.endsWith('.yaml'));
355
+ const yamlFiles = entries
356
+ .filter((e) => e.endsWith('.yaml'));
297
357
  if (yamlFiles.length > 0) {
298
- break; // qualified mark DONE below
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
- const stat = await fs_extra_1.default.stat(subDir);
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
- if (files.some((f) => f.endsWith('.md') || f.endsWith('.yaml'))) {
316
- found = true;
317
- break; // break for loop only; 'found' carries the result
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; // break switch → mark DONE
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 hasNonEmptyFiles(rulesDir) || await hasAnySubdirWithFiles(skillsDir)) {
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/ or agent-env/skills/ or agent-env/rules/ for content
450
+ // Check tasks/ for content; avoid skills/ overlap with S65 detection.
358
451
  const s70TasksDir = node_path_1.default.join(harnessRoot, 'tasks');
359
- const s70SkillsDir = node_path_1.default.join(harnessRoot, 'agent-env', 'skills');
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 hasNonEmptyFiles(memoryInbox)) {
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
- return null; // silently fall through to normal retry on any filesystem error
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 file (excluding .gitkeep). */
403
- async function hasNonEmptyFiles(dir) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svharness",
3
- "version": "0.15.10",
3
+ "version": "0.15.11",
4
4
  "description": "CLI scaffolder for SDD-Driven Agent-Agnostic Coding Framework (harness)",
5
5
  "bin": {
6
6
  "svharness": "./bin/cli.js",