ai-dev-flow 3.10.0__py3-none-any.whl

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 (35) hide show
  1. ai_dev_flow/__init__.py +5 -0
  2. ai_dev_flow/__main__.py +6 -0
  3. ai_dev_flow/checkpoint.py +65 -0
  4. ai_dev_flow/cli.py +598 -0
  5. ai_dev_flow/config.py +404 -0
  6. ai_dev_flow/data/pipeline.yaml +193 -0
  7. ai_dev_flow/data/profiles/java-spring.yml +140 -0
  8. ai_dev_flow/data/recipes/steps/00-init-project.yaml +168 -0
  9. ai_dev_flow/data/recipes/steps/01-refine-requirement.yaml +117 -0
  10. ai_dev_flow/data/recipes/steps/02-analysis.yaml +97 -0
  11. ai_dev_flow/data/recipes/steps/03-design.yaml +91 -0
  12. ai_dev_flow/data/recipes/steps/03.5-decompose.yaml +120 -0
  13. ai_dev_flow/data/recipes/steps/05-build.yaml +72 -0
  14. ai_dev_flow/data/recipes/steps/06-review.yaml +115 -0
  15. ai_dev_flow/data/recipes/steps/07-delivery.yaml +76 -0
  16. ai_dev_flow/data/recipes/steps/task-template.yaml +74 -0
  17. ai_dev_flow/error_handler.py +122 -0
  18. ai_dev_flow/executor.py +406 -0
  19. ai_dev_flow/git_ops.py +187 -0
  20. ai_dev_flow/knowledge_accumulator.py +121 -0
  21. ai_dev_flow/logger.py +84 -0
  22. ai_dev_flow/sandbox.py +291 -0
  23. ai_dev_flow/semantic_summarizer.py +351 -0
  24. ai_dev_flow/snapshot.py +210 -0
  25. ai_dev_flow/state.py +87 -0
  26. ai_dev_flow/task_context.py +134 -0
  27. ai_dev_flow/task_graph.py +841 -0
  28. ai_dev_flow/task_state.py +151 -0
  29. ai_dev_flow/watchdog.py +120 -0
  30. ai_dev_flow-3.10.0.dist-info/METADATA +171 -0
  31. ai_dev_flow-3.10.0.dist-info/RECORD +35 -0
  32. ai_dev_flow-3.10.0.dist-info/WHEEL +5 -0
  33. ai_dev_flow-3.10.0.dist-info/entry_points.txt +2 -0
  34. ai_dev_flow-3.10.0.dist-info/licenses/LICENSE +21 -0
  35. ai_dev_flow-3.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """AI 全流程开发管线 - 编排引擎"""
