workflow_manager.py 24 KB

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