Browse Source

fix: surface sqlite sync failures in process-chapter

save_state returns {saved, sqlite_sync_ok}; process-chapter CLI exits 1 with projections-retry guidance when sqlite sync fails. Test patches sm.StateManager (runtime module identity) to survive test_dashboard_app's sys.modules purge.
lingfengQAQ 1 tuần trước cách đây
mục cha
commit
3af192d041

+ 2 - 2
docs/superpowers/plans/2026-06-10-audit-fix-plan.md

@@ -119,8 +119,8 @@ def rollback(self, chapter: int) -> bool:
 - Modify: `webnovel-writer/scripts/data_modules/state_manager.py:393-416, 450-451, 606-609`
 - Test: `webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py`
 
-- [ ] `_sync_to_sqlite` 失败时:`save_state` 返回值携带 `sqlite_sync_ok=False`;`process-chapter` CLI 据此 `emit_error`(退出码非 0),错误信息提示运行 `webnovel.py projections retry --chapter N` 补偿。测试:monkeypatch `_sync_pending_patches_to_sqlite` 抛异常,断言 CLI 退出非 0 且 stdout JSON 含补偿指引。
-- [ ] 提交 `fix: surface sqlite sync failures in process-chapter`。
+- [x] `_sync_to_sqlite` 失败时:`save_state` 返回值携带 `sqlite_sync_ok=False`;`process-chapter` CLI 据此 `emit_error`(退出码非 0),错误信息提示运行 `webnovel.py projections retry --chapter N` 补偿。测试:monkeypatch `_sync_pending_patches_to_sqlite` 抛异常,断言 CLI 退出非 0 且 stdout JSON 含补偿指引。
+- [x] 提交 `fix: surface sqlite sync failures in process-chapter`。
 
 ### Task 8: get_state_changes / get_relationships 走 SQLite 回退
 

+ 18 - 4
webnovel-writer/scripts/data_modules/state_manager.py

@@ -226,7 +226,7 @@ class StateManager:
         else:
             self._state = self._ensure_state_schema({})
 
-    def save_state(self):
+    def save_state(self) -> Dict[str, Any]:
         """
         保存状态文件(锁内重读 + 合并 + 原子写入)。
 
@@ -252,7 +252,7 @@ class StateManager:
             ]
         )
         if not has_pending:
-            return
+            return {"saved": False, "sqlite_sync_ok": True}
 
         self.config.ensure_dirs()
 
@@ -415,6 +415,8 @@ class StateManager:
                 else:
                     self._restore_sqlite_pending(sqlite_pending_snapshot)
 
+                return {"saved": True, "sqlite_sync_ok": sqlite_sync_ok}
+
         except filelock.Timeout:
             raise RuntimeError("无法获取 state.json 文件锁,请稍后重试")
 
@@ -453,7 +455,11 @@ class StateManager:
 
         # 方式2: 使用 add_entity/update_entity 收集的增量数据。
         # 数据缓存在 _pending_entity_patches 等变量中。
-        return self._sync_pending_patches_to_sqlite(processed_appearances)
+        try:
+            return self._sync_pending_patches_to_sqlite(processed_appearances)
+        except Exception as exc:
+            logger.warning("SQLite sync failed (pending patches): %s", exc)
+            return False
 
     def _sync_pending_patches_to_sqlite(self, processed_appearances: set = None) -> bool:
         """同步 _pending_entity_patches 等到 SQLite(v5.1 引入,v5.4 沿用)
@@ -1471,7 +1477,15 @@ def main():
             return
 
         warnings = manager.process_chapter_result(args.chapter, validated.model_dump(by_alias=True))
-        manager.save_state()
+        save_result = manager.save_state()
+        if not save_result.get("sqlite_sync_ok", True):
+            emit_error(
+                "SQLITE_SYNC_FAILED",
+                "章节状态已写入 state.json,但 SQLite 同步失败",
+                suggestion=f"请运行 webnovel.py projections retry --chapter {args.chapter} 补偿投影",
+                chapter=args.chapter,
+            )
+            raise SystemExit(1)
         emit_success({"chapter": args.chapter, "warnings": warnings}, message="chapter_processed", chapter=args.chapter)
 
     elif args.command == "get-chapter-status":

+ 43 - 0
webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py

@@ -612,6 +612,49 @@ def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
     assert out["status"] == "success"
 
 
+def test_process_chapter_cli_fails_when_sqlite_sync_fails(temp_project, monkeypatch, capsys):
+    if not temp_project.state_file.exists():
+        temp_project.state_file.write_text("{}", encoding="utf-8")
+
+    from data_modules import state_manager as sm
+
+    def fail_sync(self, processed_appearances=None):
+        raise RuntimeError("sqlite sync boom")
+
+    payload = json.dumps(
+        {
+            "entities_appeared": [],
+            "entities_new": [],
+            "state_changes": [],
+            "relationships_new": [],
+        }
+    )
+    monkeypatch.setattr(sm.StateManager, "_sync_pending_patches_to_sqlite", fail_sync)
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "state_manager",
+            "--project-root",
+            str(temp_project.project_root),
+            "process-chapter",
+            "--chapter",
+            "7",
+            "--data",
+            payload,
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        sm.main()
+
+    assert int(exc.value.code or 0) == 1
+    out = json.loads(capsys.readouterr().out)
+    assert out["status"] == "error"
+    assert out["error"]["code"] == "SQLITE_SYNC_FAILED"
+    assert "webnovel.py projections retry --chapter 7" in out["error"]["suggestion"]
+
+
 def test_state_manager_cli_rejects_json_file_outside_resolved_book_root(tmp_path, monkeypatch):
     from data_modules.config import DataModulesConfig