@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,270 @@
1
+ # 代码审查报告:Grill/Input 文本换行与左方向键返回修复
2
+
3
+ **审查日期**: 2026-05-21 21:45
4
+ **审查范围**: `extensions/grill-me-agent.ts` + `extensions/ui-helpers.ts` 的未提交改动
5
+ **审查目标**: 验证三个需求的实现质量:输入换行 / 选项换行 / ← 左方向键返回
6
+
7
+ ---
8
+
9
+ ## 文件差异总览
10
+
11
+ | 文件 | 改动行数 | 风险等级 |
12
+ |------|---------|---------|
13
+ | `extensions/grill-me-agent.ts` | +30 / -8 | medium |
14
+ | `extensions/ui-helpers.ts` | +26 / -1 | medium |
15
+
16
+ ---
17
+
18
+ ## 1. `extensions/grill-me-agent.ts` 审查
19
+
20
+ ### 1a) 左方向键拦截 (handleInput)
21
+
22
+ ```typescript
23
+ if (backable && currentIndex > 1 && matchesKey(data, Key.left)) {
24
+ done("__BACK__");
25
+ return;
26
+ }
27
+ ```
28
+
29
+ **问题**: 左方向键的 `matchesKey` 检查先于 `selectList.handleInput(data)`。
30
+ SelectList 只在 handleInput 内部更新 `selectedIndex` 的高亮状态。左方向键被拦截后,
31
+ 当前选中的 item 仍然正确(因为 SelectList 的 selectedIndex 没被改变),
32
+ 但容器的 `invalidate()` 没有被调用,不过 tui.requestRender() 未执行。
33
+
34
+ **严重等级**: **low** — 当前不会引起可观察的 bug,但应调用 `container.invalidate()` 确保一致性。
35
+
36
+ ### 1b) 选项截断 + description (关键逻辑)
37
+
38
+ ```typescript
39
+ const truncated = truncateToWidth(label, MAX_OPTION_LABEL, "...");
40
+ return {
41
+ value: `opt-${i}`,
42
+ label: truncated,
43
+ description: truncated !== label ? opt : undefined,
44
+ };
45
+ ```
46
+
47
+ **分析**: `description` 字段会被 `normalizeToSingleLine` 处理(移除换行符),
48
+ 且 SelectList 渲染器对 description 也应用 `truncateToWidth`(剩余宽度可能很小)。
49
+ 对于超长选项文本,description 列宽度取决于终端剩余列数,如果终端很窄,
50
+ description 仍然会被截断。但两列布局确实提供了更多可见内容。
51
+
52
+ **严重等级**: **low** — 这是 pi-tui 库的设计限制,已经达到库能力范围内最好效果。
53
+
54
+ ### 1c) `__back__` 选项与左方向键返回路径的耦合
55
+
56
+ grill-me-agent 中有两个返回上一题的路径:
57
+ 1. 通过选择 "← 返回上一题" SelectItem(value 为 `"__back__"`)
58
+ 2. 通过按 ← 方向键触发 `done("__BACK__")`
59
+
60
+ 调用方代码中:
61
+ ```typescript
62
+ if (value === "__back__") return "__BACK__";
63
+ ```
64
+ 其中 `value` 来自 SelectList 的 `item.value`(即 `"__back__"`),大小写匹配正确。
65
+ 路径2直接传递 `"__BACK__"`(大写),由后续 `"__BACK__"` 检查处理。两路径殊途同归。
66
+
67
+ **严重等级**: **low** — 当前功能正确,但若未来修改了 SelectItem 的 value 字符串,
68
+ 方向键路径不受影响(因为直接传常量字符串)。
69
+
70
+ ### 1d) hint 文案更新
71
+
72
+ ```typescript
73
+ ? " ↑↓ 导航 • Enter 选择 • ← 返回上一题 • Esc 取消全部评审"
74
+ ```
75
+
76
+ **问题**: 文案正确、清晰。无问题。
77
+
78
+ ---
79
+
80
+ ## 2. `extensions/ui-helpers.ts` 审查
81
+
82
+ ### 2a) 左方向键拦截破坏 Input 光标左移 (CRITICAL)
83
+
84
+ ```typescript
85
+ if (backable && matchesKey(data, Key.left)) {
86
+ done(BACK_MARKER);
87
+ return;
88
+ }
89
+ // ... later:
90
+ input.handleInput(data);
91
+ ```
92
+
93
+ **严重问题**: **所有 `backable=true` 场景下,Input 组件的光标左移功能完全丢失**。
94
+
95
+ **根因分析**:
96
+ 1. pi-tui 的 Input 组件中,左方向键绑定 `tui.editor.cursorLeft`(详细见 keybindings: `defaultKeys: ["left", "ctrl+b"]`)
97
+ 2. 本实现将 `matchesKey(data, Key.left)` 放在 `input.handleInput(data)` **之前**,且条件满足时直接 return
98
+ 3. Input.handleInput 永远不会收到左方向键事件,光标永远无法左移
99
+
100
+ **影响范围**: 所有 `uiInput(ctx, ..., backable=true)` 调用的场景:
101
+ - grill "自定义输入"环节(用户输入长文本后想修改前面的字符)
102
+ - wizard 中可返回的上一步输入(如 dev-feat 的"核心功能描述")
103
+
104
+ **替代方案不充分**: 实施计划提到用户可用 `Ctrl+B` 替代左方向键光标左移。
105
+ 但 `Ctrl+B` 不是常见的终端快捷键(Emacs 用户熟悉,但 Vim 用户和普通终端用户不熟悉),
106
+ 且实施计划中没有在 UI hint 文本中提示 `Ctrl+B` 替代方案。
107
+
108
+ **修复建议**:
109
+ 推荐 **方案 A**(最小改动,保留最佳用户体验): 仅在输入框为空或光标在最左侧时拦截左方向键做"返回",
110
+ 否则作为正常光标左移:
111
+
112
+ ```typescript
113
+ // 左方向键 → 光标在最左侧或输入为空时触发返回
114
+ if (backable && matchesKey(data, Key.left)) {
115
+ const cursor = getInputCursor?.(input); // 需要获取 Input 的 cursor 位置
116
+ if (this.cursorPosition === 0) {
117
+ done(BACK_MARKER);
118
+ return;
119
+ }
120
+ }
121
+ ```
122
+
123
+ 但 Input 组件没有公开 cursor 属性(private)。替代方案:检查 input.getValue() 是否为空。
124
+
125
+ **推荐方案 B**: 将"返回"功能从裸 `←` 改为 `Alt+←`(pi-tui 中已绑定 `tui.editor.cursorWordLeft`)或 `Ctrl+Shift+←`(已有实现)。
126
+
127
+ **推荐方案 C(最小改动)**: 将 `backable` 左方向键拦截改为仅在 `input.getValue()` 为空时生效:
128
+
129
+ ```typescript
130
+ if (backable && matchesKey(data, Key.left) && input.getValue().length === 0) {
131
+ done(BACK_MARKER);
132
+ return;
133
+ }
134
+ ```
135
+
136
+ 这样用户在输入内容后按 ← 可以编辑,而在空输入时按 ← 返回。
137
+
138
+ ### 2b) 预览区域 Text 组件在空文本时仍输出一行
139
+
140
+ ```typescript
141
+ const previewText = new Text("", 0, 0);
142
+ ```
143
+
144
+ **中等问题 — medium**: **即使输入框为空,预览区域始终额外输出 1 行空白 + 1 Spacer 间距行**。
145
+
146
+ 经检查 pi-tui Text 组件的实现:
147
+ ```javascript
148
+ // text.js render方法
149
+ if (!this.text || this.text.trim() === "") {
150
+ const result = []; // 早期返回空数组
151
+ ...
152
+ return result;
153
+ }
154
+ // ...
155
+ return result.length > 0 ? result : [""]; // 如果 result 为空,返回 [""] 一行空行
156
+ ```
157
+
158
+ 早期返回处返回 `[]`(空数组),但最后一行 `return result.length > 0 ? result : [""]` **不会被执行**
159
+ 因为早期 return 已经走了。实际 `setText("")` 后 Text.render 返回 `[]`(输出 0 行)。
160
+
161
+ 所以 `new Text("", 0, 0)` 在空字符串时输出0行,但 paddingY=0, paddingX=0 使其没有边距。
162
+ `render()` 实际执行路径:
163
+ 1. `!this.text` → `true`(因为 `this.text = ""`,空字符串是 falsy)
164
+ 2. 进入早期返回:`const result = []; return result;` → 返回 `[]`(空数组)
165
+
166
+ **结论**: 空文本时 Text 输出 0 行。但后面的 `Spacer(1)` 固定输出 1 行空白间距。
167
+ 所以在无输入时 UI 有 **1 行额外空白**,不是 2 行。
168
+
169
+ **严重等级**: 从 medium 降为 **low** — 仅 1 行空白的视觉影响,功能正常。
170
+
171
+ ### 2c) wrapTextWithAnsi 在 handleInput 中无条件调用
172
+
173
+ ```typescript
174
+ const val = input.getValue();
175
+ if (val.length > 0) {
176
+ const wrapped = wrapTextWithAnsi(val, width - 4);
177
+ ...
178
+ }
179
+ ```
180
+
181
+ **问题**: 每次按键都读取 value、换行、拼接 ANSI 字符串。对数百字符的输入性能可忽略,
182
+ 但 es module 反复调用 `wrapTextWithAnsi` 对大文本可能产生轻微延迟。
183
+
184
+ **严重等级**: **low** — 性能微优化。
185
+
186
+ ---
187
+
188
+ ## 3. 整体架构审查
189
+
190
+ ### 3a) 左方向键在三个层级的冲突 (CRITICAL)
191
+
192
+ **严重问题**:
193
+
194
+ 左方向键在代码中有三种语义:
195
+ 1. **Input 原生**: 光标左移一个字符(`tui.editor.cursorLeft`)
196
+ 2. **uiInput 层**: 返回上一题(当 `backable=true`)
197
+ 3. **showQuestionTUI 层**: 返回上一题(当 `backable && currentIndex > 1`)
198
+
199
+ 路径 #3 → #2 → #1,是串联关系。当用户进入自定义输入后:
200
+ - 按 ← → uiInput 拦截 → 返回 BACK_MARKER → showQuestionTUI → 返回上一题
201
+ - 用户本意:在输入框中左移光标修改文字 → 无法实现
202
+
203
+ 两处左方向键拦截会同时影响不同的 UI 层级,用户流程交叉时无法区分
204
+ "在 SelectList 中返回上一题"和"在 Input 中光标左移"。
205
+
206
+ ### 3b) backable=false 场景保留
207
+
208
+ 在 `uiInput` 中,当 `backable=false` 时,左方向键正常转发给 Input 组件(光标左移)。
209
+ 这是正确的行为,原有功能没有被破坏。
210
+
211
+ ### 3c) Ctrl+Shift+← 保留
212
+
213
+ 原有 `Ctrl+Shift+←` 返回功能被保留,与新增裸 ← 键并行。
214
+ 两者功能冗余但不冲突。
215
+
216
+ ---
217
+
218
+ ## 4. 测试覆盖分析
219
+
220
+ | # | 测试场景 | 状态 | 问题 |
221
+ |---|---------|------|------|
222
+ | 1 | Grill 左方向键返回 | ✅ 已实现 | SelectList 层级正常 |
223
+ | 2 | Grill 长选项显示 | ✅ 已实现 | description 在窄终端仍被截断(库限制) |
224
+ | 3 | Grill 自定义输入 ← 返回 | ✅ 路径可达 | **但输入中无法左移光标** |
225
+ | 4 | dev-* 长文本预览 | ✅ 已实现 | 空输入时有 1 行多余间距 |
226
+ | 5 | 普通 Esc 取消 | ✅ 未改变 | — |
227
+ | 6 | Ctrl+Shift+←/→ 导航 | ✅ 保留 | — |
228
+ | 7 | 非 backable 左方向键 | ✅ 保留 | — |
229
+ | 8 | 预览内容实时更新 | ✅ 按 Backspace 测试通过 | — |
230
+
231
+ ---
232
+
233
+ ## 汇总
234
+
235
+ | 等级 | 数量 | 说明 |
236
+ |------|------|------|
237
+ | **critical** | 1 | backable=true 时 uiInput 中左方向键拦截导致 Input 光标左移完全失效 |
238
+ | **medium** | 1 | 预览区域 Spacer 无条件占用行间距(视觉影响) |
239
+ | **low** | 3 | container.invalidate() 缺失;wrapTextWithAnsi 性能微调;__BACK__ 路径耦合 |
240
+
241
+ ---
242
+
243
+ ## 关键问题详解
244
+
245
+ ### Critical 1: uiInput 左方向键拦截破坏光标左移
246
+
247
+ **文件**: `extensions/ui-helpers.ts` 第 ~263 行
248
+ **影响**: 所有 `backable=true` 的 uiInput 场景
249
+ **根本原因**: `matchesKey(data, Key.left)` 在 `input.handleInput(data)` 之前拦截并 return
250
+
251
+ **修复方案推荐**:
252
+ **方案 C(最小改动)**: 仅在输入框为空时拦截 ← 作为返回:
253
+ ```typescript
254
+ if (backable && matchesKey(data, Key.left) && input.getValue().length === 0) {
255
+ done(BACK_MARKER);
256
+ return;
257
+ }
258
+ ```
259
+ 这样做的好处:
260
+ - 空输入时 ← 返回(用户无内容需要编辑)
261
+ - 有输入内容时 ← 正常光标左移(用户可以修改文字)
262
+ - 仍可使用 Ctrl+Shift+← 在任何状态下返回
263
+
264
+ ---
265
+
266
+ ## 总结
267
+
268
+ 实现的功能需求基本正确覆盖,但 `uiInput` 中的左方向键拦截过于激进,
269
+ 没有考虑 Input 组件本身对左方向键的依赖。其他改动在可接受范围内。
270
+ 主要建议修复 `uiInput` 中左方向键拦截的逻辑,增加光标位置或输入内容检查。
@@ -0,0 +1,214 @@
1
+ # 代码审查报告 — git diff 解析与循环计数 Bug
2
+
3
+ **审查日期**:2026-05-21 21:51
4
+ **审查范围**:`extensions/workflow-engine.ts`, `extensions/ui-helpers.ts`
5
+ **审查者**:代码审查专家
6
+
7
+ ---
8
+
9
+ ## 严重性问题
10
+
11
+ ### C1. [critical] 文本刮取过滤器不完整:`filePatterns` pattern 4 仍可能匹配包含 `{}` 或 `[]` 的脏数据
12
+
13
+ **文件**:`extensions/workflow-engine.ts`
14
+ **位置**:`runAgentWithProgress` 函数,约第 1036~1044 行
15
+
16
+ **问题描述**:
17
+ `filePatterns` 中的最后一个 pattern:
18
+ ```typescript
19
+ /(?:^|\n)\s*(?:edit|new|delete|read|modify|create|update|add|remove)\s*[::]\s*([^\n]+\.[a-zA-Z0-9_]+)/gim
20
+ ```
21
+ 要求匹配的文件路径**必须包含 `.`**(`\.[a-zA-Z0-9_]+`),这排除了大部分纯符号文本。但当前已有的三个额外过滤器:
22
+ ```typescript
23
+ if (filePath.includes("${") || filePath.includes("\\n") || filePath.includes("\\t")) continue;
24
+ if (filePath.includes("[]") || filePath.includes("{}")) continue;
25
+ if (filePath.match(/^[\s,;)\]}]+$/)) continue;
26
+ ```
27
+ 存在一个盲区:如果 agent 输出中包含类似 `modify: some.text.[]` 或 `update: file.name.{key}` 这样的文本(即包含 `.` 且包含 `{}` 或 `[]`),`filePath.includes("{}")` 和 `filePath.includes("[]")` 可以拦截。但如果文本是 `modify: arrays.0.name`(没有 `[]` 但语义上不是文件路径),过滤器无法拦截。
28
+
29
+ **根因**:
30
+ 文本刮取始终是脆弱的 —— 正则无法完全区分自然语言中的"看起来像文件路径"的文本和真正的文件路径。当前代码虽然添加了多层过滤器,但 agent 输出千变万化,总会有漏网之鱼。
31
+
32
+ **影响**:
33
+ 在 agent 输出包含包含 `.` 的代码片段或 JSON 路径(如 `config.data.0.name`、`output.path` 等)时,widget 中会显示这些虚假的 "M config.data.0.name" 条目,造成 UI 混乱。
34
+
35
+ **建议修复**:
36
+ 1. 最彻底的方式:完全依赖 `updateToolsFromGit`(git diff)来做文件变更检测,将文本刮取改为纯提示性(不影响 `_workflowFileChanges`)。
37
+ 2. 或者添加更严格的路径验证:验证文件是否真实存在于磁盘上(`fs.existsSync`),虽然会影响性能但准确率最高。
38
+ 3. 至少添加对数字索引路径(如 `data.0.name`、`list.1.title`)的过滤。
39
+
40
+ **严重性**:critical — 直接导致 UI 显示脏数据,影响用户对工作流状态的判断。
41
+
42
+ ---
43
+
44
+ ### C2. [critical] 循环计数偏移:`loopCount` 在 while 循环开头递增导致 checkpoint 恢复时语义不一致
45
+
46
+ **文件**:`extensions/workflow-engine.ts`
47
+ **位置**:`executeLoopGroup` 函数
48
+
49
+ **问题描述**:
50
+ 当前(未提交的)修改将 `loopCount++` 从 reviewer 完成之后**移到了 while 循环最开头**。逻辑是:
51
+ ```
52
+ while (loopCount < maxLoops) {
53
+ loopCount++; // 现在 loopCount 从 0 → 1
54
+ // ... 执行 worker ...
55
+ // ... 执行 reviewer ...
56
+ // 如果 need_continue,continue → 再次 loopCount++ → 2
57
+ }
58
+ ```
59
+
60
+ 这个修改解决了 UI 显示问题(不再显示两次 "第 1 次循环"),但引入了新的不一致性:
61
+
62
+ 1. **checkpoint 恢复时的语义偏移**:
63
+ - 旧语义(f98799d commit):`loopCount` = "已完成次数",`loopCounts[step.id]` 保存在 checkpoint 中
64
+ - 新语义:`loopCount` = "当前正在进行的轮次"(从 1 开始)
65
+ - 从 checkpoint 恢复时:`let loopCount = loopCounts[step.id] ?? 0;` — 如果之前完成了 1 次循环但被中断,`loopCounts[step.id] = 1`(旧语义的"已完成 1 次"),恢复后进入 while 时 `loopCount < maxLoops`,然后 `loopCount++` 变成 2。但恢复时应该**从第 2 次循环开始**(第 1 次已完成),所以行为是正确的。✅
66
+
67
+ 2. **初始 pending 状态显示问题**:
68
+ - `buildWidgetLines` 中 `isRunning` 的 fallback 检查 `if (s.loopCount == null || s.loopCount === 0)` 会 fallback 到"第 1 次循环"
69
+ - 但 `executeLoopGroup` 一开始就设置了 `state.loopCount = loopCount`(=1),所以 UI 永远不会走到这个 fallback
70
+ - 然而,如果 widget 的动画渲染在 `executeLoopGroup` 设置 loopCount 之前就触发,就可能看到短暂的"第 1 次循环"fallback
71
+
72
+ 3. **最大的问题**:`buildWidgetLines` 中 `isRunning` 时的 fallback 逻辑现在**永远不会被执行**,因为 `executeLoopGroup` 在 while 循环开头立即设置了 `loopCount = 1`。但这段代码仍然存在,是个死代码。
73
+
74
+ **影响**:
75
+ 主要风险是 checkpoint 恢复场景下如果 loopCounts 的存储格式在旧版和新版之间切换,可能产生计数偏移。但当前分析显示实际行为可能是正确的。代码可读性降低,有死代码残留。
76
+
77
+ **建议修复**:
78
+ 清理 `buildWidgetLines` 中 `isRunning` 分支下无用的 fallback 代码,改为直接依赖 `s.loopCount`。
79
+
80
+ **严重性**:critical — 循环计数偏移是用户明确报告的 bug,虽然当前修改在"正常流程"下修复了它,但有死代码残留和 checkpoint 兼容性风险。
81
+
82
+ ---
83
+
84
+ ## 中等问题
85
+
86
+ ### M1. [medium] `buildWidgetLines` 中 `isRunning` 的 fallback 逻辑是死代码
87
+
88
+ **文件**:`extensions/ui-helpers.ts`
89
+ **位置**:`buildWidgetLines` 函数
90
+ ```typescript
91
+ if (isRunning) {
92
+ if (s.loopCount == null || s.loopCount === 0) {
93
+ // 安全 fallback(理论上不会走到这里)
94
+ loopStr = dim(theme, ` · 第 1 次循环`);
95
+ }
96
+ }
97
+ ```
98
+
99
+ **问题描述**:
100
+ 当前 `executeLoopGroup` 在 while 循环的第一行就设置了 `state.loopCount = loopCount`(=1),所以 widget 状态中的 `s.loopCount` 在 `isRunning` 状态下永远不会为 `null` 或 `0`。这段 fallback 代码永远无法被执行。
101
+
102
+ 实际上,这个 fallback 的存在表明原本的设计存在缺陷 —— 在 `f98799d` commit 中,`loopCount++` 被放在 reviewer 完成之后,所以 UI 在 worker 运行时 loopCount 还没更新,才需要这个 fallback 在 UI 层面补显示"第 1 次循环"。
103
+
104
+ **建议修复**:
105
+ 移除 `isRunning` 分支下的 fallback 代码,因为这个场景现在不会发生,而且如果发生了(因某个竞态条件),显示错误的"第 1 次循环"比不显示更糟糕。
106
+
107
+ **严重性**:medium — 死代码不影响功能,但降低可维护性,且未来如果代码重构可能造成误导。
108
+
109
+ ---
110
+
111
+ ### M2. [medium] `getGitDiffChanges` 的 `git status --porcelain` 解析中 `statusPrefix.startsWith("A")` 过于宽松
112
+
113
+ **文件**:`extensions/workflow-engine.ts`
114
+ **位置**:`getGitDiffChanges` 函数
115
+ ```typescript
116
+ if (filePath && !seen.has(filePath) && (statusPrefix === "??" || statusPrefix === "A " || statusPrefix.startsWith("A"))) {
117
+ ```
118
+
119
+ **问题描述**:
120
+ `statusPrefix.startsWith("A")` 会匹配所有以 `A` 开头的状态码,包括:
121
+ - `"A "` — 已暂存的新增文件(正确)
122
+ - `"AM"` — 已暂存新增但又有修改(正确但不常见)
123
+ - `"AD"` — 已暂存新增但又有删除(罕见)
124
+ - 但不应该匹配 `"??"`(未追踪,已有专用处理)
125
+
126
+ 更严重的是,`statusPrefix` 是 `trimmed.match(/^(..)\s+(.+)$/)` 的第一个捕获组,它捕获了**两个字符**。但 `trimmed` 是 `line.trim()` 后的结果,对于 `?? somefile`,`statusPrefix` 是 `"??"`,对于 `"?? somefile"` 的 `trimmed` = `"?? somefile"`,`match` 给出 `statusPrefix = "??"`,正确。对于 `"A somefile"`(两个空格),`statusPrefix = "A "`(A + 空格),正确。
127
+
128
+ 但如果有一行是 `"A somefile"`(一个空格),这是不合法的 porcelain 格式,但 trim 后是 `"A somefile"`,match 给出 `statusPrefix = "A "`(因为 `(..)` 匹配第一个和第二个字符,第二个字符是空格)。
129
+
130
+ 实际上这个逻辑是正确的,只是看起来有点让人担心。风险很低。
131
+
132
+ **建议修复**:
133
+ 明确只匹配已知的 porcelain 状态码:
134
+ ```typescript
135
+ const ADD_CODES = new Set(["A ", "AM", "AD"]);
136
+ if (filePath && !seen.has(filePath) && (statusPrefix === "??" || ADD_CODES.has(statusPrefix))) {
137
+ ```
138
+
139
+ **严重性**:medium — 当前逻辑实际正确但不够严谨,可能在未来 git 版本输出变化时出现误匹配。
140
+
141
+ ---
142
+
143
+ ### M3. [medium] `addWidgetSubStepTool` 中 `gitMatch` 正则过于宽松
144
+
145
+ **文件**:`extensions/workflow-engine.ts`
146
+ **位置**:`addWidgetSubStepTool` 函数
147
+ ```typescript
148
+ const gitMatch = !oldMatch ? tool.match(/^([MAD])\s{2,}(.+)$/) : null;
149
+ ```
150
+
151
+ **问题描述**:
152
+ 这个正则 `^([MAD])\s{2,}(.+)$` 匹配以 `M`、`A` 或 `D` 开头,后跟至少 2 个空格,然后任意内容。这意味着如果任何字符串以 `M ` 开头(比如 agent 输出中的 "More interesting..." 或 "Add new feature..."),它都可能被匹配。
153
+
154
+ 但实际上这个函数只被 `addWidgetSubStepTool` 调用,而该函数的调用者已经确保了 `tool` 参数要么来自 progress 回调(格式化的 `M path`),要么来自 `updateToolsFromGit`(格式化的 `M path`),要么来自文本刮取(格式化的 `M path`)。所以误匹配的**源头**在于文本刮取,而不是这里。
155
+
156
+ 不过,多做一层防御总是好的。可以加强验证:确保捕获的 `filePath` 确实是一个真实存在的文件路径(或至少看起来像路径)。
157
+
158
+ **严重性**:medium — 依赖上游调用的正确性,自身防御不足。
159
+
160
+ ---
161
+
162
+ ## 低优先级问题
163
+
164
+ ### L1. [low] `extractFileChanges` 函数中 `sendWorkflowResult` 使用 `sendMessage` 可能产生冗余消息
165
+
166
+ **文件**:`extensions/ui-helpers.ts`
167
+ **位置**:`sendWorkflowResult` 函数
168
+
169
+ **问题描述**:
170
+ 工作流完成时,`executeWorkflowBackground` 调用 `sendWorkflowResult` 发送完成消息。同时,在工作流取消时,cancel callback 中也调用了 `sendWorkflowResult`。如果工作流在自然完成后 cancel callback 也被触发(竞态条件),可能发送两条重复的完成消息。
171
+
172
+ **严重性**:low — 仅在极罕见的竞态条件下可能发生。
173
+
174
+ ---
175
+
176
+ ### L2. [low] `populatePredefinedSubSteps` 在 checkpoint 恢复时可能跳过 sub-step 初始化
177
+
178
+ **文件**:`extensions/workflow-engine.ts`
179
+ **位置**:`populatePredefinedSubSteps` 函数
180
+
181
+ **问题描述**:
182
+ 函数开头的 `if (step.subSteps && step.subSteps.length > 0) return;` 意味着如果 widget step 已经包含 sub-steps(从 checkpoint 恢复时),就不会重新初始化。但 checkpoint 中的 sub-steps 可能没有 `detail` 字段(如 `超时时间60m`),导致恢复后的 sub-step 缺少超时时间显示。
183
+
184
+ **严重性**:low — 仅影响 checkpoint 恢复后的展示细节。
185
+
186
+ ---
187
+
188
+ ### L3. [low] 重复的 `extractTaskSummary` 函数定义
189
+
190
+ **文件**:`extensions/workflow-engine.ts` 和 `extensions/ui-helpers.ts`
191
+
192
+ **问题描述**:
193
+ 两个文件各自定义了 `extractTaskSummary` 函数,功能完全一致。这是代码重复,如果未来需要修改需要同步修改两处。
194
+
195
+ **严重性**:low — 不影响功能,单是代码风格问题。
196
+
197
+ ---
198
+
199
+ ## 总结
200
+
201
+ 当前代码已经针对 `git diff 解析` 和 `循环计数偏移` 两个 bug 做了本地修复(未提交):
202
+
203
+ 1. **git diff 解析**:在本地已从 `split("\t")` 改回正则解析,并保留了 tab split 作为后备。✅
204
+ 2. **文本刮取脏数据**:已添加三层额外过滤器(`${}`, `[]`/`{}`, 纯符号)来拦截脏数据。✅
205
+ 3. **循环计数偏移**:已将 `loopCount++` 从 reviewer 完成后移到 while 循环开头。✅
206
+
207
+ 但存在以下**残留问题**:
208
+
209
+ - **C1**: 文本刮取方案仍然脆弱,建议完全依赖 git diff 而非文本刮取
210
+ - **C2 + M1**: 循环计数 fallback 是死代码,需要清理
211
+ - **M2**: `statusPrefix.startsWith("A")` 过于宽松
212
+ - **M3**: `addWidgetSubStepTool` 的 gitMatch 正则防御不足
213
+
214
+ 建议优先解决 C1 和 C2+M1,然后处理 M2-M3,最后清理 L1-L3 的代码风格问题。
@@ -0,0 +1,201 @@
1
+ # 审查报告 — 超时时间/循环次数/git diff 解析修复
2
+
3
+ **审查时间**: 2026-05-21 23:45
4
+ **审查范围**: `extensions/ui-helpers.ts`, `extensions/workflow-engine.ts`, `extensions/dev-prompts.ts`, `tests/test-loopcount-timeout-fix.mjs`
5
+ **审查提交**: 当前 HEAD (`01413c9`) + 未暂存修改
6
+
7
+ ---
8
+
9
+ ## 严重(Critical)
10
+
11
+ ### C1. loopCount 显示逻辑在跨循环迭代期间显示错误的循环编号
12
+
13
+ **位置**: `extensions/workflow-engine.ts` `executeLoopGroup()` (~L1190-1265) & `extensions/ui-helpers.ts` `buildWidgetLines()` (~L485-494)
14
+
15
+ **根因**: `loopCount` 变量代表的是 **已完成的循环次数**,而非当前进行的循环次数。在 `executeLoopGroup` 中:
16
+
17
+ 1. `loopCount++` 在每次循环的 **末尾**(reviewer 完成后)执行
18
+ 2. 进入下一次循环时,`setWidgetSubStepStatus` 重置 sub-step 为 pending 并触发 `refreshWidget()`,此时 widget 中 step 的 `loopCount` 仍是上一次循环完成后的值
19
+ 3. 因此在 worker/reviewer 运行期间(可能需要几分钟),UI 显示的是上一次已完成循环的次数,而非当前循环的次数
20
+
21
+ **具体场景**:
22
+ - 第 1 次循环 worker/reviewer 运行 → `loopCount=0` → `isRunning` fallback 显示"第 1 次循环" ✅
23
+ - 第 1 次循环完成 → `loopCount++` → `updateWidgetStep` → 显示"第 1 次循环" ✅
24
+ - 第 2 次循环 worker 运行 → `loopCount=1`(仍为第 1 次完成值) → 显示仍是"第 1 次循环" ❌(应为"第 2 次循环")
25
+ - 第 2 次循环完成 → `loopCount=2` → 显示"第 2 次循环" ✅
26
+ - 第 3 次循环 worker 运行 → `loopCount=2`(仍为第 2 次完成值) → 显示"第 2 次循环" ❌
27
+
28
+ **修复建议**: 在 `buildWidgetLines` 中,当 `isRunning` 状态且 `s.maxLoops != null` 时,显示的循环次数应为 `s.loopCount + 1`(当前正在进行的循环)而非 `s.loopCount`。具体来说:
29
+
30
+ ```typescript
31
+ // 当前逻辑(错误):
32
+ if (s.loopCount != null && s.loopCount > 0) {
33
+ loopStr = dim(theme, ` · 第 ${s.loopCount} 次循环`);
34
+ } else if (s.maxLoops != null) {
35
+ if (isRunning) {
36
+ loopStr = dim(theme, ` · 第 1 次循环`); // 硬编码 1
37
+ }
38
+ }
39
+
40
+ // 修复后:
41
+ if (s.maxLoops != null) {
42
+ const displayCount = (s.loopCount ?? 0) + (isRunning && !isDone ? 1 : 0);
43
+ // 如果 loopCount=0(pending),显示第 0 次
44
+ // 如果 isRunning 且 loopCount=1,显示第 2 次
45
+ // 如果 isDone,显示 loopCount
46
+ if (displayCount > 0 || isPending) {
47
+ loopStr = dim(theme, ` · 第 ${displayCount} 次循环`);
48
+ }
49
+ }
50
+ ```
51
+
52
+ ### C2. executeLoopGroup 重置 sub-step 状态时丢失 worker 的 tools/outputs 历史
53
+
54
+ **位置**: `extensions/workflow-engine.ts` `executeLoopGroup()` L1194-1195
55
+
56
+ ```typescript
57
+ // 每次循环开始时重置 sub-step 状态
58
+ setWidgetSubStepStatus(stepIndex, step.loopAgentName!, "pending");
59
+ setWidgetSubStepStatus(stepIndex, step.reviewAgentName!, "pending");
60
+ ```
61
+
62
+ **根因**: `setWidgetSubStepStatus` 只将 sub-step 的 `status` 改为 `"pending"`,并没有重置 `tools`、`outputs`、`startedAt` 等字段。当进入新一轮循环时,旧的 tool 记录仍会显示在 UI 上,导致新 worker 的结果和旧 worker 的结果混合显示。
63
+
64
+ 然而,更严重的问题是 **`runAgentWithProgress` 在创建新的 sub-step 时检查 `existing = step.subSteps?.find(s => s.agent === agentName)`**,它找到旧 sub-step 后**不会清除旧数据**,而是直接复用:
65
+
66
+ ```typescript
67
+ const existing = step.subSteps.find(s => s.agent === agentName);
68
+ if (!existing) {
69
+ // 创建新的...
70
+ } else {
71
+ // Update existing sub-step status, startedAt, and detail
72
+ existing.status = "running";
73
+ existing.startedAt = agentStartTime;
74
+ existing.detail = `超时时间${formatTimeout(timeoutMs)}`;
75
+ refreshWidget();
76
+ }
77
+ ```
78
+
79
+ 注意:`tools` 和 `outputs` 没有被清空!所以在第 2 次循环的 worker 运行时,旧的 tools 和 outputs 仍然会显示在 UI 上。
80
+
81
+ **修复建议**: 在 `executeLoopGroup` 重置 sub-step 时,同时清空 tools 和 outputs:
82
+
83
+ ```typescript
84
+ // 每次循环开始时重置 sub-step 状态并清空历史
85
+ const loopSub = _widgetSteps[stepIndex]?.subSteps?.find(s => s.agent === step.loopAgentName);
86
+ const reviewSub = _widgetSteps[stepIndex]?.subSteps?.find(s => s.agent === step.reviewAgentName);
87
+ if (loopSub) { loopSub.status = "pending"; loopSub.tools = []; loopSub.outputs = []; loopSub.startedAt = undefined; }
88
+ if (reviewSub) { reviewSub.status = "pending"; reviewSub.tools = []; reviewSub.outputs = []; reviewSub.startedAt = undefined; }
89
+ refreshWidget();
90
+ ```
91
+
92
+ 或者,在 `runAgentWithProgress` 中复用 sub-step 时清空 tools/outputs。
93
+
94
+ ---
95
+
96
+ ## 中等(Medium)
97
+
98
+ ### M1. 超时时间格式化不一致
99
+
100
+ **位置**: `extensions/ui-helpers.ts` `formatTimeout()` (~L368-371)
101
+
102
+ ```typescript
103
+ export function formatTimeout(ms: number): string {
104
+ const m = Math.floor(ms / 60000);
105
+ const s = Math.floor((ms % 60000) / 1000);
106
+ return s > 0 ? `${m}m${s}s` : `${m}m`;
107
+ }
108
+ ```
109
+
110
+ **问题**: 当超时时间恰好是整分钟(如 15min=900000ms)时,`formatTimeout` 返回 `15m`(不带秒)。这与 `formatDurationFull` 的行为不一致——`formatDurationFull` 在 `ms >= 60000` 时总是显示 `m` 和 `s` 两段(即使秒数为 0 也会显示 `0s`)。
111
+
112
+ 在 UI 上会看到 `(52.6s/超时时间15m)`,而预期应为 `(52.6s/超时时间15m0s)` 或至少格式一致。不过这个差异很细微,且现有的测试用例验证了 `15m` 格式是可接受的。
113
+
114
+ **建议**: 如果希望一致性,可以改为 `return s > 0 ? `${m}m${s}s` : `${m}m0s`;`。
115
+
116
+ ### M2. 测试覆盖不完整
117
+
118
+ **位置**: `tests/test-loopcount-timeout-fix.mjs`
119
+
120
+ **问题**:
121
+ 1. 测试用例仅通过静态代码分析(`string.includes()`)验证代码存在性,没有模拟执行验证运行时行为
122
+ 2. 没有测试验证跨多个循环迭代的 loopCount 正确性(如第 2 次循环显示"第 2 次循环")
123
+ 3. 没有测试验证 `getGitDiffChanges` 的 git diff 解析正确处理制表符分隔的格式
124
+
125
+ **建议**:
126
+ - 添加针对 `getGitDiffChanges` 的单元测试,模拟 `git diff --name-status` 输出并验证解析结果
127
+ - 添加对 `buildWidgetLines` 中 loopCount 在 `isRunning` 状态下的测试
128
+
129
+ ### M3. 子代理行超时显示可能存在重复/格式问题
130
+
131
+ **位置**: `extensions/ui-helpers.ts` `buildWidgetLines()` ~L560-565
132
+
133
+ ```typescript
134
+ if (sub.detail && sub.detail.includes("超时时间")) {
135
+ subTimeoutStr = dim(theme, `/${sub.detail}`);
136
+ }
137
+ ```
138
+
139
+ **问题**: `sub.detail` 的值是 `"超时时间60m"`(来自 `runAgentWithProgress`),当拼接到 UI 行时,会变成 `(52.6s/超时时间60m)`。但 UI 示例中期望的格式是 `(52.6s/超时时间60m)`,这没问题。
140
+
141
+ 但如果 `sub.detail` 因某种原因包含多余字符(如空格或换行),或者被后续代码修改,可能导致格式异常。且 `sub.detail.includes("超时时间")` 检查是字符串包含匹配,过于宽松,应改为更精确匹配,如 `sub.detail.startsWith("超时时间")`。
142
+
143
+ ---
144
+
145
+ ## 低优先级(Low)
146
+
147
+ ### L1. setWidgetSubStepStatus 缩进不一致
148
+
149
+ **位置**: `extensions/workflow-engine.ts` `setWidgetSubStepStatus()` ~L781-791
150
+
151
+ ```typescript
152
+ function setWidgetSubStepStatus(stepIndex: number, agentName: string, status: WorkflowSubStepWidgetState["status"]): void {
153
+ const step = _widgetSteps[stepIndex]; // Tabs → spaces
154
+ if (!step) return;
155
+ const sub = step.subSteps?.find(s => s.agent === agentName);
156
+ if (sub) {
157
+ sub.status = status; // Spaces → tabs
158
+ refreshWidget();
159
+ }
160
+ }
161
+ ```
162
+
163
+ 这个函数存在混用 tab 和 space 缩进的问题(`step` 行用空格,`sub` 行用 tab),这在 commit 01413c9 中引入。
164
+
165
+ ### L2. 注释中示例 UI 未更新
166
+
167
+ **位置**: `extensions/ui-helpers.ts` ~L399-415
168
+
169
+ `buildWidgetLines` 上方的 JSDoc 注释中的 UI 渲染示例仍然显示旧格式:
170
+ ```
171
+ ▶ ⠋ 🔧实施代码 → 审查 · 第 1 次循环 (1s/超时时间15m)
172
+ |__ worker ·
173
+ ```
174
+
175
+ 没有显示子代理行中应有的超时信息:
176
+ ```
177
+ |__ worker · (1s/超时时间15m)
178
+ ```
179
+
180
+ ### L3. git status --porcelain 解析边界情况
181
+
182
+ **位置**: `extensions/workflow-engine.ts` `getGitDiffChanges()` ~L326-330
183
+
184
+ ```typescript
185
+ const statusPrefix = trimmed.slice(0, 2);
186
+ const filePath = trimmed.slice(3).trim();
187
+ ```
188
+
189
+ 当 `trimmed` 长度小于 3 时(如空行或长度异常的输入),`trimmed.slice(3)` 可能返回空字符串,然后 `trim()` 返回 `""`,导致 `if (filePath && ...)` 条件失败。这实际上是安全的(不会产生假阳性),但代码缺乏显式的长度检查,可读性略差。
190
+
191
+ ---
192
+
193
+ ## 总结
194
+
195
+ 本次审查发现 2 个严重问题、3 个中等问题和 3 个低优先级问题。
196
+
197
+ **严重问题 C1** 是最关键的用户可见 bug:跨循环迭代期间,UI 显示的循环编号滞后。例如,当第 2 次循环的 worker 运行时,UI 仍显示"第 1 次循环"。需要修改 `buildWidgetLines` 中的 `loopCount` 显示逻辑,在运行时使用 `loopCount + 1` 作为当前循环编号。
198
+
199
+ **严重问题 C2** 是另一个用户可见问题:跨循环迭代时,旧的 tools/outputs 数据未被清空,导致新 worker 的结果与旧数据混合显示。
200
+
201
+ 当前未暂存的 diff 已正确修复了超时时间显示位置(子代理行)和 git diff 解析(string 拆分替代正则),但循环次数计数问题(C1)和 sub-step 数据重置问题(C2)仍需要进一步修复。