run_pipeline.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. """顺序运行或恢复 Global Data 与 Production 两个正式 Run。"""
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. import re
  6. from pathlib import Path
  7. from typing import Any, Mapping
  8. from dotenv import load_dotenv
  9. from production_build_agents.run.layout import (
  10. global_data_delivery_path,
  11. run_directory,
  12. )
  13. from production_build_agents.observability.pipeline import PipelineObservation
  14. from run_global_data import run_global_data
  15. from run_production import run_full_production
  16. PROJECT_ROOT = Path(__file__).resolve().parent
  17. _ROUND_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
  18. def _validate_round_id(round_id: str) -> str:
  19. value = round_id.strip()
  20. if not _ROUND_ID.fullmatch(value):
  21. raise ValueError(
  22. "round_id 只能包含字母、数字、点、下划线和连字符,"
  23. "且必须以字母或数字开头"
  24. )
  25. return value
  26. def _pipeline_result(
  27. *,
  28. round_id: str,
  29. global_data_thread_id: str,
  30. production_thread_id: str,
  31. global_data_result: Mapping[str, Any],
  32. production_result: Mapping[str, Any] | None,
  33. ) -> dict[str, Any]:
  34. active = (
  35. production_result
  36. if production_result is not None
  37. else global_data_result
  38. )
  39. return {
  40. "round_id": round_id,
  41. "status": active.get("status"),
  42. "phase": (
  43. "PRODUCTION"
  44. if production_result is not None
  45. else "GLOBAL_DATA"
  46. ),
  47. "global_data_thread_id": global_data_thread_id,
  48. "production_thread_id": production_thread_id,
  49. "global_data_result": dict(global_data_result),
  50. "production_result": (
  51. dict(production_result)
  52. if production_result is not None
  53. else None
  54. ),
  55. }
  56. def run_pipeline(
  57. input_path: Path,
  58. *,
  59. output_root: Path,
  60. round_id: str,
  61. shared_visual_anchor_path: Path,
  62. max_plan_revisions: int = 5,
  63. pause_after_progress_review: bool = False,
  64. ) -> dict[str, Any]:
  65. """顺序运行或恢复两个阶段,并只在 Global Data 完成后进入 Production。
  66. 编排层没有第三套 checkpoint。相同 ``round_id`` 会稳定映射到两个独立
  67. thread_id;重复调用时,两个正式 runtime 分别执行自己的恢复与终态重验。
  68. """
  69. load_dotenv(PROJECT_ROOT / ".env", override=False)
  70. active_round_id = _validate_round_id(round_id)
  71. output_root = output_root.resolve()
  72. global_data_thread_id = f"{active_round_id}-global-data"
  73. production_thread_id = f"{active_round_id}-production"
  74. with PipelineObservation(
  75. project_root=PROJECT_ROOT,
  76. round_id=active_round_id,
  77. input_path=input_path,
  78. output_root=output_root,
  79. shared_visual_anchor_path=shared_visual_anchor_path,
  80. ) as pipeline_observation:
  81. with pipeline_observation.stage(
  82. "global_data",
  83. input_value={
  84. "thread_id": global_data_thread_id,
  85. "input_path": str(input_path.resolve()),
  86. },
  87. ) as global_data_stage:
  88. global_result = run_global_data(
  89. input_path,
  90. output_root=output_root,
  91. thread_id=global_data_thread_id,
  92. observation_parent_run_id=(
  93. global_data_stage.parent_run_id
  94. ),
  95. observation_parent_inst_id=(
  96. global_data_stage.parent_inst_id
  97. ),
  98. pipeline_round_id=active_round_id,
  99. )
  100. global_data_stage.finish(global_result)
  101. if global_result.get("status") != "COMPLETED":
  102. pipeline_result = _pipeline_result(
  103. round_id=active_round_id,
  104. global_data_thread_id=global_data_thread_id,
  105. production_thread_id=production_thread_id,
  106. global_data_result=global_result,
  107. production_result=None,
  108. )
  109. pipeline_observation.finish(pipeline_result)
  110. return pipeline_result
  111. if global_result.get("run_id") != global_data_thread_id:
  112. raise ValueError(
  113. "Global Data 完成结果 run_id 与 Pipeline 身份不一致"
  114. )
  115. expected_delivery = global_data_delivery_path(
  116. run_directory(output_root, global_data_thread_id)
  117. ).resolve()
  118. raw_delivery_path = global_result.get("global_data_delivery_path")
  119. if not isinstance(raw_delivery_path, str) or not raw_delivery_path:
  120. raise ValueError("Global Data 完成结果缺少正式 Delivery 路径")
  121. actual_delivery = Path(raw_delivery_path).resolve()
  122. if actual_delivery != expected_delivery:
  123. raise ValueError(
  124. "Global Data 完成结果指向非当前 Run 的 Delivery:"
  125. f"{actual_delivery}"
  126. )
  127. if not actual_delivery.is_file():
  128. raise FileNotFoundError(
  129. f"Global Data 正式 Delivery 不存在:{actual_delivery}"
  130. )
  131. with pipeline_observation.stage(
  132. "production",
  133. input_value={
  134. "thread_id": production_thread_id,
  135. "global_data_thread_id": global_data_thread_id,
  136. "global_data_delivery_path": str(actual_delivery),
  137. },
  138. ) as production_stage:
  139. production_result = run_full_production(
  140. actual_delivery,
  141. output_root=output_root,
  142. thread_id=production_thread_id,
  143. shared_visual_anchor_path=shared_visual_anchor_path,
  144. max_plan_revisions=max_plan_revisions,
  145. pause_after_progress_review=pause_after_progress_review,
  146. observation_parent_run_id=(
  147. production_stage.parent_run_id
  148. ),
  149. observation_parent_inst_id=(
  150. production_stage.parent_inst_id
  151. ),
  152. pipeline_round_id=active_round_id,
  153. upstream_global_data_thread_id=global_data_thread_id,
  154. )
  155. production_stage.finish(production_result)
  156. pipeline_result = _pipeline_result(
  157. round_id=active_round_id,
  158. global_data_thread_id=global_data_thread_id,
  159. production_thread_id=production_thread_id,
  160. global_data_result=global_result,
  161. production_result=production_result,
  162. )
  163. pipeline_observation.finish(pipeline_result)
  164. return pipeline_result
  165. def _build_parser() -> argparse.ArgumentParser:
  166. parser = argparse.ArgumentParser(
  167. description=(
  168. "顺序运行或恢复 Global Data → Production;两个阶段保留独立 Run"
  169. )
  170. )
  171. parser.add_argument("input", type=Path, help="原始 production_final.json")
  172. parser.add_argument(
  173. "--output-dir",
  174. type=Path,
  175. default=Path("demo_output"),
  176. )
  177. parser.add_argument(
  178. "--round-id",
  179. required=True,
  180. help="本轮稳定身份;派生出 <round>-global-data 和 <round>-production",
  181. )
  182. parser.add_argument(
  183. "--shared-visual-anchor",
  184. type=Path,
  185. required=True,
  186. help="Production 外部已接纳的共享视觉锚点图片",
  187. )
  188. parser.add_argument(
  189. "--max-plan-revisions",
  190. type=int,
  191. default=5,
  192. choices=range(0, 6),
  193. metavar="{0..5}",
  194. help="Production 允许的 ADAPT/REPLAN 总次数",
  195. )
  196. parser.add_argument(
  197. "--pause-after-progress-review",
  198. action="store_true",
  199. help="Production 在下一次 Progress Review 后以 RUNNING 状态暂停",
  200. )
  201. return parser
  202. def main() -> None:
  203. args = _build_parser().parse_args()
  204. result = run_pipeline(
  205. args.input,
  206. output_root=args.output_dir,
  207. round_id=args.round_id,
  208. shared_visual_anchor_path=args.shared_visual_anchor,
  209. max_plan_revisions=args.max_plan_revisions,
  210. pause_after_progress_review=args.pause_after_progress_review,
  211. )
  212. print(json.dumps(result, ensure_ascii=False, indent=2))
  213. if result.get("status") == "COMPLETED":
  214. return
  215. if (
  216. result.get("phase") == "PRODUCTION"
  217. and args.pause_after_progress_review
  218. and result.get("status") == "RUNNING"
  219. ):
  220. return
  221. raise SystemExit(1)
  222. if __name__ == "__main__":
  223. main()