@ghyper9023/pi-dev-workflow 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.pi-dev-output/pi-grill/answers/answer-mpfe77f1-20260521-1913.md +58 -0
  2. package/.pi-dev-output/pi-grill/answers/answer-mpfh37wu-20260521-2034.md +13 -0
  3. package/.pi-dev-output/pi-grill/answers/answer-mpfi5q4c-20260521-2104.md +13 -0
  4. package/.pi-dev-output/pi-grill/answers/answer-mpfizccb-20260521-2127.md +13 -0
  5. package/.pi-dev-output/pi-grill/answers/answer-mpfjk78k-20260521-2143.md +13 -0
  6. package/.pi-dev-output/pi-grill/questions/questions-mpfdz1tz-20260521-1907.json +94 -0
  7. package/.pi-dev-output/pi-plans/20260521-113000-fix-loopcount-timeout.md +215 -0
  8. package/.pi-dev-output/pi-plans/20260521-1730-grill-input-wrap-back-fix.md +240 -0
  9. package/.pi-dev-output/pi-plans/20260521-230000-fix-timeout-display-loopcount-gitdiff.md +253 -0
  10. package/.pi-dev-output/pi-plans/20260521-230500-esc-double-press-confirm-workflow.md +137 -0
  11. package/.pi-dev-output/pi-plans/20260521-235000-fix-gitdiff-loopcount.md +258 -0
  12. package/.pi-dev-output/pi-review/html/20260521-2305-review-workflow-index.html +196 -0
  13. package/.pi-dev-output/pi-review/md/review-20260520-100000.md +91 -0
  14. package/.pi-dev-output/pi-review/md/review-20260521-140000.md +191 -0
  15. package/.pi-dev-output/pi-review/md/review-20260521-190000.md +189 -0
  16. package/.pi-dev-output/pi-review/md/review-20260521-204500.md +241 -0
  17. package/.pi-dev-output/pi-review/md/review-20260521-214500.md +270 -0
  18. package/.pi-dev-output/pi-review/md/review-20260521-215158.md +214 -0
  19. package/.pi-dev-output/pi-review/md/review-20260521-234500.md +201 -0
  20. package/.pi-dev-output/pi-review/md/review-20260521-235500.md +422 -0
  21. package/.pi-dev-output/pi-review/md/review-20260522-000000.md +212 -0
  22. package/.pi-dev-output/pi-review/md/review-20260522-003000.md +377 -0
  23. package/.pi-dev-output/pi-review/md/review-20260522-003500.md +296 -0
  24. package/.pi-dev-output/pi-workflow/checkpoint-20260521-113000-fix-loopcount-timeout.json +402 -0
  25. package/.pi-dev-output/pi-workflow/checkpoint-20260521-1730-grill-input-wrap-back-fix.json +447 -0
  26. package/.pi-dev-output/pi-workflow/checkpoint-20260521-230000-fix-timeout-display-loopcount-gitdiff.json +708 -0
  27. package/.pi-dev-output/pi-workflow/checkpoint-20260521-230500-esc-double-press-confirm-workflow.json +365 -0
  28. package/.pi-dev-output/pi-workflow/checkpoint-20260521-235000-fix-gitdiff-loopcount.json +395 -0
  29. package/.pi-dev-output/pi-workflow/checkpoint-archive-mpfhyxc5.json +30 -0
  30. package/.pi-dev-output/pi-workflow/checkpoint-archive-mpfi2unc.json +49 -0
  31. package/.pi-dev-output/pi-workflow/checkpoint-archive-mpfi382e.json +59 -0
  32. package/.pi-dev-output/pi-workflow/checkpoint-archive-mpfi5r22.json +76 -0
  33. package/extensions/dev-prompts.ts +16 -8
  34. package/extensions/grill-me-agent.ts +23 -7
  35. package/extensions/ui-helpers.ts +59 -7
  36. package/extensions/workflow-engine.ts +80 -32
  37. package/package.json +1 -1
  38. package/tests/test-loopcount-timeout-fix.mjs +336 -0
  39. package/themes/oh-my-pi-titanium.json +90 -0