2
+
3
+ __version__ = "3.10.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """支持 `python -m ai_dev_flow`。"""
2
+
3
+ from ai_dev_flow.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,65 @@
1
+ """人工确认检查点。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ logger = logging.getLogger("ai-dev-flow")
9
+
10
+ SEPARATOR = "=" * 60
11
+ PREVIEW_LINES = 25
12
+
13
+ ci_mode = False
14
+
15
+
16
+ def set_ci_mode(enabled: bool) -> None:
17
+ global ci_mode
18
+ ci_mode = enabled
19
+
20
+
21
+ def confirm(title: str, prompt: str, preview_file: Path | None = None) -> None:
22
+ """暂停并等待用户确认,可选展示产出文件摘要。"""
23
+ print()
24
+ print(SEPARATOR)
25
+ print(f" [人工确认] {title}")
26
+ print(SEPARATOR)
27
+ if prompt:
28
+ print(f" {prompt}")
29
+ print(SEPARATOR)
30
+
31
+ if preview_file and preview_file.exists():
32
+ preview(preview_file)
33
+
34
+ if ci_mode:
35
+ print(" [CI] 自动确认,继续执行...")
36
+ return
37
+ print()
38
+ input("按 Enter 继续...")
39
+
40
+
41
+ def preview(filepath: Path, lines: int = PREVIEW_LINES) -> None:
42
+ """展示文件的前 N 行作为摘要。"""
43
+ try:
44
+ content = filepath.read_text(encoding="utf-8")
45
+ all_lines = content.splitlines()
46
+ print()
47
+ print(f" --- {filepath.name} 摘要 (前 {min(lines, len(all_lines))} 行) ---")
48
+ for line in all_lines[:lines]:
49
+ print(f" | {line}")
50
+ if len(all_lines) > lines:
51
+ print(f" | ... (共 {len(all_lines)} 行,已省略)")
52
+ print(f" --- {filepath.stat().st_size // 1024}KB ---")
53
+ except Exception:
54
+ print(f" (无法预览 {filepath.name})")
55
+
56
+
57
+ def ask_boolean(prompt: str, default: bool = True) -> bool:
58
+ if ci_mode:
59
+ print(f" [CI] {prompt} → {'Y' if default else 'N'} (auto)")
60
+ return default
61
+ suffix = " (Y/n): " if default else " (y/N): "
62
+ answer = input(prompt + suffix).strip().lower()
63
+ if not answer:
64
+ return default
65
+ return answer in ("y", "yes")
ai_dev_flow/cli.py ADDED
@@ -0,0 +1,598 @@
1
+ #!/usr/bin/env python3
2
+ """AI 全流程开发管线 — CLI 入口。
3
+
4
+ 用法:
5
+ ai-dev-flow # 交互式启动
6
+ ai-dev-flow --project D:/MyPrj/xxx # 指定项目路径
7
+ ai-dev-flow --project ./foo --git-url https://github.com/user/repo.git # 自动 clone
8
+ ai-dev-flow --from-stage phase3 # 从指定阶段开始
9
+ ai-dev-flow --dry-run # 预览将执行的阶段
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ import os
16
+ import time
17
+ import argparse
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+ from ai_dev_flow import __version__
22
+ from ai_dev_flow.logger import init as init_logger
23
+ from ai_dev_flow.config import load_pipeline, load_profile as load_project_profile, EmptyTaskGraphError
24
+ from ai_dev_flow.state import (
25
+ read_stage, write_stage, clear_stage,
26
+ read_note, clear_note,
27
+ )
28
+ from ai_dev_flow.executor import check_goose, run_stage, GooseNotFound, detect_jdk, JdkNotFound
29
+ from ai_dev_flow.checkpoint import confirm, ask_boolean, set_ci_mode as set_checkpoint_ci
30
+ from ai_dev_flow.error_handler import handle_error, Action, set_ci_mode as set_error_ci
31
+ from ai_dev_flow.task_graph import execute_task_graph
32
+ from ai_dev_flow.git_ops import GitOps
33
+
34
+ DATA_DIR = Path(__file__).resolve().parent / "data"
35
+ SEPARATOR = "=" * 60
36
+
37
+
38
+ def setup_project(project_path: str, git_url: str = "", git_branch: str = "",
39
+ pull: bool = False) -> tuple[Path, Path, Path]:
40
+ p = Path(project_path).resolve()
41
+ if not p.exists():
42
+ if git_url:
43
+ print("项目目录不存在,从远程仓库 clone...")
44
+ print(f" {git_url} → {p}")
45
+ p.parent.mkdir(parents=True, exist_ok=True)
46
+ clone_cmd = ["git", "clone"]
47
+ if git_branch:
48
+ clone_cmd += ["-b", git_branch]
49
+ clone_cmd += [git_url, str(p)]
50
+ result = subprocess.run(
51
+ clone_cmd,
52
+ capture_output=True, text=True,
53
+ timeout=120,
54
+ )
55
+ if result.returncode != 0:
56
+ raise RuntimeError(f"git clone 失败: {result.stderr.strip()}")
57
+ print(" clone 完成")
58
+ else:
59
+ raise FileNotFoundError(f"项目目录不存在: {p}")
60
+ elif pull:
61
+ _git = (p / ".git").exists()
62
+ if _git:
63
+ branch = git_branch or "main"
64
+ print(f" 拉取最新代码 ({branch})...")
65
+ result = subprocess.run(
66
+ ["git", "-C", str(p), "pull", "origin", branch],
67
+ capture_output=True, text=True,
68
+ timeout=60,
69
+ )
70
+ if result.returncode != 0:
71
+ print(f" 警告: git pull 失败 — {result.stderr.strip()[:200]}")
72
+ else:
73
+ print(f" pull 完成")
74
+
75
+ ad = p / ".ai-dev"
76
+ out = ad / "outputs"
77
+ ad.mkdir(exist_ok=True)
78
+ out.mkdir(exist_ok=True)
79
+
80
+ return p, ad, out
81
+
82
+
83
+ def _clean_outputs(AD: Path, OUT: Path) -> None:
84
+ """白名单保留式清理:仅保留 logs/,其余 .ai-dev/ 一级条目全部删除。"""
85
+ import shutil as _shutil
86
+
87
+ KEEP = {"logs"} # 历史运行日志,排查问题唯一依据
88
+ cleaned = []
89
+
90
+ for entry in AD.iterdir():
91
+ if entry.name in KEEP:
92
+ continue
93
+ try:
94
+ was_dir = entry.is_dir()
95
+ if was_dir:
96
+ _shutil.rmtree(entry)
97
+ else:
98
+ entry.unlink()
99
+ cleaned.append(entry.name + ("/" if was_dir else ""))
100
+ except Exception as e:
101
+ print(f" 警告: 无法删除 {entry.name}: {e}")
102
+
103
+ if cleaned:
104
+ print(f" 已清除: {', '.join(sorted(cleaned))}")
105
+
106
+
107
+ def expand_params(params: dict[str, str], P: Path, AD: Path, OUT: Path,
108
+ req_path: str = "") -> dict[str, str]:
109
+ mapping = {
110
+ "{P}": str(P),
111
+ "{AD}": str(AD),
112
+ "{OUT}": str(OUT),
113
+ "{REQ}": req_path,
114
+ }
115
+ expanded = {}
116
+ for key, value in params.items():
117
+ for placeholder, replacement in mapping.items():
118
+ value = value.replace(placeholder, replacement)
119
+ expanded[key] = value
120
+ return expanded
121
+
122
+
123
+ def _snapshot_outputs(OUT: Path) -> set[str]:
124
+ """记录当前 outputs/ 中的文件名集合。"""
125
+ if not OUT.exists():
126
+ return set()
127
+ return {f.name for f in OUT.iterdir() if f.is_file()}
128
+
129
+
130
+ def _detect_extra_files(OUT: Path, before: set[str], expected: set[str]) -> list[str]:
131
+ """检测阶段执行后产生的非预期文件。"""
132
+ after = _snapshot_outputs(OUT)
133
+ new_files = after - before
134
+ extra = [f for f in new_files if f not in expected]
135
+ return extra
136
+
137
+
138
+ def banner():
139
+ print(SEPARATOR)
140
+ print(f" AI Dev Flow v{__version__}")
141
+ print(" 需求 → 分析 → 方案 → 编码 → 审查 → 交付")
142
+ print(SEPARATOR)
143
+
144
+
145
+ def _resolve_output_path(template: str | None, P: Path, AD: Path, OUT: Path) -> Path | None:
146
+ """将占位符路径解析为实际 Path。"""
147
+ if not template:
148
+ return None
149
+ resolved = expand_params({"_": template}, P, AD, OUT)["_"]
150
+ return Path(resolved)
151
+
152
+
153
+ def _check_prerequisite(prerequisite: str, AD: Path, logger) -> bool:
154
+ """检查阶段前置条件是否满足。
155
+
156
+ 'task_graph' — 任务图至少有一个任务产出代码(completed 或 code_produced)。
157
+ """
158
+ if prerequisite == "task_graph":
159
+ task_state_file = AD / "task_state.json"
160
+ if not task_state_file.exists():
161
+ logger.warning("task_state.json 不存在,视为任务图未执行")
162
+ return False
163
+ try:
164
+ import json
165
+ task_state = json.loads(task_state_file.read_text(encoding="utf-8"))
166
+ except Exception:
167
+ logger.warning("task_state.json 无法解析")
168
+ return False
169
+ code_tasks = [
170
+ tid for tid, t in task_state.items()
171
+ if t.get("status") in ("completed", "code_produced")
172
+ ]
173
+ if not code_tasks:
174
+ logger.warning("task_state.json 中无 completed/code_produced 任务")
175
+ return False
176
+ return True
177
+ return True
178
+
179
+
180
+ def main():
181
+ parser = argparse.ArgumentParser(
182
+ prog="ai-dev-flow",
183
+ description="AI 全流程开发管线",
184
+ formatter_class=argparse.RawDescriptionHelpFormatter,
185
+ )
186
+ parser.add_argument("--project", "-p", help="目标项目路径")
187
+ parser.add_argument("--git-url", help="远程仓库地址(项目目录不存在时自动 clone)")
188
+ parser.add_argument("--git-branch", help="clone 时指定分支(配合 --git-url 使用)")
189
+ parser.add_argument("--req", "-r", help="需求文件路径")
190
+ parser.add_argument("--new", dest="new_run", action="store_true", help="全新运行,清除旧的 .ai-dev 产出")
191
+ parser.add_argument("--from-stage", dest="from_stage", help="从指定阶段开始")
192
+ parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际执行")
193
+ parser.add_argument("--debug", action="store_true", help="调试模式")
194
+ parser.add_argument("--verbose", action="store_true", help="goose 全量输出(默认 -q 静默,仅显示模型回复)")
195
+ parser.add_argument("--pull", action="store_true", help="启动前 git pull 拉取最新代码")
196
+ parser.add_argument("--ci", action="store_true", help="CI 模式,跳过所有人工交互,自动使用默认选择")
197
+ parser.add_argument("--version", action="version", version=f"ai-dev-flow v{__version__}")
198
+ args = parser.parse_args()
199
+
200
+ if args.ci:
201
+ set_checkpoint_ci(True)
202
+ set_error_ci(True)
203
+
204
+ banner()
205
+
206
+ project_path = args.project
207
+ if not project_path:
208
+ if args.ci:
209
+ print("错误: CI 模式需要指定 --project")
210
+ sys.exit(1)
211
+ project_path = input("项目路径: ").strip().strip('"')
212
+ if not project_path:
213
+ print("错误: 未指定项目路径")
214
+ sys.exit(1)
215
+
216
+ P, AD, OUT = setup_project(project_path,
217
+ git_url=args.git_url or "",
218
+ git_branch=args.git_branch or "",
219
+ pull=args.pull)
220
+ logger = init_logger(P, debug=args.debug)
221
+ logger.info(f"项目: {P}")
222
+
223
+ # 检测项目画像是否存在,不存在则强制从 Phase 0 开始
224
+ profile_path = AD / "profile.yml"
225
+ profile_missing = not profile_path.exists()
226
+ if profile_missing:
227
+ logger.info("项目画像不存在,将从 Phase 0 项目初始化开始")
228
+ if not args.ci:
229
+ print()
230
+ print(SEPARATOR)
231
+ print(" 项目画像文件 (profile.yml) 不存在。")
232
+ print(" 将首先运行 Phase 0 — 自动扫描项目生成画像。")
233
+ print(SEPARATOR)
234
+
235
+ # 检测是否为全新需求提交
236
+ existing_stage = read_stage(AD)
237
+ has_outputs = list(OUT.glob("*")) if OUT.exists() else []
238
+
239
+ if args.new_run and not args.from_stage:
240
+ _clean_outputs(AD, OUT)
241
+ elif args.new_run and args.from_stage:
242
+ # from_stage 时不清除旧产出(需要之前阶段生成的文件)
243
+ logger.info("--from-stage 模式,不清除旧产出")
244
+ elif has_outputs and not existing_stage:
245
+ print()
246
+ print(SEPARATOR)
247
+ print(" 检测到旧的 .ai-dev 产出文件:")
248
+ for f in sorted(has_outputs):
249
+ print(f" - {f.name}")
250
+ print(SEPARATOR)
251
+ if ask_boolean("清除旧文件开始全新运行?", default=True):
252
+ _clean_outputs(AD, OUT)
253
+ else:
254
+ print("保留旧文件,继续执行(可能覆盖同名文件)")
255
+
256
+ # 验证 goose CLI
257
+ try:
258
+ check_goose()
259
+ except GooseNotFound as e:
260
+ logger.error(str(e))
261
+ sys.exit(1)
262
+
263
+ # JDK 自动检测(供阶段级 goose 子进程使用)
264
+ stage_env: dict[str, str] = {}
265
+ try:
266
+ _profile = load_project_profile(profile_path) if profile_path.exists() else None
267
+ if _profile:
268
+ jdk_required = int(_profile.get("backend", {}).get("jdk", {}).get("compileRelease", 17))
269
+ else:
270
+ jdk_required = 17
271
+ jdk_home = detect_jdk(jdk_required)
272
+ stage_env["JAVA_HOME"] = jdk_home
273
+ logger.info(f"JDK {jdk_required} detected: {jdk_home}")
274
+ print(f" JDK 检测: {jdk_home}")
275
+ except JdkNotFound as e:
276
+ logger.warning(str(e))
277
+ except Exception as e:
278
+ logger.warning(f"JDK 检测跳过(profile 尚未生成或无法解析): {e}")
279
+
280
+ # 检查 API Key
281
+ api_key = os.environ.get("CUSTOM_DEEPSEEK_API_KEY")
282
+ if not api_key:
283
+ logger.warning("未设置 CUSTOM_DEEPSEEK_API_KEY 环境变量")
284
+ logger.warning("请运行: set CUSTOM_DEEPSEEK_API_KEY=你的key")
285
+ if not ask_boolean("是否继续? (某些阶段可能无法正常工作)", default=False):
286
+ sys.exit(1)
287
+
288
+ # 加载管线配置
289
+ pipeline_config_path = DATA_DIR / "pipeline.yaml"
290
+ pipeline = load_pipeline(pipeline_config_path)
291
+
292
+ # 处理需求文件
293
+ req_file = args.req
294
+ if req_file:
295
+ req_file = str(Path(req_file).resolve())
296
+ else:
297
+ note = read_note(AD)
298
+ if note:
299
+ print()
300
+ print(SEPARATOR)
301
+ print(" 上次运行的笔记:")
302
+ print(f" {note}")
303
+ print(SEPARATOR)
304
+ choice = input("清除笔记并继续? (Y/n/保留笔记继续=q): ").strip().lower()
305
+ if choice in ("", "y", "yes"):
306
+ clear_note(AD)
307
+ elif choice in ("q", "keep"):
308
+ logger.info("保留笔记,继续执行")
309
+ else:
310
+ sys.exit(1)
311
+
312
+ if not args.ci:
313
+ req_file = input("需求文件路径 (留空跳过): ").strip().strip('"')
314
+ if req_file:
315
+ req_file = str(Path(req_file).resolve())
316
+
317
+ # 确定起始阶段
318
+ current_state = read_stage(AD)
319
+ if args.from_stage:
320
+ start_idx = 0
321
+ for i, s in enumerate(pipeline.stages):
322
+ if s.id == args.from_stage:
323
+ start_idx = i
324
+ break
325
+ logger.info(f"从指定阶段开始: {pipeline.stages[start_idx].name}")
326
+ elif profile_missing:
327
+ start_idx = 0 # 强制从 Phase 0 开始
328
+ logger.info("从 Phase 0 项目初始化开始(profile.yml 不存在)")
329
+ elif current_state:
330
+ start_idx = pipeline.find_resume_index(current_state)
331
+ if start_idx >= len(pipeline.stages):
332
+ logger.info("管线已全部完成!")
333
+ clear_stage(AD)
334
+ return
335
+ if start_idx > 0:
336
+ prev = pipeline.stages[start_idx - 1]
337
+ logger.info(f"从断点恢复: {prev.name} 已完成 → {pipeline.stages[start_idx].name}")
338
+ else:
339
+ logger.info("开始全新管线")
340
+ else:
341
+ start_idx = 0
342
+ logger.info("开始全新管线")
343
+
344
+ if args.dry_run:
345
+ print()
346
+ print("预览模式 — 将执行以下阶段:")
347
+ for i, s in enumerate(pipeline.stages[start_idx:], start=start_idx):
348
+ marker = "[检查点]" if s.is_checkpoint else "[AI阶段]"
349
+ print(f" {i+1:2d}. {marker} {s.name}")
350
+ return
351
+
352
+ # 执行管线 — 记录各阶段耗时
353
+ total = len(pipeline.stages)
354
+ stage_times: list[tuple[str, float, str]] = [] # (name, elapsed_sec, status)
355
+ pipeline_start = time.time()
356
+
357
+ _tasks_snapshot = "" # 任务图执行后保护 tasks.yaml 不被后续阶段覆盖
358
+
359
+ for i in range(start_idx, total):
360
+ stage = pipeline.stages[i]
361
+ progress = f"[{i+1}/{total}]"
362
+ stage_start = time.time()
363
+
364
+ if stage.is_checkpoint:
365
+ # 查找前一个阶段生成的产出文件作为预览
366
+ preview_file = None
367
+ for j in range(i - 1, -1, -1):
368
+ prev_stage = pipeline.stages[j]
369
+ if prev_stage.output_file:
370
+ preview_file = _resolve_output_path(prev_stage.output_file, P, AD, OUT)
371
+ break
372
+
373
+ prompt_text = expand_params({"_": stage.checkpoint_prompt}, P, AD, OUT, req_file or "")["_"]
374
+ confirm(f"{progress} {stage.name}", prompt_text, preview_file)
375
+ write_stage(AD, stage.state_value)
376
+ elapsed = time.time() - stage_start
377
+ stage_times.append((stage.name, elapsed, "✅"))
378
+ continue
379
+
380
+ # 前置条件检查
381
+ if stage.prerequisite:
382
+ if not _check_prerequisite(stage.prerequisite, AD, logger):
383
+ logger.warning(f"Stage {stage.name}: prerequisite '{stage.prerequisite}' not met, skipping")
384
+ write_stage(AD, stage.state_value)
385
+ elapsed = time.time() - stage_start
386
+ stage_times.append((stage.name, elapsed, "⚠️ 跳过"))
387
+ continue
388
+
389
+ # Task Graph 阶段 — 路由到任务图执行器
390
+ if stage.is_task_graph:
391
+ expanded_params = expand_params(stage.params, P, AD, OUT, req_file or "")
392
+ tasks_file = Path(expanded_params["tasks_file"])
393
+ if not tasks_file.exists():
394
+ logger.error(f"tasks.yaml 不存在: {tasks_file}")
395
+ action = handle_error(AD)
396
+ if action == Action.SKIP:
397
+ write_stage(AD, stage.state_value)
398
+ stage_times.append((stage.name, 0, "⚠️ 跳过"))
399
+ continue
400
+ else:
401
+ return
402
+
403
+ print()
404
+ print(SEPARATOR)
405
+ print(f" {progress} {stage.name}")
406
+ print(SEPARATOR)
407
+
408
+ task_start = time.time()
409
+ try:
410
+ success, _ = execute_task_graph(
411
+ project_root=P,
412
+ ai_dev_dir=AD,
413
+ tasks_file=tasks_file,
414
+ profile_path=AD / "profile.yml",
415
+ task_recipe=expanded_params.get("task_recipe",
416
+ "recipes/steps/task-template.yaml"),
417
+ quiet=not args.verbose,
418
+ )
419
+ except EmptyTaskGraphError as e:
420
+ logger.error(f"任务图加载失败: {e}")
421
+ print("\n ❌ 任务图为空,无法执行。")
422
+ print(f" {e}")
423
+ action = handle_error(AD)
424
+ if action == Action.SKIP:
425
+ write_stage(AD, stage.state_value)
426
+ elapsed = time.time() - task_start
427
+ stage_times.append((stage.name, elapsed, "⚠️ 跳过(空任务图)"))
428
+ continue
429
+ else:
430
+ return
431
+
432
+ if success:
433
+ write_stage(AD, stage.state_value)
434
+ elapsed = time.time() - task_start
435
+ mins, secs = divmod(int(elapsed), 60)
436
+ logger.info(f"任务图执行完成 ({mins}m{secs}s)")
437
+ stage_times.append((stage.name, elapsed, "✅"))
438
+ _tasks_snapshot = tasks_file.read_text(encoding="utf-8") if tasks_file.exists() else ""
439
+ continue
440
+ else:
441
+ action = handle_error(AD)
442
+ if action == Action.RETRY:
443
+ continue
444
+ elif action == Action.NOTE_EXIT:
445
+ return
446
+ elif action == Action.SKIP:
447
+ write_stage(AD, stage.state_value)
448
+ elapsed = time.time() - task_start
449
+ stage_times.append((stage.name, elapsed, "⚠️ 跳过"))
450
+ _tasks_snapshot = tasks_file.read_text(encoding="utf-8") if tasks_file.exists() else ""
451
+ continue
452
+ else:
453
+ return
454
+
455
+ # AI 阶段 — 执行前恢复被覆盖的 tasks.yaml
456
+ if _tasks_snapshot and tasks_file.exists():
457
+ current = tasks_file.read_text(encoding="utf-8")
458
+ if current != _tasks_snapshot:
459
+ logger.warning("tasks.yaml was modified by a later phase, restoring original")
460
+ tasks_file.write_text(_tasks_snapshot, encoding="utf-8")
461
+
462
+ expanded_params = expand_params(stage.params, P, AD, OUT, req_file or "")
463
+ recipe_path = str(DATA_DIR / stage.recipe)
464
+ before_files = _snapshot_outputs(OUT)
465
+
466
+ # 预期文件集合
467
+ expected_names = set()
468
+ if stage.output_file:
469
+ expected_path = _resolve_output_path(stage.output_file, P, AD, OUT)
470
+ if expected_path:
471
+ expected_names.add(expected_path.name)
472
+
473
+ while True:
474
+ print()
475
+ print(SEPARATOR)
476
+ print(f" {progress} {stage.name}")
477
+ print(SEPARATOR)
478
+ print(f" 启动 goose (max {stage.max_turns} turns)...")
479
+ print(SEPARATOR)
480
+
481
+ try:
482
+ result = run_stage(
483
+ recipe=recipe_path,
484
+ max_turns=stage.max_turns,
485
+ params=expanded_params,
486
+ cwd=P,
487
+ quiet=not args.verbose,
488
+ env=stage_env or None,
489
+ )
490
+
491
+ print()
492
+
493
+ if result.returncode == 0:
494
+ # 验证预期输出文件
495
+ missing = []
496
+ if stage.output_file:
497
+ output_path = _resolve_output_path(stage.output_file, P, AD, OUT)
498
+ if output_path and not output_path.exists():
499
+ logger.warning(f"未生成预期文件: {output_path}")
500
+ missing.append(output_path.name)
501
+
502
+ # 检测额外产出文件
503
+ extra = _detect_extra_files(OUT, before_files, expected_names)
504
+ if extra:
505
+ logger.warning(f"检测到非预期产出文件: {extra}")
506
+ print(f" [注意] AI 额外生成了非预期文件: {', '.join(extra)}")
507
+
508
+ if missing:
509
+ logger.error(f"缺少预期文件: {missing}")
510
+ action = handle_error(AD)
511
+ if action == Action.RETRY:
512
+ continue
513
+ elif action == Action.NOTE_EXIT:
514
+ return
515
+ elif action == Action.SKIP:
516
+ write_stage(AD, stage.state_value)
517
+ elapsed = time.time() - stage_start
518
+ stage_times.append((stage.name, elapsed, "⚠️ 跳过"))
519
+ break
520
+ else:
521
+ write_stage(AD, stage.state_value)
522
+ elapsed = time.time() - stage_start
523
+ mins = int(elapsed // 60)
524
+ secs = int(elapsed % 60)
525
+ logger.info(f"{stage.name} — 完成 ({mins}m{secs}s)")
526
+ stage_times.append((stage.name, elapsed, "✅"))
527
+ break
528
+ else:
529
+ action = handle_error(AD)
530
+ if action == Action.RETRY:
531
+ continue
532
+ elif action == Action.NOTE_EXIT:
533
+ return
534
+ elif action == Action.SKIP:
535
+ write_stage(AD, stage.state_value)
536
+ elapsed = time.time() - stage_start
537
+ stage_times.append((stage.name, elapsed, "⚠️ 跳过"))
538
+ break
539
+
540
+ except Exception as e:
541
+ logger.error(f"执行异常: {e}")
542
+ action = handle_error(AD)
543
+ if action == Action.RETRY:
544
+ continue
545
+ elif action == Action.NOTE_EXIT:
546
+ return
547
+ elif action == Action.SKIP:
548
+ write_stage(AD, stage.state_value)
549
+ elapsed = time.time() - stage_start
550
+ stage_times.append((stage.name, elapsed, "⚠️ 异常"))
551
+ break
552
+
553
+ # 全部完成 — 推送所有提交
554
+ try:
555
+ git = GitOps(P)
556
+ git.push()
557
+ except RuntimeError:
558
+ pass # 非 git 仓库,跳过
559
+
560
+ total_elapsed = time.time() - pipeline_start
561
+ clear_stage(AD)
562
+
563
+ print()
564
+ print(SEPARATOR)
565
+ print(" 管线全部完成!")
566
+ print(SEPARATOR)
567
+ _print_summary(stage_times, total_elapsed, OUT)
568
+
569
+
570
+ def _print_summary(stage_times: list[tuple[str, float, str]], total_elapsed: float, OUT: Path) -> None:
571
+ """打印运行摘要。"""
572
+ total_m = int(total_elapsed // 60)
573
+ total_s = int(total_elapsed % 60)
574
+ print()
575
+ print(SEPARATOR)
576
+ print(f" 运行摘要 (总耗时 {total_m}m{total_s}s)")
577
+ print(SEPARATOR)
578
+ for name, elapsed, status in stage_times:
579
+ mins = int(elapsed // 60)
580
+ secs = int(elapsed % 60)
581
+ print(f" {status} {name:<12s} {mins}m{secs}s")
582
+ print(SEPARATOR)
583
+
584
+ # 列出最终产出物
585
+ if OUT.exists():
586
+ outputs = sorted(OUT.iterdir())
587
+ if outputs:
588
+ print()
589
+ print(" 最终产出物:")
590
+ for f in outputs:
591
+ if f.is_file():
592
+ size_kb = f.stat().st_size // 1024
593
+ print(f" - {f.name} ({size_kb}KB)")
594
+ print()
595
+
596
+
597
+ if __name__ == "__main__":
598
+ main()