workflow_manager.py 26 KB

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