workflow_manager.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. #!/usr/bin/env python3
  2. """
  3. Workflow state manager
  4. - Track write/review task execution status
  5. - Detect interruption points
  6. - Provide recovery options
  7. - Emit call traces for observability
  8. """
  9. from __future__ import annotations
  10. import json
  11. import logging
  12. import os
  13. import subprocess
  14. import sys
  15. from datetime import datetime
  16. from pathlib import Path
  17. from typing import Any, Dict, Optional
  18. from chapter_paths import default_chapter_draft_path, find_chapter_file
  19. from project_locator import resolve_project_root
  20. from runtime_compat import enable_windows_utf8_stdio
  21. from security_utils import atomic_write_json, create_secure_directory
  22. logger = logging.getLogger(__name__)
  23. # UTF-8 output for Windows console (CLI run only, avoid pytest capture issues)
  24. if sys.platform == "win32" and __name__ == "__main__" and not os.environ.get("PYTEST_CURRENT_TEST"):
  25. enable_windows_utf8_stdio(skip_in_pytest=True)
  26. TASK_STATUS_RUNNING = "running"
  27. TASK_STATUS_COMPLETED = "completed"
  28. TASK_STATUS_FAILED = "failed"
  29. STEP_STATUS_STARTED = "started"
  30. STEP_STATUS_RUNNING = "running"
  31. STEP_STATUS_COMPLETED = "completed"
  32. STEP_STATUS_FAILED = "failed"
  33. def now_iso() -> str:
  34. return datetime.now().isoformat()
  35. def find_project_root() -> Path:
  36. """Resolve project root (containing .webnovel/state.json)."""
  37. return resolve_project_root()
  38. def get_workflow_state_path() -> Path:
  39. """Absolute path to workflow_state.json."""
  40. project_root = find_project_root()
  41. return project_root / ".webnovel" / "workflow_state.json"
  42. def get_call_trace_path() -> Path:
  43. project_root = find_project_root()
  44. return project_root / ".webnovel" / "observability" / "call_trace.jsonl"
  45. def append_call_trace(event: str, payload: Optional[Dict[str, Any]] = None):
  46. """Append workflow call trace event (best effort)."""
  47. payload = payload or {}
  48. trace_path = get_call_trace_path()
  49. create_secure_directory(str(trace_path.parent))
  50. row = {
  51. "timestamp": now_iso(),
  52. "event": event,
  53. "payload": payload,
  54. }
  55. with open(trace_path, "a", encoding="utf-8") as f:
  56. f.write(json.dumps(row, ensure_ascii=False) + "\n")
  57. def safe_append_call_trace(event: str, payload: Optional[Dict[str, Any]] = None):
  58. try:
  59. append_call_trace(event, payload)
  60. except Exception as exc:
  61. logger.warning("failed to append call trace for event '%s': %s", event, exc)
  62. def expected_step_owner(command: str, step_id: str) -> str:
  63. """Resolve expected caller owner by command + step id.
  64. Returns concise owner tags to align with
  65. `.claude/references/claude-code-call-matrix.md`.
  66. """
  67. if command == "webnovel-write":
  68. mapping = {
  69. "Step 1": "context-agent",
  70. "Step 1.5": "webnovel-write-skill",
  71. "Step 2A": "writer-draft",
  72. "Step 2B": "style-adapter",
  73. "Step 3": "review-agents",
  74. "Step 4": "polish-agent",
  75. "Step 5": "data-agent",
  76. "Step 6": "backup-agent",
  77. }
  78. return mapping.get(step_id, "webnovel-write-skill")
  79. if command == "webnovel-review":
  80. return "webnovel-review-skill"
  81. return "unknown"
  82. def step_allowed_before(command: str, step_id: str, completed_steps: list[Dict[str, Any]]) -> bool:
  83. """Check simple ordering constraints by pending sequence."""
  84. sequence = get_pending_steps(command)
  85. if step_id not in sequence:
  86. return True
  87. expected_index = sequence.index(step_id)
  88. completed_ids = [str(item.get("id")) for item in completed_steps]
  89. required_before = sequence[:expected_index]
  90. return all(prev in completed_ids for prev in required_before)
  91. def _new_task(command: str, args: Dict[str, Any]) -> Dict[str, Any]:
  92. started_at = now_iso()
  93. return {
  94. "command": command,
  95. "args": args,
  96. "started_at": started_at,
  97. "last_heartbeat": started_at,
  98. "status": TASK_STATUS_RUNNING,
  99. "current_step": None,
  100. "completed_steps": [],
  101. "failed_steps": [],
  102. "pending_steps": get_pending_steps(command),
  103. "retry_count": 0,
  104. "artifacts": {
  105. "chapter_file": {},
  106. "git_status": {},
  107. "state_json_modified": False,
  108. "entities_appeared": False,
  109. "review_completed": False,
  110. },
  111. }
  112. def _finalize_current_step_as_failed(task: Dict[str, Any], reason: str):
  113. current_step = task.get("current_step")
  114. if not current_step:
  115. return
  116. if current_step.get("status") in {STEP_STATUS_COMPLETED, STEP_STATUS_FAILED}:
  117. return
  118. current_step = dict(current_step)
  119. current_step["status"] = STEP_STATUS_FAILED
  120. current_step["failed_at"] = now_iso()
  121. current_step["failure_reason"] = reason
  122. task.setdefault("failed_steps", []).append(current_step)
  123. task["current_step"] = None
  124. def _mark_task_failed(state: Dict[str, Any], reason: str):
  125. task = state.get("current_task")
  126. if not task:
  127. return
  128. _finalize_current_step_as_failed(task, reason=reason)
  129. task["status"] = TASK_STATUS_FAILED
  130. task["failed_at"] = now_iso()
  131. task["failure_reason"] = reason
  132. def start_task(command, args):
  133. """Start a new task."""
  134. state = load_state()
  135. current = state.get("current_task")
  136. if current and current.get("status") == TASK_STATUS_RUNNING:
  137. current["retry_count"] = int(current.get("retry_count", 0)) + 1
  138. current["last_heartbeat"] = now_iso()
  139. state["current_task"] = current
  140. save_state(state)
  141. safe_append_call_trace(
  142. "task_reentered",
  143. {
  144. "command": current.get("command"),
  145. "chapter": current.get("args", {}).get("chapter_num"),
  146. "retry_count": current["retry_count"],
  147. },
  148. )
  149. print(f"ℹ️ 任务已在运行,执行重入标记: {current.get('command')}")
  150. return
  151. state["current_task"] = _new_task(command, args)
  152. save_state(state)
  153. safe_append_call_trace("task_started", {"command": command, "args": args})
  154. print(f"✅ 任务已启动: {command} {json.dumps(args, ensure_ascii=False)}")
  155. def start_step(step_id, step_name, progress_note=None):
  156. """Mark step started."""
  157. state = load_state()
  158. task = state.get("current_task")
  159. if not task:
  160. print("⚠️ 无活动任务,请先使用 start-task")
  161. return
  162. command = str(task.get("command") or "")
  163. if not step_allowed_before(command, step_id, task.get("completed_steps", [])):
  164. safe_append_call_trace(
  165. "step_order_violation",
  166. {
  167. "step_id": step_id,
  168. "command": command,
  169. "completed_steps": [row.get("id") for row in task.get("completed_steps", [])],
  170. },
  171. )
  172. owner = expected_step_owner(command, step_id)
  173. _finalize_current_step_as_failed(task, reason="step_replaced_before_completion")
  174. started_at = now_iso()
  175. task["current_step"] = {
  176. "id": step_id,
  177. "name": step_name,
  178. "status": STEP_STATUS_STARTED,
  179. "started_at": started_at,
  180. "running_at": started_at,
  181. "attempt": int(task.get("retry_count", 0)) + 1,
  182. "progress_note": progress_note,
  183. }
  184. task["current_step"]["status"] = STEP_STATUS_RUNNING
  185. task["status"] = TASK_STATUS_RUNNING
  186. task["last_heartbeat"] = now_iso()
  187. save_state(state)
  188. safe_append_call_trace(
  189. "step_started",
  190. {
  191. "step_id": step_id,
  192. "step_name": step_name,
  193. "command": task.get("command"),
  194. "chapter": task.get("args", {}).get("chapter_num"),
  195. "progress_note": progress_note,
  196. "expected_owner": owner,
  197. },
  198. )
  199. print(f"▶️ {step_id} 开始: {step_name}")
  200. def complete_step(step_id, artifacts_json=None):
  201. """Mark step completed."""
  202. state = load_state()
  203. task = state.get("current_task")
  204. if not task or not task.get("current_step"):
  205. print("⚠️ 无活动 Step")
  206. return
  207. current_step = task["current_step"]
  208. if current_step.get("id") != step_id:
  209. print(f"⚠️ 当前 Step 为 {current_step.get('id')},与 {step_id} 不一致,拒绝完成")
  210. safe_append_call_trace(
  211. "step_complete_rejected",
  212. {
  213. "requested_step_id": step_id,
  214. "active_step_id": current_step.get("id"),
  215. "command": task.get("command"),
  216. },
  217. )
  218. return
  219. current_step["status"] = STEP_STATUS_COMPLETED
  220. current_step["completed_at"] = now_iso()
  221. if artifacts_json:
  222. try:
  223. artifacts = json.loads(artifacts_json)
  224. current_step["artifacts"] = artifacts
  225. task["artifacts"].update(artifacts)
  226. except json.JSONDecodeError as exc:
  227. print(f"⚠️ Artifacts JSON 解析失败: {exc}")
  228. task["completed_steps"].append(current_step)
  229. task["current_step"] = None
  230. task["last_heartbeat"] = now_iso()
  231. save_state(state)
  232. safe_append_call_trace(
  233. "step_completed",
  234. {
  235. "step_id": step_id,
  236. "command": task.get("command"),
  237. "chapter": task.get("args", {}).get("chapter_num"),
  238. },
  239. )
  240. print(f"✅ {step_id} 完成")
  241. def complete_task(final_artifacts_json=None):
  242. """Mark task completed."""
  243. state = load_state()
  244. task = state.get("current_task")
  245. if not task:
  246. print("⚠️ 无活动任务")
  247. return
  248. _finalize_current_step_as_failed(task, reason="task_completed_with_active_step")
  249. task["status"] = TASK_STATUS_COMPLETED
  250. task["completed_at"] = now_iso()
  251. if final_artifacts_json:
  252. try:
  253. final_artifacts = json.loads(final_artifacts_json)
  254. task["artifacts"].update(final_artifacts)
  255. except json.JSONDecodeError as exc:
  256. print(f"⚠️ Final artifacts JSON 解析失败: {exc}")
  257. state["last_stable_state"] = extract_stable_state(task)
  258. if "history" not in state:
  259. state["history"] = []
  260. state["history"].append(
  261. {
  262. "task_id": f"task_{len(state['history']) + 1:03d}",
  263. "command": task["command"],
  264. "chapter": task["args"].get("chapter_num"),
  265. "status": TASK_STATUS_COMPLETED,
  266. "completed_at": task["completed_at"],
  267. }
  268. )
  269. state["current_task"] = None
  270. save_state(state)
  271. safe_append_call_trace(
  272. "task_completed",
  273. {
  274. "command": task.get("command"),
  275. "chapter": task.get("args", {}).get("chapter_num"),
  276. "completed_steps": len(task.get("completed_steps", [])),
  277. "failed_steps": len(task.get("failed_steps", [])),
  278. },
  279. )
  280. print("🎀 任务完成")
  281. def detect_interruption():
  282. """Detect interruption state."""
  283. state = load_state()
  284. if not state or "current_task" not in state or state["current_task"] is None:
  285. return None
  286. task = state["current_task"]
  287. if task.get("status") == TASK_STATUS_COMPLETED:
  288. return None
  289. last_heartbeat = datetime.fromisoformat(task["last_heartbeat"])
  290. elapsed = (datetime.now() - last_heartbeat).total_seconds()
  291. interrupt_info = {
  292. "command": task["command"],
  293. "args": task["args"],
  294. "task_status": task.get("status"),
  295. "current_step": task.get("current_step"),
  296. "completed_steps": task.get("completed_steps", []),
  297. "failed_steps": task.get("failed_steps", []),
  298. "elapsed_seconds": elapsed,
  299. "artifacts": task.get("artifacts", {}),
  300. "started_at": task.get("started_at"),
  301. "retry_count": int(task.get("retry_count", 0)),
  302. }
  303. safe_append_call_trace(
  304. "interruption_detected",
  305. {
  306. "command": task.get("command"),
  307. "chapter": task.get("args", {}).get("chapter_num"),
  308. "task_status": task.get("status"),
  309. "current_step": (task.get("current_step") or {}).get("id"),
  310. "elapsed_seconds": elapsed,
  311. },
  312. )
  313. return interrupt_info
  314. def analyze_recovery_options(interrupt_info):
  315. """Analyze recovery options based on interruption point."""
  316. current_step = interrupt_info["current_step"]
  317. command = interrupt_info["command"]
  318. chapter_num = interrupt_info["args"].get("chapter_num", "?")
  319. if not current_step:
  320. return [
  321. {
  322. "option": "A",
  323. "label": "从头开始",
  324. "risk": "low",
  325. "description": "重新执行完整流程",
  326. "actions": [
  327. "删除 workflow_state.json 当前任务",
  328. f"执行 /{command} {chapter_num}",
  329. ],
  330. }
  331. ]
  332. step_id = current_step["id"]
  333. if step_id in {"Step 1", "Step 1.5"}:
  334. return [
  335. {
  336. "option": "A",
  337. "label": "从 Step 1 重新开始",
  338. "risk": "low",
  339. "description": "重新加载上下文",
  340. "actions": [
  341. "清理中断状态",
  342. f"执行 /{command} {chapter_num}",
  343. ],
  344. }
  345. ]
  346. if step_id in {"Step 2", "Step 2A", "Step 2B"}:
  347. project_root = find_project_root()
  348. existing_chapter = find_chapter_file(project_root, chapter_num)
  349. draft_path = None
  350. if existing_chapter:
  351. chapter_path = str(existing_chapter.relative_to(project_root))
  352. else:
  353. draft_path = default_chapter_draft_path(project_root, chapter_num)
  354. chapter_path = str(draft_path.relative_to(project_root))
  355. options = [
  356. {
  357. "option": "A",
  358. "label": "删除半成品,从 Step 1 重启",
  359. "risk": "low",
  360. "description": f"清理 {chapter_path},重新生成章节",
  361. "actions": [
  362. f"删除 {chapter_path}(如存在)",
  363. "清理 Git 暂存区",
  364. "清理中断状态",
  365. f"执行 /{command} {chapter_num}",
  366. ],
  367. }
  368. ]
  369. candidate = existing_chapter or draft_path
  370. if candidate and candidate.exists():
  371. options.append(
  372. {
  373. "option": "B",
  374. "label": "回滚到上一章",
  375. "risk": "medium",
  376. "description": "丢弃当前章节进度",
  377. "actions": [
  378. f"git reset --hard ch{(chapter_num - 1):04d}",
  379. "清理中断状态",
  380. f"重新决定是否继续 Ch{chapter_num}",
  381. ],
  382. }
  383. )
  384. return options
  385. if step_id == "Step 3":
  386. return [
  387. {
  388. "option": "A",
  389. "label": "重新执行审查",
  390. "risk": "medium",
  391. "description": "重新调用审查员并生成报告",
  392. "actions": ["重新执行审查", "生成审查报告", "继续 Step 4 润色"],
  393. },
  394. {
  395. "option": "B",
  396. "label": "跳过审查直接润色",
  397. "risk": "low",
  398. "description": "后续可用 /webnovel-review 补审",
  399. "actions": ["标记审查已跳过", "继续 Step 4 润色"],
  400. },
  401. ]
  402. if step_id == "Step 4":
  403. project_root = find_project_root()
  404. existing_chapter = find_chapter_file(project_root, chapter_num)
  405. draft_path = None
  406. if existing_chapter:
  407. chapter_path = str(existing_chapter.relative_to(project_root))
  408. else:
  409. draft_path = default_chapter_draft_path(project_root, chapter_num)
  410. chapter_path = str(draft_path.relative_to(project_root))
  411. return [
  412. {
  413. "option": "A",
  414. "label": "继续润色",
  415. "risk": "low",
  416. "description": f"继续润色 {chapter_path},完成后进入 Step 5",
  417. "actions": [f"打开并继续润色 {chapter_path}", "保存文件", "继续 Step 5(Data Agent)"],
  418. },
  419. {
  420. "option": "B",
  421. "label": "删除润色稿,从 Step 2A 重写",
  422. "risk": "medium",
  423. "description": f"删除 {chapter_path} 并重新生成章节内容",
  424. "actions": [f"删除 {chapter_path}", "清理 Git 暂存区", "清理中断状态", f"执行 /{command} {chapter_num}"],
  425. },
  426. ]
  427. if step_id == "Step 5":
  428. return [
  429. {
  430. "option": "A",
  431. "label": "从 Step 5 重新开始",
  432. "risk": "low",
  433. "description": "重新运行 Data Agent(幂等)",
  434. "actions": ["重新调用 Data Agent", "继续 Step 6(Git 备份)"],
  435. }
  436. ]
  437. if step_id == "Step 6":
  438. return [
  439. {
  440. "option": "A",
  441. "label": "继续 Git 提交",
  442. "risk": "low",
  443. "description": "完成未完成的 Git commit + tag",
  444. "actions": ["检查 Git 暂存区", "重新执行 backup_manager.py", "继续 complete-task"],
  445. },
  446. {
  447. "option": "B",
  448. "label": "回滚 Git 改动",
  449. "risk": "medium",
  450. "description": "丢弃暂存区所有改动",
  451. "actions": ["git reset HEAD .", f"删除第{chapter_num}章文件", "清理中断状态"],
  452. },
  453. ]
  454. return [
  455. {
  456. "option": "A",
  457. "label": "从头开始",
  458. "risk": "low",
  459. "description": "重新执行完整流程",
  460. "actions": ["清理所有中断 artifacts", f"执行 /{command} {chapter_num}"],
  461. }
  462. ]
  463. def cleanup_artifacts(chapter_num):
  464. """Cleanup partial artifacts."""
  465. artifacts_cleaned = []
  466. project_root = find_project_root()
  467. chapter_path = find_chapter_file(project_root, chapter_num)
  468. if chapter_path is None:
  469. draft_path = default_chapter_draft_path(project_root, chapter_num)
  470. if draft_path.exists():
  471. chapter_path = draft_path
  472. if chapter_path and chapter_path.exists():
  473. chapter_path.unlink()
  474. artifacts_cleaned.append(str(chapter_path.relative_to(project_root)))
  475. result = subprocess.run(["git", "reset", "HEAD", "."], cwd=project_root, capture_output=True, text=True)
  476. if result.returncode == 0:
  477. artifacts_cleaned.append("Git 暂存区已清理(project)")
  478. safe_append_call_trace(
  479. "artifacts_cleaned",
  480. {
  481. "chapter": chapter_num,
  482. "items": artifacts_cleaned,
  483. "git_reset_ok": result.returncode == 0,
  484. },
  485. )
  486. return artifacts_cleaned
  487. def clear_current_task():
  488. """Clear interrupted current task."""
  489. state = load_state()
  490. task = state.get("current_task")
  491. if task:
  492. safe_append_call_trace(
  493. "task_cleared",
  494. {
  495. "command": task.get("command"),
  496. "chapter": task.get("args", {}).get("chapter_num"),
  497. "status": task.get("status"),
  498. },
  499. )
  500. state["current_task"] = None
  501. save_state(state)
  502. print("✅ 中断任务已清除")
  503. else:
  504. print("⚠️ 无中断任务")
  505. def fail_current_task(reason: str = "manual_fail"):
  506. """Mark current task as failed and keep state for diagnostics."""
  507. state = load_state()
  508. task = state.get("current_task")
  509. if not task:
  510. print("⚠️ 无活动任务")
  511. return
  512. _mark_task_failed(state, reason=reason)
  513. save_state(state)
  514. safe_append_call_trace(
  515. "task_failed",
  516. {
  517. "command": task.get("command"),
  518. "chapter": task.get("args", {}).get("chapter_num"),
  519. "reason": reason,
  520. },
  521. )
  522. print(f"⚠️ 任务已标记失败: {reason}")
  523. def load_state():
  524. """Load workflow state."""
  525. state_file = get_workflow_state_path()
  526. if not state_file.exists():
  527. return {"current_task": None, "last_stable_state": None, "history": []}
  528. with open(state_file, "r", encoding="utf-8") as f:
  529. state = json.load(f)
  530. state.setdefault("current_task", None)
  531. state.setdefault("last_stable_state", None)
  532. state.setdefault("history", [])
  533. if state.get("current_task"):
  534. state["current_task"].setdefault("failed_steps", [])
  535. state["current_task"].setdefault("retry_count", 0)
  536. return state
  537. def save_state(state):
  538. """Save workflow state atomically."""
  539. state_file = get_workflow_state_path()
  540. create_secure_directory(str(state_file.parent))
  541. atomic_write_json(state_file, state, use_lock=True, backup=False)
  542. def get_pending_steps(command):
  543. """Get command pending step list."""
  544. if command == "webnovel-write":
  545. return ["Step 1", "Step 1.5", "Step 2A", "Step 2B", "Step 3", "Step 4", "Step 5", "Step 6"]
  546. if command == "webnovel-review":
  547. return ["Step 1", "Step 2", "Step 3", "Step 4", "Step 5", "Step 6", "Step 7", "Step 8"]
  548. return []
  549. def extract_stable_state(task):
  550. """Extract stable state snapshot."""
  551. return {
  552. "command": task["command"],
  553. "chapter_num": task["args"].get("chapter_num"),
  554. "completed_at": task.get("completed_at"),
  555. "artifacts": task.get("artifacts", {}),
  556. }
  557. if __name__ == "__main__":
  558. import argparse
  559. parser = argparse.ArgumentParser(description="工作流状态管理")
  560. subparsers = parser.add_subparsers(dest="action", help="操作类型")
  561. p_start_task = subparsers.add_parser("start-task", help="开始新任务")
  562. p_start_task.add_argument("--command", required=True, help="命令名称")
  563. p_start_task.add_argument("--chapter", type=int, help="章节号")
  564. p_start_step = subparsers.add_parser("start-step", help="开始 Step")
  565. p_start_step.add_argument("--step-id", required=True, help="Step ID")
  566. p_start_step.add_argument("--step-name", required=True, help="Step 名称")
  567. p_start_step.add_argument("--note", help="进度备注")
  568. p_complete_step = subparsers.add_parser("complete-step", help="完成 Step")
  569. p_complete_step.add_argument("--step-id", required=True, help="Step ID")
  570. p_complete_step.add_argument("--artifacts", help="Artifacts JSON")
  571. p_complete_task = subparsers.add_parser("complete-task", help="完成任务")
  572. p_complete_task.add_argument("--artifacts", help="Final artifacts JSON")
  573. p_fail_task = subparsers.add_parser("fail-task", help="标记任务失败")
  574. p_fail_task.add_argument("--reason", default="manual_fail", help="失败原因")
  575. subparsers.add_parser("detect", help="检测中断")
  576. p_cleanup = subparsers.add_parser("cleanup", help="清理 artifacts")
  577. p_cleanup.add_argument("--chapter", type=int, required=True, help="章节号")
  578. subparsers.add_parser("clear", help="清除中断任务")
  579. args = parser.parse_args()
  580. if args.action == "start-task":
  581. start_task(args.command, {"chapter_num": args.chapter})
  582. elif args.action == "start-step":
  583. start_step(args.step_id, args.step_name, args.note)
  584. elif args.action == "complete-step":
  585. complete_step(args.step_id, args.artifacts)
  586. elif args.action == "complete-task":
  587. complete_task(args.artifacts)
  588. elif args.action == "fail-task":
  589. fail_current_task(args.reason)
  590. elif args.action == "detect":
  591. interrupt = detect_interruption()
  592. if interrupt:
  593. print("\n🔶 检测到中断任务:")
  594. print(json.dumps(interrupt, ensure_ascii=False, indent=2))
  595. print("\n📕 恢复选项:")
  596. options = analyze_recovery_options(interrupt)
  597. print(json.dumps(options, ensure_ascii=False, indent=2))
  598. else:
  599. print("✅ 无中断任务")
  600. elif args.action == "cleanup":
  601. cleaned = cleanup_artifacts(args.chapter)
  602. print(f"✅ 已清理: {', '.join(cleaned)}")
  603. elif args.action == "clear":
  604. clear_current_task()
  605. else:
  606. parser.print_help()