@@ -0,0 +1,191 @@
1
+ # 代码审查报告: Workflow loopCount UI 同步 & 超时时间分离修复
2
+
3
+ **审查日期**: 2026-05-21
4
+ **审查范围**: `extensions/workflow-engine.ts`, `extensions/dev-prompts.ts`, `extensions/ui-helpers.ts`, `tests/test-loopcount-timeout-fix.mjs`
5
+ **git diff**: 已实现的功能修复(3 个文件修改 + 1 个测试文件新增)
6
+
7
+ ## 总体评价
8
+
9
+ 代码改动整体方向正确,核心功能修复已实现。但发现 2 个中等严重程度的问题和 2 个低优先级问题。
10
+
11
+ ---
12
+
13
+ ## M1. 在运行 `initWidget` 初始化所有步骤时(第 1629 行),loop-group 步骤的 `timeoutMs` 被传入 widget
14
+
15
+ **严重度**: medium
16
+ **文件**: `extensions/workflow-engine.ts`
17
+ **位置**: 第 1628-1631 行
18
+
19
+ ```typescript
20
+ updateWidgetStep(i, steps[i]!.label, isDoneState ? "done" : "pending", {
21
+ maxLoops: steps[i]!.maxLoops,
22
+ timeoutMs: steps[i]!.timeoutMs, // <-- loop-group 步骤的 timeoutMs 被传入
23
+ });
24
+ ```
25
+
26
+ **问题描述**:
27
+ 根据设计评审第 8 问的决策,"loop-group 行不显示超时时间,在 worker 和 reviewer 的 sub-step 中分别显示各自的超时时间"。但 `initWidget` 阶段对所有步骤(包括 loop-group)迭代调用了 `updateWidgetStep`,且传入了 `timeoutMs`。
28
+
29
+ 虽然在 `executeWorkflowBackground` 的 running 和 done 状态更新中已经修复了(使用 `step.type === "loop-group" ? undefined : step.timeoutMs`),但初始 pending 状态的设置没有过滤 loop-group 步骤。这意味着 widget 面板在启动瞬间(步骤还未开始执行时),loop-group 行会短暂显示超时时间。
30
+
31
+ **影响**:
32
+ - 用户启动工作流后,在步骤进入 running 状态之前,会看到 loop-group 行有超时显示
33
+ - 这与设计意图不一致
34
+
35
+ **修复建议**:
36
+ ```typescript
37
+ updateWidgetStep(i, steps[i]!.label, isDoneState ? "done" : "pending", {
38
+ maxLoops: steps[i]!.maxLoops,
39
+ timeoutMs: steps[i]!.type === "loop-group" ? undefined : steps[i]!.timeoutMs,
40
+ });
41
+ ```
42
+
43
+ ---
44
+
45
+ ## M2. loopCount 的"第 1 次循环"在 `buildWidgetLines` 中存在隐藏的重复逻辑
46
+
47
+ **严重度**: medium
48
+ **文件**: `extensions/ui-helpers.ts`
49
+ **位置**: 第 462-471 行
50
+
51
+ ```typescript
52
+ let loopStr = "";
53
+ if (s.loopCount != null && s.loopCount > 0) {
54
+ loopStr = dim(theme, ` · 第 ${s.loopCount} 次循环`);
55
+ } else if (s.maxLoops != null) {
56
+ if (isRunning) {
57
+ // Immediately show 第 1 次循环 when loop-group starts
58
+ loopStr = dim(theme, ` · 第 1 次循环`);
59
+ } else if (isPending) {
60
+ loopStr = dim(theme, ` · 第 0 次循环`);
61
+ }
62
+ }
63
+ ```
64
+
65
+ **问题描述**:
66
+ 这个 `else if (isRunning)` 分支原本是修复前的"硬编码显示第 1 次循环"的残留。虽然代码逻辑在实现层面并无错误,但在已实施第 1 轮修复(`executeLoopGroup` 中 `loopCount++` 后立即 `updateWidgetStep`)之后:
67
+
68
+ 1. 当步骤第一次进入 running 状态时,`executeWorkflowBackground` 先调用 `updateWidgetStep` 将 `loopCount` 设为 `undefined`(初始调用没传 `loopCount`)
69
+ 2. 紧接着 `executeLoopGroup` 的 while 循环中,`runAgentWithProgress` 运行结束后 `loopCount++` 并调用 `updateWidgetStep` 传入 `loopCount: 1`
70
+
71
+ 因此在第 1 步与第 2 步之间的短暂时间窗口,UI 确实会通过 `else if (isRunning)` 分支显示"第 1 次循环"——但这**恰好是正确的**。
72
+
73
+ 然而问题是:**如果 `loopCount` 已通过 `updateWidgetStep` 传入**(即 `s.loopCount` 不为 null),那么这段 `else if` 分支永远不会被执行,因为它走的是上面的 `if (s.loopCount != null && s.loopCount > 0)` 分支。
74
+
75
+ 真正的风险在于:**假如 `updateWidgetStep` 因为某种竞态条件(race condition)没有及时更新 `s.loopCount`**,这个 `else if` 分支会掩盖问题,让错误很难调试。它相当于一个静默的"兜底"逻辑,可能隐藏真实的 loopCount 同步 bug。
76
+
77
+ **影响**:
78
+ - 降低可调试性:掩盖了可能的同步问题
79
+ - 代码可读性降低:混合了修复前和修复后的逻辑
80
+ - 与"直接从 `s.loopCount` 读取显示"的设计决策不完全一致
81
+
82
+ **修复建议**:
83
+ 移除 `else if (isRunning)` 分支,完全依赖 `s.loopCount`。修改为:
84
+
85
+ ```typescript
86
+ let loopStr = "";
87
+ if (s.loopCount != null && s.loopCount > 0) {
88
+ loopStr = dim(theme, ` · 第 ${s.loopCount} 次循环`);
89
+ } else if (s.maxLoops != null) {
90
+ // 仅在 pending 时显示"第 0 次循环"
91
+ // running 状态时 loopCount 应由 executeLoopGroup 通过 updateWidgetStep 更新
92
+ if (isPending) {
93
+ loopStr = dim(theme, ` · 第 0 次循环`);
94
+ }
95
+ }
96
+ ```
97
+
98
+ 这样可以让 running 且 loopCount 尚未更新时的短暂窗口不显示循环次数(或者显示空字符串),给 `updateWidgetStep` 一个可观察的时间窗口来更新。
99
+
100
+ ---
101
+
102
+ ## L1. 测试断言不够健壮 —— 使用字符串包含检测而非 AST 解析
103
+
104
+ **严重度**: low
105
+ **文件**: `tests/test-loopcount-timeout-fix.mjs`
106
+ **位置**: 多处
107
+
108
+ **问题描述**:
109
+ 测试大量使用 `assertIncludes` 进行字符串包含检测。例如:
110
+
111
+ ```javascript
112
+ assertIncludes("1.1 executeLoopGroup 中有 loopCount++ 后立即更新 UI", weContent, "// 立即更新 UI 显示当前循环次数");
113
+ ```
114
+
115
+ 这种方式有几个问题:
116
+ 1. 如果注释文本被修改(拼写修正、翻译等),测试会假阴性(false negative)
117
+ 2. 容易被注释中的无关字符串匹配到意外命中
118
+ 3. 无法验证代码的实际执行流——只验证了源代码文本存在
119
+
120
+ 更严重的是:测试检测的是**注释文本**而非实际代码。注释修改不影响功能,但测试会失败。
121
+
122
+ **修复建议**:
123
+ - 对于注释检测,改为检测更独特的代码模式(如 `updateWidgetStep(stepIndex, step.label, "running", {`)
124
+ - 或直接检测变量名模式,例如 `loopCount,\n.*maxLoops:` 等
125
+ - 对于接口定义检测,使用正则匹配 AST 级别模式(如 `reviewTimeoutMs`/`WorkflowStepDef` 上下文)
126
+
127
+ ---
128
+
129
+ ## L2. `setWidgetSubStepStatus` 重置后,sub-step 的 `detail` 字段未清除
130
+
131
+ **严重度**: low
132
+ **文件**: `extensions/workflow-engine.ts`
133
+ **位置**: 第 1186-1187 行
134
+
135
+ ```typescript
136
+ setWidgetSubStepStatus(stepIndex, step.loopAgentName!, "pending");
137
+ setWidgetSubStepStatus(stepIndex, step.reviewAgentName!, "pending");
138
+ ```
139
+
140
+ **问题描述**:
141
+ 每次循环开始时,sub-step 的状态被重置为 `"pending"`。但 `setWidgetSubStepStatus` 只更新 `sub.status` 字段,不清除 `sub.detail`、`sub.tools`、`sub.outputs` 等字段。
142
+
143
+ 查看 `setWidgetSubStepStatus` 的实现:
144
+ ```typescript
145
+ function setWidgetSubStepStatus(stepIndex: number, agentName: string, status: WorkflowSubStepWidgetState["status"]): void {
146
+ const step = _widgetSteps[stepIndex];
147
+ if (!step) return;
148
+ const sub = step.subSteps?.find(s => s.agent === agentName);
149
+ if (sub) {
150
+ sub.status = status;
151
+ refreshWidget();
152
+ }
153
+ }
154
+ ```
155
+
156
+ 当第二次循环开始时:
157
+ - sub-step 状态变成 `"pending"`
158
+ - 但在 `buildWidgetLines` 中,`isSubPending` 为 true,因此 `childItems` 只显示 `"正在排队"`——这恰好正确,因为待机状态不显示 `detail`
159
+ - 当 sub-step 再次进入 running 状态时,`runAgentWithProgress` 会重新设置 `detail`
160
+
161
+ 所以当前的 bug 不严重,**UI 显示是正确的**。但如果未来修改了 `buildWidgetLines` 中 pending 状态的渲染逻辑(例如显示 sub-step 的 detail),就可能显示上一轮循环的超时信息。
162
+
163
+ **修复建议**:
164
+ 在 `setWidgetSubStepStatus` 中增加可选清除 detail 的功能,或在 `executeLoopGroup` 重置时显式清除:
165
+
166
+ ```typescript
167
+ function resetWidgetSubStep(stepIndex: number, agentName: string): void {
168
+ const step = _widgetSteps[stepIndex];
169
+ if (!step) return;
170
+ const sub = step.subSteps?.find(s => s.agent === agentName);
171
+ if (sub) {
172
+ sub.status = "pending";
173
+ sub.detail = undefined;
174
+ sub.tools = [];
175
+ sub.outputs = [];
176
+ refreshWidget();
177
+ }
178
+ }
179
+ ```
180
+
181
+ ---
182
+
183
+ ## 总结
184
+
185
+ 本次修复正确地解决了三个核心问题:
186
+ 1. ✅ **loopCount UI 同步**:`executeLoopGroup` 中 `loopCount++` 后立即调用 `updateWidgetStep`
187
+ 2. ✅ **reviewer 独立超时**:`WorkflowStepDef` 新增 `reviewTimeoutMs` 字段,reviewer 使用独立超时
188
+ 3. ✅ **默认超时值**:worker=30min, trimmer=20min, reviewer=15min
189
+ 4. ✅ **loop-group 行不显示 timeout**:在 running/done 状态更新中过滤了 loop-group 步骤
190
+
191
+ 发现的中等问题(M1, M2)不会导致实际功能错误,但可能影响 UI 一致性和可维护性。
@@ -0,0 +1,189 @@
1
+ # 代码审查报告: Esc 双击确认停止工作流
2
+
3
+ **审查日期**: 2026-05-21
4
+ **审查范围**: `extensions/workflow-engine.ts`(Esc 处理逻辑)
5
+ **审查变更**: `git diff HEAD` — 仅 `extensions/workflow-engine.ts` 第 1706-1726 行
6
+
7
+ ## 总体评价
8
+
9
+ 本次改动代码方向正确,实现了任务要求的"第一次 Esc 显示提示、5s 内第二次 Esc 才确认取消"的功能。
10
+ 代码量极小(约 15 行改动),符合"最小改动"原则。
11
+ 但发现 **1 个严重问题(critical)** 和 **2 个低优先级问题**。
12
+
13
+ ---
14
+
15
+ ## 🔴 严重问题
16
+
17
+ ### C1. cancelWorkflow() 回调中重新订阅的 terminal input 未解绑
18
+
19
+ **严重度**: critical
20
+ **文件**: `extensions/workflow-engine.ts`
21
+ **位置**: 第 1698-1702 行(cleanup 定时器设置)/ 第 1709-1727 行(onTerminalInput 订阅)
22
+
23
+ **问题描述**:
24
+
25
+ `cancelWorkflow()` 的执行路径如下:
26
+
27
+ 1. 用户第二次按 Esc → `cancelWorkflow()` 被调用
28
+ 2. `cancelWorkflow()` → `_onCancelWorkflow?.()` → 回调执行:
29
+ - `_workflowAbortController?.abort()` — 中止工作流
30
+ - `_workflowRunning = false`
31
+ - 保存 checkpoint
32
+ - 设置 `_cleanupTimer = setTimeout(() => { cleanupWidget(); }, 5000);`
33
+
34
+ 3. 5 秒后 `cleanupWidget()` 执行:
35
+ - 清理 `_terminalInputUnsubscribe()`
36
+ - `_terminalInputUnsubscribe = null`
37
+
38
+ **问题在于**:在第 2 步中 5 秒的清理延迟期间,`_terminalInputUnsubscribe` **仍然有效**,回调依然能触发。虽然 `_workflowRunning = false` 阻止了进入 Esc 处理分支(第 1714 行的 `if (_workflowRunning && ...)` 为 false),所以不会造成错误取消,但存在一个**更隐蔽的问题**:
39
+
40
+ 如果用户在 5 秒延迟窗口内**重新开始一个新的工作流**(通过 `runWorkflow` 另一个调用),第 1711 行会创建一个新的 `_lastEscPressTime` 闭包变量,并重新赋值 `_terminalInputUnsubscribe`:
41
+
42
+ ```typescript
43
+ // 新工作流
44
+ let _lastEscPressTime = 0; // 新的闭包变量
45
+ _terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => { ... }); // 覆盖旧的
46
+ ```
47
+
48
+ 旧的回调**永远不会被解绑**(因为旧的 `_terminalInputUnsubscribe` 引用被覆盖了,且 `cleanupWidget` 还没运行)。这意味着旧定的 5 秒计时器到期后,`cleanupWidget` 运行时会:
49
+ - 调用已经过期的 `_terminalInputUnsubscribe`(指向新工作流的订阅)
50
+ - 新工作流的 Esc 监听被错误解绑!
51
+
52
+ **根因分析**:
53
+ `setWorkflowCancelCallback` 的回调中使用 `setTimeout(() => cleanupWidget(), 5000)` 延迟清理,而新工作流可能在延迟期间启动,导致 `_terminalInputUnsubscribe` 被覆盖。这不是此改动引入的新问题——它是整个 `setTimeout` 延迟清理设计本身就存在的竞态问题。
54
+
55
+ 但在没有此改动前,这个问题较少暴露,因为旧代码中单次 Esc 就立即取消了。现在加了双击确认后,用户可能在第一次 Esc 提示后**无意中触发这个竞态**:
56
+
57
+ 1. 工作流 A 正在运行
58
+ 2. 用户按一次 Esc → 显示提示
59
+ 3. 用户等了一段时间,又按了一次 Esc(但已超过 5s,重置为第一次)
60
+ 4. 用户再按一次 Esc(第二次,仍在工作流 A)
61
+ 5. 工作流 A 被取消,状态设为 `_workflowRunning = false`,等待 5s 清理
62
+ 6. 用户立即启动工作流 B
63
+ 7. 工作流 B 的 `_terminalInputUnsubscribe` 覆盖了旧的
64
+ 8. 工作流 A 的 5 秒定时器到期,`cleanupWidget()` 解绑了工作流 B 的 Esc 监听
65
+ 9. 工作流 B 运行时无法通过 Esc 取消
66
+
67
+ **影响**:竞态条件触发的概率较低(需要在特定时间窗口内快速操作),但一旦触发,后一个工作流将失去 Esc 取消功能。
68
+
69
+ **修复方案**:
70
+
71
+ **方案一(推荐,最小改动)**:在 `setWorkflowCancelCallback` 的回调中立即清理 Esc 监听,而不是等到 5 秒后。
72
+
73
+ 在第 1661 行附近,`_cleanupTimer = setTimeout(() => { ... cleanupWidget(); }, 5000)` 之前,立即执行:
74
+
75
+ ```typescript
76
+ // 在 cancel 回调中立即清理 Esc 监听,避免竞态
77
+ if (_terminalInputUnsubscribe) {
78
+ _terminalInputUnsubscribe();
79
+ _terminalInputUnsubscribe = null;
80
+ }
81
+ ```
82
+
83
+ 这将确保:
84
+ - cancel 后 Esc 监听立即解绑
85
+ - 5 秒后 `cleanupWidget()` 中再次调用 `_terminalInputUnsubscribe?.()`(此时已是 null,安全)
86
+ - 新的工作流不会受到旧监听器残留的影响
87
+
88
+ **方案二(更强健)**:使用独立的 `_cancelPending` 标志,在 `onTerminalInput` 回调中检查并跳过已经取消的工作流的残留事件。但方案一更简单且是本改动即可独立修复的。
89
+
90
+ **注意**:此问题虽然是修改前就存在的(延迟清理设计),但本次改动增加了用户使用 Esc 交互的次数(第一次 Esc→提示,第二次 Esc→确认),从而**增加了进入上述竞态时间窗口的概率**。因此将其标记为严重等级。
91
+
92
+ ---
93
+
94
+ ## 🟢 低优先级问题
95
+
96
+ ### L1. 超过 5s 后按 Esc,提示内容不会再出现
97
+
98
+ **文件**: `extensions/workflow-engine.ts` 第 1711-1726 行
99
+
100
+ **问题描述**:
101
+ 需求描述为:"俩次ecs间隔<5s才退出,5s之后,提示内容"再次按下ecs键,停止Workflow"去掉,需要重新监听ecs按下和重新计时。"
102
+
103
+ 当前实现中,超过 5s 后再次按 Esc 的逻辑是这样的:
104
+ 1. `_lastEscPressTime` 有旧值(5s 前的)
105
+ 2. `now - _lastEscPressTime >= 5000` 为 true
106
+ 3. 由于 `_lastEscPressTime > 0` **且** `now - _lastEscPressTime < 5000` 为 false
107
+ 4. 走 else 分支:`_lastEscPressTime = now; ctx.ui.notify(...)`
108
+
109
+ **这实际上是正确的**——超过 5s 后按 Esc,会重新记录时间并显示提示。所以功能上没有问题。
110
+
111
+ 但边界情况是:如果 `now - _lastEscPressTime` 恰好等于 5000ms(即用户在最后一次提示后刚好 5.000 秒按了 Esc),则 `< 5000` 的检查不通过,但 `_lastEscPressTime > 0` 仍然成立。代码会走到 else 分支,重置时间并显示提示——这符合"重新监听"的语义,**正确无误**。
112
+
113
+ **结论**: L1 不是问题,移除该条目。
114
+
115
+ ### L2. notify 消息中英文混合
116
+
117
+ **文件**: `extensions/workflow-engine.ts` 第 1725 行
118
+
119
+ **问题描述**:
120
+ 提示消息为 `"再次按下 Esc 键,停止 Workflow"`,其中 `Workflow` 是英文单词,其余为中文。该代码库中其他消息使用统一中文化(如 `"⏹️ 用户取消工作流"`)。
121
+
122
+ 建议统一为:"再次按下 Esc 键,停止工作流"(将 Workflow 改为中文)。
123
+
124
+ 这仅是一个风格建议,不影响功能。
125
+
126
+ **建议**: 将 `"Workflow"` 改为 `"工作流"`。
127
+
128
+ ### L3. 测试覆盖缺失
129
+
130
+ **文件**: 无对应测试文件
131
+
132
+ **问题描述**:
133
+ 现有 7 个测试文件中,没有任何测试覆盖 Esc 双击确认逻辑。无法验证该功能在代码重构或未来改动时不被破坏。
134
+
135
+ **建议**:
136
+ 在 `tests/` 下新增测试文件(如 `test-esc-double-press.mjs`),至少覆盖以下场景:
137
+ 1. 首次 Esc → 不取消,显示提示(检查 notify 调用)
138
+ 2. 5s 内第二次 Esc → 取消(检查 cancelWorkflow 调用)
139
+ 3. 超过 5s 后按 Esc → 重置(检查 notify 再次调用)
140
+ 4. 工作流非运行状态时 Esc → 无操作
141
+ 5. 连续 Esc 时 _lastEscPressTime 的时间戳更新逻辑
142
+
143
+ 由于 `onTerminalInput` 回调依赖 `ctx.ui`,可隔离测试回调的纯逻辑部分(mock `matchesKey`、`_workflowRunning`、`_workflowAbortController` 等)。
144
+
145
+ ---
146
+
147
+ ## 变更的正确性验证
148
+
149
+ ### ✅ 功能正确性
150
+ - 首次 Esc → 记录时间、显示提示、`return { consume: true }`(阻止默认行为) ✅
151
+ - 5s 内第二次 Esc → 调用 `cancelWorkflow()`、重置时间 ⏰ ≪-- C1 竞态风险
152
+ - 超过 5s 按 Esc → 重置为首次状态 ✅
153
+ - 非工作流运行时的 Esc → `return undefined`(不消费事件,允许其他处理) ✅
154
+ - `_lastEscPressTime` 是函数块级 `let` 变量,作用域正确 ✅
155
+ - 回调使用 `_workflowRunning + _workflowAbortController` 双重守卫 ✅
156
+
157
+ ### ✅ 与实施计划的一致性
158
+ - ✅ 引入 `_lastEscPressTime` 变量(函数块作用域)
159
+ - ✅ 第一次按 Esc → 记录时间并显示提示
160
+ - ✅ 在 5s 内再次按 Esc → 执行取消操作
161
+ - ✅ 超过 5s -> 重置为第一次状态
162
+ - ✅ 修改仅限于 `runWorkflow` 函数内的 `onTerminalInput` 回调
163
+ - ✅ 约 15 行改动量
164
+
165
+ ### ⚠️ 与需求的一致性
166
+ - ✅ "再次按下ecs键,停止Workflow" 提示显示
167
+ - ✅ 两次 Esc 间隔 < 5s 才退出
168
+ - ⚠️ 5s 之后,提示内容消失,重新监听 — 功能实现正确
169
+ - ⚠️ 不能破坏原有功能 — 需要确认 C1 竞态问题
170
+
171
+ ### ✅ 回归测试
172
+ - `node tests/test-workflow-engine-bugs.mjs` — **50/50 通过** ✅
173
+ - `node tests/test-workflow-engine.mjs` — **74/75 通过**(1 个 pre-existing `deleteCheckpointFile` 失败,非本次改动导致) ✅
174
+
175
+ ---
176
+
177
+ ## 严重等级汇总
178
+
179
+ | 等级 | 数量 | 说明 |
180
+ |------|------|------|
181
+ | 🔴 critical | 1 | cancelWorkflow 回调延迟清理导致的竞态 |
182
+ | 🟡 medium | 0 | — |
183
+ | 🟢 low | 2 | 中英文混合、测试覆盖缺失 |
184
+
185
+ ---
186
+
187
+ ## 总结
188
+
189
+ 本次 Esc 双击确认功能实现正确,核心逻辑符合需求。唯一严重问题是 `cancelWorkflow` 回调中的 5 秒延迟清理设计(第 1698 行)与 `_terminalInputUnsubscribe` 的覆盖之间存在竞态条件,可能导致后启动的工作流失去 Esc 取消功能。建议在 cancel 回调中立即清理 Esc 监听器,而不是等 5 秒后的 `cleanupWidget()` 再做。(此问题虽在改动前已存在,但本次改动增加了进入竞态时间窗口的概率。)
@@ -0,0 +1,241 @@
1
+ # 代码审查报告
2
+
3
+ **审查时间**: 2026-05-21 20:45
4
+ **审查范围**: commit `01413c9` 对 `extensions/ui-helpers.ts` 和 `extensions/workflow-engine.ts` 的修改
5
+ **功能需求**: 修复超时时间显示位置、循环次数计数、git diff 解析
6
+
7
+ ---
8
+
9
+ ## Critical 问题
10
+
11
+ ### C1. `executeLoopGroup` 中 `loopCount` 更新后调用 `updateWidgetStep` 但 UI 在 sub-step 运行期间仍不显示循环次数
12
+
13
+ **严重等级**: critical
14
+ **文件**: `extensions/workflow-engine.ts`(executeLoopGroup) & `extensions/ui-helpers.ts`(buildWidgetLines)
15
+ **根因分析**:
16
+
17
+ 在 commit `01413c9` 中,`buildWidgetLines` 的 `isRunning` 分支(原本显示"第 1 次循环")被删除,并改为"由 `executeLoopGroup` 通过 `updateWidgetStep` 管理 loopCount"。
18
+
19
+ 然而 `executeLoopGroup` 的流程是:
20
+ 1. `while (loopCount < maxLoops)` — 进入循环,`loopCount=0`
21
+ 2. 重置 sub-step 为 pending
22
+ 3. 运行 worker agent(耗时较长)
23
+ 4. 运行 reviewer agent(耗时较长)
24
+ 5. `loopCount++`(变为 1)
25
+ 6. `updateWidgetStep(...)` 设置 `loopCount=1`
26
+
27
+ **问题核心**:在第 3-4 步(worker/reviewer 运行期间),`runAgentWithProgress` 内部会多次调用 `refreshWidget()`(通过 `setWidgetSubStepStatus`、`addWidgetSubStepTool` 等)。但这些 `refreshWidget()` 调用时,widget step 的 `loopCount` 仍为 `undefined`,因为 `updateWidgetStep(loopCount=1)` 还没执行到。
28
+
29
+ 因此在整个 sub-step 运行期间(这是 UI 可见时间最长的阶段),**用户完全看不到任何循环次数提示**。只有等 sub-step 全部完成后才更新 loopCount,但此时 UI 已经准备进入下一轮了。
30
+
31
+ **影响**: 用户在整个 worker/reviewer 执行过程中完全看不到"第 N 次循环"提示,这是严重的用户体验 bug。
32
+
33
+ **修复方案**: 在 `executeLoopGroup` 中,**在运行 worker agent 之前**(而不是之后)就更新 loopCount 到 widget。
34
+
35
+ ```typescript
36
+ // 在 while 循环开始处,运行 agent 之前
37
+ loopCount++;
38
+ state.loopCount = loopCount;
39
+ updateWidgetStep(stepIndex, step.label, "running", {
40
+ loopCount,
41
+ maxLoops: step.maxLoops,
42
+ startedAt: _widgetSteps[stepIndex]?.startedAt || Date.now(),
43
+ });
44
+
45
+ // 然后运行 worker agent
46
+ const loopTask = buildTaskForStep(step.loopAgentName!, contextPrompt, planFileRelPath, _workflowCwd);
47
+ let agentResult = await runAgentWithProgress(loopAgent, loopTask, stepIndex, step.loopAgentName!, step.timeoutMs);
48
+ ```
49
+
50
+ 这样在 sub-step 开始运行前,widget 上已经显示正确的 `loopCount`,后续所有 `refreshWidget()` 都能读取到正确的值。
51
+
52
+ 同时应保留 `buildWidgetLines` 中 `isRunning` 状态的 fallback 逻辑(`第 1 次循环`)作为兜底。
53
+
54
+ ### C2. `buildWidgetLines` 中超时时间在步骤行和子代理行显示逻辑冲突
55
+
56
+ **严重等级**: critical
57
+ **文件**: `extensions/ui-helpers.ts`(buildWidgetLines)
58
+ **根因分析**:
59
+
60
+ 当前代码中:
61
+ - **步骤行(step line)**: 对于 loop-group 步骤已移除了 `timeoutMs`(`step.type === "loop-group" ? undefined : step.timeoutMs`),所以步骤行不会显示超时。
62
+ - **子代理行(sub-step line)**: 通过 `sub.detail`(内容如 `超时时间60m`)在 agent 行末尾显示。
63
+
64
+ 但当前子代理行的渲染代码(`buildWidgetLines` 中的 sub-step 渲染部分)**已经实现了** 功能需求要求的显示格式:
65
+
66
+ ```
67
+ |__ ✓ worker · (52.6s/超时时间60m)
68
+ ```
69
+
70
+ 代码中已有的逻辑(查看 ~Line 539-545):
71
+ ```typescript
72
+ if (sub.detail && sub.detail.includes("超时时间")) {
73
+ subTimeoutStr = dim(theme, `/${sub.detail}`);
74
+ }
75
+ ```
76
+
77
+ **问题在于**:当前 `sub.detail` 已在 `runAgentWithProgress` 中设置为 `超时时间${formatTimeout(timeoutMs)}`,所以这部分功能本身是**已实现**的。功能需求中描述的超时显示位置问题可能存在于旧的代码版本,commit `01413c9` 已经修复了这部分。
78
+
79
+ 但在审查过程中确认:**当前代码中 sub-step 的超时时间显示逻辑是正确的**,不应再改动。需要重点关注的是**确保 detail 中的"超时时间"文本不被作为独立的子项重复显示**。
80
+
81
+ 当前代码(~Line 554-556):
82
+ ```typescript
83
+ if (childItems.length === 0 && sub.detail && !sub.detail.includes("超时时间")) {
84
+ childItems.push(sub.detail);
85
+ }
86
+ ```
87
+
88
+ 这个逻辑是正确的:`sub.detail` 包含"超时时间"时不会被作为子项重复显示。
89
+
90
+ ---
91
+
92
+ ## Medium 问题
93
+
94
+ ### M1. `getGitDiffChanges` 中 `git status --porcelain` 解析存在潜在 bug
95
+
96
+ **严重等级**: medium
97
+ **文件**: `extensions/workflow-engine.ts`(getGitDiffChanges)
98
+ **根因分析**:
99
+
100
+ 当前代码已按实施计划中的方案改用了字符串拆分而非正则:
101
+ ```typescript
102
+ const parts = trimmed.split("\t");
103
+ if (parts.length === 2) {
104
+ const status = parts[0]!.trim();
105
+ const filePath = parts[1]!.trim();
106
+ ...
107
+ }
108
+ ```
109
+
110
+ 但 `git status --porcelain` 的解析仍有问题:
111
+ ```typescript
112
+ const statusPrefix = trimmed.slice(0, 2);
113
+ const filePath = trimmed.slice(3).trim();
114
+ ```
115
+
116
+ `git status --porcelain` 的实际输出格式为:
117
+ - `?? newfile.ts` — untracked (2 字符状态 + 空格 + 路径)
118
+ - ` M modified.ts` — working tree 修改 (空格 + M + 空格 + 路径)
119
+ - `A staged.ts` — staged 添加 (A + 空格 + 空格 + 路径)
120
+ - `M staged-mod.ts` — staged 修改 (M + 空格 + 空格 + 路径)
121
+
122
+ 前两个字符是 `XY`(index 状态 + working tree 状态),后面跟**一个空格**再跟路径。所以 `trimmed.slice(0, 2)` 取到的是完整的 2 字符状态码,`trimmed.slice(3)` 取到的是路径(跳过了第 3 个字符即空格)。
123
+
124
+ **但问题在于**:`trimmed` 是通过 `line.trim()` 得到的,**这已经去掉了首尾空格**。对于 ` M modified.ts`(首字符为空格),`trim()` 后变成了 `M modified.ts`,此时 `statusPrefix = "M "`("M" + 空格),**但原始的 git porcelain 输出中 "M" 前面有一个空格表示 working tree 状态**,trim 后丢失了这个信息。
125
+
126
+ 修复方案:**不要对 line 做 `trim()`**,或者使用 `line` 而非 `trimmed` 来解析状态码。
127
+
128
+ ```typescript
129
+ // 正确的解析方式
130
+ for (const line of statusOutput.split("\n")) {
131
+ if (!line.trim()) continue;
132
+ // 前两个字符是状态
133
+ const statusPrefix = line.slice(0, 2); // 直接使用原始 line,不 trim
134
+ const filePath = line.slice(3).trim();
135
+ // "??" 表示 untracked/new
136
+ // "A " 或 "AM" 或 "M " 表示 tracked 文件被修改
137
+ if (statusPrefix === "??") {
138
+ // untracked = new
139
+ ...
140
+ } else if (statusPrefix === "A " || statusPrefix.startsWith("A")) {
141
+ // staged new
142
+ ...
143
+ }
144
+ ...
145
+ }
146
+ ```
147
+
148
+ ### M2. `executeLoopGroup` 中异常退出重试逻辑可能导致无限循环
149
+
150
+ **严重等级**: medium
151
+ **文件**: `extensions/workflow-engine.ts`(executeLoopGroup)
152
+ **根因分析**:
153
+
154
+ 在 `executeLoopGroup` 中,当 agent 异常退出(非零退出码且非超时)时:
155
+
156
+ ```typescript
157
+ while (agentResult.exitCode !== 0 && !isTimeoutResult(agentResult)) {
158
+ if (mode === "full-auto") {
159
+ throw new Error(...);
160
+ } else {
161
+ const choice = await uiSelect(ctx, ..., [
162
+ "1. 重新执行", "2. 跳过此步骤", "3. 取消工作流",
163
+ ]);
164
+ ...
165
+ agentResult = await runAgentWithProgress(loopAgent, `[RETRY]\n\n${loopTask}`, ...);
166
+ }
167
+ }
168
+ ```
169
+
170
+ 这里使用了 `while` 循环而非 `if`,但每次重试都会创建新的 `agentResult`。如果 agent **持续**异常退出,理论上用户每次都选"重新执行"就会无限循环。不过用户有选择跳过或取消的权力,所以实际风险较低。
171
+
172
+ 建议改为 `do-while` 或加一个最大重试次数限制,但功能需求要求最小改动,可标记为观察项。
173
+
174
+ ---
175
+
176
+ ## Low 问题
177
+
178
+ ### L1. `updateWidgetStep` 中 `startedAt` 回退逻辑可能导致时间错误
179
+
180
+ **严重等级**: low
181
+ **文件**: `extensions/workflow-engine.ts`(executeLoopGroup)
182
+ **根因分析**:
183
+
184
+ 在 `executeLoopGroup` 中的 `updateWidgetStep` 调用:
185
+ ```typescript
186
+ updateWidgetStep(stepIndex, step.label, "running", {
187
+ loopCount,
188
+ maxLoops: step.maxLoops,
189
+ startedAt: _widgetSteps[stepIndex]?.startedAt || Date.now(),
190
+ });
191
+ ```
192
+
193
+ 如果 `_widgetSteps[stepIndex]?.startedAt` 为 `0`(理论上不可能,但类型上 permit),`||` 会回退到 `Date.now()`,这没问题。但如果 `startedAt` 已经有一个较早的值,每次循环都复用同一个 `startedAt` 会导致累计时间异常——步骤行显示的时间是**工作流开始到当前的总时间**,而不是本轮循环的开始时间。
194
+
195
+ 然而 `loop-group` 步骤的步骤行显示的是从 `stepStartTime` 开始到现在的总运行时长(因为 step 级别只有一个 `startedAt`),而 sub-step 有自己的 `startedAt`。所以这个行为是预期的,不构成 bug。标记为低优先级观察项。
196
+
197
+ ### L2. `buildWidgetLines` 中 `isRunning` 状态检测逻辑冗余
198
+
199
+ **严重等级**: low
200
+ **文件**: `extensions/ui-helpers.ts`(buildWidgetLines)
201
+ **根因分析**:
202
+
203
+ 当前代码:
204
+ ```typescript
205
+ const isRunning = s.status === "running" || isCurrent;
206
+ ```
207
+
208
+ `isCurrent` 定义为 `i === state.currentStepIndex && state.status === "running"`。所以 `isRunning` 的检测等价于 `s.status === "running" || (i === currentStepIndex && state.status === "running")`。
209
+
210
+ 实际上,如果 `i === currentStepIndex` 且 `state.status === "running"`,那么 `s.status` 应该已经是 `"running"`(由 `updateWidgetStep` 设置)。所以 `isCurrent` 部分通常是冗余的。
211
+
212
+ 但在初始状态或过渡状态中可能存在短暂的不一致。保留作为容错是可以的,但可考虑简化。
213
+
214
+ ### L3. 测试文件中 `extractLoopGroups` 函数解析逻辑脆弱
215
+
216
+ **严重等级**: low
217
+ **文件**: `tests/test-loopcount-timeout-fix.mjs`
218
+ **根因分析**:
219
+
220
+ `extractLoopGroups` 函数通过逐行扫描和花括号深度来解析 loop-group 配置块,这种方法很脆弱:
221
+ 1. 它假定 `type: "loop-group"` 所在行是配置块的开始
222
+ 2. 它通过 `}` 后跟 `,` 或 `;` 来检测结束
223
+ 3. 如果配置格式稍有变化(如多行注释、属性换行等),解析就会失败
224
+
225
+ 实际测试运行发现当前能正确解析所有 7 个 loop-group 配置(断言 `>=6`),但未来修改配置格式时需要同步更新测试解析器。
226
+
227
+ ---
228
+
229
+ ## 总结
230
+
231
+ | 等级 | 数量 | 说明 |
232
+ |------|------|------|
233
+ | Critical | 2 | loopCount 在 sub-step 运行期间不显示(时序问题);超时显示逻辑需验证 |
234
+ | Medium | 2 | `git status --porcelain` 解析 bug;重试可能无限循环 |
235
+ | Low | 3 | startedAt 回退、运行状态检测冗余、测试解析脆弱 |
236
+
237
+ ## 推荐修复优先级
238
+
239
+ 1. **修复 C1**: 在 `executeLoopGroup` 中,将 `loopCount++` 和 `updateWidgetStep` 移到运行 worker agent 之前
240
+ 2. **修复 M1**: 修正 `git status --porcelain` 解析中 `line.trim()` 导致的状态码丢失问题
241
+ 3. 其他问题可在后续迭代中优化